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