Skip to content

Commit 5695ec0

Browse files
committed
Add shell native tool
1 parent 3aed4c6 commit 5695ec0

File tree

11 files changed

+239
-98
lines changed

11 files changed

+239
-98
lines changed

docs/capabilities.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,21 @@ Provides access to filesystem under workspace root, listing and reading files an
1818
- `grep`: ripgrep/grep for paths with specified content.
1919
- `replace_in_file`: replace a text with another one in file.
2020

21-
### TODO - Shell
21+
### Shell
22+
23+
Provides access to run shell commands, useful to run build tools, tests, and other common commands, supports exclude/include commands.
24+
25+
- `shell_command`: run shell command. Supports configs to exclude commands via `:nativeTools :shell :excludeCommands`.
2226

2327
### TODO - Web
2428

2529
## Supported LLM models and capaibilities
2630

27-
| model | MCP / tools | thinking/reasioning | prompt caching |
28-
|-----------|-------------|---------------------|----------------|
29-
| OpenAI || X | X |
30-
| Anthropic || X ||
31-
| Ollama || X | X |
31+
| model | MCP / tools | thinking/reasioning | prompt caching | web_search |
32+
|-----------|-------------|---------------------|----------------|------------|
33+
| OpenAI || X | X ||
34+
| Anthropic || X |||
35+
| Ollama || X | X | X |
3236

3337
### OpenAI
3438

