From 888d2ac2e350c357b9369780c64eff112589026f Mon Sep 17 00:00:00 2001 From: Jakub Zika Date: Tue, 28 Apr 2026 17:41:40 +0200 Subject: [PATCH] Add ${plugin:root} dynamic interpolation for plugin config, hooks, commands, and rules --- CHANGELOG.md | 1 + src/eca/features/commands.clj | 6 ++- src/eca/features/plugins.clj | 20 ++++++--- src/eca/features/rules.clj | 10 +++-- src/eca/interpolation.clj | 50 +++++++++++++++++++++- test/eca/features/plugins_test.clj | 68 +++++++++++++++++++++++++++++- test/eca/interpolation_test.clj | 30 ++++++++++--- 7 files changed, 166 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d05807100..a7234508e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Add `${plugin:root}` dynamic interpolation for plugin-provided config, hooks, commands, and rules. - Bugfix: avoid `Divide by zero` crash in chat auto-compact when models.dev reports `0` for a model's context/output limits (e.g. `openai/chatgpt-image-latest`); such limits are now normalized to `nil` and `auto-compact?` skips models without a known positive context window. ## 0.130.1 diff --git a/src/eca/features/commands.clj b/src/eca/features/commands.clj index d1130c640..f4aa60a03 100644 --- a/src/eca/features/commands.clj +++ b/src/eca/features/commands.clj @@ -14,6 +14,7 @@ [eca.features.skills :as f.skills] [eca.features.tools.mcp :as f.mcp] [eca.features.tools.util :as tools.util] + [eca.interpolation :as interpolation] [eca.llm-api :as llm-api] [eca.llm-util :as llm-util] [eca.messenger :as messenger] @@ -56,13 +57,14 @@ :else [path])) (defn ^:private command-file->command [type file opts] - (let [base (normalize-command-name file)] + (let [base (normalize-command-name file) + content (interpolation/replace-dynamic-strings (slurp (str file)) (str (fs/parent file)) nil)] (cond-> {:name (if-let [plugin (:plugin opts)] (prefixed-command-name plugin base) base) :path (str (fs/canonicalize file)) :type type - :content (slurp (str file))} + :content content} (:plugin opts) (assoc :plugin (:plugin opts))))) (defn ^:private global-file-commands [] diff --git a/src/eca/features/plugins.clj b/src/eca/features/plugins.clj index 66a3f5dbf..d0edf19e6 100644 --- a/src/eca/features/plugins.clj +++ b/src/eca/features/plugins.clj @@ -14,6 +14,7 @@ [eca.cache :as cache] [eca.config :as config] [eca.features.agents :as agents] + [eca.interpolation :as interpolation] [eca.logger :as logger] [eca.shared :as shared])) @@ -149,24 +150,28 @@ ;; -- Component readers -- (defn ^:private read-hooks - "Reads hooks/hooks.json from a plugin directory. Expects ECA native hook format." + "Reads hooks/hooks.json from a plugin directory. Expects ECA native hook format. + Applies dynamic string interpolation after JSON parsing." [^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) + (-> (json/parse-string (slurp hooks-file) true) + (interpolation/replace-dynamic-strings-in-data (str (fs/parent hooks-file)) nil)) (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." + "Reads .mcp.json from a plugin directory and returns mcpServers map. + Applies dynamic string interpolation after JSON parsing." [^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)] + (let [content (-> (json/parse-string (slurp mcp-file) true) + (interpolation/replace-dynamic-strings-in-data (str plugin-dir) nil))] (:mcpServers content)) (catch Exception e (logger/warn logger-tag "Failed to parse .mcp.json:" (str mcp-file) @@ -174,12 +179,14 @@ nil))))) (defn ^:private read-eca-config - "Reads eca.json from a plugin directory for arbitrary ECA config overrides." + "Reads eca.json from a plugin directory for arbitrary ECA config overrides. + Applies dynamic string interpolation after JSON parsing." [^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) + (-> (json/parse-string (slurp config-file) true) + (interpolation/replace-dynamic-strings-in-data (str plugin-dir) nil)) (catch Exception e (logger/warn logger-tag "Failed to parse eca.json:" (str config-file) (.getMessage e)) @@ -328,6 +335,7 @@ "in" (str source-dir))) plugin-dir)] (do (logger/info logger-tag "Loading plugin:" plugin-name "from" source-name) + (interpolation/register-plugin-dir! (str plugin-dir)) (discover-components plugin-dir plugin-name))))] (merge-components components)))))) diff --git a/src/eca/features/rules.clj b/src/eca/features/rules.clj index 69d31912a..bae4165b5 100644 --- a/src/eca/features/rules.clj +++ b/src/eca/features/rules.clj @@ -4,6 +4,7 @@ [clojure.string :as string] [babashka.fs :as fs] [eca.config :as config] + [eca.interpolation :as interpolation] [eca.logger :as logger] [eca.shared :as shared :refer [assoc-some]]) (:import @@ -136,10 +137,11 @@ :else [path])) (defn ^:private rule-file [type file opts] - (rule-file->rule type - (fs/canonicalize file) - (slurp (str file)) - {:workspace-root (:workspace-root opts)})) + (let [content (interpolation/replace-dynamic-strings (slurp (str file)) (str (fs/parent file)) nil)] + (rule-file->rule type + (fs/canonicalize file) + content + {:workspace-root (:workspace-root opts)}))) (defn ^:private global-file-rules [] (let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME") diff --git a/src/eca/interpolation.clj b/src/eca/interpolation.clj index 5692af51b..11c295423 100644 --- a/src/eca/interpolation.clj +++ b/src/eca/interpolation.clj @@ -18,13 +18,43 @@ [babashka.process :as p] [clojure.java.io :as io] [clojure.string :as string] + [clojure.walk :as walk] [eca.logger :as logger] + [eca.shared :as shared] [eca.secrets :as secrets])) (set! *warn-on-reflection* true) (def ^:private logger-tag "[INTERPOLATION]") +(defonce ^:private plugin-dirs* + (atom #{})) + +(defn register-plugin-dir! + "Registers a plugin directory path for `${plugin:root}` resolution. + Called by the plugin system when loading plugins." + [^String dir] + (swap! plugin-dirs* conj (shared/normalize-path dir))) + +(defn reset-plugin-dirs! + "Clears all registered plugin directories. Test helper." + [] + (reset! plugin-dirs* #{})) + +(defn ^:private matching-plugin-dir + "Given a file path (the cwd of the file being interpolated), returns the + plugin directory that contains it, or nil if the file is not inside any + registered plugin directory." + [^String file-path] + (when (and file-path (seq @plugin-dirs*)) + (let [normalized (shared/normalize-path file-path) + separator (System/getProperty "file.separator")] + (->> @plugin-dirs* + (some (fn [dir] + (when (or (= normalized dir) + (string/starts-with? normalized (str dir separator))) + dir))))))) + (defn get-env [env] (System/getenv env)) (def ^:private cmd-default-timeout-ms 30000) @@ -206,9 +236,17 @@ - `${file:/some/path}`: Replace with a file content checking from cwd if relative - `${classpath:path/to/file}`: Replace with a file content found checking classpath - `${netrc:api.provider.com}`: Replace with the content from Unix net RC [credential files](https://eca.dev/config/models/#credential-file-authentication) - - `${cmd:some command}`: Replace with the trimmed stdout of an arbitrary shell command" + - `${cmd:some command}`: Replace with the trimmed stdout of an arbitrary shell command + - `${plugin:root}`: Replace with the absolute path of the plugin directory containing + the file being interpolated. Resolved by checking `cwd` against registered plugin dirs. + If the file is not inside a plugin, the placeholder is replaced with an empty string." [s cwd config] (some-> s + (string/replace #"\$\{plugin:root\}" + (fn [_match] + (or (matching-plugin-dir (some-> cwd str)) + (do (logger/warn logger-tag "No plugin directory found for ${plugin:root} in:" (str cwd)) + "")))) (string/replace #"\$\{env:([^:}]+)(?::([^}]*))?\}" (fn [[_match env-var default-value]] (or (get-env env-var) default-value ""))) @@ -245,3 +283,13 @@ (catch Exception e (logger/warn logger-tag "Error executing cmd:" (.getMessage e)) "")))))) + +(defn replace-dynamic-strings-in-data + "Walks data and applies dynamic string interpolation to every string value." + [data cwd config] + (walk/postwalk + (fn [x] + (if (string? x) + (replace-dynamic-strings x cwd config) + x)) + data)) diff --git a/test/eca/features/plugins_test.clj b/test/eca/features/plugins_test.clj index 6a1b08ce2..f6aed2530 100644 --- a/test/eca/features/plugins_test.clj +++ b/test/eca/features/plugins_test.clj @@ -2,12 +2,23 @@ (:require [babashka.fs :as fs] [cheshire.core :as json] - [clojure.test :refer [deftest is testing]] + [clojure.test :refer [deftest is testing use-fixtures]] [eca.config :as config] + [eca.features.commands :as commands] [eca.features.plugins :as plugins] + [eca.features.rules :as rules] + [eca.interpolation :as interpolation] [matcher-combinators.matchers :as m] [matcher-combinators.test :refer [match?]])) +(use-fixtures :each + (fn [t] + (interpolation/reset-plugin-dirs!) + (try + (t) + (finally + (interpolation/reset-plugin-dirs!))))) + (deftest sanitize-source-url-test (testing "HTTPS URL" (is (= "github.com-my-org-my-plugins" @@ -219,6 +230,61 @@ (finally (fs/delete-tree tmp-dir))))) +(deftest plugin-root-interpolation-test + (let [tmp-dir (fs/create-temp-dir)] + (try + (let [source-dir (fs/file tmp-dir "repo") + plugin-dir (fs/file source-dir "plugins" "test" "demo") + secret "line \"quoted\"\nbackslash \\ ok"] + (fs/create-dirs (fs/file source-dir ".eca-plugin")) + (fs/create-dirs (fs/file plugin-dir "hooks")) + (fs/create-dirs (fs/file plugin-dir "commands")) + (fs/create-dirs (fs/file plugin-dir "rules")) + (spit (fs/file source-dir ".eca-plugin" "marketplace.json") + (json/generate-string + {:plugins [{:name "demo" + :description "Demo" + :source "./plugins/test/demo"}]})) + (spit (fs/file plugin-dir "secret.txt") secret) + (spit (fs/file plugin-dir ".mcp.json") + (json/generate-string + {:mcpServers {"local" {:command "${plugin:root}/bin/server"}}})) + (spit (fs/file plugin-dir "eca.json") + (json/generate-string + {:pluginRoot "${plugin:root}" + :quotedSecret "${file:secret.txt}"})) + (spit (fs/file plugin-dir "hooks" "hooks.json") + (json/generate-string + {:PostToolUse [{:hooks [{:type "command" + :command "node ${plugin:root}/hooks/check.js"}]}]})) + (spit (fs/file plugin-dir "commands" "where.md") + "Plugin command: ${plugin:root}") + (spit (fs/file plugin-dir "rules" "where.md") + "Plugin rule: ${plugin:root}") + (let [plugin-root (str (fs/canonicalize plugin-dir)) + result (plugins/resolve-all! + {"local" {:source (str source-dir)} + "install" ["demo"]})] + (is (= (str plugin-root "/bin/server") + (get-in result [:config-fragment :mcpServers :local :command]))) + (is (= plugin-root + (get-in result [:config-fragment :pluginRoot]))) + (is (= secret + (get-in result [:config-fragment :quotedSecret]))) + (is (= (str "node " plugin-root "/hooks/check.js") + (get-in result [:config-fragment :hooks :PostToolUse 0 :hooks 0 :command]))) + (let [loaded-commands (vec (#'commands/custom-commands + {:pureConfig true + :commands (:commands result)} + []))] + (is (= [(str "Plugin command: " plugin-root)] + (mapv :content loaded-commands)))) + (let [loaded-rules (vec (#'rules/config-rules {:rules (:rules result)} []))] + (is (= [(str "Plugin rule: " plugin-root)] + (mapv :content loaded-rules)))))) + (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"}} diff --git a/test/eca/interpolation_test.clj b/test/eca/interpolation_test.clj index fba0c5fed..b7d9819f8 100644 --- a/test/eca/interpolation_test.clj +++ b/test/eca/interpolation_test.clj @@ -1,17 +1,37 @@ (ns eca.interpolation-test (:require + [babashka.fs :as fs] [clojure.test :refer [deftest is testing use-fixtures]] [eca.interpolation :as interpolation] [eca.logger :as logger])) -;; Reset the shell-PATH cache before every test so cache state never leaks -;; between tests. Tests that exercise the shell query path mock run-process! -;; (which short-circuits the query to nil via missing-delimiter parse failure) -;; or stub user-shell-path / query-user-shell-path directly. +;; Reset process-wide interpolation caches before every test so state never +;; leaks between tests. Tests that exercise the shell query path mock +;; run-process! (which short-circuits the query to nil via missing-delimiter +;; parse failure) or stub user-shell-path / query-user-shell-path directly. (use-fixtures :each (fn [t] (interpolation/reset-shell-path-cache!) - (t))) + (interpolation/reset-plugin-dirs!) + (try + (t) + (finally + (interpolation/reset-plugin-dirs!))))) + +(deftest plugin-root-interpolation-test + (let [tmp-dir (fs/create-temp-dir) + plugin-dir (fs/file tmp-dir "plugin") + nested-dir (fs/file plugin-dir "commands")] + (try + (fs/create-dirs nested-dir) + (let [plugin-root (str (fs/canonicalize plugin-dir))] + (interpolation/register-plugin-dir! (str plugin-dir)) + (is (= (str "root=" plugin-root) + (interpolation/replace-dynamic-strings "root=${plugin:root}" plugin-dir nil))) + (is (= (str "root=" plugin-root) + (interpolation/replace-dynamic-strings "root=${plugin:root}" nested-dir nil)))) + (finally + (fs/delete-tree tmp-dir))))) (deftest augment-path-test (testing "non-mac OS: existing PATH returned unchanged"