claude_cli.rs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581
  1. //! Claude Code CLI subprocess transport.
  2. //!
  3. //! Users with a Claude Code subscription already have OAuth credentials
  4. //! in ~/.claude/ and the `claude` binary on PATH. This module lets LLM
  5. //! Wiki reuse that subscription instead of requiring a separate API key.
  6. //! We treat `claude` purely as a text-completion engine — its agent
  7. //! tools, MCPs, file-edit abilities, and --resume session state are all
  8. //! out of scope. Multi-turn history is reconstructed from `messages`
  9. //! on every call, symmetric with every other provider.
  10. //!
  11. //! Why tokio::process directly (not tauri-plugin-shell): the plugin's
  12. //! scope model is designed for sidecars or fixed absolute paths; scoping
  13. //! a user-installed PATH binary cleanly is awkward. A hardcoded Rust
  14. //! command that always and only spawns `claude` provides the same
  15. //! security property (the webview can't call this command to execute
  16. //! anything else) without pulling in another plugin or editing
  17. //! capabilities JSON.
  18. use std::collections::HashMap;
  19. use std::process::Stdio;
  20. use std::sync::Arc;
  21. use std::time::Duration;
  22. use serde::{Deserialize, Serialize};
  23. use tauri::{AppHandle, Emitter, State};
  24. use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
  25. use tokio::process::{Child, Command};
  26. use tokio::sync::Mutex;
  27. use super::cli_resolver::find_cli_command;
  28. use super::local_cli_config::{
  29. apply_local_cli_environment, read_claude_local_config, resolve_home_dir, LocalCliConfigInfo,
  30. };
  31. // ── Event emitter abstraction ─────────────────────────────────────
  32. // Allows both Tauri (app.emit) and the standalone server (broadcast
  33. // channel) to share the same spawn logic.
  34. /// Abstraction over "emit a data line" and "emit a done signal".
  35. pub trait CliEmitter: Clone + Send + Sync + 'static {
  36. fn emit_data(&self, stream_id: &str, data: String);
  37. fn emit_done(&self, stream_id: &str, code: Option<i32>, stderr: String);
  38. }
  39. /// Tauri-based emitter that forwards to `app.emit()`.
  40. #[derive(Clone)]
  41. pub struct TauriCliEmitter {
  42. app: AppHandle,
  43. }
  44. impl TauriCliEmitter {
  45. pub fn new(app: AppHandle) -> Self {
  46. Self { app }
  47. }
  48. }
  49. impl CliEmitter for TauriCliEmitter {
  50. fn emit_data(&self, stream_id: &str, data: String) {
  51. let topic = format!("claude-cli:{stream_id}");
  52. let _ = self.app.emit(&topic, data);
  53. }
  54. fn emit_done(&self, stream_id: &str, code: Option<i32>, stderr: String) {
  55. let done_topic = format!("claude-cli:{stream_id}:done");
  56. let _ = self.app.emit(
  57. &done_topic,
  58. serde_json::json!({
  59. "code": code,
  60. "stderr": stderr,
  61. }),
  62. );
  63. }
  64. }
  65. /// Shared state holding running `claude` child processes keyed by the
  66. /// frontend-generated stream id. Registered via .manage() in lib.rs.
  67. #[derive(Default)]
  68. pub struct ClaudeCliState {
  69. children: Arc<Mutex<HashMap<String, Child>>>,
  70. }
  71. #[derive(Serialize)]
  72. pub struct DetectResult {
  73. installed: bool,
  74. version: Option<String>,
  75. path: Option<String>,
  76. model: Option<String>,
  77. /// When !installed, a short human-readable reason (missing from PATH,
  78. /// quarantined on macOS, spawn failed, etc). The frontend shows this
  79. /// verbatim in the status pill.
  80. error: Option<String>,
  81. }
  82. #[derive(Deserialize)]
  83. pub struct ClaudeMessage {
  84. /// "system" | "user" | "assistant"
  85. role: String,
  86. content: ClaudeContent,
  87. }
  88. #[derive(Clone, Deserialize)]
  89. #[serde(untagged)]
  90. enum ClaudeContent {
  91. Text(String),
  92. Blocks(Vec<ClaudeContentBlock>),
  93. }
  94. #[derive(Clone, Deserialize)]
  95. #[serde(tag = "type")]
  96. enum ClaudeContentBlock {
  97. #[serde(rename = "text")]
  98. Text { text: String },
  99. #[serde(rename = "image")]
  100. Image {
  101. #[serde(rename = "mediaType")]
  102. media_type: String,
  103. #[serde(rename = "dataBase64")]
  104. data_base64: String,
  105. },
  106. }
  107. fn claude_content_text_only(content: &ClaudeContent) -> String {
  108. match content {
  109. ClaudeContent::Text(text) => text.clone(),
  110. ClaudeContent::Blocks(blocks) => blocks
  111. .iter()
  112. .filter_map(|block| match block {
  113. ClaudeContentBlock::Text { text } => Some(text.as_str()),
  114. ClaudeContentBlock::Image { .. } => None,
  115. })
  116. .collect::<Vec<_>>()
  117. .join(""),
  118. }
  119. }
  120. fn claude_content_blocks(content: &ClaudeContent) -> Vec<serde_json::Value> {
  121. match content {
  122. ClaudeContent::Text(text) => vec![serde_json::json!({ "type": "text", "text": text })],
  123. ClaudeContent::Blocks(blocks) => blocks
  124. .iter()
  125. .map(|block| match block {
  126. ClaudeContentBlock::Text { text } => {
  127. serde_json::json!({ "type": "text", "text": text })
  128. }
  129. ClaudeContentBlock::Image {
  130. media_type,
  131. data_base64,
  132. } => serde_json::json!({
  133. "type": "image",
  134. "source": {
  135. "type": "base64",
  136. "media_type": media_type,
  137. "data": data_base64,
  138. },
  139. }),
  140. })
  141. .collect(),
  142. }
  143. }
  144. async fn find_claude_command() -> Result<std::path::PathBuf, String> {
  145. find_cli_command("claude", &["claude.cmd", "claude.exe"]).await
  146. }
  147. fn suppress_windows_console(_cmd: &mut Command) {
  148. #[cfg(windows)]
  149. {
  150. #[allow(unused_imports)]
  151. use std::os::windows::process::CommandExt;
  152. const CREATE_NO_WINDOW: u32 = 0x08000000;
  153. _cmd.creation_flags(CREATE_NO_WINDOW);
  154. }
  155. }
  156. /// Locate `claude` on PATH and confirm it's runnable by calling
  157. /// `claude --version` with a short timeout. Cheap — safe to call on
  158. /// mount of the settings panel.
  159. ///
  160. /// Shared implementation used by both the Tauri command and the server handler.
  161. pub async fn do_claude_cli_detect() -> Result<DetectResult, String> {
  162. let local_config = read_current_claude_local_config();
  163. let path = match find_claude_command().await {
  164. Ok(p) => p,
  165. Err(error) => {
  166. return Ok(DetectResult {
  167. installed: false,
  168. version: None,
  169. path: None,
  170. model: local_config.model,
  171. error: Some(error),
  172. });
  173. }
  174. };
  175. let path_str = path.to_string_lossy().to_string();
  176. let mut cmd = Command::new(&path);
  177. suppress_windows_console(&mut cmd);
  178. apply_local_cli_environment(&mut cmd);
  179. let output = tokio::time::timeout(Duration::from_secs(3), cmd.arg("--version").output()).await;
  180. match output {
  181. Ok(Ok(out)) if out.status.success() => {
  182. let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
  183. Ok(DetectResult {
  184. installed: true,
  185. version: Some(version),
  186. path: Some(path_str),
  187. model: local_config.model,
  188. error: None,
  189. })
  190. }
  191. Ok(Ok(out)) => {
  192. let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
  193. // macOS Gatekeeper quarantines produce a predictable error. If
  194. // we detect it, surface the remediation hint directly; the UI
  195. // renders this string into an actionable message.
  196. let error = if stderr.contains("quarantine") || stderr.contains("damaged") {
  197. Some(format!(
  198. "Binary quarantined — try: xattr -d com.apple.quarantine {path_str}"
  199. ))
  200. } else if stderr.is_empty() {
  201. Some(format!("`claude --version` exited with {}", out.status))
  202. } else {
  203. Some(stderr)
  204. };
  205. Ok(DetectResult {
  206. installed: false,
  207. version: None,
  208. path: Some(path_str),
  209. model: local_config.model,
  210. error,
  211. })
  212. }
  213. Ok(Err(e)) => Ok(DetectResult {
  214. installed: false,
  215. version: None,
  216. path: Some(path_str),
  217. model: local_config.model,
  218. error: Some(format!("Failed to spawn `claude`: {e}")),
  219. }),
  220. Err(_) => Ok(DetectResult {
  221. installed: false,
  222. version: None,
  223. path: Some(path_str),
  224. model: local_config.model,
  225. error: Some("`claude --version` timed out after 3s".to_string()),
  226. }),
  227. }
  228. }
  229. #[tauri::command]
  230. pub async fn claude_cli_detect() -> Result<DetectResult, String> {
  231. do_claude_cli_detect().await
  232. }
  233. /// Spawn `claude -p --output-format stream-json --input-format stream-json
  234. /// --verbose --model <model>` and pipe stdout back via the given emitter.
  235. /// Closes stdin after writing the serialized history so claude starts
  236. /// processing. Emits a final done event with `{ code }` when the child exits.
  237. ///
  238. /// Shared implementation used by both the Tauri command and the server handler.
  239. pub async fn do_claude_cli_spawn<E: CliEmitter>(
  240. state: &ClaudeCliState,
  241. emitter: E,
  242. stream_id: String,
  243. model: String,
  244. messages: Vec<ClaudeMessage>,
  245. isolate_local_config: bool,
  246. ) -> Result<(), String> {
  247. // Build the turn list: fold any system messages into a preamble on
  248. // the first user turn rather than using a CLI flag, because
  249. // --system-prompt / --append-system-prompt availability varies
  250. // across claude CLI versions. Inlining works on every version.
  251. let system_preamble: String = messages
  252. .iter()
  253. .filter(|m| m.role == "system")
  254. .map(|m| claude_content_text_only(&m.content))
  255. .collect::<Vec<_>>()
  256. .join("\n\n");
  257. let conversation: Vec<&ClaudeMessage> = messages
  258. .iter()
  259. .filter(|m| m.role == "user" || m.role == "assistant")
  260. .collect();
  261. if conversation.is_empty() {
  262. return Err("No user/assistant messages to send to claude CLI".to_string());
  263. }
  264. // Synthesize turns with the preamble merged into the first user turn.
  265. let mut first_user_seen = false;
  266. let turns: Vec<(String, Vec<serde_json::Value>)> = conversation
  267. .iter()
  268. .map(|m| {
  269. let role = m.role.clone();
  270. let mut content = claude_content_blocks(&m.content);
  271. if !first_user_seen && role == "user" && !system_preamble.is_empty() {
  272. content.insert(
  273. 0,
  274. serde_json::json!({ "type": "text", "text": format!("{system_preamble}\n\n") }),
  275. );
  276. first_user_seen = true;
  277. }
  278. (role, content)
  279. })
  280. .collect();
  281. let claude = find_claude_command().await?;
  282. let mut cmd = Command::new(&claude);
  283. suppress_windows_console(&mut cmd);
  284. apply_local_cli_environment(&mut cmd);
  285. cmd.args(build_claude_cli_args(&model, isolate_local_config));
  286. cmd.stdin(Stdio::piped())
  287. .stdout(Stdio::piped())
  288. .stderr(Stdio::piped())
  289. .kill_on_drop(true);
  290. let mut child = cmd
  291. .spawn()
  292. .map_err(|e| format!("Failed to spawn claude: {e}"))?;
  293. let mut stdin = child
  294. .stdin
  295. .take()
  296. .ok_or_else(|| "Missing stdin handle".to_string())?;
  297. let stdout = child
  298. .stdout
  299. .take()
  300. .ok_or_else(|| "Missing stdout handle".to_string())?;
  301. let stderr = child
  302. .stderr
  303. .take()
  304. .ok_or_else(|| "Missing stderr handle".to_string())?;
  305. // Serialize turns to stdin then close. stream-json input format
  306. // expects one JSON event per line. Conversation history is laid out
  307. // in order; the final user turn triggers claude's response.
  308. //
  309. // `content` MUST be an array of blocks, not a plain string. The CLI
  310. // iterates content blocks looking for `tool_use_id` and crashes with
  311. // `W is not an Object. (evaluating '"tool_use_id"in W')` if it
  312. // encounters a raw string. User turns silently tolerated a string
  313. // in light testing, but assistant turns reject it immediately, so
  314. // we normalize both roles to the block-array form.
  315. for (role, content) in &turns {
  316. let event = serde_json::json!({
  317. "type": role,
  318. "message": {
  319. "role": role,
  320. "content": content,
  321. }
  322. });
  323. let line = format!("{}\n", event);
  324. stdin
  325. .write_all(line.as_bytes())
  326. .await
  327. .map_err(|e| format!("Failed to write to claude stdin: {e}"))?;
  328. }
  329. stdin
  330. .flush()
  331. .await
  332. .map_err(|e| format!("Failed to flush claude stdin: {e}"))?;
  333. drop(stdin);
  334. // Register the child so `claude_cli_kill` can reach it.
  335. state.children.lock().await.insert(stream_id.clone(), child);
  336. let children = Arc::clone(&state.children);
  337. let stream_id_task = stream_id.clone();
  338. let emitter_task = emitter.clone();
  339. // Drain stdout line-by-line in a background task, emitting each
  340. // line as an event. Completes when stdout closes (child exited).
  341. tokio::spawn(async move {
  342. let mut reader = BufReader::new(stdout).lines();
  343. let mut stderr_reader = BufReader::new(stderr).lines();
  344. // Collect stderr in a background task so we can ship it with the
  345. // final :done event — otherwise a non-zero exit produces only
  346. // "exited with code N" with no diagnostic info on the frontend.
  347. // Also echo each line to the tauri dev terminal so the developer
  348. // can watch the CLI's stderr live while iterating.
  349. let stderr_task = tokio::spawn(async move {
  350. let mut collected = String::new();
  351. while let Ok(Some(line)) = stderr_reader.next_line().await {
  352. eprintln!("[claude-cli stderr] {line}");
  353. collected.push_str(&line);
  354. collected.push('\n');
  355. }
  356. collected
  357. });
  358. loop {
  359. match reader.next_line().await {
  360. Ok(Some(line)) => {
  361. emitter_task.emit_data(&stream_id_task, line);
  362. }
  363. Ok(None) => break,
  364. Err(e) => {
  365. eprintln!("[claude-cli stdout] read error: {e}");
  366. break;
  367. }
  368. }
  369. }
  370. // Wait for the child to fully exit so we can report its code.
  371. // Don't hold the map lock across .wait() — kill could race.
  372. let child_opt = children.lock().await.remove(&stream_id_task);
  373. let exit_code = if let Some(mut child) = child_opt {
  374. match child.wait().await {
  375. Ok(status) => status.code(),
  376. Err(_) => None,
  377. }
  378. } else {
  379. // Already removed by claude_cli_kill — leave code as None.
  380. None
  381. };
  382. let stderr_text = stderr_task.await.unwrap_or_default();
  383. emitter_task.emit_done(&stream_id_task, exit_code, stderr_text);
  384. });
  385. Ok(())
  386. }
  387. #[tauri::command]
  388. pub async fn claude_cli_spawn(
  389. app: AppHandle,
  390. state: State<'_, ClaudeCliState>,
  391. stream_id: String,
  392. model: String,
  393. messages: Vec<ClaudeMessage>,
  394. isolate_local_config: bool,
  395. ) -> Result<(), String> {
  396. let emitter = TauriCliEmitter::new(app);
  397. do_claude_cli_spawn(&state, emitter, stream_id, model, messages, isolate_local_config).await
  398. }
  399. fn build_claude_cli_args(model: &str, isolate_local_config: bool) -> Vec<String> {
  400. let mut args = vec![
  401. "-p".to_string(),
  402. "--output-format".to_string(),
  403. "stream-json".to_string(),
  404. "--input-format".to_string(),
  405. "stream-json".to_string(),
  406. "--verbose".to_string(),
  407. ];
  408. if isolate_local_config {
  409. args.extend([
  410. "--setting-sources".to_string(),
  411. "project".to_string(),
  412. "--strict-mcp-config".to_string(),
  413. "--mcp-config".to_string(),
  414. "{\"mcpServers\":{}}".to_string(),
  415. "--disable-slash-commands".to_string(),
  416. "--tools".to_string(),
  417. "".to_string(),
  418. "--no-session-persistence".to_string(),
  419. "--prompt-suggestions".to_string(),
  420. "false".to_string(),
  421. ]);
  422. }
  423. if !model.trim().is_empty() {
  424. args.extend(["--model".to_string(), model.to_string()]);
  425. }
  426. args
  427. }
  428. fn read_current_claude_local_config() -> LocalCliConfigInfo {
  429. let home = resolve_home_dir();
  430. read_claude_local_config(home.as_deref())
  431. }
  432. /// Kill a running child registered under `stream_id`. Called on
  433. /// AbortSignal in the frontend. No-op if the id is unknown (e.g. the
  434. /// process already exited).
  435. ///
  436. /// Shared implementation used by both the Tauri command and the server handler.
  437. pub async fn do_claude_cli_kill(state: &ClaudeCliState, stream_id: &str) -> Result<(), String> {
  438. if let Some(mut child) = state.children.lock().await.remove(stream_id) {
  439. let _ = child.start_kill();
  440. // Don't wait() here — the stdout-drain task already holds a
  441. // wait future elsewhere when it can. Dropping the handle is
  442. // enough; kill_on_drop ensures the SIGKILL is sent.
  443. }
  444. Ok(())
  445. }
  446. #[tauri::command]
  447. pub async fn claude_cli_kill(
  448. state: State<'_, ClaudeCliState>,
  449. stream_id: String,
  450. ) -> Result<(), String> {
  451. do_claude_cli_kill(&state, &stream_id).await
  452. }
  453. #[cfg(test)]
  454. mod tests {
  455. use super::*;
  456. #[test]
  457. fn claude_content_blocks_maps_frontend_image_blocks_to_anthropic_shape() {
  458. let content: ClaudeContent = serde_json::from_value(serde_json::json!([
  459. { "type": "text", "text": "describe this" },
  460. { "type": "image", "mediaType": "image/png", "dataBase64": "abc123" }
  461. ]))
  462. .expect("content block payload should deserialize");
  463. let blocks = claude_content_blocks(&content);
  464. assert_eq!(
  465. blocks,
  466. vec![
  467. serde_json::json!({ "type": "text", "text": "describe this" }),
  468. serde_json::json!({
  469. "type": "image",
  470. "source": {
  471. "type": "base64",
  472. "media_type": "image/png",
  473. "data": "abc123",
  474. },
  475. }),
  476. ]
  477. );
  478. }
  479. #[test]
  480. fn system_text_drops_images_before_inlining_preamble() {
  481. let content: ClaudeContent = serde_json::from_value(serde_json::json!([
  482. { "type": "text", "text": "system rule" },
  483. { "type": "image", "mediaType": "image/png", "dataBase64": "abc123" }
  484. ]))
  485. .expect("content block payload should deserialize");
  486. assert_eq!(claude_content_text_only(&content), "system rule");
  487. }
  488. #[test]
  489. fn claude_args_do_not_isolate_local_config_by_default() {
  490. let args = build_claude_cli_args("sonnet", false);
  491. assert!(args.contains(&"--model".to_string()));
  492. assert!(args.contains(&"sonnet".to_string()));
  493. assert!(!args.contains(&"--setting-sources".to_string()));
  494. assert!(!args.contains(&"--strict-mcp-config".to_string()));
  495. assert!(!args.contains(&"--disable-slash-commands".to_string()));
  496. }
  497. #[test]
  498. fn claude_args_can_isolate_user_config_tools_and_mcp() {
  499. let args = build_claude_cli_args("sonnet", true);
  500. assert!(args
  501. .windows(2)
  502. .any(|pair| pair[0] == "--setting-sources" && pair[1] == "project"));
  503. assert!(args.contains(&"--strict-mcp-config".to_string()));
  504. assert!(args
  505. .windows(2)
  506. .any(|pair| pair[0] == "--mcp-config" && pair[1] == "{\"mcpServers\":{}}"));
  507. assert!(args.contains(&"--disable-slash-commands".to_string()));
  508. assert!(args
  509. .windows(2)
  510. .any(|pair| pair[0] == "--tools" && pair[1].is_empty()));
  511. assert!(args.contains(&"--no-session-persistence".to_string()));
  512. assert!(args
  513. .windows(2)
  514. .any(|pair| pair[0] == "--prompt-suggestions" && pair[1] == "false"));
  515. }
  516. #[test]
  517. fn claude_args_skip_model_flag_when_model_is_empty() {
  518. let args = build_claude_cli_args("", false);
  519. assert!(!args.contains(&"--model".to_string()));
  520. }
  521. }