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