docs/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,9 @@ interface Config {
117117
"openaiApiKey" : null,
118118
"anthropicApiKey" : null,
119119
"rules" : [ ],
120-
"nativeTools": {"filesystem": {"enabled": true}},
120+
"nativeTools": {"filesystem": {"enabled": true}
121+
"shell": {"enabled": true
122+
"excludeCommands": []}},
121123
"mcpTimeoutSeconds" : 10,
122124
"mcpServers" : [ ],
123125
"ollama" : {

src/eca/config.clj

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
{:openaiApiKey nil
2020
:anthropicApiKey nil
2121
:rules []
22-
:nativeTools {:filesystem {:enabled true}}
22+
:nativeTools {:filesystem {:enabled true}
23+
:shell {:enabled true
24+
:excludeCommands []}}
2325
:mcpTimeoutSeconds 60
2426
:mcpServers {}
2527
:ollama {:host "http://localhost"

src/eca/features/tools.clj

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
[clojure.string :as string]
66
[eca.features.tools.filesystem :as f.tools.filesystem]
77
[eca.features.tools.mcp :as f.mcp]
8+
[eca.features.tools.shell :as f.tools.shell]
89
[eca.features.tools.util :as tools.util]
910
[eca.logger :as logger])
1011
(:import
@@ -15,29 +16,31 @@
1516
(def ^:private logger-tag "[TOOLS]")
1617

1718
(defn native-definitions [db config]
18-
(merge {}
19-
(when (get-in config [:nativeTools :filesystem :enabled])
20-
(into
21-
{}
22-
(map (fn [[name tool]]
23-
[name (-> tool
24-
(assoc :name name)
25-
(update :description #(-> %
26-
(string/replace #"\$workspaceRoots" (constantly (tools.util/workspace-roots-strs db))))))]))
27-
f.tools.filesystem/definitions))))
19+
(into
20+
{}
21+
(map (fn [[name tool]]
22+
[name (-> tool
23+
(assoc :name name)
24+
(update :description #(-> %
25+
(string/replace #"\$workspaceRoots" (constantly (tools.util/workspace-roots-strs db))))))]))
26+
(merge {}
27+
(when (get-in config [:nativeTools :filesystem :enabled])
28+
f.tools.filesystem/definitions)
29+
(when (get-in config [:nativeTools :shell :enabled])
30+
f.tools.shell/definitions))))
2831

2932
(defn all-tools [db config]
3033
(let [native-tools (concat
31-
[]
32-
(mapv #(select-keys % [:name :description :parameters])
33-
(vals (native-definitions db config))))
34+
[]
35+
(mapv #(select-keys % [:name :description :parameters])
36+
(vals (native-definitions db config))))
3437
mcp-tools (f.mcp/all-tools db)]
3538
(concat
36-
(mapv #(assoc % :source :native) native-tools)
37-
(mapv #(assoc % :source :mcp) mcp-tools))))
39+
(mapv #(assoc % :source :native) native-tools)
40+
(mapv #(assoc % :source :mcp) mcp-tools))))
3841

3942
(defn call-tool! [^String name ^Map arguments db config]
4043
(logger/debug logger-tag (format "Calling tool '%s' with args '%s'" name arguments))
4144
(if-let [native-tool-handler (get-in (native-definitions db config) [name :handler])]
42-
(native-tool-handler arguments db)
45+
(native-tool-handler arguments {:db db :config config})
4346
(f.mcp/call-tool! name arguments db)))

src/eca/features/tools/filesystem.clj

Lines changed: 35 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,18 @@
99

1010
(set! *warn-on-reflection* true)
1111

12-
(defn ^:private single-text-content [text & [error]]
13-
{:contents [{:type :text
14-
:content text
15-
:error (boolean error)}]})
16-
1712
(defn ^:private allowed-path? [db path]
1813
(some #(fs/starts-with? path (shared/uri->filename (:uri %)))
1914
(:workspace-folders db)))
2015

21-
(defn ^:private invalid-arguments [arguments validator]
22-
(first (keep (fn [[key pred error-msg]]
23-
(let [value (get arguments key)]
24-
(when-not (pred value)
25-
(single-text-content (string/replace error-msg (str "$" key) (str value)) :error))))
26-
validator)))
27-
2816
(defn ^:private path-validations [db]
2917
[["path" fs/exists? "$path is not a valid path"]
3018
["path" (partial allowed-path? db) (str "Access denied - path $path outside allowed directories: " (tools.util/workspace-roots-strs db))]])
3119

32-
(defn ^:private list-directory [arguments db]
20+
(defn ^:private list-directory [arguments {:keys [db]}]
3321
(let [path (delay (fs/canonicalize (get arguments "path")))]
34-
(or (invalid-arguments arguments (path-validations db))
35-
(single-text-content
22+
(or (tools.util/invalid-arguments arguments (path-validations db))
23+
(tools.util/single-text-content
3624
(reduce
3725
(fn [out path]
3826
(str out
@@ -42,9 +30,9 @@
4230
""
4331
(fs/list-dir @path))))))
4432

45-
(defn ^:private read-file [arguments db]
46-
(or (invalid-arguments arguments (concat (path-validations db)
47-
[["path" fs/readable? "File $path is not readable"]]))
33+
(defn ^:private read-file [arguments {:keys [db]}]
34+
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
35+
[["path" fs/readable? "File $path is not readable"]]))
4836
(let [head (get arguments "head")
4937
tail (get arguments "tail")
5038
content (cond-> (slurp (fs/file (fs/canonicalize (get arguments "path"))))
@@ -54,19 +42,19 @@
5442
tail (->> (string/split-lines)
5543
(take-last tail)
5644
(string/join "\n")))]
57-
(single-text-content content))))
45+
(tools.util/single-text-content content))))
5846

59-
(defn ^:private write-file [arguments db]
60-
(or (invalid-arguments arguments [["path" (partial allowed-path? db) (str "Access denied - path $path outside allowed directories: " (tools.util/workspace-roots-strs db))]])
47+
(defn ^:private write-file [arguments {:keys [db]}]
48+
(or (tools.util/invalid-arguments arguments [["path" (partial allowed-path? db) (str "Access denied - path $path outside allowed directories: " (tools.util/workspace-roots-strs db))]])
6149
(let [path (get arguments "path")
6250
content (get arguments "content")]
6351
(fs/create-dirs path)
6452
(spit path content)
65-
(single-text-content (format "Successfully wrote to %s" path)))))
53+
(tools.util/single-text-content (format "Successfully wrote to %s" path)))))
6654

67-
(defn ^:private search-files [arguments db]
68-
(or (invalid-arguments arguments (concat (path-validations db)
69-
[["pattern" #(not (string/blank? %)) "Invalid glob pattern '$pattern'"]]))
55+
(defn ^:private search-files [arguments {:keys [db]}]
56+
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
57+
[["pattern" #(not (string/blank? %)) "Invalid glob pattern '$pattern'"]]))
7058
(let [pattern (get arguments "pattern")
7159
pattern (if (string/includes? pattern "*")
7260
pattern
@@ -77,9 +65,9 @@
7765
pattern)))
7866
[]
7967
(:workspace-folders db))]
80-
(single-text-content (if (seq paths)
81-
(string/join "\n" paths)
82-
"No matches found")))))
68+
(tools.util/single-text-content (if (seq paths)
69+
(string/join "\n" paths)
70+
"No matches found")))))
8371

8472
(defn ^:private run-ripgrep [path pattern include]
8573
(let [cmd (cond-> ["rg" "--files-with-matches" "--no-heading"]
@@ -147,12 +135,12 @@
147135
148136
Returns matching file paths, prioritizing by modification time when possible.
149137
Validates that the search path is within allowed workspace directories."
150-
[arguments db]
151-
(or (invalid-arguments arguments (concat (path-validations db)
152-
[["path" fs/readable? "File $path is not readable"]
153-
["pattern" #(and % (not (string/blank? %))) "Invalid content regex pattern '$pattern'"]
154-
["include" #(or (nil? %) (not (string/blank? %))) "Invalid file pattern '$include'"]
155-
["max_results" #(or (nil? %) number?) "Invalid number '$max_results'"]]))
138+
[arguments {:keys [db]}]
139+
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
140+
[["path" fs/readable? "File $path is not readable"]
141+
["pattern" #(and % (not (string/blank? %))) "Invalid content regex pattern '$pattern'"]
142+
["include" #(or (nil? %) (not (string/blank? %))) "Invalid file pattern '$include'"]
143+
["max_results" #(or (nil? %) number?) "Invalid number '$max_results'"]]))
156144
(let [path (get arguments "path")
157145
pattern (get arguments "pattern")
158146
include (get arguments "include")
@@ -169,19 +157,19 @@
169157
(run-java-grep path pattern include))
170158
(take max-results))]
171159
;; TODO sort by modification time.
172-
(single-text-content (if (seq paths)
173-
(string/join "\n" paths)
174-
"No files found for given pattern")))))
160+
(tools.util/single-text-content (if (seq paths)
161+
(string/join "\n" paths)
162+
"No files found for given pattern")))))
175163

176-
(defn ^:private replace-in-file [arguments db]
177-
(or (invalid-arguments arguments (concat (path-validations db)
178-
[["path" fs/readable? "File $path is not readable"]]))
164+
(defn ^:private replace-in-file [arguments {:keys [db]}]
165+
(or (tools.util/invalid-arguments arguments (concat (path-validations db)
166+
[["path" fs/readable? "File $path is not readable"]]))
179167
(let [path (get arguments "path")
180168
original-content (get arguments "original_content")
181169
new-content (get arguments "new_content")
182170
all? (boolean (get arguments "all_occurrences"))
183171
content (slurp path)]
184-
(single-text-content
172+
(tools.util/single-text-content
185173
(if (string/includes? content original-content)
186174
(let [content (if all?
187175
(string/replace content original-content new-content)
@@ -190,16 +178,16 @@
190178
(format "Successfully replaced content in %s." path))
191179
(format "Original content not found in %s" path))))))
192180

193-
(defn ^:private move-file [arguments db]
181+
(defn ^:private move-file [arguments {:keys [db]}]
194182
(let [workspace-dirs (tools.util/workspace-roots-strs db)]
195-
(or (invalid-arguments arguments [["source" fs/exists? "$source is not a valid path"]
196-
["source" (partial allowed-path? db) (str "Access denied - path $source outside allowed directories: " workspace-dirs)]
197-
["destination" (partial allowed-path? db) (str "Access denied - path $destination outside allowed directories: " workspace-dirs)]
198-
["destination" (complement fs/exists?) "Path $destination already exists"]])
183+
(or (tools.util/invalid-arguments arguments [["source" fs/exists? "$source is not a valid path"]
184+
["source" (partial allowed-path? db) (str "Access denied - path $source outside allowed directories: " workspace-dirs)]
185+
["destination" (partial allowed-path? db) (str "Access denied - path $destination outside allowed directories: " workspace-dirs)]
186+
["destination" (complement fs/exists?) "Path $destination already exists"]])
199187
(let [source (get arguments "source")
200188
destination (get arguments "destination")]
201189
(fs/move source destination {:replace-existing false})
202-
(single-text-content (format "Successfully moved %s to %s" source destination))))))
190+
(tools.util/single-text-content (format "Successfully moved %s to %s" source destination))))))
203191

204192
(def definitions
205193
{"list_directory"

src/eca/features/tools/shell.clj

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
(ns eca.features.tools.shell
2+
(:require
3+
[babashka.fs :as fs]
4+
[clojure.java.shell :as shell]
5+
[clojure.string :as string]
6+
[eca.features.tools.util :as tools.util]
7+
[eca.logger :as logger]
8+
[eca.shared :as shared]))
9+
10+
(set! *warn-on-reflection* true)
11+
12+
(def ^:private logger-tag "[TOOLS-SHELL]")
13+
14+
(defn ^:private shell-command [arguments {:keys [db config]}]
15+
(let [command (get arguments "command")
16+
user-work-dir (get arguments "working_directory")
17+
exclude-cmds (-> config :nativeTools :shell :excludeCommands set)
18+
command-args (string/split command #" ")]
19+
(or (tools.util/invalid-arguments arguments [["working_directory" #(or (nil? %)
20+
(fs/exists? %)) "working directory $working_directory does not exist"]
21+
["commmand" (constantly (not (contains? exclude-cmds (first command-args)))) (format "Cannot run command '%s' because it is excluded by eca config."
22+
(first command-args))]])
23+
(let [work-dir (or (some-> user-work-dir fs/canonicalize str)
24+
(shared/uri->filename (:uri (first (:workspace-folders db)))))
25+
command-and-opts (concat [] command-args [:dir work-dir])
26+
_ (logger/debug logger-tag "Running command:" command-and-opts)
27+
result (try
28+
(apply shell/sh command-and-opts)
29+
(catch Exception e
30+
{:exit 1 :err (.getMessage e)}))]
31+
(logger/debug logger-tag "Command executed:" result)
32+
(if (zero? (:exit result))
33+
(tools.util/single-text-content (:out result))
34+
(tools.util/single-text-content (str "Command failed with exit code " (:exit result) ": " (:err result)) :error))))))
35+
36+
(def definitions
37+
{"shell_command"
38+
{:description (str "Execute an arbitrary shell command and return the output. "
39+
"Useful to run commands like `ls`, `git status`, etc.")
40+
:parameters {:type "object"
41+
:properties {"command" {:type "string"
42+
:description "The shell command to execute."}
43+
"working_directory" {:type "string"
44+
:description "The directory to run the command in. Default to the first workspace root."}}
45+
:required ["command"]}
46+
:handler #'shell-command}})

src/eca/features/tools/util.clj

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
[clojure.string :as string]
55
[eca.shared :as shared]))
66

7+
(defn single-text-content [text & [error]]
8+
{:contents [{:type :text
9+
:content text
10+
:error (boolean error)}]})
11+
712
(defn workspace-roots-strs [db]
813
(->> (:workspace-folders db)
914
(map #(shared/uri->filename (:uri %)))
@@ -13,3 +18,11 @@
1318
(try
1419
(zero? (:exit (apply shell/sh (concat [command] args))))
1520
(catch Exception _ false)))
21+
22+
(defn invalid-arguments [arguments validator]
23+
(first (keep (fn [[key pred error-msg]]
24+
(let [value (get arguments key)]
25+
(when-not (pred value)
26+
(single-text-content (string/replace error-msg (str "$" key) (str value))
27+
:error))))
28+
validator)))

src/eca/shared.clj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,11 @@
1717
(if (seq coll)
1818
(update coll (dec (count coll)) f)
1919
coll))
20+
21+
(defn deep-merge [v & vs]
22+
(letfn [(rec-merge [v1 v2]
23+
(if (and (map? v1) (map? v2))
24+
(merge-with deep-merge v1 v2)
25+
v2))]
26+
(when (some identity vs)
27+
(reduce #(rec-merge %1 %2) v vs))))

0 commit comments

Comments
 (0)