Skip to content

Commit 0ff6453

Browse files
committed
Add ${plugin:root} dynamic interpolation for plugin config, hooks, commands, and rules
1 parent 6cb71c2 commit 0ff6453

7 files changed

Lines changed: 166 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Add configurable skill paths and recursive directory loading for configured rules, commands, and skills; local skills are also discovered from `.agents/skills`. #423
6+
- Add `${plugin:root}` dynamic interpolation for plugin-provided config, hooks, commands, and rules.
67

78
## 0.130.0
89

src/eca/features/commands.clj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
[eca.features.skills :as f.skills]
1515
[eca.features.tools.mcp :as f.mcp]
1616
[eca.features.tools.util :as tools.util]
17+
[eca.interpolation :as interpolation]
1718
[eca.llm-api :as llm-api]
1819
[eca.llm-util :as llm-util]
1920
[eca.messenger :as messenger]
@@ -56,13 +57,14 @@
5657
:else [path]))
5758

5859
(defn ^:private command-file->command [type file opts]
59-
(let [base (normalize-command-name file)]
60+
(let [base (normalize-command-name file)
61+
content (interpolation/replace-dynamic-strings (slurp (str file)) (str (fs/parent file)) nil)]
6062
(cond-> {:name (if-let [plugin (:plugin opts)]
6163
(prefixed-command-name plugin base)
6264
base)
6365
:path (str (fs/canonicalize file))
6466
:type type
65-
:content (slurp (str file))}
67+
:content content}
6668
(:plugin opts) (assoc :plugin (:plugin opts)))))
6769

6870
(defn ^:private global-file-commands []

src/eca/features/plugins.clj

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
[eca.cache :as cache]
1515
[eca.config :as config]
1616
[eca.features.agents :as agents]
17+
[eca.interpolation :as interpolation]
1718
[eca.logger :as logger]
1819
[eca.shared :as shared]))
1920

@@ -149,37 +150,43 @@
149150
;; -- Component readers --
150151

151152
(defn ^:private read-hooks
152-
"Reads hooks/hooks.json from a plugin directory. Expects ECA native hook format."
153+
"Reads hooks/hooks.json from a plugin directory. Expects ECA native hook format.
154+
Applies dynamic string interpolation after JSON parsing."
153155
[^java.io.File plugin-dir]
154156
(let [hooks-file (io/file plugin-dir "hooks" "hooks.json")]
155157
(when (fs/exists? hooks-file)
156158
(try
157-
(json/parse-string (slurp hooks-file) true)
159+
(-> (json/parse-string (slurp hooks-file) true)
160+
(interpolation/replace-dynamic-strings-in-data (str (fs/parent hooks-file)) nil))
158161
(catch Exception e
159162
(logger/warn logger-tag "Failed to parse hooks.json:" (str hooks-file)
160163
(.getMessage e))
161164
nil)))))
162165

163166
(defn ^:private read-mcp-servers
164-
"Reads .mcp.json from a plugin directory and returns mcpServers map."
167+
"Reads .mcp.json from a plugin directory and returns mcpServers map.
168+
Applies dynamic string interpolation after JSON parsing."
165169
[^java.io.File plugin-dir]
166170
(let [mcp-file (io/file plugin-dir ".mcp.json")]
167171
(when (fs/exists? mcp-file)
168172
(try
169-
(let [content (json/parse-string (slurp mcp-file) true)]
173+
(let [content (-> (json/parse-string (slurp mcp-file) true)
174+
(interpolation/replace-dynamic-strings-in-data (str plugin-dir) nil))]
170175
(:mcpServers content))
171176
(catch Exception e
172177
(logger/warn logger-tag "Failed to parse .mcp.json:" (str mcp-file)
173178
(.getMessage e))
174179
nil)))))
175180

176181
(defn ^:private read-eca-config
177-
"Reads eca.json from a plugin directory for arbitrary ECA config overrides."
182+
"Reads eca.json from a plugin directory for arbitrary ECA config overrides.
183+
Applies dynamic string interpolation after JSON parsing."
178184
[^java.io.File plugin-dir]
179185
(let [config-file (io/file plugin-dir "eca.json")]
180186
(when (fs/exists? config-file)
181187
(try
182-
(json/parse-string (slurp config-file) true)
188+
(-> (json/parse-string (slurp config-file) true)
189+
(interpolation/replace-dynamic-strings-in-data (str plugin-dir) nil))
183190
(catch Exception e
184191
(logger/warn logger-tag "Failed to parse eca.json:" (str config-file)
185192
(.getMessage e))
@@ -328,6 +335,7 @@
328335
"in" (str source-dir)))
329336
plugin-dir)]
330337
(do (logger/info logger-tag "Loading plugin:" plugin-name "from" source-name)
338+
(interpolation/register-plugin-dir! (str plugin-dir))
331339
(discover-components plugin-dir plugin-name))))]
332340
(merge-components components))))))
333341

