Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

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

## 0.130.0

- Improve rules with frontmatter filters, condition variables, path-scoped loading, enforcement support, and clearer documentation. #222
Expand Down
27 changes: 23 additions & 4 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@
"properties": {
"path": {
"type": "string",
"description": "Path to a rule file (relative to workspace root or absolute).",
"markdownDescription": "Path to a rule file (relative to workspace root or absolute)."
"description": "Path to a rule file or directory (relative to workspace root or absolute). Directories are loaded recursively.",
"markdownDescription": "Path to a rule file or directory (relative to workspace root or absolute). Directories are loaded recursively."
}
},
"required": [
Expand All @@ -112,8 +112,27 @@
"properties": {
"path": {
"type": "string",
"description": "Path to a command prompt markdown file.",
"markdownDescription": "Path to a command prompt markdown file."
"description": "Path to a command prompt markdown file or directory. Directories load markdown files recursively.",
"markdownDescription": "Path to a command prompt markdown file or directory. Directories load markdown files recursively."
}
},
"required": [
"path"
],
"additionalProperties": false
}
},
"skills": {
"type": "array",
"description": "Skill files or directories to load.",
"markdownDescription": "Skill files or directories to load.",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to a skill file or directory. Directories load SKILL.md files recursively.",
"markdownDescription": "Path to a skill file or directory. Directories load SKILL.md files recursively."
}
},
"required": [
Expand Down
9 changes: 7 additions & 2 deletions docs/config/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,17 @@ You can configure in multiple different ways:

=== "Config"

Just add to your config the `commands` pointing to `.md` files that will be searched from the workspace root if not an absolute path:
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:

```javascript title="~/.config/eca/config.json"
{
"commands": [{"path": "my-custom-prompt.md"}]
}
```

ECA will make available a `/my-custom-prompt` command after creating that file.
```javascript title="~/.config/eca/config.json"
// Load all command files from a directory recursively
{
"commands": [{"path": "/home/user/commands"}]
}
```
1 change: 1 addition & 0 deletions docs/config/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ By default ECA consider the following as the base configuration:
"hooks": {},
"rules" : [],
"commands" : [],
"skills": [],
"disabledTools": [],
"toolCall": {
"approval": {
Expand Down
9 changes: 8 additions & 1 deletion docs/config/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,21 @@ ECA loads rules from 3 sources:

=== "Config"

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.
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.

```javascript title="~/.config/eca/config.json"
{
"rules": [{"path": "my-rule.md"}]
}
```

```javascript title="~/.config/eca/config.json"
// Load all rules from a directory recursively
{
"rules": [{"path": "/home/user/rules"}]
}
```

## Static and path-scoped rules

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.
Expand Down
12 changes: 11 additions & 1 deletion docs/config/skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: "Configure ECA skills: structured knowledge units that teach the LL
![](../images/features/skills.png)

Skills are folders with `SKILL.md` which teachs LLM how to solve a specific task or gain knowledge about it.
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.
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.

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.

Expand Down Expand Up @@ -75,6 +75,16 @@ Check the examples:
}
```

=== "Config"

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:

```javascript title="~/.config/eca/config.json"
{
"skills": [{"path": "/home/user/skills"}]
}
```

## Parameterized skills

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.
Expand Down
1 change: 1 addition & 0 deletions src/eca/config.clj
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@
:hooks {}
:rules []
:commands []
:skills []
:disabledTools []
:toolCall {:approval {:byDefault "ask"
:allow {"eca__compact_chat" {}
Expand Down
204 changes: 99 additions & 105 deletions src/eca/features/commands.clj
Original file line number Diff line number Diff line change
Expand Up @@ -38,65 +38,59 @@

(defn ^:private prefixed-command-name
"Builds the user-invocation name for a plugin-sourced command.
Returns just the plugin name when it equals the command name (dedup),
Returns just the plugin name when it equals the command name,
otherwise 'plugin:command'."
[plugin-name command-name]
(if (= plugin-name command-name)
plugin-name
(str plugin-name ":" command-name)))

(defn ^:private markdown-file? [file]
(and (not (fs/directory? file))
(string/ends-with? (string/lower-case (str file)) ".md")))

(defn ^:private configured-command-files [path]
(cond
(not (fs/exists? path)) []
(fs/directory? path) (filter markdown-file? (fs/glob path "**" {:follow-links true}))
:else [path]))

(defn ^:private command-file->command [type file opts]
(let [base (normalize-command-name file)]
(cond-> {:name (if-let [plugin (:plugin opts)]
(prefixed-command-name plugin base)
base)
:path (str (fs/canonicalize file))
:type type
:content (slurp (str file))}
(:plugin opts) (assoc :plugin (:plugin opts)))))

(defn ^:private global-file-commands []
(let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME")
(io/file (config/get-property "user.home") ".config"))
commands-dir (io/file xdg-config-home "eca" "commands")]
(when (fs/exists? commands-dir)
(keep (fn [file]
(when-not (fs/directory? file)
{:name (normalize-command-name file)
:path (str (fs/canonicalize file))
:type :user-global-file
:content (slurp (fs/file file))}))
(fs/glob commands-dir "**" {:follow-links true})))))
(map #(command-file->command :user-global-file % {})
(configured-command-files commands-dir))))

(defn ^:private local-file-commands [roots]
(->> roots
(mapcat (fn [{:keys [uri]}]
(let [commands-dir (fs/file (shared/uri->filename uri) ".eca" "commands")]
(when (fs/exists? commands-dir)
(fs/glob commands-dir "**" {:follow-links true})))))
(keep (fn [file]
(when-not (fs/directory? file)
{:name (normalize-command-name file)
:path (str (fs/canonicalize file))
:type :user-local-file
:content (slurp (fs/file file))})))))
(configured-command-files (fs/file (shared/uri->filename uri) ".eca" "commands"))))
(map #(command-file->command :user-local-file % {}))))

(defn ^:private config-commands [config roots]
(->> (get config :commands)
(map
(mapcat
(fn [{:keys [path plugin]}]
(let [path (str (fs/expand-home path))
effective-name (fn [file]
(let [base (normalize-command-name file)]
(if plugin (prefixed-command-name plugin base) base)))]
(if (fs/absolute? path)
(when (fs/exists? path)
(cond-> {:name (effective-name path)
:path path
:type :user-config
:content (slurp path)}
plugin (assoc :plugin plugin)))
(keep (fn [{:keys [uri]}]
(let [f (fs/file (shared/uri->filename uri) path)]
(when (fs/exists? f)
(cond-> {:name (effective-name f)
:path (str (fs/canonicalize f))
:type :user-config
:content (slurp f)}
plugin (assoc :plugin plugin)))))
roots)))))
(flatten)
(remove nil?)))
opts (cond-> {}
plugin (assoc :plugin plugin))]
(->> (if (fs/absolute? path)
(configured-command-files path)
(mapcat (fn [{:keys [uri]}]
(configured-command-files (fs/file (shared/uri->filename uri) path)))
roots))
(map #(command-file->command :user-config % opts))))))))

(defn ^:private custom-commands [config roots]
(concat (config-commands config roots)
Expand Down Expand Up @@ -394,72 +388,72 @@
{:type :new-chat-status
:status :login})
"model" (let [selected-model (first args)
current-model (or (get-in db [:chats chat-id :model])
full-model
(llm-api/default-model db config))
available-models (sort (keys (:models db)))
chat-message (fn [text]
{:type :chat-messages
:chats {chat-id {:messages [{:role "system"
:content [{:type :text
:text text}]}]}}})]
(cond
(string/blank? selected-model)
(if (seq available-models)
(chat-message
(multi-str (str "Current model: `" current-model "`")
""
"Available models:"
(string/join "\n" (map #(str "- `" % "`") available-models))
""
"Run `/model <provider/model>` to switch chat model."))
(chat-message
(multi-str "No models available."
""
"Sync models or login first, for example `/login anthropic`.")))
current-model (or (get-in db [:chats chat-id :model])
full-model
(llm-api/default-model db config))
available-models (sort (keys (:models db)))
chat-message (fn [text]
{:type :chat-messages
:chats {chat-id {:messages [{:role "system"
:content [{:type :text
:text text}]}]}}})]
(cond
(string/blank? selected-model)
(if (seq available-models)
(chat-message
(multi-str (str "Current model: `" current-model "`")
""
"Available models:"
(string/join "\n" (map #(str "- `" % "`") available-models))
""
"Run `/model <provider/model>` to switch chat model."))
(chat-message
(multi-str "No models available."
""
"Sync models or login first, for example `/login anthropic`.")))

(not (contains? (:models db) selected-model))
(chat-message
(multi-str (str "Unknown model: `" selected-model "`")
""
(when (seq available-models)
(str "Available models:\n"
(string/join "\n" (map #(str "- `" % "`") available-models))))))
(not (contains? (:models db) selected-model))
(chat-message
(multi-str (str "Unknown model: `" selected-model "`")
""
(when (seq available-models)
(str "Available models:\n"
(string/join "\n" (map #(str "- `" % "`") available-models))))))

:else
(do
(swap! db* update-in [:chats chat-id] assoc :model selected-model :variant nil)
(config/notify-fields-changed-only!
{:chat {:select-model selected-model
:variants []
:select-variant nil}}
messenger
db*)
(chat-message
(multi-str (str "Selected model: `" selected-model "`")
"Using model defaults.")))))
:else
(do
(swap! db* update-in [:chats chat-id] assoc :model selected-model :variant nil)
(config/notify-fields-changed-only!
{:chat {:select-model selected-model
:variants []
:select-variant nil}}
messenger
db*)
(chat-message
(multi-str (str "Selected model: `" selected-model "`")
"Using model defaults.")))))
"fork" (let [chat (get-in db [:chats chat-id])
new-id (str (random-uuid))
now (System/currentTimeMillis)
new-title (fork-title (:title chat))
new-chat {:id new-id
:title new-title
:status :idle
:created-at now
:updated-at now
:model (:model chat)
:last-api (:last-api chat)
:messages (vec (:messages chat))
:prompt-finished? true}]
(swap! db* assoc-in [:chats new-id] new-chat)
(db/update-workspaces-cache! @db* metrics)
(messenger/chat-opened messenger {:chat-id new-id :title new-title})
{:type :chat-messages
:chats {new-id {:messages (:messages chat)
:title new-title}
chat-id {:messages [{:role "system"
:content [{:type :text
:text (str "Chat forked to: " new-title)}]}]}}})
new-id (str (random-uuid))
now (System/currentTimeMillis)
new-title (fork-title (:title chat))
new-chat {:id new-id
:title new-title
:status :idle
:created-at now
:updated-at now
:model (:model chat)
:last-api (:last-api chat)
:messages (vec (:messages chat))
:prompt-finished? true}]
(swap! db* assoc-in [:chats new-id] new-chat)
(db/update-workspaces-cache! @db* metrics)
(messenger/chat-opened messenger {:chat-id new-id :title new-title})
{:type :chat-messages
:chats {new-id {:messages (:messages chat)
:title new-title}
chat-id {:messages [{:role "system"
:content [{:type :text
:text (str "Chat forked to: " new-title)}]}]}}})
"resume" (let [chats (into {}
(filter #(and (not= chat-id (first %))
(not (:subagent (second %)))))
Expand Down Expand Up @@ -652,7 +646,7 @@
(if-let [skill (first (filter #(= command (:name %)) skills))]
{:type :send-prompt
:prompt (if (seq args)
(substitute-args (:body skill) args)
(str "Load skill: " (:name skill)))}
(substitute-args (:body skill) args)
(str "Load skill: " (:name skill)))}
{:type :text
:text (str "Unknown command: " command)})))))
Loading
Loading