diff --git a/CHANGELOG.md b/CHANGELOG.md index bde4b5679..4c32fe932 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Recover Anthropic streaming responses interrupted by transient TLS `bad_record_mac` failures. - BREAKING: `plugins.install` now appends across config layers. Set `plugins.installMode` to `replace` beside the list to exclude inherited plugins as before. +- Expand `@file` references in custom-command and parameterized-skill arguments into file content before sending the prompt. #163 ## 0.159.0 diff --git a/docs/config/commands.md b/docs/config/commands.md index f610353fe..5ea4da88c 100644 --- a/docs/config/commands.md +++ b/docs/config/commands.md @@ -7,7 +7,7 @@ description: "Configure ECA commands: built-in slash commands like /init and /co ![](../images/features/commands.png) You can configure custom command prompts for project, global or via `commands` config pointing to the path of the commands. -Prompts can use positional variables like `$ARGUMENTS`, `$1`, `$2`, or [named `{{name}}` variables](#frontmatter-description-and-named-arguments), to replace in the prompt during command call. +Prompts can use positional variables like `$ARGUMENTS`, `$1`, `$2`, or [named `{{name}}` variables](#frontmatter-description-and-named-arguments), to replace in the prompt during command call. File references passed as arguments (for example, `@/workspace/src/app.clj`) include the file content just like they do in a regular chat prompt. Quote arguments that contain spaces. !!! tip "Skills support arguments too" diff --git a/src/eca/features/commands.clj b/src/eca/features/commands.clj index 3f0cd0c82..863901514 100644 --- a/src/eca/features/commands.clj +++ b/src/eca/features/commands.clj @@ -10,6 +10,7 @@ [eca.features.chat.debug :as f.chat.debug] [eca.features.chat.export :as f.chat.export] [eca.features.chat.lifecycle :as lifecycle] + [eca.features.context :as f.context] [eca.features.index :as f.index] [eca.features.login :as f.login] [eca.features.plugins :as f.plugins] @@ -339,6 +340,20 @@ custom-cmds)))] (substitute-args raw-content args))) +(defn ^:private file-context-arg [arg] + (when (and (string/starts-with? arg "@") + (< 1 (count arg))) + (f.context/parse-context-reference (subs arg 1)))) + +(defn ^:private add-file-context [prompt args db] + (if-let [contexts-str (some-> (keep file-context-arg args) + (f.context/raw-contexts->refined db) + seq + (f.prompt/contexts-str nil nil))] + [{:type :text :text prompt} + {:type :text :text contexts-str}] + prompt)) + (defn ^:private format-tool-permissions [{:keys [toolCall]}] (when-let [approval (:approval toolCall)] (let [by-default (:byDefault approval) @@ -1152,7 +1167,7 @@ ;; else check if a custom command or skill (if-let [custom-command-prompt (get-custom-command command args custom-cmds)] {:type :send-prompt - :prompt custom-command-prompt} + :prompt (add-file-context custom-command-prompt args db)} (if-let [skill (first (filter #(= command (:name %)) skills))] {:type :send-prompt :prompt (cond @@ -1163,7 +1178,7 @@ (str "Load skill: " (:name skill)) (seq args) - (substitute-args (:body skill) args) + (add-file-context (substitute-args (:body skill) args) args db) :else (str "Load skill: " (:name skill)))} diff --git a/src/eca/features/context.clj b/src/eca/features/context.clj index 06c21cb71..553411445 100644 --- a/src/eca/features/context.clj +++ b/src/eca/features/context.clj @@ -165,20 +165,28 @@ nil)) contexts)) +(defn parse-context-reference + "Parse a path with an optional `:L-L` suffix into a raw context map." + [reference] + (when (seq reference) + (let [[_ path start end] (re-matches #"(.+):L(\d+)-L(\d+)$" reference) + path (or path reference) + path (if (string/starts-with? path "~") + (str (fs/expand-home path)) + path)] + (cond-> {:type (if (fs/directory? path) "directory" "file") + :path path} + (and start end) (assoc :lines-range {:start (parse-long start) + :end (parse-long end)}))))) + (defn contexts-str-from-prompt "Extract all contexts (@something) and refine them. Parse lines if present in contexts like @/path/to/file:L1-L4" [prompt db] - (let [ ;; Capture @ with optional :L-L - context-pattern #"@([/~\.][^\s:]+)(?::L(\d+)-L(\d+))?" + (let [;; Capture @ with optional :L-L + context-pattern #"@([/~\.][^\s:]+(?::L\d+-L\d+)?)" matches (re-seq context-pattern prompt) - raw-contexts (mapv (fn [[_ path s e]] - (assoc-some {:type (if (fs/directory? path) "directory" "file") - :path path} - :lines-range (when (and s e) - {:start (Integer/parseInt s) - :end (Integer/parseInt e)}))) - matches)] + raw-contexts (mapv (comp parse-context-reference second) matches)] (when (seq raw-contexts) (raw-contexts->refined raw-contexts db)))) diff --git a/test/eca/features/chat_test.clj b/test/eca/features/chat_test.clj index 507ce9ab7..4917ba30b 100644 --- a/test/eca/features/chat_test.clj +++ b/test/eca/features/chat_test.clj @@ -1713,6 +1713,9 @@ :command "login" :args ["foo bar" "baz" "qux bla blow"]} (#'f.chat/message->decision "/login \"foo bar\" baz \"qux bla blow\"" {} {})))) + (testing "quoted file arguments with spaces remain one token" + (is (= ["review" "@/dir/My File.clj"] + (#'f.chat/tokenize-args "review \"@/dir/My File.clj\"")))) (with-redefs [f.mcp/all-prompts (constantly [{:name "prompt" :server "server"}])] (testing "MCP prompt without args" @@ -2925,4 +2928,3 @@ (is (= "company-litellm/big" (resolve-model nil "main" {:defaultModel "explorer-small"})))))) - diff --git a/test/eca/features/commands_test.clj b/test/eca/features/commands_test.clj index 468a8a608..c5a2af5a4 100644 --- a/test/eca/features/commands_test.clj +++ b/test/eca/features/commands_test.clj @@ -7,6 +7,7 @@ [eca.features.chat.export :as f.chat.export] [eca.features.commands :as f.commands] [eca.features.rules :as f.rules] + [eca.features.skills :as f.skills] [eca.shared :as shared] [eca.test-helper :as h])) @@ -103,6 +104,55 @@ (is (= "Process one two here" (#'f.commands/get-custom-command "test" ["one" "two"] custom)))))) +(deftest custom-command-file-context-test + (let [tmp-dir (fs/create-temp-dir)] + (try + (let [command-file (fs/file tmp-dir "review.md") + context-file (fs/file tmp-dir "example file.clj") + context-path (str (fs/canonicalize context-file))] + (spit command-file "Review $1") + (spit context-file "(def answer 42)") + (let [result (f.commands/handle-command! + "review" + [(str "@" context-path)] + (assoc (command-context "chat-1") + :config {:pureConfig true + :commands [{:path (str command-file)}]}))] + (is (= :send-prompt (:type result))) + (is (= (str "Review @" context-path) + (get-in result [:prompt 0 :text]))) + (is (string/includes? (get-in result [:prompt 1 :text]) + (str "(def answer 42)"))))) + (testing "an unreadable file argument leaves the expanded prompt unchanged" + (let [result (f.commands/handle-command! + "review" + ["@/missing/example.clj"] + (assoc (command-context "chat-1") + :config {:pureConfig true + :commands [{:path (str (fs/file tmp-dir "review.md"))}]}))] + (is (= "Review @/missing/example.clj" (:prompt result))))) + (finally + (fs/delete-tree tmp-dir))))) + +(deftest parameterized-skill-file-context-test + (let [tmp-dir (fs/create-temp-dir)] + (try + (let [context-file (fs/file tmp-dir "example.clj") + context-path (str (fs/canonicalize context-file))] + (spit context-file "(def answer 42)") + (with-redefs [f.skills/all (constantly [{:name "review" + :body "Review $1"}])] + (let [result (f.commands/handle-command! "review" + [(str "@" context-path)] + (command-context "chat-1"))] + (is (= :send-prompt (:type result))) + (is (= (str "Review @" context-path) + (get-in result [:prompt 0 :text]))) + (is (string/includes? (get-in result [:prompt 1 :text]) + (str "(def answer 42)")))))) + (finally + (fs/delete-tree tmp-dir))))) + (deftest substitute-args-test (testing "replaces $ARGS with all args joined" (is (= "Review https://github.com/org/repo/pull/1" diff --git a/test/eca/features/context_test.clj b/test/eca/features/context_test.clj index 1e3304520..d38165327 100644 --- a/test/eca/features/context_test.clj +++ b/test/eca/features/context_test.clj @@ -570,6 +570,22 @@ :content "Some content"}] (f.context/contexts-str-from-prompt "check @/path/to/folder" (h/db))))))) +(deftest parse-context-reference-test + (with-redefs [fs/directory? (constantly false)] + (is (= {:type "file" + :path "/path/to/file" + :lines-range {:start 2 :end 4}} + (f.context/parse-context-reference "/path/to/file:L2-L4"))) + (is (= {:type "file" + :path (h/file-path "C:\\dir\\file.clj") + :lines-range {:start 2 :end 4}} + (f.context/parse-context-reference "C:\\dir\\file.clj:L2-L4")))) + (testing "expands a leading home directory" + (with-redefs [fs/expand-home (constantly (h/file-path "/home/user/notes.md")) + fs/directory? (constantly false)] + (is (= {:type "file" :path (h/file-path "/home/user/notes.md")} + (f.context/parse-context-reference "~/notes.md")))))) + (deftest raw-contexts->refined-image-test (testing "Inline image context is refined into the canonical {:type :image ...} shape" (h/reset-components!)