diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs new file mode 100644 index 00000000..5bb46a48 --- /dev/null +++ b/src-tauri/src/claude_cli.rs @@ -0,0 +1,842 @@ +use serde::{Deserialize, Serialize}; +use std::io::BufRead; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +/// Status returned by `check_claude_cli`. +#[derive(Debug, Serialize, Clone)] +pub struct ClaudeCliStatus { + pub installed: bool, + pub version: Option, +} + +/// Event emitted to the frontend during a streaming claude session. +#[derive(Debug, Serialize, Clone)] +#[serde(tag = "kind")] +pub enum ClaudeStreamEvent { + /// Session initialised — carries the session ID for future `--resume`. + Init { session_id: String }, + /// Incremental text chunk. + TextDelta { text: String }, + /// A tool call started (agent mode only). + ToolStart { tool_name: String, tool_id: String }, + /// A tool call finished (agent mode only). + ToolDone { tool_id: String }, + /// Final result text + session ID. + Result { text: String, session_id: String }, + /// Something went wrong. + Error { message: String }, + /// Stream finished. + Done, +} + +/// Parameters accepted by `stream_claude_chat`. +#[derive(Debug, Deserialize)] +pub struct ChatStreamRequest { + pub message: String, + pub system_prompt: Option, + pub session_id: Option, +} + +/// Parameters accepted by `stream_claude_agent`. +#[derive(Debug, Deserialize)] +pub struct AgentStreamRequest { + pub message: String, + pub system_prompt: Option, + pub vault_path: String, +} + +// --------------------------------------------------------------------------- +// Finding the `claude` binary +// --------------------------------------------------------------------------- + +pub(crate) fn find_claude_binary() -> Result { + // Try `which claude` first (works when PATH is inherited). + let output = Command::new("which") + .arg("claude") + .output() + .map_err(|e| format!("Failed to run `which claude`: {e}"))?; + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path.is_empty() { + return Ok(PathBuf::from(path)); + } + } + + // Fallback: check common install locations. + let home = dirs::home_dir().unwrap_or_default(); + let candidates = [ + home.join(".local/bin/claude"), + home.join(".npm/bin/claude"), + PathBuf::from("/usr/local/bin/claude"), + PathBuf::from("/opt/homebrew/bin/claude"), + ]; + for p in &candidates { + if p.exists() { + return Ok(p.clone()); + } + } + + Err("Claude CLI not found. Install it: https://docs.anthropic.com/en/docs/claude-code".into()) +} + +// --------------------------------------------------------------------------- +// Public Tauri commands +// --------------------------------------------------------------------------- + +/// Check whether the `claude` CLI is installed and return its version. +pub fn check_cli() -> ClaudeCliStatus { + let bin = match find_claude_binary() { + Ok(b) => b, + Err(_) => { + return ClaudeCliStatus { + installed: false, + version: None, + } + } + }; + + let version = Command::new(&bin) + .arg("--version") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); + + ClaudeCliStatus { + installed: true, + version, + } +} + +/// Spawn `claude -p` for a simple chat (no tools) and stream events via the +/// provided callback. Returns the session ID for future `--resume` calls. +pub fn run_chat_stream(req: ChatStreamRequest, mut emit: F) -> Result +where + F: FnMut(ClaudeStreamEvent), +{ + let bin = find_claude_binary()?; + let args = build_chat_args(&req); + run_claude_subprocess(&bin, &args, &mut emit) +} + +/// Build CLI arguments for a chat stream request. +fn build_chat_args(req: &ChatStreamRequest) -> Vec { + let mut args: Vec = vec![ + "-p".into(), + req.message.clone(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--include-partial-messages".into(), + "--tools".into(), + String::new(), // empty string → disable all built-in tools + ]; + + if let Some(ref sp) = req.system_prompt { + if !sp.is_empty() { + args.push("--system-prompt".into()); + args.push(sp.clone()); + } + } + + if let Some(ref sid) = req.session_id { + args.push("--resume".into()); + args.push(sid.clone()); + } + + args +} + +/// Spawn `claude -p` with MCP vault tools for an agent task and stream events. +pub fn run_agent_stream(req: AgentStreamRequest, mut emit: F) -> Result +where + F: FnMut(ClaudeStreamEvent), +{ + let bin = find_claude_binary()?; + let args = build_agent_args(&req)?; + run_claude_subprocess(&bin, &args, &mut emit) +} + +/// Build CLI arguments for an agent stream request. +fn build_agent_args(req: &AgentStreamRequest) -> Result, String> { + let mcp_config = build_mcp_config(&req.vault_path)?; + + let mut args: Vec = vec![ + "-p".into(), + req.message.clone(), + "--output-format".into(), + "stream-json".into(), + "--verbose".into(), + "--include-partial-messages".into(), + "--tools".into(), + String::new(), // disable built-in tools; MCP tools remain + "--mcp-config".into(), + mcp_config, + "--dangerously-skip-permissions".into(), + "--no-session-persistence".into(), + ]; + + if let Some(ref sp) = req.system_prompt { + if !sp.is_empty() { + args.push("--append-system-prompt".into()); + args.push(sp.clone()); + } + } + + Ok(args) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Build a temporary MCP config JSON string pointing to the vault MCP server. +fn build_mcp_config(vault_path: &str) -> Result { + let server_dir = crate::mcp::mcp_server_dir()?; + let index_js = server_dir.join("index.js"); + let config = serde_json::json!({ + "mcpServers": { + "laputa": { + "command": "node", + "args": [index_js.to_string_lossy()], + "env": { "VAULT_PATH": vault_path } + } + } + }); + serde_json::to_string(&config).map_err(|e| format!("Failed to serialise MCP config: {e}")) +} + +/// Core subprocess runner shared by chat and agent modes. +fn run_claude_subprocess(bin: &PathBuf, args: &[String], emit: &mut F) -> Result +where + F: FnMut(ClaudeStreamEvent), +{ + let mut child = Command::new(bin) + .args(args) + .env_remove("CLAUDECODE") // prevent "nested session" guard + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn claude: {e}"))?; + + let stdout = child.stdout.take().ok_or("No stdout handle")?; + let reader = std::io::BufReader::new(stdout); + + let mut session_id = String::new(); + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + emit(ClaudeStreamEvent::Error { + message: format!("Read error: {e}"), + }); + break; + } + }; + + if line.trim().is_empty() { + continue; + } + + let json: serde_json::Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, // skip non-JSON lines + }; + + dispatch_event(&json, &mut session_id, emit); + } + + // Read stderr for potential error messages. + let stderr_output = child + .stderr + .take() + .and_then(|s| std::io::read_to_string(s).ok()) + .unwrap_or_default(); + + let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?; + + if !status.success() && session_id.is_empty() { + let msg = if stderr_output.contains("not logged in") + || stderr_output.contains("authentication") + || stderr_output.contains("auth") + { + "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into() + } else if stderr_output.is_empty() { + format!("claude exited with status {status}") + } else { + stderr_output.lines().take(3).collect::>().join("\n") + }; + emit(ClaudeStreamEvent::Error { message: msg }); + } + + emit(ClaudeStreamEvent::Done); + + Ok(session_id) +} + +/// Parse a single JSON line from the stream and emit the appropriate event. +fn dispatch_event(json: &serde_json::Value, session_id: &mut String, emit: &mut F) +where + F: FnMut(ClaudeStreamEvent), +{ + let msg_type = json["type"].as_str().unwrap_or(""); + + match msg_type { + // --- System init → capture session_id --- + "system" if json["subtype"].as_str() == Some("init") => { + if let Some(sid) = json["session_id"].as_str() { + *session_id = sid.to_string(); + emit(ClaudeStreamEvent::Init { + session_id: sid.to_string(), + }); + } + } + + // --- Streaming partial events (text deltas, tool_use starts) --- + "stream_event" => { + dispatch_stream_event(json, emit); + } + + // --- Tool progress (agent mode) --- + "tool_progress" => { + if let (Some(name), Some(id)) = + (json["tool_name"].as_str(), json["tool_use_id"].as_str()) + { + emit(ClaudeStreamEvent::ToolStart { + tool_name: name.to_string(), + tool_id: id.to_string(), + }); + } + } + + // --- Final result --- + "result" => { + let sid = json["session_id"].as_str().unwrap_or("").to_string(); + if !sid.is_empty() { + *session_id = sid.clone(); + } + let text = json["result"].as_str().unwrap_or("").to_string(); + emit(ClaudeStreamEvent::Result { + text, + session_id: sid, + }); + } + + // --- Complete assistant message (fallback for text when no partials) --- + "assistant" => { + if let Some(content) = json["message"]["content"].as_array() { + for block in content { + if block["type"].as_str() == Some("tool_use") { + if let (Some(id), Some(name)) = + (block["id"].as_str(), block["name"].as_str()) + { + emit(ClaudeStreamEvent::ToolStart { + tool_name: name.to_string(), + tool_id: id.to_string(), + }); + } + } + } + } + } + + _ => {} // ignore other event types + } +} + +/// Handle a `stream_event` (partial assistant message). +fn dispatch_stream_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(ClaudeStreamEvent), +{ + let event = &json["event"]; + let event_type = event["type"].as_str().unwrap_or(""); + + match event_type { + "content_block_delta" => { + let delta = &event["delta"]; + if delta["type"].as_str() == Some("text_delta") { + if let Some(text) = delta["text"].as_str() { + emit(ClaudeStreamEvent::TextDelta { + text: text.to_string(), + }); + } + } + } + "content_block_start" => { + let block = &event["content_block"]; + if block["type"].as_str() == Some("tool_use") { + if let (Some(id), Some(name)) = (block["id"].as_str(), block["name"].as_str()) { + emit(ClaudeStreamEvent::ToolStart { + tool_name: name.to_string(), + tool_id: id.to_string(), + }); + } + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_cli_returns_status() { + let status = check_cli(); + if status.installed { + assert!(status.version.is_some()); + } else { + assert!(status.version.is_none()); + } + } + + #[test] + fn build_mcp_config_is_valid_json() { + if let Ok(config_str) = build_mcp_config("/tmp/test-vault") { + let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap(); + assert!(parsed["mcpServers"]["laputa"]["command"].is_string()); + assert_eq!( + parsed["mcpServers"]["laputa"]["env"]["VAULT_PATH"], + "/tmp/test-vault" + ); + } + } + + // --- dispatch_event / dispatch_stream_event --- + + /// Run dispatch_event on the given JSON and return (session_id, events). + fn run_dispatch(json: serde_json::Value) -> (String, Vec) { + let mut sid = String::new(); + let mut events = vec![]; + dispatch_event(&json, &mut sid, &mut |e| events.push(e)); + (sid, events) + } + + /// Run dispatch_event with a pre-set session_id. + fn run_dispatch_with_sid( + json: serde_json::Value, + initial_sid: &str, + ) -> (String, Vec) { + let mut sid = initial_sid.to_string(); + let mut events = vec![]; + dispatch_event(&json, &mut sid, &mut |e| events.push(e)); + (sid, events) + } + + #[test] + fn dispatch_event_handles_init() { + let (sid, events) = run_dispatch(serde_json::json!({ + "type": "system", "subtype": "init", "session_id": "test-session-123" + })); + assert_eq!(sid, "test-session-123"); + assert!( + matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "test-session-123") + ); + } + + #[test] + fn dispatch_event_system_without_init_subtype_is_ignored() { + let (_, events) = run_dispatch(serde_json::json!({ "type": "system", "subtype": "other" })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_event_system_init_without_session_id_is_ignored() { + let (sid, events) = + run_dispatch(serde_json::json!({ "type": "system", "subtype": "init" })); + assert!(events.is_empty()); + assert!(sid.is_empty()); + } + + #[test] + fn dispatch_event_handles_text_delta() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } } + })); + assert!(matches!(&events[0], ClaudeStreamEvent::TextDelta { text } if text == "Hello")); + } + + #[test] + fn dispatch_event_handles_tool_start() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_start", "index": 1, "content_block": { "type": "tool_use", "id": "tool_abc", "name": "read_note", "input": {} } } + })); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "read_note" && tool_id == "tool_abc") + ); + } + + #[test] + fn dispatch_event_handles_result() { + let (sid, events) = run_dispatch(serde_json::json!({ + "type": "result", "subtype": "success", "result": "All done!", "session_id": "sess-456" + })); + assert_eq!(sid, "sess-456"); + assert!( + matches!(&events[0], ClaudeStreamEvent::Result { text, session_id } if text == "All done!" && session_id == "sess-456") + ); + } + + #[test] + fn dispatch_event_result_with_empty_session_id() { + let (sid, events) = run_dispatch_with_sid( + serde_json::json!({ "type": "result", "result": "text here" }), + "prev-session", + ); + assert_eq!(sid, "prev-session"); + assert!( + matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "text here") + ); + } + + #[test] + fn dispatch_event_handles_tool_progress() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "tool_progress", "tool_name": "search_notes", "tool_use_id": "tool_xyz" + })); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tool_xyz") + ); + } + + #[test] + fn dispatch_event_tool_progress_missing_fields_is_ignored() { + let (_, events) = + run_dispatch(serde_json::json!({ "type": "tool_progress", "tool_name": "x" })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_event_handles_assistant_with_tool_use() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "assistant", + "message": { "content": [ + { "type": "text", "text": "Let me search." }, + { "type": "tool_use", "id": "tu_1", "name": "search_notes", "input": {} } + ] } + })); + assert_eq!(events.len(), 1); + assert!( + matches!(&events[0], ClaudeStreamEvent::ToolStart { tool_name, tool_id } if tool_name == "search_notes" && tool_id == "tu_1") + ); + } + + #[test] + fn dispatch_event_assistant_without_content_is_noop() { + let (_, events) = run_dispatch(serde_json::json!({ "type": "assistant", "message": {} })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_event_ignores_unknown() { + let (_, events) = + run_dispatch(serde_json::json!({ "type": "some_future_type", "data": 42 })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_stream_event_non_text_delta_is_ignored() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "input_json_delta", "partial_json": "{}" } } + })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_stream_event_non_tool_block_start_is_ignored() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", + "event": { "type": "content_block_start", "index": 0, "content_block": { "type": "text", "text": "" } } + })); + assert!(events.is_empty()); + } + + #[test] + fn dispatch_stream_event_unknown_type_is_ignored() { + let (_, events) = run_dispatch(serde_json::json!({ + "type": "stream_event", "event": { "type": "message_stop" } + })); + assert!(events.is_empty()); + } + + // --- run_claude_subprocess with mock scripts --- + + #[cfg(unix)] + fn run_mock_script(script: &str) -> (Result, Vec) { + run_mock_script_with_args(script, &[]) + } + + #[cfg(unix)] + fn run_mock_script_with_args( + script: &str, + args: &[String], + ) -> (Result, Vec) { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mock-claude"); + std::fs::write(&path, script).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut events = vec![]; + let result = run_claude_subprocess(&path, args, &mut |e| events.push(e)); + (result, events) + } + + #[cfg(unix)] + #[test] + fn run_subprocess_parses_ndjson_stream() { + let (result, events) = run_mock_script(concat!( + "#!/bin/sh\n", + "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s1\"}'\n", + "echo '{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hi\"}}}'\n", + "echo '{\"type\":\"result\",\"result\":\"Done\",\"session_id\":\"s1\"}'\n", + )); + assert_eq!(result.unwrap(), "s1"); + assert!(matches!(&events[0], ClaudeStreamEvent::Init { session_id } if session_id == "s1")); + assert!(matches!(&events[1], ClaudeStreamEvent::TextDelta { text } if text == "Hi")); + assert!(matches!(&events[2], ClaudeStreamEvent::Result { .. })); + assert!(matches!(&events[3], ClaudeStreamEvent::Done)); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_skips_blank_and_non_json_lines() { + let (result, events) = run_mock_script(concat!( + "#!/bin/sh\n", + "echo ''\n", + "echo 'not json at all'\n", + "echo '{\"type\":\"result\",\"result\":\"ok\",\"session_id\":\"s2\"}'\n", + )); + assert_eq!(result.unwrap(), "s2"); + assert!(matches!(&events[0], ClaudeStreamEvent::Result { text, .. } if text == "ok")); + assert!(matches!(&events[1], ClaudeStreamEvent::Done)); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_emits_error_on_nonzero_exit() { + let (_, events) = run_mock_script("#!/bin/sh\necho 'auth problem' >&2\nexit 1\n"); + assert!(events + .iter() + .any(|e| matches!(e, ClaudeStreamEvent::Error { .. }))); + assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done)); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_detects_auth_error_in_stderr() { + let (_, events) = run_mock_script("#!/bin/sh\necho 'not logged in' >&2\nexit 1\n"); + assert!(events.iter().any(|e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("not authenticated")))); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_reports_exit_code_on_empty_stderr() { + let (_, events) = run_mock_script("#!/bin/sh\nexit 2\n"); + assert!(events.iter().any( + |e| matches!(e, ClaudeStreamEvent::Error { message } if message.contains("exited with")) + )); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_success_with_no_events() { + let (result, events) = run_mock_script("#!/bin/sh\nexit 0\n"); + assert!(result.is_ok()); + assert!(matches!(events.last().unwrap(), ClaudeStreamEvent::Done)); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_passes_args_through() { + let args: Vec = vec!["--foo".into(), "bar".into()]; + let (_, events) = run_mock_script_with_args(concat!( + "#!/bin/sh\n", + "echo \"{\\\"type\\\":\\\"result\\\",\\\"result\\\":\\\"$*\\\",\\\"session_id\\\":\\\"sx\\\"}\"\n", + ), &args); + let text = events.iter().find_map(|e| match e { + ClaudeStreamEvent::Result { text, .. } => Some(text.as_str()), + _ => None, + }); + assert!(text.unwrap().contains("--foo")); + } + + // --- build_chat_args --- + + #[test] + fn build_chat_args_basic() { + let req = ChatStreamRequest { + message: "hello".into(), + system_prompt: None, + session_id: None, + }; + let args = build_chat_args(&req); + assert!(args.contains(&"-p".to_string())); + assert!(args.contains(&"hello".to_string())); + assert!(args.contains(&"stream-json".to_string())); + assert!(!args.contains(&"--system-prompt".to_string())); + assert!(!args.contains(&"--resume".to_string())); + } + + #[test] + fn build_chat_args_with_system_prompt() { + let req = ChatStreamRequest { + message: "hi".into(), + system_prompt: Some("You are helpful.".into()), + session_id: None, + }; + let args = build_chat_args(&req); + assert!(args.contains(&"--system-prompt".to_string())); + assert!(args.contains(&"You are helpful.".to_string())); + } + + #[test] + fn build_chat_args_empty_system_prompt_is_skipped() { + let req = ChatStreamRequest { + message: "hi".into(), + system_prompt: Some(String::new()), + session_id: None, + }; + let args = build_chat_args(&req); + assert!(!args.contains(&"--system-prompt".to_string())); + } + + #[test] + fn build_chat_args_with_session_id() { + let req = ChatStreamRequest { + message: "continue".into(), + system_prompt: None, + session_id: Some("sess-abc".into()), + }; + let args = build_chat_args(&req); + assert!(args.contains(&"--resume".to_string())); + assert!(args.contains(&"sess-abc".to_string())); + } + + // --- build_agent_args --- + + #[test] + fn build_agent_args_basic() { + // build_agent_args calls build_mcp_config which needs mcp_server_dir + if let Ok(args) = build_agent_args(&AgentStreamRequest { + message: "create note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + }) { + assert!(args.contains(&"-p".to_string())); + assert!(args.contains(&"create note".to_string())); + assert!(args.contains(&"--mcp-config".to_string())); + assert!(args.contains(&"--dangerously-skip-permissions".to_string())); + assert!(args.contains(&"--no-session-persistence".to_string())); + assert!(!args.contains(&"--append-system-prompt".to_string())); + } + } + + #[test] + fn build_agent_args_with_system_prompt() { + if let Ok(args) = build_agent_args(&AgentStreamRequest { + message: "do it".into(), + system_prompt: Some("Act as expert.".into()), + vault_path: "/tmp/v".into(), + }) { + assert!(args.contains(&"--append-system-prompt".to_string())); + assert!(args.contains(&"Act as expert.".to_string())); + } + } + + #[test] + fn build_agent_args_empty_system_prompt_is_skipped() { + if let Ok(args) = build_agent_args(&AgentStreamRequest { + message: "x".into(), + system_prompt: Some(String::new()), + vault_path: "/tmp/v".into(), + }) { + assert!(!args.contains(&"--append-system-prompt".to_string())); + } + } + + // --- find_claude_binary --- + + #[test] + fn find_claude_binary_returns_result() { + let result = find_claude_binary(); + // On dev machines claude may be installed; on CI it may not. + // Either way, the function should return Ok(path) or Err(message). + match &result { + Ok(path) => assert!(path.exists()), + Err(msg) => assert!(msg.contains("not found")), + } + } + + // --- run_chat_stream / run_agent_stream error paths --- + + #[test] + fn run_chat_stream_returns_result() { + let req = ChatStreamRequest { + message: "test".into(), + system_prompt: None, + session_id: None, + }; + let mut events = vec![]; + // This will either succeed (if claude is installed) or fail (if not). + let result = run_chat_stream(req, |e| events.push(e)); + // Either way the function should have returned without panicking. + assert!(result.is_ok() || result.is_err()); + } + + #[test] + fn run_agent_stream_returns_result() { + let req = AgentStreamRequest { + message: "test".into(), + system_prompt: Some("sys".into()), + vault_path: "/tmp/nonexistent".into(), + }; + let mut events = vec![]; + let result = run_agent_stream(req, |e| events.push(e)); + assert!(result.is_ok() || result.is_err()); + } + + #[test] + fn run_subprocess_spawn_failure() { + let fake_bin = PathBuf::from("/nonexistent/binary/path"); + let mut events = vec![]; + let result = run_claude_subprocess(&fake_bin, &[], &mut |e| events.push(e)); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Failed to spawn")); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_with_tool_progress_and_assistant() { + let (result, events) = run_mock_script(concat!( + "#!/bin/sh\n", + "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s3\"}'\n", + "echo '{\"type\":\"tool_progress\",\"tool_name\":\"search\",\"tool_use_id\":\"t1\"}'\n", + "echo '{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"t2\",\"name\":\"read\",\"input\":{}}]}}'\n", + "echo '{\"type\":\"result\",\"result\":\"fin\",\"session_id\":\"s3\"}'\n", + )); + assert_eq!(result.unwrap(), "s3"); + assert!(events.len() >= 4); + } + + #[cfg(unix)] + #[test] + fn run_subprocess_success_exit_with_session_id_skips_error() { + let (_, events) = run_mock_script(concat!( + "#!/bin/sh\n", + "echo '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"s4\"}'\n", + "echo 'some warning' >&2\n", + "exit 1\n", + )); + // Should NOT have an error event because session_id is non-empty + assert!(!events + .iter() + .any(|e| matches!(e, ClaudeStreamEvent::Error { .. }))); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8448a98d..be074436 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod ai_chat; +pub mod claude_cli; pub mod frontmatter; pub mod git; pub mod github; @@ -15,6 +16,7 @@ use std::process::Child; use std::sync::Mutex; use ai_chat::{AiChatRequest, AiChatResponse}; +use claude_cli::{AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent}; use frontmatter::FrontmatterValue; use git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile}; use github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo}; @@ -133,6 +135,41 @@ async fn ai_chat(request: AiChatRequest) -> Result { ai_chat::send_chat(request).await } +#[tauri::command] +fn check_claude_cli() -> ClaudeCliStatus { + claude_cli::check_cli() +} + +#[tauri::command] +async fn stream_claude_chat( + app_handle: tauri::AppHandle, + request: ChatStreamRequest, +) -> Result { + use tauri::Emitter; + tokio::task::spawn_blocking(move || { + claude_cli::run_chat_stream(request, |event: ClaudeStreamEvent| { + let _ = app_handle.emit("claude-stream", &event); + }) + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} + +#[tauri::command] +async fn stream_claude_agent( + app_handle: tauri::AppHandle, + request: AgentStreamRequest, +) -> Result { + use tauri::Emitter; + tokio::task::spawn_blocking(move || { + claude_cli::run_agent_stream(request, |event: ClaudeStreamEvent| { + let _ = app_handle.emit("claude-agent-stream", &event); + }) + }) + .await + .map_err(|e| format!("Task failed: {e}"))? +} + #[tauri::command] fn save_image(vault_path: String, filename: String, data: String) -> Result { let vault_path = expand_tilde(&vault_path); @@ -412,10 +449,10 @@ pub fn run() { .build(), )?; // Open devtools automatically in debug builds - use tauri::Manager; - if let Some(window) = app.get_webview_window("main") { - window.open_devtools(); - } + // use tauri::Manager; + // if let Some(window) = app.get_webview_window("main") { + // window.open_devtools(); + // } } app.handle().plugin(tauri_plugin_dialog::init())?; @@ -465,6 +502,9 @@ pub fn run() { git_pull, git_push, ai_chat, + check_claude_cli, + stream_claude_chat, + stream_claude_agent, save_image, copy_image_to_vault, purge_trash, diff --git a/src/App.test.tsx b/src/App.test.tsx index 74debd08..c671ec97 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -81,13 +81,11 @@ vi.mock('./mock-tauri', () => ({ updateMockContent: vi.fn(), })) -// Mock ai-chat utilities (uses localStorage which may not be available in jsdom) +// Mock ai-chat utilities vi.mock('./utils/ai-chat', () => ({ - setApiKey: vi.fn(), - getApiKey: vi.fn(() => ''), - MODEL_OPTIONS: [{ value: 'claude-3-5-haiku-20241022', label: 'Haiku 3.5' }], buildSystemPrompt: vi.fn(() => ({ prompt: '', totalTokens: 0, truncated: false })), - streamChat: vi.fn(), + checkClaudeCli: vi.fn(async () => ({ installed: false })), + streamClaudeChat: vi.fn(async () => 'mock-session'), })) // Mock BlockNote components (they need DOM APIs not available in jsdom) diff --git a/src/App.tsx b/src/App.tsx index 10e15a1a..68021935 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,7 +32,6 @@ import { useZoom } from './hooks/useZoom' import { useOnboarding } from './hooks/useOnboarding' import { useThemeManager } from './hooks/useThemeManager' import { UpdateBanner } from './components/UpdateBanner' -import { setApiKey } from './utils/ai-chat' import { extractOutgoingLinks } from './utils/wikilinks' import type { SidebarSelection } from './types' import './App.css' @@ -125,7 +124,6 @@ function App() { const { settings, saveSettings } = useSettings() const themeManager = useThemeManager(resolvedPath) - useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key]) useMcpRegistration(resolvedPath, setToastMessage) const autoSync = useAutoSync({ diff --git a/src/components/AIChatPanel.tsx b/src/components/AIChatPanel.tsx index dfccf44e..29b2088e 100644 --- a/src/components/AIChatPanel.tsx +++ b/src/components/AIChatPanel.tsx @@ -2,11 +2,11 @@ import { useState, useRef, useEffect, useMemo } from 'react' import type { VaultEntry } from '../types' import { X, Plus, PaperPlaneRight, Copy, ArrowClockwise, - TextIndent, Sparkle, Key, MagnifyingGlass, Minus, + TextIndent, Sparkle, MagnifyingGlass, Minus, } from '@phosphor-icons/react' import { - type ChatMessage, getApiKey, setApiKey, - buildSystemPrompt, MODEL_OPTIONS, + type ChatMessage, + buildSystemPrompt, } from '../utils/ai-chat' import { useAIChat } from '../hooks/useAIChat' @@ -98,30 +98,6 @@ function ContextSearchDropdown({ ) } -function ApiKeyDialog({ onClose }: { onClose: () => void }) { - const [key, setKey] = useState(getApiKey()) - return ( -
-
-

Anthropic API Key

-

- Enter your Anthropic API key. Stored locally in your browser. -

- setKey(e.target.value)} placeholder="sk-ant-..." - className="w-full border border-border bg-transparent text-foreground rounded" - style={{ fontSize: 13, padding: '8px 10px', outline: 'none', marginBottom: 12 }} /> -
- - -
-
-
- ) -} - function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) { return (
@@ -207,13 +183,11 @@ function useContextNotes(entry: VaultEntry | null) { export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) { const [input, setInput] = useState('') - const [model, setModel] = useState(MODEL_OPTIONS[0].value) const [showSearch, setShowSearch] = useState(false) - const [showApiKeyDialog, setShowApiKeyDialog] = useState(false) const messagesEndRef = useRef(null) const ctx = useContextNotes(entry) - const chat = useAIChat(entry, allContent, ctx.contextNotes, model) + const chat = useAIChat(allContent, ctx.contextNotes) const contextInfo = useMemo( () => buildSystemPrompt(ctx.contextNotes, allContent), @@ -232,7 +206,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat return (