Skip to content

Commit 8f1dab5

Browse files
authored
Merge pull request #431 from editor-code-assistant/add-config-directory-loading
Add directory path support for rules, commands, skills
2 parents 3d358e1 + 6cb71c2 commit 8f1dab5

13 files changed

Lines changed: 298 additions & 171 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Add configurable skill paths and recursive directory loading for configured rules, commands, and skills; local skills are also discovered from `.agents/skills`. #423
6+
57
## 0.130.0
68

79
- Improve rules with frontmatter filters, condition variables, path-scoped loading, enforcement support, and clearer documentation. #222

docs/config.json

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@
8585
"properties": {
8686
"path": {
8787
"type": "string",
88-
"description": "Path to a rule file (relative to workspace root or absolute).",
89-
"markdownDescription": "Path to a rule file (relative to workspace root or absolute)."
88+
"description": "Path to a rule file or directory (relative to workspace root or absolute). Directories are loaded recursively.",
89+
"markdownDescription": "Path to a rule file or directory (relative to workspace root or absolute). Directories are loaded recursively."
9090
}
9191
},
9292
"required": [
@@ -112,8 +112,27 @@
112112
"properties": {
113113
"path": {
114114
"type": "string",
115-
"description": "Path to a command prompt markdown file.",
116-
"markdownDescription": "Path to a command prompt markdown file."
115+
"description": "Path to a command prompt markdown file or directory. Directories load markdown files recursively.",
116+
"markdownDescription": "Path to a command prompt markdown file or directory. Directories load markdown files recursively."
117+
}
118+
},
119+
"required": [
120+
"path"
121+
],
122+
"additionalProperties": false
123+
}
124+
},
125+
"skills": {
126+
"type": "array",
127+
"description": "Skill files or directories to load.",
128+
"markdownDescription": "Skill files or directories to load.",
129+
"items": {
130+
"type": "object",
131+
"properties": {
132+
"path": {
133+
"type": "string",
134+
"description": "Path to a skill file or directory. Directories load SKILL.md files recursively.",
135+
"markdownDescription": "Path to a skill file or directory. Directories load SKILL.md files recursively."
117136
}
118137
},
119138
"required": [

docs/config/commands.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,17 @@ You can configure in multiple different ways:
3737

3838
=== "Config"
3939

40-
Just add to your config the `commands` pointing to `.md` files that will be searched from the workspace root if not an absolute path:
40+
Add to your config the `commands` key. `path` can point to a single `.md` file or a directory. Directories load markdown files recursively. Relative paths are searched from each workspace root if not an absolute path:
4141

4242
```javascript title="~/.config/eca/config.json"
4343
{
4444
"commands": [{"path": "my-custom-prompt.md"}]
4545
}
4646
```
4747

48-
ECA will make available a `/my-custom-prompt` command after creating that file.
48+
```javascript title="~/.config/eca/config.json"
49+
// Load all command files from a directory recursively
50+
{
51+
"commands": [{"path": "/home/user/commands"}]
52+
}
53+
```

docs/config/introduction.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ By default ECA consider the following as the base configuration:
9494
"hooks": {},
9595
"rules" : [],
9696
"commands" : [],
97+
"skills": [],
9798
"disabledTools": [],
9899
"toolCall": {
99100
"approval": {

docs/config/rules.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,21 @@ ECA loads rules from 3 sources:
3535

3636
=== "Config"
3737

38-
Paths listed in the `rules` config key. Relative paths are searched from each workspace root. Absolute paths inside a workspace behave as project rules; absolute paths outside workspaces behave as global rules.
38+
Paths listed in the `rules` config key. `path` can point to a single rule file or a directory. Directories are loaded recursively, loading all files within. Relative paths are searched from each workspace root. Absolute paths inside a workspace behave as project rules; absolute paths outside workspaces behave as global rules.
3939

4040
```javascript title="~/.config/eca/config.json"
4141
{
4242
"rules": [{"path": "my-rule.md"}]
4343
}
4444
```
4545

46+
```javascript title="~/.config/eca/config.json"
47+
// Load all rules from a directory recursively
48+
{
49+
"rules": [{"path": "/home/user/rules"}]
50+
}
51+
```
52+
4653
## Static and path-scoped rules
4754

4855
Most rules should be **static rules**: rules without `paths`. Their full content is automatically included in the system prompt. Use them for guidance that should always be available, such as coding style, response tone, or repository-wide conventions.

docs/config/skills.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: "Configure ECA skills: structured knowledge units that teach the LL
77
![](../images/features/skills.png)
88

99
Skills are folders with `SKILL.md` which teachs LLM how to solve a specific task or gain knowledge about it.
10-
Following the [agentskills](https://agentskills.io/) standard, ECA search for skills following `~/.config/eca/skills/some-skill/SKILL.md` and `.eca/skills/some-skill/SKILL.md` which should contain `name` and `description` metadatas.
10+
Following the [agentskills](https://agentskills.io/) standard, ECA searches for skills following `~/.config/eca/skills/some-skill/SKILL.md`, `.eca/skills/some-skill/SKILL.md`, and `.agents/skills/some-skill/SKILL.md` which should contain `name` and `description` metadatas.
1111

1212
When sending a prompt request to LLM, ECA will send only name and description of all available skills, LLM then can choose to load a skill via `eca__skill` tool if that matches user request.
1313

@@ -75,6 +75,16 @@ Check the examples:
7575
}
7676
```
7777

78+
=== "Config"
79+
80+
Add to your config the `skills` key. `path` can point to a single skill directory (containing `SKILL.md`) or a directory containing multiple skill subdirectories. Directories load `SKILL.md` files recursively. Relative paths are searched from each workspace root if not an absolute path:
81+
82+
```javascript title="~/.config/eca/config.json"
83+
{
84+
"skills": [{"path": "/home/user/skills"}]
85+
}
86+
```
87+
7888
## Parameterized skills
7989

8090
Skills can receive arguments when invoked as slash commands, using the same variable substitution as [custom commands](./commands.md): `$ARGS`, `$ARGUMENTS`, and positional `$1`, `$2`, etc.

src/eca/config.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@
166166
:hooks {}
167167
:rules []
168168
:commands []
169+
:skills []
169170
:disabledTools []
170171
:toolCall {:approval {:byDefault "ask"
171172
:allow {"eca__compact_chat" {}

src/eca/features/commands.clj

Lines changed: 99 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -38,65 +38,59 @@
3838

3939
(defn ^:private prefixed-command-name
4040
"Builds the user-invocation name for a plugin-sourced command.
41-
Returns just the plugin name when it equals the command name (dedup),
41+
Returns just the plugin name when it equals the command name,
4242
otherwise 'plugin:command'."
4343
[plugin-name command-name]
4444
(if (= plugin-name command-name)
4545
plugin-name
4646
(str plugin-name ":" command-name)))
4747

48+
(defn ^:private markdown-file? [file]
49+
(and (not (fs/directory? file))
50+
(string/ends-with? (string/lower-case (str file)) ".md")))
51+
52+
(defn ^:private configured-command-files [path]
53+
(cond
54+
(not (fs/exists? path)) []
55+
(fs/directory? path) (filter markdown-file? (fs/glob path "**" {:follow-links true}))
56+
:else [path]))
57+
58+
(defn ^:private command-file->command [type file opts]
59+
(let [base (normalize-command-name file)]
60+
(cond-> {:name (if-let [plugin (:plugin opts)]
61+
(prefixed-command-name plugin base)
62+
base)
63+
:path (str (fs/canonicalize file))
64+
:type type
65+
:content (slurp (str file))}
66+
(:plugin opts) (assoc :plugin (:plugin opts)))))
67+
4868
(defn ^:private global-file-commands []
4969
(let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME")
5070
(io/file (config/get-property "user.home") ".config"))
5171
commands-dir (io/file xdg-config-home "eca" "commands")]
52-
(when (fs/exists? commands-dir)
53-
(keep (fn [file]
54-
(when-not (fs/directory? file)
55-
{:name (normalize-command-name file)
56-
:path (str (fs/canonicalize file))
57-
:type :user-global-file
58-
:content (slurp (fs/file file))}))
59-
(fs/glob commands-dir "**" {:follow-links true})))))
72+
(map #(command-file->command :user-global-file % {})
73+
(configured-command-files commands-dir))))
6074

6175
(defn ^:private local-file-commands [roots]
6276
(->> roots
6377
(mapcat (fn [{:keys [uri]}]
64-
(let [commands-dir (fs/file (shared/uri->filename uri) ".eca" "commands")]
65-
(when (fs/exists? commands-dir)
66-
(fs/glob commands-dir "**" {:follow-links true})))))
67-
(keep (fn [file]
68-
(when-not (fs/directory? file)
69-
{:name (normalize-command-name file)
70-
:path (str (fs/canonicalize file))
71-
:type :user-local-file
72-
:content (slurp (fs/file file))})))))
78+
(configured-command-files (fs/file (shared/uri->filename uri) ".eca" "commands"))))
79+
(map #(command-file->command :user-local-file % {}))))
7380

7481
(defn ^:private config-commands [config roots]
7582
(->> (get config :commands)
76-
(map
83+
(mapcat
7784
(fn [{:keys [path plugin]}]
7885
(let [path (str (fs/expand-home path))
79-
effective-name (fn [file]
80-
(let [base (normalize-command-name file)]
81-
(if plugin (prefixed-command-name plugin base) base)))]
82-
(if (fs/absolute? path)
83-
(when (fs/exists? path)
84-
(cond-> {:name (effective-name path)
85-
:path path
86-
:type :user-config
87-
:content (slurp path)}
88-
plugin (assoc :plugin plugin)))
89-
(keep (fn [{:keys [uri]}]
90-
(let [f (fs/file (shared/uri->filename uri) path)]
91-
(when (fs/exists? f)
92-
(cond-> {:name (effective-name f)
93-
:path (str (fs/canonicalize f))
94-
:type :user-config
95-
:content (slurp f)}
96-
plugin (assoc :plugin plugin)))))
97-
roots)))))
98-
(flatten)
99-
(remove nil?)))
86+
opts (cond-> {}
87+
plugin (assoc :plugin plugin))]
88+
(->> (if (fs/absolute? path)
89+
(configured-command-files path)
90+
(mapcat (fn [{:keys [uri]}]
91+
(configured-command-files (fs/file (shared/uri->filename uri) path)))
92+
roots))
93+
(map #(command-file->command :user-config % opts))))))))
10094

10195
(defn ^:private custom-commands [config roots]
10296
(concat (config-commands config roots)
@@ -394,72 +388,72 @@
394388
{:type :new-chat-status
395389
:status :login})
396390
"model" (let [selected-model (first args)
397-
current-model (or (get-in db [:chats chat-id :model])
398-
full-model
399-
(llm-api/default-model db config))
400-
available-models (sort (keys (:models db)))
401-
chat-message (fn [text]
402-
{:type :chat-messages
403-
:chats {chat-id {:messages [{:role "system"
404-
:content [{:type :text
405-
:text text}]}]}}})]
406-
(cond
407-
(string/blank? selected-model)
408-
(if (seq available-models)
409-
(chat-message
410-
(multi-str (str "Current model: `" current-model "`")
411-
""
412-
"Available models:"
413-
(string/join "\n" (map #(str "- `" % "`") available-models))
414-
""
415-
"Run `/model <provider/model>` to switch chat model."))
416-
(chat-message
417-
(multi-str "No models available."
418-
""
419-
"Sync models or login first, for example `/login anthropic`.")))
391+
current-model (or (get-in db [:chats chat-id :model])
392+
full-model
393+
(llm-api/default-model db config))
394+
available-models (sort (keys (:models db)))
395+
chat-message (fn [text]
396+
{:type :chat-messages
397+
:chats {chat-id {:messages [{:role "system"
398+
:content [{:type :text
399+
:text text}]}]}}})]
400+
(cond
401+
(string/blank? selected-model)
402+
(if (seq available-models)
403+
(chat-message
404+
(multi-str (str "Current model: `" current-model "`")
405+
""
406+
"Available models:"
407+
(string/join "\n" (map #(str "- `" % "`") available-models))
408+
""
409+
"Run `/model <provider/model>` to switch chat model."))
410+
(chat-message
411+
(multi-str "No models available."
412+
""
413+
"Sync models or login first, for example `/login anthropic`.")))
420414

421-
(not (contains? (:models db) selected-model))
422-
(chat-message
423-
(multi-str (str "Unknown model: `" selected-model "`")
424-
""
425-
(when (seq available-models)
426-
(str "Available models:\n"
427-
(string/join "\n" (map #(str "- `" % "`") available-models))))))
415+
(not (contains? (:models db) selected-model))
416+
(chat-message
417+
(multi-str (str "Unknown model: `" selected-model "`")
418+
""
419+
(when (seq available-models)
420+
(str "Available models:\n"
421+
(string/join "\n" (map #(str "- `" % "`") available-models))))))
428422

429-
:else
430-
(do
431-
(swap! db* update-in [:chats chat-id] assoc :model selected-model :variant nil)
432-
(config/notify-fields-changed-only!
433-
{:chat {:select-model selected-model
434-
:variants []
435-
:select-variant nil}}
436-
messenger
437-
db*)
438-
(chat-message
439-
(multi-str (str "Selected model: `" selected-model "`")
440-
"Using model defaults.")))))
423+
:else
424+
(do
425+
(swap! db* update-in [:chats chat-id] assoc :model selected-model :variant nil)
426+
(config/notify-fields-changed-only!
427+
{:chat {:select-model selected-model
428+
:variants []
429+
:select-variant nil}}
430+
messenger
431+
db*)
432+
(chat-message
433+
(multi-str (str "Selected model: `" selected-model "`")
434+
"Using model defaults.")))))
441435
"fork" (let [chat (get-in db [:chats chat-id])
442-
new-id (str (random-uuid))
443-
now (System/currentTimeMillis)
444-
new-title (fork-title (:title chat))
445-
new-chat {:id new-id
446-
:title new-title
447-
:status :idle
448-
:created-at now
449-
:updated-at now
450-
:model (:model chat)
451-
:last-api (:last-api chat)
452-
:messages (vec (:messages chat))
453-
:prompt-finished? true}]
454-
(swap! db* assoc-in [:chats new-id] new-chat)
455-
(db/update-workspaces-cache! @db* metrics)
456-
(messenger/chat-opened messenger {:chat-id new-id :title new-title})
457-
{:type :chat-messages
458-
:chats {new-id {:messages (:messages chat)
459-
:title new-title}
460-
chat-id {:messages [{:role "system"
461-
:content [{:type :text
462-
:text (str "Chat forked to: " new-title)}]}]}}})
436+
new-id (str (random-uuid))
437+
now (System/currentTimeMillis)
438+
new-title (fork-title (:title chat))
439+
new-chat {:id new-id
440+
:title new-title
441+
:status :idle
442+
:created-at now
443+
:updated-at now
444+
:model (:model chat)
445+
:last-api (:last-api chat)
446+
:messages (vec (:messages chat))
447+
:prompt-finished? true}]
448+
(swap! db* assoc-in [:chats new-id] new-chat)
449+
(db/update-workspaces-cache! @db* metrics)
450+
(messenger/chat-opened messenger {:chat-id new-id :title new-title})
451+
{:type :chat-messages
452+
:chats {new-id {:messages (:messages chat)
453+
:title new-title}
454+
chat-id {:messages [{:role "system"
455+
:content [{:type :text
456+
:text (str "Chat forked to: " new-title)}]}]}}})
463457
"resume" (let [chats (into {}
464458
(filter #(and (not= chat-id (first %))
465459
(not (:subagent (second %)))))
@@ -652,7 +646,7 @@
652646
(if-let [skill (first (filter #(= command (:name %)) skills))]
653647
{:type :send-prompt
654648
:prompt (if (seq args)
655-
(substitute-args (:body skill) args)
656-
(str "Load skill: " (:name skill)))}
649+
(substitute-args (:body skill) args)
650+
(str "Load skill: " (:name skill)))}
657651
{:type :text
658652
:text (str "Unknown command: " command)})))))

0 commit comments

Comments
 (0)