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;
17
18use serde_json::Value;
19
20use crate::{
21    normalize_process_event, probe_version, Command, ResolveCli, CredentialSpec, Harness,
22    Features, Error, Info, ModelChoice, Readiness,
23    InstallCallback, InstallHint, RunCallback, RunHandle, RunMode, RunRequest, RunTuning,
24};
25
26mod parser;
27// Shared with the Codex adapter so it can report its install-kind too. (The
28// binary resolve + classify logic is harness-agnostic; it lives here for now.)
29pub(crate) mod resolve;
30pub use parser::parse_claude_line;
31
32/// Registry id for the Claude Code harness.
33pub const CLAUDE_HARNESS_ID: &str = "claude";
34
35/// The program spawned when the host doesn't name one.
36pub const DEFAULT_CLAUDE_COMMAND: &str = "claude";
37
38/// Claude Code CLI as a [`Harness`].
39#[derive(Debug, Clone)]
40pub struct ClaudeHarness {
41    command: String,
42}
43
44impl Default for ClaudeHarness {
45    // Not derived: a derived `Default` would leave `command` empty and every
46    // spawn would fail on a name nobody chose.
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52/// What this adapter is, as opposed to what is layered onto it — the same
53/// split as [`AcpHarnessConfig`](crate::AcpHarnessConfig) and
54/// [`OpenHarnessConfig`](crate::OpenHarnessConfig).
55#[derive(Clone, Debug)]
56pub struct ClaudeHarnessConfig {
57    /// Program to spawn. A bare name is resolved on PATH; a path is used as
58    /// given. Everything else about the adapter — arguments, output parsing,
59    /// auth probing — is unchanged, so a rename upstream, a fork, a wrapper
60    /// script or a test stub costs a field here rather than a release.
61    pub command: String,
62}
63
64impl Default for ClaudeHarnessConfig {
65    fn default() -> Self {
66        Self { command: DEFAULT_CLAUDE_COMMAND.to_owned() }
67    }
68}
69
70impl ClaudeHarness {
71    /// Drives `claude` from PATH.
72    pub fn new() -> Self {
73        Self::custom(ClaudeHarnessConfig::default())
74    }
75
76    /// Drives a binary the host names:
77    ///
78    /// ```no_run
79    /// use harness::{ClaudeHarness, ClaudeHarnessConfig};
80    /// let claude = ClaudeHarness::custom(ClaudeHarnessConfig {
81    ///     command: "/opt/forks/claude-next".into(),
82    /// });
83    /// ```
84    pub fn custom(config: ClaudeHarnessConfig) -> Self {
85        Self { command: config.command }
86    }
87}
88
89impl Harness for ClaudeHarness {
90    fn info(&self) -> Info {
91        Info {
92            id: CLAUDE_HARNESS_ID.to_owned(),
93            display_name: "Claude Code".to_owned(),
94            description: "Anthropic's Claude Code agent CLI. Uses your existing Claude Code login."
95                .to_owned(),
96            install_hint: Some(
97                InstallHint::url("https://code.claude.com/docs")
98                    .with_command("curl -fsSL https://claude.ai/install.sh | bash"),
99            ),
100        }
101    }
102
103    fn features(&self) -> Features {
104        Features {
105            // Claude Code owns its own login; it edits files directly, so
106            // no previews and no stored credential. Everything it does not
107            // support is left to `Default`.
108            //
109            // The aliases `claude --help` documents. This list is the whole
110            // picker when models.dev is unreachable, and `custom_model`
111            // stays off, so anything missing here is unreachable — not merely
112            // unlisted.
113            models: vec![
114                ModelChoice { value: "sonnet".to_owned(), label: "Sonnet (latest)".to_owned() },
115                ModelChoice { value: "opus".to_owned(), label: "Opus (latest)".to_owned() },
116                ModelChoice { value: "fable".to_owned(), label: "Fable (latest)".to_owned() },
117                ModelChoice { value: "haiku".to_owned(), label: "Haiku (latest)".to_owned() },
118            ],
119            max_turns: true,
120            login: true,
121            ..Default::default()
122        }
123    }
124
125    fn list_models(&self) -> Result<Vec<ModelChoice>, Error> {
126        // Keep the curated aliases first (`sonnet`/`opus` track "latest" and don't
127        // churn), then append models.dev's current `anthropic` lineup (exact ids)
128        // when the `models-dev` feature is on. Offline / feature-off → just aliases.
129        let mut models = self.features().models;
130        models.extend(crate::models_dev::provider_models("anthropic"));
131        Ok(models)
132    }
133
134    fn readiness(&self) -> Readiness {
135        let Some(version) = probe_version(&self.command) else {
136            return Readiness {
137                harness_id: CLAUDE_HARNESS_ID.to_owned(),
138                ready: false,
139                installed: false,
140                version: None,
141                auth_configured: false,
142                error: Some("Claude Code (`claude`) is not installed or not on PATH.".to_owned()),
143                details: Value::Null,
144            };
145        };
146        // Installed — now distinguish signed-in from not, so the picker
147        // can offer "Sign in" instead of failing the first run. Either the
148        // CLI's own OAuth login OR an `ANTHROPIC_API_KEY` in the environment
149        // counts: the env key is how you run headless (a container / CI),
150        // where `claude auth login` can't open a browser. `claude auth status`
151        // only sees the OAuth state, so we OR in the env key ourselves.
152        let signed_in = probe_claude_signed_in(&self.command)
153            || crate::harness::api_key_value_usable(std::env::var("ANTHROPIC_API_KEY").ok());
154        Readiness {
155            harness_id: CLAUDE_HARNESS_ID.to_owned(),
156            ready: signed_in,
157            installed: true,
158            version: Some(version),
159            auth_configured: signed_in,
160            error: if signed_in {
161                None
162            } else {
163                Some(
164                    "Claude Code is installed but not signed in. Click Sign in to connect your Anthropic account, or set ANTHROPIC_API_KEY."
165                        .to_owned(),
166                )
167            },
168            details: resolved_details(&self.command),
169        }
170    }
171
172    fn start(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, Error> {
173        // `attachments` ignored: Claude Code is a text CLI (no image input here).
174        let RunRequest { run_id, prompt, cwd, mode, tuning, resume, attachments: _ } = request;
175        let args = build_claude_args(prompt, mode, &tuning, resume.as_deref());
176        let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
177
178        // No env injected — Claude Code uses its own auth. PATH
179        // augmentation inside `spawn_streaming` ensures `node` is
180        // found for a Finder-launched .app.
181        let program = tuning.binary_path.clone().unwrap_or_else(|| PathBuf::from(&self.command));
182        let handle = Command::new(program)
183            .cwd(cwd)
184            .run_id(run_id)
185            .args(args)
186            .resolve_cli()
187            .stream(move |event| {
188                for normalized in normalize_process_event(event, parse_claude_line) {
189                    (*on_event)(normalized);
190                }
191            },
192        )
193        .map_err(Error::spawn)?;
194        Ok(Box::new(handle))
195    }
196
197    fn credential(&self) -> CredentialSpec {
198        CredentialSpec {
199            label: "Claude Code login (managed by the claude CLI)".to_owned(),
200            keychain_service: "anthropic".to_owned(),
201            keychain_account: "ANTHROPIC_API_KEY".to_owned(),
202            // Claude Code authenticates itself; Compose need not store
203            // a key for it.
204            required: false,
205        }
206    }
207
208    fn login(&self, on_event: InstallCallback) -> Result<(), Error> {
209        // `claude auth login` runs the CLI's OAuth flow (opens the
210        // browser); streamed + blocked-until-exit by the shared helper.
211        crate::run_login_command(&self.command, &["auth", "login"], on_event)
212    }
213}
214
215/// Probe Claude Code's auth: `claude auth status` prints JSON with a
216/// `loggedIn` boolean (exit 0 when signed in). Returns true only when
217/// signed in; defensively falls back to the exit code if the JSON is
218/// unexpected. Lets [`ClaudeHarness::readiness`] distinguish installed
219/// from signed-in.
220fn probe_claude_signed_in(command: &str) -> bool {
221    let Ok(output) = crate::hidden_command(command)
222        .args(["auth", "status"])
223        .env("PATH", crate::augmented_node_path())
224        .output()
225    else {
226        return false;
227    };
228    let stdout = String::from_utf8_lossy(&output.stdout);
229    if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(stdout.trim()) {
230        if let Some(logged_in) = map.get("loggedIn").and_then(Value::as_bool) {
231            return logged_in;
232        }
233    }
234    // Fallback: exit 0 with non-empty output ≈ signed in.
235    output.status.success() && !stdout.trim().is_empty()
236}
237
238/// Build readiness `details` carrying where `claude` resolves on the augmented
239/// PATH and how it was installed (native / npm-global / homebrew / bundled /
240/// unknown). Attached as a `serde_json::Value` object so it rides the existing
241/// `Readiness.details` without a struct change. `details.resolved_path`
242/// is absent when the binary can't be located despite a successful
243/// `--version` (e.g. a PATH entry the resolver can't read) — the host renders
244/// version + status regardless.
245fn resolved_details(command: &str) -> Value {
246    let path = crate::augmented_node_path();
247    let Some(resolved) = resolve::resolve_on_path(command, &path) else {
248        return Value::Null;
249    };
250    let mut details = serde_json::Map::new();
251    details.insert(
252        "resolved_path".to_owned(),
253        Value::String(resolved.to_string_lossy().into_owned()),
254    );
255    if let Ok(home) = std::env::var("HOME") {
256        let kind = resolve::classify(&resolved, std::path::Path::new(&home), None);
257        details.insert("install_kind".to_owned(), Value::String(kind.as_str().to_owned()));
258    }
259    Value::Object(details)
260}
261
262/// Build the argv for a `claude -p` headless run. Kept pure (no
263/// spawn) so the flag mapping is unit-tested. `tuning.model` →
264/// `--model`, `tuning.max_turns` → `--max-turns`; Claude Code has no
265/// reasoning-effort `-p` flag, so `tuning.effort` is intentionally
266/// ignored here.
267fn build_claude_args(
268    prompt: String,
269    mode: RunMode,
270    tuning: &RunTuning,
271    resume: Option<&str>,
272) -> Vec<String> {
273    let mut args = vec![
274        "-p".to_owned(),
275        prompt,
276        "--output-format".to_owned(),
277        "stream-json".to_owned(),
278        "--verbose".to_owned(),
279        "--include-partial-messages".to_owned(),
280    ];
281    // Continue a prior session instead of replaying history in the prompt.
282    if let Some(session_id) = resume {
283        args.push("--resume".to_owned());
284        args.push(session_id.to_owned());
285    }
286    if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
287        args.push("--model".to_owned());
288        args.push(model.to_owned());
289    }
290    if let Some(max_turns) = tuning.max_turns {
291        args.push("--max-turns".to_owned());
292        args.push(max_turns.to_string());
293    }
294    // Conservative *default* permission mode (auto-approve edits; Bash etc.
295    // stay gated), emitted only when the caller hasn't set `--permission-mode`
296    // through `extra_args`. So there is a sensible default, but a host fully
297    // controls the mode — `bypassPermissions` for headless, `auto`, … — by
298    // passing its own, with no adapter edit and no duplicate flag. In Ask mode
299    // the CLI stays read-only by default.
300    if matches!(mode, RunMode::Edit) && !extra_args_sets(&tuning.extra_args, "--permission-mode") {
301        args.push("--permission-mode".to_owned());
302        args.push("acceptEdits".to_owned());
303    }
304    // Host passthrough/overrides, appended verbatim after the adapter's own.
305    args.extend(tuning.extra_args.iter().cloned());
306    args
307}
308
309/// Whether the host's `extra_args` already sets `flag` (so the adapter should
310/// not also emit its own default for it). Matches `--flag` and `--flag=value`.
311fn extra_args_sets(extra_args: &[String], flag: &str) -> bool {
312    let with_eq = format!("{flag}=");
313    extra_args.iter().any(|a| a == flag || a.starts_with(&with_eq))
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::events::RunEvent;
320    use crate::ReasoningEffort;
321
322    /// A stand-in `claude` answering the three ways the adapter invokes it:
323    /// `--version`, `auth status` (JSON with `loggedIn`), and a `-p` run that
324    /// records the argv it was handed.
325    #[cfg(unix)]
326    fn fake_claude(tag: &str, signed_in: bool, emits: &str) -> (std::path::PathBuf, std::path::PathBuf) {
327        use std::os::unix::fs::PermissionsExt;
328        let dir = std::env::temp_dir().join(format!("claude-{tag}-{}", std::process::id()));
329        std::fs::create_dir_all(&dir).unwrap();
330        let argv = dir.join("argv");
331        let cli = dir.join("claude");
332        std::fs::write(
333            &cli,
334            format!(
335                "#!/bin/sh\n\
336                 case \"$1\" in\n\
337                 --version) echo '1.2.3 (Claude Code)'; exit 0 ;;\n\
338                 auth) echo '{{\"loggedIn\":{signed_in}}}'; exit 0 ;;\n\
339                 -p) : > '{argv}'; for a in \"$@\"; do printf '%s\\n' \"$a\" >> '{argv}'; done\n\
340                 {emits}\n\
341                 exit 0 ;;\n\
342                 esac\n\
343                 exit 1\n",
344                argv = argv.display(),
345            ),
346        )
347        .unwrap();
348        std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
349        (cli, argv)
350    }
351
352    #[cfg(unix)]
353    fn drive(cli: &std::path::Path, request: RunRequest) -> Vec<RunEvent> {
354        use std::sync::{Arc, Mutex};
355        let seen: Arc<Mutex<Vec<RunEvent>>> = Arc::default();
356        let sink = Arc::clone(&seen);
357        let harness = ClaudeHarness::custom(ClaudeHarnessConfig {
358            command: cli.display().to_string(),
359        });
360        let handle = harness
361            .start(request, Arc::new(move |event| sink.lock().unwrap().push(event)))
362            .expect("the stand-in should spawn");
363
364        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
365        loop {
366            let done = seen
367                .lock()
368                .unwrap()
369                .iter()
370                .any(|event| matches!(event, RunEvent::Exited { .. }));
371            if done {
372                break;
373            }
374            assert!(std::time::Instant::now() < deadline, "the run never exited");
375            std::thread::sleep(std::time::Duration::from_millis(20));
376        }
377        let _ = handle.cancel();
378        let events = seen.lock().unwrap().clone();
379        events
380    }
381
382    #[cfg(unix)]
383    #[test]
384    fn a_run_reaches_the_cli_as_stream_json_and_comes_back_as_events() {
385        // The argv tests elsewhere check `build_claude_args` in isolation; this
386        // is the only one proving the process receives that argv, and that its
387        // NDJSON comes back through the parser as normalized events.
388        let delta = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi there"}}}"#;
389        let (cli, argv) = fake_claude("run", true, &format!("printf '%s\\n' '{delta}'"));
390
391        let events = drive(
392            &cli,
393            RunRequest {
394                run_id: "r1".to_owned(),
395                prompt: "greet me".to_owned(),
396                cwd: Some(std::env::temp_dir()),
397                tuning: RunTuning { model: Some("opus".to_owned()), ..RunTuning::default() },
398                ..RunRequest::default()
399            },
400        );
401
402        let passed: Vec<String> =
403            std::fs::read_to_string(&argv).unwrap().lines().map(str::to_owned).collect();
404        assert_eq!(passed.first().map(String::as_str), Some("-p"));
405        assert_eq!(
406            passed.get(1).map(String::as_str),
407            Some("greet me"),
408            "the prompt follows -p: {passed:?}",
409        );
410        assert!(passed.windows(2).any(|w| w[0] == "--output-format" && w[1] == "stream-json"));
411        assert!(passed.iter().any(|a| a == "--include-partial-messages"), "{passed:?}");
412        assert!(passed.windows(2).any(|w| w[0] == "--model" && w[1] == "opus"));
413
414        assert!(
415            events.iter().any(|e| matches!(e, RunEvent::Text { delta, .. } if delta == "hi there")),
416            "the CLI's delta should arrive as text: {events:?}",
417        );
418        assert!(events.iter().any(|e| matches!(
419            e,
420            RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }
421        )));
422    }
423
424    #[cfg(unix)]
425    #[test]
426    fn readiness_reads_logged_in_from_the_auth_probe() {
427        // `auth status` answers JSON, so this covers the parse as well as the
428        // spawn. Signed-out still reads as installed: the UI offers "Sign in"
429        // only once it knows the binary is there.
430        let (yes, _) = fake_claude("in", true, ":");
431        let ready =
432            ClaudeHarness::custom(ClaudeHarnessConfig { command: yes.display().to_string() })
433                .readiness();
434        assert!(ready.installed && ready.ready && ready.auth_configured);
435        assert_eq!(ready.version.as_deref(), Some("1.2.3 (Claude Code)"));
436
437        let (no, _) = fake_claude("out", false, ":");
438        let ready =
439            ClaudeHarness::custom(ClaudeHarnessConfig { command: no.display().to_string() })
440                .readiness();
441        assert!(ready.installed, "the binary is present either way");
442        assert!(!ready.ready && !ready.auth_configured);
443        assert!(ready.error.is_some(), "a signed-out CLI must say what to do");
444    }
445
446    #[test]
447    fn claude_info_and_credential() {
448        let h = ClaudeHarness::new();
449        assert_eq!(h.info().id, CLAUDE_HARNESS_ID);
450        let hint = h.info().install_hint.expect("Claude Code is a CLI the user installs");
451        assert!(hint.command.is_some_and(|c| c.contains("claude.ai/install.sh")));
452        // Claude manages its own auth — Compose doesn't require a key.
453        assert!(!h.credential().required);
454    }
455
456    #[test]
457    fn a_renamed_binary_is_what_gets_probed() {
458        // The point of the field: if the CLI is renamed upstream, or a user
459        // keeps a fork or wrapper under another name, that costs a call here
460        // rather than a release. A name nothing can resolve must read as "not
461        // installed" — proving readiness consults the configured command and
462        // not a baked-in "claude".
463        let renamed = ClaudeHarness::custom(ClaudeHarnessConfig {
464            command: "definitely-not-a-real-binary-xyz".into(),
465        });
466        let readiness = renamed.readiness();
467        assert!(!readiness.installed, "an unresolvable command cannot report installed");
468
469        // And the default still targets the real one.
470        assert_eq!(ClaudeHarness::new().command, DEFAULT_CLAUDE_COMMAND);
471        assert_eq!(ClaudeHarness::default().command, DEFAULT_CLAUDE_COMMAND);
472    }
473
474    /// A throwaway CLI that behaves however the test needs. The probes take the
475    /// command as an argument, so no PATH juggling is involved — the same trick
476    /// the MCP client uses to test a protocol against a real process.
477    #[cfg(unix)]
478    fn fake_cli(tag: &str, script: &str) -> std::path::PathBuf {
479        use std::os::unix::fs::PermissionsExt;
480        let dir = std::env::temp_dir().join(format!("hl-claude-{tag}-{}", std::process::id()));
481        std::fs::create_dir_all(&dir).unwrap();
482        let path = dir.join("cli");
483        std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
484        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
485        path
486    }
487
488    #[cfg(unix)]
489    #[test]
490    fn a_signed_out_cli_is_believed_over_the_exit_code() {
491        // `auth status` answering `{"loggedIn": false}` while exiting 0 is the
492        // case that matters: the fallback below would read that as signed in,
493        // and the user would be told to try a run that cannot work.
494        let out = fake_cli("signedout", r#"echo '{"loggedIn": false}'"#);
495        assert!(!probe_claude_signed_in(out.to_str().unwrap()));
496
497        let inn = fake_cli("signedin", r#"echo '{"loggedIn": true}'"#);
498        assert!(probe_claude_signed_in(inn.to_str().unwrap()));
499    }
500
501    #[cfg(unix)]
502    #[test]
503    fn a_cli_that_does_not_answer_in_json_falls_back_to_how_it_exited() {
504        // Older builds print prose. Exit 0 with something to say is the best
505        // available evidence; silence or a failure is not.
506        let prose = fake_cli("prose", "echo 'Logged in as someone@example.test'");
507        assert!(probe_claude_signed_in(prose.to_str().unwrap()));
508
509        let silent = fake_cli("silent", "exit 0");
510        assert!(!probe_claude_signed_in(silent.to_str().unwrap()), "exit 0 with nothing said proves nothing");
511
512        let failed = fake_cli("authfail", "echo 'not logged in'; exit 1");
513        assert!(!probe_claude_signed_in(failed.to_str().unwrap()));
514
515        assert!(!probe_claude_signed_in("definitely-not-a-real-binary-xyz"), "an absent CLI is not signed in");
516    }
517
518
519    #[test]
520    fn every_documented_alias_is_offered() {
521        // These are the aliases `claude --help` names. The list matters more
522        // than it looks: models.dev supplies the exact ids, but when it is
523        // unreachable — offline, or a first launch with a cold cache — this
524        // vec IS the picker. Combined with `custom_model: false`, an
525        // alias missing here cannot be selected or typed. `fable` was absent
526        // and therefore unreachable in exactly that state.
527        let caps = ClaudeHarness::new().features();
528        let offered: Vec<&str> = caps.models.iter().map(|m| m.value.as_str()).collect();
529        for alias in ["sonnet", "opus", "fable", "haiku"] {
530            assert!(offered.contains(&alias), "`--model {alias}` is documented, got {offered:?}");
531        }
532        assert!(
533            !caps.custom_model,
534            "if free-text entry is ever allowed, an omission above stops being unreachable \
535             and this test can relax"
536        );
537    }
538
539    /// Value of the arg immediately following `flag`, if present.
540    fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
541        args.iter()
542            .position(|a| a == flag)
543            .and_then(|i| args.get(i + 1))
544            .map(String::as_str)
545    }
546
547    #[test]
548    fn claude_args_default_omit_model_and_turn_cap() {
549        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), None);
550        // Prompt is the positional right after `-p`.
551        assert_eq!(args[0], "-p");
552        assert_eq!(args[1], "hi");
553        assert!(!args.iter().any(|a| a == "--model"));
554        assert!(!args.iter().any(|a| a == "--max-turns"));
555        assert!(!args.iter().any(|a| a == "--permission-mode"));
556    }
557
558    #[test]
559    fn claude_resume_adds_session_flag() {
560        let args =
561            build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), Some("sess-123"));
562        assert_eq!(flag_value(&args, "--resume"), Some("sess-123"));
563        // The prompt + headless stream flags are untouched.
564        assert_eq!(args[0], "-p");
565        assert_eq!(args[1], "hi");
566    }
567
568    #[test]
569    fn claude_args_carry_model_and_max_turns_and_ignore_effort() {
570        let tuning = RunTuning {
571            model: Some("opus".to_owned()),
572            effort: Some(ReasoningEffort::High),
573            max_turns: Some(5),
574            ..RunTuning::default()
575        };
576        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
577        assert_eq!(flag_value(&args, "--model"), Some("opus"));
578        assert_eq!(flag_value(&args, "--max-turns"), Some("5"));
579        // Claude Code has no reasoning-effort `-p` flag — it must not leak.
580        assert!(!args.iter().any(|a| a.contains("reasoning_effort")));
581    }
582
583    #[test]
584    fn claude_blank_model_is_treated_as_unset() {
585        let tuning = RunTuning { model: Some("   ".to_owned()), ..RunTuning::default() };
586        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
587        assert!(!args.iter().any(|a| a == "--model"));
588    }
589
590    #[test]
591    fn claude_edit_mode_defaults_to_accept_edits() {
592        // Conservative built-in default; a host overrides via extra_args.
593        let args = build_claude_args("hi".to_owned(), RunMode::Edit, &RunTuning::default(), None);
594        assert_eq!(flag_value(&args, "--permission-mode"), Some("acceptEdits"));
595    }
596
597    #[test]
598    fn host_extra_args_are_appended_verbatim() {
599        // A host adds flags the adapter doesn't manage — appended as given.
600        let tuning = RunTuning {
601            extra_args: vec!["--add-dir".to_owned(), "/extra".to_owned()],
602            ..RunTuning::default()
603        };
604        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
605        assert!(args.ends_with(&["--add-dir".to_owned(), "/extra".to_owned()]));
606    }
607
608    #[test]
609    fn host_permission_mode_replaces_the_default_cleanly() {
610        // When the host sets --permission-mode, the adapter does NOT also emit
611        // its acceptEdits default — the host fully owns the flag, no duplicate.
612        let tuning = RunTuning {
613            extra_args: vec!["--permission-mode".to_owned(), "bypassPermissions".to_owned()],
614            ..RunTuning::default()
615        };
616        let args = build_claude_args("hi".to_owned(), RunMode::Edit, &tuning, None);
617        let modes: Vec<usize> = args
618            .iter()
619            .enumerate()
620            .filter(|(_, a)| a.as_str() == "--permission-mode")
621            .map(|(i, _)| i)
622            .collect();
623        assert_eq!(modes.len(), 1, "exactly one --permission-mode (the host's)");
624        assert_eq!(args[modes[0] + 1], "bypassPermissions");
625        assert!(!args.iter().any(|a| a == "acceptEdits"));
626    }
627
628    #[test]
629    fn extra_args_sets_matches_flag_and_flag_eq_value() {
630        assert!(extra_args_sets(&["--permission-mode".to_owned()], "--permission-mode"));
631        assert!(extra_args_sets(&["--permission-mode=auto".to_owned()], "--permission-mode"));
632        assert!(!extra_args_sets(&["--add-dir".to_owned()], "--permission-mode"));
633    }
634}