use super::{AiBackend, AiEvent, AiRequest, AiResponse, AiUsage};
use anyhow::{Context, Result};
use async_trait::async_trait;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc::UnboundedSender;
const ALLOWED_TOOLS: &[&str] = &[
"Bash(git:diff)",
"Bash(git:log)",
"Bash(git:show)",
"Bash(git:status)",
"Bash(git:ls-files)",
"Bash(git:rev-parse)",
"Bash(git:branch)",
"Bash(git:cat-file)",
"Bash(git:rev-list)",
"Bash(git:shortlog)",
"Bash(git:blame)",
"Read",
];
pub struct ClaudeBackend {
model: Option<String>,
budget: f64,
debug: bool,
}
impl ClaudeBackend {
pub fn new(model: Option<String>, budget: f64, debug: bool) -> Self {
Self {
model,
budget,
debug,
}
}
fn base_command(&self, working_dir: &str) -> Command {
let model = self.model.as_deref().unwrap_or("haiku");
let mut cmd = Command::new("claude");
cmd.current_dir(working_dir).arg("--model").arg(model);
for tool in ALLOWED_TOOLS {
cmd.arg("--allowed-tools").arg(tool);
}
cmd.arg("--max-budget-usd")
.arg(format!("{:.2}", self.budget))
.arg("-p");
cmd
}
async fn request_streaming(
&self,
req: &AiRequest,
events: UnboundedSender<AiEvent>,
) -> Result<AiResponse> {
let system = embed_schema(&req.system_prompt, req.json_schema.as_deref());
let mut cmd = self.base_command(&req.working_dir);
cmd.arg(&req.user_prompt)
.arg("--system-prompt")
.arg(&system)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose");
if self.debug {
eprintln!(
"[DEBUG] claude stream-json (model={}, budget={:.2})",
self.model.as_deref().unwrap_or("haiku"),
self.budget
);
}
let mut child = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("failed to run claude CLI")?;
let stdout = child.stdout.take().unwrap();
let stderr_handle = child.stderr.take().unwrap();
let stderr_task = tokio::spawn(async move {
let mut buf = String::new();
let _ = tokio::io::AsyncReadExt::read_to_string(
&mut BufReader::new(stderr_handle),
&mut buf,
)
.await;
buf
});
let mut reader = BufReader::new(stdout).lines();
let mut result_text = String::new();
let mut usage = None;
while let Ok(Some(line)) = reader.next_line().await {
let event: serde_json::Value = match serde_json::from_str(line.trim()) {
Ok(v) => v,
Err(_) => continue,
};
if self.debug {
let t = event.get("type").and_then(|t| t.as_str()).unwrap_or("?");
eprintln!("[DEBUG] event: {t}");
}
parse_tool_calls(&event, &events);
if event.get("type").and_then(|t| t.as_str()) == Some("result") {
if let Some(r) = event.get("result") {
let raw = match r {
serde_json::Value::String(s) => s.clone(),
_ => r.to_string(),
};
result_text = crate::ai::copilot::extract_json(&raw).unwrap_or(raw);
}
usage = extract_usage(&event);
}
}
let stderr_text = stderr_task.await.unwrap_or_default();
let status = child.wait().await?;
if !status.success() {
anyhow::bail!(crate::error::SrAiError::AiBackend(format!(
"claude CLI failed (exit {}): {}",
status,
stderr_text.trim()
)));
}
if result_text.is_empty() {
anyhow::bail!(crate::error::SrAiError::ParseResponse(
"no result in claude stream".into()
));
}
Ok(AiResponse {
text: result_text,
usage,
})
}
async fn request_batch(&self, req: &AiRequest) -> Result<AiResponse> {
let mut cmd = self.base_command(&req.working_dir);
cmd.arg(&req.user_prompt)
.arg("--system-prompt")
.arg(&req.system_prompt)
.arg("--output-format")
.arg("json");
if let Some(schema) = &req.json_schema {
cmd.arg("--json-schema").arg(schema);
}
if self.debug {
eprintln!(
"[DEBUG] claude json (model={}, budget={:.2})",
self.model.as_deref().unwrap_or("haiku"),
self.budget
);
}
let output = cmd.output().await.context("failed to run claude CLI")?;
let raw = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr);
if self.debug {
eprintln!("[DEBUG] exit: {}", output.status);
eprintln!("[DEBUG] stdout (first 500): {}", &raw[..raw.len().min(500)]);
if !stderr.is_empty() {
eprintln!("[DEBUG] stderr: {stderr}");
}
}
if !output.status.success() {
anyhow::bail!(crate::error::SrAiError::AiBackend(format!(
"claude CLI failed (exit {}): {}",
output.status,
stderr.trim()
)));
}
let parsed: serde_json::Value =
serde_json::from_str(&raw).context("failed to parse claude JSON response")?;
let usage = extract_usage(&parsed);
if req.json_schema.is_some() {
let structured = &parsed["structured_output"];
if structured.is_null() {
anyhow::bail!(crate::error::SrAiError::ParseResponse(
"empty structured_output from claude".into()
));
}
Ok(AiResponse {
text: structured.to_string(),
usage,
})
} else {
let text = parsed
.get("result")
.map(|r| match r {
serde_json::Value::String(s) => s.clone(),
_ => r.to_string(),
})
.unwrap_or(raw);
Ok(AiResponse { text, usage })
}
}
}
#[async_trait]
impl AiBackend for ClaudeBackend {
fn name(&self) -> &str {
"claude"
}
async fn is_available(&self) -> bool {
Command::new("claude")
.arg("--version")
.output()
.await
.is_ok_and(|o| o.status.success())
}
async fn request(
&self,
req: &AiRequest,
events: Option<tokio::sync::mpsc::UnboundedSender<AiEvent>>,
) -> Result<AiResponse> {
match events {
Some(tx) => self.request_streaming(req, tx).await,
None => self.request_batch(req).await,
}
}
}
fn embed_schema(system_prompt: &str, json_schema: Option<&str>) -> String {
match json_schema {
Some(schema) => format!(
"{system_prompt}\n\n\
You MUST respond with valid JSON matching this schema:\n\
```json\n{schema}\n```\n\n\
Respond ONLY with the JSON object, no markdown fences, no explanation."
),
None => system_prompt.to_string(),
}
}
pub(crate) fn parse_tool_calls(event: &serde_json::Value, events: &UnboundedSender<AiEvent>) {
if let Some(content) = event.pointer("/message/content")
&& let Some(arr) = content.as_array()
{
for item in arr {
if item["type"] == "tool_use"
&& let Some(input) = extract_tool_input(item)
{
let tool = item["name"].as_str().unwrap_or("unknown").to_string();
let _ = events.send(AiEvent::ToolCall { tool, input });
}
}
}
if event.get("type").and_then(|t| t.as_str()) == Some("stream_event")
&& let Some(inner) = event.get("event")
&& inner.get("type").and_then(|t| t.as_str()) == Some("content_block_start")
&& let Some(block) = inner.get("content_block")
&& block.get("type").and_then(|t| t.as_str()) == Some("tool_use")
{
let tool = block["name"].as_str().unwrap_or("unknown").to_string();
let input = extract_tool_input(block).unwrap_or_default();
if !input.is_empty() {
let _ = events.send(AiEvent::ToolCall { tool, input });
}
}
}
fn extract_tool_input(item: &serde_json::Value) -> Option<String> {
if let Some(cmd) = item.pointer("/input/command").and_then(|c| c.as_str()) {
return Some(cmd.to_string());
}
if let Some(path) = item.pointer("/input/file_path").and_then(|p| p.as_str()) {
return Some(path.to_string());
}
item.get("input")
.filter(|i| !i.is_null())
.map(|i| serde_json::to_string(i).unwrap_or_default())
.filter(|s| !s.is_empty() && s != "{}")
}
fn extract_usage(parsed: &serde_json::Value) -> Option<AiUsage> {
let u = parsed.get("usage")?;
Some(AiUsage {
input_tokens: u.get("input_tokens")?.as_u64()?,
output_tokens: u.get("output_tokens")?.as_u64()?,
cost_usd: parsed.get("cost_usd").and_then(|c| c.as_f64()),
})
}