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        tracing::warn!(
165            session_id = %session_id,
166            "Claude Code resume: spawning new session (native resume not yet supported via CLI)"
167        );
168        self.spawn(working_dir, prompt, config).await
169    }
170
171    fn capabilities(&self) -> AgentCapabilities {
172        AgentCapabilities {
173            session_resume: false,
174            token_usage: true,
175            mcp_support: true,
176            autonomous_mode: true,
177            structured_output: false,
178        }
179    }
180
181    fn availability(&self) -> AvailabilityStatus {
182        if Self::check_cli_available() {
183            AvailabilityStatus {
184                available: true,
185                reason: None,
186            }
187        } else {
188            AvailabilityStatus {
189                available: false,
190                reason: Some(
191                    "`claude` CLI not found. Install: npm install -g @anthropic-ai/claude-code"
192                        .to_string(),
193                ),
194            }
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn executor_type_is_claude_code() {
205        let exec = ClaudeCodeExecutor::new();
206        assert_eq!(exec.executor_type(), ExecutorType::ClaudeCode);
207    }
208
209    #[test]
210    fn capabilities_declares_autonomous_mode() {
211        let exec = ClaudeCodeExecutor::new();
212        let caps = exec.capabilities();
213        assert!(caps.autonomous_mode);
214        assert!(caps.token_usage);
215        assert!(caps.mcp_support);
216        assert!(!caps.session_resume);
217    }
218
219    #[tokio::test]
220    async fn budget_zero_returns_error_before_spawn() {
221        let exec = ClaudeCodeExecutor::new();
222        let result = exec
223            .spawn(
224                Path::new("/tmp"),
225                "test",
226                &SpawnConfig {
227                    budget_usd: Some(0.0),
228                    ..Default::default()
229                },
230            )
231            .await;
232        assert!(matches!(result, Err(AgentError::BudgetExceeded { .. })));
233    }
234}