chronicle/provider/
claude_code.rs1use crate::error::provider_error::ApiSnafu;
2use crate::error::ProviderError;
3use crate::provider::{
4 AuthStatus, CompletionRequest, CompletionResponse, ContentBlock, LlmProvider, StopReason,
5 TokenUsage,
6};
7
8pub 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 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 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 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 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}