use std::path::PathBuf;
use serde_json::Value;
use crate::{
normalize_process_event, probe_version, Command, ResolveCli, CredentialSpec, Harness,
Features, Error, Info, ModelChoice, Readiness,
InstallCallback, InstallHint, RunCallback, RunHandle, RunMode, RunRequest, RunTuning,
};
mod parser;
pub(crate) mod resolve;
pub use parser::parse_claude_line;
pub const CLAUDE_HARNESS_ID: &str = "claude";
pub const DEFAULT_CLAUDE_COMMAND: &str = "claude";
#[derive(Debug, Clone)]
pub struct ClaudeHarness {
command: String,
}
impl Default for ClaudeHarness {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug)]
pub struct ClaudeHarnessConfig {
pub command: String,
}
impl Default for ClaudeHarnessConfig {
fn default() -> Self {
Self { command: DEFAULT_CLAUDE_COMMAND.to_owned() }
}
}
impl ClaudeHarness {
pub fn new() -> Self {
Self::custom(ClaudeHarnessConfig::default())
}
pub fn custom(config: ClaudeHarnessConfig) -> Self {
Self { command: config.command }
}
}
impl Harness for ClaudeHarness {
fn info(&self) -> Info {
Info {
id: CLAUDE_HARNESS_ID.to_owned(),
display_name: "Claude Code".to_owned(),
description: "Anthropic's Claude Code agent CLI. Uses your existing Claude Code login."
.to_owned(),
install_hint: Some(
InstallHint::url("https://code.claude.com/docs")
.with_command("curl -fsSL https://claude.ai/install.sh | bash"),
),
}
}
fn features(&self) -> Features {
Features {
models: vec![
ModelChoice { value: "sonnet".to_owned(), label: "Sonnet (latest)".to_owned() },
ModelChoice { value: "opus".to_owned(), label: "Opus (latest)".to_owned() },
ModelChoice { value: "fable".to_owned(), label: "Fable (latest)".to_owned() },
ModelChoice { value: "haiku".to_owned(), label: "Haiku (latest)".to_owned() },
],
max_turns: true,
login: true,
..Default::default()
}
}
fn list_models(&self) -> Result<Vec<ModelChoice>, Error> {
let mut models = self.features().models;
models.extend(crate::models_dev::provider_models("anthropic"));
Ok(models)
}
fn readiness(&self) -> Readiness {
let Some(version) = probe_version(&self.command) else {
return Readiness {
harness_id: CLAUDE_HARNESS_ID.to_owned(),
ready: false,
installed: false,
version: None,
auth_configured: false,
error: Some("Claude Code (`claude`) is not installed or not on PATH.".to_owned()),
details: Value::Null,
};
};
let signed_in = probe_claude_signed_in(&self.command)
|| crate::harness::api_key_value_usable(std::env::var("ANTHROPIC_API_KEY").ok());
Readiness {
harness_id: CLAUDE_HARNESS_ID.to_owned(),
ready: signed_in,
installed: true,
version: Some(version),
auth_configured: signed_in,
error: if signed_in {
None
} else {
Some(
"Claude Code is installed but not signed in. Click Sign in to connect your Anthropic account, or set ANTHROPIC_API_KEY."
.to_owned(),
)
},
details: resolved_details(&self.command),
}
}
fn start(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, Error> {
let RunRequest { run_id, prompt, cwd, mode, tuning, resume, attachments: _ } = request;
let args = build_claude_args(prompt, mode, &tuning, resume.as_deref());
let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let program = tuning.binary_path.clone().unwrap_or_else(|| PathBuf::from(&self.command));
let handle = Command::new(program)
.cwd(cwd)
.run_id(run_id)
.args(args)
.resolve_cli()
.stream(move |event| {
for normalized in normalize_process_event(event, parse_claude_line) {
(*on_event)(normalized);
}
},
)
.map_err(Error::spawn)?;
Ok(Box::new(handle))
}
fn credential(&self) -> CredentialSpec {
CredentialSpec {
label: "Claude Code login (managed by the claude CLI)".to_owned(),
keychain_service: "anthropic".to_owned(),
keychain_account: "ANTHROPIC_API_KEY".to_owned(),
required: false,
}
}
fn login(&self, on_event: InstallCallback) -> Result<(), Error> {
crate::run_login_command(&self.command, &["auth", "login"], on_event)
}
}
fn probe_claude_signed_in(command: &str) -> bool {
let Ok(output) = crate::hidden_command(command)
.args(["auth", "status"])
.env("PATH", crate::augmented_node_path())
.output()
else {
return false;
};
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(stdout.trim()) {
if let Some(logged_in) = map.get("loggedIn").and_then(Value::as_bool) {
return logged_in;
}
}
output.status.success() && !stdout.trim().is_empty()
}
fn resolved_details(command: &str) -> Value {
let path = crate::augmented_node_path();
let Some(resolved) = resolve::resolve_on_path(command, &path) else {
return Value::Null;
};
let mut details = serde_json::Map::new();
details.insert(
"resolved_path".to_owned(),
Value::String(resolved.to_string_lossy().into_owned()),
);
if let Ok(home) = std::env::var("HOME") {
let kind = resolve::classify(&resolved, std::path::Path::new(&home), None);
details.insert("install_kind".to_owned(), Value::String(kind.as_str().to_owned()));
}
Value::Object(details)
}
fn build_claude_args(
prompt: String,
mode: RunMode,
tuning: &RunTuning,
resume: Option<&str>,
) -> Vec<String> {
let mut args = vec![
"-p".to_owned(),
prompt,
"--output-format".to_owned(),
"stream-json".to_owned(),
"--verbose".to_owned(),
"--include-partial-messages".to_owned(),
];
if let Some(session_id) = resume {
args.push("--resume".to_owned());
args.push(session_id.to_owned());
}
if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
args.push("--model".to_owned());
args.push(model.to_owned());
}
if let Some(max_turns) = tuning.max_turns {
args.push("--max-turns".to_owned());
args.push(max_turns.to_string());
}
if matches!(mode, RunMode::Edit) && !extra_args_sets(&tuning.extra_args, "--permission-mode") {
args.push("--permission-mode".to_owned());
args.push("acceptEdits".to_owned());
}
args.extend(tuning.extra_args.iter().cloned());
args
}
fn extra_args_sets(extra_args: &[String], flag: &str) -> bool {
let with_eq = format!("{flag}=");
extra_args.iter().any(|a| a == flag || a.starts_with(&with_eq))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::events::RunEvent;
use crate::ReasoningEffort;
#[cfg(unix)]
fn fake_claude(tag: &str, signed_in: bool, emits: &str) -> (std::path::PathBuf, std::path::PathBuf) {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("claude-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let argv = dir.join("argv");
let cli = dir.join("claude");
std::fs::write(
&cli,
format!(
"#!/bin/sh\n\
case \"$1\" in\n\
--version) echo '1.2.3 (Claude Code)'; exit 0 ;;\n\
auth) echo '{{\"loggedIn\":{signed_in}}}'; exit 0 ;;\n\
-p) : > '{argv}'; for a in \"$@\"; do printf '%s\\n' \"$a\" >> '{argv}'; done\n\
{emits}\n\
exit 0 ;;\n\
esac\n\
exit 1\n",
argv = argv.display(),
),
)
.unwrap();
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
(cli, argv)
}
#[cfg(unix)]
fn drive(cli: &std::path::Path, request: RunRequest) -> Vec<RunEvent> {
use std::sync::{Arc, Mutex};
let seen: Arc<Mutex<Vec<RunEvent>>> = Arc::default();
let sink = Arc::clone(&seen);
let harness = ClaudeHarness::custom(ClaudeHarnessConfig {
command: cli.display().to_string(),
});
let handle = harness
.start(request, Arc::new(move |event| sink.lock().unwrap().push(event)))
.expect("the stand-in should spawn");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let done = seen
.lock()
.unwrap()
.iter()
.any(|event| matches!(event, RunEvent::Exited { .. }));
if done {
break;
}
assert!(std::time::Instant::now() < deadline, "the run never exited");
std::thread::sleep(std::time::Duration::from_millis(20));
}
let _ = handle.cancel();
let events = seen.lock().unwrap().clone();
events
}
#[cfg(unix)]
#[test]
fn a_run_reaches_the_cli_as_stream_json_and_comes_back_as_events() {
let delta = r#"{"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"hi there"}}}"#;
let (cli, argv) = fake_claude("run", true, &format!("printf '%s\\n' '{delta}'"));
let events = drive(
&cli,
RunRequest {
run_id: "r1".to_owned(),
prompt: "greet me".to_owned(),
cwd: Some(std::env::temp_dir()),
tuning: RunTuning { model: Some("opus".to_owned()), ..RunTuning::default() },
..RunRequest::default()
},
);
let passed: Vec<String> =
std::fs::read_to_string(&argv).unwrap().lines().map(str::to_owned).collect();
assert_eq!(passed.first().map(String::as_str), Some("-p"));
assert_eq!(
passed.get(1).map(String::as_str),
Some("greet me"),
"the prompt follows -p: {passed:?}",
);
assert!(passed.windows(2).any(|w| w[0] == "--output-format" && w[1] == "stream-json"));
assert!(passed.iter().any(|a| a == "--include-partial-messages"), "{passed:?}");
assert!(passed.windows(2).any(|w| w[0] == "--model" && w[1] == "opus"));
assert!(
events.iter().any(|e| matches!(e, RunEvent::Text { delta, .. } if delta == "hi there")),
"the CLI's delta should arrive as text: {events:?}",
);
assert!(events.iter().any(|e| matches!(
e,
RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }
)));
}
#[cfg(unix)]
#[test]
fn readiness_reads_logged_in_from_the_auth_probe() {
let (yes, _) = fake_claude("in", true, ":");
let ready =
ClaudeHarness::custom(ClaudeHarnessConfig { command: yes.display().to_string() })
.readiness();
assert!(ready.installed && ready.ready && ready.auth_configured);
assert_eq!(ready.version.as_deref(), Some("1.2.3 (Claude Code)"));
let (no, _) = fake_claude("out", false, ":");
let ready =
ClaudeHarness::custom(ClaudeHarnessConfig { command: no.display().to_string() })
.readiness();
assert!(ready.installed, "the binary is present either way");
assert!(!ready.ready && !ready.auth_configured);
assert!(ready.error.is_some(), "a signed-out CLI must say what to do");
}
#[test]
fn claude_info_and_credential() {
let h = ClaudeHarness::new();
assert_eq!(h.info().id, CLAUDE_HARNESS_ID);
let hint = h.info().install_hint.expect("Claude Code is a CLI the user installs");
assert!(hint.command.is_some_and(|c| c.contains("claude.ai/install.sh")));
assert!(!h.credential().required);
}
#[test]
fn a_renamed_binary_is_what_gets_probed() {
let renamed = ClaudeHarness::custom(ClaudeHarnessConfig {
command: "definitely-not-a-real-binary-xyz".into(),
});
let readiness = renamed.readiness();
assert!(!readiness.installed, "an unresolvable command cannot report installed");
assert_eq!(ClaudeHarness::new().command, DEFAULT_CLAUDE_COMMAND);
assert_eq!(ClaudeHarness::default().command, DEFAULT_CLAUDE_COMMAND);
}
#[cfg(unix)]
fn fake_cli(tag: &str, script: &str) -> std::path::PathBuf {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("hl-claude-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cli");
std::fs::write(&path, format!("#!/bin/sh\n{script}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[cfg(unix)]
#[test]
fn a_signed_out_cli_is_believed_over_the_exit_code() {
let out = fake_cli("signedout", r#"echo '{"loggedIn": false}'"#);
assert!(!probe_claude_signed_in(out.to_str().unwrap()));
let inn = fake_cli("signedin", r#"echo '{"loggedIn": true}'"#);
assert!(probe_claude_signed_in(inn.to_str().unwrap()));
}
#[cfg(unix)]
#[test]
fn a_cli_that_does_not_answer_in_json_falls_back_to_how_it_exited() {
let prose = fake_cli("prose", "echo 'Logged in as someone@example.test'");
assert!(probe_claude_signed_in(prose.to_str().unwrap()));
let silent = fake_cli("silent", "exit 0");
assert!(!probe_claude_signed_in(silent.to_str().unwrap()), "exit 0 with nothing said proves nothing");
let failed = fake_cli("authfail", "echo 'not logged in'; exit 1");
assert!(!probe_claude_signed_in(failed.to_str().unwrap()));
assert!(!probe_claude_signed_in("definitely-not-a-real-binary-xyz"), "an absent CLI is not signed in");
}
#[test]
fn every_documented_alias_is_offered() {
let caps = ClaudeHarness::new().features();
let offered: Vec<&str> = caps.models.iter().map(|m| m.value.as_str()).collect();
for alias in ["sonnet", "opus", "fable", "haiku"] {
assert!(offered.contains(&alias), "`--model {alias}` is documented, got {offered:?}");
}
assert!(
!caps.custom_model,
"if free-text entry is ever allowed, an omission above stops being unreachable \
and this test can relax"
);
}
fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
args.iter()
.position(|a| a == flag)
.and_then(|i| args.get(i + 1))
.map(String::as_str)
}
#[test]
fn claude_args_default_omit_model_and_turn_cap() {
let args = build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), None);
assert_eq!(args[0], "-p");
assert_eq!(args[1], "hi");
assert!(!args.iter().any(|a| a == "--model"));
assert!(!args.iter().any(|a| a == "--max-turns"));
assert!(!args.iter().any(|a| a == "--permission-mode"));
}
#[test]
fn claude_resume_adds_session_flag() {
let args =
build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default(), Some("sess-123"));
assert_eq!(flag_value(&args, "--resume"), Some("sess-123"));
assert_eq!(args[0], "-p");
assert_eq!(args[1], "hi");
}
#[test]
fn claude_args_carry_model_and_max_turns_and_ignore_effort() {
let tuning = RunTuning {
model: Some("opus".to_owned()),
effort: Some(ReasoningEffort::High),
max_turns: Some(5),
..RunTuning::default()
};
let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
assert_eq!(flag_value(&args, "--model"), Some("opus"));
assert_eq!(flag_value(&args, "--max-turns"), Some("5"));
assert!(!args.iter().any(|a| a.contains("reasoning_effort")));
}
#[test]
fn claude_blank_model_is_treated_as_unset() {
let tuning = RunTuning { model: Some(" ".to_owned()), ..RunTuning::default() };
let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
assert!(!args.iter().any(|a| a == "--model"));
}
#[test]
fn claude_edit_mode_defaults_to_accept_edits() {
let args = build_claude_args("hi".to_owned(), RunMode::Edit, &RunTuning::default(), None);
assert_eq!(flag_value(&args, "--permission-mode"), Some("acceptEdits"));
}
#[test]
fn host_extra_args_are_appended_verbatim() {
let tuning = RunTuning {
extra_args: vec!["--add-dir".to_owned(), "/extra".to_owned()],
..RunTuning::default()
};
let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning, None);
assert!(args.ends_with(&["--add-dir".to_owned(), "/extra".to_owned()]));
}
#[test]
fn host_permission_mode_replaces_the_default_cleanly() {
let tuning = RunTuning {
extra_args: vec!["--permission-mode".to_owned(), "bypassPermissions".to_owned()],
..RunTuning::default()
};
let args = build_claude_args("hi".to_owned(), RunMode::Edit, &tuning, None);
let modes: Vec<usize> = args
.iter()
.enumerate()
.filter(|(_, a)| a.as_str() == "--permission-mode")
.map(|(i, _)| i)
.collect();
assert_eq!(modes.len(), 1, "exactly one --permission-mode (the host's)");
assert_eq!(args[modes[0] + 1], "bypassPermissions");
assert!(!args.iter().any(|a| a == "acceptEdits"));
}
#[test]
fn extra_args_sets_matches_flag_and_flag_eq_value() {
assert!(extra_args_sets(&["--permission-mode".to_owned()], "--permission-mode"));
assert!(extra_args_sets(&["--permission-mode=auto".to_owned()], "--permission-mode"));
assert!(!extra_args_sets(&["--add-dir".to_owned()], "--permission-mode"));
}
}