Skip to main content

chronicle/provider/
claude_code.rs

1use crate::error::provider_error::ApiSnafu;
2use crate::error::ProviderError;
3use crate::provider::{
4    AuthStatus, CompletionRequest, CompletionResponse, ContentBlock, LlmProvider, StopReason,
5    TokenUsage,
6};
7
8/// Provider that wraps the `claude` CLI (Claude Code) as a subprocess.
9/// Single-turn: sends full prompt via `claude --print -p`, gets text response.
10pub struct ClaudeCodeProvider {
11    model: Option<String>,
12}
13
14impl ClaudeCodeProvider {
15    pub fn new(model: Option<String>) -> Self {
16        Self { model }
17    }
18}
19
20impl LlmProvider for ClaudeCodeProvider {
21    fn complete(&self, request: &CompletionRequest) -> Result<CompletionResponse, ProviderError> {
22        // Build a single text prompt from the request
23        let mut prompt = String::new();
24
25        if !request.system.is_empty() {
26            prompt.push_str("System: ");
27            prompt.push_str(&request.system);
28            prompt.push_str("\n\n");
29        }
30
31        // Include tool definitions as formatted text
32        if !request.tools.is_empty() {
33            prompt.push_str("Available tools:\n");
34            for tool in &request.tools {
35                prompt.push_str(&format!("- {}: {}\n", tool.name, tool.description));
36                prompt.push_str(&format!(
37                    "  Input schema: {}\n",
38                    serde_json::to_string(&tool.input_schema).unwrap_or_default()
39                ));
40            }
41            prompt.push_str("\nTo use a tool, output a JSON block with {\"tool\": \"name\", \"input\": {...}}\n\n");
42        }
43
44        // Include messages
45        for msg in &request.messages {
46            let role = match msg.role {
47                crate::provider::Role::User => "User",
48                crate::provider::Role::Assistant => "Assistant",
49            };
50            for block in &msg.content {
51                match block {
52                    ContentBlock::Text { text } => {
53                        prompt.push_str(&format!("{role}: {text}\n\n"));
54                    }
55                    ContentBlock::ToolUse { name, input, .. } => {
56                        prompt.push_str(&format!(
57                            "{role}: [tool_use: {} {}]\n\n",
58                            name,
59                            serde_json::to_string(input).unwrap_or_default()
60                        ));
61                    }
62                    ContentBlock::ToolResult {
63                        content, is_error, ..
64                    } => {
65                        let prefix = if *is_error == Some(true) {
66                            "Error"
67                        } else {
68                            "Result"
69                        };
70                        prompt.push_str(&format!("{role}: [tool_result: {prefix}] {content}\n\n"));
71                    }
72                }
73            }
74        }
75
76        // Spawn claude CLI
77        let mut cmd = std::process::Command::new("claude");
78        cmd.arg("--print");
79        cmd.arg("-p");
80        cmd.arg(&prompt);
81
82        if let Some(ref model) = self.model {
83            cmd.arg("--model");
84            cmd.arg(model);
85        }
86
87        let output = cmd.output().map_err(|e| {
88            if e.kind() == std::io::ErrorKind::NotFound {
89                ProviderError::Api {
90                    message: "Claude CLI not found. Install Claude Code or run 'git chronicle reconfigure' to select a different provider.".to_string(),
91                    location: snafu::Location::default(),
92                }
93            } else {
94                ProviderError::Api {
95                    message: format!("Failed to spawn claude CLI: {e}"),
96                    location: snafu::Location::default(),
97                }
98            }
99        })?;
100
101        if !output.status.success() {
102            let stderr = String::from_utf8_lossy(&output.stderr);
103            return ApiSnafu {
104                message: format!("claude CLI failed: {stderr}"),
105            }
106            .fail();
107        }
108
109        let response_text = String::from_utf8_lossy(&output.stdout).to_string();
110
111        Ok(CompletionResponse {
112            content: vec![ContentBlock::Text {
113                text: response_text,
114            }],
115            stop_reason: StopReason::EndTurn,
116            usage: TokenUsage::default(),
117        })
118    }
119
120    fn check_auth(&self) -> Result<AuthStatus, ProviderError> {
121        match std::process::Command::new("claude")
122            .arg("--version")
123            .output()
124        {
125            Ok(output) if output.status.success() => Ok(AuthStatus::Valid),
126            Ok(_) => Ok(AuthStatus::Invalid(
127                "claude CLI returned non-zero exit code".to_string(),
128            )),
129            Err(e) => Ok(AuthStatus::Invalid(format!("claude CLI not found: {e}"))),
130        }
131    }
132
133    fn name(&self) -> &str {
134        "claude-code"
135    }
136
137    fn model(&self) -> &str {
138        self.model
139            .as_deref()
140            .unwrap_or("claude-sonnet-4-5-20250929")
141    }
142}