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 } = request;
139 let args = build_claude_args(prompt, mode, &tuning);
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(prompt: String, mode: RunMode, tuning: &RunTuning) -> Vec<String> {
208 let mut args = vec![
209 "-p".to_owned(),
210 prompt,
211 "--output-format".to_owned(),
212 "stream-json".to_owned(),
213 "--verbose".to_owned(),
214 "--include-partial-messages".to_owned(),
215 ];
216 if let Some(model) = tuning.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
217 args.push("--model".to_owned());
218 args.push(model.to_owned());
219 }
220 if let Some(max_turns) = tuning.max_turns {
221 args.push("--max-turns".to_owned());
222 args.push(max_turns.to_string());
223 }
224 if matches!(mode, RunMode::Edit) {
225 args.push("--permission-mode".to_owned());
228 args.push("acceptEdits".to_owned());
229 }
230 args
231}
232
233fn probe_version(program: &str) -> Option<String> {
236 let output = Command::new(program)
240 .arg("--version")
241 .env("PATH", crate::augmented_node_path())
242 .output()
243 .ok()?;
244 if !output.status.success() {
245 return None;
246 }
247 let text = String::from_utf8_lossy(&output.stdout).trim().to_owned();
248 if text.is_empty() {
249 None
250 } else {
251 Some(text)
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::ReasoningEffort;
259
260 #[test]
261 fn claude_info_and_credential() {
262 let h = ClaudeHarness::new();
263 assert_eq!(h.info().id, CLAUDE_HARNESS_ID);
264 assert!(h.info().requires_install);
265 assert!(!h.credential().required);
267 }
268
269 fn flag_value<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
271 args.iter()
272 .position(|a| a == flag)
273 .and_then(|i| args.get(i + 1))
274 .map(String::as_str)
275 }
276
277 #[test]
278 fn claude_args_default_omit_model_and_turn_cap() {
279 let args = build_claude_args("hi".to_owned(), RunMode::Ask, &RunTuning::default());
280 assert_eq!(args[0], "-p");
282 assert_eq!(args[1], "hi");
283 assert!(!args.iter().any(|a| a == "--model"));
284 assert!(!args.iter().any(|a| a == "--max-turns"));
285 assert!(!args.iter().any(|a| a == "--permission-mode"));
286 }
287
288 #[test]
289 fn claude_args_carry_model_and_max_turns_and_ignore_effort() {
290 let tuning = RunTuning {
291 model: Some("opus".to_owned()),
292 effort: Some(ReasoningEffort::High),
293 max_turns: Some(5),
294 };
295 let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning);
296 assert_eq!(flag_value(&args, "--model"), Some("opus"));
297 assert_eq!(flag_value(&args, "--max-turns"), Some("5"));
298 assert!(!args.iter().any(|a| a.contains("reasoning_effort")));
300 }
301
302 #[test]
303 fn claude_blank_model_is_treated_as_unset() {
304 let tuning = RunTuning { model: Some(" ".to_owned()), ..RunTuning::default() };
305 let args = build_claude_args("hi".to_owned(), RunMode::Ask, &tuning);
306 assert!(!args.iter().any(|a| a == "--model"));
307 }
308
309 #[test]
310 fn claude_edit_mode_accepts_edits() {
311 let args = build_claude_args("hi".to_owned(), RunMode::Edit, &RunTuning::default());
312 assert_eq!(flag_value(&args, "--permission-mode"), Some("acceptEdits"));
313 }
314}