Skip to main content

retro_core/analysis/
claude_cli.rs

1use 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
10/// Maximum time to wait for a single `claude -p` call before killing it.
11const EXECUTE_TIMEOUT_SECS: u64 = 300; // 5 minutes
12
13/// AI backend that spawns `claude -p` in non-interactive mode.
14pub 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    /// Check if the claude CLI is available on PATH.
26    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    /// Pre-flight auth check: sends a minimal prompt WITHOUT --json-schema
38    /// (which returns immediately on auth failure) and checks is_error.
39    /// This prevents the infinite StructuredOutput retry loop that occurs
40    /// when --json-schema is used with an expired/missing auth token.
41    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            // Couldn't parse JSON — fall back to checking stderr/stdout for auth errors
61            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
77/// Maximum time to wait for an agentic `claude -p` call (codebase exploration).
78const AGENTIC_TIMEOUT_SECS: u64 = 600; // 10 minutes
79
80/// Spawn a `claude` CLI child process, write the prompt to stdin, wait with a timeout,
81/// and return the parsed `ClaudeCliOutput`. Shared by `execute()` and `execute_agentic()`.
82fn run_claude_child(
83    mut child: std::process::Child,
84    prompt: &str,
85    timeout_secs: u64,
86    label: &str,
87) -> Result<ClaudeCliOutput, CoreError> {
88    // Write prompt to stdin and close it
89    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    // Read stdout/stderr in background threads to prevent pipe deadlock
96    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    /// Execute an agentic prompt: unlimited turns, full tool access, raw markdown output.
168    ///
169    /// Key differences from `execute()`:
170    /// - No `--max-turns` (unlimited turns for codebase exploration)
171    /// - No `--tools ""` (model needs tool access)
172    /// - No `--json-schema` (output is raw markdown)
173    /// - Longer timeout: 600 seconds (10 minutes)
174    /// - Result comes from `result` field (not `structured_output`)
175    /// - Optional `cwd` to set the working directory for tool calls
176    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        // Agentic calls: result comes from `result` field (no --json-schema used)
209        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        // Pipe prompt via stdin to avoid ARG_MAX limits on large prompts.
234        //
235        // When --json-schema is used:
236        //   - --tools "" is omitted because it conflicts with the internal
237        //     constrained-decoding tool call on large prompts.
238        //   - --max-turns 5 gives the model room for tool calls (which it
239        //     sometimes makes when tools aren't disabled) plus the final
240        //     structured output turn. With --max-turns 2, the model
241        //     intermittently exhausts turns on tool calls before producing
242        //     structured_output, leaving both result and structured_output empty.
243        //
244        // When --json-schema is NOT used:
245        //   - --tools "" disables all tool use (we only need a plain response).
246        //   - --max-turns 1 is sufficient since there are no tool calls.
247        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            // Clear CLAUDECODE to avoid nested-session rejection when retro
268            // is invoked from a post-commit hook inside a Claude Code session.
269            .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        // When --json-schema is used, the structured JSON appears in
286        // `structured_output` (as a parsed JSON value) rather than `result`.
287        // Serialize it back to a string for downstream parsing.
288        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}