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;
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
26pub 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
59struct 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 {
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 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}