Skip to main content

claude_runner_core/command/
mod.rs

1//! Claude Code Command Builder
2//!
3//! Provides fluent API for constructing and executing Claude Code CLI commands.
4//!
5//! ## Execution Modes
6//!
7//! This module supports two execution modes:
8//!
9//! - **Non-interactive mode** ([`execute`](ClaudeCommand::execute)): Captures stdout/stderr, suitable for programmatic usage
10//! - **Interactive mode** ([`execute_interactive`](ClaudeCommand::execute_interactive)): Allows Claude Code to take over terminal (TTY attached)
11//!
12//! The distinction is critical: `.output()` captures process output which prevents Claude Code from
13//! accessing the terminal for interactive sessions. Interactive mode uses `.status()` to preserve TTY access.
14
15use std::path::PathBuf;
16use error_tools::{ Result, Error };
17
18mod params_core;
19mod params_security;
20mod params_extended;
21
22/// Builder for Claude Code CLI commands
23///
24/// # Example
25///
26/// ```no_run
27/// use claude_runner_core::ClaudeCommand;
28///
29/// let result = ClaudeCommand::new()
30///   .with_working_directory( "/home/user/project" )
31///   .with_max_output_tokens( 200_000 )
32///   .execute()?;
33/// # Ok::<(), Box<dyn std::error::Error>>(())
34/// ```
35#[ allow( clippy::struct_excessive_bools ) ] // four independent flags (continue, skip_permissions, dry_run, unset_claudecode) — enum refactor adds no clarity
36#[derive( Debug )]
37pub struct ClaudeCommand {
38  pub(super) working_directory: Option<PathBuf>,
39  pub(super) max_output_tokens: Option<u32>,
40  pub(super) continue_conversation: bool,
41  pub(super) message: Option<String>,
42  pub(super) args: Vec<String>,
43
44  // Tier 1: Critical parameters with different defaults (fix automation blockers)
45  pub(super) bash_default_timeout_ms: Option<u32>,
46  pub(super) bash_max_timeout_ms: Option<u32>,
47  pub(super) auto_continue: Option<bool>,
48  pub(super) telemetry: Option<bool>,
49
50  // Tier 2: Essential parameters with standard defaults (security-sensitive)
51  pub(super) auto_approve_tools: Option<bool>,
52  pub(super) action_mode: Option<crate::types::ActionMode>,
53  pub(super) log_level: Option<crate::types::LogLevel>,
54  pub(super) temperature: Option<f64>,
55
56  // Safety override
57  pub(super) skip_permissions: bool,
58
59  // Terminal & IDE flags with non-standard builder defaults
60  pub(super) chrome: Option<bool>,
61
62  // Tier 3: Optional parameters with standard defaults
63  pub(super) sandbox_mode: Option<bool>,
64  pub(super) session_dir: Option<PathBuf>,
65  pub(super) top_p: Option<f64>,
66  pub(super) top_k: Option<u32>,
67
68  // Execution control
69  pub(super) dry_run: bool,
70
71  // Isolation
72  pub(super) home_override: Option< PathBuf >,
73
74  // Stdin piping
75  pub(super) stdin_file: Option< PathBuf >,
76
77  // Subprocess environment control
78  pub(super) unset_claudecode: bool,
79}
80
81impl ClaudeCommand {
82  /// Create a new Claude Code command builder
83  ///
84  /// # Example
85  ///
86  /// ```
87  /// use claude_runner_core::ClaudeCommand;
88  ///
89  /// let cmd = ClaudeCommand::new();
90  /// ```
91  #[inline]
92  #[must_use]
93  pub fn new() -> Self {
94    // Fix(issue-token-limit-default): Default token limit changed from 32K to 200K
95    // Root cause: Migration from factory pattern didnt preserve correct default value
96    // Pitfall: Always verify defaults match specification when refactoring APIs
97
98    // Fix(issue-bash-timeout-default): Bash timeouts increased from 2min/10min to 1hr/2hr
99    // Root cause: Standard 2min default causes premature timeout in real automation workflows
100    // Pitfall: Always set explicit timeouts matching actual operation duration needs
101
102    // Fix(issue-auto-continue-default): Auto-continue enabled by default (true vs false)
103    // Root cause: Standard false blocks all automation with manual prompts
104    // Pitfall: Programmatic usage requires automation-friendly defaults
105
106    // Fix(issue-telemetry-default): Telemetry disabled by default (false vs true)
107    // Root cause: Automation contexts shouldnt send usage data without explicit consent
108    // Pitfall: Respect user privacy in programmatic execution
109
110    // Fix(issue-chrome-default): Chrome enabled by default (Some(true) vs None/omit)
111    // Root cause: Browser context is essential for web-aware automation; omitting --chrome
112    //             relies on the user's global claude config being set to on, which is not guaranteed
113    // Pitfall: Store as field, not push to args in new() — must remain overridable by with_chrome()
114
115    Self {
116      working_directory: None,
117      max_output_tokens: Some( 200_000 ),
118      continue_conversation: false,
119      message: None,
120      args: Vec::new(),
121
122      // Tier 1: Different defaults (fix automation blockers)
123      bash_default_timeout_ms: Some( 3_600_000 ),  // 1 hour (vs 2 min standard)
124      bash_max_timeout_ms: Some( 7_200_000 ),      // 2 hours (vs 10 min standard)
125      auto_continue: Some( true ),                 // Enable automation (vs false standard)
126      telemetry: Some( false ),                    // Disable telemetry (vs true standard)
127
128      skip_permissions: false,
129      chrome: Some( true ),  // Enable browser context by default (vs off in raw claude binary)
130
131      // Tier 2 & 3: Standard defaults (security-sensitive, opt-in only)
132      auto_approve_tools: None,  // Inherits standard: false
133      action_mode: None,         // Inherits standard: Ask
134      log_level: None,           // Inherits standard: Info
135      temperature: None,         // Inherits standard: 1.0
136      sandbox_mode: None,        // Inherits standard: true
137      session_dir: None,         // Inherits standard: auto-detect
138      top_p: None,               // Inherits standard: None
139      top_k: None,               // Inherits standard: None
140
141      dry_run: false,
142
143      home_override: None,
144
145      stdin_file: None,
146      unset_claudecode: true,
147    }
148  }
149
150  /// Describe only the invocation line (no `cd` prefix)
151  ///
152  /// Unlike `describe()`, this always returns a single line (without the leading `cd /dir` line).
153  /// When `unset_claudecode` is active (the default), the line starts with `env -u CLAUDECODE claude ...`;
154  /// when disabled via `with_unset_claudecode(false)`, it starts with `claude ...`.
155  ///
156  /// # Critical: Implementation Must Use `describe().lines().last()`
157  ///
158  /// Do NOT reconstruct the command from parts — that would diverge from the
159  /// actual command built by `build_command()`. The only correct implementation
160  /// is to delegate to `describe()` and extract the last line.
161  ///
162  /// # Example
163  ///
164  /// ```
165  /// use claude_runner_core::ClaudeCommand;
166  ///
167  /// // Default: CLAUDECODE is removed — invocation line starts with "env -u CLAUDECODE"
168  /// let compact = ClaudeCommand::new()
169  ///   .with_working_directory( "/tmp" )
170  ///   .with_skip_permissions( true )
171  ///   .describe_compact();
172  ///
173  /// assert!( compact.starts_with( "env -u CLAUDECODE" ) );
174  /// assert!( !compact.contains( "cd " ) );
175  /// ```
176  #[inline]
177  #[must_use]
178  pub fn describe_compact( &self ) -> String {
179    // Fix(issue-describe-compact-double-cd): Always extract last line from describe()
180    // Root cause: describe() emits "cd /dir\nclaude ..." when working_directory is set
181    // Pitfall: Rebuilding from parts diverges from build_command(); always delegate to describe()
182    self.describe()
183      .lines()
184      .last()
185      .unwrap_or( "claude" )
186      .to_string()
187  }
188
189  /// Describe the command line that would be executed
190  ///
191  /// Returns a human-readable representation of the command. If a working
192  /// directory is set, the first line is `cd /path/to/dir`. The last line
193  /// is the `claude` invocation with all flags and arguments.
194  ///
195  /// # Output Flag Order
196  ///
197  /// The command-line flag order in the output is fixed by the implementation,
198  /// **not** by the order in which `with_*` builder methods are called. The order is:
199  ///
200  /// 0. `env -u CLAUDECODE` prefix (if `unset_claudecode` is true, the default)
201  /// 1. `--dangerously-skip-permissions` (if `skip_permissions` is true)
202  /// 2. `--chrome` or `--no-chrome` (from `chrome` field; default `Some(true)` = `--chrome`)
203  /// 3. custom args (in insertion order via `with_arg`)
204  /// 4. `-c` (if `continue_conversation` is true)
205  /// 5. `"<message>"` (if message is set)
206  ///
207  /// This matters when writing tests that assert the exact output string (e.g. `assert_eq!`).
208  /// Use `contains` assertions for individual flags when order is not the subject of the test.
209  ///
210  /// # Critical: Must Mirror `build_command()`
211  ///
212  /// `describe()` reconstructs the command string independently of `build_command()`. Every CLI
213  /// flag that `build_command()` emits from a **typed field** (not from `self.args`) MUST also
214  /// appear in `describe()` at the corresponding position.
215  ///
216  /// Typed-field flags (currently `skip_permissions`, `chrome`, `continue_conversation`) are
217  /// emitted directly in `build_command()` — NOT via `self.args`. Updating `build_command()`
218  /// without updating `describe()` causes dry-run output to diverge from the actual command.
219  ///
220  /// Pitfall: always update both methods when adding a new typed-field CLI parameter.
221  ///
222  /// # Example
223  ///
224  /// ```
225  /// use claude_runner_core::ClaudeCommand;
226  ///
227  /// let desc = ClaudeCommand::new()
228  ///   .with_working_directory( "/tmp" )
229  ///   .with_skip_permissions( true )
230  ///   .with_message( "hello" )
231  ///   .describe();
232  ///
233  /// assert!( desc.contains( "cd /tmp" ) );
234  /// assert!( desc.contains( "--dangerously-skip-permissions" ) );
235  /// ```
236  #[inline]
237  #[must_use]
238  pub fn describe( &self ) -> String {
239    let mut lines = Vec::new();
240
241    if let Some( ref dir ) = self.working_directory {
242      lines.push( format!( "cd {}", dir.display() ) );
243    }
244
245    // Fix(BUG-246): prefix `env -u CLAUDECODE` when unset_claudecode is active so
246    //   trace/dry-run output is WYSIWYG — what you see is what subprocess actually runs.
247    // Root cause: describe() started with "claude" unconditionally; env_remove("CLAUDECODE")
248    //   in build_command() is an OS-level call invisible in the displayed command string.
249    // Pitfall: both describe() and build_command() must be kept in sync — every env
250    //   manipulation in build_command() must appear in describe() output at the same position.
251    let mut parts = if self.unset_claudecode
252    {
253      vec![ "env".to_string(), "-u".to_string(), "CLAUDECODE".to_string(), "claude".to_string() ]
254    }
255    else
256    {
257      vec![ "claude".to_string() ]
258    };
259
260    if self.skip_permissions {
261      parts.push( "--dangerously-skip-permissions".to_string() );
262    }
263
264    // Emit chrome flag from typed field (default: Some(true) → --chrome)
265    match self.chrome {
266      Some( true )  => parts.push( "--chrome".to_string() ),
267      Some( false ) => parts.push( "--no-chrome".to_string() ),
268      None          => {}
269    }
270
271    for arg in &self.args {
272      parts.push( arg.clone() );
273    }
274
275    if self.continue_conversation {
276      parts.push( "-c".to_string() );
277    }
278
279    if let Some( ref msg ) = self.message {
280      // Fix(issue-describe-backslash-escape): Escape `\` before `"` to prevent malformed shell output
281      // Root cause: Only `"` was escaped, not `\`. Messages containing `\"` produced `\\"` in output
282      // which shell parses as a closing double-quote, breaking the command representation.
283      // Pitfall: Always escape `\` first, then `"`, when quoting for double-quoted shell strings.
284      let escaped = msg.replace( '\\', "\\\\" ).replace( '"', "\\\"" );
285      parts.push( format!( "\"{escaped}\"" ) );
286    }
287
288    // stdin redirect notation appended to the invocation line (P1: must mirror build_command)
289    if let Some( ref path ) = self.stdin_file
290    {
291      parts.push( format!( "< {}", path.display() ) );
292    }
293
294    lines.push( parts.join( " " ) );
295    lines.join( "\n" )
296  }
297
298  /// Describe environment variables that would be set
299  ///
300  /// Returns one `NAME=VALUE` line per configured environment variable.
301  /// Only includes variables that have been explicitly set (via defaults
302  /// or builder methods). Omits `None` values.
303  ///
304  /// # Example
305  ///
306  /// ```
307  /// use claude_runner_core::ClaudeCommand;
308  ///
309  /// let env = ClaudeCommand::new().describe_env();
310  ///
311  /// assert!( env.contains( "CLAUDE_CODE_MAX_OUTPUT_TOKENS=200000" ) );
312  /// assert!( env.contains( "CLAUDE_CODE_BASH_TIMEOUT=3600000" ) );
313  /// ```
314  #[inline]
315  #[must_use]
316  pub fn describe_env( &self ) -> String {
317    let mut lines = Vec::new();
318
319    if let Some( tokens ) = self.max_output_tokens {
320      lines.push( format!( "CLAUDE_CODE_MAX_OUTPUT_TOKENS={tokens}" ) );
321    }
322    if let Some( timeout ) = self.bash_default_timeout_ms {
323      lines.push( format!( "CLAUDE_CODE_BASH_TIMEOUT={timeout}" ) );
324    }
325    if let Some( max_timeout ) = self.bash_max_timeout_ms {
326      lines.push( format!( "CLAUDE_CODE_BASH_MAX_TIMEOUT={max_timeout}" ) );
327    }
328    if let Some( auto_continue ) = self.auto_continue {
329      lines.push( format!( "CLAUDE_CODE_AUTO_CONTINUE={auto_continue}" ) );
330    }
331    if let Some( telemetry ) = self.telemetry {
332      lines.push( format!( "CLAUDE_CODE_TELEMETRY={telemetry}" ) );
333    }
334    if let Some( approve ) = self.auto_approve_tools {
335      lines.push( format!( "CLAUDE_CODE_AUTO_APPROVE_TOOLS={approve}" ) );
336    }
337    if let Some( mode ) = self.action_mode {
338      lines.push( format!( "CLAUDE_CODE_ACTION_MODE={}", mode.as_str() ) );
339    }
340    if let Some( level ) = self.log_level {
341      lines.push( format!( "CLAUDE_CODE_LOG_LEVEL={}", level.as_str() ) );
342    }
343    if let Some( temp ) = self.temperature {
344      lines.push( format!( "CLAUDE_CODE_TEMPERATURE={temp}" ) );
345    }
346    if let Some( sandbox ) = self.sandbox_mode {
347      lines.push( format!( "CLAUDE_CODE_SANDBOX_MODE={sandbox}" ) );
348    }
349    if let Some( ref dir ) = self.session_dir {
350      lines.push( format!( "CLAUDE_CODE_SESSION_DIR={}", dir.display() ) );
351    }
352    if let Some( top_p ) = self.top_p {
353      lines.push( format!( "CLAUDE_CODE_TOP_P={top_p}" ) );
354    }
355    if let Some( top_k ) = self.top_k {
356      lines.push( format!( "CLAUDE_CODE_TOP_K={top_k}" ) );
357    }
358    if let Some( ref home ) = self.home_override {
359      lines.push( format!( "HOME={}", home.display() ) );
360    }
361
362    lines.join( "\n" )
363  }
364
365  /// Execute the Claude Code command and capture output (non-interactive mode)
366  ///
367  /// This is the SINGLE execution point for non-interactive Claude Code process invocations.
368  /// For interactive sessions, use [`execute_interactive`](Self::execute_interactive).
369  ///
370  /// Returns [`ExecutionOutput`](crate::ExecutionOutput) with stdout, stderr, and exit code.
371  ///
372  /// # Errors
373  ///
374  /// Returns error if Claude Code binary not found in PATH or process fails to spawn.
375  ///
376  /// # Example
377  ///
378  /// ```no_run
379  /// use claude_runner_core::ClaudeCommand;
380  ///
381  /// let result = ClaudeCommand::new()
382  ///   .with_max_output_tokens( 200_000 )
383  ///   .execute()?;
384  /// println!( "{}", result.stdout );
385  /// # Ok::<(), Box<dyn std::error::Error>>(())
386  /// ```
387  #[inline]
388  pub fn execute( &self ) -> Result< crate::types::ExecutionOutput > {
389    if self.dry_run {
390      return Ok( crate::types::ExecutionOutput {
391        stdout: self.describe_compact(),
392        stderr: String::new(),
393        exit_code: 0,
394      } );
395    }
396
397    let mut cmd = self.build_command();
398
399    if let Some( ref path ) = self.stdin_file
400    {
401      let file = std::fs::File::open( path )
402        .map_err( | e | Error::msg( format!( "cannot open stdin file '{}': {e}", path.display() ) ) )?;
403      cmd.stdin( std::process::Stdio::from( file ) );
404    }
405
406    // Fix(BUG-241): map NotFound to an actionable install hint.
407    // Root cause: Command::output() on a missing binary returns io::ErrorKind::NotFound
408    //   with a raw OS error string ("No such file or directory (os error 2)") — giving
409    //   the caller no actionable information about what to install or where.
410    // Pitfall: always check e.kind() before formatting the error; NotFound is a distinct
411    //   user-fixable condition and must produce a separate message from other spawn failures.
412    let output = cmd.output()
413      .map_err( |e|
414      {
415        if e.kind() == std::io::ErrorKind::NotFound
416        {
417          Error::msg( "claude binary not found in PATH — install with: npm i -g @anthropic-ai/claude-code" )
418        }
419        else
420        {
421          Error::msg( format!( "Failed to execute Claude Code: {e}" ) )
422        }
423      } )?;
424
425    let stdout = String::from_utf8_lossy( &output.stdout ).to_string();
426    let stderr = String::from_utf8_lossy( &output.stderr ).to_string();
427    // Fix(BUG-242): use signal_exit_code() so SIGTERM (→143) and SIGKILL (→137) are
428    //   preserved instead of collapsed to -1.
429    // Root cause: unwrap_or(-1) returns -1 for any signal-killed subprocess on Unix;
430    //   callers cannot distinguish a signal kill from any other non-exit condition.
431    // Pitfall: code() returns None only for signal kills on Unix — never for a normal exit;
432    //   the #[cfg(unix)] branch in signal_exit_code fires exactly in those cases.
433    let exit_code = crate::signal_exit_code( &output.status );
434
435    Ok( crate::types::ExecutionOutput { stdout, stderr, exit_code } )
436  }
437
438  /// Execute the Claude Code command in interactive mode (TTY attached)
439  ///
440  /// This method allows Claude Code to take over the terminal for interactive sessions.
441  /// Unlike [`execute`](Self::execute), this doesnt capture output and instead lets
442  /// Claude Code directly interact with the user's terminal.
443  ///
444  /// # Errors
445  ///
446  /// Returns error if Claude Code binary not found in PATH or process fails to spawn.
447  ///
448  /// # Example
449  ///
450  /// ```no_run
451  /// use claude_runner_core::ClaudeCommand;
452  ///
453  /// let exit_status = ClaudeCommand::new()
454  ///   .with_max_output_tokens( 200_000 )
455  ///   .execute_interactive()?;
456  /// # Ok::<(), Box<dyn std::error::Error>>(())
457  /// ```
458  #[inline]
459  pub fn execute_interactive( &self ) -> Result< std::process::ExitStatus > {
460    if self.dry_run {
461      #[cfg( unix )]
462      {
463        use std::os::unix::process::ExitStatusExt;
464        return Ok( std::process::ExitStatus::from_raw( 0 ) );
465      }
466      #[cfg( not( unix ) )]
467      {
468        // On non-Unix, run a no-op command to obtain a success ExitStatus
469        let status = std::process::Command::new( "cmd" )
470          .args( [ "/C", "exit", "0" ] )
471          .status()
472          .map_err( |e| Error::msg( format!( "Failed to create dry-run status: {e}" ) ) )?;
473        return Ok( status );
474      }
475    }
476
477    let mut cmd = self.build_command();
478
479    if let Some( ref path ) = self.stdin_file
480    {
481      let file = std::fs::File::open( path )
482        .map_err( | e | Error::msg( format!( "cannot open stdin file '{}': {e}", path.display() ) ) )?;
483      cmd.stdin( std::process::Stdio::from( file ) );
484    }
485
486    // Fix(BUG-241): map NotFound to install hint (mirrors the fix in execute()).
487    // Root cause: same as execute() — Command::status() on a missing binary emits
488    //   a raw OS error string with no install guidance.
489    // Pitfall: both execute() and execute_interactive() must carry this fix; missing
490    //   one leaves interactive mode with an unhelpful message.
491    let status = cmd.status()
492      .map_err( |e|
493      {
494        if e.kind() == std::io::ErrorKind::NotFound
495        {
496          Error::msg( "claude binary not found in PATH — install with: npm i -g @anthropic-ai/claude-code" )
497        }
498        else
499        {
500          Error::msg( format!( "Failed to execute Claude Code: {e}" ) )
501        }
502      } )?;
503
504    Ok( status )
505  }
506
507  /// Build the Command instance with all configured parameters
508  ///
509  /// SINGLE EXECUTION POINT: This is the ONLY location where `Command::new("claude")` appears
510  ///
511  /// Pitfall: any typed-field CLI flag OR env manipulation added here MUST also
512  /// appear in `describe()` at the same relative position — otherwise dry-run/trace diverges.
513  /// Typed-field flags: `skip_permissions`, `chrome`, `continue_conversation` (see `describe()`).
514  /// Env manipulations: `unset_claudecode` → `cmd.env_remove("CLAUDECODE")` → shown as `env -u CLAUDECODE` prefix.
515  /// Flags pushed via `self.args` are automatically mirrored; only typed fields need manual sync.
516  #[inline]
517  fn build_command( &self ) -> std::process::Command {
518    use std::process::Command;
519
520    // SINGLE EXECUTION POINT: This is the ONLY location where `Command::new("claude")` appears
521    let mut cmd = Command::new( "claude" );
522
523    // Set working directory if provided
524    if let Some( ref dir ) = self.working_directory {
525      cmd.current_dir( dir );
526    }
527
528    // Set max output tokens (fixes token limit bug: 32K → 200K)
529    if let Some( tokens ) = self.max_output_tokens {
530      cmd.env( "CLAUDE_CODE_MAX_OUTPUT_TOKENS", tokens.to_string() );
531    }
532
533    // Tier 1: Critical parameters with different defaults
534    if let Some( timeout ) = self.bash_default_timeout_ms {
535      cmd.env( "CLAUDE_CODE_BASH_TIMEOUT", timeout.to_string() );
536    }
537
538    if let Some( max_timeout ) = self.bash_max_timeout_ms {
539      cmd.env( "CLAUDE_CODE_BASH_MAX_TIMEOUT", max_timeout.to_string() );
540    }
541
542    if let Some( auto_continue ) = self.auto_continue {
543      cmd.env( "CLAUDE_CODE_AUTO_CONTINUE", auto_continue.to_string() );
544    }
545
546    if let Some( telemetry ) = self.telemetry {
547      cmd.env( "CLAUDE_CODE_TELEMETRY", telemetry.to_string() );
548    }
549
550    // Tier 2: Essential parameters (security-sensitive)
551    if let Some( approve ) = self.auto_approve_tools {
552      cmd.env( "CLAUDE_CODE_AUTO_APPROVE_TOOLS", approve.to_string() );
553    }
554
555    if let Some( mode ) = self.action_mode {
556      cmd.env( "CLAUDE_CODE_ACTION_MODE", mode.as_str() );
557    }
558
559    if let Some( level ) = self.log_level {
560      cmd.env( "CLAUDE_CODE_LOG_LEVEL", level.as_str() );
561    }
562
563    if let Some( temp ) = self.temperature {
564      cmd.env( "CLAUDE_CODE_TEMPERATURE", temp.to_string() );
565    }
566
567    // Tier 3: Optional parameters
568    if let Some( sandbox ) = self.sandbox_mode {
569      cmd.env( "CLAUDE_CODE_SANDBOX_MODE", sandbox.to_string() );
570    }
571
572    if let Some( ref dir ) = self.session_dir {
573      cmd.env( "CLAUDE_CODE_SESSION_DIR", dir.to_string_lossy().as_ref() );
574    }
575
576    if let Some( top_p ) = self.top_p {
577      cmd.env( "CLAUDE_CODE_TOP_P", top_p.to_string() );
578    }
579
580    if let Some( top_k ) = self.top_k {
581      cmd.env( "CLAUDE_CODE_TOP_K", top_k.to_string() );
582    }
583
584    if let Some( ref home ) = self.home_override {
585      cmd.env( "HOME", home );
586    }
587
588    if self.unset_claudecode
589    {
590      cmd.env_remove( "CLAUDECODE" );
591    }
592
593    // Add skip-permissions flag before custom args
594    if self.skip_permissions {
595      cmd.arg( "--dangerously-skip-permissions" );
596    }
597
598    // Emit chrome flag from typed field (default: Some(true) → --chrome)
599    match self.chrome {
600      Some( true )  => { cmd.arg( "--chrome" ); }
601      Some( false ) => { cmd.arg( "--no-chrome" ); }
602      None          => {}
603    }
604
605    // Add custom arguments
606    for arg in &self.args {
607      cmd.arg( arg );
608    }
609
610    // Add continuation flag if requested
611    if self.continue_conversation {
612      cmd.arg( "-c" );
613    }
614
615    // Add message last if provided
616    if let Some( ref msg ) = self.message {
617      cmd.arg( msg );
618    }
619
620    cmd
621  }
622}
623
624/// Query installed Claude Code version.
625///
626/// Runs `claude --version` and returns trimmed stdout.
627/// Returns `None` if binary not found or produces no output.
628///
629/// # Examples
630///
631/// ```no_run
632/// if let Some( version ) = claude_runner_core::claude_version()
633/// {
634///   println!( "Claude Code version: {version}" );
635/// }
636/// ```
637#[ inline ]
638#[ must_use ]
639pub fn claude_version() -> Option< String >
640{
641  // Route through ClaudeCommand::execute() → build_command() to preserve the
642  // single-execution-point invariant (Command::new("claude") must appear exactly once).
643  // Fix(issue-claude-version-chrome): with_chrome(None) omits the --chrome flag;
644  // Root cause: ClaudeCommand::new() defaults chrome=Some(true) for automation use,
645  //             but version queries must not pass browser-context flags.
646  // Pitfall: Always override automation defaults for system-query functions.
647  let output = ClaudeCommand::new()
648    .with_chrome( None )
649    .with_args( [ "--version" ] )
650    .execute()
651    .ok()?;
652  let s = output.stdout.trim().to_string();
653  if s.is_empty() { None } else { Some( s ) }
654}
655
656impl Default for ClaudeCommand {
657  #[inline]
658  fn default() -> Self {
659    Self::new()
660  }
661}
662
663// ============================================================================
664// Subprocess spawning
665// ============================================================================
666
667impl ClaudeCommand {
668  /// Spawn the Claude Code process with piped stdout/stderr and return the `Child` handle.
669  ///
670  /// Unlike [`execute`](Self::execute), this method does **not** wait for the subprocess
671  /// to finish. The caller owns the `Child` and is responsible for calling
672  /// [`Child::wait`](std::process::Child::wait) or
673  /// [`Child::wait_with_output`](std::process::Child::wait_with_output).
674  ///
675  /// Used by `run_isolated()` to enable timeout-with-kill-and-partial-output handling.
676  ///
677  /// # Errors
678  ///
679  /// Returns `io::Error` on spawn failure. Check `e.kind() == ErrorKind::NotFound` to
680  /// detect a missing `claude` binary.
681  ///
682  /// # Example
683  ///
684  /// ```no_run
685  /// use claude_runner_core::ClaudeCommand;
686  ///
687  /// let mut child = ClaudeCommand::new()
688  ///   .with_message( "hello" )
689  ///   .spawn_piped()?;
690  /// let output = child.wait_with_output()?;
691  /// # Ok::<(), std::io::Error>(())
692  /// ```
693  #[ inline ]
694  pub fn spawn_piped( &self ) -> std::io::Result< std::process::Child >
695  {
696    use std::process::Stdio;
697    let mut cmd = self.build_command();
698    if let Some( ref path ) = self.stdin_file
699    {
700      let file = std::fs::File::open( path )?;
701      cmd.stdin( Stdio::from( file ) );
702    }
703    else
704    {
705      cmd.stdin( Stdio::null() );
706    }
707    cmd.stdout( Stdio::piped() );
708    cmd.stderr( Stdio::piped() );
709    cmd.spawn()
710  }
711
712  /// Spawn the Claude Code process with inherited TTY stdio and return the `Child` handle.
713  ///
714  /// Unlike [`spawn_piped`](Self::spawn_piped), stdout and stderr are inherited from the
715  /// parent process so Claude can use the terminal for interactive output. stdin is
716  /// either the provided `--file` content or inherited from the parent TTY.
717  ///
718  /// The caller owns the `Child` and is responsible for calling
719  /// [`Child::wait`](std::process::Child::wait) after killing or waiting for the process.
720  ///
721  /// Used by `run_interactive` in `claude_runner` when `--timeout > 0` to enable
722  /// watchdog-kill while preserving the full TTY experience.
723  ///
724  /// # Errors
725  ///
726  /// Returns `io::Error` on spawn failure. Check `e.kind() == ErrorKind::NotFound` to
727  /// detect a missing `claude` binary.
728  #[ inline ]
729  pub fn spawn_tty( &self ) -> std::io::Result< std::process::Child >
730  {
731    use std::process::Stdio;
732    let mut cmd = self.build_command();
733    if let Some( ref path ) = self.stdin_file
734    {
735      let file = std::fs::File::open( path )?;
736      cmd.stdin( Stdio::from( file ) );
737    }
738    // stdout and stderr inherit from parent (TTY passthrough) — no redirection needed.
739    cmd.spawn()
740  }
741}
742
743// ============================================================================
744// Testing Support
745// ============================================================================
746//
747// Note: Uses #[doc(hidden)] instead of #[cfg(test)] because integration tests
748// in tests/ directory need access to this method. Integration tests compile
749// against the public API and cannot see #[cfg(test)] items from the library.
750
751impl ClaudeCommand {
752  /// Test helper: Expose built Command for inspection
753  ///
754  /// **FOR TESTING ONLY** - This method allows integration tests to inspect
755  /// the constructed Command without executing it.
756  ///
757  /// # Why Public?
758  ///
759  /// Integration tests (in `tests/` directory) need this to verify command
760  /// construction. Cannot use `#[cfg(test)]` because integration tests compile
761  /// against the public API.
762  ///
763  /// # Do Not Use in Production
764  ///
765  /// This method is marked `#[doc(hidden)]` to prevent it from appearing in
766  /// public documentation. It should only be used by tests in this crate.
767  #[ doc( hidden ) ]
768  #[ inline ]
769  #[ must_use ]
770  pub fn build_command_for_test( &self ) -> std::process::Command {
771    self.build_command()
772  }
773}