Skip to main content

zag_agent/providers/claude/
mod.rs

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