diff --git a/AGENTS.md b/AGENTS.md index f9e2d8b3e..d7f31e239 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,4 +30,5 @@ ECA Agent Guide (AGENTS.md) - Use java class typing to avoid GraalVM reflection issues - Avoid adding too many comments, only add essential or when you think is really important to mention something. - ECA's protocol specification of client <-> server lives in docs/protocol.md +- If changing ECA config structure, remember to update its docs/config.json - When adding support to a new feature or fixing a existing github issue, add a entry to Unreleased in CHANGELOG.md if not already there, be concise like the rest. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f94a68b4..f04e1069c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Add `plugins` config for loading external configuration from git repos or local paths. #349 + - Plugins can provide skills, MCP servers, agents, commands, hooks, rules, and arbitrary config overrides. + - Add commands `/plugins` and `/plugin-install`. + ## 0.111.0 - Fix MCP server initialization crash (`String cannot be cast to IPersistentCollection`) when OAuth metadata endpoint returns a non-JSON or error response. diff --git a/docs/config.json b/docs/config.json index c0463b886..608ffd713 100644 --- a/docs/config.json +++ b/docs/config.json @@ -281,6 +281,57 @@ } } }, + "plugins": { + "type": "object", + "description": "Plugin system for loading external configuration from git repos or local paths. Each key (except 'install') is a named plugin source with a 'source' URL or path. 'install' lists plugin names to install from any registered source.", + "markdownDescription": "Plugin system for loading external configuration from git repos or local paths. Each key (except `install`) is a named plugin source with a `source` URL or path. `install` lists plugin names to install from any registered source.", + "examples": [ + { + "my-org": { + "source": "https://github.com/org/ai-plugins.git" + }, + "install": ["plugin-a", "plugin-b"] + } + ], + "properties": { + "install": { + "type": "array", + "description": "List of plugin names to install from registered sources.", + "markdownDescription": "List of plugin names to install from registered sources.", + "items": { + "type": "string" + } + } + }, + "additionalProperties": { + "oneOf": [ + { + "type": "object", + "description": "A named plugin source.", + "properties": { + "source": { + "type": "string", + "description": "Git URL or local path to a plugin repository containing .eca-plugin/marketplace.json.", + "markdownDescription": "Git URL or local path to a plugin repository containing `.eca-plugin/marketplace.json`.", + "examples": [ + "https://github.com/org/ai-plugins.git", + "git@github.com:org/ai-plugins.git", + "/home/user/local-plugins" + ] + } + }, + "required": ["source"], + "additionalProperties": false + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, "network": { "type": "object", "description": "Network configuration for custom CA certificates and mTLS client certificates. Values support dynamic string interpolation (e.g. '${env:SSL_CERT_FILE}').", diff --git a/docs/config/plugins.md b/docs/config/plugins.md new file mode 100644 index 000000000..3c7270d14 --- /dev/null +++ b/docs/config/plugins.md @@ -0,0 +1,205 @@ +--- +description: "Configure ECA plugins: load external skills, agents, commands, rules, hooks, MCP servers and config overrides from git repos or local paths." +--- + +# Plugins / Marketplace + +Plugins let you share and reuse ECA configuration across projects and teams. A plugin source is a git repository or local directory containing a **marketplace** of plugins, each providing any combination of skills, agents, commands, rules, hooks, MCP servers, and config overrides. + +## How it works + +```mermaid +flowchart TD + A[ECA has a 'plugins' in config.json] --> B{Git URL or local path?} + B -->|git| C[Clone to ~/.eca/cache/plugins] + B -->|local| D[Use directory directly] + C --> E[Read .eca-plugin/marketplace.json] + D --> E + E --> F[Read plugins from 'install'] + F --> G[Merge into final ECA config] +``` + +1. You register one or more **sources** (git URL or local path) and list plugin names in **`install`**. +2. ECA resolves each source — cloning git repos to a local cache or using the local path directly. +3. Each source provides a **marketplace** (`.eca-plugin/marketplace.json`) listing its available plugins. +4. ECA matches `install` names against the marketplace, then **discovers components** from each matched plugin directory. +5. All components are **merged** into the config waterfall — user config always takes precedence on conflicts. + +## Commands + +### `/plugins` + +Lists all available plugins from your configured marketplaces. Plugins that are already installed are marked with ✅. + +``` +/plugins +``` + +### `/plugin-install` + +Installs a plugin by adding it to the `install` list in your global config. + +``` +/plugin-install +/plugin-install +``` + +Use `` to disambiguate when multiple sources provide a plugin with the same name. After installing, restart ECA for the plugin to take effect. + +## Pointing to a plugin source / marketplace + +Add a `plugins` key to your config with one or more named sources and an `install` array: + +=== "Git source" + + ```javascript title="~/.config/eca/config.json" + { + "plugins": { + "my-org": { + "source": "https://github.com/my-org/eca-plugins.git" + }, + "install": ["code-review", "security-scanner"] + } + } + ``` + +=== "Local path (for development)" + + ```javascript title=".eca/config.json" + { + "plugins": { + "local-dev": { + "source": "/home/user/my-eca-plugins" + }, + "install": ["my-plugin"] + } + } + ``` + +=== "Multiple sources" + + ECA searches all registered sources when resolving `install` entries: + + ```javascript title="~/.config/eca/config.json" + { + "plugins": { + "company": { + "source": "https://github.com/company/eca-plugins.git" + }, + "community": { + "source": "https://github.com/community/shared-plugins.git" + }, + "install": ["company-standards", "linter-setup", "shared-skills"] + } + } + ``` + +## Creating a plugin source (Plugins marketplace) + +A plugin source is a directory (typically a git repo) with a `.eca-plugin/marketplace.json` file that lists available plugins. + +### Marketplace file + +```json title=".eca-plugin/marketplace.json" +{ + "plugins": [ + { + "name": "code-review", + "description": "Agents and skills for thorough code review", + "source": "plugins/code-review" + }, + { + "name": "security-scanner", + "description": "Security-focused rules and hooks", + "source": "plugins/security-scanner" + } + ] +} +``` + +Each plugin entry has: + +| Field | Description | +|-------|-------------| +| `name` | Unique plugin name (used in `install`) | +| `description` | Human-readable description | +| `source` | Relative path from the repo root to the plugin directory | + +### Plugin directory structure + +Each plugin directory can contain any combination of: + +``` +plugins/code-review/ +├── skills/ +│ └── review-checklist/ +│ └── SKILL.md +├── agents/ +│ └── reviewer.md +├── commands/ +│ └── review.md +├── rules/ +│ └── code-standards.md +├── hooks/ +│ └── hooks.json +├── .mcp.json +└── eca.json +``` + +| Path | What it provides | Details | +|------|-----------------|---------| +| `skills/` | Skill definitions | Each subfolder follows the [agentskills.io](https://agentskills.io/) spec with a `SKILL.md` | +| `agents/*.md` | Agent definitions | Markdown files with YAML frontmatter, same format as local agents | +| `commands/*.md` | Custom commands | Markdown command files, same format as local commands | +| `rules/**` | Rule files | Any files under `rules/` are loaded as rules | +| `hooks/hooks.json` | Hooks | [ECA hook format](hooks.md) | +| `.mcp.json` | MCP server definitions | Standard `{"mcpServers": {...}}` format | +| `eca.json` | Config overrides | Arbitrary ECA config keys deep-merged into config | + +All paths are optional — include only what your plugin needs. + +=== "Skill-only plugin" + + ``` + plugins/gif-maker/ + └── skills/ + └── gif-generator/ + ├── SKILL.md + └── scripts/ + └── generate.py + ``` + +=== "Hooks + MCP plugin" + + ``` + plugins/security-scanner/ + ├── hooks/ + │ └── hooks.json + └── .mcp.json + ``` + +=== "Config overrides only" + + ``` + plugins/team-defaults/ + └── eca.json + ``` + +=== "Full plugin" + + ``` + plugins/company-standards/ + ├── skills/ + │ └── internal-api/ + │ └── SKILL.md + ├── agents/ + │ └── reviewer.md + ├── commands/ + │ └── deploy.md + ├── rules/ + │ └── coding-standards.md + ├── hooks/ + │ └── hooks.json + ├── .mcp.json + └── eca.json + ``` diff --git a/integration-test/integration/chat/commands_test.clj b/integration-test/integration/chat/commands_test.clj index 3f0312262..b18e47514 100644 --- a/integration-test/integration/chat/commands_test.clj +++ b/integration-test/integration/chat/commands_test.clj @@ -32,7 +32,9 @@ {:name "doctor" :arguments []} {:name "repo-map-show" :arguments []} {:name "prompt-show" :arguments [{:name "optional-prompt"}]} - {:name "subagents" :arguments []}]} + {:name "subagents" :arguments []} + {:name "plugins" :arguments []} + {:name "plugin-install" :arguments [{:name "plugin"}]}]} resp)))) (testing "We query specific commands" @@ -45,7 +47,8 @@ {:name "costs" :arguments []} {:name "compact" :arguments [{:name "additional-input"}]} {:name "config" :arguments []} - {:name "subagents" :arguments []}]} + {:name "subagents" :arguments []} + {:name "plugins" :arguments []}]} resp)))) (testing "We send a built-in command" diff --git a/mkdocs.yml b/mkdocs.yml index a8915ab08..1966d262a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,7 @@ nav: - Completion: config/completion.md - Rewrite: config/rewrite.md - Context Management: config/context-management.md + - Plugins / Marketplace: config/plugins.md - Metrics: config/metrics.md - Network / Enterprise: config/network.md - User Examples: config/examples.md diff --git a/src/eca/cache.clj b/src/eca/cache.clj index 44d58be26..51b1b01be 100644 --- a/src/eca/cache.clj +++ b/src/eca/cache.clj @@ -41,12 +41,18 @@ (def ^:private logger-tag "[CACHE]") (def ^:private tool-call-outputs-dir-name "toolCallOutputs") +(def ^:private plugins-dir-name "plugins") (defn tool-call-outputs-dir "Returns the File object for the tool call outputs cache directory." ^File [] (io/file (global-dir) tool-call-outputs-dir-name)) +(defn plugins-dir + "Returns the base directory for caching cloned plugin sources." + ^java.io.File [] + (io/file (global-dir) plugins-dir-name)) + (defn save-tool-call-output! "Saves the full tool call output text to a cache file. Returns the absolute path of the saved file as a string." diff --git a/src/eca/config.clj b/src/eca/config.clj index 377e3b719..35dd9d6cb 100644 --- a/src/eca/config.clj +++ b/src/eca/config.clj @@ -192,6 +192,7 @@ :completion {:model "openai/gpt-4.1"} :netrcFile nil :autoCompactPercentage 75 + :plugins {} :env "prod"}) (defn ^:private parse-dynamic-string-values @@ -306,6 +307,8 @@ (def initialization-config* (atom {})) +(def plugin-components* (atom nil)) + (defn ^:private deep-merge [& maps] (apply merge-with (fn [& args] (if (every? #(or (map? %) (nil? %)) args) @@ -411,7 +414,8 @@ [:behavior :ANY :toolCall :approval :ask :ANY :argsMatchers] [:behavior :ANY :toolCall :approval :deny] [:behavior :ANY :toolCall :approval :deny :ANY :argsMatchers] - [:otlp]]}) + [:otlp] + [:plugins]]}) (defn ^:private migrate-legacy-agent-name "Migrates legacy agent names 'agent' and 'build' to 'code'." @@ -455,7 +459,15 @@ (let [initialization-config @initialization-config* pure-config? (:pureConfig initialization-config) merge-config (fn [c1 c2] - (deep-merge c1 (normalize-fields normalization-rules c2)))] + (deep-merge c1 (normalize-fields normalization-rules c2))) + plugin-data (when-not pure-config? @plugin-components*) + plugin-config (when plugin-data + (let [cfg (:config-fragment plugin-data)] + ;; commands/rules are vectors — separate them to avoid deep-merge replacement + (dissoc cfg :commands :rules))) + plugin-commands (:commands plugin-data) + plugin-rules (:rules plugin-data) + plugin-agents (:agents plugin-data)] (-> (as-> {} $ (merge-config $ (initial-config)) (merge-config $ initialization-config) @@ -465,16 +477,22 @@ (merge-config $ (when-not pure-config? custom-config)) (-> $ (merge-config (when-not pure-config? (config-from-global-file))) - (merge-config (when-not pure-config? (config-from-local-file (:workspace-folders db))))))) + (merge-config (when-not pure-config? (config-from-local-file (:workspace-folders db)))))) + ;; Plugin config merges after all file configs (user local config wins via later merge) + (merge-config $ plugin-config)) + ;; Append plugin commands/rules (vector concat, not deep-merge replace) + (cond-> + (seq plugin-commands) (update :commands #(vec (concat % plugin-commands))) + (seq plugin-rules) (update :rules #(vec (concat % plugin-rules)))) migrate-legacy-config ;; Merge markdown-defined agents (lowest priority — JSON config agents win) + ;; Plugin agents merge at same level as markdown agents (as-> config (let [md-agent-configs (when-not pure-config? - ;; TODO how to avoid this dependency? - (agents/all-md-agents (:workspace-folders db)))] - (if (seq md-agent-configs) + (agents/all-md-agents (:workspace-folders db)))] + (if (or (seq md-agent-configs) (seq plugin-agents)) (update config :agent (fn [existing] - (merge md-agent-configs existing))) + (merge md-agent-configs plugin-agents existing))) config)))))) (def all (memoize/ttl all* :ttl/threshold ttl-cache-config-ms)) diff --git a/src/eca/features/agents.clj b/src/eca/features/agents.clj index 383288813..0eabe6dbc 100644 --- a/src/eca/features/agents.clj +++ b/src/eca/features/agents.clj @@ -42,7 +42,7 @@ (get tools-map "ask") (assoc-in [:approval :ask] (tools-list->approval-map (get tools-map "ask")))))))) -(defn ^:private agent-md-file->agent +(defn agent-md-file->agent [md-file] (try (let [agent-name (string/lower-case (fs/strip-ext (fs/file-name md-file))) diff --git a/src/eca/features/commands.clj b/src/eca/features/commands.clj index 99182ad67..9d50847cc 100644 --- a/src/eca/features/commands.clj +++ b/src/eca/features/commands.clj @@ -8,6 +8,7 @@ [eca.db :as db] [eca.features.index :as f.index] [eca.features.login :as f.login] + [eca.features.plugins :as f.plugins] [eca.features.prompt :as f.prompt] [eca.features.skills :as f.skills] [eca.features.tools.mcp :as f.mcp] @@ -135,7 +136,15 @@ {:name "subagents" :type :native :description "List available subagents and their configuration." - :arguments []}] + :arguments []} + {:name "plugins" + :type :native + :description "List available plugins from configured marketplaces." + :arguments []} + {:name "plugin-install" + :type :native + :description "Install a plugin (e.g. /plugin-install my-plugin or /plugin-install my-plugin@marketplace)" + :arguments [{:name "plugin" :description "Plugin name or plugin@marketplace"}]}] custom-cmds (map (fn [custom] {:name (:name custom) :type :custom-prompt @@ -408,6 +417,35 @@ "subagents" (let [msg (subagents-msg config)] {:type :chat-messages :chats {chat-id {:messages [{:role "system" :content [{:type :text :text msg}]}]}}}) + "plugins" (let [plugins-config (:plugins config) + plugins (f.plugins/list-marketplace-plugins plugins-config) + msg (if (seq plugins) + (let [by-source (group-by :source-name plugins)] + (multi-str (reduce-kv + (fn [s source-name source-plugins] + (str s "**" source-name "** (`" (:source-url (first source-plugins)) "`)\n" + (reduce + (fn [s2 {:keys [name description installed?]}] + (str s2 "- " name + (when installed? " ✅") + (when description (str " — " description)) + "\n")) + "" + source-plugins) + "\n")) + "Plugins available:\n\n" + by-source) + "Use `/plugin-install ` to install a plugin.")) + "No plugin marketplaces configured. Add plugin sources to your config under the `plugins` key.")] + {:type :chat-messages + :chats {chat-id {:messages [{:role "system" :content [{:type :text :text msg}]}]}}}) + "plugin-install" (let [plugin-input (first args) + result (if (string/blank? plugin-input) + {:status :error + :message "Usage: `/plugin-install ` or `/plugin-install `"} + (f.plugins/install-plugin! (:plugins config) plugin-input))] + {:type :chat-messages + :chats {chat-id {:messages [{:role "system" :content [{:type :text :text (:message result)}]}]}}}) ;; else check if a custom command or skill (if-let [custom-command-prompt (get-custom-command command args custom-cmds)] diff --git a/src/eca/features/plugins.clj b/src/eca/features/plugins.clj new file mode 100644 index 000000000..71e744d1f --- /dev/null +++ b/src/eca/features/plugins.clj @@ -0,0 +1,397 @@ +(ns eca.features.plugins + "Plugin system for loading external configuration from git repos or local paths. + + Each source must contain .eca-plugin/marketplace.json listing available plugins. + Installed plugins are discovered for skills, agents, commands, rules, hooks, and MCP servers. + All components are returned as a config-ready data structure that config.clj merges + into the waterfall without requiring changes to individual feature modules." + (:require + [babashka.fs :as fs] + [babashka.process :as p] + [cheshire.core :as json] + [clojure.java.io :as io] + [clojure.string :as string] + [eca.cache :as cache] + [eca.config :as config] + [eca.features.agents :as agents] + [eca.logger :as logger] + [eca.shared :as shared])) + +(set! *warn-on-reflection* true) + +(def ^:private logger-tag "[PLUGINS]") + +(defn ^:private sanitize-source-url + "Converts a git URL into a human-readable directory name. + e.g. 'https://github.com/nubank/ai-agents-plugins.git' -> 'github.com-nubank-ai-agents-plugins'" + ^String [^String url] + (-> url + (string/replace #"^https?://" "") + (string/replace #"^git@" "") + (string/replace #"\.git$" "") + (string/replace #":" "-") + (string/replace #"/" "-"))) + +(defn ^:private source-cache-path + "Returns the local cache directory for a given source URL." + ^java.io.File [^String source-url] + (io/file (cache/plugins-dir) (sanitize-source-url source-url))) + +(defn ^:private git-url? + "Returns true if the source string looks like a git URL rather than a local path." + [^String source] + (or (string/starts-with? source "http://") + (string/starts-with? source "https://") + (string/starts-with? source "git@"))) + +(def ^:private git-timeout-ms 30000) + +(def ^:private pull-ttl-ms + "Minimum time between git pull attempts for the same source (1 hour)." + (* 60 60 1000)) + +(def ^:private last-pull-times (atom {})) + +(defn ^:private run-git! + "Runs a git command with a timeout and returns {:exit :out :err}." + [& args] + (try + (let [proc (apply p/process {:out :string :err :string} "git" args) + result (deref proc git-timeout-ms nil)] + (if result + {:exit (:exit result) + :out (:out result) + :err (:err result)} + (do (p/destroy-tree proc) + {:exit 1 :out "" :err (str "git operation timed out after " (/ git-timeout-ms 1000) "s")}))) + (catch Exception e + {:exit 1 :out "" :err (.getMessage e)}))) + +(defn ^:private pull-needed? + "Returns true if enough time has passed since the last pull for this source." + [^String source-url] + (let [last-pull (get @last-pull-times source-url 0) + now (System/currentTimeMillis)] + (> (- now last-pull) pull-ttl-ms))) + +(defn ^:private clone-or-pull! + "Clones a git repo if not cached, or pulls if already cached (respecting TTL). + Returns the local directory path or nil on failure." + [^String source-url] + (let [cache-dir (source-cache-path source-url)] + (if (fs/exists? (io/file cache-dir ".git")) + (if (pull-needed? source-url) + (let [{:keys [exit err]} (run-git! "-C" (str cache-dir) "pull" "--ff-only" "-q")] + (swap! last-pull-times assoc source-url (System/currentTimeMillis)) + (if (zero? exit) + (do (logger/info logger-tag "Updated plugin source:" source-url) + cache-dir) + (do (logger/warn logger-tag "Failed to update plugin source, using cached version:" + source-url err) + cache-dir))) + (do (logger/debug logger-tag "Plugin source recently pulled, using cached version:" source-url) + cache-dir)) + (do + (fs/create-dirs (fs/parent cache-dir)) + (let [{:keys [exit err]} (run-git! "clone" "--depth" "1" "-q" source-url (str cache-dir))] + (if (zero? exit) + (do (logger/info logger-tag "Cloned plugin source:" source-url) + (swap! last-pull-times assoc source-url (System/currentTimeMillis)) + cache-dir) + (do (logger/warn logger-tag "Failed to clone plugin source:" source-url err) + nil))))))) + +(defn ^:private resolve-source! + "Resolves a plugin source to a local directory. + For git URLs: clones/pulls to cache. For local paths: verifies existence. + Returns a File or nil." + [^String source] + (if (git-url? source) + (clone-or-pull! source) + (let [local-dir (io/file source)] + (if (fs/exists? local-dir) + (do (logger/debug logger-tag "Using local plugin source:" source) + local-dir) + (do (logger/warn logger-tag "Local plugin source not found:" source) + nil))))) + +(defn ^:private read-marketplace + "Reads and parses .eca-plugin/marketplace.json from a source directory. + Returns a vector of plugin entries or nil." + [^java.io.File source-dir] + (let [marketplace-file (io/file source-dir ".eca-plugin" "marketplace.json")] + (if (fs/exists? marketplace-file) + (try + (let [content (json/parse-string (slurp marketplace-file) true)] + (or (:plugins content) [])) + (catch Exception e + (logger/warn logger-tag "Failed to parse marketplace.json:" (str marketplace-file) + (.getMessage e)) + nil)) + (do (logger/warn logger-tag "No .eca-plugin/marketplace.json found in:" (str source-dir)) + nil)))) + +(defn ^:private find-plugin-entry + "Finds a plugin entry by name in a marketplace plugin list." + [^String plugin-name plugins] + (first (filter #(= plugin-name (:name %)) plugins))) + +(defn ^:private resolve-plugin-dir + "Resolves the absolute path to a plugin directory given a source dir and marketplace entry. + Returns a File or nil." + [^java.io.File source-dir {:keys [source path]}] + (let [relative-path (or source path)] + (when relative-path + (let [plugin-dir (io/file source-dir relative-path)] + (when (fs/exists? plugin-dir) + plugin-dir))))) + +;; -- Component readers -- + +(defn ^:private read-hooks + "Reads hooks/hooks.json from a plugin directory. Expects ECA native hook format." + [^java.io.File plugin-dir] + (let [hooks-file (io/file plugin-dir "hooks" "hooks.json")] + (when (fs/exists? hooks-file) + (try + (json/parse-string (slurp hooks-file) true) + (catch Exception e + (logger/warn logger-tag "Failed to parse hooks.json:" (str hooks-file) + (.getMessage e)) + nil))))) + +(defn ^:private read-mcp-servers + "Reads .mcp.json from a plugin directory and returns mcpServers map." + [^java.io.File plugin-dir] + (let [mcp-file (io/file plugin-dir ".mcp.json")] + (when (fs/exists? mcp-file) + (try + (let [content (json/parse-string (slurp mcp-file) true)] + (:mcpServers content)) + (catch Exception e + (logger/warn logger-tag "Failed to parse .mcp.json:" (str mcp-file) + (.getMessage e)) + nil))))) + +(defn ^:private read-eca-config + "Reads eca.json from a plugin directory for arbitrary ECA config overrides." + [^java.io.File plugin-dir] + (let [config-file (io/file plugin-dir "eca.json")] + (when (fs/exists? config-file) + (try + (json/parse-string (slurp config-file) true) + (catch Exception e + (logger/warn logger-tag "Failed to parse eca.json:" (str config-file) + (.getMessage e)) + nil))))) + +(defn ^:private read-agents + "Reads agents/*.md from a plugin directory and returns a map of {agent-name agent-config}. + Skips non-agent files like README.md." + [^java.io.File plugin-dir] + (let [agents-dir (io/file plugin-dir "agents")] + (when (fs/exists? agents-dir) + (->> (fs/glob agents-dir "*.md" {:follow-links true}) + (remove (fn [f] + (let [fname (string/lower-case (str (fs/file-name f)))] + (= fname "readme.md")))) + (keep agents/agent-md-file->agent) + (into {}))))) + +(defn ^:private read-commands + "Reads commands/*.md from a plugin directory and returns a vector of {:path ...} entries." + [^java.io.File plugin-dir] + (let [commands-dir (io/file plugin-dir "commands")] + (when (fs/exists? commands-dir) + (->> (fs/glob commands-dir "**" {:follow-links true}) + (keep (fn [file] + (when (and (not (fs/directory? file)) + (string/ends-with? (str (fs/file-name file)) ".md")) + {:path (str (fs/canonicalize file))}))) + vec)))) + +(defn ^:private read-rules + "Reads rules/** from a plugin directory and returns a vector of {:path ...} entries." + [^java.io.File plugin-dir] + (let [rules-dir (io/file plugin-dir "rules")] + (when (fs/exists? rules-dir) + (->> (fs/glob rules-dir "**" {:follow-links true}) + (keep (fn [file] + (when-not (fs/directory? file) + {:path (str (fs/canonicalize file))}))) + vec)))) + +(defn ^:private read-skill-dirs + "Returns skill directories from a plugin directory." + [^java.io.File plugin-dir] + (let [skills-dir (io/file plugin-dir "skills")] + (when (fs/exists? skills-dir) + [(str (fs/canonicalize skills-dir))]))) + +;; -- Discovery and resolution -- + +(defn ^:private discover-components + "Walks a plugin directory and discovers all components. + Returns a config-ready map: + {:config-fragment {...} — deep-mergeable into ECA config (mcpServers, hooks, pluginSkillDirs, eca.json overrides) + :agents {...} — agent-name -> agent-config map (merged alongside markdown agents) + :commands [{:path ...}] — appended to :commands config vector + :rules [{:path ...}]} — appended to :rules config vector" + [^java.io.File plugin-dir] + (let [mcp-servers (read-mcp-servers plugin-dir) + hooks (read-hooks plugin-dir) + eca-config (read-eca-config plugin-dir) + skill-dirs (read-skill-dirs plugin-dir) + config-fragment (cond-> (or eca-config {}) + (seq mcp-servers) (assoc :mcpServers mcp-servers) + (seq hooks) (assoc :hooks hooks) + (seq skill-dirs) (update :pluginSkillDirs (fnil into []) skill-dirs))] + {:config-fragment config-fragment + :agents (read-agents plugin-dir) + :commands (read-commands plugin-dir) + :rules (read-rules plugin-dir)})) + +(defn ^:private merge-components + "Merges components from multiple plugins into a single map. + Config fragments are deep-merged, but :pluginSkillDirs is concatenated (not replaced)." + [components-list] + (reduce + (fn [acc components] + (let [new-skill-dirs (get-in components [:config-fragment :pluginSkillDirs]) + fragment-rest (dissoc (:config-fragment components) :pluginSkillDirs)] + (-> acc + (update :config-fragment shared/deep-merge fragment-rest) + (cond-> + (seq new-skill-dirs) + (update-in [:config-fragment :pluginSkillDirs] (fnil into []) new-skill-dirs)) + (update :agents merge (:agents components)) + (update :commands into (:commands components)) + (update :rules into (:rules components))))) + {:config-fragment {} + :agents {} + :commands [] + :rules []} + components-list)) + +(defn ^:private parse-sources + "Extracts plugin sources from config, filtering out the install key. + Returns a seq of [source-name source-url] pairs." + [plugins-config] + (->> plugins-config + (remove (fn [[k _]] (= "install" (name k)))) + (keep (fn [[source-name source-config]] + (when-let [source-url (if (map? source-config) + (get source-config :source) + nil)] + [(name source-name) source-url]))))) + +(def ^:private empty-result + {:config-fragment {} :agents {} :commands [] :rules []}) + +(defn resolve-all! + "Main entry point: resolves all plugin sources, reads marketplaces, + discovers components from installed plugins. + Returns a merged result with :config-fragment, :agents, :commands, :rules." + [plugins-config] + (if (or (nil? plugins-config) (empty? plugins-config)) + empty-result + (let [auto-install (get plugins-config "install" []) + sources (parse-sources plugins-config)] + (if (empty? auto-install) + (do (logger/debug logger-tag "No plugins in install, skipping") + empty-result) + (let [components + (doall + (for [[source-name source-url] sources + :let [_ (logger/info logger-tag "Resolving plugin source:" source-name source-url) + source-dir (resolve-source! source-url)] + :when source-dir + :let [marketplace (read-marketplace source-dir)] + :when marketplace + plugin-name auto-install + :let [entry (find-plugin-entry plugin-name marketplace)] + :when (do (when-not entry + (logger/debug logger-tag "Plugin not found in source" source-name ":" plugin-name)) + entry) + :let [plugin-dir (resolve-plugin-dir source-dir entry)] + :when (do (when-not plugin-dir + (logger/warn logger-tag "Plugin directory not found:" plugin-name + "in" (str source-dir))) + plugin-dir)] + (do (logger/info logger-tag "Loading plugin:" plugin-name "from" source-name) + (discover-components plugin-dir))))] + (merge-components components)))))) + +(defn list-marketplace-plugins + "Lists all available plugins from configured marketplace sources. + Returns a seq of {:name :source-name :source-url :description :installed?} maps." + [plugins-config] + (when (seq plugins-config) + (let [installed-set (set (get plugins-config :install [])) + sources (parse-sources plugins-config)] + (doall + (for [[source-name source-url] sources + :let [source-dir (resolve-source! source-url)] + :when source-dir + :let [marketplace (read-marketplace source-dir)] + :when marketplace + plugin marketplace] + {:name (:name plugin) + :description (:description plugin) + :source-name source-name + :source-url source-url + :installed? (contains? installed-set (:name plugin))}))))) + +(defn ^:private parse-plugin-arg + "Parses a plugin install argument. Supports 'plugin-name' or 'plugin-name@marketplace'. + Returns {:plugin-name ... :marketplace ...} where :marketplace may be nil." + [^String arg] + (let [parts (string/split arg #"@" 2)] + {:plugin-name (first parts) + :marketplace (when (= 2 (count parts)) (second parts))})) + +(defn ^:private find-plugin-in-marketplaces + "Finds a plugin by name across all resolved marketplaces, optionally filtered by source name. + Returns {:name :source-name :source-url} or nil." + [plugins-config plugin-name marketplace-filter] + (let [sources (parse-sources plugins-config)] + (first + (for [[source-name source-url] sources + :when (or (nil? marketplace-filter) (= marketplace-filter source-name)) + :let [source-dir (resolve-source! source-url)] + :when source-dir + :let [marketplace (read-marketplace source-dir)] + :when marketplace + :let [entry (find-plugin-entry plugin-name marketplace)] + :when entry] + {:name plugin-name + :source-name source-name + :source-url source-url})))) + +(defn install-plugin! + "Installs a plugin by adding it to the global config install list. + `input` is either 'plugin-name' or 'plugin-name@marketplace'. + Returns {:status :ok/:error, :message ...}." + [plugins-config ^String input] + (let [{:keys [plugin-name marketplace]} (parse-plugin-arg input) + sources (parse-sources plugins-config) + current-install (set (get plugins-config :install []))] + (cond + (empty? sources) + {:status :error + :message "No plugin marketplaces configured. Add plugin sources to your config under the `plugins` key."} + + (contains? current-install plugin-name) + {:status :error + :message (str "Plugin `" plugin-name "` is already installed.")} + + :else + (if-let [found (find-plugin-in-marketplaces plugins-config plugin-name marketplace)] + (let [new-install (vec (sort (conj current-install plugin-name)))] + (config/update-global-config! {:plugins {:install new-install}}) + {:status :ok + :message (str "Plugin `" plugin-name "` installed from **" (:source-name found) "**. Restart ECA to activate it.")}) + {:status :error + :message (if marketplace + (str "Plugin `" plugin-name "` not found in marketplace `" marketplace "`.") + (str "Plugin `" plugin-name "` not found in any configured marketplace."))})))) diff --git a/src/eca/features/skills.clj b/src/eca/features/skills.clj index ed1a18491..3c1b18a13 100644 --- a/src/eca/features/skills.clj +++ b/src/eca/features/skills.clj @@ -36,8 +36,17 @@ (fs/glob skills-dir "**/SKILL.md" {:follow-links true}))))) (keep skill-file->skill))) +(defn ^:private plugin-skills [plugin-skill-dirs] + (->> plugin-skill-dirs + (mapcat (fn [dir] + (let [dir (if (string? dir) (fs/file dir) dir)] + (when (fs/exists? dir) + (fs/glob dir "**/SKILL.md" {:follow-links true}))))) + (keep skill-file->skill))) + (defn all [config roots] (concat [] (when-not (:pureConfig config) (global-skills)) + (plugin-skills (:pluginSkillDirs config)) (local-skills roots))) diff --git a/src/eca/handlers.clj b/src/eca/handlers.clj index 7d1644a05..bfab5093f 100644 --- a/src/eca/handlers.clj +++ b/src/eca/handlers.clj @@ -7,6 +7,7 @@ [eca.features.completion :as f.completion] [eca.features.hooks :as f.hooks] [eca.features.login :as f.login] + [eca.features.plugins :as f.plugins] [eca.features.rewrite :as f.rewrite] [eca.features.tools :as f.tools] [eca.features.tools.mcp :as f.mcp] @@ -104,7 +105,15 @@ error)}})) (config/listen-for-changes! db*)) (future - (f.tools/init-servers! db* messenger config metrics)) + ;; Resolve plugins before MCP init so plugin-provided servers are included + (try + (let [plugins-config (:plugins config)] + (when (seq plugins-config) + (reset! config/plugin-components* (f.plugins/resolve-all! plugins-config)))) + (catch Exception e + (logger/warn "[PLUGINS]" "Plugin resolution failed:" (.getMessage e)))) + (let [config (config/all @db*)] + (f.tools/init-servers! db* messenger config metrics))) (future (cache/cleanup-tool-call-outputs!)) ;; Trigger sessionStart hook after initialization diff --git a/test/eca/features/plugins_test.clj b/test/eca/features/plugins_test.clj new file mode 100644 index 000000000..574ea44f1 --- /dev/null +++ b/test/eca/features/plugins_test.clj @@ -0,0 +1,231 @@ +(ns eca.features.plugins-test + (:require + [babashka.fs :as fs] + [cheshire.core :as json] + [clojure.test :refer [deftest is testing]] + [eca.features.plugins :as plugins] + [matcher-combinators.matchers :as m] + [matcher-combinators.test :refer [match?]])) + +(deftest sanitize-source-url-test + (testing "HTTPS URL" + (is (= "github.com-nubank-ai-agents-plugins" + (#'plugins/sanitize-source-url "https://github.com/nubank/ai-agents-plugins.git")))) + (testing "SSH URL" + (is (= "github.com-nubank-ai-agents-plugins" + (#'plugins/sanitize-source-url "git@github.com:nubank/ai-agents-plugins.git")))) + (testing "GitLab URL" + (is (= "gitlab.com-org-repo" + (#'plugins/sanitize-source-url "https://gitlab.com/org/repo.git"))))) + +(deftest git-url?-test + (testing "recognizes HTTPS URLs" + (is (true? (#'plugins/git-url? "https://github.com/org/repo.git")))) + (testing "recognizes SSH URLs" + (is (true? (#'plugins/git-url? "git@github.com:org/repo.git")))) + (testing "rejects local paths" + (is (false? (#'plugins/git-url? "/home/user/plugins"))) + (is (false? (#'plugins/git-url? "./relative/path"))))) + +(deftest read-marketplace-test + (let [tmp-dir (fs/create-temp-dir)] + (try + (testing "reads valid marketplace.json" + (let [eca-plugin-dir (fs/file tmp-dir ".eca-plugin")] + (fs/create-dirs eca-plugin-dir) + (spit (fs/file eca-plugin-dir "marketplace.json") + (json/generate-string + {:plugins [{:name "test-plugin" + :description "A test plugin" + :source "./plugins/test/test-plugin" + :category "development" + :version "1.0.0"} + {:name "mcp-plugin" + :description "An MCP plugin" + :source "./plugins/test/mcp-plugin" + :category "mcp" + :version "1.0.0" + :mcpServers "./plugins/test/mcp-plugin/.mcp.json"}]})) + (is (match? [{:name "test-plugin" + :description "A test plugin" + :source "./plugins/test/test-plugin"} + {:name "mcp-plugin" + :source "./plugins/test/mcp-plugin"}] + (#'plugins/read-marketplace (fs/file tmp-dir)))))) + + (testing "returns nil for missing marketplace.json" + (let [empty-dir (fs/file tmp-dir "empty")] + (fs/create-dirs empty-dir) + (is (nil? (#'plugins/read-marketplace (fs/file empty-dir)))))) + + (testing "returns nil for malformed JSON" + (let [bad-dir (fs/file tmp-dir "bad")] + (fs/create-dirs (fs/file bad-dir ".eca-plugin")) + (spit (fs/file bad-dir ".eca-plugin" "marketplace.json") "{invalid json") + (is (nil? (#'plugins/read-marketplace (fs/file bad-dir)))))) + (finally + (fs/delete-tree tmp-dir))))) + +(deftest discover-components-test + (let [tmp-dir (fs/create-temp-dir) + plugin-dir (fs/file tmp-dir "test-plugin")] + (try + (testing "discovers skills dir in config-fragment" + (fs/create-dirs (fs/file plugin-dir "skills" "my-skill")) + (spit (fs/file plugin-dir "skills" "my-skill" "SKILL.md") + "---\nname: my-skill\ndescription: Test\n---\nBody") + (let [result (#'plugins/discover-components (fs/file plugin-dir))] + (is (= 1 (count (get-in result [:config-fragment :pluginSkillDirs])))))) + + (testing "discovers agents as parsed config" + (fs/create-dirs (fs/file plugin-dir "agents")) + (spit (fs/file plugin-dir "agents" "test-agent.md") + "---\nname: test-agent\ndescription: A helper agent\n---\nAgent prompt body") + (let [result (#'plugins/discover-components (fs/file plugin-dir))] + (is (match? {"test-agent" {:description "A helper agent" + :systemPrompt "Agent prompt body"}} + (:agents result))))) + + (testing "skips README.md in agents dir" + (spit (fs/file plugin-dir "agents" "README.md") "# Docs") + (let [result (#'plugins/discover-components (fs/file plugin-dir))] + (is (not (contains? (:agents result) "readme"))))) + + (testing "discovers commands as path entries" + (fs/create-dirs (fs/file plugin-dir "commands")) + (spit (fs/file plugin-dir "commands" "my-cmd.md") + "---\ndescription: A command\n---\nCommand body") + (let [result (#'plugins/discover-components (fs/file plugin-dir))] + (is (= 1 (count (:commands result)))) + (is (string? (:path (first (:commands result))))))) + + (testing "discovers rules as path entries" + (fs/create-dirs (fs/file plugin-dir "rules")) + (spit (fs/file plugin-dir "rules" "my-rule.mdc") + "---\ndescription: A rule\nglobs: \"**/*.md\"\n---\nRule body") + (let [result (#'plugins/discover-components (fs/file plugin-dir))] + (is (= 1 (count (:rules result)))) + (is (string? (:path (first (:rules result))))))) + + (testing "discovers MCP servers in config-fragment" + (spit (fs/file plugin-dir ".mcp.json") + (json/generate-string + {:mcpServers {"test-server" {:type "http" + :url "https://example.com/mcp"}}})) + (let [result (#'plugins/discover-components (fs/file plugin-dir))] + (is (match? {:test-server {:url "https://example.com/mcp"}} + (get-in result [:config-fragment :mcpServers]))))) + + (testing "discovers eca.json config overrides in config-fragment" + (spit (fs/file plugin-dir "eca.json") + (json/generate-string {:mcpTimeoutSeconds 30})) + (let [result (#'plugins/discover-components (fs/file plugin-dir))] + (is (= 30 (get-in result [:config-fragment :mcpTimeoutSeconds]))))) + + (testing "returns empty for non-existent directories" + (let [empty-plugin (fs/file tmp-dir "empty-plugin")] + (fs/create-dirs empty-plugin) + (let [result (#'plugins/discover-components (fs/file empty-plugin))] + (is (empty? (:agents result))) + (is (empty? (:commands result))) + (is (empty? (:rules result))) + (is (nil? (get-in result [:config-fragment :mcpServers])))))) + (finally + (fs/delete-tree tmp-dir))))) + +(deftest parse-sources-test + (testing "extracts source entries, filtering install" + (let [config {:nubank {:source "https://github.com/nubank/ai-agents-plugins.git"} + :local {:source "/home/user/plugins"} + :install ["plugin-a" "plugin-b"]}] + (is (match? (m/in-any-order [["nubank" "https://github.com/nubank/ai-agents-plugins.git"] + ["local" "/home/user/plugins"]]) + (#'plugins/parse-sources config))))) + + (testing "returns empty for no sources" + (is (empty? (#'plugins/parse-sources {:install ["plugin-a"]}))))) + +(deftest resolve-all!-test + (let [tmp-dir (fs/create-temp-dir)] + (try + (testing "full resolution with local source" + (let [source-dir (fs/file tmp-dir "repo") + plugin-dir (fs/file source-dir "plugins" "test" "my-plugin")] + (fs/create-dirs (fs/file source-dir ".eca-plugin")) + (fs/create-dirs (fs/file plugin-dir "skills" "hello")) + (fs/create-dirs (fs/file plugin-dir "agents")) + (fs/create-dirs (fs/file plugin-dir "commands")) + (spit (fs/file source-dir ".eca-plugin" "marketplace.json") + (json/generate-string + {:plugins [{:name "my-plugin" + :description "Test" + :source "./plugins/test/my-plugin"}]})) + (spit (fs/file plugin-dir "skills" "hello" "SKILL.md") + "---\nname: hello\ndescription: Greet\n---\nSay hello") + (spit (fs/file plugin-dir "agents" "helper.md") + "---\nname: helper\ndescription: Helps\n---\nHelp prompt") + (spit (fs/file plugin-dir "commands" "do-thing.md") + "---\ndescription: Does a thing\n---\nDo the thing") + (spit (fs/file plugin-dir ".mcp.json") + (json/generate-string + {:mcpServers {"test-mcp" {:type "http" + :url "https://example.com/mcp"}}})) + (let [result (plugins/resolve-all! + {"my-source" {:source (str source-dir)} + "install" ["my-plugin"]})] + (is (= 1 (count (get-in result [:config-fragment :pluginSkillDirs])))) + (is (match? {:test-mcp {:url "https://example.com/mcp"}} + (get-in result [:config-fragment :mcpServers]))) + (is (match? {"helper" {:description "Helps"}} (:agents result))) + (is (= 1 (count (:commands result))))))) + + (testing "returns empty for nil config" + (let [result (plugins/resolve-all! nil)] + (is (empty? (:agents result))) + (is (empty? (:commands result))))) + + (testing "returns empty for empty install" + (let [result (plugins/resolve-all! + {"my-source" {:source "/some/path"} + "install" []})] + (is (empty? (:agents result))))) + + (testing "skips missing plugins gracefully" + (let [source-dir (fs/file tmp-dir "repo2")] + (fs/create-dirs (fs/file source-dir ".eca-plugin")) + (spit (fs/file source-dir ".eca-plugin" "marketplace.json") + (json/generate-string {:plugins [{:name "exists" :source "./plugins/exists"}]})) + (let [result (plugins/resolve-all! + {"src" {:source (str source-dir)} + "install" ["does-not-exist"]})] + (is (empty? (:agents result)))))) + (finally + (fs/delete-tree tmp-dir))))) + +(deftest merge-components-test + (testing "merges multiple plugin components" + (let [c1 {:config-fragment {:mcpServers {:server-a {:url "http://a"}} + :hooks {"hook-a" {:type "postToolCall"}} + :pluginSkillDirs ["/a/skills"] + :mcpTimeoutSeconds 30} + :agents {"agent-a" {:description "A"}} + :commands [{:path "/a/commands/cmd.md"}] + :rules []} + c2 {:config-fragment {:mcpServers {:server-b {:url "http://b"}} + :pluginSkillDirs ["/b/skills"] + :mcpTimeoutSeconds 60} + :agents {"agent-b" {:description "B"}} + :commands [] + :rules [{:path "/b/rules/rule.mdc"}]} + result (#'plugins/merge-components [c1 c2])] + (is (= ["/a/skills" "/b/skills"] + (get-in result [:config-fragment :pluginSkillDirs]))) + (is (match? {:server-a {:url "http://a"} + :server-b {:url "http://b"}} + (get-in result [:config-fragment :mcpServers]))) + (is (= 60 (get-in result [:config-fragment :mcpTimeoutSeconds]))) + (is (match? {"agent-a" {:description "A"} + "agent-b" {:description "B"}} + (:agents result))) + (is (= [{:path "/a/commands/cmd.md"}] (:commands result))) + (is (= [{:path "/b/rules/rule.mdc"}] (:rules result)))))) diff --git a/test/eca/test_helper.clj b/test/eca/test_helper.clj index 0cb1be600..e64df4b53 100644 --- a/test/eca/test_helper.clj +++ b/test/eca/test_helper.clj @@ -60,6 +60,7 @@ (defn reset-components! [] (reset! config/initialization-config* {}) + (reset! config/plugin-components* nil) (reset! components* (make-components)) ;; Set default workspace folder for tests (swap! (db*) assoc :workspace-folders [{:uri (shared/filename->uri (System/getProperty "user.dir"))}]))