Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions plugins/spai-search
Original file line number Diff line number Diff line change
@@ -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})))))))
122 changes: 117 additions & 5 deletions setup.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 ---

Expand Down Expand Up @@ -176,13 +236,65 @@
(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]
(let [flags (set args)]
(check-deps)
(ensure-path)
(setup-claude-hook flags)
(setup-mcp flags)
(println)
(info "Setup complete!")
(println " spai help")
Expand Down
Loading