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_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_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    #[cfg(unix)]
320    use crate::events::RunEvent;
321    use crate::ReasoningEffort;
322
323    /// A stand-in `claude` answering the three ways the adapter invokes it:
324    /// `--version`, `auth status` (JSON with `loggedIn`), and a `-p` run that
325    /// records the argv it was handed.
326    #[cfg(unix)]
327    fn fake_claude(tag: &str, signed_in: bool, emits: &str) -> (std::path::PathBuf, std::path::PathBuf) {
328        use std::os::unix::fs::PermissionsExt;
329        let dir = std::env::temp_dir().join(format!("claude-{tag}-{}", std::process::id()));
330        std::fs::create_dir_all(&dir).unwrap();
331        let argv = dir.join("argv");
332        let cli = dir.join("claude");
333        std::fs::write(
334            &cli,
335            format!(
336                "#!/bin/sh\n\
337                 case \"$1\" in\n\
338                 --version) echo '1.2.3 (Claude Code)'; exit 0 ;;\n\
339                 auth) echo '{{\"loggedIn\":{signed_in}}}'; exit 0 ;;\n\
340                 -p) : > '{argv}'; for a in \"$@\"; do printf '%s\\n' \"$a\" >> '{argv}'; done\n\
341                 {emits}\n\
342                 exit 0 ;;\n\
343                 esac\n\
344                 exit 1\n",
345                argv = argv.display(),
346            ),
347        )
348        .unwrap();
349        std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
350        (cli, argv)
351    }
352
353    #[cfg(unix)]
354    fn drive(cli: &std::path::Path, request: RunRequest) -> Vec<RunEvent> {
355        use std::sync::{Arc, Mutex};
356        let seen: Arc<Mutex<Vec<RunEvent>>> = Arc::default();
357        let sink = Arc::clone(&seen);
358        let harness = ClaudeHarness::custom(ClaudeHarnessConfig {
359            command: cli.display().to_string(),
360        });
361        let handle = harness
362            .start(request, Arc::new(move |event| sink.lock().unwrap().push(event)))
363            .expect("the stand-in should spawn");
364
365        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
366        loop {
367            let done = seen
368                .lock()
369                .unwrap()
370                .iter()
371                .any(|event| matches!(event, RunEvent::Exited { .. }));
372            if done {
373                break;
374            }
375            assert!(std::time::Instant::now() < deadline, "the run never exited");
376            std::thread::sleep(std::time::Duration::from_millis(20));
377        }
378        let _ = handle.cancel();
379        let events = seen.lock().unwrap().clone();
380        events
381    }
382
383    #[cfg(unix)]
384    #[test]
385    fn a_run_reaches_the_cli_as_stream_json_and_comes_back_as_events() {
386        // The argv tests elsewhere check `build_claude_args` in isolation; this
387        // is the only one proving the process receives that argv, and that its
388        // NDJSON comes back through the parser as normalized events.
389        let delta = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi there"}}}"#;
390        let (cli, argv) = fake_claude("run", true, &format!("printf '%s\\n' '{delta}'"));
391
392        let events = drive(
393            &cli,
394            RunRequest {
395                run_id: "r1".to_owned(),
396                prompt: "greet me".to_owned(),
397                cwd: Some(std::env::temp_dir()),
398                tuning: RunTuning { model: Some("opus".to_owned()), ..RunTuning::default() },
399                ..RunRequest::default()
400            },
401        );
402
403        let passed: Vec<String> =
404            std::fs::read_to_string(&argv).unwrap().lines().map(str::to_owned).collect();
405        assert_eq!(passed.first().map(String::as_str), Some("-p"));
406        assert_eq!(
407            passed.get(1).map(String::as_str),
408            Some("greet me"),
409            "the prompt follows -p: {passed:?}",
410        );
411        assert!(passed.windows(2).any(|w| w[0] == "--output-format" && w[1] == "stream-json"));
412        assert!(passed.iter().any(|a| a == "--include-partial-messages"), "{passed:?}");
413        assert!(passed.windows(2).any(|w| w[0] == "--model" && w[1] == "opus"));
414
415        assert!(
416            events.iter().any(|e| matches!(e, RunEvent::Text { delta, .. } if delta == "hi there")),
417            "the CLI's delta should arrive as text: {events:?}",
418        );
419        assert!(events.iter().any(|e| matches!(
420            e,
421            RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }
422        )));
423    }
424
425    #[cfg(unix)]
426    #[test]
427    fn readiness_reads_logged_in_from_the_auth_probe() {
428        // `auth status` answers JSON, so this covers the parse as well as the
429        // spawn. Signed-out still reads as installed: the UI offers "Sign in"
430        // only once it knows the binary is there.
431        let (yes, _) = fake_claude("in", true, ":");
432        let ready =
433            ClaudeHarness::custom(ClaudeHarnessConfig { command: yes.display().to_string() })
434                .readiness();
435        assert!(ready.installed && ready.ready && ready.auth_configured);
436        assert_eq!(ready.version.as_deref(), Some("1.2.3 (Claude Code)"));
437
438        let (no, _) = fake_claude("out", false, ":");
439        let ready =
440            ClaudeHarness::custom(ClaudeHarnessConfig { command: no.display().to_string() })
441                .readiness();
442        assert!(ready.installed, "the binary is present either way");
443        assert!(!ready.ready && !ready.auth_configured);
444        assert!(ready.error.is_some(), "a signed-out CLI must say what to do");
445    }
446
447    #[test]
448    fn claude_info_and_credential() {
449        let h = ClaudeHarness::new();
450        assert_eq!(h.info().id, CLAUDE_HARNESS_ID);
451        let hint = h.info().install_hint.expect("Claude Code is a CLI the user installs");
452        assert!(hint.command.is_some_and(|c| c.contains("claude.ai/install.sh")));
453        // Claude manages its own auth — Compose doesn't require a key.
454        assert!(!h.credential().required);
455    }
456
457    #[test]
458    fn a_renamed_binary_is_what_gets_probed() {
459        // The point of the field: if the CLI is renamed upstream, or a user
460        // keeps a fork or wrapper under another name, that costs a call here
461        // rather than a release. A name nothing can resolve must read as "not
462        // installed" — proving readiness consults the configured command and
463        // not a baked-in "claude".
464        let renamed = ClaudeHarness::custom(ClaudeHarnessConfig {
465            command: "definitely-not-a-real-binary-xyz".into(),
466        });
467        let readiness = renamed.readiness();
468        assert!(!readiness.installed, "an unresolvable command cannot report installed");
469
470        // And the default still targets the real one.
471        assert_eq!(ClaudeHarness::new().command, DEFAULT_CLAUDE_COMMAND);
472        assert_eq!(ClaudeHarness::default().command, DEFAULT_CLAUDE_COMMAND);
473    }
474
475    /// A throwaway CLI that behaves however the test needs. The probes take the
476    /// command as an argument, so no PATH juggling is involved — the same trick
477    /// the MCP client uses to test a protocol against a real process.
478    #[cfg(unix)]
479    fn fake_cli(tag: &str, script: &str) -> std::path::PathBuf {
480        use std::os::unix::fs::PermissionsExt;
481        let dir = std::env::temp_dir().join(format!("hl-claude-{tag}-{}", std::process::id()));
482        std::fs::create_dir_all(&dir).unwrap();
483        let path = dir.join("cli");
484        std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
485        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
486        path
487    }
488
489    #[cfg(unix)]
490    #[test]
491    fn a_signed_out_cli_is_believed_over_the_exit_code() {
492        // `auth status` answering `{"loggedIn": false}` while exiting 0 is the
493        // case that matters: the fallback below would read that as signed in,
494        // and the user would be told to try a run that cannot work.
495        let out = fake_cli("signedout", r#"echo '{"loggedIn": false}'"#);
496        assert!(!probe_claude_signed_in(out.to_str().unwrap()));
497
498        let inn = fake_cli("signedin", r#"echo '{"loggedIn": true}'"#);
499        assert!(probe_claude_signed_in(inn.to_str().unwrap()));
500    }
501
502    #[cfg(unix)]
503    #[test]
504    fn a_cli_that_does_not_answer_in_json_falls_back_to_how_it_exited() {
505        // Older builds print prose. Exit 0 with something to say is the best
506        // available evidence; silence or a failure is not.
507        let prose = fake_cli("prose", "echo 'Logged in as someone@example.test'");
508        assert!(probe_claude_signed_in(prose.to_str().unwrap()));
509
510        let silent = fake_cli("silent", "exit 0");
511        assert!(!probe_claude_signed_in(silent.to_str().unwrap()), "exit 0 with nothing said proves nothing");
512
513        let failed = fake_cli("authfail", "echo 'not logged in'; exit 1");
514        assert!(!probe_claude_signed_in(failed.to_str().unwrap()));
515
516        assert!(!probe_claude_signed_in("definitely-not-a-real-binary-xyz"), "an absent CLI is not signed in");
517    }
518
519
520    #[test]
521    fn every_documented_alias_is_offered() {
522        // These are the aliases `claude --help` names. The list matters more
523        // than it looks: models.dev supplies the exact ids, but when it is
524        // unreachable — offline, or a first launch with a cold cache — this
525        // vec IS the picker. Combined with `custom_model: false`, an
526        // alias missing here cannot be selected or typed. `fable` was absent
527        // and therefore unreachable in exactly that state.
528        let caps = ClaudeHarness::new().features();
529        let offered: Vec<&str> = caps.models.iter().map(|m| m.value.as_str()).collect();
530        for alias in ["sonnet", "opus", "fable", "haiku"] {
531            assert!(offered.contains(&alias), "`--model {alias}` is documented, got {offered:?}");
532        }
533        assert!(
534            !caps.custom_model,
535            "if free-text entry is ever allowed, an omission above stops being unreachable \
536             and this test can relax"
537        );
538    }
539
540    /// Value of the arg immediately following `flag`, if present.
541    fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
542        args.iter()
543            .position(|a| a == flag)
544            .and_then(|i| args.get(i + 1))
545            .map(String::as_str)
546    }
547
548    #[test]
549    fn claude_args_default_omit_model_and_turn_cap() {
550        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), None);
551        // Prompt is the positional right after `-p`.
552        assert_eq!(args[0], "-p");
553        assert_eq!(args[1], "hi");
554        assert!(!args.iter().any(|a| a == "--model"));
555        assert!(!args.iter().any(|a| a == "--max-turns"));
556        assert!(!args.iter().any(|a| a == "--permission-mode"));
557    }
558
559    #[test]
560    fn claude_resume_adds_session_flag() {
561        let args =
562            build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), Some("sess-123"));
563        assert_eq!(flag_value(&args, "--resume"), Some("sess-123"));
564        // The prompt + headless stream flags are untouched.
565        assert_eq!(args[0], "-p");
566        assert_eq!(args[1], "hi");
567    }
568
569    #[test]
570    fn claude_args_carry_model_and_max_turns_and_ignore_effort() {
571        let tuning = RunTuning {
572            model: Some("opus".to_owned()),
573            effort: Some(ReasoningEffort::High),
574            max_turns: Some(5),
575            ..RunTuning::default()
576        };
577        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
578        assert_eq!(flag_value(&args, "--model"), Some("opus"));
579        assert_eq!(flag_value(&args, "--max-turns"), Some("5"));
580        // Claude Code has no reasoning-effort `-p` flag — it must not leak.
581        assert!(!args.iter().any(|a| a.contains("reasoning_effort")));
582    }
583
584    #[test]
585    fn claude_blank_model_is_treated_as_unset() {
586        let tuning = RunTuning { model: Some("   ".to_owned()), ..RunTuning::default() };
587        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
588        assert!(!args.iter().any(|a| a == "--model"));
589    }
590
591    #[test]
592    fn claude_edit_mode_defaults_to_accept_edits() {
593        // Conservative built-in default; a host overrides via extra_args.
594        let args = build_claude_args("hi".to_owned(), RunMode::Edit, &RunTuning::default(), None);
595        assert_eq!(flag_value(&args, "--permission-mode"), Some("acceptEdits"));
596    }
597
598    #[test]
599    fn host_extra_args_are_appended_verbatim() {
600        // A host adds flags the adapter doesn't manage — appended as given.
601        let tuning = RunTuning {
602            extra_args: vec!["--add-dir".to_owned(), "/extra".to_owned()],
603            ..RunTuning::default()
604        };
605        let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
606        assert!(args.ends_with(&["--add-dir".to_owned(), "/extra".to_owned()]));
607    }
608
609    #[test]
610    fn host_permission_mode_replaces_the_default_cleanly() {
611        // When the host sets --permission-mode, the adapter does NOT also emit
612        // its acceptEdits default — the host fully owns the flag, no duplicate.
613        let tuning = RunTuning {
614            extra_args: vec!["--permission-mode".to_owned(), "bypassPermissions".to_owned()],
615            ..RunTuning::default()
616        };
617        let args = build_claude_args("hi".to_owned(), RunMode::Edit, &tuning, None);
618        let modes: Vec<usize> = args
619            .iter()
620            .enumerate()
621            .filter(|(_, a)| a.as_str() == "--permission-mode")
622            .map(|(i, _)| i)
623            .collect();
624        assert_eq!(modes.len(), 1, "exactly one --permission-mode (the host's)");
625        assert_eq!(args[modes[0] + 1], "bypassPermissions");
626        assert!(!args.iter().any(|a| a == "acceptEdits"));
627    }
628
629    #[test]
630    fn extra_args_sets_matches_flag_and_flag_eq_value() {
631        assert!(extra_args_sets(&["--permission-mode".to_owned()], "--permission-mode"));
632        assert!(extra_args_sets(&["--permission-mode=auto".to_owned()], "--permission-mode"));
633        assert!(!extra_args_sets(&["--add-dir".to_owned()], "--permission-mode"));
634    }
635}