Skip to main content

harness/claude/
mod.rs

1//! Claude Code (`claude`) as a [`Harness`].
2//!
3//! Same process-spawn shape as the bob adapter — a different binary,
4//! flags, and stdout parser. We invoke `claude -p` in headless
5//! streaming mode and parse its NDJSON into the shared normalized
6//! [`crate::RunEvent`] stream, so the front-end treats Claude exactly
7//! like any other harness.
8//!
9//! Auth: Claude Code manages its own credentials (its OAuth login or
10//! its own `ANTHROPIC_API_KEY` in the environment), so Compose does
11//! not store or inject a key — `credential().required` is `false`.
12//!
13//! The stdout wire format and its decode into [`crate::RunEvent`]s live in
14//! [`parser`] ([`parse_claude_line`]).
15
16use std::path::PathBuf;
17use std::process::Command;
18
19use serde_json::Value;
20
21use crate::{
22    normalize_process_event, spawn_streaming, CredentialSpec, Harness, HarnessCapabilities,
23    HarnessError, HarnessInfo, HarnessModel, HarnessReadiness, InstallCallback, InstallEvent,
24    RunCallback, RunHandle, RunMode, RunRequest, RunTuning,
25};
26
27mod parser;
28pub use parser::parse_claude_line;
29
30/// Registry id for the Claude Code harness.
31pub const CLAUDE_HARNESS_ID: &str = "claude";
32
33/// Claude Code CLI as a [`Harness`].
34#[derive(Debug, Default, Clone)]
35pub struct ClaudeHarness;
36
37impl ClaudeHarness {
38    pub fn new() -> Self {
39        Self
40    }
41}
42
43impl Harness for ClaudeHarness {
44    fn info(&self) -> HarnessInfo {
45        HarnessInfo {
46            id: CLAUDE_HARNESS_ID.to_owned(),
47            display_name: "Claude Code".to_owned(),
48            description: "Anthropic's Claude Code agent CLI. Uses your existing Claude Code login."
49                .to_owned(),
50            requires_install: true,
51            capabilities: HarnessCapabilities {
52                // Claude Code owns its own login; it edits files
53                // directly (no previews). Curated model aliases (no
54                // free-text) + a turn cap; no reasoning-effort flag.
55                credential_required: false,
56                previews_edits: false,
57                models: vec![
58                    HarnessModel { value: "sonnet".to_owned(), label: "Sonnet (latest)".to_owned() },
59                    HarnessModel { value: "opus".to_owned(), label: "Opus (latest)".to_owned() },
60                    HarnessModel { value: "haiku".to_owned(), label: "Haiku (latest)".to_owned() },
61                ],
62                allows_custom_model: false,
63                supports_effort: false,
64                supports_max_turns: true,
65                supports_login: true,
66            },
67        }
68    }
69
70    fn readiness(&self) -> HarnessReadiness {
71        let Some(version) = probe_version("claude") else {
72            return HarnessReadiness {
73                harness_id: CLAUDE_HARNESS_ID.to_owned(),
74                ready: false,
75                installed: false,
76                version: None,
77                auth_configured: false,
78                error: Some("Claude Code (`claude`) is not installed or not on PATH.".to_owned()),
79                details: Value::Null,
80            };
81        };
82        // Installed — now distinguish signed-in from not, so the picker
83        // can offer "Sign in" instead of failing the first run. Either the
84        // CLI's own OAuth login OR an `ANTHROPIC_API_KEY` in the environment
85        // counts: the env key is how you run headless (a container / CI),
86        // where `claude auth login` can't open a browser. `claude auth status`
87        // only sees the OAuth state, so we OR in the env key ourselves.
88        let signed_in = probe_claude_signed_in()
89            || crate::harness::api_key_value_usable(std::env::var("ANTHROPIC_API_KEY").ok());
90        HarnessReadiness {
91            harness_id: CLAUDE_HARNESS_ID.to_owned(),
92            ready: signed_in,
93            installed: true,
94            version: Some(version),
95            auth_configured: signed_in,
96            error: if signed_in {
97                None
98            } else {
99                Some(
100                    "Claude Code is installed but not signed in. Click Sign in to connect your Anthropic account, or set ANTHROPIC_API_KEY."
101                        .to_owned(),
102                )
103            },
104            details: Value::Null,
105        }
106    }
107
108    fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
109        // npm global install. Blocking (matches the `install`
110        // contract); we capture output and forward it as install
111        // events. Streaming live progress is a future refinement.
112        (*on_event)(InstallEvent::Step {
113            text: "Installing Claude Code via npm…".to_owned(),
114        });
115        let output = Command::new("npm")
116            .args(["install", "-g", "@anthropic-ai/claude-code"])
117            .env("PATH", crate::augmented_node_path())
118            .output()
119            .map_err(|e| HarnessError::install(format!("failed to run npm: {e}")))?;
120        for line in String::from_utf8_lossy(&output.stdout).lines() {
121            (*on_event)(InstallEvent::Stdout {
122                text: line.to_owned(),
123            });
124        }
125        for line in String::from_utf8_lossy(&output.stderr).lines() {
126            (*on_event)(InstallEvent::Stderr {
127                text: line.to_owned(),
128            });
129        }
130        (*on_event)(InstallEvent::Done {
131            exit_code: output.status.code(),
132            ok: output.status.success(),
133        });
134        Ok(())
135    }
136
137    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
138        let RunRequest { run_id, prompt, cwd, mode, tuning, resume } = request;
139        let args = build_claude_args(prompt, mode, &tuning, resume.as_deref());
140        let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
141
142        // No env injected — Claude Code uses its own auth. PATH
143        // augmentation inside `spawn_streaming` ensures `node` is
144        // found for a Finder-launched .app.
145        let handle = spawn_streaming(
146            PathBuf::from("claude"),
147            args,
148            Vec::new(),
149            cwd,
150            run_id,
151            move |event| {
152                for normalized in normalize_process_event(event, parse_claude_line) {
153                    (*on_event)(normalized);
154                }
155            },
156        )
157        .map_err(HarnessError::spawn)?;
158        Ok(Box::new(handle))
159    }
160
161    fn credential(&self) -> CredentialSpec {
162        CredentialSpec {
163            label: "Claude Code login (managed by the claude CLI)".to_owned(),
164            keychain_service: "anthropic".to_owned(),
165            keychain_account: "ANTHROPIC_API_KEY".to_owned(),
166            // Claude Code authenticates itself; Compose need not store
167            // a key for it.
168            required: false,
169        }
170    }
171
172    fn login(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
173        // `claude auth login` runs the CLI's OAuth flow (opens the
174        // browser); streamed + blocked-until-exit by the shared helper.
175        crate::run_login_command("claude", &["auth", "login"], on_event)
176    }
177}
178
179/// Probe Claude Code's auth: `claude auth status` prints JSON with a
180/// `loggedIn` boolean (exit 0 when signed in). Returns true only when
181/// signed in; defensively falls back to the exit code if the JSON is
182/// unexpected. Lets [`ClaudeHarness::readiness`] distinguish installed
183/// from signed-in.
184fn probe_claude_signed_in() -> bool {
185    let Ok(output) = Command::new("claude")
186        .args(["auth", "status"])
187        .env("PATH", crate::augmented_node_path())
188        .output()
189    else {
190        return false;
191    };
192    let stdout = String::from_utf8_lossy(&output.stdout);
193    if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(stdout.trim()) {
194        if let Some(logged_in) = map.get("loggedIn").and_then(Value::as_bool) {
195            return logged_in;
196        }
197    }
198    // Fallback: exit 0 with non-empty output ≈ signed in.
199    output.status.success() && !stdout.trim().is_empty()
200}
201
202/// Build the argv for a `claude -p` headless run. Kept pure (no
203/// spawn) so the flag mapping is unit-tested. `tuning.model` →
204/// `--model`, `tuning.max_turns` → `--max-turns`; Claude Code has no
205/// reasoning-effort `-p` flag, so `tuning.effort` is intentionally
206/// ignored here.
207fn build_claude_args(
208    prompt: String,
209    mode: RunMode,
210    tuning: &RunTuning,
211    resume: Option<&str>,
212) -> Vec<String> {
213    let mut args = vec![
214        "-p".to_owned(),
215        prompt,
216        "--output-format".to_owned(),
217        "stream-json".to_owned(),
218        "--verbose".to_owned(),
219        "--include-partial-messages".to_owned(),
220    ];
221    // Continue a prior session instead of replaying history in the prompt.
222    if let Some(session_id) = resume {
223        args.push("--resume".to_owned());
224        args.push(session_id.to_owned());
225    }
226    if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
227        args.push("--model".to_owned());
228        args.push(model.to_owned());
229    }
230    if let Some(max_turns) = tuning.max_turns {
231        args.push("--max-turns".to_owned());
232        args.push(max_turns.to_string());
233    }
234    // Conservative *default* permission mode (auto-approve edits; Bash etc.
235    // stay gated), emitted only when the caller hasn't set `--permission-mode`
236    // through `extra_args`. So there is a sensible default, but a host fully
237    // controls the mode — `bypassPermissions` for headless, `auto`, … — by
238    // passing its own, with no adapter edit and no duplicate flag. In Ask mode
239    // the CLI stays read-only by default.
240    if matches!(mode, RunMode::Edit) && !extra_args_sets(&tuning.extra_args, "--permission-mode") {
241        args.push("--permission-mode".to_owned());
242        args.push("acceptEdits".to_owned());
243    }
244    // Host passthrough/overrides, appended verbatim after the adapter's own.
245    args.extend(tuning.extra_args.iter().cloned());
246    args
247}
248
249/// Whether the host's `extra_args` already sets `flag` (so the adapter should
250/// not also emit its own default for it). Matches `--flag` and `--flag=value`.
251fn extra_args_sets(extra_args: &[String], flag: &str) -> bool {
252    let with_eq = format!("{flag}=");
253    extra_args.iter().any(|a| a == flag || a.starts_with(&with_eq))
254}
255
256/// Run `<program> --version`, returning the trimmed stdout on
257/// success. Used by readiness to detect the CLI on PATH.
258fn probe_version(program: &str) -> Option<String> {
259    // Augment PATH so a packaged `.app` (minimal launchd PATH) can find a
260    // CLI installed via nvm / Homebrew / official installer — otherwise an
261    // installed CLI is mis-reported as "not installed".
262    let output = Command::new(program)
263        .arg("--version")
264        .env("PATH", crate::augmented_node_path())
265        .output()
266        .ok()?;
267    if !output.status.success() {
268        return None;
269    }
270    let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
271    if text.is_empty() {
272        None
273    } else {
274        Some(text)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::ReasoningEffort;
282
283    #[test]
284    fn claude_info_and_credential() {
285        let h = ClaudeHarness::new();
286        assert_eq!(h.info().id, CLAUDE_HARNESS_ID);
287        assert!(h.info().requires_install);
288        // Claude manages its own auth — Compose doesn't require a key.
289        assert!(!h.credential().required);
290    }
291
292    /// Value of the arg immediately following `flag`, if present.
293    fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
294        args.iter()
295            .position(|a| a == flag)
296            .and_then(|i| args.get(i + 1))
297            .map(String::as_str)
298    }
299
300    #[test]
301    fn claude_args_default_omit_model_and_turn_cap() {
302        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), None);
303        // Prompt is the positional right after `-p`.
304        assert_eq!(args[0], "-p");
305        assert_eq!(args[1], "hi");
306        assert!(!args.iter().any(|a| a == "--model"));
307        assert!(!args.iter().any(|a| a == "--max-turns"));
308        assert!(!args.iter().any(|a| a == "--permission-mode"));
309    }
310
311    #[test]
312    fn claude_resume_adds_session_flag() {
313        let args =
314            build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), Some("sess-123"));
315        assert_eq!(flag_value(&args, "--resume"), Some("sess-123"));
316        // The prompt + headless stream flags are untouched.
317        assert_eq!(args[0], "-p");
318        assert_eq!(args[1], "hi");
319    }
320
321    #[test]
322    fn claude_args_carry_model_and_max_turns_and_ignore_effort() {
323        let tuning = RunTuning {
324            model: Some("opus".to_owned()),
325            effort: Some(ReasoningEffort::High),
326            max_turns: Some(5),
327            ..RunTuning::default()
328        };
329        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
330        assert_eq!(flag_value(&args, "--model"), Some("opus"));
331        assert_eq!(flag_value(&args, "--max-turns"), Some("5"));
332        // Claude Code has no reasoning-effort `-p` flag — it must not leak.
333        assert!(!args.iter().any(|a| a.contains("reasoning_effort")));
334    }
335
336    #[test]
337    fn claude_blank_model_is_treated_as_unset() {
338        let tuning = RunTuning { model: Some("   ".to_owned()), ..RunTuning::default() };
339        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
340        assert!(!args.iter().any(|a| a == "--model"));
341    }
342
343    #[test]
344    fn claude_edit_mode_defaults_to_accept_edits() {
345        // Conservative built-in default; a host overrides via extra_args.
346        let args = build_claude_args("hi".to_owned(), RunMode::Edit, &RunTuning::default(), None);
347        assert_eq!(flag_value(&args, "--permission-mode"), Some("acceptEdits"));
348    }
349
350    #[test]
351    fn host_extra_args_are_appended_verbatim() {
352        // A host adds flags the adapter doesn't manage — appended as given.
353        let tuning = RunTuning {
354            extra_args: vec!["--add-dir".to_owned(), "/extra".to_owned()],
355            ..RunTuning::default()
356        };
357        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
358        assert!(args.ends_with(&["--add-dir".to_owned(), "/extra".to_owned()]));
359    }
360
361    #[test]
362    fn host_permission_mode_replaces_the_default_cleanly() {
363        // When the host sets --permission-mode, the adapter does NOT also emit
364        // its acceptEdits default — the host fully owns the flag, no duplicate.
365        let tuning = RunTuning {
366            extra_args: vec!["--permission-mode".to_owned(), "bypassPermissions".to_owned()],
367            ..RunTuning::default()
368        };
369        let args = build_claude_args("hi".to_owned(), RunMode::Edit, &tuning, None);
370        let modes: Vec<usize> = args
371            .iter()
372            .enumerate()
373            .filter(|(_, a)| a.as_str() == "--permission-mode")
374            .map(|(i, _)| i)
375            .collect();
376        assert_eq!(modes.len(), 1, "exactly one --permission-mode (the host's)");
377        assert_eq!(args[modes[0] + 1], "bypassPermissions");
378        assert!(!args.iter().any(|a| a == "acceptEdits"));
379    }
380
381    #[test]
382    fn extra_args_sets_matches_flag_and_flag_eq_value() {
383        assert!(extra_args_sets(&["--permission-mode".to_owned()], "--permission-mode"));
384        assert!(extra_args_sets(&["--permission-mode=auto".to_owned()], "--permission-mode"));
385        assert!(!extra_args_sets(&["--add-dir".to_owned()], "--permission-mode"));
386    }
387}