Skip to content

Commit 3394aca

Browse files
authored
Merge branch 'master' into skill-base-dir-path-resolution
2 parents 4a7dd55 + 7fec1cd commit 3394aca

10 files changed

Lines changed: 155 additions & 33 deletions

File tree

CHANGELOG.md

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

33
## Unreleased
44

5+
- `variantsByModel` entries now support an optional `:api` filter (string or vector) to restrict variant matching by provider API type.
6+
- Custom commands and skills now expose `:arguments` metadata inferred from their content. Previously they always reported empty arguments.
7+
- Native `skill-create`, `plugin-install`, and `plugin-uninstall` commands now declare `:required true` on their arguments in the command listing.
8+
- Fix documentation link in `--help` output.
9+
- Add built-in variants for `deepseek-v4-pro` (`none`, `high`, `max`).
510
- Improve skill tool description to resolve file paths and scripts mentioned in skill content against the skill's base directory.
611

712
## 0.131.1

docs/config.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,17 @@
475475
"items": {
476476
"type": "string"
477477
}
478+
},
479+
"api": {
480+
"type": [
481+
"string",
482+
"array"
483+
],
484+
"description": "Restrict variants to the given API type(s). String for exact match, array for any-of. Absent = match all.",
485+
"markdownDescription": "Restrict variants to the given API type(s). String for exact match, array for any-of. Absent = match all.",
486+
"items": {
487+
"type": "string"
488+
}
478489
}
479490
},
480491
"additionalProperties": false

docs/config/variants.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ ECA ships with built-in variants for some known models via the `variantsByModel`
3535
| `high` | `{"reasoning": {"effort": "high", "summary": "auto"}}` |
3636
| `xhigh` | `{"reasoning": {"effort": "xhigh", "summary": "auto"}}` |
3737

38+
=== "DeepSeek"
39+
40+
Applies to models matching `deepseek-v4-pro`. Only for providers using the `openai-chat` API.
41+
42+
| Variant | Payload |
43+
| ---------- | ------- |
44+
| `none` | `{"thinking": {"type": "disabled"}}` |
45+
| `high` | `{"reasoning_effort": "high"}` |
46+
| `max` | `{"reasoning_effort": "max"}}` |
47+
3848
## Custom Variants
3949

4050
You can define your own variants per model under `providers.<provider>.models.<model>.variants`. Custom variants are merged with built-in ones — if names clash, your definition wins.

