Skip to content

Commit e182d57

Browse files
committed
Support ${classpath:...}
1 parent bd3bbac commit e182d57

File tree

7 files changed

+130
-54
lines changed

7 files changed

+130
-54
lines changed

CHANGELOG.md

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

33
## Unreleased
44

5+
- Support `${classapath:path/to/eca/classpath/file}` in dynamic string parse.
6+
- Deprecate configs:
7+
- `systemPromptFile` in favor of `systemPrompt` using `${file:...}` or `${classpath:...}`
8+
59
## 0.83.0
610

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

docs/configuration.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ There are multiples ways to configure ECA:
5252

5353
- `file`: `${file:/path/to/my-file}` or `${file:../rel-path/to/my-file}` to get a file content
5454
- `env`: `${env:MY_ENV}` to get a system env value
55+
- `classpath`: `${classpath:path/to/eca/file}` to get a file content from [ECA's classpath](https://github.com/editor-code-assistant/eca/tree/master/resources)
5556

5657
## Providers / Models
5758

@@ -332,7 +333,7 @@ ECA allows to totally customize the prompt sent to LLM via the `behavior` config
332333
{
333334
"behavior": {
334335
"my-behavior": {
335-
"systemPromptFile": "/path/to/my-behavior-prompt.md"
336+
"systemPrompt": "${file:/path/to/my-behavior-prompt.md}"
336337
}
337338
}
338339
}
@@ -431,7 +432,7 @@ You can configure which model and system prompt ECA will use during its inline c
431432
{
432433
"completion": {
433434
"model": "github-copilot/gpt-4.1",
434-
"systemPromptFile": "/path/to/my-prompt.md"
435+
"systemPrompt": "${file:/path/to/my-prompt.md}"
435436
}
436437
}
437438
```
@@ -446,7 +447,7 @@ Configure the model and system prompt used for ECA's rewrite feature via the `re
446447
{
447448
"rewrite": {
448449
"model": "github-copilot/gpt-4.1",
449-
"systemPromptFile": "/path/to/my-prompt.md"
450+
"systemPrompt": "${file:/path/to/my-prompt.md}"
450451
}
451452
}
452453
```
@@ -500,7 +501,7 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
500501
rules?: [{path: string;}];
501502
commands?: [{path: string;}];
502503
behavior?: {[key: string]: {
503-
systemPromptFile?: string;
504+
systemPrompt?: string;
504505
defaultModel?: string;
505506
disabledTools?: string[];
506507
toolCall?: {
@@ -560,11 +561,11 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
560561
};
561562
completion?: {
562563
model?: string;
563-
systemPromptFile?: string;
564+
systemPrompt?: string;
564565
};
565566
rewrite?: {
566567
model?: string;
567-
systemPromptFile?: string;
568+
systemPrompt?: string;
568569
};
569570
otlp?: {[key: string]: string};
570571
netrcFile?: string;
@@ -610,9 +611,9 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
610611
"lspTimeoutSeconds" : 30,
611612
"mcpServers" : {},
612613
"behavior" {
613-
"agent": {"systemPromptFile": "prompts/agent_behavior.md",
614+
"agent": {"systemPrompt": "${classpath:prompts/agent_behavior.md}",
614615
"disabledTools": ["preview_file_change"]},
615-
"plan": {"systemPromptFile": "prompts/plan_behavior.md",
616+
"plan": {"systemPrompt": "${classpath:prompts/plan_behavior.md}",
616617
"disabledTools": ["edit_file", "write_file", "move_file"],
617618
"toolCall": {"approval": {"deny": {"eca__shell_command":
618619
{"argsMatchers": {"command" [".*>.*",
@@ -638,10 +639,10 @@ To configure, add your OTLP collector config via `:otlp` map following [otlp aut
638639
},
639640
"completion": {
640641
"model": "openai/gpt-4.1",
641-
"systemPromptFile": "prompts/inline_completion.md"
642+
"systemPrompt": "${classpath:prompts/inline_completion.md}"
642643
},
643644
"rewrite": {
644-
"systemPromptFile": "prompts/rewrite.md"
645+
"systemPrompt": "${classpath:prompts/rewrite.md}"
645646
}
646647
}
647648
```

src/eca/config.clj

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@
3535

3636
(def custom-config-file-path* (atom nil))
3737

38-
(def initial-config
38+
(defn get-env [env] (System/getenv env))
39+
(defn get-property [property] (System/getProperty property))
40+
41+
(def ^:private initial-config*
3942
{:providers {"openai" {:api "openai-responses"
4043
:url "https://api.openai.com"
4144
:key nil
@@ -84,9 +87,9 @@
8487
"ollama" {:url "http://localhost:11434"
8588
:urlEnv "OLLAMA_API_URL"}}
8689
:defaultBehavior "agent"
87-
:behavior {"agent" {:systemPromptFile "prompts/agent_behavior.md"
90+
:behavior {"agent" {:systemPrompt "${classpath:prompts/agent_behavior.md}"
8891
:disabledTools ["preview_file_change"]}
89-
"plan" {:systemPromptFile "prompts/plan_behavior.md"
92+
"plan" {:systemPrompt "${classpath:prompts/plan_behavior.md}"
9093
:disabledTools ["edit_file" "write_file" "move_file"]
9194
:toolCall {:approval {:allow {"eca__shell_command"
9295
{:argsMatchers {"command" ["pwd"]}}
@@ -139,30 +142,16 @@
139142
:repoMap {:maxTotalEntries 800
140143
:maxEntriesPerDir 50}}
141144
:completion {:model "openai/gpt-4.1"
142-
:systemPromptFile "prompts/inline_completion.md"}
143-
:rewrite {:systemPromptFile "prompts/rewrite.md"}
145+
:systemPrompt "${classpath:prompts/inline_completion.md}"}
146+
:rewrite {:systemPrompt "${classpath:prompts/rewrite.md}"}
144147
:netrcFile nil
145148
:env "prod"})
146149

147-
(def ^:private fallback-behavior "agent")
148-
149-
(defn validate-behavior-name
150-
"Validates if a behavior exists in config. Returns the behavior if valid,
151-
or the fallback behavior if not."
152-
[behavior config]
153-
(if (contains? (:behavior config) behavior)
154-
behavior
155-
(do (logger/warn logger-tag (format "Unknown behavior '%s' specified, falling back to '%s'"
156-
behavior fallback-behavior))
157-
fallback-behavior)))
158-
159-
(defn get-env [env] (System/getenv env))
160-
(defn get-property [property] (System/getProperty property))
161-
162150
(defn ^:private parse-dynamic-string
163151
"Given a string and a current working directory, look for patterns replacing its content:
164152
- `${env:SOME-ENV}`: Replace with a env
165-
- `${file:/some/path}`: Replace with a file content checking from cwd if relative"
153+
- `${file:/some/path}`: Replace with a file content checking from cwd if relative
154+
- `${classpath:path/to/file}`: Replace with a file content found checking classpath"
166155
[s cwd]
167156
(some-> s
168157
(string/replace #"\$\{env:([^}]+)\}"
@@ -173,9 +162,18 @@
173162
(try
174163
(slurp (str (if (fs/absolute? file-path)
175164
file-path
176-
(fs/path cwd file-path))))
165+
(if cwd
166+
(fs/path cwd file-path)
167+
(fs/path file-path)))))
177168
(catch Exception _
178169
(logger/warn logger-tag "File not found when parsing string:" s)
170+
""))))
171+
(string/replace #"\$\{classpath:([^}]+)\}"
172+
(fn [[_match resource-path]]
173+
(try
174+
(slurp (io/resource resource-path))
175+
(catch Exception e
176+
(logger/warn logger-tag "Error reading classpath resource:" (.getMessage e))
179177
""))))))
180178

181179
(defn ^:private parse-dynamic-string-values
@@ -188,6 +186,20 @@
188186
x))
189187
config))
190188

189+
(def initial-config (memoize #(parse-dynamic-string-values initial-config* nil)))
190+
191+
(def ^:private fallback-behavior "agent")
192+
193+
(defn validate-behavior-name
194+
"Validates if a behavior exists in config. Returns the behavior if valid,
195+
or the fallback behavior if not."
196+
[behavior config]
197+
(if (contains? (:behavior config) behavior)
198+
behavior
199+
(do (logger/warn logger-tag (format "Unknown behavior '%s' specified, falling back to '%s'"
200+
behavior fallback-behavior))
201+
fallback-behavior)))
202+
191203
(def ^:private ttl-cache-config-ms 5000)
192204

193205
(defn ^:private safe-read-json-string [raw-string config-dyn-var]
@@ -341,7 +353,7 @@
341353
(defn all [db]
342354
(let [initialization-config @initialization-config*
343355
pure-config? (:pureConfig initialization-config)]
344-
(deep-merge initial-config
356+
(deep-merge (initial-config)
345357
(normalize-fields
346358
eca-config-normalization-rules
347359
(deep-merge initialization-config

src/eca/features/prompt.clj

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,19 @@
4141

4242
(defn ^:private eca-chat-prompt [behavior config]
4343
(let [behavior-config (get-in config [:behavior behavior])
44-
;; Use systemPromptFile from behavior config, or fall back to built-in
45-
prompt-file (or (:systemPromptFile behavior-config)
46-
;; For built-in behaviors without explicit config
47-
(when (#{"agent" "plan"} behavior)
48-
(str "prompts/" behavior "_behavior.md")))]
44+
prompt (:systemPrompt behavior-config)
45+
legacy-prompt-file (:systemPromptFile behavior-config)]
4946
(cond
50-
;; Custom behavior with absolute path
51-
(and prompt-file (string/starts-with? prompt-file "/"))
52-
(slurp prompt-file)
47+
prompt
48+
prompt
49+
50+
;; behavior with absolute path
51+
(and legacy-prompt-file (string/starts-with? legacy-prompt-file "/"))
52+
(slurp legacy-prompt-file)
5353

5454
;; Built-in or resource path
55-
prompt-file
56-
(load-builtin-prompt (some-> prompt-file (string/replace-first #"prompts/" "")))
55+
legacy-prompt-file
56+
(load-builtin-prompt (some-> legacy-prompt-file (string/replace-first #"prompts/" "")))
5757

5858
;; Fallback for unknown behavior
5959
:else
@@ -112,15 +112,19 @@
112112
{:workspaceRoots (shared/workspaces-as-str db)})))
113113

114114
(defn build-rewrite-instructions [text path full-text range config]
115-
(let [prompt-file (-> config :rewrite :systemPromptFile)
115+
(let [legacy-prompt-file (-> config :rewrite :systemPromptFile)
116+
prompt (-> config :rewrite :systemPrompt)
116117
prompt-str (cond
118+
prompt
119+
prompt
120+
117121
;; Absolute path
118-
(and prompt-file (string/starts-with? prompt-file "/"))
119-
(slurp prompt-file)
122+
(and legacy-prompt-file (string/starts-with? legacy-prompt-file "/"))
123+
(slurp legacy-prompt-file)
120124

121125
;; Resource path
122126
:else
123-
(load-builtin-prompt (some-> prompt-file (string/replace-first #"prompts/" ""))))]
127+
(load-builtin-prompt (some-> legacy-prompt-file (string/replace-first #"prompts/" ""))))]
124128
(replace-vars
125129
prompt-str
126130
{:text text
@@ -154,15 +158,19 @@
154158
"")}))
155159

156160
(defn inline-completion-prompt [config]
157-
(let [prompt-file (get-in config [:completion :systemPromptFile])]
161+
(let [legacy-prompt-file (get-in config [:completion :systemPromptFile])
162+
prompt (get-in config [:completion :systemPrompt])]
158163
(cond
164+
prompt
165+
prompt
166+
159167
;; Absolute path
160-
(and prompt-file (string/starts-with? prompt-file "/"))
161-
(slurp prompt-file)
168+
(and legacy-prompt-file (string/starts-with? legacy-prompt-file "/"))
169+
(slurp legacy-prompt-file)
162170

163171
;; Resource path
164172
:else
165-
(load-builtin-prompt (some-> prompt-file (string/replace-first #"prompts/" ""))))))
173+
(load-builtin-prompt (some-> legacy-prompt-file (string/replace-first #"prompts/" ""))))))
166174

167175
(defn get-prompt! [^String name ^Map arguments db]
168176
(logger/info logger-tag (format "Calling prompt '%s' with args '%s'" name arguments))

test/eca/config_test.clj

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
(ns eca.config-test
22
(:require
33
[babashka.fs :as fs]
4+
[clojure.java.io :as io]
45
[clojure.test :refer [deftest is testing]]
56
[eca.config :as config]
67
[eca.logger :as logger]
@@ -264,3 +265,53 @@
264265
(testing "preserves content with escaped-like patterns that don't match"
265266
(is (= "${notenv:VAR}" (#'config/parse-dynamic-string "${notenv:VAR}" "/tmp")))
266267
(is (= "${env:}" (#'config/parse-dynamic-string "${env:}" "/tmp")))))
268+
269+
(deftest parse-dynamic-string-classpath-test
270+
(testing "replaces classpath pattern with resource content"
271+
;; ECA_VERSION is a real resource file
272+
(let [version-content (#'config/parse-dynamic-string "${classpath:ECA_VERSION}" "/tmp")]
273+
(is (string? version-content))
274+
(is (seq version-content))))
275+
276+
(testing "replaces classpath pattern with empty string when resource not found"
277+
(with-redefs [logger/warn (fn [& _] nil)]
278+
(is (= "" (#'config/parse-dynamic-string "${classpath:nonexistent/resource.txt}" "/tmp")))
279+
(is (= "prefix suffix" (#'config/parse-dynamic-string "prefix ${classpath:nonexistent/resource.txt} suffix" "/tmp")))))
280+
281+
(testing "handles multiple classpath patterns"
282+
(with-redefs [io/resource (fn [path]
283+
(case path
284+
"resource1.txt" (java.io.ByteArrayInputStream. (.getBytes "content1" "UTF-8"))
285+
"resource2.txt" (java.io.ByteArrayInputStream. (.getBytes "content2" "UTF-8"))
286+
nil))]
287+
(is (= "content1 and content2"
288+
(#'config/parse-dynamic-string "${classpath:resource1.txt} and ${classpath:resource2.txt}" "/tmp")))))
289+
290+
(testing "handles mixed env, file, and classpath patterns"
291+
(with-redefs [config/get-env (fn [env-var]
292+
(when (= env-var "TEST_VAR") "env-value"))
293+
fs/absolute? (fn [_] true)
294+
slurp (fn [path]
295+
(cond
296+
(string? path)
297+
(if (= path "/file.txt")
298+
"file-value"
299+
(throw (ex-info "File not found" {})))
300+
:else "classpath-value"))
301+
io/resource (fn [_] (java.io.ByteArrayInputStream. (.getBytes "classpath-value" "UTF-8")))
302+
logger/warn (fn [& _] nil)]
303+
(is (= "env-value and file-value and classpath-value"
304+
(#'config/parse-dynamic-string "${env:TEST_VAR} and ${file:/file.txt} and ${classpath:resource.txt}" "/tmp")))))
305+
306+
(testing "handles classpath patterns within longer strings"
307+
(with-redefs [io/resource (fn [path]
308+
(when (= path "config/prompt.md")
309+
(java.io.ByteArrayInputStream. (.getBytes "# System Prompt\nYou are helpful." "UTF-8"))))]
310+
(is (= "# System Prompt\nYou are helpful."
311+
(#'config/parse-dynamic-string "${classpath:config/prompt.md}" "/tmp")))))
312+
313+
(testing "handles exception when reading classpath resource throws NullPointerException"
314+
(with-redefs [logger/warn (fn [& _] nil)
315+
io/resource (constantly nil)]
316+
(is (= "" (#'config/parse-dynamic-string "${classpath:error/resource.txt}" "/tmp")))
317+
(is (= "prefix suffix" (#'config/parse-dynamic-string "prefix ${classpath:error/resource.txt} suffix" "/tmp"))))))

test/eca/features/tools_test.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@
178178
;; No behavior specified (nil) should also not have plan restrictions
179179
(is (= :ask (f.tools/approval all-tools shell-tool {"command" "rm file.txt"} {} config nil)))))
180180
(testing "regex patterns match dangerous commands correctly"
181-
(let [config config/initial-config]
181+
(let [config (config/initial-config)]
182182
;; Test output redirection patterns
183183
(is (= :deny (f.tools/approval all-tools shell-tool {"command" "echo test > file.txt"} {} config "plan")))
184184
(is (= :deny (f.tools/approval all-tools shell-tool {"command" "ls >> log.txt"} {} config "plan")))
@@ -197,7 +197,7 @@
197197
(deftest plan-mode-approval-restrictions-test
198198
(let [shell-tool {:name "shell_command" :server {:name "eca"} :origin :native}
199199
all-tools [shell-tool]
200-
config config/initial-config]
200+
config (config/initial-config)]
201201

202202
(testing "dangerous commands blocked in plan mode via approval"
203203
(are [command] (= :deny

test/eca/test_helper.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
{:db* (atom db/initial-db)
4141
:messenger (->TestMessenger (atom {}) (atom []))
4242
:metrics (metrics/->NoopMetrics)
43-
:config config/initial-config})
43+
:config (config/initial-config)})
4444

4545
(def components* (atom (make-components)))
4646
(defn components [] @components*)

0 commit comments

Comments
 (0)