Skip to content
Closed
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
26 changes: 26 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
330 changes: 330 additions & 0 deletions src-tauri/src/commands/claude_cli.rs
Original file line number Diff line number Diff line change
@@ -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<Mutex<HashMap<String, Child>>>,
}

#[derive(Serialize)]
pub struct DetectResult {
installed: bool,
version: Option<String>,
path: Option<String>,
/// 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<String>,
}

#[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<DetectResult, String> {
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 <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<ClaudeMessage>,
) -> 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::<Vec<_>>()
.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(())
}

Loading
Loading