From 09d2c2d63d132b7522615ef0fa4533b11f531c5f Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Mon, 27 Apr 2026 19:35:34 +0200 Subject: [PATCH 1/2] Add directory path support for rules, commands, skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rules, commands, and skills config entries now accept directories in addition to files — directories are loaded recursively so users can organise related items in a single folder instead of listing every file individually. Skills can now be specified via the new `skills` config key (same shape as `rules`/`commands`), and local skills are also discovered from `.agents/skills` alongside the existing `.eca/skills`. Refactors the loading code across rules, commands, and skills to share common directory/file discovery patterns. Closes #423 --- CHANGELOG.md | 2 + docs/config.json | 27 +++- src/eca/config.clj | 1 + src/eca/features/commands.clj | 204 ++++++++++++++-------------- src/eca/features/rules.clj | 52 ++++--- src/eca/features/skills.clj | 69 ++++++---- test/eca/features/commands_test.clj | 25 +++- test/eca/features/rules_test.clj | 19 +++ test/eca/features/skills_test.clj | 39 ++++++ 9 files changed, 271 insertions(+), 167 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b84042043..81b90551d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/config.json b/docs/config.json index f2803e049..3092eed1d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -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": [ @@ -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": [ diff --git a/src/eca/config.clj b/src/eca/config.clj index 1b63df28b..a1931e8b6 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -166,6 +166,7 @@ :hooks {} :rules [] :commands [] + :skills [] :disabledTools [] :toolCall {:approval {:byDefault "ask" :allow {"eca__compact_chat" {} diff --git a/src/eca/features/commands.clj b/src/eca/features/commands.clj index 94bdbd30f..96e1ecee5 100644 --- a/src/eca/features/commands.clj +++ b/src/eca/features/commands.clj @@ -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) @@ -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 ` 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 ` 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 %))))) @@ -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)}))))) diff --git a/src/eca/features/rules.clj b/src/eca/features/rules.clj index 3c396f08d..69d31912a 100644 --- a/src/eca/features/rules.clj +++ b/src/eca/features/rules.clj @@ -129,33 +129,33 @@ :matched-pattern matched-pattern :paths (:paths rule)})) +(defn ^:private rule-files [path] + (cond + (not (fs/exists? path)) [] + (fs/directory? path) (remove fs/directory? (fs/glob path "**" {:follow-links true})) + :else [path])) + +(defn ^:private rule-file [type file opts] + (rule-file->rule type + (fs/canonicalize file) + (slurp (str file)) + {:workspace-root (:workspace-root opts)})) + (defn ^:private global-file-rules [] (let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME") (io/file (config/get-property "user.home") ".config")) rules-dir (io/file xdg-config-home "eca" "rules")] - (when (fs/exists? rules-dir) - (keep (fn [file] - (when-not (fs/directory? file) - (rule-file->rule :user-global-file - (fs/canonicalize file) - (slurp (fs/file file))))) - (fs/glob rules-dir "**" {:follow-links true}))))) + (keep #(rule-file :user-global-file % {}) + (rule-files rules-dir)))) (defn ^:private local-file-rules [roots] (->> roots (mapcat (fn [{:keys [uri]}] - (let [workspace-root (shared/normalize-path (shared/uri->filename uri)) - rules-dir (fs/file workspace-root ".eca" "rules")] - (when (fs/exists? rules-dir) - (keep (fn [file] - (when-not (fs/directory? file) - (rule-file->rule :user-local-file - (fs/canonicalize file) - (slurp (fs/file file)) - {:workspace-root workspace-root}))) - (fs/glob rules-dir "**" {:follow-links true})))))))) - -(defn ^:private config-rule-candidates + (let [workspace-root (shared/normalize-path (shared/uri->filename uri))] + (keep #(rule-file :user-local-file % {:workspace-root workspace-root}) + (rule-files (fs/file workspace-root ".eca" "rules")))))))) + +(defn ^:private config-rule-paths [roots path] (if (fs/absolute? path) [{:path path @@ -163,20 +163,16 @@ (map (fn [{:keys [uri]}] (let [workspace-root (shared/normalize-path (shared/uri->filename uri))] {:path (fs/file workspace-root path) - :workspace-root workspace-root - :canonicalize? true})) + :workspace-root workspace-root})) roots))) (defn ^:private config-rules [config roots] (->> (get config :rules) (mapcat (fn [{:keys [path]}] - (config-rule-candidates roots path))) - (keep (fn [{:keys [path workspace-root canonicalize?]}] - (when (fs/exists? path) - (rule-file->rule :user-config - (cond-> path canonicalize? fs/canonicalize) - (slurp path) - {:workspace-root workspace-root})))))) + (config-rule-paths roots (str (fs/expand-home path))))) + (mapcat (fn [{:keys [path workspace-root]}] + (keep #(rule-file :user-config % {:workspace-root workspace-root}) + (rule-files path)))))) (defn ^:private loaded-rules [config roots] diff --git a/src/eca/features/skills.clj b/src/eca/features/skills.clj index 94fd481c1..adaf6fee4 100644 --- a/src/eca/features/skills.clj +++ b/src/eca/features/skills.clj @@ -29,47 +29,68 @@ (io/file (config/get-property "user.home") ".config"))] (io/file xdg-config-home "eca" "skills"))) -(defn ^:private global-skills [] - (let [skills-dir (global-skills-dir)] - (when (fs/exists? skills-dir) - (keep skill-file->skill - (fs/glob skills-dir "**/SKILL.md" {:follow-links true}))))) +(defn ^:private skill-file? [file] + (and (not (fs/directory? file)) + (= "SKILL.md" (fs/file-name file)))) -(defn ^:private local-skills [roots] - (->> roots - (mapcat (fn [{:keys [uri]}] - (let [skills-dir (fs/file (shared/uri->filename uri) ".eca" "skills")] - (when (fs/exists? skills-dir) - (fs/glob skills-dir "**/SKILL.md" {:follow-links true}))))) - (keep skill-file->skill))) +(defn ^:private skill-files [path] + (cond + (not (fs/exists? path)) [] + (fs/directory? path) (filter skill-file? (fs/glob path "**" {:follow-links true})) + :else [path])) (defn ^:private prefixed-skill-name "Builds the user-invocation name for a plugin skill. - Returns just the plugin name when it equals the skill name (dedup), + Returns just the plugin name when it equals the skill name, otherwise 'plugin:skill'." [plugin-name skill-name] (if (= plugin-name skill-name) plugin-name (str plugin-name ":" skill-name))) +(defn ^:private plugin-skill [plugin file] + (when-let [skill (skill-file->skill file)] + (cond-> skill + plugin (assoc :plugin plugin + :name (prefixed-skill-name plugin (:name skill)))))) + +(defn ^:private global-skills [] + (keep skill-file->skill + (skill-files (global-skills-dir)))) + +(defn ^:private local-skills [roots] + (->> roots + (mapcat (fn [{:keys [uri]}] + (let [root (shared/uri->filename uri)] + (mapcat skill-files + [(fs/file root ".eca" "skills") + (fs/file root ".agents" "skills")])))) + (keep skill-file->skill))) + (defn ^:private plugin-skills [plugin-skill-dirs] (->> plugin-skill-dirs (mapcat (fn [entry] (let [{:keys [dir plugin]} (if (string? entry) {:dir entry} - entry) - dir-file (fs/file dir)] - (when (and dir (fs/exists? dir-file)) - (->> (fs/glob dir-file "**/SKILL.md" {:follow-links true}) - (map (fn [f] {:file f :plugin plugin}))))))) - (keep (fn [{:keys [file plugin]}] - (when-let [skill (skill-file->skill file)] - (cond-> skill - plugin (assoc :plugin plugin - :name (prefixed-skill-name plugin (:name skill))))))))) + entry)] + (when dir + (keep #(plugin-skill plugin %) + (skill-files (fs/file dir))))))))) + +(defn ^:private config-skills [config roots] + (->> (get config :skills) + (mapcat + (fn [{:keys [path]}] + (let [path (str (fs/expand-home path))] + (if (fs/absolute? path) + (skill-files path) + (mapcat (fn [{:keys [uri]}] + (skill-files (fs/file (shared/uri->filename uri) path))) + roots))))) + (keep skill-file->skill))) (defn all [config roots] - (concat [] + (concat (config-skills config roots) (when-not (:pureConfig config) (global-skills)) (plugin-skills (:pluginSkillDirs config)) diff --git a/test/eca/features/commands_test.clj b/test/eca/features/commands_test.clj index 4ab412464..a7f9eecd2 100644 --- a/test/eca/features/commands_test.clj +++ b/test/eca/features/commands_test.clj @@ -77,8 +77,8 @@ (testing "plugin commands get prefixed with plugin name" (let [cmd-file (fs/file tmp-dir "deploy.md")] (spit cmd-file "Plugin command body") - (let [config {:commands [{:path (str cmd-file) :plugin "ui"}]} - result (vec (#'f.commands/config-commands config []))] + (let [config {:pureConfig true :commands [{:path (str cmd-file) :plugin "ui"}]} + result (vec (#'f.commands/custom-commands config []))] (is (= 1 (count result))) (is (= "ui:deploy" (:name (first result)))) (is (= "ui" (:plugin (first result))))))) @@ -86,8 +86,8 @@ (testing "plugin command with same name as plugin drops the prefix" (let [cmd-file (fs/file tmp-dir "tdd.md")] (spit cmd-file "TDD body") - (let [config {:commands [{:path (str cmd-file) :plugin "tdd"}]} - result (vec (#'f.commands/config-commands config []))] + (let [config {:pureConfig true :commands [{:path (str cmd-file) :plugin "tdd"}]} + result (vec (#'f.commands/custom-commands config []))] (is (= 1 (count result))) (is (= "tdd" (:name (first result)))) (is (= "tdd" (:plugin (first result))))))) @@ -95,11 +95,24 @@ (testing "user-config commands without a plugin stay unprefixed" (let [cmd-file (fs/file tmp-dir "plain.md")] (spit cmd-file "Plain body") - (let [config {:commands [{:path (str cmd-file)}]} - result (vec (#'f.commands/config-commands config []))] + (let [config {:pureConfig true :commands [{:path (str cmd-file)}]} + result (vec (#'f.commands/custom-commands config []))] (is (= 1 (count result))) (is (= "plain" (:name (first result)))) (is (not (contains? (first result) :plugin)))))) + + (testing "user-config command directories load markdown files recursively" + (let [cmd-dir (fs/file tmp-dir "commands") + nested-dir (fs/file cmd-dir "nested")] + (fs/create-dirs nested-dir) + (spit (fs/file cmd-dir "review.md") "Review body") + (spit (fs/file nested-dir "ship.md") "Ship body") + (spit (fs/file nested-dir "ignore.txt") "Ignored") + (let [config {:pureConfig true :commands [{:path (str cmd-dir)}]} + result (vec (#'f.commands/custom-commands config []))] + (is (= #{"review" "ship"} (set (map :name result)))) + (is (= #{"Review body" "Ship body"} (set (map :content result))))))) + (finally (fs/delete-tree tmp-dir))))) diff --git a/test/eca/features/rules_test.clj b/test/eca/features/rules_test.clj index bbc95f492..fd5201c99 100644 --- a/test/eca/features/rules_test.clj +++ b/test/eca/features/rules_test.clj @@ -64,10 +64,28 @@ :content "MY_RULE_CONTENT"}]) (:static (f.rules/all-rules config roots nil nil))))))) + (testing "config rule directories load files recursively" + (let [tmp-dir (fs/create-temp-dir) + rules-dir (fs/file tmp-dir "rules") + nested-dir (fs/file rules-dir "nested")] + (try + (fs/create-dirs nested-dir) + (spit (fs/file rules-dir "root.md") "Root rule") + (spit (fs/file nested-dir "child.mdc") "Child rule") + (let [config {:rules [{:path (str rules-dir)}]} + result (filter #(contains? #{"root.md" "child.mdc"} (:name %)) + (:static (f.rules/all-rules config [] nil nil)))] + (is (= #{"root.md" "child.mdc"} (set (map :name result)))) + (is (= #{"Root rule" "Child rule"} (set (map :content result))))) + (finally + (fs/delete-tree tmp-dir))))) + + (testing "local file rules load as project-scoped static rules" (with-redefs [fs/exists? #(contains? #{(h/file-path "/my/project/.eca/rules") (h/file-path "/my/project") (h/file-path "/my/project/.eca/rules/cool.md")} (str %)) + fs/directory? #(= (h/file-path "/my/project/.eca/rules") (str %)) fs/glob (constantly [(fs/path (h/file-path "/my/project/.eca/rules/cool.md"))]) fs/canonicalize identity fs/file-name (constantly "cool-name") @@ -88,6 +106,7 @@ (h/file-path "/home/someuser/.config/eca/rules/cool.md") (h/file-path "/home/someuser/.config") (h/file-path "/home/someuser/.config/eca")} (str %)) + fs/directory? #(= (h/file-path "/home/someuser/.config/eca/rules") (str %)) fs/glob (constantly [(fs/path (h/file-path "/home/someuser/.config/eca/rules/cool.md"))]) fs/canonicalize identity fs/file-name (constantly "cool-name") diff --git a/test/eca/features/skills_test.clj b/test/eca/features/skills_test.clj index 87b909bfb..b6b89784a 100644 --- a/test/eca/features/skills_test.clj +++ b/test/eca/features/skills_test.clj @@ -4,6 +4,7 @@ [clojure.test :refer [deftest is testing]] [eca.config :as config] [eca.features.skills :as f.skills] + [eca.shared :as shared] [eca.test-helper :as h] [matcher-combinators.matchers :as m] [matcher-combinators.test :refer [match?]])) @@ -13,6 +14,7 @@ (with-redefs [config/get-env (constantly nil) config/get-property (constantly (h/file-path "/home/someuser")) fs/exists? #(= (h/file-path "/my/project/.eca/skills") (str %)) + fs/directory? #(= (h/file-path "/my/project/.eca/skills") (str %)) fs/glob (constantly [(fs/path (h/file-path "/my/project/.eca/skills/my-skill/SKILL.md"))]) fs/canonicalize identity fs/parent (constantly (fs/path (h/file-path "/my/project/.eca/skills/my-skill"))) @@ -28,6 +30,7 @@ (testing "global skills" (with-redefs [config/get-env (constantly (h/file-path "/home/someuser/.config")) fs/exists? #(= (h/file-path "/home/someuser/.config/eca/skills") (str %)) + fs/directory? #(= (h/file-path "/home/someuser/.config/eca/skills") (str %)) fs/glob (constantly [(fs/path (h/file-path "/home/someuser/.config/eca/skills/global-skill/SKILL.md"))]) fs/canonicalize identity fs/parent (constantly (fs/path (h/file-path "/home/someuser/.config/eca/skills/global-skill"))) @@ -44,6 +47,7 @@ (with-redefs [config/get-env (constantly nil) config/get-property (constantly (h/file-path "/home/someuser")) fs/exists? #(= (h/file-path "/home/someuser/.config/eca/skills") (str %)) + fs/directory? #(= (h/file-path "/home/someuser/.config/eca/skills") (str %)) fs/glob (constantly [(fs/path (h/file-path "/home/someuser/.config/eca/skills/fallback-skill/SKILL.md"))]) fs/canonicalize identity fs/parent (constantly (fs/path (h/file-path "/home/someuser/.config/eca/skills/fallback-skill"))) @@ -56,6 +60,37 @@ :dir (h/file-path "/home/someuser/.config/eca/skills/fallback-skill")}]) (f.skills/all {} roots)))))) + (testing "config skill directories load SKILL.md files recursively" + (let [tmp-dir (fs/create-temp-dir) + skills-dir (fs/file tmp-dir "skills") + skill-dir (fs/file skills-dir "configured")] + (try + (fs/create-dirs skill-dir) + (spit (fs/file skill-dir "SKILL.md") "---\nname: configured\ndescription: Configured skill\n---\nConfigured body") + (spit (fs/file skills-dir "ignored.md") "Ignored") + (let [config {:pureConfig true :skills [{:path (str skills-dir)}]} + result (vec (f.skills/all config []))] + (is (= ["configured"] (mapv :name result))) + (is (= ["Configured skill"] (mapv :description result)))) + (finally + (fs/delete-tree tmp-dir))))) + + (testing "local .agents skills" + (let [tmp-dir (fs/create-temp-dir) + skill-dir (fs/file tmp-dir ".agents" "skills" "agent-skill")] + (try + (fs/create-dirs skill-dir) + (spit (fs/file skill-dir "SKILL.md") "---\nname: agent-skill\ndescription: Agent skill\n---\nAgent body") + (let [roots [{:uri (shared/filename->uri (str tmp-dir))}]] + (is (match? + (m/embeds [{:name "agent-skill" + :description "Agent skill" + :body "Agent body"}]) + (f.skills/all {:pureConfig true} roots)))) + (finally + (fs/delete-tree tmp-dir))))) + + (testing "resolves dynamic strings in skill markdown" (let [tmp-dir (fs/create-temp-dir) skills-dir (fs/file tmp-dir "skills" "dynamic-skill") @@ -81,6 +116,7 @@ (with-redefs [config/get-env (constantly nil) config/get-property (constantly (h/file-path "/home/someuser")) fs/exists? #(= (h/file-path "/my/project/.eca/skills") (str %)) + fs/directory? #(= (h/file-path "/my/project/.eca/skills") (str %)) fs/glob (constantly [(fs/path (h/file-path "/my/project/.eca/skills/quoted-skill/SKILL.md"))]) fs/canonicalize identity fs/parent (constantly (fs/path (h/file-path "/my/project/.eca/skills/quoted-skill"))) @@ -96,6 +132,7 @@ (with-redefs [config/get-env (constantly nil) config/get-property (constantly (h/file-path "/home/someuser")) fs/exists? (fn [p] (= (h/file-path "/plugins/ui/skills") (str p))) + fs/directory? #(= (h/file-path "/plugins/ui/skills") (str %)) fs/glob (constantly [(fs/path (h/file-path "/plugins/ui/skills/button/SKILL.md"))]) fs/canonicalize identity fs/parent (constantly (fs/path (h/file-path "/plugins/ui/skills/button"))) @@ -112,6 +149,7 @@ (with-redefs [config/get-env (constantly nil) config/get-property (constantly (h/file-path "/home/someuser")) fs/exists? (fn [p] (= (h/file-path "/plugins/tdd/skills") (str p))) + fs/directory? #(= (h/file-path "/plugins/tdd/skills") (str %)) fs/glob (constantly [(fs/path (h/file-path "/plugins/tdd/skills/tdd/SKILL.md"))]) fs/canonicalize identity fs/parent (constantly (fs/path (h/file-path "/plugins/tdd/skills/tdd"))) @@ -128,6 +166,7 @@ (with-redefs [config/get-env (constantly nil) config/get-property (constantly (h/file-path "/home/someuser")) fs/exists? (fn [p] (= (h/file-path "/plugins/legacy/skills") (str p))) + fs/directory? #(= (h/file-path "/plugins/legacy/skills") (str %)) fs/glob (constantly [(fs/path (h/file-path "/plugins/legacy/skills/old/SKILL.md"))]) fs/canonicalize identity fs/parent (constantly (fs/path (h/file-path "/plugins/legacy/skills/old"))) From 6cb71c2d75e2ae8f0d59453f9331e1bf3701a390 Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Tue, 28 Apr 2026 07:43:34 +0200 Subject: [PATCH 2/2] Update docs: directory paths in config --- docs/config/commands.md | 9 +++++++-- docs/config/introduction.md | 1 + docs/config/rules.md | 9 ++++++++- docs/config/skills.md | 12 +++++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/config/commands.md b/docs/config/commands.md index 07f93e9b2..f414646b0 100644 --- a/docs/config/commands.md +++ b/docs/config/commands.md @@ -37,7 +37,7 @@ 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" { @@ -45,4 +45,9 @@ You can configure in multiple different ways: } ``` - 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"}] + } + ``` diff --git a/docs/config/introduction.md b/docs/config/introduction.md index 1ab509401..c1c952ad1 100644 --- a/docs/config/introduction.md +++ b/docs/config/introduction.md @@ -94,6 +94,7 @@ By default ECA consider the following as the base configuration: "hooks": {}, "rules" : [], "commands" : [], + "skills": [], "disabledTools": [], "toolCall": { "approval": { diff --git a/docs/config/rules.md b/docs/config/rules.md index a5b794ffd..480746de4 100644 --- a/docs/config/rules.md +++ b/docs/config/rules.md @@ -35,7 +35,7 @@ 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" { @@ -43,6 +43,13 @@ ECA loads rules from 3 sources: } ``` + ```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. diff --git a/docs/config/skills.md b/docs/config/skills.md index 21339b888..68fb9b2e9 100644 --- a/docs/config/skills.md +++ b/docs/config/skills.md @@ -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. @@ -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.