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 return Err(crate::process::ProcessError {
423 exit_code: status.code(),
424 stderr: String::new(),
425 agent_name: "Claude".to_string(),
426 }
427 .into());
428 }
429 Ok(None)
430 } else if is_native_json {
431 cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
433
434 crate::process::run_with_captured_stderr(&mut cmd).await?;
435 Ok(None)
436 } else if capture_json {
437 let output_format = effective_output_format.as_deref();
438 let is_streaming = output_format == Some("stream-json") || output_format.is_none();
439
440 if is_streaming {
441 cmd.stdin(Stdio::inherit());
443 cmd.stdout(Stdio::piped());
444
445 let mut child = crate::process::spawn_with_captured_stderr(&mut cmd).await?;
446 let stdout = child
447 .stdout
448 .take()
449 .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
450
451 let reader = BufReader::new(stdout);
452 let mut lines = reader.lines();
453
454 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? {
460 if format_as_text || format_as_json {
461 match serde_json::from_str::<models::ClaudeEvent>(&line) {
462 Ok(claude_event) => {
463 if let Some(unified_event) =
464 convert_claude_event_to_unified(&claude_event)
465 {
466 if let Some(ref handler) = self.event_handler {
467 handler(&unified_event, self.verbose);
468 }
469 }
470 }
471 Err(e) => {
472 log::debug!(
473 "Failed to parse streaming Claude event: {}. Line: {}",
474 e,
475 crate::truncate_str(&line, 200)
476 );
477 }
478 }
479 }
480 }
481
482 if let Some(ref handler) = self.event_handler {
484 handler(
486 &crate::output::Event::Result {
487 success: true,
488 message: None,
489 duration_ms: None,
490 num_turns: None,
491 },
492 self.verbose,
493 );
494 }
495
496 crate::process::wait_with_stderr(child).await?;
497
498 Ok(None)
500 } else {
501 cmd.stdin(Stdio::inherit());
503 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
504
505 let output = cmd.output().await?;
506
507 crate::process::handle_output(&output, "Claude")?;
508
509 let json_str = String::from_utf8(output.stdout)?;
511 log::debug!("Parsing Claude JSON output ({} bytes)", json_str.len());
512 let claude_output: models::ClaudeOutput =
513 serde_json::from_str(&json_str).map_err(|e| {
514 log::debug!(
515 "Failed to parse Claude JSON output: {}. First 500 chars: {}",
516 e,
517 crate::truncate_str(&json_str, 500)
518 );
519 anyhow::anyhow!("Failed to parse Claude JSON output: {}", e)
520 })?;
521 log::debug!("Parsed {} Claude events successfully", claude_output.len());
522
523 let agent_output: AgentOutput =
525 models::claude_output_to_agent_output(claude_output);
526 Ok(Some(agent_output))
527 }
528 } else {
529 cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
531
532 crate::process::run_with_captured_stderr(&mut cmd).await?;
533 Ok(None)
534 }
535 }
536}
537
538fn convert_claude_event_to_unified(event: &models::ClaudeEvent) -> Option<crate::output::Event> {
541 use crate::output::{
542 ContentBlock as UnifiedContentBlock, Event as UnifiedEvent, ToolResult,
543 Usage as UnifiedUsage,
544 };
545 use models::ClaudeEvent;
546
547 match event {
548 ClaudeEvent::System {
549 model, tools, cwd, ..
550 } => {
551 let mut metadata = std::collections::HashMap::new();
552 if let Some(cwd_val) = cwd {
553 metadata.insert("cwd".to_string(), serde_json::json!(cwd_val));
554 }
555
556 Some(UnifiedEvent::Init {
557 model: model.clone(),
558 tools: tools.clone(),
559 working_directory: cwd.clone(),
560 metadata,
561 })
562 }
563
564 ClaudeEvent::Assistant {
565 message,
566 parent_tool_use_id,
567 ..
568 } => {
569 let content: Vec<UnifiedContentBlock> = message
571 .content
572 .iter()
573 .filter_map(|block| match block {
574 models::ContentBlock::Text { text } => {
575 Some(UnifiedContentBlock::Text { text: text.clone() })
576 }
577 models::ContentBlock::ToolUse { id, name, input } => {
578 Some(UnifiedContentBlock::ToolUse {
579 id: id.clone(),
580 name: name.clone(),
581 input: input.clone(),
582 })
583 }
584 models::ContentBlock::Thinking { .. } => None,
585 })
586 .collect();
587
588 let usage = Some(UnifiedUsage {
590 input_tokens: message.usage.input_tokens,
591 output_tokens: message.usage.output_tokens,
592 cache_read_tokens: Some(message.usage.cache_read_input_tokens),
593 cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
594 web_search_requests: message
595 .usage
596 .server_tool_use
597 .as_ref()
598 .map(|s| s.web_search_requests),
599 web_fetch_requests: message
600 .usage
601 .server_tool_use
602 .as_ref()
603 .map(|s| s.web_fetch_requests),
604 });
605
606 Some(UnifiedEvent::AssistantMessage {
607 content,
608 usage,
609 parent_tool_use_id: parent_tool_use_id.clone(),
610 })
611 }
612
613 ClaudeEvent::User {
614 message,
615 tool_use_result,
616 parent_tool_use_id,
617 ..
618 } => {
619 let first_tool_result = message.content.iter().find_map(|b| {
623 if let models::UserContentBlock::ToolResult {
624 tool_use_id,
625 content,
626 is_error,
627 } = b
628 {
629 Some((tool_use_id, content, is_error))
630 } else {
631 None
632 }
633 });
634
635 if let Some((tool_use_id, content, is_error)) = first_tool_result {
636 let tool_result = ToolResult {
637 success: !is_error,
638 output: if !is_error {
639 Some(content.clone())
640 } else {
641 None
642 },
643 error: if *is_error {
644 Some(content.clone())
645 } else {
646 None
647 },
648 data: tool_use_result.clone(),
649 };
650
651 Some(UnifiedEvent::ToolExecution {
652 tool_name: "unknown".to_string(),
653 tool_id: tool_use_id.clone(),
654 input: serde_json::Value::Null,
655 result: tool_result,
656 parent_tool_use_id: parent_tool_use_id.clone(),
657 })
658 } else {
659 let text_blocks: Vec<UnifiedContentBlock> = message
661 .content
662 .iter()
663 .filter_map(|b| {
664 if let models::UserContentBlock::Text { text } = b {
665 Some(UnifiedContentBlock::Text { text: text.clone() })
666 } else {
667 None
668 }
669 })
670 .collect();
671
672 if !text_blocks.is_empty() {
673 Some(UnifiedEvent::UserMessage {
674 content: text_blocks,
675 })
676 } else {
677 None
678 }
679 }
680 }
681
682 ClaudeEvent::Other => {
683 log::debug!("Skipping unknown Claude event type during streaming conversion");
684 None
685 }
686
687 ClaudeEvent::Result {
688 is_error,
689 result,
690 duration_ms,
691 num_turns,
692 ..
693 } => Some(UnifiedEvent::Result {
694 success: !is_error,
695 message: Some(result.clone()),
696 duration_ms: Some(*duration_ms),
697 num_turns: Some(*num_turns),
698 }),
699 }
700}
701
702#[cfg(test)]
703#[path = "claude_tests.rs"]
704mod tests;
705
706impl Default for Claude {
707 fn default() -> Self {
708 Self::new()
709 }
710}
711
712#[async_trait]
713impl Agent for Claude {
714 fn name(&self) -> &str {
715 "claude"
716 }
717
718 fn default_model() -> &'static str {
719 DEFAULT_MODEL
720 }
721
722 fn model_for_size(size: ModelSize) -> &'static str {
723 match size {
724 ModelSize::Small => "haiku",
725 ModelSize::Medium => "sonnet",
726 ModelSize::Large => "default",
727 }
728 }
729
730 fn available_models() -> &'static [&'static str] {
731 AVAILABLE_MODELS
732 }
733
734 fn system_prompt(&self) -> &str {
735 &self.system_prompt
736 }
737
738 fn set_system_prompt(&mut self, prompt: String) {
739 self.system_prompt = prompt;
740 }
741
742 fn get_model(&self) -> &str {
743 &self.model
744 }
745
746 fn set_model(&mut self, model: String) {
747 self.model = model;
748 }
749
750 fn set_root(&mut self, root: String) {
751 self.root = Some(root);
752 }
753
754 fn set_skip_permissions(&mut self, skip: bool) {
755 self.skip_permissions = skip;
756 }
757
758 fn set_output_format(&mut self, format: Option<String>) {
759 self.output_format = format;
760 }
761
762 fn set_capture_output(&mut self, capture: bool) {
763 self.capture_output = capture;
764 }
765
766 fn set_max_turns(&mut self, turns: u32) {
767 self.max_turns = Some(turns);
768 }
769
770 fn set_sandbox(&mut self, config: SandboxConfig) {
771 self.sandbox = Some(config);
772 }
773
774 fn set_add_dirs(&mut self, dirs: Vec<String>) {
775 self.add_dirs = dirs;
776 }
777
778 fn set_env_vars(&mut self, vars: Vec<(String, String)>) {
779 self.env_vars = vars;
780 }
781
782 fn as_any_ref(&self) -> &dyn std::any::Any {
783 self
784 }
785
786 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
787 self
788 }
789
790 async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
791 self.execute(false, prompt).await
792 }
793
794 async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
795 self.execute(true, prompt).await?;
796 Ok(())
797 }
798
799 async fn run_resume(&self, session_id: Option<&str>, _last: bool) -> Result<()> {
800 let agent_args = self.build_resume_args(session_id);
801 let mut cmd = self.make_command(agent_args);
802
803 cmd.stdin(Stdio::inherit())
804 .stdout(Stdio::inherit())
805 .stderr(Stdio::inherit());
806
807 let status = cmd
808 .status()
809 .await
810 .context("Failed to execute 'claude' CLI. Is it installed and in PATH?")?;
811 if !status.success() {
812 return Err(crate::process::ProcessError {
813 exit_code: status.code(),
814 stderr: String::new(),
815 agent_name: "Claude".to_string(),
816 }
817 .into());
818 }
819 Ok(())
820 }
821
822 async fn run_resume_with_prompt(
823 &self,
824 session_id: &str,
825 prompt: &str,
826 ) -> Result<Option<AgentOutput>> {
827 log::debug!(
828 "Claude resume with prompt: session={}, prompt={}",
829 session_id,
830 prompt
831 );
832 let in_sandbox = self.sandbox.is_some();
833 let mut args = vec!["--print".to_string()];
834 args.extend(["--resume".to_string(), session_id.to_string()]);
835 args.extend(["--verbose", "--output-format", "json"].map(String::from));
836
837 if self.skip_permissions && !in_sandbox {
838 args.push("--dangerously-skip-permissions".to_string());
839 }
840
841 args.extend(["--model".to_string(), self.model.clone()]);
842
843 for dir in &self.add_dirs {
844 args.extend(["--add-dir".to_string(), dir.clone()]);
845 }
846
847 if let Some(ref schema) = self.json_schema {
848 args.extend(["--json-schema".to_string(), schema.clone()]);
849 }
850
851 args.push(prompt.to_string());
852
853 let mut cmd = self.make_command(args);
854
855 cmd.stdin(Stdio::inherit());
856 cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
857
858 let output = cmd.output().await?;
859
860 crate::process::handle_output(&output, "Claude")?;
861
862 let json_str = String::from_utf8(output.stdout)?;
864 log::debug!(
865 "Parsing Claude resume JSON output ({} bytes)",
866 json_str.len()
867 );
868 let claude_output: models::ClaudeOutput = serde_json::from_str(&json_str)
869 .map_err(|e| anyhow::anyhow!("Failed to parse Claude resume JSON output: {}", e))?;
870
871 let agent_output: AgentOutput = models::claude_output_to_agent_output(claude_output);
872 Ok(Some(agent_output))
873 }
874
875 async fn cleanup(&self) -> Result<()> {
876 Ok(())
877 }
878}