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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/eca/features/commands.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 []
Expand Down
20 changes: 14 additions & 6 deletions src/eca/features/plugins.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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]))

Expand Down Expand Up @@ -149,37 +150,43 @@
;; -- 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)
(.getMessage e))
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))
Expand Down Expand Up @@ -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))))))

Expand Down
10 changes: 6 additions & 4 deletions src/eca/features/rules.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
50 changes: 49 additions & 1 deletion src/eca/interpolation.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 "")))
Expand Down Expand Up @@ -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))
68 changes: 67 additions & 1 deletion test/eca/features/plugins_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"}}
Expand Down
30 changes: 25 additions & 5 deletions test/eca/interpolation_test.clj
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading