Skip to main content

ralph_adapters/
cli_backend.rs

1//! CLI backend definitions for different AI tools.
2
3use ralph_core::{CliConfig, HatBackend};
4use std::fmt;
5use std::io::Write;
6use tempfile::NamedTempFile;
7
8/// Output format supported by a CLI backend.
9///
10/// This allows adapters to declare whether they emit structured JSON
11/// for real-time streaming or plain text output.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum OutputFormat {
14    /// Plain text output (default for most adapters)
15    #[default]
16    Text,
17    /// Newline-delimited JSON stream (Claude with --output-format stream-json)
18    StreamJson,
19    /// JSONL stream from Copilot prompt mode (`--output-format json`)
20    CopilotStreamJson,
21    /// Newline-delimited JSON stream (Pi with --mode json)
22    PiStreamJson,
23    /// Agent Client Protocol over stdio (Kiro v2)
24    Acp,
25}
26
27/// Error when creating a custom backend without a command.
28#[derive(Debug, Clone)]
29pub struct CustomBackendError;
30
31impl fmt::Display for CustomBackendError {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        write!(f, "custom backend requires a command to be specified")
34    }
35}
36
37impl std::error::Error for CustomBackendError {}
38
39/// How to pass prompts to the CLI tool.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PromptMode {
42    /// Pass prompt as a command-line argument.
43    Arg,
44    /// Write prompt to stdin.
45    Stdin,
46}
47
48/// A CLI backend configuration for executing prompts.
49#[derive(Debug, Clone)]
50pub struct CliBackend {
51    /// The command to execute.
52    pub command: String,
53    /// Additional arguments before the prompt.
54    pub args: Vec<String>,
55    /// How to pass the prompt.
56    pub prompt_mode: PromptMode,
57    /// Argument flag for prompt (if prompt_mode is Arg).
58    pub prompt_flag: Option<String>,
59    /// Output format emitted by this backend.
60    pub output_format: OutputFormat,
61    /// Environment variables to set when spawning the process.
62    pub env_vars: Vec<(String, String)>,
63}
64
65impl CliBackend {
66    /// Creates a backend from configuration.
67    ///
68    /// # Errors
69    /// Returns `CustomBackendError` if backend is "custom" but no command is specified.
70    pub fn from_config(config: &CliConfig) -> Result<Self, CustomBackendError> {
71        let mut backend = match config.backend.as_str() {
72            "claude" => Self::claude(),
73            "kiro" => Self::kiro(),
74            "kiro-acp" => Self::kiro_acp(),
75            "gemini" => Self::gemini(),
76            "codex" => Self::codex(),
77            "amp" => Self::amp(),
78            "copilot" => Self::copilot(),
79            "opencode" => Self::opencode(),
80            "pi" => Self::pi(),
81            "roo" => Self::roo(),
82            "custom" => return Self::custom(config),
83            _ => Self::claude(), // Default to claude
84        };
85
86        // Apply configured extra args for named backends too.
87        // This keeps ralph.yml `cli.args` consistent with CLI `-- ...` extra args behavior.
88        backend.args.extend(config.args.iter().cloned());
89        if backend.command == "codex" {
90            Self::reconcile_codex_args(&mut backend.args);
91        }
92
93        // Honor command override for named backends (e.g., custom binary path)
94        if let Some(ref cmd) = config.command {
95            backend.command = cmd.clone();
96        }
97
98        Ok(backend)
99    }
100
101    /// Creates the Claude backend.
102    ///
103    /// Uses `--print` for headless execution and sends the prompt over stdin.
104    /// This avoids Claude's large-prompt `-p` behavior, which can stall before
105    /// emitting any stream output when asked to read the real prompt from an
106    /// intermediate temp-file instruction.
107    ///
108    /// Emits `--output-format stream-json` for NDJSON streaming output.
109    /// Note: `--verbose` is required when using `--output-format stream-json`.
110    pub fn claude() -> Self {
111        Self {
112            command: "claude".to_string(),
113            args: vec![
114                "--dangerously-skip-permissions".to_string(),
115                "--verbose".to_string(),
116                "--output-format".to_string(),
117                "stream-json".to_string(),
118                "--print".to_string(),
119                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet".to_string(),
120            ],
121            prompt_mode: PromptMode::Stdin,
122            prompt_flag: None,
123            output_format: OutputFormat::StreamJson,
124            env_vars: vec![],
125        }
126    }
127
128    /// Creates the Claude backend for interactive prompt injection.
129    ///
130    /// Runs Claude without `-p` flag, passing prompt as a positional argument.
131    /// Used by SOP runner for interactive command injection.
132    ///
133    /// Note: This is NOT for TUI mode - Ralph's TUI uses the standard `claude()`
134    /// backend. This is for cases where Claude's interactive mode is needed.
135    /// Uses `=` syntax for `--disallowedTools` to prevent variadic consumption
136    /// of the positional prompt argument.
137    pub fn claude_interactive() -> Self {
138        Self {
139            command: "claude".to_string(),
140            args: vec![
141                "--dangerously-skip-permissions".to_string(),
142                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet".to_string(),
143            ],
144            prompt_mode: PromptMode::Arg,
145            prompt_flag: None,
146            output_format: OutputFormat::Text,
147            env_vars: vec![],
148        }
149    }
150
151    /// Creates the Kiro backend.
152    ///
153    /// Uses kiro-cli in headless mode with all tools trusted.
154    pub fn kiro() -> Self {
155        Self {
156            command: "kiro-cli".to_string(),
157            args: vec![
158                "chat".to_string(),
159                "--no-interactive".to_string(),
160                "--trust-all-tools".to_string(),
161            ],
162            prompt_mode: PromptMode::Arg,
163            prompt_flag: None,
164            output_format: OutputFormat::Text,
165            env_vars: vec![],
166        }
167    }
168
169    /// Creates the Kiro backend with a specific agent and optional extra args.
170    ///
171    /// Uses kiro-cli with --agent flag to select a specific agent.
172    pub fn kiro_with_agent(agent: String, extra_args: &[String]) -> Self {
173        let mut backend = Self {
174            command: "kiro-cli".to_string(),
175            args: vec![
176                "chat".to_string(),
177                "--no-interactive".to_string(),
178                "--trust-all-tools".to_string(),
179                "--agent".to_string(),
180                agent,
181            ],
182            prompt_mode: PromptMode::Arg,
183            prompt_flag: None,
184            output_format: OutputFormat::Text,
185            env_vars: vec![],
186        };
187        backend.args.extend(extra_args.iter().cloned());
188        backend
189    }
190
191    /// Creates the Kiro ACP backend.
192    ///
193    /// Uses kiro-cli with the ACP subcommand for structured JSON-RPC
194    /// communication over stdio instead of PTY text scraping.
195    pub fn kiro_acp() -> Self {
196        Self::kiro_acp_with_options(None, None)
197    }
198
199    /// Creates the Kiro ACP backend with an optional agent and/or model.
200    pub fn kiro_acp_with_options(agent: Option<&str>, model: Option<&str>) -> Self {
201        let mut args = vec!["acp".to_string()];
202        if let Some(name) = agent {
203            args.push("--agent".to_string());
204            args.push(name.to_string());
205        }
206        if let Some(m) = model {
207            args.push("--model".to_string());
208            args.push(m.to_string());
209        }
210        Self {
211            command: "kiro-cli".to_string(),
212            args,
213            prompt_mode: PromptMode::Stdin,
214            prompt_flag: None,
215            output_format: OutputFormat::Acp,
216            env_vars: vec![],
217        }
218    }
219
220    /// Creates a backend from a named backend with additional args.
221    ///
222    /// # Errors
223    /// Returns error if the backend name is invalid.
224    pub fn from_name_with_args(
225        name: &str,
226        extra_args: &[String],
227    ) -> Result<Self, CustomBackendError> {
228        let mut backend = Self::from_name(name)?;
229        backend.args.extend(extra_args.iter().cloned());
230        if backend.command == "codex" {
231            Self::reconcile_codex_args(&mut backend.args);
232        }
233        Ok(backend)
234    }
235
236    /// Creates a backend from a named backend string.
237    ///
238    /// # Errors
239    /// Returns error if the backend name is invalid.
240    pub fn from_name(name: &str) -> Result<Self, CustomBackendError> {
241        match name {
242            "claude" => Ok(Self::claude()),
243            "kiro" => Ok(Self::kiro()),
244            "kiro-acp" => Ok(Self::kiro_acp()),
245            "gemini" => Ok(Self::gemini()),
246            "codex" => Ok(Self::codex()),
247            "amp" => Ok(Self::amp()),
248            "copilot" => Ok(Self::copilot()),
249            "opencode" => Ok(Self::opencode()),
250            "pi" => Ok(Self::pi()),
251            "roo" => Ok(Self::roo()),
252            _ => Err(CustomBackendError),
253        }
254    }
255
256    /// Creates a backend from a HatBackend configuration.
257    ///
258    /// # Errors
259    /// Returns error if the backend configuration is invalid.
260    pub fn from_hat_backend(hat_backend: &HatBackend) -> Result<Self, CustomBackendError> {
261        match hat_backend {
262            HatBackend::Named(name) => Self::from_name(name),
263            HatBackend::NamedWithArgs { backend_type, args } => {
264                Self::from_name_with_args(backend_type, args)
265            }
266            HatBackend::KiroAgent {
267                backend_type,
268                agent,
269                args,
270            } => {
271                if backend_type == "kiro-acp" {
272                    Ok(Self::kiro_acp_with_options(Some(agent), None))
273                } else {
274                    Ok(Self::kiro_with_agent(agent.clone(), args))
275                }
276            }
277            HatBackend::Custom { command, args } => Ok(Self {
278                command: command.clone(),
279                args: args.clone(),
280                prompt_mode: PromptMode::Arg,
281                prompt_flag: None,
282                output_format: OutputFormat::Text,
283                env_vars: vec![],
284            }),
285        }
286    }
287
288    /// Creates the Gemini backend.
289    pub fn gemini() -> Self {
290        Self {
291            command: "gemini".to_string(),
292            args: vec!["--yolo".to_string()],
293            prompt_mode: PromptMode::Arg,
294            prompt_flag: Some("-p".to_string()),
295            output_format: OutputFormat::Text,
296            env_vars: vec![],
297        }
298    }
299
300    /// Creates the Codex backend.
301    pub fn codex() -> Self {
302        Self {
303            command: "codex".to_string(),
304            args: vec!["exec".to_string(), "--yolo".to_string()],
305            prompt_mode: PromptMode::Arg,
306            prompt_flag: None, // Positional argument
307            output_format: OutputFormat::Text,
308            env_vars: vec![],
309        }
310    }
311
312    /// Creates the Amp backend.
313    pub fn amp() -> Self {
314        Self {
315            command: "amp".to_string(),
316            args: vec!["--dangerously-allow-all".to_string()],
317            prompt_mode: PromptMode::Arg,
318            prompt_flag: Some("-x".to_string()),
319            output_format: OutputFormat::Text,
320            env_vars: vec![],
321        }
322    }
323
324    /// Creates the Copilot backend for autonomous mode.
325    ///
326    /// Uses GitHub Copilot CLI with `--allow-all-tools` for automated tool approval.
327    /// Prompt mode emits JSONL via `--output-format json` for programmatic parsing.
328    pub fn copilot() -> Self {
329        Self {
330            command: "copilot".to_string(),
331            args: vec![
332                "--allow-all-tools".to_string(),
333                "--output-format".to_string(),
334                "json".to_string(),
335            ],
336            prompt_mode: PromptMode::Arg,
337            prompt_flag: Some("-p".to_string()),
338            output_format: OutputFormat::CopilotStreamJson,
339            env_vars: vec![],
340        }
341    }
342
343    /// Creates the Copilot TUI backend for interactive mode.
344    ///
345    /// Runs Copilot in full interactive mode (no -p flag), allowing
346    /// Copilot's native TUI to render. The prompt is passed as a
347    /// positional argument.
348    pub fn copilot_tui() -> Self {
349        Self {
350            command: "copilot".to_string(),
351            args: vec![], // No --allow-all-tools in TUI mode
352            prompt_mode: PromptMode::Arg,
353            prompt_flag: None, // Positional argument
354            output_format: OutputFormat::Text,
355            env_vars: vec![],
356        }
357    }
358
359    /// Creates the Claude interactive backend with Agent Teams support.
360    ///
361    /// Like `claude_interactive()` but with reduced `--disallowedTools` (only `TodoWrite`)
362    /// and `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var.
363    pub fn claude_interactive_teams() -> Self {
364        Self {
365            command: "claude".to_string(),
366            args: vec![
367                "--dangerously-skip-permissions".to_string(),
368                "--disallowedTools=TodoWrite".to_string(),
369            ],
370            prompt_mode: PromptMode::Arg,
371            prompt_flag: None,
372            output_format: OutputFormat::Text,
373            env_vars: vec![(
374                "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS".to_string(),
375                "1".to_string(),
376            )],
377        }
378    }
379
380    /// Creates a backend configured for interactive mode with initial prompt.
381    ///
382    /// This factory method returns the correct backend configuration for running
383    /// an interactive session with an initial prompt. The key differences from
384    /// headless mode are:
385    ///
386    /// | Backend | Interactive + Prompt |
387    /// |---------|---------------------|
388    /// | Claude  | positional arg (no `-p` flag) |
389    /// | Kiro    | removes `--no-interactive` |
390    /// | Gemini  | uses `-i` instead of `-p` |
391    /// | Codex   | no `exec` subcommand |
392    /// | Amp     | removes `--dangerously-allow-all` |
393    /// | Copilot | removes `--allow-all-tools` |
394    /// | OpenCode| `run` subcommand with positional prompt |
395    ///
396    /// # Errors
397    /// Returns `CustomBackendError` if the backend name is not recognized.
398    pub fn for_interactive_prompt(backend_name: &str) -> Result<Self, CustomBackendError> {
399        match backend_name {
400            "claude" => Ok(Self::claude_interactive()),
401            "kiro" => Ok(Self::kiro_interactive()),
402            "gemini" => Ok(Self::gemini_interactive()),
403            "codex" => Ok(Self::codex_interactive()),
404            "amp" => Ok(Self::amp_interactive()),
405            "copilot" => Ok(Self::copilot_interactive()),
406            "opencode" => Ok(Self::opencode_interactive()),
407            "pi" => Ok(Self::pi_interactive()),
408            "roo" => Ok(Self::roo_interactive()),
409            _ => Err(CustomBackendError),
410        }
411    }
412
413    /// Kiro in interactive mode (removes --no-interactive).
414    ///
415    /// Unlike headless `kiro()`, this allows the user to interact with
416    /// Kiro's TUI while still passing an initial prompt.
417    pub fn kiro_interactive() -> Self {
418        Self {
419            command: "kiro-cli".to_string(),
420            args: vec!["chat".to_string(), "--trust-all-tools".to_string()],
421            prompt_mode: PromptMode::Arg,
422            prompt_flag: None,
423            output_format: OutputFormat::Text,
424            env_vars: vec![],
425        }
426    }
427
428    /// Gemini in interactive mode with initial prompt (uses -i, not -p).
429    ///
430    /// **Critical quirk**: Gemini requires `-i` flag for interactive+prompt mode.
431    /// Using `-p` would make it run headless and exit after one response.
432    pub fn gemini_interactive() -> Self {
433        Self {
434            command: "gemini".to_string(),
435            args: vec!["--yolo".to_string()],
436            prompt_mode: PromptMode::Arg,
437            prompt_flag: Some("-i".to_string()), // NOT -p!
438            output_format: OutputFormat::Text,
439            env_vars: vec![],
440        }
441    }
442
443    /// Codex in interactive TUI mode (no exec subcommand).
444    ///
445    /// Unlike headless `codex()`, this runs without `exec` and `--full-auto`
446    /// flags, allowing interactive TUI mode.
447    pub fn codex_interactive() -> Self {
448        Self {
449            command: "codex".to_string(),
450            args: vec![], // No exec, no --full-auto
451            prompt_mode: PromptMode::Arg,
452            prompt_flag: None, // Positional argument
453            output_format: OutputFormat::Text,
454            env_vars: vec![],
455        }
456    }
457
458    /// Amp in interactive mode (removes --dangerously-allow-all).
459    ///
460    /// Unlike headless `amp()`, this runs without the auto-approve flag,
461    /// requiring user confirmation for tool usage.
462    pub fn amp_interactive() -> Self {
463        Self {
464            command: "amp".to_string(),
465            args: vec![],
466            prompt_mode: PromptMode::Arg,
467            prompt_flag: Some("-x".to_string()),
468            output_format: OutputFormat::Text,
469            env_vars: vec![],
470        }
471    }
472
473    /// Copilot in interactive mode (removes --allow-all-tools).
474    ///
475    /// Unlike headless `copilot()`, this runs without the auto-approve flag,
476    /// requiring user confirmation for tool usage.
477    pub fn copilot_interactive() -> Self {
478        Self {
479            command: "copilot".to_string(),
480            args: vec![],
481            prompt_mode: PromptMode::Arg,
482            prompt_flag: Some("-p".to_string()),
483            output_format: OutputFormat::Text,
484            env_vars: vec![],
485        }
486    }
487
488    /// Creates the OpenCode backend for autonomous mode.
489    ///
490    /// Uses OpenCode CLI with `run` subcommand. The prompt is passed as a
491    /// positional argument after the subcommand:
492    /// ```bash
493    /// opencode run "prompt text here"
494    /// ```
495    ///
496    /// Output is plain text (no JSON streaming available).
497    pub fn opencode() -> Self {
498        Self {
499            command: "opencode".to_string(),
500            args: vec!["run".to_string()],
501            prompt_mode: PromptMode::Arg,
502            prompt_flag: None, // Positional argument
503            output_format: OutputFormat::Text,
504            env_vars: vec![],
505        }
506    }
507
508    /// Creates the OpenCode TUI backend for interactive mode.
509    ///
510    /// Runs OpenCode with `run` subcommand. The prompt is passed as a
511    /// positional argument:
512    /// ```bash
513    /// opencode run "prompt text here"
514    /// ```
515    pub fn opencode_tui() -> Self {
516        Self {
517            command: "opencode".to_string(),
518            args: vec!["run".to_string()],
519            prompt_mode: PromptMode::Arg,
520            prompt_flag: None, // Positional argument
521            output_format: OutputFormat::Text,
522            env_vars: vec![],
523        }
524    }
525
526    /// OpenCode in interactive TUI mode.
527    ///
528    /// Runs OpenCode TUI with an initial prompt via `--prompt` flag:
529    /// ```bash
530    /// opencode --prompt "prompt text here"
531    /// ```
532    ///
533    /// Unlike `opencode()` which uses `opencode run` (headless mode),
534    /// this launches the interactive TUI and injects the prompt.
535    pub fn opencode_interactive() -> Self {
536        Self {
537            command: "opencode".to_string(),
538            args: vec![],
539            prompt_mode: PromptMode::Arg,
540            prompt_flag: Some("--prompt".to_string()),
541            output_format: OutputFormat::Text,
542            env_vars: vec![],
543        }
544    }
545
546    /// Creates the Pi backend for headless execution.
547    ///
548    /// Uses `-p` for print mode with `--mode json` for NDJSON streaming output.
549    /// Emits `PiStreamJson` output format for structured event parsing.
550    pub fn pi() -> Self {
551        Self {
552            command: "pi".to_string(),
553            args: vec![
554                "-p".to_string(),
555                "--mode".to_string(),
556                "json".to_string(),
557                "--no-session".to_string(),
558            ],
559            prompt_mode: PromptMode::Arg,
560            prompt_flag: None, // Positional argument
561            output_format: OutputFormat::PiStreamJson,
562            env_vars: vec![],
563        }
564    }
565
566    /// Creates the Pi backend for interactive mode with initial prompt.
567    ///
568    /// Runs pi TUI without `-p` or `--mode json`, passing the prompt as a
569    /// positional argument. Used by `ralph plan` for interactive sessions.
570    pub fn pi_interactive() -> Self {
571        Self {
572            command: "pi".to_string(),
573            args: vec!["--no-session".to_string()],
574            prompt_mode: PromptMode::Arg,
575            prompt_flag: None, // Positional argument
576            output_format: OutputFormat::Text,
577            env_vars: vec![],
578        }
579    }
580
581    /// Creates the Roo backend for headless execution.
582    ///
583    /// Uses `--print` for non-interactive output and `--ephemeral` for clean
584    /// disk state. Prompts are always passed via `--prompt-file` (handled in
585    /// `build_command()`). Roo auto-approves tools by default, so no
586    /// `--trust-all-tools` equivalent is needed.
587    pub fn roo() -> Self {
588        Self {
589            command: "roo".to_string(),
590            args: vec!["--print".to_string(), "--ephemeral".to_string()],
591            prompt_mode: PromptMode::Arg,
592            prompt_flag: None,
593            output_format: OutputFormat::Text,
594            env_vars: vec![],
595        }
596    }
597
598    /// Creates the Roo backend for interactive mode with initial prompt.
599    ///
600    /// Runs roo TUI without `--print` or `--ephemeral`, passing the prompt
601    /// as a positional argument. Used by `ralph plan` for interactive sessions.
602    pub fn roo_interactive() -> Self {
603        Self {
604            command: "roo".to_string(),
605            args: vec![],
606            prompt_mode: PromptMode::Arg,
607            prompt_flag: None,
608            output_format: OutputFormat::Text,
609            env_vars: vec![],
610        }
611    }
612
613    /// Creates a custom backend from configuration.
614    ///
615    /// # Errors
616    /// Returns `CustomBackendError` if no command is specified.
617    pub fn custom(config: &CliConfig) -> Result<Self, CustomBackendError> {
618        let command = config.command.clone().ok_or(CustomBackendError)?;
619        let prompt_mode = if config.prompt_mode == "stdin" {
620            PromptMode::Stdin
621        } else {
622            PromptMode::Arg
623        };
624
625        Ok(Self {
626            command,
627            args: config.args.clone(),
628            prompt_mode,
629            prompt_flag: config.prompt_flag.clone(),
630            output_format: OutputFormat::Text,
631            env_vars: vec![],
632        })
633    }
634
635    /// Builds roo prompt-file args: writes prompt to a temp file and
636    /// appends `--prompt-file <path>` to args. Falls back to positional
637    /// arg if temp file creation fails.
638    fn build_roo_prompt_file(
639        args: &mut Vec<String>,
640        prompt: &str,
641    ) -> (Option<String>, Option<NamedTempFile>) {
642        match NamedTempFile::new() {
643            Ok(mut file) => {
644                if let Err(e) = file.write_all(prompt.as_bytes()) {
645                    tracing::warn!("Failed to write roo prompt to temp file: {}", e);
646                    args.push(prompt.to_string());
647                    (None, None)
648                } else {
649                    args.push("--prompt-file".to_string());
650                    args.push(file.path().display().to_string());
651                    (None, Some(file))
652                }
653            }
654            Err(e) => {
655                tracing::warn!("Failed to create temp file for roo: {}", e);
656                args.push(prompt.to_string());
657                (None, None)
658            }
659        }
660    }
661
662    /// Builds the command for PTY (non-interactive) execution.
663    ///
664    /// Forces arg mode to avoid PTY line-discipline deadlocks on large prompts.
665    /// The PTY canonical input buffer (~4 KB) cannot handle 30-50 KB+ prompts
666    /// delivered via stdin. Instead, the prompt is passed as a command argument
667    /// (with temp-file indirection for prompts over 7000 chars).  See #280.
668    pub fn build_command_pty(
669        &self,
670        prompt: &str,
671    ) -> (String, Vec<String>, Option<String>, Option<NamedTempFile>) {
672        if self.prompt_mode == PromptMode::Stdin {
673            // Convert stdin-mode to arg-mode for PTY safety
674            let mut pty_backend = self.clone();
675            pty_backend.prompt_mode = PromptMode::Arg;
676            // Use -p flag for Claude when forcing arg mode
677            if pty_backend.prompt_flag.is_none() {
678                pty_backend.prompt_flag = Some("-p".to_string());
679            }
680            pty_backend.build_command(prompt, false)
681        } else {
682            self.build_command(prompt, false)
683        }
684    }
685
686    /// Builds the full command with arguments for execution.
687    ///
688    /// # Arguments
689    /// * `prompt` - The prompt text to pass to the agent
690    /// * `interactive` - Whether to run in interactive mode (affects agent flags)
691    pub fn build_command(
692        &self,
693        prompt: &str,
694        interactive: bool,
695    ) -> (String, Vec<String>, Option<String>, Option<NamedTempFile>) {
696        let mut args = self.args.clone();
697
698        // Filter args based on execution mode per interactive-mode.spec.md
699        if interactive {
700            args = self.filter_args_for_interactive(args);
701        }
702
703        // Handle prompt passing: Roo uses --prompt-file, all others use temp file for large prompts
704        let (stdin_input, temp_file) = match self.prompt_mode {
705            PromptMode::Arg => {
706                // Roo headless: always use --prompt-file for all prompts
707                // Only headless roo() has --print in args; roo_interactive() does not
708                if self.command == "roo" && args.contains(&"--print".to_string()) {
709                    Self::build_roo_prompt_file(&mut args, prompt)
710                } else {
711                    // Use temp file for large prompts (>7000 chars) to avoid shell ARG_MAX limits
712                    let (prompt_text, temp_file) = if prompt.len() > 7000 {
713                        match NamedTempFile::new() {
714                            Ok(mut file) => {
715                                if let Err(e) = file.write_all(prompt.as_bytes()) {
716                                    tracing::warn!("Failed to write prompt to temp file: {}", e);
717                                    (prompt.to_string(), None)
718                                } else {
719                                    let path = file.path().display().to_string();
720                                    (
721                                        format!("Please read and execute the task in {}", path),
722                                        Some(file),
723                                    )
724                                }
725                            }
726                            Err(e) => {
727                                tracing::warn!("Failed to create temp file: {}", e);
728                                (prompt.to_string(), None)
729                            }
730                        }
731                    } else {
732                        (prompt.to_string(), None)
733                    };
734
735                    if let Some(ref flag) = self.prompt_flag {
736                        args.push(flag.clone());
737                    }
738                    args.push(prompt_text);
739                    (None, temp_file)
740                }
741            }
742            PromptMode::Stdin => (Some(prompt.to_string()), None),
743        };
744
745        // Log the full command being built
746        tracing::debug!(
747            command = %self.command,
748            args_count = args.len(),
749            prompt_len = prompt.len(),
750            interactive = interactive,
751            uses_stdin = stdin_input.is_some(),
752            uses_temp_file = temp_file.is_some(),
753            "Built CLI command"
754        );
755        // Log full prompt at trace level for debugging
756        tracing::trace!(prompt = %prompt, "Full prompt content");
757
758        (self.command.clone(), args, stdin_input, temp_file)
759    }
760
761    /// Filters args for interactive mode per spec table.
762    fn filter_args_for_interactive(&self, args: Vec<String>) -> Vec<String> {
763        match self.command.as_str() {
764            "kiro-cli" => args
765                .into_iter()
766                .filter(|a| a != "--no-interactive")
767                .collect(),
768            "codex" => args.into_iter().filter(|a| a != "--full-auto").collect(),
769            "amp" => args
770                .into_iter()
771                .filter(|a| a != "--dangerously-allow-all")
772                .collect(),
773            "copilot" => args
774                .into_iter()
775                .filter(|a| a != "--allow-all-tools")
776                .collect(),
777            "claude" => args.into_iter().filter(|a| a != "--print").collect(),
778            "roo" => args
779                .into_iter()
780                .filter(|a| a != "--print" && a != "--ephemeral")
781                .collect(),
782            _ => args, // gemini, opencode unchanged
783        }
784    }
785
786    fn reconcile_codex_args(args: &mut Vec<String>) {
787        let had_dangerous_bypass = args
788            .iter()
789            .any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox");
790        if had_dangerous_bypass {
791            args.retain(|arg| arg != "--dangerously-bypass-approvals-and-sandbox");
792            if !args.iter().any(|arg| arg == "--yolo") {
793                if let Some(pos) = args.iter().position(|arg| arg == "exec") {
794                    args.insert(pos + 1, "--yolo".to_string());
795                } else {
796                    args.push("--yolo".to_string());
797                }
798            }
799        }
800
801        if args.iter().any(|arg| arg == "--yolo") {
802            args.retain(|arg| arg != "--full-auto");
803            // Collapse duplicate --yolo entries to a single flag.
804            let mut seen_yolo = false;
805            args.retain(|arg| {
806                if arg == "--yolo" {
807                    if seen_yolo {
808                        return false;
809                    }
810                    seen_yolo = true;
811                }
812                true
813            });
814            if !seen_yolo {
815                if let Some(pos) = args.iter().position(|arg| arg == "exec") {
816                    args.insert(pos + 1, "--yolo".to_string());
817                } else {
818                    args.push("--yolo".to_string());
819                }
820            }
821        }
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    #[test]
830    fn test_claude_backend() {
831        let backend = CliBackend::claude();
832        let (cmd, args, stdin, temp) = backend.build_command("test prompt", false);
833
834        assert_eq!(cmd, "claude");
835        assert_eq!(
836            args,
837            vec![
838                "--dangerously-skip-permissions",
839                "--verbose",
840                "--output-format",
841                "stream-json",
842                "--print",
843                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet",
844            ]
845        );
846        assert_eq!(stdin, Some("test prompt".to_string()));
847        assert!(temp.is_none());
848        assert_eq!(backend.output_format, OutputFormat::StreamJson);
849    }
850
851    #[test]
852    fn test_claude_interactive_backend() {
853        let backend = CliBackend::claude_interactive();
854        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
855
856        assert_eq!(cmd, "claude");
857        // Should have --dangerously-skip-permissions, --disallowedTools=..., and prompt as positional arg
858        // No -p flag, no --output-format, no --verbose
859        // Uses = syntax to prevent variadic consumption of the prompt
860        assert_eq!(
861            args,
862            vec![
863                "--dangerously-skip-permissions",
864                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet",
865                "test prompt"
866            ]
867        );
868        assert!(stdin.is_none()); // Uses positional arg, not stdin
869        assert_eq!(backend.output_format, OutputFormat::Text);
870        assert_eq!(backend.prompt_flag, None);
871    }
872
873    #[test]
874    fn test_claude_large_prompt_uses_stdin_not_temp_file() {
875        let backend = CliBackend::claude();
876        let large_prompt = "x".repeat(7001);
877        let (cmd, args, stdin, temp) = backend.build_command(&large_prompt, false);
878
879        assert_eq!(cmd, "claude");
880        assert!(args.contains(&"--print".to_string()));
881        assert_eq!(stdin, Some(large_prompt));
882        assert!(temp.is_none());
883    }
884
885    /// Regression test for #280: build_command_pty converts Claude's stdin mode
886    /// to arg mode so large prompts don't deadlock the PTY line discipline.
887    #[test]
888    fn test_claude_build_command_pty_uses_arg_mode() {
889        let backend = CliBackend::claude();
890        let large_prompt = "x".repeat(7001);
891        let (cmd, args, stdin, temp) = backend.build_command_pty(&large_prompt);
892
893        assert_eq!(cmd, "claude");
894        // --print should still be present (headless mode flag)
895        assert!(args.contains(&"--print".to_string()));
896        // stdin should be None — prompt delivered via arg, not PTY stdin
897        assert!(stdin.is_none(), "PTY mode should not use stdin");
898        // Large prompt should use temp file
899        assert!(
900            temp.is_some(),
901            "Large prompt in PTY mode should use temp file"
902        );
903        assert!(args.iter().any(|a| a.contains("Please read and execute")));
904    }
905
906    #[test]
907    fn test_claude_build_command_pty_small_prompt_uses_arg_directly() {
908        let backend = CliBackend::claude();
909        let (cmd, args, stdin, temp) = backend.build_command_pty("small prompt");
910
911        assert_eq!(cmd, "claude");
912        assert!(args.contains(&"--print".to_string()));
913        assert!(stdin.is_none());
914        assert!(temp.is_none());
915        // The prompt should be a direct arg with -p flag
916        assert!(args.contains(&"-p".to_string()));
917        assert!(args.contains(&"small prompt".to_string()));
918    }
919
920    #[test]
921    fn test_non_claude_large_prompt() {
922        let backend = CliBackend::kiro();
923        let large_prompt = "x".repeat(7001);
924        let (cmd, args, _stdin, temp) = backend.build_command(&large_prompt, false);
925
926        assert_eq!(cmd, "kiro-cli");
927        assert!(temp.is_some());
928        assert!(args.iter().any(|a| a.contains("Please read and execute")));
929    }
930
931    #[test]
932    fn test_kiro_backend() {
933        let backend = CliBackend::kiro();
934        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
935
936        assert_eq!(cmd, "kiro-cli");
937        assert_eq!(
938            args,
939            vec![
940                "chat",
941                "--no-interactive",
942                "--trust-all-tools",
943                "test prompt"
944            ]
945        );
946        assert!(stdin.is_none());
947    }
948
949    #[test]
950    fn test_gemini_backend() {
951        let backend = CliBackend::gemini();
952        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
953
954        assert_eq!(cmd, "gemini");
955        assert_eq!(args, vec!["--yolo", "-p", "test prompt"]);
956        assert!(stdin.is_none());
957    }
958
959    #[test]
960    fn test_codex_backend() {
961        let backend = CliBackend::codex();
962        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
963
964        assert_eq!(cmd, "codex");
965        assert_eq!(args, vec!["exec", "--yolo", "test prompt"]);
966        assert!(stdin.is_none());
967    }
968
969    #[test]
970    fn test_codex_large_prompt_uses_temp_file() {
971        let backend = CliBackend::codex();
972        let large_prompt = "x".repeat(7001);
973        let (cmd, args, _stdin, temp) = backend.build_command(&large_prompt, false);
974
975        assert_eq!(cmd, "codex");
976        assert!(temp.is_some());
977        assert!(args.iter().any(|a| a.contains("Please read and execute")));
978    }
979
980    #[test]
981    fn test_amp_backend() {
982        let backend = CliBackend::amp();
983        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
984
985        assert_eq!(cmd, "amp");
986        assert_eq!(args, vec!["--dangerously-allow-all", "-x", "test prompt"]);
987        assert!(stdin.is_none());
988    }
989
990    #[test]
991    fn test_copilot_backend() {
992        let backend = CliBackend::copilot();
993        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
994
995        assert_eq!(cmd, "copilot");
996        assert_eq!(
997            args,
998            vec![
999                "--allow-all-tools",
1000                "--output-format",
1001                "json",
1002                "-p",
1003                "test prompt"
1004            ]
1005        );
1006        assert!(stdin.is_none());
1007        assert_eq!(backend.output_format, OutputFormat::CopilotStreamJson);
1008    }
1009
1010    #[test]
1011    fn test_copilot_tui_backend() {
1012        let backend = CliBackend::copilot_tui();
1013        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1014
1015        assert_eq!(cmd, "copilot");
1016        // Should have prompt as positional arg, no -p flag, no --allow-all-tools
1017        assert_eq!(args, vec!["test prompt"]);
1018        assert!(stdin.is_none());
1019        assert_eq!(backend.output_format, OutputFormat::Text);
1020        assert_eq!(backend.prompt_flag, None);
1021    }
1022
1023    #[test]
1024    fn test_from_config() {
1025        let config = CliConfig {
1026            backend: "claude".to_string(),
1027            command: None,
1028            prompt_mode: "arg".to_string(),
1029            ..Default::default()
1030        };
1031        let backend = CliBackend::from_config(&config).unwrap();
1032
1033        assert_eq!(backend.command, "claude");
1034        assert_eq!(backend.prompt_mode, PromptMode::Stdin);
1035        assert_eq!(backend.prompt_flag, None);
1036        assert!(backend.args.contains(&"--print".to_string()));
1037    }
1038
1039    #[test]
1040    fn test_from_config_command_override() {
1041        let config = CliConfig {
1042            backend: "claude".to_string(),
1043            command: Some("my-custom-claude".to_string()),
1044            prompt_mode: "arg".to_string(),
1045            ..Default::default()
1046        };
1047        let backend = CliBackend::from_config(&config).unwrap();
1048
1049        assert_eq!(backend.command, "my-custom-claude");
1050        assert_eq!(backend.prompt_flag, None);
1051        assert_eq!(backend.prompt_mode, PromptMode::Stdin);
1052        assert!(backend.args.contains(&"--print".to_string()));
1053        assert_eq!(backend.output_format, OutputFormat::StreamJson);
1054    }
1055
1056    #[test]
1057    fn test_kiro_interactive_mode_omits_no_interactive_flag() {
1058        let backend = CliBackend::kiro();
1059        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
1060
1061        assert_eq!(cmd, "kiro-cli");
1062        assert_eq!(args, vec!["chat", "--trust-all-tools", "test prompt"]);
1063        assert!(stdin.is_none());
1064        assert!(!args.contains(&"--no-interactive".to_string()));
1065    }
1066
1067    #[test]
1068    fn test_codex_interactive_mode_omits_full_auto() {
1069        let backend = CliBackend::codex();
1070        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
1071
1072        assert_eq!(cmd, "codex");
1073        assert_eq!(args, vec!["exec", "--yolo", "test prompt"]);
1074        assert!(stdin.is_none());
1075        assert!(!args.contains(&"--full-auto".to_string()));
1076    }
1077
1078    #[test]
1079    fn test_amp_interactive_mode_no_flags() {
1080        let backend = CliBackend::amp();
1081        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
1082
1083        assert_eq!(cmd, "amp");
1084        assert_eq!(args, vec!["-x", "test prompt"]);
1085        assert!(stdin.is_none());
1086        assert!(!args.contains(&"--dangerously-allow-all".to_string()));
1087    }
1088
1089    #[test]
1090    fn test_copilot_interactive_mode_omits_allow_all_tools() {
1091        let backend = CliBackend::copilot();
1092        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
1093
1094        assert_eq!(cmd, "copilot");
1095        assert_eq!(args, vec!["--output-format", "json", "-p", "test prompt"]);
1096        assert!(stdin.is_none());
1097        assert!(!args.contains(&"--allow-all-tools".to_string()));
1098    }
1099
1100    #[test]
1101    fn test_claude_interactive_mode_omits_print() {
1102        let backend = CliBackend::claude();
1103        let (cmd, args_auto, stdin_auto, _) = backend.build_command("test prompt", false);
1104        let (_, args_interactive, stdin_interactive, _) =
1105            backend.build_command("test prompt", true);
1106
1107        assert_eq!(cmd, "claude");
1108        assert!(args_auto.contains(&"--print".to_string()));
1109        assert!(!args_interactive.contains(&"--print".to_string()));
1110        assert_eq!(
1111            args_interactive,
1112            vec![
1113                "--dangerously-skip-permissions",
1114                "--verbose",
1115                "--output-format",
1116                "stream-json",
1117                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet",
1118            ]
1119        );
1120        assert_eq!(stdin_auto, Some("test prompt".to_string()));
1121        assert_eq!(stdin_interactive, Some("test prompt".to_string()));
1122    }
1123
1124    #[test]
1125    fn test_gemini_interactive_mode_unchanged() {
1126        let backend = CliBackend::gemini();
1127        let (cmd, args_auto, stdin_auto, _) = backend.build_command("test prompt", false);
1128        let (_, args_interactive, stdin_interactive, _) =
1129            backend.build_command("test prompt", true);
1130
1131        assert_eq!(cmd, "gemini");
1132        assert_eq!(args_auto, args_interactive);
1133        assert_eq!(args_auto, vec!["--yolo", "-p", "test prompt"]);
1134        assert_eq!(stdin_auto, stdin_interactive);
1135        assert!(stdin_auto.is_none());
1136    }
1137
1138    #[test]
1139    fn test_custom_backend_with_prompt_flag_short() {
1140        let config = CliConfig {
1141            backend: "custom".to_string(),
1142            command: Some("my-agent".to_string()),
1143            prompt_mode: "arg".to_string(),
1144            prompt_flag: Some("-p".to_string()),
1145            ..Default::default()
1146        };
1147        let backend = CliBackend::from_config(&config).unwrap();
1148        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1149
1150        assert_eq!(cmd, "my-agent");
1151        assert_eq!(args, vec!["-p", "test prompt"]);
1152        assert!(stdin.is_none());
1153    }
1154
1155    #[test]
1156    fn test_custom_backend_with_prompt_flag_long() {
1157        let config = CliConfig {
1158            backend: "custom".to_string(),
1159            command: Some("my-agent".to_string()),
1160            prompt_mode: "arg".to_string(),
1161            prompt_flag: Some("--prompt".to_string()),
1162            ..Default::default()
1163        };
1164        let backend = CliBackend::from_config(&config).unwrap();
1165        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1166
1167        assert_eq!(cmd, "my-agent");
1168        assert_eq!(args, vec!["--prompt", "test prompt"]);
1169        assert!(stdin.is_none());
1170    }
1171
1172    #[test]
1173    fn test_custom_backend_without_prompt_flag_positional() {
1174        let config = CliConfig {
1175            backend: "custom".to_string(),
1176            command: Some("my-agent".to_string()),
1177            prompt_mode: "arg".to_string(),
1178            prompt_flag: None,
1179            ..Default::default()
1180        };
1181        let backend = CliBackend::from_config(&config).unwrap();
1182        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1183
1184        assert_eq!(cmd, "my-agent");
1185        assert_eq!(args, vec!["test prompt"]);
1186        assert!(stdin.is_none());
1187    }
1188
1189    #[test]
1190    fn test_custom_backend_without_command_returns_error() {
1191        let config = CliConfig {
1192            backend: "custom".to_string(),
1193            command: None,
1194            prompt_mode: "arg".to_string(),
1195            ..Default::default()
1196        };
1197        let result = CliBackend::from_config(&config);
1198
1199        assert!(result.is_err());
1200        let err = result.unwrap_err();
1201        assert_eq!(
1202            err.to_string(),
1203            "custom backend requires a command to be specified"
1204        );
1205    }
1206
1207    #[test]
1208    fn test_kiro_with_agent() {
1209        let backend = CliBackend::kiro_with_agent("my-agent".to_string(), &[]);
1210        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1211
1212        assert_eq!(cmd, "kiro-cli");
1213        assert_eq!(
1214            args,
1215            vec![
1216                "chat",
1217                "--no-interactive",
1218                "--trust-all-tools",
1219                "--agent",
1220                "my-agent",
1221                "test prompt"
1222            ]
1223        );
1224        assert!(stdin.is_none());
1225    }
1226
1227    #[test]
1228    fn test_kiro_with_agent_extra_args() {
1229        let extra_args = vec!["--verbose".to_string(), "--debug".to_string()];
1230        let backend = CliBackend::kiro_with_agent("my-agent".to_string(), &extra_args);
1231        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1232
1233        assert_eq!(cmd, "kiro-cli");
1234        assert_eq!(
1235            args,
1236            vec![
1237                "chat",
1238                "--no-interactive",
1239                "--trust-all-tools",
1240                "--agent",
1241                "my-agent",
1242                "--verbose",
1243                "--debug",
1244                "test prompt"
1245            ]
1246        );
1247        assert!(stdin.is_none());
1248    }
1249
1250    #[test]
1251    fn test_from_name_claude() {
1252        let backend = CliBackend::from_name("claude").unwrap();
1253        assert_eq!(backend.command, "claude");
1254        assert_eq!(backend.prompt_mode, PromptMode::Stdin);
1255        assert_eq!(backend.prompt_flag, None);
1256        assert!(backend.args.contains(&"--print".to_string()));
1257    }
1258
1259    #[test]
1260    fn test_from_name_kiro() {
1261        let backend = CliBackend::from_name("kiro").unwrap();
1262        assert_eq!(backend.command, "kiro-cli");
1263    }
1264
1265    #[test]
1266    fn test_from_name_gemini() {
1267        let backend = CliBackend::from_name("gemini").unwrap();
1268        assert_eq!(backend.command, "gemini");
1269    }
1270
1271    #[test]
1272    fn test_from_name_codex() {
1273        let backend = CliBackend::from_name("codex").unwrap();
1274        assert_eq!(backend.command, "codex");
1275    }
1276
1277    #[test]
1278    fn test_from_name_amp() {
1279        let backend = CliBackend::from_name("amp").unwrap();
1280        assert_eq!(backend.command, "amp");
1281    }
1282
1283    #[test]
1284    fn test_from_name_copilot() {
1285        let backend = CliBackend::from_name("copilot").unwrap();
1286        assert_eq!(backend.command, "copilot");
1287        assert_eq!(backend.prompt_flag, Some("-p".to_string()));
1288    }
1289
1290    #[test]
1291    fn test_from_name_invalid() {
1292        let result = CliBackend::from_name("invalid");
1293        assert!(result.is_err());
1294    }
1295
1296    #[test]
1297    fn test_from_hat_backend_named() {
1298        let hat_backend = HatBackend::Named("claude".to_string());
1299        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1300        assert_eq!(backend.command, "claude");
1301    }
1302
1303    #[test]
1304    fn test_from_hat_backend_kiro_agent() {
1305        let hat_backend = HatBackend::KiroAgent {
1306            backend_type: "kiro".to_string(),
1307            agent: "my-agent".to_string(),
1308            args: vec![],
1309        };
1310        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1311        let (cmd, args, _, _) = backend.build_command("test", false);
1312        assert_eq!(cmd, "kiro-cli");
1313        assert!(args.contains(&"--agent".to_string()));
1314        assert!(args.contains(&"my-agent".to_string()));
1315    }
1316
1317    #[test]
1318    fn test_from_hat_backend_kiro_acp_agent_uses_acp_executor() {
1319        let hat_backend = HatBackend::KiroAgent {
1320            backend_type: "kiro-acp".to_string(),
1321            agent: "my-agent".to_string(),
1322            args: vec![],
1323        };
1324        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1325        assert_eq!(backend.command, "kiro-cli");
1326        assert_eq!(backend.output_format, OutputFormat::Acp);
1327        assert!(backend.args.contains(&"acp".to_string()));
1328        assert!(backend.args.contains(&"--agent".to_string()));
1329        assert!(backend.args.contains(&"my-agent".to_string()));
1330    }
1331
1332    #[test]
1333    fn test_from_hat_backend_kiro_agent_with_args() {
1334        let hat_backend = HatBackend::KiroAgent {
1335            backend_type: "kiro".to_string(),
1336            agent: "my-agent".to_string(),
1337            args: vec!["--verbose".to_string()],
1338        };
1339        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1340        let (cmd, args, _, _) = backend.build_command("test", false);
1341        assert_eq!(cmd, "kiro-cli");
1342        assert!(args.contains(&"--agent".to_string()));
1343        assert!(args.contains(&"my-agent".to_string()));
1344        assert!(args.contains(&"--verbose".to_string()));
1345    }
1346
1347    #[test]
1348    fn test_from_hat_backend_named_with_args() {
1349        let hat_backend = HatBackend::NamedWithArgs {
1350            backend_type: "claude".to_string(),
1351            args: vec!["--model".to_string(), "claude-sonnet-4".to_string()],
1352        };
1353        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1354        assert_eq!(backend.command, "claude");
1355        assert!(backend.args.contains(&"--model".to_string()));
1356        assert!(backend.args.contains(&"claude-sonnet-4".to_string()));
1357    }
1358
1359    #[test]
1360    fn test_codex_named_with_args_dangerous_bypass_normalizes_to_yolo() {
1361        let hat_backend = HatBackend::NamedWithArgs {
1362            backend_type: "codex".to_string(),
1363            args: vec!["--dangerously-bypass-approvals-and-sandbox".to_string()],
1364        };
1365        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1366        let (cmd, args, _, _) = backend.build_command("test prompt", false);
1367
1368        assert_eq!(cmd, "codex");
1369        assert_eq!(args, vec!["exec", "--yolo", "test prompt"]);
1370    }
1371
1372    #[test]
1373    fn test_codex_named_with_args_yolo_removes_full_auto() {
1374        let hat_backend = HatBackend::NamedWithArgs {
1375            backend_type: "codex".to_string(),
1376            args: vec!["--yolo".to_string()],
1377        };
1378        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1379        let (cmd, args, _, _) = backend.build_command("test prompt", false);
1380
1381        assert_eq!(cmd, "codex");
1382        assert_eq!(args, vec!["exec", "--yolo", "test prompt"]);
1383    }
1384
1385    #[test]
1386    fn test_from_hat_backend_custom() {
1387        let hat_backend = HatBackend::Custom {
1388            command: "my-cli".to_string(),
1389            args: vec!["--flag".to_string()],
1390        };
1391        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1392        assert_eq!(backend.command, "my-cli");
1393        assert_eq!(backend.args, vec!["--flag"]);
1394    }
1395
1396    // ─────────────────────────────────────────────────────────────────────────
1397    // Tests for interactive prompt backends
1398    // ─────────────────────────────────────────────────────────────────────────
1399
1400    #[test]
1401    fn test_for_interactive_prompt_claude() {
1402        let backend = CliBackend::for_interactive_prompt("claude").unwrap();
1403        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1404
1405        assert_eq!(cmd, "claude");
1406        // Should use positional arg (no -p flag)
1407        assert_eq!(
1408            args,
1409            vec![
1410                "--dangerously-skip-permissions",
1411                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet",
1412                "test prompt"
1413            ]
1414        );
1415        assert!(stdin.is_none());
1416        assert_eq!(backend.prompt_flag, None);
1417    }
1418
1419    #[test]
1420    fn test_for_interactive_prompt_kiro() {
1421        let backend = CliBackend::for_interactive_prompt("kiro").unwrap();
1422        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1423
1424        assert_eq!(cmd, "kiro-cli");
1425        // Should NOT have --no-interactive
1426        assert_eq!(args, vec!["chat", "--trust-all-tools", "test prompt"]);
1427        assert!(!args.contains(&"--no-interactive".to_string()));
1428        assert!(stdin.is_none());
1429    }
1430
1431    #[test]
1432    fn test_for_interactive_prompt_gemini() {
1433        let backend = CliBackend::for_interactive_prompt("gemini").unwrap();
1434        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1435
1436        assert_eq!(cmd, "gemini");
1437        // Critical: should use -i flag, NOT -p
1438        assert_eq!(args, vec!["--yolo", "-i", "test prompt"]);
1439        assert_eq!(backend.prompt_flag, Some("-i".to_string()));
1440        assert!(stdin.is_none());
1441    }
1442
1443    #[test]
1444    fn test_for_interactive_prompt_codex() {
1445        let backend = CliBackend::for_interactive_prompt("codex").unwrap();
1446        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1447
1448        assert_eq!(cmd, "codex");
1449        // Should NOT have exec or --full-auto
1450        assert_eq!(args, vec!["test prompt"]);
1451        assert!(!args.contains(&"exec".to_string()));
1452        assert!(!args.contains(&"--full-auto".to_string()));
1453        assert!(stdin.is_none());
1454    }
1455
1456    #[test]
1457    fn test_for_interactive_prompt_amp() {
1458        let backend = CliBackend::for_interactive_prompt("amp").unwrap();
1459        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1460
1461        assert_eq!(cmd, "amp");
1462        // Should NOT have --dangerously-allow-all
1463        assert_eq!(args, vec!["-x", "test prompt"]);
1464        assert!(!args.contains(&"--dangerously-allow-all".to_string()));
1465        assert!(stdin.is_none());
1466    }
1467
1468    #[test]
1469    fn test_for_interactive_prompt_copilot() {
1470        let backend = CliBackend::for_interactive_prompt("copilot").unwrap();
1471        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1472
1473        assert_eq!(cmd, "copilot");
1474        // Should NOT have --allow-all-tools
1475        assert_eq!(args, vec!["-p", "test prompt"]);
1476        assert!(!args.contains(&"--allow-all-tools".to_string()));
1477        assert!(stdin.is_none());
1478    }
1479
1480    #[test]
1481    fn test_for_interactive_prompt_invalid() {
1482        let result = CliBackend::for_interactive_prompt("invalid_backend");
1483        assert!(result.is_err());
1484    }
1485
1486    // ─────────────────────────────────────────────────────────────────────────
1487    // Tests for OpenCode backend
1488    // ─────────────────────────────────────────────────────────────────────────
1489
1490    #[test]
1491    fn test_opencode_backend() {
1492        let backend = CliBackend::opencode();
1493        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1494
1495        assert_eq!(cmd, "opencode");
1496        // Uses `run` subcommand with positional prompt arg
1497        assert_eq!(args, vec!["run", "test prompt"]);
1498        assert!(stdin.is_none());
1499        assert_eq!(backend.output_format, OutputFormat::Text);
1500        assert_eq!(backend.prompt_flag, None);
1501    }
1502
1503    #[test]
1504    fn test_opencode_tui_backend() {
1505        let backend = CliBackend::opencode_tui();
1506        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1507
1508        assert_eq!(cmd, "opencode");
1509        // Uses `run` subcommand with positional prompt arg
1510        assert_eq!(args, vec!["run", "test prompt"]);
1511        assert!(stdin.is_none());
1512        assert_eq!(backend.output_format, OutputFormat::Text);
1513        assert_eq!(backend.prompt_flag, None);
1514    }
1515
1516    #[test]
1517    fn test_opencode_interactive_mode_unchanged() {
1518        // OpenCode has no flags to filter in interactive mode
1519        let backend = CliBackend::opencode();
1520        let (cmd, args_auto, stdin_auto, _) = backend.build_command("test prompt", false);
1521        let (_, args_interactive, stdin_interactive, _) =
1522            backend.build_command("test prompt", true);
1523
1524        assert_eq!(cmd, "opencode");
1525        // Should be identical in both modes
1526        assert_eq!(args_auto, args_interactive);
1527        assert_eq!(args_auto, vec!["run", "test prompt"]);
1528        assert!(stdin_auto.is_none());
1529        assert!(stdin_interactive.is_none());
1530    }
1531
1532    #[test]
1533    fn test_from_name_opencode() {
1534        let backend = CliBackend::from_name("opencode").unwrap();
1535        assert_eq!(backend.command, "opencode");
1536        assert_eq!(backend.prompt_flag, None); // Positional argument
1537    }
1538
1539    #[test]
1540    fn test_for_interactive_prompt_opencode() {
1541        let backend = CliBackend::for_interactive_prompt("opencode").unwrap();
1542        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1543
1544        assert_eq!(cmd, "opencode");
1545        // Uses --prompt flag for TUI mode (no `run` subcommand)
1546        assert_eq!(args, vec!["--prompt", "test prompt"]);
1547        assert!(stdin.is_none());
1548        assert_eq!(backend.prompt_flag, Some("--prompt".to_string()));
1549    }
1550
1551    #[test]
1552    fn test_opencode_interactive_launches_tui_not_headless() {
1553        // Issue #96: opencode backend doesn't start interactive session with ralph plan
1554        //
1555        // The bug: opencode_interactive() uses `opencode run "prompt"` which is headless mode.
1556        // The fix: Interactive mode should use `opencode --prompt "prompt"` (without `run`)
1557        // to launch the TUI with an initial prompt.
1558        //
1559        // From `opencode --help`:
1560        // - `opencode [project]` = start opencode tui (interactive mode) [default]
1561        // - `opencode run [message..]` = run opencode with a message (headless mode)
1562        let backend = CliBackend::opencode_interactive();
1563        let (cmd, args, _, _) = backend.build_command("test prompt", true);
1564
1565        assert_eq!(cmd, "opencode");
1566        // Interactive mode should NOT include "run" subcommand
1567        // `run` makes opencode execute headlessly, which defeats the purpose of interactive mode
1568        assert!(
1569            !args.contains(&"run".to_string()),
1570            "opencode_interactive() should not use 'run' subcommand. \
1571             'opencode run' is headless mode, but interactive mode needs TUI. \
1572             Expected: opencode --prompt \"test prompt\", got: opencode {}",
1573            args.join(" ")
1574        );
1575        // Should pass prompt via --prompt flag for TUI mode
1576        assert!(
1577            args.contains(&"--prompt".to_string()),
1578            "opencode_interactive() should use --prompt flag for TUI mode. \
1579             Expected args to contain '--prompt', got: {:?}",
1580            args
1581        );
1582    }
1583
1584    // ─────────────────────────────────────────────────────────────────────────
1585    // Tests for Pi backend
1586    // ─────────────────────────────────────────────────────────────────────────
1587
1588    #[test]
1589    fn test_pi_backend() {
1590        let backend = CliBackend::pi();
1591        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1592
1593        assert_eq!(cmd, "pi");
1594        assert_eq!(
1595            args,
1596            vec!["-p", "--mode", "json", "--no-session", "test prompt"]
1597        );
1598        assert!(stdin.is_none());
1599        assert_eq!(backend.output_format, OutputFormat::PiStreamJson);
1600        assert_eq!(backend.prompt_flag, None); // Positional argument
1601    }
1602
1603    #[test]
1604    fn test_pi_interactive_backend() {
1605        let backend = CliBackend::pi_interactive();
1606        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1607
1608        assert_eq!(cmd, "pi");
1609        // No -p, no --mode json, just --no-session + positional prompt
1610        assert_eq!(args, vec!["--no-session", "test prompt"]);
1611        assert!(stdin.is_none());
1612        assert_eq!(backend.output_format, OutputFormat::Text);
1613        assert_eq!(backend.prompt_flag, None);
1614    }
1615
1616    #[test]
1617    fn test_from_name_pi() {
1618        let backend = CliBackend::from_name("pi").unwrap();
1619        assert_eq!(backend.command, "pi");
1620        assert_eq!(backend.prompt_flag, None);
1621        assert_eq!(backend.output_format, OutputFormat::PiStreamJson);
1622    }
1623
1624    #[test]
1625    fn test_for_interactive_prompt_pi() {
1626        let backend = CliBackend::for_interactive_prompt("pi").unwrap();
1627        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1628
1629        assert_eq!(cmd, "pi");
1630        assert_eq!(args, vec!["--no-session", "test prompt"]);
1631        assert!(stdin.is_none());
1632        assert_eq!(backend.output_format, OutputFormat::Text);
1633    }
1634
1635    #[test]
1636    fn test_from_config_pi() {
1637        let config = CliConfig {
1638            backend: "pi".to_string(),
1639            command: None,
1640            prompt_mode: "arg".to_string(),
1641            args: vec![
1642                "--provider".to_string(),
1643                "zai".to_string(),
1644                "--model".to_string(),
1645                "glm-5".to_string(),
1646            ],
1647            ..Default::default()
1648        };
1649        let backend = CliBackend::from_config(&config).unwrap();
1650        let (_cmd, args, _stdin, _temp) = backend.build_command("test prompt", false);
1651
1652        assert_eq!(backend.command, "pi");
1653        assert_eq!(backend.output_format, OutputFormat::PiStreamJson);
1654        assert!(args.contains(&"--provider".to_string()));
1655        assert!(args.contains(&"zai".to_string()));
1656        assert!(args.contains(&"--model".to_string()));
1657        assert!(args.contains(&"glm-5".to_string()));
1658    }
1659
1660    #[test]
1661    fn test_from_hat_backend_named_with_args_pi() {
1662        let hat_backend = HatBackend::NamedWithArgs {
1663            backend_type: "pi".to_string(),
1664            args: vec![
1665                "--provider".to_string(),
1666                "anthropic".to_string(),
1667                "--model".to_string(),
1668                "claude-sonnet-4".to_string(),
1669            ],
1670        };
1671        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1672        let (cmd, args, _, _) = backend.build_command("test prompt", false);
1673
1674        assert_eq!(cmd, "pi");
1675        // Default args + extra args + prompt
1676        assert!(args.contains(&"-p".to_string()));
1677        assert!(args.contains(&"--mode".to_string()));
1678        assert!(args.contains(&"json".to_string()));
1679        assert!(args.contains(&"--no-session".to_string()));
1680        assert!(args.contains(&"--provider".to_string()));
1681        assert!(args.contains(&"anthropic".to_string()));
1682        assert!(args.contains(&"--model".to_string()));
1683        assert!(args.contains(&"claude-sonnet-4".to_string()));
1684        assert!(args.contains(&"test prompt".to_string()));
1685    }
1686
1687    #[test]
1688    fn test_pi_large_prompt_uses_temp_file() {
1689        let backend = CliBackend::pi();
1690        let large_prompt = "x".repeat(7001);
1691        let (cmd, args, _stdin, temp) = backend.build_command(&large_prompt, false);
1692
1693        assert_eq!(cmd, "pi");
1694        assert!(temp.is_some());
1695        assert!(args.iter().any(|a| a.contains("Please read and execute")));
1696    }
1697
1698    #[test]
1699    fn test_pi_interactive_mode_unchanged() {
1700        // Pi has no flags to filter in interactive mode
1701        let backend = CliBackend::pi();
1702        let (_, args_auto, _, _) = backend.build_command("test prompt", false);
1703        let (_, args_interactive, _, _) = backend.build_command("test prompt", true);
1704
1705        assert_eq!(args_auto, args_interactive);
1706    }
1707
1708    #[test]
1709    fn test_custom_args_can_be_appended() {
1710        // Verify that custom args can be appended to backend args
1711        // This is used for `ralph run -b opencode -- --model="some-model"`
1712        let mut backend = CliBackend::opencode();
1713
1714        // Append custom args
1715        let custom_args = vec!["--model=gpt-4".to_string(), "--temperature=0.7".to_string()];
1716        backend.args.extend(custom_args.clone());
1717
1718        // Build command and verify custom args are included
1719        let (cmd, args, _, _) = backend.build_command("test prompt", false);
1720
1721        assert_eq!(cmd, "opencode");
1722        // Should have: original args + custom args + prompt
1723        assert!(args.contains(&"run".to_string())); // Original arg
1724        assert!(args.contains(&"--model=gpt-4".to_string())); // Custom arg
1725        assert!(args.contains(&"--temperature=0.7".to_string())); // Custom arg
1726        assert!(args.contains(&"test prompt".to_string())); // Prompt
1727
1728        // Verify order: original args come before custom args
1729        let run_idx = args.iter().position(|a| a == "run").unwrap();
1730        let model_idx = args.iter().position(|a| a == "--model=gpt-4").unwrap();
1731        assert!(
1732            run_idx < model_idx,
1733            "Original args should come before custom args"
1734        );
1735    }
1736
1737    // ─────────────────────────────────────────────────────────────────────────
1738    // Tests for Agent Teams backends
1739    // ─────────────────────────────────────────────────────────────────────────
1740
1741    #[test]
1742    fn test_claude_interactive_teams_backend() {
1743        let backend = CliBackend::claude_interactive_teams();
1744        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1745
1746        assert_eq!(cmd, "claude");
1747        assert_eq!(
1748            args,
1749            vec![
1750                "--dangerously-skip-permissions",
1751                "--disallowedTools=TodoWrite",
1752                "test prompt"
1753            ]
1754        );
1755        assert!(stdin.is_none());
1756        assert_eq!(backend.output_format, OutputFormat::Text);
1757        assert_eq!(backend.prompt_flag, None);
1758        assert_eq!(
1759            backend.env_vars,
1760            vec![(
1761                "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS".to_string(),
1762                "1".to_string()
1763            )]
1764        );
1765    }
1766
1767    #[test]
1768    fn test_env_vars_default_empty() {
1769        // All non-teams constructors should have empty env_vars
1770        assert!(CliBackend::claude().env_vars.is_empty());
1771        assert!(CliBackend::claude_interactive().env_vars.is_empty());
1772        assert!(CliBackend::kiro().env_vars.is_empty());
1773        assert!(CliBackend::gemini().env_vars.is_empty());
1774        assert!(CliBackend::codex().env_vars.is_empty());
1775        assert!(CliBackend::amp().env_vars.is_empty());
1776        assert!(CliBackend::copilot().env_vars.is_empty());
1777        assert!(CliBackend::opencode().env_vars.is_empty());
1778        assert!(CliBackend::pi().env_vars.is_empty());
1779        assert!(CliBackend::roo().env_vars.is_empty());
1780    }
1781
1782    // ─────────────────────────────────────────────────────────────────────────
1783    // Tests for Roo backend
1784    // ─────────────────────────────────────────────────────────────────────────
1785
1786    #[test]
1787    fn test_roo_backend() {
1788        let backend = CliBackend::roo();
1789        let (cmd, args, stdin, temp) = backend.build_command("test prompt", false);
1790
1791        assert_eq!(cmd, "roo");
1792        // Should use --prompt-file with temp file, not positional arg
1793        assert!(
1794            temp.is_some(),
1795            "roo should always use temp file for prompts"
1796        );
1797        assert!(
1798            args.contains(&"--print".to_string()),
1799            "roo headless should have --print"
1800        );
1801        assert!(
1802            args.contains(&"--ephemeral".to_string()),
1803            "roo headless should have --ephemeral"
1804        );
1805        assert!(
1806            args.contains(&"--prompt-file".to_string()),
1807            "roo should use --prompt-file"
1808        );
1809        assert!(stdin.is_none());
1810        assert_eq!(backend.output_format, OutputFormat::Text);
1811    }
1812
1813    #[test]
1814    fn test_roo_interactive() {
1815        let backend = CliBackend::roo_interactive();
1816        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1817
1818        assert_eq!(cmd, "roo");
1819        // Interactive mode: no --print, no --ephemeral, positional prompt
1820        assert_eq!(args, vec!["test prompt"]);
1821        assert!(stdin.is_none());
1822        assert_eq!(backend.output_format, OutputFormat::Text);
1823        assert_eq!(backend.prompt_flag, None);
1824    }
1825
1826    #[test]
1827    fn test_from_name_roo() {
1828        let backend = CliBackend::from_name("roo").unwrap();
1829        assert_eq!(backend.command, "roo");
1830        assert_eq!(backend.prompt_flag, None);
1831        assert_eq!(backend.output_format, OutputFormat::Text);
1832    }
1833
1834    #[test]
1835    fn test_from_config_roo() {
1836        let config = CliConfig {
1837            backend: "roo".to_string(),
1838            command: None,
1839            prompt_mode: "arg".to_string(),
1840            ..Default::default()
1841        };
1842        let backend = CliBackend::from_config(&config).unwrap();
1843
1844        assert_eq!(backend.command, "roo");
1845        assert_eq!(backend.output_format, OutputFormat::Text);
1846        assert!(backend.args.contains(&"--print".to_string()));
1847        assert!(backend.args.contains(&"--ephemeral".to_string()));
1848    }
1849
1850    #[test]
1851    fn test_from_config_roo_with_args() {
1852        let config = CliConfig {
1853            backend: "roo".to_string(),
1854            command: None,
1855            prompt_mode: "arg".to_string(),
1856            args: vec![
1857                "--provider".to_string(),
1858                "bedrock".to_string(),
1859                "--model".to_string(),
1860                "anthropic.claude-sonnet-4-6".to_string(),
1861            ],
1862            ..Default::default()
1863        };
1864        let backend = CliBackend::from_config(&config).unwrap();
1865        let (_cmd, args, _stdin, _temp) = backend.build_command("test prompt", false);
1866
1867        assert_eq!(backend.command, "roo");
1868        // Should have default args + extra args + --prompt-file
1869        assert!(args.contains(&"--print".to_string()));
1870        assert!(args.contains(&"--ephemeral".to_string()));
1871        assert!(args.contains(&"--provider".to_string()));
1872        assert!(args.contains(&"bedrock".to_string()));
1873        assert!(args.contains(&"--model".to_string()));
1874        assert!(args.contains(&"anthropic.claude-sonnet-4-6".to_string()));
1875        assert!(args.contains(&"--prompt-file".to_string()));
1876    }
1877
1878    #[test]
1879    fn test_for_interactive_prompt_roo() {
1880        let backend = CliBackend::for_interactive_prompt("roo").unwrap();
1881        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1882
1883        assert_eq!(cmd, "roo");
1884        // Interactive: no --print, no --ephemeral, positional prompt
1885        assert_eq!(args, vec!["test prompt"]);
1886        assert!(stdin.is_none());
1887        assert_eq!(backend.output_format, OutputFormat::Text);
1888    }
1889
1890    #[test]
1891    fn test_roo_interactive_mode_removes_print() {
1892        let backend = CliBackend::roo();
1893        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
1894
1895        assert_eq!(cmd, "roo");
1896        // In interactive mode, --print and --ephemeral should be removed
1897        assert!(
1898            !args.contains(&"--print".to_string()),
1899            "interactive mode should remove --print"
1900        );
1901        assert!(
1902            !args.contains(&"--ephemeral".to_string()),
1903            "interactive mode should remove --ephemeral"
1904        );
1905        assert!(stdin.is_none());
1906    }
1907
1908    #[test]
1909    fn test_roo_uses_prompt_file() {
1910        let backend = CliBackend::roo();
1911        // Test with small prompt
1912        let (_, args_small, _, temp_small) = backend.build_command("small prompt", false);
1913        assert!(
1914            temp_small.is_some(),
1915            "even small prompts should use temp file"
1916        );
1917        assert!(
1918            args_small.contains(&"--prompt-file".to_string()),
1919            "should use --prompt-file"
1920        );
1921
1922        // Test with large prompt
1923        let large_prompt = "x".repeat(10000);
1924        let (_, args_large, _, temp_large) = backend.build_command(&large_prompt, false);
1925        assert!(temp_large.is_some(), "large prompts should use temp file");
1926        assert!(
1927            args_large.contains(&"--prompt-file".to_string()),
1928            "should use --prompt-file for large prompts"
1929        );
1930    }
1931
1932    #[test]
1933    fn test_roo_prompt_file_content() {
1934        use std::io::{Read, Seek};
1935        let backend = CliBackend::roo();
1936        let prompt = "This is a test prompt for roo";
1937        let (_, _, _, temp) = backend.build_command(prompt, false);
1938
1939        let mut temp_file = temp.expect("should have temp file");
1940        let mut content = String::new();
1941        temp_file
1942            .as_file_mut()
1943            .seek(std::io::SeekFrom::Start(0))
1944            .unwrap();
1945        temp_file
1946            .as_file_mut()
1947            .read_to_string(&mut content)
1948            .unwrap();
1949        assert_eq!(content, prompt);
1950    }
1951}