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