Skip to content

Commit a598fd2

Browse files
committed
Support custom commands via md files
1 parent 9574c55 commit a598fd2

File tree

8 files changed

+203
-64
lines changed

8 files changed

+203
-64
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 custom commands via md files in `~/.config/eca/commands/` or `.eca/commands/`.
6+
57
## 0.19.0
68

79
- Support `claude-opus-4-1` model.

docs/configuration.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,39 @@ It's possible to configure ECA to be aware of custom LLM providers if they follo
123123

124124
With that, ECA will include in the known models something like: `my-company/gpt-4.1`, `my-company/deepseek-r1`.
125125

126+
## Custom command prompts
127+
128+
You can configure custom command prompts for project, global or via `commands` config pointing to the path of the commands.
129+
Prompts can use variables like `$ARGS`, `$ARG1`, `ARG2`, to replace in the prompt during command call.
130+
131+
### Local custom commands
132+
133+
A `.eca/commands` folder from the workspace root containing `.md` files with the custom prompt.
134+
135+
`.eca/commands/check-performance.md`
136+
```markdown
137+
Check for performance issues in $ARG1 and optimize if needed.
138+
```
139+
140+
### Global custom commands
141+
142+
A `$XDG_CONFIG_HOME/eca/commands` or `~/.config/eca/commands` folder containing `.md` files with the custom command prompt.
143+
144+
`~/.config/eca/commands/check-performance.mdc`
145+
```markdown
146+
Check for performance issues in $ARG1 and optimize if needed.
147+
```
148+
149+
### Config
150+
151+
Just add to your config the `commands` pointing to `.md` files that will be searched from the workspace root if not an absolute path:
152+
153+
```javascript
154+
{
155+
"commands": [{"path": "my-custom-prompt.md"}]
156+
}
157+
```
158+
126159
## All configs
127160

128161
### Schema
@@ -132,6 +165,7 @@ interface Config {
132165
openaiApiKey?: string;
133166
anthropicApiKey?: string;
134167
rules: [{path: string;}];
168+
commands: [{path: string;}];
135169
systemPromptTemplate?: string;
136170
nativeTools: {
137171
filesystem: {enabled: boolean};
@@ -181,6 +215,7 @@ interface Config {
181215
"openaiApiKey" : null,
182216
"anthropicApiKey" : null,
183217
"rules" : [],
218+
"commands" : [],
184219
"nativeTools": {"filesystem": {"enabled": true},
185220
"shell": {"enabled": true,
186221
"excludeCommands": []}},

docs/features.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ The built-in commands are:
5151
`/costs`: shows costs about current session.
5252
`/repo-map-show`: shows the current repoMap context of the session.
5353

54+
#### Custom commands
55+
56+
It's possible to configure custom command prompts, for more details check [its configuration](./configuration.md#custom-commands)
57+
5458
## Completion
5559

5660
Soon

src/eca/config.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
{:openaiApiKey nil
2020
:anthropicApiKey nil
2121
:rules []
22+
:commands []
2223
:nativeTools {:filesystem {:enabled true}
2324
:shell {:enabled true
2425
:excludeCommands []}}

src/eca/features/chat.clj

Lines changed: 20 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
[cheshire.core :as json]
44
[clojure.set :as set]
55
[clojure.string :as string]
6+
[eca.features.commands :as f.commands]
67
[eca.features.context :as f.context]
78
[eca.features.index :as f.index]
89
[eca.features.prompt :as f.prompt]
@@ -12,7 +13,7 @@
1213
[eca.llm-api :as llm-api]
1314
[eca.logger :as logger]
1415
[eca.messenger :as messenger]
15-
[eca.shared :as shared :refer [assoc-some multi-str]]))
16+
[eca.shared :as shared :refer [assoc-some]]))
1617

1718
(set! *warn-on-reflection* true)
1819

