Skip to content

Commit 48762c1

Browse files
committed
Support dynamic string content in configs
Related to #200
1 parent abf8028 commit 48762c1

File tree

5 files changed

+133
-7
lines changed

5 files changed

+133
-7
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Support dynamic string parse (`${file:/path/to/something}` and `${env:MY_ENV}`) in all configs with string values. #200
6+
57
## 0.82.1
68

79
- Fix custom tools output to return stderr when tool error. #219

docs/configuration.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ There are multiples ways to configure ECA:
4545
```bash
4646
ECA_CONFIG='{"myConfig": "my_value"}' eca server
4747
```
48+
49+
!!! info "Dynamic string contents"
50+
51+
It's possible to retrieve content of any configs with a string value using the `${key:value}` approach, being `key`:
52+
53+
- `file`: `${file:/path/to/my-file}` or `${file:../rel-path/to/my-file}` to get a file content
54+
- `env`: `${env:MY_ENV}` to get a system env value
4855

4956
## Providers / Models
5057

@@ -231,7 +238,7 @@ Placeholders in the format `{{argument_name}}` within the `command` string will
231238
{
232239
"customTools": {
233240
"file-search": {
234-
"description": "Finds files within a directory that match a specific name pattern.",
241+
"description": "${file:tools/my-tool.md}",
235242
"command": "find {{directory}} -name {{pattern}}",
236243
"schema": {
237244
"properties": {
@@ -373,6 +380,10 @@ Examples:
373380

374381
=== "Ring bell sound when pending tool call approval"
375382

383+
```javascript title="~/.config/eca/hooks/my-hook.sh"
384+
[[ $(jq '.approval == "ask"' <<< "$1") ]] && canberra-gtk-play -i complete
385+
```
386+
376387
```javascript title="~/.config/eca/config.json"
377388
{
378389
"hooks": {
@@ -382,7 +393,7 @@ Examples:
382393
"actions": [
383394
{
384395
"type": "shell",
385-
"shell": "[[ $(jq '.approval == \"ask\"' <<< \"$1\") ]] && canberra-gtk-play -i complete"
396+
"shell": "${file:hooks/my-hook.sh}"
386397
}
387398
]
388399
}

src/eca/config.clj

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88
99
When `:config-file` from cli option is passed, it uses that instead of searching default locations."
1010
(:require
11+
[babashka.fs :as fs]
1112
[camel-snake-kebab.core :as csk]
1213
[cheshire.core :as json]
1314
[cheshire.factory :as json.factory]
1415
[clojure.core.memoize :as memoize]
1516
[clojure.java.io :as io]
1617
[clojure.string :as string]
18+
[clojure.walk :as walk]
1719
[eca.logger :as logger]
1820
[eca.messenger :as messenger]
1921
[eca.shared :as shared :refer [multi-str]])
@@ -157,6 +159,35 @@
157159
(defn get-env [env] (System/getenv env))
158160
(defn get-property [property] (System/getProperty property))
159161

162+
(defn ^:private parse-dynamic-string
163+
"Given a string and a current working directory, look for patterns replacing its content:
164+
- `${env:SOME-ENV}`: Replace with a env
165+
- `${file:/some/path}`: Replace with a file content checking from cwd if relative"
166+
[s cwd]
167+
(some-> s
168+
(string/replace #"\$\{env:([^}]+)\}"
169+
(fn [[_match env-var]]
170+
(or (get-env env-var) "")))
171+
(string/replace #"\$\{file:([^}]+)\}"
172+
(fn [[_match file-path]]
173+
(try
174+
(slurp (str (if (fs/absolute? file-path)
175+
file-path
176+
(fs/path cwd file-path))))
177+
(catch Exception _
178+
(logger/warn logger-tag "File not found when parsing string:" s)
179+
""))))))
180+
181+
(defn ^:private parse-dynamic-string-values
182+
"walk through config parsing dynamic string contents if value is a string."
183+
[config cwd]
184+
(walk/postwalk
185+
(fn [x]
186+
(if (string? x)
187+
(parse-dynamic-string x cwd)
188+
x))
189+
config))
190+
160191
(def ^:private ttl-cache-config-ms 5000)
161192

162193
(defn ^:private safe-read-json-string [raw-string config-dyn-var]
@@ -194,7 +225,8 @@
194225
(defn ^:private config-from-global-file* []
195226
(let [config-file (global-config-file)]
196227
(when (.exists config-file)
197-
(safe-read-json-string (slurp config-file) (var *global-config-error*)))))
228+
(some-> (safe-read-json-string (slurp config-file) (var *global-config-error*))
229+
(parse-dynamic-string-values (global-config-dir))))))
198230

199231
(def ^:private config-from-global-file (memoize/ttl config-from-global-file* :ttl/threshold ttl-cache-config-ms))
200232

@@ -203,9 +235,11 @@
203235
(fn [final-config {:keys [uri]}]
204236
(merge
205237
final-config
206-
(let [config-file (io/file (shared/uri->filename uri) ".eca" "config.json")]
238+
(let [config-dir (io/file (shared/uri->filename uri) ".eca")
239+
config-file (io/file config-dir "config.json")]
207240
(when (.exists config-file)
208-
(safe-read-json-string (slurp config-file) (var *local-config-error*))))))
241+
(some-> (safe-read-json-string (slurp config-file) (var *local-config-error*))
242+
(parse-dynamic-string-values config-dir))))))
209243
{}
210244
roots))
211245

@@ -235,7 +269,7 @@
235269
[normalization-rules m]
236270
(let [kc-paths (set (:kebab-case normalization-rules))
237271
str-paths (set (:stringfy normalization-rules))
238-
; match a current path against a rule path with :ANY wildcard
272+
; match a current path against a rule path with :ANY wildcard
239273
matches-path? (fn [rule-path cur-path]
240274
(and (= (count rule-path) (count cur-path))
241275
(every? true?

src/eca/features/tools/custom.clj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
(:require
33
[babashka.process :as p]
44
[clojure.string :as string]
5-
[eca.features.tools.util :as tools.util]
65
[eca.logger :as logger]
76
[eca.shared :as shared]))
87

test/eca/config_test.clj

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
(ns eca.config-test
22
(:require
3+
[babashka.fs :as fs]
34
[clojure.test :refer [deftest is testing]]
45
[eca.config :as config]
56
[eca.logger :as logger]
@@ -184,3 +185,82 @@
184185
:b [:bar]}
185186
{:c 3
186187
:b []})))))
188+
189+
(deftest parse-dynamic-string-test
190+
(testing "returns nil for nil input"
191+
(is (nil? (#'config/parse-dynamic-string nil "/tmp"))))
192+
193+
(testing "returns string unchanged when no patterns"
194+
(is (= "hello world" (#'config/parse-dynamic-string "hello world" "/tmp"))))
195+
196+
(testing "replaces environment variable patterns"
197+
(with-redefs [config/get-env (fn [env-var]
198+
(case env-var
199+
"TEST_VAR" "test-value"
200+
"ANOTHER_VAR" "another-value"
201+
nil))]
202+
(is (= "test-value" (#'config/parse-dynamic-string "${env:TEST_VAR}" "/tmp")))
203+
(is (= "prefix test-value suffix" (#'config/parse-dynamic-string "prefix ${env:TEST_VAR} suffix" "/tmp")))
204+
(is (= "test-value and another-value" (#'config/parse-dynamic-string "${env:TEST_VAR} and ${env:ANOTHER_VAR}" "/tmp")))))
205+
206+
(testing "replaces undefined env var with empty string"
207+
(with-redefs [config/get-env (constantly nil)]
208+
(is (= "" (#'config/parse-dynamic-string "${env:UNDEFINED_VAR}" "/tmp")))
209+
(is (= "prefix suffix" (#'config/parse-dynamic-string "prefix ${env:UNDEFINED_VAR} suffix" "/tmp")))))
210+
211+
(testing "replaces file pattern with file content - absolute path"
212+
(with-redefs [fs/absolute? (fn [path] (= path "/absolute/file.txt"))
213+
slurp (fn [path]
214+
(if (= (str path) "/absolute/file.txt")
215+
"test file content"
216+
(throw (ex-info "File not found" {}))))]
217+
(is (= "test file content" (#'config/parse-dynamic-string "${file:/absolute/file.txt}" "/tmp")))))
218+
219+
(testing "replaces file pattern with file content - relative path"
220+
(with-redefs [fs/absolute? (fn [_] false)
221+
fs/path (fn [cwd file-path] (str cwd "/" file-path))
222+
slurp (fn [path]
223+
(if (= path "/tmp/test.txt")
224+
"relative file content"
225+
(throw (ex-info "File not found" {}))))]
226+
(is (= "relative file content" (#'config/parse-dynamic-string "${file:test.txt}" "/tmp")))))
227+
228+
(testing "replaces file pattern with empty string when file not found"
229+
(with-redefs [logger/warn (fn [& _] nil)
230+
fs/absolute? (fn [_] true)
231+
slurp (fn [_] (throw (ex-info "File not found" {})))]
232+
(is (= "" (#'config/parse-dynamic-string "${file:/nonexistent/file.txt}" "/tmp")))
233+
(is (= "prefix suffix" (#'config/parse-dynamic-string "prefix ${file:/nonexistent/file.txt} suffix" "/tmp")))))
234+
235+
(testing "handles multiple file patterns"
236+
(with-redefs [fs/absolute? (fn [_] true)
237+
slurp (fn [path]
238+
(case (str path)
239+
"/file1.txt" "content1"
240+
"/file2.txt" "content2"
241+
(throw (ex-info "File not found" {}))))]
242+
(is (= "content1 and content2"
243+
(#'config/parse-dynamic-string "${file:/file1.txt} and ${file:/file2.txt}" "/tmp")))))
244+
245+
(testing "handles mixed env and file patterns"
246+
(with-redefs [config/get-env (fn [env-var]
247+
(when (= env-var "TEST_VAR") "env-value"))
248+
fs/absolute? (fn [_] true)
249+
slurp (fn [path]
250+
(if (= (str path) "/file.txt")
251+
"file-value"
252+
(throw (ex-info "File not found" {}))))]
253+
(is (= "env-value and file-value"
254+
(#'config/parse-dynamic-string "${env:TEST_VAR} and ${file:/file.txt}" "/tmp")))))
255+
256+
(testing "handles patterns within longer strings"
257+
(with-redefs [config/get-env (fn [env-var]
258+
(when (= env-var "API_KEY") "secret123"))]
259+
(is (= "Bearer secret123" (#'config/parse-dynamic-string "Bearer ${env:API_KEY}" "/tmp")))))
260+
261+
(testing "handles empty string input"
262+
(is (= "" (#'config/parse-dynamic-string "" "/tmp"))))
263+
264+
(testing "preserves content with escaped-like patterns that don't match"
265+
(is (= "${notenv:VAR}" (#'config/parse-dynamic-string "${notenv:VAR}" "/tmp")))
266+
(is (= "${env:}" (#'config/parse-dynamic-string "${env:}" "/tmp")))))

0 commit comments

Comments
 (0)