diff --git a/plugins/spai-search b/plugins/spai-search new file mode 100755 index 0000000..f85cae2 --- /dev/null +++ b/plugins/spai-search @@ -0,0 +1,186 @@ +#!/usr/bin/env bb +;; spai plugin: search +;; +;; Natural language → spai command recommendation via local Ollama. +;; "Which spai command do I need?" answered by a tiny local model. +;; +;; Usage: spai search "find class predicates" +;; spai search "what changed recently" +;; spai search --model qwen2.5-coder:3b "who uses this file" + +{:doap/name "search" + :doap/description "NL search over spai's tool catalog via local Ollama" + :dc/creator "Claude + Lance" + :spai/args "\"natural language query\" [--model name]" + :spai/returns "EDN: matched command(s) with usage and rationale" + :spai/example "spai search \"find what predicates a class has\"" + :spai/tags #{:meta :discovery :nl}} + +(require '[babashka.http-client :as http] + '[babashka.process :as p] + '[cheshire.core :as json] + '[clojure.edn :as edn] + '[clojure.string :as str] + '[clojure.pprint :as pp]) + +(def ollama-url (or (System/getenv "OLLAMA_URL") "http://localhost:11434")) +(def default-model "qwen2.5-coder:7b") + +;; --------------------------------------------------------------------------- +;; Catalog: read from spai help +;; --------------------------------------------------------------------------- + +(defn load-catalog + "Run `spai help` and parse the EDN tool catalog." + [] + (let [{:keys [exit out]} (p/shell {:out :string :err :string :continue true} + "spai" "help")] + (when (zero? exit) + ;; spai help outputs a header line then EDN. Find the opening { + (let [edn-start (str/index-of out "{")] + (when edn-start + (edn/read-string (subs out edn-start))))))) + +(defn catalog->prompt-text + "Turn the tool catalog into a compact text block for the LLM." + [catalog] + (str/join "\n" + (for [[cmd-key info] (sort-by key catalog) + :when (not= cmd-key :search)] ; don't recommend ourselves + (let [name (name cmd-key) + desc (or (:returns info) "") + args (or (:args info) "") + example (or (:example info) "")] + (str name + (when (seq args) (str " " args)) + (when (seq desc) (str " — " desc)) + (when (seq example) (str " (e.g. " example ")"))))))) + +;; --------------------------------------------------------------------------- +;; System prompt — deliberately minimal +;; --------------------------------------------------------------------------- + +(defn build-system-prompt [catalog-text] + (str +"You are a tool recommender for `spai`, a code exploration CLI for LLM agents. +Given a natural language question, return the best matching spai command(s). + +Output ONLY a valid EDN vector of maps. No explanation, no markdown, no prose. + +Each map must have: + :command — the spai command name (string) + :invocation — exact command line to run (string) + :why — one sentence explaining the match (string) + +Return 1-3 matches, best first. If unsure, return your best guess. + +## Available commands + +" catalog-text " + +Output ONLY the EDN vector.")) + +;; --------------------------------------------------------------------------- +;; Ollama +;; --------------------------------------------------------------------------- + +(defn ollama-chat [model system-prompt user-prompt] + (let [resp (http/post (str ollama-url "/api/chat") + {:headers {"Content-Type" "application/json"} + :body (json/generate-string + {:model model + :messages [{:role "system" :content system-prompt} + {:role "user" :content user-prompt}] + :stream false + :options {:temperature 0.1 + :num_predict 256}}) + :throw false + :timeout 30000}) + body (json/parse-string (:body resp) true)] + {:content (get-in body [:message :content]) + :model (:model body) + :prompt_tokens (get-in body [:prompt_eval_count]) + :completion_tokens (get-in body [:eval_count]) + :total_ms (some-> (get-in body [:total_duration]) (/ 1e6) long) + :tokens_per_sec (when-let [eval-count (get-in body [:eval_count])] + (when-let [eval-dur (get-in body [:eval_duration])] + (when (pos? eval-dur) + (-> (/ (* eval-count 1e9) eval-dur) (Math/round) (/ 1.0)))))})) + +(defn extract-edn + "Extract EDN from LLM response, stripping markdown fencing." + [text] + (let [text (str/trim (or text ""))] + (cond + (str/starts-with? text "```") + (let [lines (str/split-lines text) + inner (drop 1 (butlast lines))] + (str/join "\n" inner)) + :else text))) + +;; --------------------------------------------------------------------------- +;; Main +;; --------------------------------------------------------------------------- + +(let [args *command-line-args* + model (atom default-model) + query-args (atom [])] + + ;; Parse args + (loop [args args] + (when (seq args) + (cond + (= (first args) "--model") + (do (reset! model (second args)) + (recur (drop 2 args))) + + (contains? #{"--help" "-h"} (first args)) + (do + (println "Usage: spai search \"your question here\"") + (println " spai search --model qwen2.5-coder:3b \"who uses this file\"") + (println) + (println "Searches spai's tool catalog using natural language via local Ollama.") + (println "Returns the best matching command(s) with invocation examples.") + (System/exit 0)) + + :else + (do (swap! query-args conj (first args)) + (recur (rest args)))))) + + (when (empty? @query-args) + (println "Usage: spai search \"your question here\"") + (System/exit 1)) + + ;; Load tool catalog + (let [catalog (load-catalog)] + (when-not catalog + (binding [*out* *err*] + (println "Error: could not load spai tool catalog (is spai installed?)")) + (System/exit 1)) + + (let [catalog-text (catalog->prompt-text catalog) + user-query (str/join " " @query-args) + system-prompt (build-system-prompt catalog-text)] + + ;; Query ollama + (binding [*out* *err*] + (print (str "Searching (" @model ")... ")) + (flush)) + + (let [result (ollama-chat @model system-prompt user-query) + raw (extract-edn (:content result))] + + (binding [*out* *err*] + (println (str "done. (" + (:total_ms result) "ms, " + (:prompt_tokens result) "→" (:completion_tokens result) " tokens, " + (:tokens_per_sec result) " tok/s)"))) + + ;; Try to parse as EDN, fall back to raw + (let [parsed (try (edn/read-string raw) (catch Exception _ nil))] + (if (and parsed (or (vector? parsed) (seq? parsed))) + (pp/pprint parsed) + ;; Fallback: print raw and wrap in error + (pp/pprint {:error "Could not parse LLM response as EDN" + :raw raw + :query user-query}))))))) diff --git a/setup.clj b/setup.clj index 9cb4bb6..12dc4c9 100755 --- a/setup.clj +++ b/setup.clj @@ -43,7 +43,8 @@ (defn check-deps [] (println) (let [bb-ok (= 0 (:exit (sh "which" "bb"))) - rg-ok (= 0 (:exit (sh "which" "rg")))] + rg-ok (= 0 (:exit (sh "which" "rg"))) + ollama-ok (= 0 (:exit (sh "which" "ollama")))] (if bb-ok (info (str "babashka " (str/trim (:out (sh "bb" "--version"))))) (do (warn "babashka (bb) not found") @@ -52,10 +53,69 @@ (println))) (if rg-ok (info (str "ripgrep " (first (str/split-lines (:out (sh "rg" "--version")))))) - (do (warn "ripgrep (rg) not found — spai will use grep (slower)") - (println " Install: brew install ripgrep") - (println))) - {:bb bb-ok :rg rg-ok})) + (let [os (str/lower-case (System/getProperty "os.name")) + mac? (str/includes? os "mac") + win? (str/includes? os "windows") + brew? (= 0 (:exit (sh "which" "brew"))) + apt? (= 0 (:exit (sh "which" "apt-get"))) + dnf? (= 0 (:exit (sh "which" "dnf"))) + opkg? (= 0 (:exit (sh "which" "opkg"))) + install-cmd (cond + (and mac? brew?) ["brew" "install" "ripgrep"] + apt? ["sudo" "apt-get" "install" "-y" "ripgrep"] + dnf? ["sudo" "dnf" "install" "-y" "ripgrep"] + opkg? ["opkg" "install" "ripgrep"] + win? nil + :else nil)] + (warn "ripgrep (rg) not found — spai will use grep (slower)") + (if install-cmd + (do (print (str " Install now? (" (str/join " " install-cmd) ") [Y/n] ")) + (flush) + (let [ans (str/trim (or (read-line) ""))] + (when (or (= "" ans) (= "y" (str/lower-case ans))) + (info "Installing ripgrep...") + (let [r (apply sh install-cmd)] + (if (zero? (:exit r)) + (info "ripgrep installed.") + (do (warn (str "Install failed — run manually: " (str/join " " install-cmd))) + (println (:err r)))))))) + (do (println (if win? + " Install: winget install BurntSushi.ripgrep.MSVC" + " Install: https://github.com/BurntSushi/ripgrep#installation")) + (println))))) + (if ollama-ok + (do (info (str "ollama " (str/trim (:out (sh "ollama" "--version"))))) + (let [list-out (:out (sh "ollama" "list")) + has-model? (str/includes? list-out "qwen2.5-coder:7b")] + (when-not has-model? + (when (ask "Pull qwen2.5-coder:7b for spai search? (2.2GB) [optional]" :y) + (info "Pulling qwen2.5-coder:7b...") + (let [r (sh "ollama" "pull" "qwen2.5-coder:7b")] + (if (zero? (:exit r)) + (info "qwen2.5-coder:7b pulled.") + (do (warn "Pull failed — run manually: ollama pull qwen2.5-coder:7b") + (println (:err r))))))))) + (let [os (str/lower-case (System/getProperty "os.name")) + mac? (str/includes? os "mac") + brew? (= 0 (:exit (sh "which" "brew"))) + install-cmd (cond + (and mac? brew?) ["brew" "install" "ollama"] + :else nil)] + (warn "ollama not found — spai search will not be available [optional]") + (if install-cmd + (do (print (str " Install now? (" (str/join " " install-cmd) ") [Y/n] ")) + (flush) + (let [ans (str/trim (or (read-line) ""))] + (when (or (= "" ans) (= "y" (str/lower-case ans))) + (info "Installing ollama...") + (let [r (apply sh install-cmd)] + (if (zero? (:exit r)) + (info "ollama installed.") + (do (warn (str "Install failed — run manually: " (str/join " " install-cmd))) + (println (:err r)))))))) + (do (println " Install: https://ollama.ai/download") + (println))))) + {:bb bb-ok :rg rg-ok :ollama ollama-ok})) ;; --- PATH --- @@ -176,6 +236,57 @@ (println (str " spai setup --claude-hooks")) (println (str " Or manually: cp " hook-src " " hook-dst)))))) +;; --- MCP server --- + +(def mcp-script (str share-dir "/spai-mcp.bb")) + +(defn find-bb [] + (let [candidates [(str home "/.local/bin/bb") "/usr/local/bin/bb" "/opt/homebrew/bin/bb"] + on-path (let [r (sh "which" "bb")] (when (zero? (:exit r)) (str/trim (:out r))))] + (or on-path + (first (filter #(.exists (io/file %)) candidates))))) + +(defn has-spai-mcp? [settings] + (some? (get-in settings [:mcpServers :spai]))) + +(defn install-mcp [] + (let [bb (find-bb)] + (if-not bb + (do (warn "bb not found — cannot register MCP server") + (println " Install babashka first, then re-run: spai setup")) + (let [settings (if (.exists (io/file settings-file)) + (json/parse-string (slurp settings-file) true) + {})] + (if (has-spai-mcp? settings) + (info "spai MCP server already registered") + (let [updated (assoc-in settings [:mcpServers :spai] + {:command bb :args [mcp-script]})] + (spit settings-file (json/generate-string updated {:pretty true})) + (info "spai MCP server registered (restart Claude Code to activate)"))))))) + +(defn setup-mcp [flags] + (when (.isDirectory (io/file claude-dir)) + (when-not (.exists (io/file mcp-script)) + (warn (str "spai-mcp.bb not found at " mcp-script " — skipping MCP setup")) + (System/exit 0)) + (cond + (contains? flags "--mcp") + (install-mcp) + + interactive? + (do (println) + (info "spai MCP server available!") + (println " Exposes spai tools (memory, shape, blast, etc.) natively in Claude Code.") + (println " Claude sees them as first-class tools, not shell commands.") + (println) + (when (ask "Register spai MCP server?" :y) + (install-mcp))) + + :else + (do (println) + (info "To register spai as an MCP server:") + (println " spai setup --mcp"))))) + ;; --- Main --- (defn -main [& args] @@ -183,6 +294,7 @@ (check-deps) (ensure-path) (setup-claude-hook flags) + (setup-mcp flags) (println) (info "Setup complete!") (println " spai help")