use crate::config::AiConfig;
use crate::errors::CoreError;
use crate::models::ClaudeCliOutput;
use std::io::{Read, Write};
use std::process::Command;
use std::thread;
use std::time::{Duration, Instant};
use super::backend::{AnalysisBackend, BackendResponse};
const EXECUTE_TIMEOUT_SECS: u64 = 300;
pub struct ClaudeCliBackend {
model: String,
}
impl ClaudeCliBackend {
pub fn new(config: &AiConfig) -> Self {
Self {
model: config.model.clone(),
}
}
pub fn is_available() -> bool {
let safe_cwd = crate::config::retro_dir();
Command::new("claude")
.arg("--version")
.env_remove("CLAUDECODE")
.current_dir(&safe_cwd)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn check_auth() -> Result<(), CoreError> {
let safe_cwd = crate::config::retro_dir();
let output = Command::new("claude")
.args(["-p", "ping", "--output-format", "json", "--max-turns", "1", "--tools", ""])
.env_remove("CLAUDECODE")
.current_dir(&safe_cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| CoreError::Analysis(format!("auth check failed to spawn: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if let Ok(cli_output) = serde_json::from_str::<ClaudeCliOutput>(&stdout) {
if cli_output.is_error {
let msg = cli_output.result.unwrap_or_default();
return Err(CoreError::Analysis(format!(
"claude CLI auth failed: {msg}"
)));
}
} else if !output.status.success() {
let all_output = format!("{}{}", stdout, String::from_utf8_lossy(&output.stderr));
if all_output.contains("Not logged in") || all_output.contains("/login") {
return Err(CoreError::Analysis(
"claude CLI is not authenticated. Run `claude /login` first.".to_string()
));
}
return Err(CoreError::Analysis(format!(
"claude CLI auth check failed with exit code {}: {}",
output.status, all_output.trim()
)));
}
Ok(())
}
}
const AGENTIC_TIMEOUT_SECS: u64 = 600;
fn run_claude_child(
mut child: std::process::Child,
prompt: &str,
timeout_secs: u64,
label: &str,
) -> Result<ClaudeCliOutput, CoreError> {
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(prompt.as_bytes()).map_err(|e| {
CoreError::Analysis(format!("failed to write prompt to claude stdin: {e}"))
})?;
}
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_handle = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut pipe) = stdout_pipe {
let _ = pipe.read_to_end(&mut buf);
}
buf
});
let stderr_handle = thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut pipe) = stderr_pipe {
let _ = pipe.read_to_end(&mut buf);
}
buf
});
let timeout = Duration::from_secs(timeout_secs);
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) => {
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return Err(CoreError::Analysis(format!(
"claude CLI {label} timed out after {timeout_secs}s — killed process."
)));
}
thread::sleep(Duration::from_millis(500));
}
Err(e) => {
return Err(CoreError::Analysis(format!(
"error waiting for claude CLI ({label}): {e}"
)));
}
}
};
let stdout_bytes = stdout_handle.join().unwrap_or_default();
let stderr_bytes = stderr_handle.join().unwrap_or_default();
if !status.success() {
let stderr = String::from_utf8_lossy(&stderr_bytes);
return Err(CoreError::Analysis(format!(
"claude CLI ({label}) exited with {status}: {stderr}"
)));
}
let stdout = String::from_utf8_lossy(&stdout_bytes);
let cli_output: ClaudeCliOutput = serde_json::from_str(&stdout).map_err(|e| {
CoreError::Analysis(format!(
"failed to parse claude CLI {label} output: {e}\nraw output: {}",
truncate_for_error(&stdout)
))
})?;
if cli_output.is_error {
let error_text = cli_output.result.clone().unwrap_or_else(|| "unknown error".to_string());
return Err(CoreError::Analysis(format!(
"claude CLI ({label}) returned error: {error_text}"
)));
}
Ok(cli_output)
}
impl ClaudeCliBackend {
pub fn execute_agentic(&self, prompt: &str, cwd: Option<&str>) -> Result<BackendResponse, CoreError> {
let args = vec![
"-p",
"-",
"--output-format",
"json",
"--model",
&self.model,
];
let mut cmd = Command::new("claude");
cmd.args(&args)
.env_remove("CLAUDECODE")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(dir) = cwd {
cmd.current_dir(dir);
}
let child = cmd.spawn().map_err(|e| {
CoreError::Analysis(format!(
"failed to spawn claude CLI (agentic): {e}. Is claude installed and on PATH?"
))
})?;
let cli_output = run_claude_child(child, prompt, AGENTIC_TIMEOUT_SECS, "agentic")?;
let input_tokens = cli_output.total_input_tokens();
let output_tokens = cli_output.total_output_tokens();
let result_text = cli_output
.result
.filter(|s| !s.is_empty())
.ok_or_else(|| {
CoreError::Analysis(format!(
"claude CLI (agentic) returned empty result (is_error={}, num_turns={}, duration_ms={}, tokens_in={}, tokens_out={})",
cli_output.is_error,
cli_output.num_turns,
cli_output.duration_ms,
input_tokens,
output_tokens,
))
})?;
Ok(BackendResponse {
text: result_text,
input_tokens,
output_tokens,
})
}
}
impl AnalysisBackend for ClaudeCliBackend {
fn execute(&self, prompt: &str, json_schema: Option<&str>) -> Result<BackendResponse, CoreError> {
let max_turns = if json_schema.is_some() { "5" } else { "1" };
let mut args = vec![
"-p",
"-",
"--output-format",
"json",
"--model",
&self.model,
"--max-turns",
max_turns,
];
if let Some(schema) = json_schema {
args.push("--json-schema");
args.push(schema);
} else {
args.push("--tools");
args.push("");
}
let safe_cwd = crate::config::retro_dir();
let child = Command::new("claude")
.args(&args)
.env_remove("CLAUDECODE")
.current_dir(&safe_cwd)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| {
CoreError::Analysis(format!(
"failed to spawn claude CLI: {e}. Is claude installed and on PATH?"
))
})?;
let cli_output = run_claude_child(child, prompt, EXECUTE_TIMEOUT_SECS, "execute")?;
let input_tokens = cli_output.total_input_tokens();
let output_tokens = cli_output.total_output_tokens();
let result_text = cli_output
.structured_output
.map(|v| serde_json::to_string(&v).unwrap_or_default())
.filter(|s| !s.is_empty())
.or_else(|| cli_output.result.filter(|s| !s.is_empty()))
.ok_or_else(|| {
CoreError::Analysis(format!(
"claude CLI returned empty result (is_error={}, num_turns={}, duration_ms={}, tokens_in={}, tokens_out={})",
cli_output.is_error,
cli_output.num_turns,
cli_output.duration_ms,
input_tokens,
output_tokens,
))
})?;
Ok(BackendResponse {
text: result_text,
input_tokens,
output_tokens,
})
}
}
fn truncate_for_error(s: &str) -> &str {
if s.len() <= 500 {
s
} else {
let mut i = 500;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
&s[..i]
}
}