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