retro_core/analysis/
claude_cli.rs1use crate::config::AiConfig;
2use crate::errors::CoreError;
3use crate::models::ClaudeCliOutput;
4use std::io::{Read, Write};
5use std::process::Command;
6use std::thread;
7use std::time::{Duration, Instant};
8use super::backend::{AnalysisBackend, BackendResponse};
9
10const EXECUTE_TIMEOUT_SECS: u64 = 300; pub struct ClaudeCliBackend {
15 model: String,
16}
17
18impl ClaudeCliBackend {
19 pub fn new(config: &AiConfig) -> Self {
20 Self {
21 model: config.model.clone(),
22 }
23 }
24
25 pub fn is_available() -> bool {
27 Command::new("claude")
28 .arg("--version")
29 .env_remove("CLAUDECODE")
30 .stdout(std::process::Stdio::null())
31 .stderr(std::process::Stdio::null())
32 .status()
33 .map(|s| s.success())
34 .unwrap_or(false)
35 }
36
37 pub fn check_auth() -> Result<(), CoreError> {
42 let output = Command::new("claude")
43 .args(["-p", "ping", "--output-format", "json", "--max-turns", "1", "--tools", ""])
44 .env_remove("CLAUDECODE")
45 .stdin(std::process::Stdio::null())
46 .stdout(std::process::Stdio::piped())
47 .stderr(std::process::Stdio::piped())
48 .output()
49 .map_err(|e| CoreError::Analysis(format!("auth check failed to spawn: {e}")))?;
50
51 let stdout = String::from_utf8_lossy(&output.stdout);
52 if let Ok(cli_output) = serde_json::from_str::<ClaudeCliOutput>(&stdout) {
53 if cli_output.is_error {
54 let msg = cli_output.result.unwrap_or_default();
55 return Err(CoreError::Analysis(format!(
56 "claude CLI auth failed: {msg}"
57 )));
58 }
59 } else if !output.status.success() {
60 let all_output = format!("{}{}", stdout, String::from_utf8_lossy(&output.stderr));
62 if all_output.contains("Not logged in") || all_output.contains("/login") {
63 return Err(CoreError::Analysis(
64 "claude CLI is not authenticated. Run `claude /login` first.".to_string()
65 ));
66 }
67 return Err(CoreError::Analysis(format!(
68 "claude CLI auth check failed with exit code {}: {}",
69 output.status, all_output.trim()
70 )));
71 }
72
73 Ok(())
74 }
75}
76
77const AGENTIC_TIMEOUT_SECS: u64 = 600; fn run_claude_child(
83 mut child: std::process::Child,
84 prompt: &str,
85 timeout_secs: u64,
86 label: &str,
87) -> Result<ClaudeCliOutput, CoreError> {
88 if let Some(mut stdin) = child.stdin.take() {
90 stdin.write_all(prompt.as_bytes()).map_err(|e| {
91 CoreError::Analysis(format!("failed to write prompt to claude stdin: {e}"))
92 })?;
93 }
94
95 let stdout_pipe = child.stdout.take();
97 let stderr_pipe = child.stderr.take();
98
99 let stdout_handle = thread::spawn(move || {
100 let mut buf = Vec::new();
101 if let Some(mut pipe) = stdout_pipe {
102 let _ = pipe.read_to_end(&mut buf);
103 }
104 buf
105 });
106 let stderr_handle = thread::spawn(move || {
107 let mut buf = Vec::new();
108 if let Some(mut pipe) = stderr_pipe {
109 let _ = pipe.read_to_end(&mut buf);
110 }
111 buf
112 });
113
114 let timeout = Duration::from_secs(timeout_secs);
115 let start = Instant::now();
116 let status = loop {
117 match child.try_wait() {
118 Ok(Some(status)) => break status,
119 Ok(None) => {
120 if start.elapsed() > timeout {
121 let _ = child.kill();
122 let _ = child.wait();
123 return Err(CoreError::Analysis(format!(
124 "claude CLI {label} timed out after {timeout_secs}s — killed process."
125 )));
126 }
127 thread::sleep(Duration::from_millis(500));
128 }
129 Err(e) => {
130 return Err(CoreError::Analysis(format!(
131 "error waiting for claude CLI ({label}): {e}"
132 )));
133 }
134 }
135 };
136
137 let stdout_bytes = stdout_handle.join().unwrap_or_default();
138 let stderr_bytes = stderr_handle.join().unwrap_or_default();
139
140 if !status.success() {
141 let stderr = String::from_utf8_lossy(&stderr_bytes);
142 return Err(CoreError::Analysis(format!(
143 "claude CLI ({label}) exited with {status}: {stderr}"
144 )));
145 }
146
147 let stdout = String::from_utf8_lossy(&stdout_bytes);
148
149 let cli_output: ClaudeCliOutput = serde_json::from_str(&stdout).map_err(|e| {
150 CoreError::Analysis(format!(
151 "failed to parse claude CLI {label} output: {e}\nraw output: {}",
152 truncate_for_error(&stdout)
153 ))
154 })?;
155
156 if cli_output.is_error {
157 let error_text = cli_output.result.clone().unwrap_or_else(|| "unknown error".to_string());
158 return Err(CoreError::Analysis(format!(
159 "claude CLI ({label}) returned error: {error_text}"
160 )));
161 }
162
163 Ok(cli_output)
164}
165
166impl ClaudeCliBackend {
167 pub fn execute_agentic(&self, prompt: &str, cwd: Option<&str>) -> Result<BackendResponse, CoreError> {
177 let args = vec![
178 "-p",
179 "-",
180 "--output-format",
181 "json",
182 "--model",
183 &self.model,
184 ];
185
186 let mut cmd = Command::new("claude");
187 cmd.args(&args)
188 .env_remove("CLAUDECODE")
189 .stdin(std::process::Stdio::piped())
190 .stdout(std::process::Stdio::piped())
191 .stderr(std::process::Stdio::piped());
192
193 if let Some(dir) = cwd {
194 cmd.current_dir(dir);
195 }
196
197 let child = cmd.spawn().map_err(|e| {
198 CoreError::Analysis(format!(
199 "failed to spawn claude CLI (agentic): {e}. Is claude installed and on PATH?"
200 ))
201 })?;
202
203 let cli_output = run_claude_child(child, prompt, AGENTIC_TIMEOUT_SECS, "agentic")?;
204
205 let input_tokens = cli_output.total_input_tokens();
206 let output_tokens = cli_output.total_output_tokens();
207
208 let result_text = cli_output
210 .result
211 .filter(|s| !s.is_empty())
212 .ok_or_else(|| {
213 CoreError::Analysis(format!(
214 "claude CLI (agentic) returned empty result (is_error={}, num_turns={}, duration_ms={}, tokens_in={}, tokens_out={})",
215 cli_output.is_error,
216 cli_output.num_turns,
217 cli_output.duration_ms,
218 input_tokens,
219 output_tokens,
220 ))
221 })?;
222
223 Ok(BackendResponse {
224 text: result_text,
225 input_tokens,
226 output_tokens,
227 })
228 }
229}
230
231impl AnalysisBackend for ClaudeCliBackend {
232 fn execute(&self, prompt: &str, json_schema: Option<&str>) -> Result<BackendResponse, CoreError> {
233 let max_turns = if json_schema.is_some() { "5" } else { "1" };
248 let mut args = vec![
249 "-p",
250 "-",
251 "--output-format",
252 "json",
253 "--model",
254 &self.model,
255 "--max-turns",
256 max_turns,
257 ];
258 if let Some(schema) = json_schema {
259 args.push("--json-schema");
260 args.push(schema);
261 } else {
262 args.push("--tools");
263 args.push("");
264 }
265 let child = Command::new("claude")
266 .args(&args)
267 .env_remove("CLAUDECODE")
270 .stdin(std::process::Stdio::piped())
271 .stdout(std::process::Stdio::piped())
272 .stderr(std::process::Stdio::piped())
273 .spawn()
274 .map_err(|e| {
275 CoreError::Analysis(format!(
276 "failed to spawn claude CLI: {e}. Is claude installed and on PATH?"
277 ))
278 })?;
279
280 let cli_output = run_claude_child(child, prompt, EXECUTE_TIMEOUT_SECS, "execute")?;
281
282 let input_tokens = cli_output.total_input_tokens();
283 let output_tokens = cli_output.total_output_tokens();
284
285 let result_text = cli_output
289 .structured_output
290 .map(|v| serde_json::to_string(&v).unwrap_or_default())
291 .filter(|s| !s.is_empty())
292 .or_else(|| cli_output.result.filter(|s| !s.is_empty()))
293 .ok_or_else(|| {
294 CoreError::Analysis(format!(
295 "claude CLI returned empty result (is_error={}, num_turns={}, duration_ms={}, tokens_in={}, tokens_out={})",
296 cli_output.is_error,
297 cli_output.num_turns,
298 cli_output.duration_ms,
299 input_tokens,
300 output_tokens,
301 ))
302 })?;
303
304 Ok(BackendResponse {
305 text: result_text,
306 input_tokens,
307 output_tokens,
308 })
309 }
310}
311
312fn truncate_for_error(s: &str) -> &str {
313 if s.len() <= 500 {
314 s
315 } else {
316 let mut i = 500;
317 while i > 0 && !s.is_char_boundary(i) {
318 i -= 1;
319 }
320 &s[..i]
321 }
322}