src/eca/features/rules.clj

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
[clojure.string :as string]
55
[babashka.fs :as fs]
66
[eca.config :as config]
7+
[eca.interpolation :as interpolation]
78
[eca.logger :as logger]
89
[eca.shared :as shared :refer [assoc-some]])
910
(:import
@@ -136,10 +137,11 @@
136137
:else [path]))
137138

138139
(defn ^:private rule-file [type file opts]
139-
(rule-file->rule type
140-
(fs/canonicalize file)
141-
(slurp (str file))
142-
{:workspace-root (:workspace-root opts)}))
140+
(let [content (interpolation/replace-dynamic-strings (slurp (str file)) (str (fs/parent file)) nil)]
141+
(rule-file->rule type
142+
(fs/canonicalize file)
143+
content
144+
{:workspace-root (:workspace-root opts)})))
143145

144146
(defn ^:private global-file-rules []
145147
(let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME")

src/eca/interpolation.clj

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,43 @@
1818
[babashka.process :as p]
1919
[clojure.java.io :as io]
2020
[clojure.string :as string]
21+
[clojure.walk :as walk]
2122
[eca.logger :as logger]
23+
[eca.shared :as shared]
2224
[eca.secrets :as secrets]))
2325

2426
(set! *warn-on-reflection* true)
2527

2628
(def ^:private logger-tag "[INTERPOLATION]")
2729

