Skip to main content

nucel_agent_claude_code/
lib.rs

1//! Claude Code provider — wraps the `claude` CLI as a subprocess.
2//!
3//! Communicates via JSONL stdio protocol. Supports:
4//! - One-shot and multi-turn queries
5//! - Cost tracking per session
6//! - Permission mode configuration
7//! - Budget enforcement
8
9mod process;
10mod protocol;
11
12use std::path::Path;
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use tokio::sync::Mutex;
17
18use nucel_agent_core::{
19    AgentCapabilities, AgentCost, AgentError, AgentExecutor, AgentResponse, AgentSession,
20    AvailabilityStatus, ExecutorType, Result, SessionImpl, SpawnConfig,
21};
22
23use process::ClaudeProcess;
24
25/// Claude Code executor — spawns `claude` CLI subprocess.
26pub struct ClaudeCodeExecutor {
27    api_key: Option<String>,
28}
29
30impl ClaudeCodeExecutor {
31    pub fn new() -> Self {
32        Self { api_key: None }
33    }
34
35    pub fn with_api_key(api_key: impl Into<String>) -> Self {
36        Self {
37            api_key: Some(api_key.into()),
38        }
39    }
40
41    fn check_cli_available() -> bool {
42        std::process::Command::new("which")
43            .arg("claude")
44            .stdout(std::process::Stdio::null())
45            .stderr(std::process::Stdio::null())
46            .status()
47            .map(|s| s.success())
48            .unwrap_or(false)
49    }
50}
51
52impl Default for ClaudeCodeExecutor {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58/// Internal session implementation for Claude Code.
59struct ClaudeSessionImpl {
60    process: Arc<Mutex<ClaudeProcess>>,
61    cost: Arc<std::sync::Mutex<AgentCost>>,
62    budget: f64,
63}
64
65#[async_trait]
66impl SessionImpl for ClaudeSessionImpl {
67    async fn query(&self, prompt: &str) -> Result<AgentResponse> {
68        // Budget guard.
69        {
70            let c = self.cost.lock().unwrap();
71            if c.total_usd >= self.budget {
72                return Err(AgentError::BudgetExceeded {
73                    limit: self.budget,
74                    spent: c.total_usd,
75                });
76            }
77        }
78
79        let mut proc = self.process.lock().await;
80        proc.send_query(prompt).await?;
81        let resp = proc.read_response(self.budget).await?;
82
83        {
84            let mut c = self.cost.lock().unwrap();
85            c.input_tokens += resp.cost.input_tokens;
86            c.output_tokens += resp.cost.output_tokens;
87            c.total_usd += resp.cost.total_usd;
88        }
89
90        Ok(resp)
91    }
92
93    async fn total_cost(&self) -> Result<AgentCost> {
94        Ok(self.cost.lock().unwrap().clone())
95    }
96
97    async fn close(&self) -> Result<()> {
98        let mut proc = self.process.lock().await;
99        proc.shutdown().await
100    }
101}
102
103#[async_trait]
104impl AgentExecutor for ClaudeCodeExecutor {
105    fn executor_type(&self) -> ExecutorType {
106        ExecutorType::ClaudeCode
107    }
108
109    async fn spawn(
110        &self,
111        working_dir: &Path,
112        prompt: &str,
113        config: &SpawnConfig,
114    ) -> Result<AgentSession> {
115        let cost = Arc::new(std::sync::Mutex::new(AgentCost::default()));
116        let budget = config.budget_usd.unwrap_or(f64::MAX);
117
118        if budget <= 0.0 {
119            return Err(AgentError::BudgetExceeded {
120                limit: budget,
121                spent: 0.0,
122            });
123        }
124
125        let mut proc = ClaudeProcess::start(
126            working_dir,
127            prompt,
128            config,
129            self.api_key.as_deref(),
130        )
131        .await?;
132
133        // Capture the pre-minted session id before the read may consume `proc`.
134        let session_id = proc.session_id().to_string();
135
136        let response = proc.read_response(budget).await?;
137
138        {
139            let mut c = cost.lock().unwrap();
140            *c = response.cost.clone();
141        }
142
143        let inner = Arc::new(ClaudeSessionImpl {
144            process: Arc::new(Mutex::new(proc)),
145            cost: cost.clone(),
146            budget,
147        });
148
149        Ok(AgentSession::new(
150            session_id,
151            ExecutorType::ClaudeCode,
152            working_dir.to_path_buf(),
153            config.model.clone(),
154            inner,
155        ))
156    }
157
158    async fn resume(
159        &self,
160        working_dir: &Path,
161        session_id: &str,
162        prompt: &str,
163        config: &SpawnConfig,
164    ) -> Result<AgentSession> {
165        let cost = Arc::new(std::sync::Mutex::new(AgentCost::default()));
166        let budget = config.budget_usd.unwrap_or(f64::MAX);
167
168        if budget <= 0.0 {
169            return Err(AgentError::BudgetExceeded {
170                limit: budget,
171                spent: 0.0,
172            });
173        }
174
175        // Use official --resume <session_id> CLI flag. The resume keeps the
176        // original session id so consumers can keep resuming.
177        let mut proc = ClaudeProcess::start_resume(
178            working_dir,
179            session_id,
180            prompt,
181            config,
182            self.api_key.as_deref(),
183        )
184        .await?;
185
186        let resumed_session_id = proc.session_id().to_string();
187        let response = proc.read_response(budget).await?;
188
189        {
190            let mut c = cost.lock().unwrap();
191            *c = response.cost.clone();
192        }
193
194        let inner = Arc::new(ClaudeSessionImpl {
195            process: Arc::new(Mutex::new(proc)),
196            cost: cost.clone(),
197            budget,
198        });
199
200        Ok(AgentSession::new(
201            resumed_session_id,
202            ExecutorType::ClaudeCode,
203            working_dir.to_path_buf(),
204            config.model.clone(),
205            inner,
206        ))
207    }
208
209    fn capabilities(&self) -> AgentCapabilities {
210        AgentCapabilities {
211            session_resume: true,
212            token_usage: true,
213            mcp_support: true,
214            autonomous_mode: true,
215            structured_output: false,
216        }
217    }
218
219    fn availability(&self) -> AvailabilityStatus {
220        if Self::check_cli_available() {
221            AvailabilityStatus {
222                available: true,
223                reason: None,
224            }
225        } else {
226            AvailabilityStatus {
227                available: false,
228                reason: Some(
229                    "`claude` CLI not found. Install: npm install -g @anthropic-ai/claude-code"
230                        .to_string(),
231                ),
232            }
233        }
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn executor_type_is_claude_code() {
243        let exec = ClaudeCodeExecutor::new();
244        assert_eq!(exec.executor_type(), ExecutorType::ClaudeCode);
245    }
246
247    #[test]
248    fn capabilities_declares_autonomous_mode() {
249        let exec = ClaudeCodeExecutor::new();
250        let caps = exec.capabilities();
251        assert!(caps.autonomous_mode);
252        assert!(caps.token_usage);
253        assert!(caps.mcp_support);
254        assert!(caps.session_resume, "Claude Code supports --resume flag");
255    }
256
257    #[tokio::test]
258    async fn budget_zero_returns_error_before_spawn() {
259        let exec = ClaudeCodeExecutor::new();
260        let result = exec
261            .spawn(
262                Path::new("/tmp"),
263                "test",
264                &SpawnConfig {
265                    budget_usd: Some(0.0),
266                    ..Default::default()
267                },
268            )
269            .await;
270        assert!(matches!(result, Err(AgentError::BudgetExceeded { .. })));
271    }
272}