nucel_agent_claude_code/
lib.rs1mod 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
25pub 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
58struct 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 {
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 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 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}