diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 08f19b3d..fd1756fc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2298,6 +2298,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "equivalent" version = "1.0.2" @@ -4434,6 +4440,8 @@ dependencies = [ "tauri-plugin-store", "tiny_http", "tokio", + "uuid", + "which", "zip 2.4.2", ] @@ -8688,6 +8696,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix 1.1.4", + "winsafe", +] + [[package]] name = "winapi" version = "0.3.9" @@ -9170,6 +9190,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f17f780c..1878548c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,6 +37,12 @@ lancedb = "0.27.2" arrow-array = "57" arrow-schema = "57" futures = "0.3" +# Claude Code CLI subprocess transport: spawn `claude` as a child process, +# stream stdout line-by-line back to the frontend. tokio::process gives us +# async io and clean cancellation; `which` locates the binary on PATH. +tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt"] } +which = "7" +uuid = { version = "1", features = ["v4"] } [dev-dependencies] tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } diff --git a/src-tauri/src/commands/claude_cli.rs b/src-tauri/src/commands/claude_cli.rs new file mode 100644 index 00000000..da97e657 --- /dev/null +++ b/src-tauri/src/commands/claude_cli.rs @@ -0,0 +1,330 @@ +//! Claude Code CLI subprocess transport. +//! +//! Users with a Claude Code subscription already have OAuth credentials +//! in ~/.claude/ and the `claude` binary on PATH. This module lets LLM +//! Wiki reuse that subscription instead of requiring a separate API key. +//! We treat `claude` purely as a text-completion engine — its agent +//! tools, MCPs, file-edit abilities, and --resume session state are all +//! out of scope. Multi-turn history is reconstructed from `messages` +//! on every call, symmetric with every other provider. +//! +//! Why tokio::process directly (not tauri-plugin-shell): the plugin's +//! scope model is designed for sidecars or fixed absolute paths; scoping +//! a user-installed PATH binary cleanly is awkward. A hardcoded Rust +//! command that always and only spawns `claude` provides the same +//! security property (the webview can't call this command to execute +//! anything else) without pulling in another plugin or editing +//! capabilities JSON. + +use std::collections::HashMap; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, State}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::Mutex; + +/// Shared state holding running `claude` child processes keyed by the +/// frontend-generated stream id. Registered via .manage() in lib.rs. +#[derive(Default)] +pub struct ClaudeCliState { + children: Arc>>, +} + +#[derive(Serialize)] +pub struct DetectResult { + installed: bool, + version: Option, + path: Option, + /// When !installed, a short human-readable reason (missing from PATH, + /// quarantined on macOS, spawn failed, etc). The frontend shows this + /// verbatim in the status pill. + error: Option, +} + +#[derive(Deserialize)] +pub struct ClaudeMessage { + /// "system" | "user" | "assistant" + role: String, + content: String, +} + +/// Locate `claude` on PATH and confirm it's runnable by calling +/// `claude --version` with a short timeout. Cheap — safe to call on +/// mount of the settings panel. +#[tauri::command] +pub async fn claude_cli_detect() -> Result { + let path = match which::which("claude") { + Ok(p) => p, + Err(_) => { + return Ok(DetectResult { + installed: false, + version: None, + path: None, + error: Some("`claude` not found on PATH".to_string()), + }); + } + }; + + let path_str = path.to_string_lossy().to_string(); + + let output = tokio::time::timeout( + Duration::from_secs(3), + Command::new(&path).arg("--version").output(), + ) + .await; + + match output { + Ok(Ok(out)) if out.status.success() => { + let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok(DetectResult { + installed: true, + version: Some(version), + path: Some(path_str), + error: None, + }) + } + Ok(Ok(out)) => { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + // macOS Gatekeeper quarantines produce a predictable error. If + // we detect it, surface the remediation hint directly; the UI + // renders this string into an actionable message. + let error = if stderr.contains("quarantine") || stderr.contains("damaged") { + Some(format!( + "Binary quarantined — try: xattr -d com.apple.quarantine {path_str}" + )) + } else if stderr.is_empty() { + Some(format!("`claude --version` exited with {}", out.status)) + } else { + Some(stderr) + }; + Ok(DetectResult { + installed: false, + version: None, + path: Some(path_str), + error, + }) + } + Ok(Err(e)) => Ok(DetectResult { + installed: false, + version: None, + path: Some(path_str), + error: Some(format!("Failed to spawn `claude`: {e}")), + }), + Err(_) => Ok(DetectResult { + installed: false, + version: None, + path: Some(path_str), + error: Some("`claude --version` timed out after 3s".to_string()), + }), + } +} + +/// Spawn `claude -p --output-format stream-json --input-format stream-json +/// --verbose --model ` and pipe stdout back to the frontend as +/// `claude-cli:{stream_id}` events (one line per event). Closes stdin +/// after writing the serialized history so claude starts processing. +/// Emits a final `claude-cli:{stream_id}:done` event with `{ code }` +/// when the child exits. +#[tauri::command] +pub async fn claude_cli_spawn( + app: AppHandle, + state: State<'_, ClaudeCliState>, + stream_id: String, + model: String, + messages: Vec, +) -> Result<(), String> { + // Build the turn list: fold any system messages into a preamble on + // the first user turn rather than using a CLI flag, because + // --system-prompt / --append-system-prompt availability varies + // across claude CLI versions. Inlining works on every version. + let system_preamble: String = messages + .iter() + .filter(|m| m.role == "system") + .map(|m| m.content.clone()) + .collect::>() + .join("\n\n"); + + let conversation: Vec<&ClaudeMessage> = messages + .iter() + .filter(|m| m.role == "user" || m.role == "assistant") + .collect(); + + if conversation.is_empty() { + return Err("No user/assistant messages to send to claude CLI".to_string()); + } + + // Synthesize turns with the preamble merged into the first user turn. + let mut first_user_seen = false; + let turns: Vec<(String, String)> = conversation + .iter() + .map(|m| { + let role = m.role.clone(); + let mut content = m.content.clone(); + if !first_user_seen && role == "user" && !system_preamble.is_empty() { + content = format!("{system_preamble}\n\n{content}"); + first_user_seen = true; + } + (role, content) + }) + .collect(); + + let mut cmd = Command::new("claude"); + cmd.arg("-p") + .arg("--output-format") + .arg("stream-json") + .arg("--input-format") + .arg("stream-json") + .arg("--verbose") + .arg("--model") + .arg(&model); + + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn claude: {e}"))?; + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "Missing stdin handle".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "Missing stdout handle".to_string())?; + let stderr = child + .stderr + .take() + .ok_or_else(|| "Missing stderr handle".to_string())?; + + // Serialize turns to stdin then close. stream-json input format + // expects one JSON event per line. Conversation history is laid out + // in order; the final user turn triggers claude's response. + // + // `content` MUST be an array of blocks, not a plain string. The CLI + // iterates content blocks looking for `tool_use_id` and crashes with + // `W is not an Object. (evaluating '"tool_use_id"in W')` if it + // encounters a raw string. User turns silently tolerated a string + // in light testing, but assistant turns reject it immediately, so + // we normalize both roles to the block-array form. + for (role, content) in &turns { + let event = serde_json::json!({ + "type": role, + "message": { + "role": role, + "content": [{ "type": "text", "text": content }], + } + }); + let line = format!("{}\n", event); + stdin + .write_all(line.as_bytes()) + .await + .map_err(|e| format!("Failed to write to claude stdin: {e}"))?; + } + stdin + .flush() + .await + .map_err(|e| format!("Failed to flush claude stdin: {e}"))?; + drop(stdin); + + // Register the child so `claude_cli_kill` can reach it. + state + .children + .lock() + .await + .insert(stream_id.clone(), child); + + let children = Arc::clone(&state.children); + let app_for_task = app.clone(); + let stream_id_task = stream_id.clone(); + let topic = format!("claude-cli:{stream_id}"); + let done_topic = format!("claude-cli:{stream_id}:done"); + + // Drain stdout line-by-line in a background task, emitting each + // line as an event. Completes when stdout closes (child exited). + tokio::spawn(async move { + let mut reader = BufReader::new(stdout).lines(); + let mut stderr_reader = BufReader::new(stderr).lines(); + let app = app_for_task; + + // Collect stderr in a background task so we can ship it with the + // final :done event — otherwise a non-zero exit produces only + // "exited with code N" with no diagnostic info on the frontend. + // Also echo each line to the tauri dev terminal so the developer + // can watch the CLI's stderr live while iterating. + let stderr_task = tokio::spawn(async move { + let mut collected = String::new(); + while let Ok(Some(line)) = stderr_reader.next_line().await { + eprintln!("[claude-cli stderr] {line}"); + collected.push_str(&line); + collected.push('\n'); + } + collected + }); + + loop { + match reader.next_line().await { + Ok(Some(line)) => { + if app.emit(&topic, line).is_err() { + break; + } + } + Ok(None) => break, + Err(e) => { + eprintln!("[claude-cli stdout] read error: {e}"); + break; + } + } + } + + // Wait for the child to fully exit so we can report its code. + // Don't hold the map lock across .wait() — kill could race. + let child_opt = children.lock().await.remove(&stream_id_task); + let exit_code = if let Some(mut child) = child_opt { + match child.wait().await { + Ok(status) => status.code(), + Err(_) => None, + } + } else { + // Already removed by claude_cli_kill — leave code as None. + None + }; + + let stderr_text = stderr_task.await.unwrap_or_default(); + + let _ = app.emit( + &done_topic, + serde_json::json!({ + "code": exit_code, + "stderr": stderr_text, + }), + ); + }); + + Ok(()) +} + +/// Kill a running child registered under `stream_id`. Called on +/// AbortSignal in the frontend. No-op if the id is unknown (e.g. the +/// process already exited). +#[tauri::command] +pub async fn claude_cli_kill( + state: State<'_, ClaudeCliState>, + stream_id: String, +) -> Result<(), String> { + if let Some(mut child) = state.children.lock().await.remove(&stream_id) { + let _ = child.start_kill(); + // Don't wait() here — the stdout-drain task already holds a + // wait future elsewhere when it can. Dropping the handle is + // enough; kill_on_drop ensures the SIGKILL is sent. + } + Ok(()) +} + diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5ff36f8b..2cb1623c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod claude_cli; pub mod fs; pub mod project; pub mod vectorstore; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2a90e32c..29c0d379 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -33,6 +33,10 @@ pub fn run() { if let Ok(dir) = app.path().resource_dir() { commands::fs::set_resource_dir_hint(dir); } + // Registry of running `claude` subprocesses, keyed by the + // frontend-generated stream id. Populated by claude_cli_spawn, + // drained on process exit or by claude_cli_kill. + app.manage(commands::claude_cli::ClaudeCliState::default()); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -53,6 +57,9 @@ pub fn run() { commands::vectorstore::vector_search, commands::vectorstore::vector_delete, commands::vectorstore::vector_count, + commands::claude_cli::claude_cli_detect, + commands::claude_cli::claude_cli_spawn, + commands::claude_cli::claude_cli_kill, ]) .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { diff --git a/src/components/settings/llm-presets.ts b/src/components/settings/llm-presets.ts index 7158a949..6a441317 100644 --- a/src/components/settings/llm-presets.ts +++ b/src/components/settings/llm-presets.ts @@ -15,6 +15,7 @@ export type Provider = | "ollama" | "custom" | "minimax" + | "claude-code" export interface LlmPreset { /** Stable id used as the dropdown value. */ @@ -72,6 +73,24 @@ export const LLM_PRESETS: LlmPreset[] = [ ], suggestedContextSize: 200000, }, + { + id: "claude-code-cli", + label: "Claude Code CLI (local)", + hint: "Uses the local `claude` binary — no API key needed", + provider: "claude-code", + defaultModel: "claude-sonnet-4-6", + // Mirrors anthropic preset; the CLI forwards to the same Anthropic + // backend, so model ids are identical. Users with a subscription + // can pick Opus/Sonnet/Haiku here without paying an API key bill. + suggestedModels: [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", + "claude-sonnet-4-5-20250929", + "claude-haiku-4-5-20251001", + ], + suggestedContextSize: 200000, + }, { id: "openai", label: "OpenAI (GPT)", diff --git a/src/components/settings/preset-resolver.ts b/src/components/settings/preset-resolver.ts index 7bbead0a..156c6047 100644 --- a/src/components/settings/preset-resolver.ts +++ b/src/components/settings/preset-resolver.ts @@ -41,6 +41,19 @@ export function resolveConfig( } } + if (preset.provider === "claude-code") { + // Subprocess transport — no apiKey, no endpoint URL. Model id is + // passed straight to `claude --model`. + return { + provider: "claude-code", + apiKey: "", + model, + ollamaUrl: fallback.ollamaUrl, + customEndpoint: fallback.customEndpoint, + maxContextSize, + } + } + // openai / anthropic / google / minimax — use fixed endpoint baked into the // provider dispatch. We still let users override baseUrl via apiKey env if // needed by editing manually, but presets for these don't expose it. diff --git a/src/components/settings/sections/llm-provider-section.tsx b/src/components/settings/sections/llm-provider-section.tsx index 5afc2713..ed8f791b 100644 --- a/src/components/settings/sections/llm-provider-section.tsx +++ b/src/components/settings/sections/llm-provider-section.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from "react" -import { ChevronDown, ChevronRight, AlertCircle, CheckCircle2 } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import { ChevronDown, ChevronRight, AlertCircle, CheckCircle2, Loader2, XCircle } from "lucide-react" import { useTranslation } from "react-i18next" +import { invoke } from "@tauri-apps/api/core" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { useWikiStore, type ProviderOverride } from "@/stores/wiki-store" @@ -117,7 +118,10 @@ function PresetRow({ const baseUrl = ov.baseUrl ?? preset.baseUrl ?? "" const context = ov.maxContextSize ?? preset.suggestedContextSize ?? 131072 const hasConfig = !!apiKey || !!ov.baseUrl || !!ov.model - const needsApiKey = preset.provider !== "ollama" + // Claude Code CLI authenticates via the user's existing ~/.claude OAuth + // (inherited from the spawned subprocess), so no API key field is + // shown. Ollama ditto for its local-only model. + const needsApiKey = preset.provider !== "ollama" && preset.provider !== "claude-code" return (
)} + {preset.provider === "claude-code" && } + {needsApiKey && (
@@ -405,3 +411,100 @@ function ModelPicker({ value, suggestions, placeholder, onChange }: ModelPickerP
) } + +interface DetectResult { + installed: boolean + version: string | null + path: string | null + error: string | null +} + +/** + * Health-check pill for the Claude Code CLI provider. Auto-runs + * `claude --version` on mount, with a refresh button for when the user + * just installed the binary and wants to re-check without reopening the + * panel. The error message comes straight from the Rust side — it + * already tailors the hint (macOS quarantine, missing binary, etc). + */ +function ClaudeCliStatusPill() { + const [state, setState] = useState<"loading" | "ok" | "err">("loading") + const [result, setResult] = useState(null) + + async function detect() { + setState("loading") + try { + const r = await invoke("claude_cli_detect") + setResult(r) + setState(r.installed ? "ok" : "err") + } catch (e) { + setResult({ + installed: false, + version: null, + path: null, + error: e instanceof Error ? e.message : String(e), + }) + setState("err") + } + } + + useEffect(() => { + void detect() + }, []) + + return ( +
+
+ + +
+
+ {state === "loading" && } + {state === "ok" && } + {state === "err" && } +
+ {state === "loading" &&
Detecting local claude binary…
} + {state === "ok" && ( + <> +
+ Detected{result?.version ? ` ${result.version}` : ""}. Ready to use your local + subscription — no API key needed. +
+ {result?.path && ( +
+ {result.path} +
+ )} + + )} + {state === "err" && ( + <> +
{result?.error ?? "claude CLI not available."}
+
+ Install from{" "} + + npm i -g @anthropic-ai/claude-code + {" "} + then re-check. +
+ + )} +
+
+
+ ) +} diff --git a/src/components/settings/settings-types.ts b/src/components/settings/settings-types.ts index a74a8e82..98019ad5 100644 --- a/src/components/settings/settings-types.ts +++ b/src/components/settings/settings-types.ts @@ -8,7 +8,7 @@ import type { CustomApiMode } from "./llm-presets" */ export interface SettingsDraft { // LLM provider - provider: "openai" | "anthropic" | "google" | "ollama" | "custom" | "minimax" + provider: "openai" | "anthropic" | "google" | "ollama" | "custom" | "minimax" | "claude-code" apiKey: string model: string ollamaUrl: string diff --git a/src/lib/__tests__/claude-cli-transport.test.ts b/src/lib/__tests__/claude-cli-transport.test.ts new file mode 100644 index 00000000..508b7153 --- /dev/null +++ b/src/lib/__tests__/claude-cli-transport.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest" +import { createClaudeCodeStreamParser } from "../claude-cli-transport" + +describe("createClaudeCodeStreamParser", () => { + it("emits text from a single stream_event text_delta", () => { + const parse = createClaudeCodeStreamParser() + const line = JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "text_delta", text: "Hello" }, + }, + }) + expect(parse(line)).toBe("Hello") + }) + + it("accumulates multiple stream_event deltas in order", () => { + const parse = createClaudeCodeStreamParser() + const mk = (t: string) => + JSON.stringify({ + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text: t } }, + }) + expect(parse(mk("Hello "))).toBe("Hello ") + expect(parse(mk("world"))).toBe("world") + expect(parse(mk("!"))).toBe("!") + }) + + it("falls back to `assistant` message text when no deltas arrived", () => { + const parse = createClaudeCodeStreamParser() + const line = JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Hi there" }] }, + }) + expect(parse(line)).toBe("Hi there") + }) + + it("emits only the novel tail when `assistant` events ship cumulative text", () => { + // Older claude CLI versions re-send the full in-progress message on + // each assistant event instead of emitting deltas. The parser must + // diff those so the UI doesn't render "HiHi thereHi there, friend". + const parse = createClaudeCodeStreamParser() + const mk = (t: string) => + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: t }] } }) + expect(parse(mk("Hi"))).toBe("Hi") + expect(parse(mk("Hi there"))).toBe(" there") + expect(parse(mk("Hi there, friend"))).toBe(", friend") + }) + + it("skips `assistant` events entirely once stream_event deltas are seen", () => { + // When both event types are present (newer CLIs with --verbose), + // deltas are authoritative and the fat `assistant` events would + // duplicate text if we emitted them. + const parse = createClaudeCodeStreamParser() + const delta = JSON.stringify({ + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text: "Hi" } }, + }) + const asst = JSON.stringify({ + type: "assistant", + message: { content: [{ type: "text", text: "Hi" }] }, + }) + expect(parse(delta)).toBe("Hi") + expect(parse(asst)).toBeNull() + }) + + it("concatenates multiple text parts inside one `assistant` event", () => { + const parse = createClaudeCodeStreamParser() + const line = JSON.stringify({ + type: "assistant", + message: { + content: [ + { type: "text", text: "Part one. " }, + { type: "tool_use", id: "x", name: "bash", input: {} }, + { type: "text", text: "Part two." }, + ], + }, + }) + expect(parse(line)).toBe("Part one. Part two.") + }) + + it("returns null for system init, result, tool_use, and unknown types", () => { + const parse = createClaudeCodeStreamParser() + expect(parse(JSON.stringify({ type: "system", subtype: "init" }))).toBeNull() + expect(parse(JSON.stringify({ type: "result", subtype: "success", result: "done" }))).toBeNull() + expect(parse(JSON.stringify({ type: "tool_use", id: "x" }))).toBeNull() + expect(parse(JSON.stringify({ type: "future_type_we_dont_know" }))).toBeNull() + }) + + it("returns null for malformed JSON or blank lines", () => { + const parse = createClaudeCodeStreamParser() + expect(parse("")).toBeNull() + expect(parse(" ")).toBeNull() + expect(parse("not json at all")).toBeNull() + expect(parse("{bad json")).toBeNull() + }) + + it("returns null for stream_event shapes we don't recognize (usage/etc.)", () => { + const parse = createClaudeCodeStreamParser() + // e.g. message_start / message_delta / ping — Anthropic lifecycle + // events that carry no user-visible text. + expect( + parse( + JSON.stringify({ + type: "stream_event", + event: { type: "message_start", message: { id: "m" } }, + }), + ), + ).toBeNull() + expect( + parse( + JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { type: "input_json_delta", partial_json: "{\"a\":" }, + }, + }), + ), + ).toBeNull() + }) +}) diff --git a/src/lib/__tests__/llm-providers.test.ts b/src/lib/__tests__/llm-providers.test.ts index 9e81bd29..d1118d3a 100644 --- a/src/lib/__tests__/llm-providers.test.ts +++ b/src/lib/__tests__/llm-providers.test.ts @@ -194,6 +194,24 @@ describe("parseGoogleLine — Gemini SSE parsing", () => { }) }) +describe("Claude Code CLI provider — not reachable via getProviderConfig", () => { + it("throws, because the subprocess transport dispatches one layer up in streamChat", () => { + // If this ever stops throwing, someone wired claude-code into the + // HTTP path by mistake — it has no URL/headers and would crash + // silently inside fetch() otherwise. + expect(() => + getProviderConfig({ + provider: "claude-code", + apiKey: "", + model: "claude-sonnet-4-6", + ollamaUrl: "", + customEndpoint: "", + maxContextSize: 200000, + } as RealLlmConfig), + ).toThrow(/subprocess transport/) + }) +}) + describe("Google provider URL — model path encoding", () => { const makeGoogleConfig = (model: string): RealLlmConfig => ({ provider: "google", diff --git a/src/lib/claude-cli-transport.ts b/src/lib/claude-cli-transport.ts new file mode 100644 index 00000000..53d0cf91 --- /dev/null +++ b/src/lib/claude-cli-transport.ts @@ -0,0 +1,212 @@ +/** + * Claude Code CLI subprocess transport. + * + * Rust-side counterpart: src-tauri/src/commands/claude_cli.rs. The Rust + * commands spawn `claude -p --output-format stream-json + * --input-format stream-json --verbose --model `, pipe the + * serialized history over stdin, and emit stdout back as + * `claude-cli:{streamId}` events (one line per event). This module + * listens for those events, parses each line as a stream-json event, + * and forwards assistant text to `onToken`. + */ + +import { invoke } from "@tauri-apps/api/core" +import { listen, type UnlistenFn } from "@tauri-apps/api/event" +import type { LlmConfig } from "@/stores/wiki-store" +import type { ChatMessage, RequestOverrides } from "./llm-providers" +import type { StreamCallbacks } from "./llm-client" + +/** + * Public parse entry point. Given one stream-json line from claude's + * stdout, returns any assistant text it contains (or null for events + * that carry no user-visible text: session init, tool_use, result, etc.). + * + * State is carried in a small closure because `assistant` events ship + * the full in-progress message on every emission (NOT incremental), but + * `stream_event` passthrough (emitted when --verbose is on) carries + * real token-level deltas. To avoid double-counting, we prefer deltas + * when they arrive and skip the fat `assistant` events after seeing one. + */ +export function createClaudeCodeStreamParser() { + let sawDelta = false + // Track the running text we have emitted for the current assistant + // turn via `assistant` events so we can diff new content off the end + // and only emit what wasn't already streamed. + let emittedFromAssistant = "" + + return function parseLine(rawLine: string): string | null { + const line = rawLine.trim() + if (!line) return null + + let evt: unknown + try { + evt = JSON.parse(line) + } catch { + return null + } + + if (!evt || typeof evt !== "object") return null + const obj = evt as Record + const type = obj.type + + // Real streaming deltas (passthrough from Anthropic API when + // --verbose is active on newer claude CLI versions). + if (type === "stream_event") { + const event = obj.event as Record | undefined + if (event?.type === "content_block_delta") { + const delta = event.delta as Record | undefined + if (delta?.type === "text_delta" && typeof delta.text === "string") { + sawDelta = true + return delta.text + } + } + return null + } + + // Full assistant message (older CLI versions or when deltas are + // unavailable). Ship only the portion we haven't already emitted + // via stream_event deltas, so streaming still works smoothly. + if (type === "assistant") { + const message = obj.message as Record | undefined + const content = message?.content + if (!Array.isArray(content)) return null + const text = content + .map((c) => { + const cc = c as Record + return cc.type === "text" && typeof cc.text === "string" ? cc.text : "" + }) + .join("") + if (!text) return null + + if (sawDelta) { + // Deltas already covered this turn; skip the fat assistant event. + return null + } + if (text.startsWith(emittedFromAssistant)) { + const novel = text.slice(emittedFromAssistant.length) + emittedFromAssistant = text + return novel || null + } + // Non-prefix change: cli sent something different than expected. + // Reset tracker and emit the new text wholesale. + emittedFromAssistant = text + return text + } + + // Ignore session init, tool_use, result summary, unknown types. + return null + } +} + +interface SpawnPayload { + streamId: string + model: string + messages: ChatMessage[] +} + +/** + * Subprocess equivalent of the HTTP path in streamChat. Obeys the same + * StreamCallbacks contract so chat-panel code doesn't need to know + * which transport it's talking to. + */ +export async function streamClaudeCodeCli( + config: LlmConfig, + messages: ChatMessage[], + callbacks: StreamCallbacks, + signal?: AbortSignal, + overrides?: RequestOverrides, +): Promise { + const { onToken, onDone, onError } = callbacks + + // Sampling knobs aren't wired through the Claude Code CLI (no flag + // equivalents for temperature/top_p/max_tokens/stop). Warn loudly in + // dev so a caller wiring these up doesn't silently wonder why they + // don't take effect; keep quiet in prod so regular users aren't + // alarmed by a reasonable default. + if (import.meta.env?.DEV && overrides) { + for (const key of ["temperature", "top_p", "top_k", "max_tokens", "stop"] as const) { + if (overrides[key] !== undefined) { + // eslint-disable-next-line no-console + console.warn(`[claude-code] ignoring unsupported override "${key}": CLI has no equivalent flag`) + } + } + } + + const streamId = crypto.randomUUID() + const parse = createClaudeCodeStreamParser() + + let unlistenData: UnlistenFn | undefined + let unlistenDone: UnlistenFn | undefined + let finished = false + + const cleanup = () => { + unlistenData?.() + unlistenDone?.() + } + + const finishWith = (cb: () => void) => { + if (finished) return + finished = true + cleanup() + cb() + } + + const abortListener = () => { + void invoke("claude_cli_kill", { streamId }).catch(() => { + // Kill is best-effort; if the process already exited, the Rust + // side returns Ok and the done handler fires normally. + }) + finishWith(onDone) + } + signal?.addEventListener("abort", abortListener) + + try { + // Listen FIRST so we don't miss the very first event on fast CLIs. + unlistenData = await listen(`claude-cli:${streamId}`, (event) => { + const token = parse(event.payload) + if (token !== null) onToken(token) + }) + + unlistenDone = await listen<{ code: number | null; stderr: string }>( + `claude-cli:${streamId}:done`, + (event) => { + const code = event.payload?.code + const stderr = event.payload?.stderr?.trim() ?? "" + if (code !== null && code !== undefined && code !== 0) { + // Include stderr in the message so the user sees the actual + // failure reason (missing flag, bad model id, auth issue, etc.) + // rather than a bare exit code. + const detail = stderr ? `: ${stderr}` : "" + finishWith(() => + onError(new Error(`claude CLI exited with code ${code}${detail}`)), + ) + } else { + finishWith(onDone) + } + }, + ) + + const payload: SpawnPayload = { + streamId, + model: config.model, + messages, + } + await invoke("claude_cli_spawn", payload) + } catch (err) { + finishWith(() => { + const message = err instanceof Error ? err.message : String(err) + // Surface the classic "CLI not installed" case as an actionable + // message — the Rust side returns a plain string from + // spawn-failed, but users need to know to install claude. + if (/not found|No such file|executable file not found/i.test(message)) { + onError(new Error( + "Claude Code CLI not found. Install `claude` (https://www.anthropic.com/claude-code) or pick a different provider.", + )) + } else { + onError(err instanceof Error ? err : new Error(message)) + } + }) + } finally { + signal?.removeEventListener("abort", abortListener) + } +} diff --git a/src/lib/llm-client.ts b/src/lib/llm-client.ts index 9e160c62..140aa3dd 100644 --- a/src/lib/llm-client.ts +++ b/src/lib/llm-client.ts @@ -11,6 +11,19 @@ export interface StreamCallbacks { onError: (error: Error) => void } +// Lazy import keeps the Tauri event/invoke bindings out of bundles that +// never touch the subprocess provider (e.g. vitest with a fetch mock). +async function streamViaClaudeCodeCli( + config: LlmConfig, + messages: import("./llm-providers").ChatMessage[], + callbacks: StreamCallbacks, + signal?: AbortSignal, + requestOverrides?: RequestOverrides, +) { + const mod = await import("./claude-cli-transport") + return mod.streamClaudeCodeCli(config, messages, callbacks, signal, requestOverrides) +} + const DECODER = new TextDecoder() function parseLines(chunk: Uint8Array, buffer: string): [string[], string] { @@ -36,6 +49,14 @@ export async function streamChat( requestOverrides?: RequestOverrides, ): Promise { const { onToken, onDone, onError } = callbacks + + // Claude Code CLI uses a subprocess transport (stdin/stdout), not + // HTTP. Dispatch before getProviderConfig — that function throws for + // this provider because it has no URL/headers. + if (config.provider === "claude-code") { + return streamViaClaudeCodeCli(config, messages, callbacks, signal, requestOverrides) + } + const providerConfig = getProviderConfig(config) // Combined abort: (a) user cancel, (b) our long-horizon timeout. diff --git a/src/lib/llm-providers.ts b/src/lib/llm-providers.ts index 4f2c492f..6b24847c 100644 --- a/src/lib/llm-providers.ts +++ b/src/lib/llm-providers.ts @@ -332,6 +332,15 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig { } } + case "claude-code": + // Claude Code CLI uses a subprocess transport (stdin/stdout JSON + // stream), not HTTP. Dispatch happens one layer up in + // streamChat() before getProviderConfig is called. Reaching this + // branch means wiring is broken somewhere upstream. + throw new Error( + "claude-code provider uses subprocess transport; getProviderConfig should not be called for it", + ) + case "custom": { // Custom endpoints can speak either OpenAI's /chat/completions // wire or Anthropic's /v1/messages wire. The field `apiMode` on diff --git a/src/stores/wiki-store.ts b/src/stores/wiki-store.ts index a5da187d..0e331524 100644 --- a/src/stores/wiki-store.ts +++ b/src/stores/wiki-store.ts @@ -10,7 +10,7 @@ import type { WikiProject, FileNode } from "@/types/wiki" export type CustomApiMode = "chat_completions" | "anthropic_messages" interface LlmConfig { - provider: "openai" | "anthropic" | "google" | "ollama" | "custom" | "minimax" + provider: "openai" | "anthropic" | "google" | "ollama" | "custom" | "minimax" | "claude-code" apiKey: string model: string ollamaUrl: string