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        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    /// Pre-flight auth check: sends a minimal prompt WITHOUT --json-schema
40    /// (which returns immediately on auth failure) and checks is_error.
41    /// This prevents the infinite StructuredOutput retry loop that occurs
42    /// when --json-schema is used with an expired/missing auth token.
43    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            // Couldn't parse JSON — fall back to checking stderr/stdout for auth errors
65            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
81/// Maximum time to wait for an agentic `claude -p` call (codebase exploration).
82const AGENTIC_TIMEOUT_SECS: u64 = 600; // 10 minutes
83
84/// Spawn a `claude` CLI child process, write the prompt to stdin, wait with a timeout,
85/// and return the parsed `ClaudeCliOutput`. Shared by `execute()` and `execute_agentic()`.
86fn run_claude_child(
87    mut child: std::process::Child,
88    prompt: &str,
89    timeout_secs: u64,
90    label: &str,
91) -> Result<ClaudeCliOutput, CoreError> {
92    // Write prompt to stdin and close it
93    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    // Read stdout/stderr in background threads to prevent pipe deadlock
100    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    /// Execute an agentic prompt: unlimited turns, full tool access, raw markdown output.
172    ///
173    /// Key differences from `execute()`:
174    /// - No `--max-turns` (unlimited turns for codebase exploration)
175    /// - No `--tools ""` (model needs tool access)
176    /// - No `--json-schema` (output is raw markdown)
177    /// - Longer timeout: 600 seconds (10 minutes)
178    /// - Result comes from `result` field (not `structured_output`)
179    /// - Optional `cwd` to set the working directory for tool calls
180    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        // Agentic calls: result comes from `result` field (no --json-schema used)
213        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        // Pipe prompt via stdin to avoid ARG_MAX limits on large prompts.
238        //
239        // When --json-schema is used:
240        //   - --tools "" is omitted because it conflicts with the internal
241        //     constrained-decoding tool call on large prompts.
242        //   - --max-turns 5 gives the model room for tool calls (which it
243        //     sometimes makes when tools aren't disabled) plus the final
244        //     structured output turn. With --max-turns 2, the model
245        //     intermittently exhausts turns on tool calls before producing
246        //     structured_output, leaving both result and structured_output empty.
247        //
248        // When --json-schema is NOT used:
249        //   - --tools "" disables all tool use (we only need a plain response).
250        //   - --max-turns 1 is sufficient since there are no tool calls.
251        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        // Set cwd to ~/.retro/ when spawning claude CLI. This prevents the claude
270        // CLI from traversing the filesystem root or protected macOS directories
271        // (Documents, Desktop, Photos, network volumes) when launched by launchd
272        // where the default cwd is /. macOS TCC attributes child process file access
273        // to the parent (retro), causing spurious permission dialogs.
274        let safe_cwd = crate::config::retro_dir();
275        let child = Command::new("claude")
276            .args(&args)
277            // Clear CLAUDECODE to avoid nested-session rejection when retro
278            // is invoked from a post-commit hook inside a Claude Code session.
279            .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        // When --json-schema is used, the structured JSON appears in
297        // `structured_output` (as a parsed JSON value) rather than `result`.
298        // Serialize it back to a string for downstream parsing.
299        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}