@@ -45,23 +46,6 @@
4546
(defn ^:private tool-name->origin [name all-tools]
4647
(:origin (first (filter #(= name (:name %)) all-tools))))
4748

48-
(defn ^:private tokens->cost [input-tokens input-cache-creation-tokens input-cache-read-tokens output-tokens model db]
49-
(let [normalized-model (if (string/includes? model "/")
50-
(last (string/split model #"/"))
51-
model)
52-
{:keys [input-token-cost output-token-cost
53-
input-cache-creation-token-cost input-cache-read-token-cost]} (get-in db [:models normalized-model])
54-
input-cost (* input-tokens input-token-cost)
55-
input-cost (if input-cache-creation-tokens
56-
(+ input-cost (* input-cache-creation-tokens input-cache-creation-token-cost))
57-
input-cost)
58-
input-cost (if input-cache-read-tokens
59-
(+ input-cost (* input-cache-read-tokens input-cache-read-token-cost))
60-
input-cost)]
61-
(when (and input-token-cost output-token-cost)
62-
(format "%.2f" (+ input-cost
63-
(* output-tokens output-token-cost))))))
64-
6549
(defn ^:private usage-msg->usage
6650
[{:keys [input-tokens output-tokens
6751
input-cache-creation-tokens input-cache-read-tokens]}
@@ -84,8 +68,8 @@
8468
(assoc-some {:message-output-tokens output-tokens
8569
:message-input-tokens (+ input-tokens message-input-cache-tokens)
8670
:session-tokens (+ total-input-tokens total-input-cache-tokens total-output-tokens)}
87-
:message-cost (tokens->cost input-tokens input-cache-creation-tokens input-cache-read-tokens output-tokens model db)
88-
:session-cost (tokens->cost total-input-tokens total-input-cache-creation-tokens total-input-cache-read-tokens total-output-tokens model db)))))
71+
:message-cost (shared/tokens->cost input-tokens input-cache-creation-tokens input-cache-read-tokens output-tokens model db)
72+
:session-cost (shared/tokens->cost total-input-tokens total-input-cache-creation-tokens total-input-cache-read-tokens total-output-tokens model db)))))
8973

9074
(defn ^:private message->decision [message]
9175
(let [slash? (string/starts-with? message "/")
@@ -98,13 +82,14 @@
9882
{:type :mcp-prompt
9983
:server server
10084
:prompt (second (string/split (first parts) #":"))
101-
:args (if (seq parts)
102-
(vec (rest parts))
103-
[])})
85+
:args (vec (rest parts))})
10486

10587
slash?
106-
{:type :eca-command
107-
:command (subs message 1)}
88+
(let [command (subs message 1)
89+
parts (string/split command #" ")]
90+
{:type :eca-command
91+
:command (first parts)
92+
:args (vec (rest parts))})
10893

10994
:else
11095
{:type :prompt-message
@@ -276,27 +261,13 @@
276261
:text error-message})
277262
(prompt-messages! messages false chat-ctx))))
278263

279-
(defn ^:private handle-command! [{:keys [command]} {:keys [chat-id db* model] :as chat-ctx}]
280-
(let [db @db*]
281-
(case command
282-
"costs" (let [total-input-tokens (get-in db [:chats chat-id :total-input-tokens] 0)
283-
total-input-cache-creation-tokens (get-in db [:chats chat-id :total-input-cache-creation-tokens] nil)
284-
total-input-cache-read-tokens (get-in db [:chats chat-id :total-input-cache-read-tokens] nil)
285-
total-output-tokens (get-in db [:chats chat-id :total-output-tokens] 0)
286-
text (multi-str (str "Total input tokens: " total-input-tokens)
287-
(when total-input-cache-creation-tokens
288-
(str "Total input cache creation tokens: " total-input-cache-creation-tokens))
289-
(when total-input-cache-read-tokens
290-
(str "Total input cache read tokens: " total-input-cache-read-tokens))
291-
(str "Total output tokens: " total-output-tokens)
292-
(str "Total cost: $" (tokens->cost total-input-tokens total-input-cache-creation-tokens total-input-cache-read-tokens total-output-tokens model db)))]
293-
(send-content! chat-ctx :system {:type :text
294-
:text text}))
295-
"repo-map-show" (send-content! chat-ctx :system {:type :text
296-
:text (f.index/repo-map db {:as-string? true})})
297-
(send-content! chat-ctx :system {:type :text
298-
:text (str "Unknown command: " command)})))
299-
(finish-chat-prompt! :idle chat-ctx))
264+
(defn ^:private handle-command! [{:keys [command args]} {:keys [chat-id db* config model] :as chat-ctx}]
265+
(let [{:keys [type] :as result} (f.commands/handle-command! command args chat-id model config db*)]
266+
(case type
267+
:text (do (send-content! chat-ctx :system {:type :text :text (:text result)})
268+
(finish-chat-prompt! :idle chat-ctx))
269+
:send-prompt (prompt-messages! [{:role "user" :content (:prompt result)}] true chat-ctx)
270+
nil)))
300271

301272
(defn prompt
302273
[{:keys [message model behavior contexts chat-id request-id]}
@@ -346,23 +317,10 @@
346317

347318
(defn query-commands
348319
[{:keys [query chat-id]}
349-
db*]
320+
db*
321+
config]
350322
(let [query (string/lower-case query)
351-
mcp-prompts (->> (f.mcp/all-prompts @db*)
352-
(mapv #(-> %
353-
(assoc :name (str (:server %) ":" (:name %))
354-
:type :mcpPrompt)
355-
(dissoc :server))))
356-
eca-commands [{:name "costs"
357-
:type :native
358-
:description "Show the total costs of the current chat session."
359-
:arguments []}
360-
{:name "repo-map-show"
361-
:type :native
362-
:description "Show the actual repoMap of current session."
363-
:arguments []}]
364-
commands (concat mcp-prompts
365-
eca-commands)
323+
commands (f.commands/all-commands @db* config)
366324
commands (if (string/blank? query)
367325
commands
368326
(filter #(or (string/includes? (string/lower-case (:name %)) query)

src/eca/features/commands.clj

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
(ns eca.features.commands
2+
(:require
3+
[babashka.fs :as fs]
4+
[clojure.java.io :as io]
5+
[clojure.string :as string]
6+
[eca.config :as config]
7+
[eca.features.index :as f.index]
8+
[eca.features.tools.mcp :as f.mcp]
9+
[eca.shared :as shared :refer [multi-str]]))
10+
11+
(set! *warn-on-reflection* true)
12+
13+
(defn ^:private normalize-command-name [f]
14+
(string/lower-case (fs/strip-ext (fs/file-name f))))
15+
16+
(defn ^:private global-file-commands []
17+
(let [xdg-config-home (or (config/get-env "XDG_CONFIG_HOME")
18+
(io/file (config/get-property "user.home") ".config"))
19+
commands-dir (io/file xdg-config-home "eca" "commands")]
20+
(when (fs/exists? commands-dir)
21+
(map (fn [file]
22+
{:name (normalize-command-name file)
23+
:path (str (fs/canonicalize file))
24+
:type :user-global-file
25+
:content (slurp (fs/file file))})
26+
(fs/list-dir commands-dir)))))
27+
28+
(defn ^:private local-file-commands [roots]
29+
(->> roots
30+
(mapcat (fn [{:keys [uri]}]
31+
(let [commands-dir (fs/file (shared/uri->filename uri) ".eca" "commands")]
32+
(when (fs/exists? commands-dir)
33+
(fs/list-dir commands-dir)))))
34+
(map (fn [file]
35+
{:name (normalize-command-name file)
36+
:path (str (fs/canonicalize file))
37+
:type :user-local-file
38+
:content (slurp (fs/file file))}))))
39+
40+
(defn ^:private config-commands [config roots]
41+
(->> (get config :commands)
42+
(map
43+
(fn [{:keys [path]}]
44+
(if (fs/absolute? path)
45+
(when (fs/exists? path)
46+
{:name (normalize-command-name path)
47+
:path path
48+
:type :user-config
49+
:content (slurp path)})
50+
(keep (fn [{:keys [uri]}]
51+
(let [f (fs/file (shared/uri->filename uri) path)]
52+
(when (fs/exists? f)
53+
{:name (normalize-command-name f)
54+
:path (str (fs/canonicalize f))
55+
:type :user-config
56+
:content (slurp f)})))
57+
roots))))
58+
(flatten)
59+
(remove nil?)))
60+
61+
(defn ^:private custom-commands [config roots]
62+
(concat (config-commands config roots)
63+
(global-file-commands)
64+
(local-file-commands roots)))
65+
66+
(defn all-commands [db config]
67+
(let [mcp-prompts (->> (f.mcp/all-prompts db)
68+
(mapv #(-> %
69+
(assoc :name (str (:server %) ":" (:name %))
70+
:type :mcpPrompt)
71+
(dissoc :server))))
72+
eca-commands [{:name "costs"
73+
:type :native
74+
:description "Show the total costs of the current chat session."
75+
:arguments []}
76+
{:name "repo-map-show"
77+
:type :native
78+
:description "Show the actual repoMap of current session."
79+
:arguments []}]
80+
custom-commands (map (fn [custom]
81+
{:name (:name custom)
82+
:type :custom-prompt
83+
:description (:path custom)
84+
:arguments []})
85+
(custom-commands config (:workspace-folders db)))]
86+
(concat mcp-prompts
87+
eca-commands
88+
custom-commands)))
89+
90+
(defn ^:private get-custom-command [command args custom-commands]
91+
(when-let [raw-content (:content (first (filter #(= command (:name %))
92+
custom-commands)))]
93+
(let [raw-content (string/replace raw-content "$ARGS" (string/join " " args))]
94+
(reduce (fn [content [i arg]]
95+
(string/replace content (str "$ARG" (inc i)) arg))
96+
raw-content
97+
(map-indexed vector args)))))
98+
99+
(defn handle-command! [command args chat-id model config db*]
100+
(let [db @db*
101+
custom-commands (custom-commands config (:workspace-folders db))]
102+
(case command
103+
"costs" (let [total-input-tokens (get-in db [:chats chat-id :total-input-tokens] 0)
104+
total-input-cache-creation-tokens (get-in db [:chats chat-id :total-input-cache-creation-tokens] nil)
105+
total-input-cache-read-tokens (get-in db [:chats chat-id :total-input-cache-read-tokens] nil)
106+
total-output-tokens (get-in db [:chats chat-id :total-output-tokens] 0)
107+
text (multi-str (str "Total input tokens: " total-input-tokens)
108+
(when total-input-cache-creation-tokens
109+
(str "Total input cache creation tokens: " total-input-cache-creation-tokens))
110+
(when total-input-cache-read-tokens
111+
(str "Total input cache read tokens: " total-input-cache-read-tokens))
112+
(str "Total output tokens: " total-output-tokens)
113+
(str "Total cost: $" (shared/tokens->cost total-input-tokens total-input-cache-creation-tokens total-input-cache-read-tokens total-output-tokens model db)))]
114+
{:type :text :text text})
115+
"repo-map-show" {:type :text :text (f.index/repo-map db {:as-string? true})}
116+
117+
;; else check if a custom command
118+
(if-let [custom-command-prompt (get-custom-command command args custom-commands)]
119+
{:type :send-prompt
120+
:prompt custom-command-prompt}
121+
{:type :text
122+
:text (str "Unknown command: " command)}))))

src/eca/handlers.clj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,10 @@
7474
:eca/chat-query-context
7575
(f.chat/query-context params db* config)))
7676

77-
(defn chat-query-commands [{:keys [db*]} params]
77+
(defn chat-query-commands [{:keys [db* config]} params]
7878
(logger/logging-task
7979
:eca/chat-query-commands
80-
(f.chat/query-commands params db*)))
80+
(f.chat/query-commands params db* config)))
8181

8282
(defn chat-tool-call-approve [{:keys [db*]} params]
8383
(logger/logging-task

src/eca/shared.clj

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,20 @@
4444
ret))))
4545

4646
(defn multi-str [& strings] (string/join "\n" (remove nil? strings)))
47+
48+
(defn tokens->cost [input-tokens input-cache-creation-tokens input-cache-read-tokens output-tokens model db]
49+
(let [normalized-model (if (string/includes? model "/")
50+
(last (string/split model #"/"))
51+
model)
52+
{:keys [input-token-cost output-token-cost
53+
input-cache-creation-token-cost input-cache-read-token-cost]} (get-in db [:models normalized-model])
54+
input-cost (* input-tokens input-token-cost)
55+
input-cost (if input-cache-creation-tokens
56+
(+ input-cost (* input-cache-creation-tokens input-cache-creation-token-cost))
57+
input-cost)
58+
input-cost (if input-cache-read-tokens
59+
(+ input-cost (* input-cache-read-tokens input-cache-read-token-cost))
60+
input-cost)]
61+
(when (and input-token-cost output-token-cost)
62+
(format "%.2f" (+ input-cost
63+
(* output-tokens output-token-cost))))))

0 commit comments

Comments
 (0)