30+
(defonce ^:private plugin-dirs*
31+
(atom #{}))
32+
33+
(defn register-plugin-dir!
34+
"Registers a plugin directory path for `${plugin:root}` resolution.
35+
Called by the plugin system when loading plugins."
36+
[^String dir]
37+
(swap! plugin-dirs* conj (shared/normalize-path dir)))
38+
39+
(defn reset-plugin-dirs!
40+
"Clears all registered plugin directories. Test helper."
41+
[]
42+
(reset! plugin-dirs* #{}))
43+
44+
(defn ^:private matching-plugin-dir
45+
"Given a file path (the cwd of the file being interpolated), returns the
46+
plugin directory that contains it, or nil if the file is not inside any
47+
registered plugin directory."
48+
[^String file-path]
49+
(when (and file-path (seq @plugin-dirs*))
50+
(let [normalized (shared/normalize-path file-path)
51+
separator (System/getProperty "file.separator")]
52+
(->> @plugin-dirs*
53+
(some (fn [dir]
54+
(when (or (= normalized dir)
55+
(string/starts-with? normalized (str dir separator)))
56+
dir)))))))
57+
2858
(defn get-env [env] (System/getenv env))
2959

3060
(def ^:private cmd-default-timeout-ms 30000)
@@ -206,9 +236,17 @@
206236
- `${file:/some/path}`: Replace with a file content checking from cwd if relative
207237
- `${classpath:path/to/file}`: Replace with a file content found checking classpath
208238
- `${netrc:api.provider.com}`: Replace with the content from Unix net RC [credential files](https://eca.dev/config/models/#credential-file-authentication)
209-
- `${cmd:some command}`: Replace with the trimmed stdout of an arbitrary shell command"
239+
- `${cmd:some command}`: Replace with the trimmed stdout of an arbitrary shell command
240+
- `${plugin:root}`: Replace with the absolute path of the plugin directory containing
241+
the file being interpolated. Resolved by checking `cwd` against registered plugin dirs.
242+
If the file is not inside a plugin, the placeholder is replaced with an empty string."
210243
[s cwd config]
211244
(some-> s
245+
(string/replace #"\$\{plugin:root\}"
246+
(fn [_match]
247+
(or (matching-plugin-dir (some-> cwd str))
248+
(do (logger/warn logger-tag "No plugin directory found for ${plugin:root} in:" (str cwd))
249+
""))))
212250
(string/replace #"\$\{env:([^:}]+)(?::([^}]*))?\}"
213251
(fn [[_match env-var default-value]]
214252
(or (get-env env-var) default-value "")))
@@ -245,3 +283,13 @@
245283
(catch Exception e
246284
(logger/warn logger-tag "Error executing cmd:" (.getMessage e))
247285
""))))))
286+
287+
(defn replace-dynamic-strings-in-data
288+
"Walks data and applies dynamic string interpolation to every string value."
289+
[data cwd config]
290+
(walk/postwalk
291+
(fn [x]
292+
(if (string? x)
293+
(replace-dynamic-strings x cwd config)
294+
x))
295+
data))

test/eca/features/plugins_test.clj

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,23 @@
22
(:require
33
[babashka.fs :as fs]
44
[cheshire.core :as json]
5-
[clojure.test :refer [deftest is testing]]
5+
[clojure.test :refer [deftest is testing use-fixtures]]
66
[eca.config :as config]
7+
[eca.features.commands :as commands]
78
[eca.features.plugins :as plugins]
9+
[eca.features.rules :as rules]
10+
[eca.interpolation :as interpolation]
811
[matcher-combinators.matchers :as m]
912
[matcher-combinators.test :refer [match?]]))
1013

