Skip to main content

claude_codes/
cli.rs

1//! Builder pattern for configuring and launching the Claude CLI process.
2//!
3//! This module provides [`ClaudeCliBuilder`] for constructing Claude CLI commands
4//! with the correct flags for JSON streaming mode. The builder automatically configures:
5//!
6//! - JSON streaming input/output formats
7//! - Non-interactive print mode
8//! - Verbose output for proper streaming
9//! - OAuth token and API key environment variables for authentication
10//!
11
12use crate::error::{Error, Result};
13use log::debug;
14use std::path::PathBuf;
15use std::process::Stdio;
16use uuid::Uuid;
17
18/// Permission mode for Claude CLI
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum PermissionMode {
21    AcceptEdits,
22    BypassPermissions,
23    Default,
24    Delegate,
25    DontAsk,
26    Plan,
27}
28
29impl PermissionMode {
30    /// Get the CLI string representation
31    pub fn as_str(&self) -> &'static str {
32        match self {
33            PermissionMode::AcceptEdits => "acceptEdits",
34            PermissionMode::BypassPermissions => "bypassPermissions",
35            PermissionMode::Default => "default",
36            PermissionMode::Delegate => "delegate",
37            PermissionMode::DontAsk => "dontAsk",
38            PermissionMode::Plan => "plan",
39        }
40    }
41}
42
43/// Input format for Claude CLI
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum InputFormat {
46    Text,
47    StreamJson,
48}
49
50impl InputFormat {
51    /// Get the CLI string representation
52    pub fn as_str(&self) -> &'static str {
53        match self {
54            InputFormat::Text => "text",
55            InputFormat::StreamJson => "stream-json",
56        }
57    }
58}
59
60/// Output format for Claude CLI
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum OutputFormat {
63    Text,
64    Json,
65    StreamJson,
66}
67
68impl OutputFormat {
69    /// Get the CLI string representation
70    pub fn as_str(&self) -> &'static str {
71        match self {
72            OutputFormat::Text => "text",
73            OutputFormat::Json => "json",
74            OutputFormat::StreamJson => "stream-json",
75        }
76    }
77}
78
79/// Comprehensive enum of all Claude CLI flags.
80///
81/// This enum represents every flag available in the Claude CLI (`claude --help`).
82/// Each variant carries the appropriate data type for its flag value.
83///
84/// Use `as_flag()` to get the CLI flag string (e.g., `"--model"`),
85/// or `to_args()` to get the complete flag + value as CLI arguments.
86///
87/// # Example
88/// ```
89/// use claude_codes::CliFlag;
90///
91/// let flag = CliFlag::Model("sonnet".to_string());
92/// assert_eq!(flag.as_flag(), "--model");
93/// assert_eq!(flag.to_args(), vec!["--model", "sonnet"]);
94/// ```
95#[derive(Debug, Clone)]
96pub enum CliFlag {
97    /// Additional directories to allow tool access to
98    AddDir(Vec<PathBuf>),
99    /// Agent for the current session
100    Agent(String),
101    /// JSON object defining custom agents
102    Agents(String),
103    /// Enable bypassing all permission checks as an option
104    AllowDangerouslySkipPermissions,
105    /// Tool names to allow (e.g. "Bash(git:*) Edit")
106    AllowedTools(Vec<String>),
107    /// Append to the default system prompt
108    AppendSystemPrompt(String),
109    /// Beta headers for API requests (API key users only)
110    Betas(Vec<String>),
111    /// Enable Claude in Chrome integration
112    Chrome,
113    /// Continue the most recent conversation
114    Continue,
115    /// Bypass all permission checks
116    DangerouslySkipPermissions,
117    /// Enable debug mode with optional category filter
118    Debug(Option<String>),
119    /// Write debug logs to a specific file path
120    DebugFile(PathBuf),
121    /// Disable all skills/slash commands
122    DisableSlashCommands,
123    /// Tool names to deny (e.g. "Bash(git:*) Edit")
124    DisallowedTools(Vec<String>),
125    /// Automatic fallback model when default is overloaded
126    FallbackModel(String),
127    /// File resources to download at startup (format: file_id:relative_path)
128    File(Vec<String>),
129    /// Create a new session ID when resuming instead of reusing original
130    ForkSession,
131    /// Resume a session linked to a PR
132    FromPr(Option<String>),
133    /// Include partial message chunks as they arrive
134    IncludePartialMessages,
135    /// Input format (text or stream-json)
136    InputFormat(InputFormat),
137    /// JSON Schema for structured output validation
138    JsonSchema(String),
139    /// Maximum dollar amount for API calls
140    MaxBudgetUsd(f64),
141    /// Maximum number of tokens for extended thinking
142    MaxThinkingTokens(u32),
143    /// Load MCP servers from JSON files or strings
144    McpConfig(Vec<String>),
145    /// Enable MCP debug mode (deprecated, use Debug instead)
146    McpDebug,
147    /// Model for the current session
148    Model(String),
149    /// Disable Claude in Chrome integration
150    NoChrome,
151    /// Disable session persistence
152    NoSessionPersistence,
153    /// Output format (text, json, or stream-json)
154    OutputFormat(OutputFormat),
155    /// Permission mode for the session
156    PermissionMode(PermissionMode),
157    /// Tool for handling permission prompts (e.g., "stdio")
158    PermissionPromptTool(String),
159    /// Load plugins from directories
160    PluginDir(Vec<PathBuf>),
161    /// Print response and exit
162    Print,
163    /// Re-emit user messages from stdin back on stdout
164    ReplayUserMessages,
165    /// Resume a conversation by session ID
166    Resume(Option<String>),
167    /// Use a specific session ID (UUID or tagged ID)
168    SessionId(String),
169    /// Comma-separated list of setting sources (user, project, local)
170    SettingSources(String),
171    /// Path to settings JSON file or JSON string
172    Settings(String),
173    /// Only use MCP servers from --mcp-config
174    StrictMcpConfig,
175    /// System prompt for the session
176    SystemPrompt(String),
177    /// Specify available tools from the built-in set
178    Tools(Vec<String>),
179    /// Override verbose mode setting
180    Verbose,
181}
182
183impl CliFlag {
184    /// Get the CLI flag string (e.g., `"--model"`)
185    pub fn as_flag(&self) -> &'static str {
186        match self {
187            CliFlag::AddDir(_) => "--add-dir",
188            CliFlag::Agent(_) => "--agent",
189            CliFlag::Agents(_) => "--agents",
190            CliFlag::AllowDangerouslySkipPermissions => "--allow-dangerously-skip-permissions",
191            CliFlag::AllowedTools(_) => "--allowed-tools",
192            CliFlag::AppendSystemPrompt(_) => "--append-system-prompt",
193            CliFlag::Betas(_) => "--betas",
194            CliFlag::Chrome => "--chrome",
195            CliFlag::Continue => "--continue",
196            CliFlag::DangerouslySkipPermissions => "--dangerously-skip-permissions",
197            CliFlag::Debug(_) => "--debug",
198            CliFlag::DebugFile(_) => "--debug-file",
199            CliFlag::DisableSlashCommands => "--disable-slash-commands",
200            CliFlag::DisallowedTools(_) => "--disallowed-tools",
201            CliFlag::FallbackModel(_) => "--fallback-model",
202            CliFlag::File(_) => "--file",
203            CliFlag::ForkSession => "--fork-session",
204            CliFlag::FromPr(_) => "--from-pr",
205            CliFlag::IncludePartialMessages => "--include-partial-messages",
206            CliFlag::InputFormat(_) => "--input-format",
207            CliFlag::JsonSchema(_) => "--json-schema",
208            CliFlag::MaxBudgetUsd(_) => "--max-budget-usd",
209            CliFlag::MaxThinkingTokens(_) => "--max-thinking-tokens",
210            CliFlag::McpConfig(_) => "--mcp-config",
211            CliFlag::McpDebug => "--mcp-debug",
212            CliFlag::Model(_) => "--model",
213            CliFlag::NoChrome => "--no-chrome",
214            CliFlag::NoSessionPersistence => "--no-session-persistence",
215            CliFlag::OutputFormat(_) => "--output-format",
216            CliFlag::PermissionMode(_) => "--permission-mode",
217            CliFlag::PermissionPromptTool(_) => "--permission-prompt-tool",
218            CliFlag::PluginDir(_) => "--plugin-dir",
219            CliFlag::Print => "--print",
220            CliFlag::ReplayUserMessages => "--replay-user-messages",
221            CliFlag::Resume(_) => "--resume",
222            CliFlag::SessionId(_) => "--session-id",
223            CliFlag::SettingSources(_) => "--setting-sources",
224            CliFlag::Settings(_) => "--settings",
225            CliFlag::StrictMcpConfig => "--strict-mcp-config",
226            CliFlag::SystemPrompt(_) => "--system-prompt",
227            CliFlag::Tools(_) => "--tools",
228            CliFlag::Verbose => "--verbose",
229        }
230    }
231
232    /// Convert this flag into CLI arguments (flag + value)
233    pub fn to_args(&self) -> Vec<String> {
234        let flag = self.as_flag().to_string();
235        match self {
236            // Boolean flags (no value)
237            CliFlag::AllowDangerouslySkipPermissions
238            | CliFlag::Chrome
239            | CliFlag::Continue
240            | CliFlag::DangerouslySkipPermissions
241            | CliFlag::DisableSlashCommands
242            | CliFlag::ForkSession
243            | CliFlag::IncludePartialMessages
244            | CliFlag::McpDebug
245            | CliFlag::NoChrome
246            | CliFlag::NoSessionPersistence
247            | CliFlag::Print
248            | CliFlag::ReplayUserMessages
249            | CliFlag::StrictMcpConfig
250            | CliFlag::Verbose => vec![flag],
251
252            // Optional value flags
253            CliFlag::Debug(filter) => match filter {
254                Some(f) => vec![flag, f.clone()],
255                None => vec![flag],
256            },
257            CliFlag::FromPr(value) | CliFlag::Resume(value) => match value {
258                Some(v) => vec![flag, v.clone()],
259                None => vec![flag],
260            },
261
262            // Single string value flags
263            CliFlag::Agent(v)
264            | CliFlag::Agents(v)
265            | CliFlag::AppendSystemPrompt(v)
266            | CliFlag::FallbackModel(v)
267            | CliFlag::JsonSchema(v)
268            | CliFlag::Model(v)
269            | CliFlag::PermissionPromptTool(v)
270            | CliFlag::SessionId(v)
271            | CliFlag::SettingSources(v)
272            | CliFlag::Settings(v)
273            | CliFlag::SystemPrompt(v) => vec![flag, v.clone()],
274
275            // Format flags
276            CliFlag::InputFormat(f) => vec![flag, f.as_str().to_string()],
277            CliFlag::OutputFormat(f) => vec![flag, f.as_str().to_string()],
278            CliFlag::PermissionMode(m) => vec![flag, m.as_str().to_string()],
279
280            // Numeric flags
281            CliFlag::MaxBudgetUsd(amount) => vec![flag, amount.to_string()],
282            CliFlag::MaxThinkingTokens(tokens) => vec![flag, tokens.to_string()],
283
284            // Path flags
285            CliFlag::DebugFile(p) => vec![flag, p.to_string_lossy().to_string()],
286
287            // Multi-value string flags
288            CliFlag::AllowedTools(items)
289            | CliFlag::Betas(items)
290            | CliFlag::DisallowedTools(items)
291            | CliFlag::File(items)
292            | CliFlag::McpConfig(items)
293            | CliFlag::Tools(items) => {
294                let mut args = vec![flag];
295                args.extend(items.clone());
296                args
297            }
298
299            // Multi-value path flags
300            CliFlag::AddDir(paths) | CliFlag::PluginDir(paths) => {
301                let mut args = vec![flag];
302                args.extend(paths.iter().map(|p| p.to_string_lossy().to_string()));
303                args
304            }
305        }
306    }
307
308    /// Returns all CLI flag names with their flag strings.
309    ///
310    /// Useful for enumerating available options in a UI or for validation.
311    ///
312    /// # Example
313    /// ```
314    /// use claude_codes::CliFlag;
315    ///
316    /// for (name, flag) in CliFlag::all_flags() {
317    ///     println!("{}: {}", name, flag);
318    /// }
319    /// ```
320    pub fn all_flags() -> Vec<(&'static str, &'static str)> {
321        vec![
322            ("AddDir", "--add-dir"),
323            ("Agent", "--agent"),
324            ("Agents", "--agents"),
325            (
326                "AllowDangerouslySkipPermissions",
327                "--allow-dangerously-skip-permissions",
328            ),
329            ("AllowedTools", "--allowed-tools"),
330            ("AppendSystemPrompt", "--append-system-prompt"),
331            ("Betas", "--betas"),
332            ("Chrome", "--chrome"),
333            ("Continue", "--continue"),
334            (
335                "DangerouslySkipPermissions",
336                "--dangerously-skip-permissions",
337            ),
338            ("Debug", "--debug"),
339            ("DebugFile", "--debug-file"),
340            ("DisableSlashCommands", "--disable-slash-commands"),
341            ("DisallowedTools", "--disallowed-tools"),
342            ("FallbackModel", "--fallback-model"),
343            ("File", "--file"),
344            ("ForkSession", "--fork-session"),
345            ("FromPr", "--from-pr"),
346            ("IncludePartialMessages", "--include-partial-messages"),
347            ("InputFormat", "--input-format"),
348            ("JsonSchema", "--json-schema"),
349            ("MaxBudgetUsd", "--max-budget-usd"),
350            ("MaxThinkingTokens", "--max-thinking-tokens"),
351            ("McpConfig", "--mcp-config"),
352            ("McpDebug", "--mcp-debug"),
353            ("Model", "--model"),
354            ("NoChrome", "--no-chrome"),
355            ("NoSessionPersistence", "--no-session-persistence"),
356            ("OutputFormat", "--output-format"),
357            ("PermissionMode", "--permission-mode"),
358            ("PermissionPromptTool", "--permission-prompt-tool"),
359            ("PluginDir", "--plugin-dir"),
360            ("Print", "--print"),
361            ("ReplayUserMessages", "--replay-user-messages"),
362            ("Resume", "--resume"),
363            ("SessionId", "--session-id"),
364            ("SettingSources", "--setting-sources"),
365            ("Settings", "--settings"),
366            ("StrictMcpConfig", "--strict-mcp-config"),
367            ("SystemPrompt", "--system-prompt"),
368            ("Tools", "--tools"),
369            ("Verbose", "--verbose"),
370        ]
371    }
372}
373
374/// Builder for creating Claude CLI commands in JSON streaming mode
375///
376/// This builder automatically configures Claude to use:
377/// - `--print` mode for non-interactive operation
378/// - `--output-format stream-json` for streaming JSON responses
379/// - `--input-format stream-json` for JSON input
380/// - `--replay-user-messages` to echo back user messages
381#[derive(Debug, Clone)]
382pub struct ClaudeCliBuilder {
383    command: PathBuf,
384    working_directory: Option<PathBuf>,
385    prompt: Option<String>,
386    debug: Option<String>,
387    verbose: bool,
388    dangerously_skip_permissions: bool,
389    allowed_tools: Vec<String>,
390    disallowed_tools: Vec<String>,
391    mcp_config: Vec<String>,
392    append_system_prompt: Option<String>,
393    permission_mode: Option<PermissionMode>,
394    continue_conversation: bool,
395    resume: Option<String>,
396    fork_session: bool,
397    model: Option<String>,
398    fallback_model: Option<String>,
399    settings: Option<String>,
400    add_dir: Vec<PathBuf>,
401    ide: bool,
402    strict_mcp_config: bool,
403    session_id: Option<Uuid>,
404    oauth_token: Option<String>,
405    api_key: Option<String>,
406    /// Tool for handling permission prompts (e.g., "stdio" for bidirectional control)
407    permission_prompt_tool: Option<String>,
408    /// Allow spawning inside another Claude Code session by unsetting CLAUDECODE env var
409    allow_recursion: bool,
410    /// Maximum number of tokens for extended thinking
411    max_thinking_tokens: Option<u32>,
412}
413
414impl Default for ClaudeCliBuilder {
415    fn default() -> Self {
416        Self::new()
417    }
418}
419
420impl ClaudeCliBuilder {
421    /// Create a new Claude CLI builder with JSON streaming mode pre-configured
422    pub fn new() -> Self {
423        Self {
424            command: PathBuf::from("claude"),
425            working_directory: None,
426            prompt: None,
427            debug: None,
428            verbose: false,
429            dangerously_skip_permissions: false,
430            allowed_tools: Vec::new(),
431            disallowed_tools: Vec::new(),
432            mcp_config: Vec::new(),
433            append_system_prompt: None,
434            permission_mode: None,
435            continue_conversation: false,
436            resume: None,
437            fork_session: false,
438            model: None,
439            fallback_model: None,
440            settings: None,
441            add_dir: Vec::new(),
442            ide: false,
443            strict_mcp_config: false,
444            session_id: None,
445            oauth_token: None,
446            api_key: None,
447            permission_prompt_tool: None,
448            allow_recursion: false,
449            max_thinking_tokens: None,
450        }
451    }
452
453    /// Set custom path to Claude binary
454    pub fn command<P: Into<PathBuf>>(mut self, path: P) -> Self {
455        self.command = path.into();
456        self
457    }
458
459    /// Set the working directory for the Claude process.
460    pub fn working_directory<P: Into<PathBuf>>(mut self, path: P) -> Self {
461        self.working_directory = Some(path.into());
462        self
463    }
464
465    /// Set the prompt for Claude
466    pub fn prompt<S: Into<String>>(mut self, prompt: S) -> Self {
467        self.prompt = Some(prompt.into());
468        self
469    }
470
471    /// Enable debug mode with optional filter
472    pub fn debug<S: Into<String>>(mut self, filter: Option<S>) -> Self {
473        self.debug = filter.map(|s| s.into());
474        self
475    }
476
477    /// Enable verbose mode
478    pub fn verbose(mut self, verbose: bool) -> Self {
479        self.verbose = verbose;
480        self
481    }
482
483    /// Skip all permission checks (dangerous!)
484    pub fn dangerously_skip_permissions(mut self, skip: bool) -> Self {
485        self.dangerously_skip_permissions = skip;
486        self
487    }
488
489    /// Add allowed tools
490    pub fn allowed_tools<I, S>(mut self, tools: I) -> Self
491    where
492        I: IntoIterator<Item = S>,
493        S: Into<String>,
494    {
495        self.allowed_tools
496            .extend(tools.into_iter().map(|s| s.into()));
497        self
498    }
499
500    /// Add disallowed tools
501    pub fn disallowed_tools<I, S>(mut self, tools: I) -> Self
502    where
503        I: IntoIterator<Item = S>,
504        S: Into<String>,
505    {
506        self.disallowed_tools
507            .extend(tools.into_iter().map(|s| s.into()));
508        self
509    }
510
511    /// Add MCP configuration
512    pub fn mcp_config<I, S>(mut self, configs: I) -> Self
513    where
514        I: IntoIterator<Item = S>,
515        S: Into<String>,
516    {
517        self.mcp_config
518            .extend(configs.into_iter().map(|s| s.into()));
519        self
520    }
521
522    /// Append a system prompt
523    pub fn append_system_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
524        self.append_system_prompt = Some(prompt.into());
525        self
526    }
527
528    /// Set permission mode
529    pub fn permission_mode(mut self, mode: PermissionMode) -> Self {
530        self.permission_mode = Some(mode);
531        self
532    }
533
534    /// Continue the most recent conversation
535    pub fn continue_conversation(mut self, continue_conv: bool) -> Self {
536        self.continue_conversation = continue_conv;
537        self
538    }
539
540    /// Resume a specific conversation
541    pub fn resume<S: Into<String>>(mut self, session_id: Option<S>) -> Self {
542        self.resume = session_id.map(|s| s.into());
543        self
544    }
545
546    /// When resuming (or continuing), create a new session ID instead of
547    /// reusing the source session's — i.e. fork. The CLI only accepts
548    /// `--session-id` alongside `--resume`/`--continue` when this is set.
549    ///
550    /// Prefer [`fork_from`](Self::fork_from), which assembles the whole
551    /// combination.
552    pub fn fork_session(mut self, fork: bool) -> Self {
553        self.fork_session = fork;
554        self
555    }
556
557    /// Fork an existing session: resume `source_session_id`'s full history
558    /// into a **new** session, leaving the source untouched.
559    ///
560    /// Assembles `--resume <source> --fork-session --session-id <new>`,
561    /// generating a fresh UUID for the fork (override it by chaining
562    /// [`session_id`](Self::session_id)). The forked session starts with the
563    /// source's entire history — the Claude CLI has no headless
564    /// fork-at-a-point cut; for that semantic see `codex-codes`'
565    /// `thread_fork` with `last_turn_id`.
566    ///
567    /// ```no_run
568    /// # use claude_codes::ClaudeCliBuilder;
569    /// let builder = ClaudeCliBuilder::new()
570    ///     .fork_from("0b8fa762-6c48-4f3b-a3a1-0347f96a52bc")
571    ///     .prompt("Continue, but try the other approach");
572    /// ```
573    pub fn fork_from<S: Into<String>>(mut self, source_session_id: S) -> Self {
574        self.resume = Some(source_session_id.into());
575        self.fork_session = true;
576        self
577    }
578
579    /// Set the model to use
580    pub fn model<S: Into<String>>(mut self, model: S) -> Self {
581        self.model = Some(model.into());
582        self
583    }
584
585    /// Set fallback model for overload situations
586    pub fn fallback_model<S: Into<String>>(mut self, model: S) -> Self {
587        self.fallback_model = Some(model.into());
588        self
589    }
590
591    /// Set maximum number of tokens for extended thinking
592    pub fn max_thinking_tokens(mut self, tokens: u32) -> Self {
593        self.max_thinking_tokens = Some(tokens);
594        self
595    }
596
597    /// Load settings from file or JSON
598    pub fn settings<S: Into<String>>(mut self, settings: S) -> Self {
599        self.settings = Some(settings.into());
600        self
601    }
602
603    /// Add directories for tool access
604    pub fn add_directories<I, P>(mut self, dirs: I) -> Self
605    where
606        I: IntoIterator<Item = P>,
607        P: Into<PathBuf>,
608    {
609        self.add_dir.extend(dirs.into_iter().map(|p| p.into()));
610        self
611    }
612
613    /// Automatically connect to IDE
614    pub fn ide(mut self, ide: bool) -> Self {
615        self.ide = ide;
616        self
617    }
618
619    /// Use only MCP servers from config
620    pub fn strict_mcp_config(mut self, strict: bool) -> Self {
621        self.strict_mcp_config = strict;
622        self
623    }
624
625    /// Set a specific session ID (must be a UUID)
626    pub fn session_id(mut self, id: Uuid) -> Self {
627        self.session_id = Some(id);
628        self
629    }
630
631    /// Set OAuth token for authentication (must start with "sk-ant-oat")
632    pub fn oauth_token<S: Into<String>>(mut self, token: S) -> Self {
633        let token_str = token.into();
634        if !token_str.starts_with("sk-ant-oat") {
635            eprintln!("Warning: OAuth token should start with 'sk-ant-oat'");
636        }
637        self.oauth_token = Some(token_str);
638        self
639    }
640
641    /// Set API key for authentication (must start with "sk-ant-api")
642    pub fn api_key<S: Into<String>>(mut self, key: S) -> Self {
643        let key_str = key.into();
644        if !key_str.starts_with("sk-ant-api") {
645            eprintln!("Warning: API key should start with 'sk-ant-api'");
646        }
647        self.api_key = Some(key_str);
648        self
649    }
650
651    /// Enable bidirectional tool permission protocol via stdio
652    ///
653    /// When enabled, Claude CLI will send permission requests via stdout
654    /// and expect responses via stdin. Use "stdio" for standard I/O based
655    /// permission handling.
656    ///
657    /// # Example
658    /// ```
659    /// use claude_codes::ClaudeCliBuilder;
660    ///
661    /// let builder = ClaudeCliBuilder::new()
662    ///     .permission_prompt_tool("stdio")
663    ///     .model("sonnet");
664    /// ```
665    pub fn permission_prompt_tool<S: Into<String>>(mut self, tool: S) -> Self {
666        self.permission_prompt_tool = Some(tool.into());
667        self
668    }
669
670    /// Allow spawning inside another Claude Code session by unsetting the
671    /// `CLAUDECODE` environment variable in the child process.
672    #[cfg(feature = "integration-tests")]
673    pub fn allow_recursion(mut self) -> Self {
674        self.allow_recursion = true;
675        self
676    }
677
678    /// Resolve the command path, using `which` for non-absolute paths.
679    fn resolve_command(&self) -> Result<PathBuf> {
680        if self.command.is_absolute() {
681            return Ok(self.command.clone());
682        }
683        which::which(&self.command).map_err(|_| Error::BinaryNotFound {
684            name: self.command.display().to_string(),
685        })
686    }
687
688    /// Build the command arguments (always includes JSON streaming flags)
689    fn build_args(&self) -> Vec<String> {
690        // Always add JSON streaming mode flags
691        // Note: --print with stream-json requires --verbose
692        let mut args = vec![
693            "--print".to_string(),
694            "--verbose".to_string(),
695            "--output-format".to_string(),
696            "stream-json".to_string(),
697            "--input-format".to_string(),
698            "stream-json".to_string(),
699        ];
700
701        if let Some(ref debug) = self.debug {
702            args.push("--debug".to_string());
703            if !debug.is_empty() {
704                args.push(debug.clone());
705            }
706        }
707
708        if self.dangerously_skip_permissions {
709            args.push("--dangerously-skip-permissions".to_string());
710        }
711
712        if !self.allowed_tools.is_empty() {
713            args.push("--allowed-tools".to_string());
714            args.extend(self.allowed_tools.clone());
715        }
716
717        if !self.disallowed_tools.is_empty() {
718            args.push("--disallowed-tools".to_string());
719            args.extend(self.disallowed_tools.clone());
720        }
721
722        if !self.mcp_config.is_empty() {
723            args.push("--mcp-config".to_string());
724            args.extend(self.mcp_config.clone());
725        }
726
727        if let Some(ref prompt) = self.append_system_prompt {
728            args.push("--append-system-prompt".to_string());
729            args.push(prompt.clone());
730        }
731
732        if let Some(ref mode) = self.permission_mode {
733            args.push("--permission-mode".to_string());
734            args.push(mode.as_str().to_string());
735        }
736
737        if self.continue_conversation {
738            args.push("--continue".to_string());
739        }
740
741        if let Some(ref session) = self.resume {
742            args.push("--resume".to_string());
743            args.push(session.clone());
744        }
745
746        if self.fork_session {
747            args.push("--fork-session".to_string());
748        }
749
750        if let Some(ref model) = self.model {
751            args.push("--model".to_string());
752            args.push(model.clone());
753        }
754
755        if let Some(ref model) = self.fallback_model {
756            args.push("--fallback-model".to_string());
757            args.push(model.clone());
758        }
759
760        if let Some(tokens) = self.max_thinking_tokens {
761            args.push("--max-thinking-tokens".to_string());
762            args.push(tokens.to_string());
763        }
764
765        if let Some(ref settings) = self.settings {
766            args.push("--settings".to_string());
767            args.push(settings.clone());
768        }
769
770        if !self.add_dir.is_empty() {
771            args.push("--add-dir".to_string());
772            for dir in &self.add_dir {
773                args.push(dir.to_string_lossy().to_string());
774            }
775        }
776
777        if self.ide {
778            args.push("--ide".to_string());
779        }
780
781        if self.strict_mcp_config {
782            args.push("--strict-mcp-config".to_string());
783        }
784
785        if let Some(ref tool) = self.permission_prompt_tool {
786            args.push("--permission-prompt-tool".to_string());
787            args.push(tool.clone());
788        }
789
790        // --session-id is only legal alongside --resume/--continue when
791        // --fork-session is set (it names the fork); otherwise emit it only
792        // for fresh sessions. Either way a UUID is generated when unset so
793        // the session id is known before spawn.
794        if (self.resume.is_none() && !self.continue_conversation) || self.fork_session {
795            args.push("--session-id".to_string());
796            let session_uuid = self.session_id.unwrap_or_else(|| {
797                let uuid = Uuid::new_v4();
798                debug!("[CLI] Generated session UUID: {}", uuid);
799                uuid
800            });
801            args.push(session_uuid.to_string());
802        }
803
804        // Add prompt as the last argument if provided
805        if let Some(ref prompt) = self.prompt {
806            args.push(prompt.clone());
807        }
808
809        args
810    }
811
812    /// Spawn the Claude process
813    #[cfg(feature = "async-client")]
814    pub async fn spawn(self) -> Result<tokio::process::Child> {
815        let resolved = self.resolve_command()?;
816        let args = self.build_args();
817
818        debug!(
819            "[CLI] Executing command: {} {}",
820            resolved.display(),
821            args.join(" ")
822        );
823
824        let mut cmd = tokio::process::Command::new(&resolved);
825        cmd.args(&args)
826            .stdin(Stdio::piped())
827            .stdout(Stdio::piped())
828            .stderr(Stdio::piped());
829
830        if let Some(ref dir) = self.working_directory {
831            cmd.current_dir(dir);
832        }
833
834        if self.allow_recursion {
835            cmd.env_remove("CLAUDECODE");
836        }
837
838        if let Some(ref token) = self.oauth_token {
839            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
840        }
841
842        if let Some(ref key) = self.api_key {
843            cmd.env("ANTHROPIC_API_KEY", key);
844        }
845
846        crate::process::configure_no_window(cmd.as_std_mut());
847        let child = cmd.spawn().map_err(Error::Io)?;
848
849        Ok(child)
850    }
851
852    /// Build a Command without spawning (for testing or manual execution)
853    #[cfg(feature = "async-client")]
854    pub fn build_command(self) -> Result<tokio::process::Command> {
855        let resolved = self.resolve_command()?;
856        let args = self.build_args();
857        let mut cmd = tokio::process::Command::new(&resolved);
858        cmd.args(&args)
859            .stdin(Stdio::piped())
860            .stdout(Stdio::piped())
861            .stderr(Stdio::piped());
862
863        if let Some(ref dir) = self.working_directory {
864            cmd.current_dir(dir);
865        }
866
867        if self.allow_recursion {
868            cmd.env_remove("CLAUDECODE");
869        }
870
871        if let Some(ref token) = self.oauth_token {
872            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
873        }
874
875        if let Some(ref key) = self.api_key {
876            cmd.env("ANTHROPIC_API_KEY", key);
877        }
878
879        crate::process::configure_no_window(cmd.as_std_mut());
880        Ok(cmd)
881    }
882
883    /// Spawn the Claude process using synchronous std::process
884    pub fn spawn_sync(self) -> Result<std::process::Child> {
885        let resolved = self.resolve_command()?;
886        let args = self.build_args();
887
888        debug!(
889            "[CLI] Executing sync command: {} {}",
890            resolved.display(),
891            args.join(" ")
892        );
893
894        let mut cmd = std::process::Command::new(&resolved);
895        cmd.args(&args)
896            .stdin(Stdio::piped())
897            .stdout(Stdio::piped())
898            .stderr(Stdio::piped());
899
900        if let Some(ref dir) = self.working_directory {
901            cmd.current_dir(dir);
902        }
903
904        if self.allow_recursion {
905            cmd.env_remove("CLAUDECODE");
906        }
907
908        if let Some(ref token) = self.oauth_token {
909            cmd.env("CLAUDE_CODE_OAUTH_TOKEN", token);
910        }
911
912        if let Some(ref key) = self.api_key {
913            cmd.env("ANTHROPIC_API_KEY", key);
914        }
915
916        crate::process::configure_no_window(&mut cmd);
917        cmd.spawn().map_err(Error::Io)
918    }
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    #[test]
926    fn test_working_directory() {
927        let builder = ClaudeCliBuilder::new().working_directory("workspace");
928        assert_eq!(builder.working_directory, Some(PathBuf::from("workspace")));
929    }
930
931    #[test]
932    fn test_streaming_flags_always_present() {
933        let builder = ClaudeCliBuilder::new();
934        let args = builder.build_args();
935
936        // Verify all streaming flags are present by default
937        assert!(args.contains(&"--print".to_string()));
938        assert!(args.contains(&"--verbose".to_string())); // Required for --print with stream-json
939        assert!(args.contains(&"--output-format".to_string()));
940        assert!(args.contains(&"stream-json".to_string()));
941        assert!(args.contains(&"--input-format".to_string()));
942    }
943
944    #[test]
945    fn test_with_prompt() {
946        let builder = ClaudeCliBuilder::new().prompt("Hello, Claude!");
947        let args = builder.build_args();
948
949        assert_eq!(args.last().unwrap(), "Hello, Claude!");
950    }
951
952    #[test]
953    fn test_with_model() {
954        let builder = ClaudeCliBuilder::new()
955            .model("sonnet")
956            .fallback_model("opus");
957        let args = builder.build_args();
958
959        assert!(args.contains(&"--model".to_string()));
960        assert!(args.contains(&"sonnet".to_string()));
961        assert!(args.contains(&"--fallback-model".to_string()));
962        assert!(args.contains(&"opus".to_string()));
963    }
964
965    #[test]
966    fn test_with_debug() {
967        let builder = ClaudeCliBuilder::new().debug(Some("api"));
968        let args = builder.build_args();
969
970        assert!(args.contains(&"--debug".to_string()));
971        assert!(args.contains(&"api".to_string()));
972    }
973
974    #[test]
975    fn test_with_oauth_token() {
976        let valid_token = "sk-ant-oat-123456789";
977        let builder = ClaudeCliBuilder::new().oauth_token(valid_token);
978
979        // OAuth token is set as env var, not in args
980        let args = builder.clone().build_args();
981        assert!(!args.contains(&valid_token.to_string()));
982
983        // Verify it's stored in the builder
984        assert_eq!(builder.oauth_token, Some(valid_token.to_string()));
985    }
986
987    #[test]
988    fn test_oauth_token_validation() {
989        // Test with invalid prefix (should print warning but still accept)
990        let invalid_token = "invalid-token-123";
991        let builder = ClaudeCliBuilder::new().oauth_token(invalid_token);
992        assert_eq!(builder.oauth_token, Some(invalid_token.to_string()));
993    }
994
995    #[test]
996    fn test_with_api_key() {
997        let valid_key = "sk-ant-api-987654321";
998        let builder = ClaudeCliBuilder::new().api_key(valid_key);
999
1000        // API key is set as env var, not in args
1001        let args = builder.clone().build_args();
1002        assert!(!args.contains(&valid_key.to_string()));
1003
1004        // Verify it's stored in the builder
1005        assert_eq!(builder.api_key, Some(valid_key.to_string()));
1006    }
1007
1008    #[test]
1009    fn test_api_key_validation() {
1010        // Test with invalid prefix (should print warning but still accept)
1011        let invalid_key = "invalid-api-key";
1012        let builder = ClaudeCliBuilder::new().api_key(invalid_key);
1013        assert_eq!(builder.api_key, Some(invalid_key.to_string()));
1014    }
1015
1016    #[test]
1017    fn test_both_auth_methods() {
1018        let oauth = "sk-ant-oat-123";
1019        let api_key = "sk-ant-api-456";
1020        let builder = ClaudeCliBuilder::new().oauth_token(oauth).api_key(api_key);
1021
1022        assert_eq!(builder.oauth_token, Some(oauth.to_string()));
1023        assert_eq!(builder.api_key, Some(api_key.to_string()));
1024    }
1025
1026    #[test]
1027    fn test_permission_prompt_tool() {
1028        let builder = ClaudeCliBuilder::new().permission_prompt_tool("stdio");
1029        let args = builder.build_args();
1030
1031        assert!(args.contains(&"--permission-prompt-tool".to_string()));
1032        assert!(args.contains(&"stdio".to_string()));
1033    }
1034
1035    #[test]
1036    fn test_permission_prompt_tool_not_present_by_default() {
1037        let builder = ClaudeCliBuilder::new();
1038        let args = builder.build_args();
1039
1040        assert!(!args.contains(&"--permission-prompt-tool".to_string()));
1041    }
1042
1043    #[test]
1044    fn test_session_id_present_for_new_session() {
1045        let builder = ClaudeCliBuilder::new();
1046        let args = builder.build_args();
1047
1048        assert!(
1049            args.contains(&"--session-id".to_string()),
1050            "New sessions should have --session-id"
1051        );
1052    }
1053
1054    #[test]
1055    fn test_session_id_not_present_with_resume() {
1056        // When resuming a session, --session-id should NOT be added
1057        // (Claude CLI rejects --session-id + --resume without --fork-session)
1058        let builder = ClaudeCliBuilder::new().resume(Some("existing-uuid".to_string()));
1059        let args = builder.build_args();
1060
1061        assert!(
1062            args.contains(&"--resume".to_string()),
1063            "Should have --resume flag"
1064        );
1065        assert!(
1066            !args.contains(&"--session-id".to_string()),
1067            "--session-id should NOT be present when resuming"
1068        );
1069    }
1070
1071    #[test]
1072    fn test_session_id_not_present_with_continue() {
1073        // When continuing a session, --session-id should NOT be added
1074        let builder = ClaudeCliBuilder::new().continue_conversation(true);
1075        let args = builder.build_args();
1076
1077        assert!(
1078            args.contains(&"--continue".to_string()),
1079            "Should have --continue flag"
1080        );
1081        assert!(
1082            !args.contains(&"--session-id".to_string()),
1083            "--session-id should NOT be present when continuing"
1084        );
1085    }
1086
1087    #[test]
1088    fn test_fork_from_assembles_resume_fork_and_new_session_id() {
1089        let new_id = Uuid::new_v4();
1090        let args = ClaudeCliBuilder::new()
1091            .fork_from("source-uuid")
1092            .session_id(new_id)
1093            .build_args();
1094
1095        let resume_pos = args.iter().position(|a| a == "--resume").unwrap();
1096        assert_eq!(args[resume_pos + 1], "source-uuid");
1097        assert!(args.contains(&"--fork-session".to_string()));
1098        let sid_pos = args.iter().position(|a| a == "--session-id").unwrap();
1099        assert_eq!(args[sid_pos + 1], new_id.to_string());
1100    }
1101
1102    #[test]
1103    fn test_fork_from_generates_session_id_when_unset() {
1104        let args = ClaudeCliBuilder::new()
1105            .fork_from("source-uuid")
1106            .build_args();
1107
1108        assert!(args.contains(&"--fork-session".to_string()));
1109        let sid_pos = args.iter().position(|a| a == "--session-id").unwrap();
1110        assert!(
1111            Uuid::parse_str(&args[sid_pos + 1]).is_ok(),
1112            "generated fork session id should be a UUID"
1113        );
1114    }
1115
1116    #[test]
1117    fn test_fork_session_with_continue_emits_session_id() {
1118        let args = ClaudeCliBuilder::new()
1119            .continue_conversation(true)
1120            .fork_session(true)
1121            .build_args();
1122
1123        assert!(args.contains(&"--continue".to_string()));
1124        assert!(args.contains(&"--fork-session".to_string()));
1125        assert!(args.contains(&"--session-id".to_string()));
1126    }
1127}