1pub mod logs;
3pub mod models;
10
11use crate::agent::{Agent, ModelSize};
12
13pub fn projects_dir() -> Option<std::path::PathBuf> {
15 dirs::home_dir().map(|h| h.join(".claude/projects"))
16}
17use crate::output::AgentOutput;
18use crate::sandbox::SandboxConfig;
19use anyhow::{Context, Result};
20use async_trait::async_trait;
21use std::process::Stdio;
22use tokio::io::{AsyncBufReadExt, BufReader};
23use tokio::process::Command;
24
25pub const DEFAULT_MODEL: &str = "default";
26
27pub const AVAILABLE_MODELS: &[&str] = &[
28 "default",
29 "sonnet",
30 "sonnet-4.6",
31 "opus",
32 "opus-4.6",
33 "haiku",
34 "haiku-4.5",
35];
36
37pub type EventHandler = Box<dyn Fn(&crate::output::Event, bool) + Send + Sync>;
40
41pub struct Claude {
42 system_prompt: String,
43 model: String,
44 root: Option<String>,
45 session_id: Option<String>,
46 skip_permissions: bool,
47 output_format: Option<String>,
48 input_format: Option<String>,
49 add_dirs: Vec<String>,
50 capture_output: bool,
51 verbose: bool,
52 json_schema: Option<String>,
53 sandbox: Option<SandboxConfig>,
54 event_handler: Option<EventHandler>,
55 replay_user_messages: bool,
56 include_partial_messages: bool,
57 max_turns: Option<u32>,
58 mcp_config_path: Option<String>,
59 env_vars: Vec<(String, String)>,
60}
61
62impl Claude {
63 pub fn new() -> Self {
64 Self {
65 system_prompt: String::new(),
66 model: DEFAULT_MODEL.to_string(),
67 root: None,
68 session_id: None,
69 skip_permissions: false,
70 output_format: None,
71 input_format: None,
72 add_dirs: Vec::new(),
73 capture_output: false,
74 verbose: false,
75 json_schema: None,
76 sandbox: None,
77 event_handler: None,
78 replay_user_messages: false,
79 include_partial_messages: false,
80 max_turns: None,
81 mcp_config_path: None,
82 env_vars: Vec::new(),
83 }
84 }
85
86 pub fn set_input_format(&mut self, format: Option<String>) {
87 self.input_format = format;
88 }
89
90 pub fn set_session_id(&mut self, session_id: String) {
91 self.session_id = Some(session_id);
92 }
93
94 pub fn set_verbose(&mut self, verbose: bool) {
95 self.verbose = verbose;
96 }
97
98 pub fn set_json_schema(&mut self, schema: Option<String>) {
99 self.json_schema = schema;
100 }
101
102 pub fn set_replay_user_messages(&mut self, replay: bool) {
103 self.replay_user_messages = replay;
104 }
105
106 pub fn set_include_partial_messages(&mut self, include: bool) {
107 self.include_partial_messages = include;
108 }
109
110 pub fn set_mcp_config(&mut self, config: Option<String>) {
112 self.mcp_config_path = config.map(|c| {
113 if c.trim_start().starts_with('{') {
114 let path =
115 std::env::temp_dir().join(format!("zag-mcp-{}.json", uuid::Uuid::new_v4()));
116 if let Err(e) = std::fs::write(&path, &c) {
117 log::warn!("Failed to write MCP config temp file: {}", e);
118 return c;
119 }
120 path.to_string_lossy().into_owned()
121 } else {
122 c
123 }
124 });
125 }
126
127 pub fn set_event_handler(&mut self, handler: EventHandler) {
132 self.event_handler = Some(handler);
133 }
134
135 fn build_run_args(
137 &self,
138 interactive: bool,
139 prompt: Option<&str>,
140 effective_output_format: &Option<String>,
141 ) -> Vec<String> {
142 let mut args = Vec::new();
143 let in_sandbox = self.sandbox.is_some();
144
145 if !interactive {
146 args.push("--print".to_string());
147
148 match effective_output_format.as_deref() {
149 Some("json") | Some("json-pretty") => {
150 args.extend(["--verbose", "--output-format", "json"].map(String::from));
151 }
152 Some("stream-json") | None => {
153 args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
154 }
155 Some("native-json") => {
156 args.extend(["--verbose", "--output-format", "json"].map(String::from));
157 }
158 Some("text") => {}
159 _ => {}
160 }
161 }
162
163 if self.skip_permissions && !in_sandbox {
165 args.push("--dangerously-skip-permissions".to_string());
166 }
167
168 args.extend(["--model".to_string(), self.model.clone()]);
169
170 if interactive && let Some(session_id) = &self.session_id {
171 args.extend(["--session-id".to_string(), session_id.clone()]);
172 }
173
174 for dir in &self.add_dirs {
175 args.extend(["--add-dir".to_string(), dir.clone()]);
176 }
177
178 if !self.system_prompt.is_empty() {
179 args.extend([
180 "--append-system-prompt".to_string(),
181 self.system_prompt.clone(),
182 ]);
183 }
184
185 if !interactive && let Some(ref input_fmt) = self.input_format {
186 args.extend(["--input-format".to_string(), input_fmt.clone()]);
187 }
188
189 if !interactive && self.replay_user_messages {
190 args.push("--replay-user-messages".to_string());
191 }
192
193 if !interactive && self.include_partial_messages {
194 args.push("--include-partial-messages".to_string());
195 }
196
197 if let Some(ref schema) = self.json_schema {
198 args.extend(["--json-schema".to_string(), schema.clone()]);
199 }
200
201 if let Some(turns) = self.max_turns {
202 args.extend(["--max-turns".to_string(), turns.to_string()]);
203 }
204
205 if let Some(ref path) = self.mcp_config_path {
206 args.extend(["--mcp-config".to_string(), path.clone()]);
207 }
208
209 if let Some(p) = prompt {
210 args.push(p.to_string());
211 }
212
213 args
214 }
215
216 fn build_resume_args(&self, session_id: Option<&str>) -> Vec<String> {
218 let mut args = Vec::new();
219 let in_sandbox = self.sandbox.is_some();
220
221 if let Some(id) = session_id {
222 args.extend(["--resume".to_string(), id.to_string()]);
223 } else {
224 args.push("--continue".to_string());
225 }
226
227 if self.skip_permissions && !in_sandbox {
228 args.push("--dangerously-skip-permissions".to_string());
229 }
230
231 args.extend(["--model".to_string(), self.model.clone()]);
232
233 for dir in &self.add_dirs {
234 args.extend(["--add-dir".to_string(), dir.clone()]);
235 }
236
237 args
238 }
239
240 fn make_command(&self, agent_args: Vec<String>) -> Command {
242 if let Some(ref sb) = self.sandbox {
243 let std_cmd = crate::sandbox::build_sandbox_command(sb, agent_args);
244 Command::from(std_cmd)
245 } else {
246 let mut cmd = Command::new("claude");
247 if let Some(ref root) = self.root {
248 cmd.current_dir(root);
249 }
250 cmd.args(&agent_args);
251 for (key, value) in &self.env_vars {
252 cmd.env(key, value);
253 }
254 cmd
255 }
256 }
257
258 pub fn execute_streaming(
264 &self,
265 prompt: Option<&str>,
266 ) -> Result<crate::streaming::StreamingSession> {
267 let mut args = Vec::new();
269 let in_sandbox = self.sandbox.is_some();
270
271 args.push("--print".to_string());
272 args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
273
274 if self.skip_permissions && !in_sandbox {
275 args.push("--dangerously-skip-permissions".to_string());
276 }
277
278 args.extend(["--model".to_string(), self.model.clone()]);
279
280 for dir in &self.add_dirs {
281 args.extend(["--add-dir".to_string(), dir.clone()]);
282 }
283
284 if !self.system_prompt.is_empty() {
285 args.extend([
286 "--append-system-prompt".to_string(),
287 self.system_prompt.clone(),
288 ]);
289 }
290
291 args.extend(["--input-format".to_string(), "stream-json".to_string()]);
292 args.push("--replay-user-messages".to_string());
293
294 if self.include_partial_messages {
295 args.push("--include-partial-messages".to_string());
296 }
297
298 if let Some(ref schema) = self.json_schema {
299 args.extend(["--json-schema".to_string(), schema.clone()]);
300 }
301
302 if let Some(p) = prompt {
303 args.push(p.to_string());
304 }
305
306 log::debug!("Claude streaming command: claude {}", args.join(" "));
307
308 let mut cmd = self.make_command(args);
309 cmd.stdin(Stdio::piped())
310 .stdout(Stdio::piped())
311 .stderr(Stdio::piped());
312
313 let child = cmd
314 .spawn()
315 .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
316 crate::streaming::StreamingSession::new(child)
317 }
318
319 fn build_streaming_resume_args(&self, session_id: &str) -> Vec<String> {
321 let mut args = Vec::new();
322 let in_sandbox = self.sandbox.is_some();
323
324 args.push("--print".to_string());
325 args.extend(["--resume".to_string(), session_id.to_string()]);
326 args.extend(["--verbose", "--output-format", "stream-json"].map(String::from));
327
328 if self.skip_permissions && !in_sandbox {
329 args.push("--dangerously-skip-permissions".to_string());
330 }
331
332 args.extend(["--model".to_string(), self.model.clone()]);
333
334 for dir in &self.add_dirs {
335 args.extend(["--add-dir".to_string(), dir.clone()]);
336 }
337
338 args.extend(["--input-format".to_string(), "stream-json".to_string()]);
339 args.push("--replay-user-messages".to_string());
340
341 if self.include_partial_messages {
342 args.push("--include-partial-messages".to_string());
343 }
344
345 args
346 }
347
348 pub fn execute_streaming_resume(
354 &self,
355 session_id: &str,
356 ) -> Result<crate::streaming::StreamingSession> {
357 let args = self.build_streaming_resume_args(session_id);
358
359 log::debug!("Claude streaming resume command: claude {}", args.join(" "));
360
361 let mut cmd = self.make_command(args);
362 cmd.stdin(Stdio::piped())
363 .stdout(Stdio::piped())
364 .stderr(Stdio::piped());
365
366 let child = cmd
367 .spawn()
368 .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
369 crate::streaming::StreamingSession::new(child)
370 }
371
372 async fn execute(
373 &self,
374 interactive: bool,
375 prompt: Option<&str>,
376 ) -> Result<Option<AgentOutput>> {
377 let effective_output_format = if self.capture_output && self.output_format.is_none() {
380 Some("json".to_string())
381 } else {
382 self.output_format.clone()
383 };
384
385 let capture_json = !interactive
388 && effective_output_format
389 .as_ref()
390 .is_none_or(|f| f == "json" || f == "json-pretty" || f == "stream-json");
391
392 let agent_args = self.build_run_args(interactive, prompt, &effective_output_format);
393 log::debug!("Claude command: claude {}", agent_args.join(" "));
394 if !self.system_prompt.is_empty() {
395 log::debug!("Claude system prompt: {}", self.system_prompt);
396 }
397 if let Some(p) = prompt {
398 log::debug!("Claude user prompt: {}", p);
399 }
400 log::debug!(
401 "Claude mode: interactive={}, capture_json={}, output_format={:?}",
402 interactive,
403 capture_json,
404 effective_output_format
405 );
406 let mut cmd = self.make_command(agent_args);
407
408 let is_native_json = effective_output_format.as_deref() == Some("native-json");
410
411 if interactive {
412 cmd.stdin(Stdio::inherit())
414 .stdout(Stdio::inherit())
415 .stderr(Stdio::inherit());
416
417 let status = cmd
418 .status()
419 .await
420 .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
421 if !status.success() {
422 anyhow::bail!("Claude command failed with status: {}", status);
423 }
424 Ok(None)
425 } else if is_native_json {
426 cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
428
429 crate::process::run_with_captured_stderr(&mut cmd).await?;
430 Ok(None)
431 } else if capture_json {
432 let output_format = effective_output_format.as_deref();
433 let is_streaming = output_format == Some("stream-json") || output_format.is_none();
434
435 if is_streaming {
436 cmd.stdin(Stdio::inherit());
438 cmd.stdout(Stdio::piped());
439
440 let mut child = crate::process::spawn_with_captured_stderr(&mut cmd).await?;
441 let stdout = child
442 .stdout
443 .take()
444 .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
445
446 let reader = BufReader::new(stdout);
447 let mut lines = reader.lines();
448
449 let format_as_text = output_format.is_none(); let format_as_json = output_format == Some("stream-json"); while let Some(line) = lines.next_line().await? {
455 if format_as_text || format_as_json {
456 match serde_json::from_str::<models::ClaudeEvent>(&line) {
457 Ok(claude_event) => {
458 if let Some(unified_event) =
459 convert_claude_event_to_unified(&claude_event)
460 {
461 if let Some(ref handler) = self.event_handler {
462 handler(&unified_event, self.verbose);
463 }
464 }
465 }
466 Err(e) => {
467 log::debug!(
468 "Failed to parse streaming Claude event: {}. Line: {}",
469 e,
470 crate::truncate_str(&line, 200)
471 );
472 }
473 }
474 }
475 }
476
477 if let Some(ref handler) = self.event_handler {
479 handler(
481 &crate::output::Event::Result {
482 success: true,
483 message: None,
484 duration_ms: None,
485 num_turns: None,
486 },
487 self.verbose,
488 );
489 }
490
491 crate::process::wait_with_stderr(child).await?;
492
493 Ok(None)
495 } else {
496 cmd.stdin(Stdio::inherit());
498 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
499
500 let output = cmd.output().await?;
501
502 crate::process::handle_output(&output, "Claude")?;
503
504 let json_str = String::from_utf8(output.stdout)?;
506 log::debug!("Parsing Claude JSON output ({} bytes)", json_str.len());
507 let claude_output: models::ClaudeOutput =
508 serde_json::from_str(&json_str).map_err(|e| {
509 log::debug!(
510 "Failed to parse Claude JSON output: {}. First 500 chars: {}",
511 e,
512 crate::truncate_str(&json_str, 500)
513 );
514 anyhow::anyhow!("Failed to parse Claude JSON output: {}", e)
515 })?;
516 log::debug!("Parsed {} Claude events successfully", claude_output.len());
517
518 let agent_output: AgentOutput =
520 models::claude_output_to_agent_output(claude_output);
521 Ok(Some(agent_output))
522 }
523 } else {
524 cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
526
527 crate::process::run_with_captured_stderr(&mut cmd).await?;
528 Ok(None)
529 }
530 }
531}
532
533fn convert_claude_event_to_unified(event: &models::ClaudeEvent) -> Option<crate::output::Event> {
536 use crate::output::{
537 ContentBlock as UnifiedContentBlock, Event as UnifiedEvent, ToolResult,
538 Usage as UnifiedUsage,
539 };
540 use models::ClaudeEvent;
541
542 match event {
543 ClaudeEvent::System {
544 model, tools, cwd, ..
545 } => {
546 let mut metadata = std::collections::HashMap::new();
547 if let Some(cwd_val) = cwd {
548 metadata.insert("cwd".to_string(), serde_json::json!(cwd_val));
549 }
550
551 Some(UnifiedEvent::Init {
552 model: model.clone(),
553 tools: tools.clone(),
554 working_directory: cwd.clone(),
555 metadata,
556 })
557 }
558
559 ClaudeEvent::Assistant { message, .. } => {
560 let content: Vec<UnifiedContentBlock> = message
562 .content
563 .iter()
564 .filter_map(|block| match block {
565 models::ContentBlock::Text { text } => {
566 Some(UnifiedContentBlock::Text { text: text.clone() })
567 }
568 models::ContentBlock::ToolUse { id, name, input } => {
569 Some(UnifiedContentBlock::ToolUse {
570 id: id.clone(),
571 name: name.clone(),
572 input: input.clone(),
573 })
574 }
575 models::ContentBlock::Thinking { .. } => None,
576 })
577 .collect();
578
579 let usage = Some(UnifiedUsage {
581 input_tokens: message.usage.input_tokens,
582 output_tokens: message.usage.output_tokens,
583 cache_read_tokens: Some(message.usage.cache_read_input_tokens),
584 cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
585 web_search_requests: message
586 .usage
587 .server_tool_use
588 .as_ref()
589 .map(|s| s.web_search_requests),
590 web_fetch_requests: message
591 .usage
592 .server_tool_use
593 .as_ref()
594 .map(|s| s.web_fetch_requests),
595 });
596
597 Some(UnifiedEvent::AssistantMessage { content, usage })
598 }
599
600 ClaudeEvent::User {
601 message,
602 tool_use_result,
603 ..
604 } => {
605 let first_tool_result = message.content.iter().find_map(|b| {
609 if let models::UserContentBlock::ToolResult {
610 tool_use_id,
611 content,
612 is_error,
613 } = b
614 {
615 Some((tool_use_id, content, is_error))
616 } else {
617 None
618 }
619 });
620
621 if let Some((tool_use_id, content, is_error)) = first_tool_result {
622 let tool_result = ToolResult {
623 success: !is_error,
624 output: if !is_error {
625 Some(content.clone())
626 } else {
627 None
628 },
629 error: if *is_error {
630 Some(content.clone())
631 } else {
632 None
633 },
634 data: tool_use_result.clone(),
635 };
636
637 Some(UnifiedEvent::ToolExecution {
638 tool_name: "unknown".to_string(),
639 tool_id: tool_use_id.clone(),
640 input: serde_json::Value::Null,
641 result: tool_result,
642 })
643 } else {
644 let text_blocks: Vec<UnifiedContentBlock> = message
646 .content
647 .iter()
648 .filter_map(|b| {
649 if let models::UserContentBlock::Text { text } = b {
650 Some(UnifiedContentBlock::Text { text: text.clone() })
651 } else {
652 None
653 }
654 })
655 .collect();
656
657 if !text_blocks.is_empty() {
658 Some(UnifiedEvent::UserMessage {
659 content: text_blocks,
660 })
661 } else {
662 None
663 }
664 }
665 }
666
667 ClaudeEvent::Other => {
668 log::debug!("Skipping unknown Claude event type during streaming conversion");
669 None
670 }
671
672 ClaudeEvent::Result {
673 is_error,
674 result,
675 duration_ms,
676 num_turns,
677 ..
678 } => Some(UnifiedEvent::Result {
679 success: !is_error,
680 message: Some(result.clone()),
681 duration_ms: Some(*duration_ms),
682 num_turns: Some(*num_turns),
683 }),
684 }
685}
686
687#[cfg(test)]
688#[path = "claude_tests.rs"]
689mod tests;
690
691impl Default for Claude {
692 fn default() -> Self {
693 Self::new()
694 }
695}
696
697#[async_trait]
698impl Agent for Claude {
699 fn name(&self) -> &str {
700 "claude"
701 }
702
703 fn default_model() -> &'static str {
704 DEFAULT_MODEL
705 }
706
707 fn model_for_size(size: ModelSize) -> &'static str {
708 match size {
709 ModelSize::Small => "haiku",
710 ModelSize::Medium => "sonnet",
711 ModelSize::Large => "default",
712 }
713 }
714
715 fn available_models() -> &'static [&'static str] {
716 AVAILABLE_MODELS
717 }
718
719 fn system_prompt(&self) -> &str {
720 &self.system_prompt
721 }
722
723 fn set_system_prompt(&mut self, prompt: String) {
724 self.system_prompt = prompt;
725 }
726
727 fn get_model(&self) -> &str {
728 &self.model
729 }
730
731 fn set_model(&mut self, model: String) {
732 self.model = model;
733 }
734
735 fn set_root(&mut self, root: String) {
736 self.root = Some(root);
737 }
738
739 fn set_skip_permissions(&mut self, skip: bool) {
740 self.skip_permissions = skip;
741 }
742
743 fn set_output_format(&mut self, format: Option<String>) {
744 self.output_format = format;
745 }
746
747 fn set_capture_output(&mut self, capture: bool) {
748 self.capture_output = capture;
749 }
750
751 fn set_max_turns(&mut self, turns: u32) {
752 self.max_turns = Some(turns);
753 }
754
755 fn set_sandbox(&mut self, config: SandboxConfig) {
756 self.sandbox = Some(config);
757 }
758
759 fn set_add_dirs(&mut self, dirs: Vec<String>) {
760 self.add_dirs = dirs;
761 }
762
763 fn set_env_vars(&mut self, vars: Vec<(String, String)>) {
764 self.env_vars = vars;
765 }
766
767 fn as_any_ref(&self) -> &dyn std::any::Any {
768 self
769 }
770
771 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
772 self
773 }
774
775 async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
776 self.execute(false, prompt).await
777 }
778
779 async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
780 self.execute(true, prompt).await?;
781 Ok(())
782 }
783
784 async fn run_resume(&self, session_id: Option<&str>, _last: bool) -> Result<()> {
785 let agent_args = self.build_resume_args(session_id);
786 let mut cmd = self.make_command(agent_args);
787
788 cmd.stdin(Stdio::inherit())
789 .stdout(Stdio::inherit())
790 .stderr(Stdio::inherit());
791
792 let status = cmd
793 .status()
794 .await
795 .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
796 if !status.success() {
797 anyhow::bail!("Claude resume failed with status: {}", status);
798 }
799 Ok(())
800 }
801
802 async fn run_resume_with_prompt(
803 &self,
804 session_id: &str,
805 prompt: &str,
806 ) -> Result<Option<AgentOutput>> {
807 log::debug!(
808 "Claude resume with prompt: session={}, prompt={}",
809 session_id,
810 prompt
811 );
812 let in_sandbox = self.sandbox.is_some();
813 let mut args = vec!["--print".to_string()];
814 args.extend(["--resume".to_string(), session_id.to_string()]);
815 args.extend(["--verbose", "--output-format", "json"].map(String::from));
816
817 if self.skip_permissions && !in_sandbox {
818 args.push("--dangerously-skip-permissions".to_string());
819 }
820
821 args.extend(["--model".to_string(), self.model.clone()]);
822
823 for dir in &self.add_dirs {
824 args.extend(["--add-dir".to_string(), dir.clone()]);
825 }
826
827 if let Some(ref schema) = self.json_schema {
828 args.extend(["--json-schema".to_string(), schema.clone()]);
829 }
830
831 args.push(prompt.to_string());
832
833 let mut cmd = self.make_command(args);
834
835 cmd.stdin(Stdio::inherit());
836 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
837
838 let output = cmd.output().await?;
839
840 crate::process::handle_output(&output, "Claude")?;
841
842 let json_str = String::from_utf8(output.stdout)?;
844 log::debug!(
845 "Parsing Claude resume JSON output ({} bytes)",
846 json_str.len()
847 );
848 let claude_output: models::ClaudeOutput = serde_json::from_str(&json_str)
849 .map_err(|e| anyhow::anyhow!("Failed to parse Claude resume JSON output: {}", e))?;
850
851 let agent_output: AgentOutput = models::claude_output_to_agent_output(claude_output);
852 Ok(Some(agent_output))
853 }
854
855 async fn cleanup(&self) -> Result<()> {
856 Ok(())
857 }
858}