docs/protocol.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1474,7 +1474,7 @@ interface ChatCommand {
14741474
arguments: [{
14751475
name: string;
14761476
description?: string;
1477-
required: boolean;
1477+
required?: boolean;
14781478
}];
14791479
}
14801480
```

src/eca/config.clj

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@
7272
"xhigh" {:output_config {:effort "xhigh"} :thinking {:type "adaptive" :display "summarized"}}
7373
"max" {:output_config {:effort "max"} :thinking {:type "adaptive" :display "summarized"}}})
7474

75+
(def ^:private deepseek-variants
76+
{"none" {:thinking {:type "disabled"}}
77+
"high" {:reasoning_effort "high"}
78+
"max" {:reasoning_effort "max"}})
79+
7580
(def ^:private initial-config*
7681
{:providers {"openai" {:api "openai-responses"
7782
:url "${env:OPENAI_API_URL:https://api.openai.com}"
@@ -188,7 +193,9 @@
188193
:variantsByModel {".*sonnet[-._]4[-._]6|opus[-._]4[-._][56]" {:variants anthropic-variants}
189194
".*opus[-._]4[-._]7" {:variants anthropic-v2-variants}
190195
".*gpt[-._]5(?:[-._](?:2|4|5)(?!\\d)|[-._]3[-._]codex)" {:variants openai-variants
191-
:excludeProviders ["github-copilot"]}}
196+
:excludeProviders ["github-copilot"]}
197+
".*deepseek[-._]v4[-._]pro" {:variants deepseek-variants
198+
:api "openai-chat"}}
192199
:mcpTimeoutSeconds 60
193200
:lspTimeoutSeconds 30
194201
:streamIdleTimeoutSeconds 120
@@ -243,10 +250,16 @@
243250
A variant set to {} is removed from the result, allowing users to disable
244251
built-in variants."
245252
[config provider model-name user-variants]
246-
(let [builtin (when model-name
247-
(some (fn [[pattern-str {:keys [variants excludeProviders]}]]
253+
(let [provider-api (get-in config [:providers provider :api])
254+
api-match? (fn [api config-val]
255+
(cond (sequential? config-val) (some #{api} config-val)
256+
config-val (= api config-val)
257+
:else true))
258+
builtin (when model-name
259+
(some (fn [[pattern-str {:keys [variants excludeProviders api]}]]
248260
(when (and (regex-matches? pattern-str model-name)
249-
(not (some #{provider} excludeProviders)))
261+
(not (some #{provider} excludeProviders))
262+
(api-match? provider-api api))
250263
variants))
251264
(:variantsByModel config)))
252265
merged (cond

src/eca/features/commands.clj

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,6 @@
3737
(defn ^:private normalize-command-name [f]
3838
(string/lower-case (fs/strip-ext (fs/file-name f))))
3939

40-
(defn ^:private prefixed-command-name
41-
"Builds the user-invocation name for a plugin-sourced command.
42-
Returns just the plugin name when it equals the command name,
43-
otherwise 'plugin:command'."
44-
[plugin-name command-name]
45-
(if (= plugin-name command-name)
46-
plugin-name
47-
(str plugin-name ":" command-name)))
48-
4940
(defn ^:private markdown-file? [file]
5041
(and (not (fs/directory? file))
5142
(string/ends-with? (string/lower-case (str file)) ".md")))
@@ -60,11 +51,12 @@
6051
(let [base (normalize-command-name file)
6152
content (interpolation/replace-dynamic-strings (slurp (str file)) (str (fs/parent file)) nil)]
6253
(cond-> {:name (if-let [plugin (:plugin opts)]
63-
(prefixed-command-name plugin base)
54+
(shared/prefixed-name plugin base)
6455
base)
6556
:path (str (fs/canonicalize file))
6657
:type type
67-
:content content}
58+
:content content
59+
:arguments (shared/extract-args-from-content content)}
6860
(:plugin opts) (assoc :plugin (:plugin opts)))))
6961

7062
(defn ^:private global-file-commands []
@@ -125,8 +117,8 @@
125117
{:name "skill-create"
126118
:type :native
127119
:description "Create a skill considering a user request"
128-
:arguments [{:name "name" :description "The skill name"}
129-
{:name "prompt" :description "What to consider as this skill content"}]}
120+
:arguments [{:name "name" :description "The skill name" :required true}
121+
{:name "prompt" :description "What to consider as this skill content" :required true}]}
130122
{:name "costs"
131123
:type :native
132124
:description "Total costs of the current chat session."
@@ -178,23 +170,23 @@
178170
{:name "plugin-install"
179171
:type :native
180172
:description "Install a plugin (e.g. /plugin-install my-plugin or /plugin-install my-plugin@marketplace)"
181-
:arguments [{:name "plugin" :description "Plugin name or plugin@marketplace"}]}
173+
:arguments [{:name "plugin" :description "Plugin name or plugin@marketplace" :required true}]}
182174
{:name "plugin-uninstall"
183175
:type :native
184176
:description "Uninstall a plugin (e.g. /plugin-uninstall my-plugin)"
185-
:arguments [{:name "plugin" :description "Plugin name"}]}]
177+
:arguments [{:name "plugin" :description "Plugin name" :required true}]}]
186178
custom-cmds (map (fn [custom]
187179
{:name (:name custom)
188180
:type :custom-prompt
189181
:description (:path custom)
190-
:arguments []})
182+
:arguments (:arguments custom)})
191183
(custom-commands config (:workspace-folders db)))
192184
skills-cmds (->> (f.skills/all config (:workspace-folders db))
193185
(mapv (fn [skill]
194186
{:name (:name skill)
195187
:type :skill
196188
:description (:description skill)
197-
:arguments []})))]
189+
:arguments (:arguments skill)})))]
198190
(concat mcp-prompts
199191
eca-commands
200192
skills-cmds

src/eca/features/skills.clj

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
{:name name
2020
:description description
2121
:body body
22+
:arguments (shared/extract-args-from-content body)
2223
:dir (str (fs/canonicalize (fs/parent skill-file)))}))
2324
(catch Exception e
2425
(logger/warn logger-tag (format "Error parsing skill file '%s': %s" (str skill-file) (.getMessage e)))
@@ -39,20 +40,11 @@
3940
(fs/directory? path) (filter skill-file? (fs/glob path "**" {:follow-links true}))
4041
:else [path]))
4142

42-
(defn ^:private prefixed-skill-name
43-
"Builds the user-invocation name for a plugin skill.
44-
Returns just the plugin name when it equals the skill name,
45-
otherwise 'plugin:skill'."
46-
[plugin-name skill-name]
47-
(if (= plugin-name skill-name)
48-
plugin-name
49-
(str plugin-name ":" skill-name)))
50-
5143
(defn ^:private plugin-skill [plugin file]
5244
(when-let [skill (skill-file->skill file)]
5345
(cond-> skill
5446
plugin (assoc :plugin plugin
55-
:name (prefixed-skill-name plugin (:name skill))))))
47+
:name (shared/prefixed-name plugin (:name skill))))))
5648

5749
(defn ^:private global-skills []
5850
(keep skill-file->skill

src/eca/main.clj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"Available commands:"
3434
" server Start eca as server, listening to stdin."
3535
""
36-
"See https://eca.dev/settings/ for detailed documentation."]
36+
"See https://eca.dev/config/introduction/ for detailed documentation."]
3737
(string/join \newline)))
3838

3939
(defn ^:private error-msg [errors]

src/eca/shared.clj

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,27 @@
6363
(assoc metadata :body (string/trim (string/join "\n" body-lines)))))
6464
{:body (string/trim content)})))
6565

66+
(defn extract-args-from-content
67+
"Parses command/skill content for $ARGN, $N, $ARGS, and $ARGUMENTS placeholders
68+
and returns the corresponding :arguments vector for command metadata.
69+
Returns an empty vector when no argument placeholders are found."
70+
[content]
71+
(if (string/blank? content)
72+
[]
73+
(let [content (str content)
74+
nums (keep (fn [[_ n]] (parse-long n))
75+
(re-seq #"\$(?:ARG)?(\d+)" content))
76+
has-varargs (some #(string/includes? content %)
77+
["$ARGS" "$ARGUMENTS"])
78+
max-n (when (seq nums) (apply max nums))
79+
declared-count (cond
80+
max-n max-n
81+
has-varargs 1)]
82+
(if declared-count
83+
(vec (for [i (range 1 (inc declared-count))]
84+
{:name (str "arg" i) :required true}))
85+
[]))))
86+
6687
;; Walks up from a non-existing path to find the nearest existing ancestor,
6788
;; canonicalizes it (resolving symlinks), then re-attaches the missing segments.
6889
;; This is needed because workspace roots can be symlinks — for write_file
@@ -116,6 +137,14 @@
116137
"The system's line separator."
117138
(System/lineSeparator))
118139

140+
(defn prefixed-name
141+
"Builds a plugin-prefixed name. Returns the bare name when it
142+
equals the plugin name, otherwise 'plugin:name'."
143+
[plugin-name capability-name]
144+
(if (= plugin-name capability-name)
145+
plugin-name
146+
(str plugin-name ":" capability-name)))
147+
119148
(defn normalize-api-url [api-url]
120149
(some-> api-url
121150
string/trim

test/eca/features/commands_test.clj

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
[clojure.test :refer [deftest is testing]]
66
[eca.features.commands :as f.commands]
77
[eca.features.rules :as f.rules]
8+
[eca.shared :as shared]
89
[eca.test-helper :as h]))
910

1011
(h/reset-components-before-test)
@@ -116,6 +117,29 @@
116117
(finally
117118
(fs/delete-tree tmp-dir)))))
118119

120+
(deftest command-arguments-test
121+
(let [tmp-dir (fs/create-temp-dir)]
122+
(try
123+
(testing "command with $ARG1 placeholder detects arguments"
124+
(let [cmd-file (fs/file tmp-dir "greet.md")]
125+
(spit cmd-file "Greet $ARG1!")
126+
(let [config {:pureConfig true :commands [{:path (str cmd-file)}]}
127+
result (vec (#'f.commands/custom-commands config []))]
128+
(is (= 1 (count result)))
129+
(is (= "greet" (:name (first result))))
130+
(is (= [{:name "arg1" :required true}]
131+
(:arguments (first result)))))))
132+
133+
(testing "command without placeholders has empty arguments"
134+
(let [cmd-file (fs/file tmp-dir "simple.md")]
135+
(spit cmd-file "Simple body")
136+
(let [config {:pureConfig true :commands [{:path (str cmd-file)}]}
137+
result (vec (#'f.commands/custom-commands config []))]
138+
(is (= [] (:arguments (first result)))))))
139+
140+
(finally
141+
(fs/delete-tree tmp-dir)))))
142+
119143
(deftest all-commands-include-model-command-test
120144
(let [commands (f.commands/all-commands {:workspace-folders []} {})]
121145
(is (some #(= {:name "model"
@@ -305,3 +329,49 @@
305329
:metrics (h/metrics)})
306330
text (get-in result [:chats "chat-1" :messages 0 :content 0 :text])]
307331
(is (= "No rules available for the current agent and model." text))))))
332+
333+
(deftest extract-args-from-content-test
334+
(testing "detects $ARG1 placeholder"
335+
(is (= [{:name "arg1" :required true}]
336+
(shared/extract-args-from-content "Respond with $ARG1"))))
337+
338+
(testing "detects multiple $ARGn placeholders"
339+
(is (= [{:name "arg1" :required true}
340+
{:name "arg2" :required true}
341+
{:name "arg3" :required true}]
342+
(shared/extract-args-from-content "First:$ARG1 Second:$ARG2 Third:$ARG3"))))
343+
344+
(testing "detects $ARGS without $ARGn"
345+
(is (= [{:name "arg1" :required true}]
346+
(shared/extract-args-from-content "Use all args: $ARGS"))))
347+
348+
(testing "detects $ARGUMENTS without $ARGn"
349+
(is (= [{:name "arg1" :required true}]
350+
(shared/extract-args-from-content "Process $ARGUMENTS here"))))
351+
352+
(testing "detects Claude Code style $1 and $2"
353+
(is (= [{:name "arg1" :required true}
354+
{:name "arg2" :required true}]
355+
(shared/extract-args-from-content "First:$1 Second:$2"))))
356+
357+
(testing "uses max of all placeholder styles"
358+
(is (= [{:name "arg1" :required true}]
359+
(shared/extract-args-from-content "$ARG1 $1"))))
360+
361+
(testing "returns empty vector when no placeholders present"
362+
(is (= []
363+
(shared/extract-args-from-content "No placeholders here"))))
364+
365+
(testing "returns empty vector for nil/blank content"
366+
(is (= [] (shared/extract-args-from-content nil)))
367+
(is (= [] (shared/extract-args-from-content ""))))
368+
369+
(testing "$ARGn takes precedence over $ARGS for argument count"
370+
(is (= [{:name "arg1" :required true}]
371+
(shared/extract-args-from-content "Single:$ARG1 All:$ARGS"))))
372+
373+
(testing "detects $ARG10 and declares all args up to 10"
374+
(let [result (shared/extract-args-from-content "Use $ARG10")]
375+
(is (= 10 (count result)))
376+
(is (= {:name "arg1" :required true} (first result)))
377+
(is (= {:name "arg10" :required true} (last result))))))

0 commit comments

Comments
 (0)