14+
(use-fixtures :each
15+
(fn [t]
16+
(interpolation/reset-plugin-dirs!)
17+
(try
18+
(t)
19+
(finally
20+
(interpolation/reset-plugin-dirs!)))))
21+
1122
(deftest sanitize-source-url-test
1223
(testing "HTTPS URL"
1324
(is (= "github.com-my-org-my-plugins"
@@ -219,6 +230,61 @@
219230
(finally
220231
(fs/delete-tree tmp-dir)))))
221232

233+
(deftest plugin-root-interpolation-test
234+
(let [tmp-dir (fs/create-temp-dir)]
235+
(try
236+
(let [source-dir (fs/file tmp-dir "repo")
237+
plugin-dir (fs/file source-dir "plugins" "test" "demo")
238+
secret "line \"quoted\"\nbackslash \\ ok"]
239+
(fs/create-dirs (fs/file source-dir ".eca-plugin"))
240+
(fs/create-dirs (fs/file plugin-dir "hooks"))
241+
(fs/create-dirs (fs/file plugin-dir "commands"))
242+
(fs/create-dirs (fs/file plugin-dir "rules"))
243+
(spit (fs/file source-dir ".eca-plugin" "marketplace.json")
244+
(json/generate-string
245+
{:plugins [{:name "demo"
246+
:description "Demo"
247+
:source "./plugins/test/demo"}]}))
248+
(spit (fs/file plugin-dir "secret.txt") secret)
249+
(spit (fs/file plugin-dir ".mcp.json")
250+
(json/generate-string
251+
{:mcpServers {"local" {:command "${plugin:root}/bin/server"}}}))
252+
(spit (fs/file plugin-dir "eca.json")
253+
(json/generate-string
254+
{:pluginRoot "${plugin:root}"
255+
:quotedSecret "${file:secret.txt}"}))
256+
(spit (fs/file plugin-dir "hooks" "hooks.json")
257+
(json/generate-string
258+
{:PostToolUse [{:hooks [{:type "command"
259+
:command "node ${plugin:root}/hooks/check.js"}]}]}))
260+
(spit (fs/file plugin-dir "commands" "where.md")
261+
"Plugin command: ${plugin:root}")
262+
(spit (fs/file plugin-dir "rules" "where.md")
263+
"Plugin rule: ${plugin:root}")
264+
(let [plugin-root (str (fs/canonicalize plugin-dir))
265+
result (plugins/resolve-all!
266+
{"local" {:source (str source-dir)}
267+
"install" ["demo"]})]
268+
(is (= (str plugin-root "/bin/server")
269+
(get-in result [:config-fragment :mcpServers :local :command])))
270+
(is (= plugin-root
271+
(get-in result [:config-fragment :pluginRoot])))
272+
(is (= secret
273+
(get-in result [:config-fragment :quotedSecret])))
274+
(is (= (str "node " plugin-root "/hooks/check.js")
275+
(get-in result [:config-fragment :hooks :PostToolUse 0 :hooks 0 :command])))
276+
(let [loaded-commands (vec (#'commands/custom-commands
277+
{:pureConfig true
278+
:commands (:commands result)}
279+
[]))]
280+
(is (= [(str "Plugin command: " plugin-root)]
281+
(mapv :content loaded-commands))))
282+
(let [loaded-rules (vec (#'rules/config-rules {:rules (:rules result)} []))]
283+
(is (= [(str "Plugin rule: " plugin-root)]
284+
(mapv :content loaded-rules))))))
285+
(finally
286+
(fs/delete-tree tmp-dir)))))
287+
222288
(deftest merge-components-test
223289
(testing "merges multiple plugin components"
224290
(let [c1 {:config-fragment {:mcpServers {:server-a {:url "http://a"}}

test/eca/interpolation_test.clj

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,37 @@
11
(ns eca.interpolation-test
22
(:require
3+
[babashka.fs :as fs]
34
[clojure.test :refer [deftest is testing use-fixtures]]
45
[eca.interpolation :as interpolation]
56
[eca.logger :as logger]))
67

7-
;; Reset the shell-PATH cache before every test so cache state never leaks
8-
;; between tests. Tests that exercise the shell query path mock run-process!
9-
;; (which short-circuits the query to nil via missing-delimiter parse failure)
10-
;; or stub user-shell-path / query-user-shell-path directly.
8+
;; Reset process-wide interpolation caches before every test so state never
9+
;; leaks between tests. Tests that exercise the shell query path mock
10+
;; run-process! (which short-circuits the query to nil via missing-delimiter
11+
;; parse failure) or stub user-shell-path / query-user-shell-path directly.
1112
(use-fixtures :each
1213
(fn [t]
1314
(interpolation/reset-shell-path-cache!)
14-
(t)))
15+
(interpolation/reset-plugin-dirs!)
16+
(try
17+
(t)
18+
(finally
19+
(interpolation/reset-plugin-dirs!)))))
20+
21+
(deftest plugin-root-interpolation-test
22+
(let [tmp-dir (fs/create-temp-dir)
23+
plugin-dir (fs/file tmp-dir "plugin")
24+
nested-dir (fs/file plugin-dir "commands")]
25+
(try
26+
(fs/create-dirs nested-dir)
27+
(let [plugin-root (str (fs/canonicalize plugin-dir))]
28+
(interpolation/register-plugin-dir! (str plugin-dir))
29+
(is (= (str "root=" plugin-root)
30+
(interpolation/replace-dynamic-strings "root=${plugin:root}" plugin-dir nil)))
31+
(is (= (str "root=" plugin-root)
32+
(interpolation/replace-dynamic-strings "root=${plugin:root}" nested-dir nil))))
33+
(finally
34+
(fs/delete-tree tmp-dir)))))
1535

1636
(deftest augment-path-test
1737
(testing "non-mac OS: existing PATH returned unchanged"

0 commit comments

Comments
 (0)