1use 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;
27pub(crate) mod resolve;
30pub use parser::parse_claude_line;
31
32pub const CLAUDE_HARNESS_ID: &str = "claude";
34
35pub const DEFAULT_CLAUDE_COMMAND: &str = "claude";
37
38#[derive(Debug, Clone)]
40pub struct ClaudeHarness {
41 command: String,
42}
43
44impl Default for ClaudeHarness {
45 fn default() -> Self {
48 Self::new()
49 }
50}
51
52#[derive(Clone, Debug)]
56pub struct ClaudeHarnessConfig {
57 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 pub fn new() -> Self {
73 Self::custom(ClaudeHarnessConfig::default())
74 }
75
76 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 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 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 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 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 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 required: false,
205 }
206 }
207
208 fn login(&self, on_event: InstallCallback) -> Result<(), Error> {
209 crate::run_login_command(&self.command, &["auth", "login"], on_event)
212 }
213}
214
215fn 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 output.status.success() && !stdout.trim().is_empty()
236}
237
238fn 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
262fn 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 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 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 args.extend(tuning.extra_args.iter().cloned());
306 args
307}
308
309fn 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 #[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 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 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 assert!(!h.credential().required);
454 }
455
456 #[test]
457 fn a_renamed_binary_is_what_gets_probed() {
458 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 assert_eq!(ClaudeHarness::new().command, DEFAULT_CLAUDE_COMMAND);
471 assert_eq!(ClaudeHarness::default().command, DEFAULT_CLAUDE_COMMAND);
472 }
473
474 #[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 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 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 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 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 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 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 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 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 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 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}