1use 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
30pub const CLAUDE_HARNESS_ID: &str = "claude";
32
33#[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 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 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 (*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 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 required: false,
169 }
170 }
171
172 fn login(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
173 crate::run_login_command("claude", &["auth", "login"], on_event)
176 }
177}
178
179fn 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 output.status.success() && !stdout.trim().is_empty()
200}
201
202fn 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 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 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 args.extend(tuning.extra_args.iter().cloned());
246 args
247}
248
249fn 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
256fn probe_version(program: &str) -> Option<String> {
259 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 assert!(!h.credential().required);
290 }
291
292 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 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 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 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 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 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 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}