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}
20
21/// Error when creating a custom backend without a command.
22#[derive(Debug, Clone)]
23pub struct CustomBackendError;
24
25impl fmt::Display for CustomBackendError {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        write!(f, "custom backend requires a command to be specified")
28    }
29}
30
31impl std::error::Error for CustomBackendError {}
32
33/// How to pass prompts to the CLI tool.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PromptMode {
36    /// Pass prompt as a command-line argument.
37    Arg,
38    /// Write prompt to stdin.
39    Stdin,
40}
41
42/// A CLI backend configuration for executing prompts.
43#[derive(Debug, Clone)]
44pub struct CliBackend {
45    /// The command to execute.
46    pub command: String,
47    /// Additional arguments before the prompt.
48    pub args: Vec<String>,
49    /// How to pass the prompt.
50    pub prompt_mode: PromptMode,
51    /// Argument flag for prompt (if prompt_mode is Arg).
52    pub prompt_flag: Option<String>,
53    /// Output format emitted by this backend.
54    pub output_format: OutputFormat,
55}
56
57impl CliBackend {
58    /// Creates a backend from configuration.
59    ///
60    /// # Errors
61    /// Returns `CustomBackendError` if backend is "custom" but no command is specified.
62    pub fn from_config(config: &CliConfig) -> Result<Self, CustomBackendError> {
63        let mut backend = match config.backend.as_str() {
64            "claude" => Self::claude(),
65            "kiro" => Self::kiro(),
66            "gemini" => Self::gemini(),
67            "codex" => Self::codex(),
68            "amp" => Self::amp(),
69            "copilot" => Self::copilot(),
70            "opencode" => Self::opencode(),
71            "custom" => return Self::custom(config),
72            _ => Self::claude(), // Default to claude
73        };
74
75        // Honor command override for named backends (e.g., custom binary path)
76        if let Some(ref cmd) = config.command {
77            backend.command = cmd.clone();
78        }
79
80        Ok(backend)
81    }
82
83    /// Creates the Claude backend.
84    ///
85    /// Uses `-p` flag for headless/print mode execution. This runs Claude
86    /// in non-interactive mode where it executes the prompt and exits.
87    /// For interactive mode, stdin is used instead (handled in build_command).
88    ///
89    /// Emits `--output-format stream-json` for NDJSON streaming output.
90    /// Note: `--verbose` is required when using `--output-format stream-json` with `-p`.
91    pub fn claude() -> Self {
92        Self {
93            command: "claude".to_string(),
94            args: vec![
95                "--dangerously-skip-permissions".to_string(),
96                "--verbose".to_string(),
97                "--output-format".to_string(),
98                "stream-json".to_string(),
99                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet".to_string(),
100            ],
101            prompt_mode: PromptMode::Arg,
102            prompt_flag: Some("-p".to_string()),
103            output_format: OutputFormat::StreamJson,
104        }
105    }
106
107    /// Creates the Claude backend for interactive prompt injection.
108    ///
109    /// Runs Claude without `-p` flag, passing prompt as a positional argument.
110    /// Used by SOP runner for interactive command injection.
111    ///
112    /// Note: This is NOT for TUI mode - Ralph's TUI uses the standard `claude()`
113    /// backend. This is for cases where Claude's interactive mode is needed.
114    /// Uses `=` syntax for `--disallowedTools` to prevent variadic consumption
115    /// of the positional prompt argument.
116    pub fn claude_interactive() -> Self {
117        Self {
118            command: "claude".to_string(),
119            args: vec![
120                "--dangerously-skip-permissions".to_string(),
121                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet".to_string(),
122            ],
123            prompt_mode: PromptMode::Arg,
124            prompt_flag: None,
125            output_format: OutputFormat::Text,
126        }
127    }
128
129    /// Creates the Kiro backend.
130    ///
131    /// Uses kiro-cli in headless mode with all tools trusted.
132    pub fn kiro() -> Self {
133        Self {
134            command: "kiro-cli".to_string(),
135            args: vec![
136                "chat".to_string(),
137                "--no-interactive".to_string(),
138                "--trust-all-tools".to_string(),
139            ],
140            prompt_mode: PromptMode::Arg,
141            prompt_flag: None,
142            output_format: OutputFormat::Text,
143        }
144    }
145
146    /// Creates the Kiro backend with a specific agent and optional extra args.
147    ///
148    /// Uses kiro-cli with --agent flag to select a specific agent.
149    pub fn kiro_with_agent(agent: String, extra_args: &[String]) -> Self {
150        let mut backend = Self {
151            command: "kiro-cli".to_string(),
152            args: vec![
153                "chat".to_string(),
154                "--no-interactive".to_string(),
155                "--trust-all-tools".to_string(),
156                "--agent".to_string(),
157                agent,
158            ],
159            prompt_mode: PromptMode::Arg,
160            prompt_flag: None,
161            output_format: OutputFormat::Text,
162        };
163        backend.args.extend(extra_args.iter().cloned());
164        backend
165    }
166
167    /// Creates a backend from a named backend with additional args.
168    ///
169    /// # Errors
170    /// Returns error if the backend name is invalid.
171    pub fn from_name_with_args(
172        name: &str,
173        extra_args: &[String],
174    ) -> Result<Self, CustomBackendError> {
175        let mut backend = Self::from_name(name)?;
176        backend.args.extend(extra_args.iter().cloned());
177        Ok(backend)
178    }
179
180    /// Creates a backend from a named backend string.
181    ///
182    /// # Errors
183    /// Returns error if the backend name is invalid.
184    pub fn from_name(name: &str) -> Result<Self, CustomBackendError> {
185        match name {
186            "claude" => Ok(Self::claude()),
187            "kiro" => Ok(Self::kiro()),
188            "gemini" => Ok(Self::gemini()),
189            "codex" => Ok(Self::codex()),
190            "amp" => Ok(Self::amp()),
191            "copilot" => Ok(Self::copilot()),
192            "opencode" => Ok(Self::opencode()),
193            _ => Err(CustomBackendError),
194        }
195    }
196
197    /// Creates a backend from a HatBackend configuration.
198    ///
199    /// # Errors
200    /// Returns error if the backend configuration is invalid.
201    pub fn from_hat_backend(hat_backend: &HatBackend) -> Result<Self, CustomBackendError> {
202        match hat_backend {
203            HatBackend::Named(name) => Self::from_name(name),
204            HatBackend::NamedWithArgs { backend_type, args } => {
205                Self::from_name_with_args(backend_type, args)
206            }
207            HatBackend::KiroAgent { agent, args, .. } => {
208                Ok(Self::kiro_with_agent(agent.clone(), args))
209            }
210            HatBackend::Custom { command, args } => Ok(Self {
211                command: command.clone(),
212                args: args.clone(),
213                prompt_mode: PromptMode::Arg,
214                prompt_flag: None,
215                output_format: OutputFormat::Text,
216            }),
217        }
218    }
219
220    /// Creates the Gemini backend.
221    pub fn gemini() -> Self {
222        Self {
223            command: "gemini".to_string(),
224            args: vec!["--yolo".to_string()],
225            prompt_mode: PromptMode::Arg,
226            prompt_flag: Some("-p".to_string()),
227            output_format: OutputFormat::Text,
228        }
229    }
230
231    /// Creates the Codex backend.
232    pub fn codex() -> Self {
233        Self {
234            command: "codex".to_string(),
235            args: vec![
236                "exec".to_string(),
237                "--yolo".to_string(),
238                "--full-auto".to_string(),
239            ],
240            prompt_mode: PromptMode::Arg,
241            prompt_flag: None, // Positional argument
242            output_format: OutputFormat::Text,
243        }
244    }
245
246    /// Creates the Amp backend.
247    pub fn amp() -> Self {
248        Self {
249            command: "amp".to_string(),
250            args: vec!["--dangerously-allow-all".to_string()],
251            prompt_mode: PromptMode::Arg,
252            prompt_flag: Some("-x".to_string()),
253            output_format: OutputFormat::Text,
254        }
255    }
256
257    /// Creates the Copilot backend for autonomous mode.
258    ///
259    /// Uses GitHub Copilot CLI with `--allow-all-tools` for automated tool approval.
260    /// Output is plain text (no JSON streaming available).
261    pub fn copilot() -> Self {
262        Self {
263            command: "copilot".to_string(),
264            args: vec!["--allow-all-tools".to_string()],
265            prompt_mode: PromptMode::Arg,
266            prompt_flag: Some("-p".to_string()),
267            output_format: OutputFormat::Text,
268        }
269    }
270
271    /// Creates the Copilot TUI backend for interactive mode.
272    ///
273    /// Runs Copilot in full interactive mode (no -p flag), allowing
274    /// Copilot's native TUI to render. The prompt is passed as a
275    /// positional argument.
276    pub fn copilot_tui() -> Self {
277        Self {
278            command: "copilot".to_string(),
279            args: vec![], // No --allow-all-tools in TUI mode
280            prompt_mode: PromptMode::Arg,
281            prompt_flag: None, // Positional argument
282            output_format: OutputFormat::Text,
283        }
284    }
285
286    /// Creates a backend configured for interactive mode with initial prompt.
287    ///
288    /// This factory method returns the correct backend configuration for running
289    /// an interactive session with an initial prompt. The key differences from
290    /// headless mode are:
291    ///
292    /// | Backend | Interactive + Prompt |
293    /// |---------|---------------------|
294    /// | Claude  | positional arg (no `-p` flag) |
295    /// | Kiro    | removes `--no-interactive` |
296    /// | Gemini  | uses `-i` instead of `-p` |
297    /// | Codex   | no `exec` subcommand |
298    /// | Amp     | removes `--dangerously-allow-all` |
299    /// | Copilot | removes `--allow-all-tools` |
300    /// | OpenCode| `run` subcommand with positional prompt |
301    ///
302    /// # Errors
303    /// Returns `CustomBackendError` if the backend name is not recognized.
304    pub fn for_interactive_prompt(backend_name: &str) -> Result<Self, CustomBackendError> {
305        match backend_name {
306            "claude" => Ok(Self::claude_interactive()),
307            "kiro" => Ok(Self::kiro_interactive()),
308            "gemini" => Ok(Self::gemini_interactive()),
309            "codex" => Ok(Self::codex_interactive()),
310            "amp" => Ok(Self::amp_interactive()),
311            "copilot" => Ok(Self::copilot_interactive()),
312            "opencode" => Ok(Self::opencode_interactive()),
313            _ => Err(CustomBackendError),
314        }
315    }
316
317    /// Kiro in interactive mode (removes --no-interactive).
318    ///
319    /// Unlike headless `kiro()`, this allows the user to interact with
320    /// Kiro's TUI while still passing an initial prompt.
321    pub fn kiro_interactive() -> Self {
322        Self {
323            command: "kiro-cli".to_string(),
324            args: vec!["chat".to_string(), "--trust-all-tools".to_string()],
325            prompt_mode: PromptMode::Arg,
326            prompt_flag: None,
327            output_format: OutputFormat::Text,
328        }
329    }
330
331    /// Gemini in interactive mode with initial prompt (uses -i, not -p).
332    ///
333    /// **Critical quirk**: Gemini requires `-i` flag for interactive+prompt mode.
334    /// Using `-p` would make it run headless and exit after one response.
335    pub fn gemini_interactive() -> Self {
336        Self {
337            command: "gemini".to_string(),
338            args: vec!["--yolo".to_string()],
339            prompt_mode: PromptMode::Arg,
340            prompt_flag: Some("-i".to_string()), // NOT -p!
341            output_format: OutputFormat::Text,
342        }
343    }
344
345    /// Codex in interactive TUI mode (no exec subcommand).
346    ///
347    /// Unlike headless `codex()`, this runs without `exec` and `--full-auto`
348    /// flags, allowing interactive TUI mode.
349    pub fn codex_interactive() -> Self {
350        Self {
351            command: "codex".to_string(),
352            args: vec![], // No exec, no --full-auto
353            prompt_mode: PromptMode::Arg,
354            prompt_flag: None, // Positional argument
355            output_format: OutputFormat::Text,
356        }
357    }
358
359    /// Amp in interactive mode (removes --dangerously-allow-all).
360    ///
361    /// Unlike headless `amp()`, this runs without the auto-approve flag,
362    /// requiring user confirmation for tool usage.
363    pub fn amp_interactive() -> Self {
364        Self {
365            command: "amp".to_string(),
366            args: vec![],
367            prompt_mode: PromptMode::Arg,
368            prompt_flag: Some("-x".to_string()),
369            output_format: OutputFormat::Text,
370        }
371    }
372
373    /// Copilot in interactive mode (removes --allow-all-tools).
374    ///
375    /// Unlike headless `copilot()`, this runs without the auto-approve flag,
376    /// requiring user confirmation for tool usage.
377    pub fn copilot_interactive() -> Self {
378        Self {
379            command: "copilot".to_string(),
380            args: vec![],
381            prompt_mode: PromptMode::Arg,
382            prompt_flag: Some("-p".to_string()),
383            output_format: OutputFormat::Text,
384        }
385    }
386
387    /// Creates the OpenCode backend for autonomous mode.
388    ///
389    /// Uses OpenCode CLI with `run` subcommand. The prompt is passed as a
390    /// positional argument after the subcommand:
391    /// ```bash
392    /// opencode run "prompt text here"
393    /// ```
394    ///
395    /// Output is plain text (no JSON streaming available).
396    pub fn opencode() -> Self {
397        Self {
398            command: "opencode".to_string(),
399            args: vec!["run".to_string()],
400            prompt_mode: PromptMode::Arg,
401            prompt_flag: None, // Positional argument
402            output_format: OutputFormat::Text,
403        }
404    }
405
406    /// Creates the OpenCode TUI backend for interactive mode.
407    ///
408    /// Runs OpenCode with `run` subcommand. The prompt is passed as a
409    /// positional argument:
410    /// ```bash
411    /// opencode run "prompt text here"
412    /// ```
413    pub fn opencode_tui() -> Self {
414        Self {
415            command: "opencode".to_string(),
416            args: vec!["run".to_string()],
417            prompt_mode: PromptMode::Arg,
418            prompt_flag: None, // Positional argument
419            output_format: OutputFormat::Text,
420        }
421    }
422
423    /// OpenCode in interactive TUI mode.
424    ///
425    /// Runs OpenCode TUI with an initial prompt via `--prompt` flag:
426    /// ```bash
427    /// opencode --prompt "prompt text here"
428    /// ```
429    ///
430    /// Unlike `opencode()` which uses `opencode run` (headless mode),
431    /// this launches the interactive TUI and injects the prompt.
432    pub fn opencode_interactive() -> Self {
433        Self {
434            command: "opencode".to_string(),
435            args: vec![],
436            prompt_mode: PromptMode::Arg,
437            prompt_flag: Some("--prompt".to_string()),
438            output_format: OutputFormat::Text,
439        }
440    }
441
442    /// Creates a custom backend from configuration.
443    ///
444    /// # Errors
445    /// Returns `CustomBackendError` if no command is specified.
446    pub fn custom(config: &CliConfig) -> Result<Self, CustomBackendError> {
447        let command = config.command.clone().ok_or(CustomBackendError)?;
448        let prompt_mode = if config.prompt_mode == "stdin" {
449            PromptMode::Stdin
450        } else {
451            PromptMode::Arg
452        };
453
454        Ok(Self {
455            command,
456            args: config.args.clone(),
457            prompt_mode,
458            prompt_flag: config.prompt_flag.clone(),
459            output_format: OutputFormat::Text,
460        })
461    }
462
463    /// Builds the full command with arguments for execution.
464    ///
465    /// # Arguments
466    /// * `prompt` - The prompt text to pass to the agent
467    /// * `interactive` - Whether to run in interactive mode (affects agent flags)
468    pub fn build_command(
469        &self,
470        prompt: &str,
471        interactive: bool,
472    ) -> (String, Vec<String>, Option<String>, Option<NamedTempFile>) {
473        let mut args = self.args.clone();
474
475        // Filter args based on execution mode per interactive-mode.spec.md
476        if interactive {
477            args = self.filter_args_for_interactive(args);
478        }
479
480        // Handle large prompts for Claude (>7000 chars)
481        let (stdin_input, temp_file) = match self.prompt_mode {
482            PromptMode::Arg => {
483                let (prompt_text, temp_file) = if self.command == "claude" && prompt.len() > 7000 {
484                    // Write to temp file and instruct Claude to read it
485                    match NamedTempFile::new() {
486                        Ok(mut file) => {
487                            if let Err(e) = file.write_all(prompt.as_bytes()) {
488                                tracing::warn!("Failed to write prompt to temp file: {}", e);
489                                (prompt.to_string(), None)
490                            } else {
491                                let path = file.path().display().to_string();
492                                (
493                                    format!("Please read and execute the task in {}", path),
494                                    Some(file),
495                                )
496                            }
497                        }
498                        Err(e) => {
499                            tracing::warn!("Failed to create temp file: {}", e);
500                            (prompt.to_string(), None)
501                        }
502                    }
503                } else {
504                    (prompt.to_string(), None)
505                };
506
507                if let Some(ref flag) = self.prompt_flag {
508                    args.push(flag.clone());
509                }
510                args.push(prompt_text);
511                (None, temp_file)
512            }
513            PromptMode::Stdin => (Some(prompt.to_string()), None),
514        };
515
516        // Log the full command being built
517        tracing::debug!(
518            command = %self.command,
519            args_count = args.len(),
520            prompt_len = prompt.len(),
521            interactive = interactive,
522            uses_stdin = stdin_input.is_some(),
523            uses_temp_file = temp_file.is_some(),
524            "Built CLI command"
525        );
526        // Log full prompt at trace level for debugging
527        tracing::trace!(prompt = %prompt, "Full prompt content");
528
529        (self.command.clone(), args, stdin_input, temp_file)
530    }
531
532    /// Filters args for interactive mode per spec table.
533    fn filter_args_for_interactive(&self, args: Vec<String>) -> Vec<String> {
534        match self.command.as_str() {
535            "kiro-cli" => args
536                .into_iter()
537                .filter(|a| a != "--no-interactive")
538                .collect(),
539            "codex" => args.into_iter().filter(|a| a != "--full-auto").collect(),
540            "amp" => args
541                .into_iter()
542                .filter(|a| a != "--dangerously-allow-all")
543                .collect(),
544            "copilot" => args
545                .into_iter()
546                .filter(|a| a != "--allow-all-tools")
547                .collect(),
548            _ => args, // claude, gemini, opencode unchanged
549        }
550    }
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn test_claude_backend() {
559        let backend = CliBackend::claude();
560        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
561
562        assert_eq!(cmd, "claude");
563        assert_eq!(
564            args,
565            vec![
566                "--dangerously-skip-permissions",
567                "--verbose",
568                "--output-format",
569                "stream-json",
570                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet",
571                "-p",
572                "test prompt"
573            ]
574        );
575        assert!(stdin.is_none()); // Uses -p flag, not stdin
576        assert_eq!(backend.output_format, OutputFormat::StreamJson);
577    }
578
579    #[test]
580    fn test_claude_interactive_backend() {
581        let backend = CliBackend::claude_interactive();
582        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
583
584        assert_eq!(cmd, "claude");
585        // Should have --dangerously-skip-permissions, --disallowedTools=..., and prompt as positional arg
586        // No -p flag, no --output-format, no --verbose
587        // Uses = syntax to prevent variadic consumption of the prompt
588        assert_eq!(
589            args,
590            vec![
591                "--dangerously-skip-permissions",
592                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet",
593                "test prompt"
594            ]
595        );
596        assert!(stdin.is_none()); // Uses positional arg, not stdin
597        assert_eq!(backend.output_format, OutputFormat::Text);
598        assert_eq!(backend.prompt_flag, None);
599    }
600
601    #[test]
602    fn test_claude_large_prompt_uses_temp_file() {
603        // With -p mode, large prompts (>7000 chars) use temp file to avoid CLI issues
604        let backend = CliBackend::claude();
605        let large_prompt = "x".repeat(7001);
606        let (cmd, args, _stdin, temp) = backend.build_command(&large_prompt, false);
607
608        assert_eq!(cmd, "claude");
609        // Should have temp file for large prompts
610        assert!(temp.is_some());
611        // Args should contain instruction to read from temp file
612        assert!(args.iter().any(|a| a.contains("Please read and execute")));
613    }
614
615    #[test]
616    fn test_non_claude_large_prompt() {
617        let backend = CliBackend::kiro();
618        let large_prompt = "x".repeat(7001);
619        let (cmd, args, stdin, temp) = backend.build_command(&large_prompt, false);
620
621        assert_eq!(cmd, "kiro-cli");
622        assert_eq!(args[3], large_prompt);
623        assert!(stdin.is_none());
624        assert!(temp.is_none());
625    }
626
627    #[test]
628    fn test_kiro_backend() {
629        let backend = CliBackend::kiro();
630        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
631
632        assert_eq!(cmd, "kiro-cli");
633        assert_eq!(
634            args,
635            vec![
636                "chat",
637                "--no-interactive",
638                "--trust-all-tools",
639                "test prompt"
640            ]
641        );
642        assert!(stdin.is_none());
643    }
644
645    #[test]
646    fn test_gemini_backend() {
647        let backend = CliBackend::gemini();
648        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
649
650        assert_eq!(cmd, "gemini");
651        assert_eq!(args, vec!["--yolo", "-p", "test prompt"]);
652        assert!(stdin.is_none());
653    }
654
655    #[test]
656    fn test_codex_backend() {
657        let backend = CliBackend::codex();
658        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
659
660        assert_eq!(cmd, "codex");
661        assert_eq!(args, vec!["exec", "--yolo", "--full-auto", "test prompt"]);
662        assert!(stdin.is_none());
663    }
664
665    #[test]
666    fn test_amp_backend() {
667        let backend = CliBackend::amp();
668        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
669
670        assert_eq!(cmd, "amp");
671        assert_eq!(args, vec!["--dangerously-allow-all", "-x", "test prompt"]);
672        assert!(stdin.is_none());
673    }
674
675    #[test]
676    fn test_copilot_backend() {
677        let backend = CliBackend::copilot();
678        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
679
680        assert_eq!(cmd, "copilot");
681        assert_eq!(args, vec!["--allow-all-tools", "-p", "test prompt"]);
682        assert!(stdin.is_none());
683        assert_eq!(backend.output_format, OutputFormat::Text);
684    }
685
686    #[test]
687    fn test_copilot_tui_backend() {
688        let backend = CliBackend::copilot_tui();
689        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
690
691        assert_eq!(cmd, "copilot");
692        // Should have prompt as positional arg, no -p flag, no --allow-all-tools
693        assert_eq!(args, vec!["test prompt"]);
694        assert!(stdin.is_none());
695        assert_eq!(backend.output_format, OutputFormat::Text);
696        assert_eq!(backend.prompt_flag, None);
697    }
698
699    #[test]
700    fn test_from_config() {
701        // Claude backend uses -p arg mode for headless execution
702        let config = CliConfig {
703            backend: "claude".to_string(),
704            command: None,
705            prompt_mode: "arg".to_string(),
706            ..Default::default()
707        };
708        let backend = CliBackend::from_config(&config).unwrap();
709
710        assert_eq!(backend.command, "claude");
711        assert_eq!(backend.prompt_mode, PromptMode::Arg);
712        assert_eq!(backend.prompt_flag, Some("-p".to_string()));
713    }
714
715    #[test]
716    fn test_from_config_command_override() {
717        let config = CliConfig {
718            backend: "claude".to_string(),
719            command: Some("my-custom-claude".to_string()),
720            prompt_mode: "arg".to_string(),
721            ..Default::default()
722        };
723        let backend = CliBackend::from_config(&config).unwrap();
724
725        assert_eq!(backend.command, "my-custom-claude");
726        assert_eq!(backend.prompt_flag, Some("-p".to_string()));
727        assert_eq!(backend.output_format, OutputFormat::StreamJson);
728    }
729
730    #[test]
731    fn test_kiro_interactive_mode_omits_no_interactive_flag() {
732        let backend = CliBackend::kiro();
733        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
734
735        assert_eq!(cmd, "kiro-cli");
736        assert_eq!(args, vec!["chat", "--trust-all-tools", "test prompt"]);
737        assert!(stdin.is_none());
738        assert!(!args.contains(&"--no-interactive".to_string()));
739    }
740
741    #[test]
742    fn test_codex_interactive_mode_omits_full_auto() {
743        let backend = CliBackend::codex();
744        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
745
746        assert_eq!(cmd, "codex");
747        assert_eq!(args, vec!["exec", "--yolo", "test prompt"]);
748        assert!(stdin.is_none());
749        assert!(!args.contains(&"--full-auto".to_string()));
750    }
751
752    #[test]
753    fn test_amp_interactive_mode_no_flags() {
754        let backend = CliBackend::amp();
755        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
756
757        assert_eq!(cmd, "amp");
758        assert_eq!(args, vec!["-x", "test prompt"]);
759        assert!(stdin.is_none());
760        assert!(!args.contains(&"--dangerously-allow-all".to_string()));
761    }
762
763    #[test]
764    fn test_copilot_interactive_mode_omits_allow_all_tools() {
765        let backend = CliBackend::copilot();
766        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
767
768        assert_eq!(cmd, "copilot");
769        assert_eq!(args, vec!["-p", "test prompt"]);
770        assert!(stdin.is_none());
771        assert!(!args.contains(&"--allow-all-tools".to_string()));
772    }
773
774    #[test]
775    fn test_claude_interactive_mode_unchanged() {
776        let backend = CliBackend::claude();
777        let (cmd, args_auto, stdin_auto, _) = backend.build_command("test prompt", false);
778        let (_, args_interactive, stdin_interactive, _) =
779            backend.build_command("test prompt", true);
780
781        assert_eq!(cmd, "claude");
782        assert_eq!(args_auto, args_interactive);
783        assert_eq!(
784            args_auto,
785            vec![
786                "--dangerously-skip-permissions",
787                "--verbose",
788                "--output-format",
789                "stream-json",
790                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet",
791                "-p",
792                "test prompt"
793            ]
794        );
795        // -p mode is used for both auto and interactive
796        assert!(stdin_auto.is_none());
797        assert!(stdin_interactive.is_none());
798    }
799
800    #[test]
801    fn test_gemini_interactive_mode_unchanged() {
802        let backend = CliBackend::gemini();
803        let (cmd, args_auto, stdin_auto, _) = backend.build_command("test prompt", false);
804        let (_, args_interactive, stdin_interactive, _) =
805            backend.build_command("test prompt", true);
806
807        assert_eq!(cmd, "gemini");
808        assert_eq!(args_auto, args_interactive);
809        assert_eq!(args_auto, vec!["--yolo", "-p", "test prompt"]);
810        assert_eq!(stdin_auto, stdin_interactive);
811        assert!(stdin_auto.is_none());
812    }
813
814    #[test]
815    fn test_custom_backend_with_prompt_flag_short() {
816        let config = CliConfig {
817            backend: "custom".to_string(),
818            command: Some("my-agent".to_string()),
819            prompt_mode: "arg".to_string(),
820            prompt_flag: Some("-p".to_string()),
821            ..Default::default()
822        };
823        let backend = CliBackend::from_config(&config).unwrap();
824        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
825
826        assert_eq!(cmd, "my-agent");
827        assert_eq!(args, vec!["-p", "test prompt"]);
828        assert!(stdin.is_none());
829    }
830
831    #[test]
832    fn test_custom_backend_with_prompt_flag_long() {
833        let config = CliConfig {
834            backend: "custom".to_string(),
835            command: Some("my-agent".to_string()),
836            prompt_mode: "arg".to_string(),
837            prompt_flag: Some("--prompt".to_string()),
838            ..Default::default()
839        };
840        let backend = CliBackend::from_config(&config).unwrap();
841        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
842
843        assert_eq!(cmd, "my-agent");
844        assert_eq!(args, vec!["--prompt", "test prompt"]);
845        assert!(stdin.is_none());
846    }
847
848    #[test]
849    fn test_custom_backend_without_prompt_flag_positional() {
850        let config = CliConfig {
851            backend: "custom".to_string(),
852            command: Some("my-agent".to_string()),
853            prompt_mode: "arg".to_string(),
854            prompt_flag: None,
855            ..Default::default()
856        };
857        let backend = CliBackend::from_config(&config).unwrap();
858        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
859
860        assert_eq!(cmd, "my-agent");
861        assert_eq!(args, vec!["test prompt"]);
862        assert!(stdin.is_none());
863    }
864
865    #[test]
866    fn test_custom_backend_without_command_returns_error() {
867        let config = CliConfig {
868            backend: "custom".to_string(),
869            command: None,
870            prompt_mode: "arg".to_string(),
871            ..Default::default()
872        };
873        let result = CliBackend::from_config(&config);
874
875        assert!(result.is_err());
876        let err = result.unwrap_err();
877        assert_eq!(
878            err.to_string(),
879            "custom backend requires a command to be specified"
880        );
881    }
882
883    #[test]
884    fn test_kiro_with_agent() {
885        let backend = CliBackend::kiro_with_agent("my-agent".to_string(), &[]);
886        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
887
888        assert_eq!(cmd, "kiro-cli");
889        assert_eq!(
890            args,
891            vec![
892                "chat",
893                "--no-interactive",
894                "--trust-all-tools",
895                "--agent",
896                "my-agent",
897                "test prompt"
898            ]
899        );
900        assert!(stdin.is_none());
901    }
902
903    #[test]
904    fn test_kiro_with_agent_extra_args() {
905        let extra_args = vec!["--verbose".to_string(), "--debug".to_string()];
906        let backend = CliBackend::kiro_with_agent("my-agent".to_string(), &extra_args);
907        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
908
909        assert_eq!(cmd, "kiro-cli");
910        assert_eq!(
911            args,
912            vec![
913                "chat",
914                "--no-interactive",
915                "--trust-all-tools",
916                "--agent",
917                "my-agent",
918                "--verbose",
919                "--debug",
920                "test prompt"
921            ]
922        );
923        assert!(stdin.is_none());
924    }
925
926    #[test]
927    fn test_from_name_claude() {
928        let backend = CliBackend::from_name("claude").unwrap();
929        assert_eq!(backend.command, "claude");
930        assert_eq!(backend.prompt_flag, Some("-p".to_string()));
931    }
932
933    #[test]
934    fn test_from_name_kiro() {
935        let backend = CliBackend::from_name("kiro").unwrap();
936        assert_eq!(backend.command, "kiro-cli");
937    }
938
939    #[test]
940    fn test_from_name_gemini() {
941        let backend = CliBackend::from_name("gemini").unwrap();
942        assert_eq!(backend.command, "gemini");
943    }
944
945    #[test]
946    fn test_from_name_codex() {
947        let backend = CliBackend::from_name("codex").unwrap();
948        assert_eq!(backend.command, "codex");
949    }
950
951    #[test]
952    fn test_from_name_amp() {
953        let backend = CliBackend::from_name("amp").unwrap();
954        assert_eq!(backend.command, "amp");
955    }
956
957    #[test]
958    fn test_from_name_copilot() {
959        let backend = CliBackend::from_name("copilot").unwrap();
960        assert_eq!(backend.command, "copilot");
961        assert_eq!(backend.prompt_flag, Some("-p".to_string()));
962    }
963
964    #[test]
965    fn test_from_name_invalid() {
966        let result = CliBackend::from_name("invalid");
967        assert!(result.is_err());
968    }
969
970    #[test]
971    fn test_from_hat_backend_named() {
972        let hat_backend = HatBackend::Named("claude".to_string());
973        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
974        assert_eq!(backend.command, "claude");
975    }
976
977    #[test]
978    fn test_from_hat_backend_kiro_agent() {
979        let hat_backend = HatBackend::KiroAgent {
980            backend_type: "kiro".to_string(),
981            agent: "my-agent".to_string(),
982            args: vec![],
983        };
984        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
985        let (cmd, args, _, _) = backend.build_command("test", false);
986        assert_eq!(cmd, "kiro-cli");
987        assert!(args.contains(&"--agent".to_string()));
988        assert!(args.contains(&"my-agent".to_string()));
989    }
990
991    #[test]
992    fn test_from_hat_backend_kiro_agent_with_args() {
993        let hat_backend = HatBackend::KiroAgent {
994            backend_type: "kiro".to_string(),
995            agent: "my-agent".to_string(),
996            args: vec!["--verbose".to_string()],
997        };
998        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
999        let (cmd, args, _, _) = backend.build_command("test", false);
1000        assert_eq!(cmd, "kiro-cli");
1001        assert!(args.contains(&"--agent".to_string()));
1002        assert!(args.contains(&"my-agent".to_string()));
1003        assert!(args.contains(&"--verbose".to_string()));
1004    }
1005
1006    #[test]
1007    fn test_from_hat_backend_named_with_args() {
1008        let hat_backend = HatBackend::NamedWithArgs {
1009            backend_type: "claude".to_string(),
1010            args: vec!["--model".to_string(), "claude-sonnet-4".to_string()],
1011        };
1012        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1013        assert_eq!(backend.command, "claude");
1014        assert!(backend.args.contains(&"--model".to_string()));
1015        assert!(backend.args.contains(&"claude-sonnet-4".to_string()));
1016    }
1017
1018    #[test]
1019    fn test_from_hat_backend_custom() {
1020        let hat_backend = HatBackend::Custom {
1021            command: "my-cli".to_string(),
1022            args: vec!["--flag".to_string()],
1023        };
1024        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
1025        assert_eq!(backend.command, "my-cli");
1026        assert_eq!(backend.args, vec!["--flag"]);
1027    }
1028
1029    // ─────────────────────────────────────────────────────────────────────────
1030    // Tests for interactive prompt backends
1031    // ─────────────────────────────────────────────────────────────────────────
1032
1033    #[test]
1034    fn test_for_interactive_prompt_claude() {
1035        let backend = CliBackend::for_interactive_prompt("claude").unwrap();
1036        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1037
1038        assert_eq!(cmd, "claude");
1039        // Should use positional arg (no -p flag)
1040        assert_eq!(
1041            args,
1042            vec![
1043                "--dangerously-skip-permissions",
1044                "--disallowedTools=TodoWrite,TaskCreate,TaskUpdate,TaskList,TaskGet",
1045                "test prompt"
1046            ]
1047        );
1048        assert!(stdin.is_none());
1049        assert_eq!(backend.prompt_flag, None);
1050    }
1051
1052    #[test]
1053    fn test_for_interactive_prompt_kiro() {
1054        let backend = CliBackend::for_interactive_prompt("kiro").unwrap();
1055        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1056
1057        assert_eq!(cmd, "kiro-cli");
1058        // Should NOT have --no-interactive
1059        assert_eq!(args, vec!["chat", "--trust-all-tools", "test prompt"]);
1060        assert!(!args.contains(&"--no-interactive".to_string()));
1061        assert!(stdin.is_none());
1062    }
1063
1064    #[test]
1065    fn test_for_interactive_prompt_gemini() {
1066        let backend = CliBackend::for_interactive_prompt("gemini").unwrap();
1067        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1068
1069        assert_eq!(cmd, "gemini");
1070        // Critical: should use -i flag, NOT -p
1071        assert_eq!(args, vec!["--yolo", "-i", "test prompt"]);
1072        assert_eq!(backend.prompt_flag, Some("-i".to_string()));
1073        assert!(stdin.is_none());
1074    }
1075
1076    #[test]
1077    fn test_for_interactive_prompt_codex() {
1078        let backend = CliBackend::for_interactive_prompt("codex").unwrap();
1079        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1080
1081        assert_eq!(cmd, "codex");
1082        // Should NOT have exec or --full-auto
1083        assert_eq!(args, vec!["test prompt"]);
1084        assert!(!args.contains(&"exec".to_string()));
1085        assert!(!args.contains(&"--full-auto".to_string()));
1086        assert!(stdin.is_none());
1087    }
1088
1089    #[test]
1090    fn test_for_interactive_prompt_amp() {
1091        let backend = CliBackend::for_interactive_prompt("amp").unwrap();
1092        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1093
1094        assert_eq!(cmd, "amp");
1095        // Should NOT have --dangerously-allow-all
1096        assert_eq!(args, vec!["-x", "test prompt"]);
1097        assert!(!args.contains(&"--dangerously-allow-all".to_string()));
1098        assert!(stdin.is_none());
1099    }
1100
1101    #[test]
1102    fn test_for_interactive_prompt_copilot() {
1103        let backend = CliBackend::for_interactive_prompt("copilot").unwrap();
1104        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1105
1106        assert_eq!(cmd, "copilot");
1107        // Should NOT have --allow-all-tools
1108        assert_eq!(args, vec!["-p", "test prompt"]);
1109        assert!(!args.contains(&"--allow-all-tools".to_string()));
1110        assert!(stdin.is_none());
1111    }
1112
1113    #[test]
1114    fn test_for_interactive_prompt_invalid() {
1115        let result = CliBackend::for_interactive_prompt("invalid_backend");
1116        assert!(result.is_err());
1117    }
1118
1119    // ─────────────────────────────────────────────────────────────────────────
1120    // Tests for OpenCode backend
1121    // ─────────────────────────────────────────────────────────────────────────
1122
1123    #[test]
1124    fn test_opencode_backend() {
1125        let backend = CliBackend::opencode();
1126        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1127
1128        assert_eq!(cmd, "opencode");
1129        // Uses `run` subcommand with positional prompt arg
1130        assert_eq!(args, vec!["run", "test prompt"]);
1131        assert!(stdin.is_none());
1132        assert_eq!(backend.output_format, OutputFormat::Text);
1133        assert_eq!(backend.prompt_flag, None);
1134    }
1135
1136    #[test]
1137    fn test_opencode_tui_backend() {
1138        let backend = CliBackend::opencode_tui();
1139        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1140
1141        assert_eq!(cmd, "opencode");
1142        // Uses `run` subcommand with positional prompt arg
1143        assert_eq!(args, vec!["run", "test prompt"]);
1144        assert!(stdin.is_none());
1145        assert_eq!(backend.output_format, OutputFormat::Text);
1146        assert_eq!(backend.prompt_flag, None);
1147    }
1148
1149    #[test]
1150    fn test_opencode_interactive_mode_unchanged() {
1151        // OpenCode has no flags to filter in interactive mode
1152        let backend = CliBackend::opencode();
1153        let (cmd, args_auto, stdin_auto, _) = backend.build_command("test prompt", false);
1154        let (_, args_interactive, stdin_interactive, _) =
1155            backend.build_command("test prompt", true);
1156
1157        assert_eq!(cmd, "opencode");
1158        // Should be identical in both modes
1159        assert_eq!(args_auto, args_interactive);
1160        assert_eq!(args_auto, vec!["run", "test prompt"]);
1161        assert!(stdin_auto.is_none());
1162        assert!(stdin_interactive.is_none());
1163    }
1164
1165    #[test]
1166    fn test_from_name_opencode() {
1167        let backend = CliBackend::from_name("opencode").unwrap();
1168        assert_eq!(backend.command, "opencode");
1169        assert_eq!(backend.prompt_flag, None); // Positional argument
1170    }
1171
1172    #[test]
1173    fn test_for_interactive_prompt_opencode() {
1174        let backend = CliBackend::for_interactive_prompt("opencode").unwrap();
1175        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
1176
1177        assert_eq!(cmd, "opencode");
1178        // Uses --prompt flag for TUI mode (no `run` subcommand)
1179        assert_eq!(args, vec!["--prompt", "test prompt"]);
1180        assert!(stdin.is_none());
1181        assert_eq!(backend.prompt_flag, Some("--prompt".to_string()));
1182    }
1183
1184    #[test]
1185    fn test_opencode_interactive_launches_tui_not_headless() {
1186        // Issue #96: opencode backend doesn't start interactive session with ralph plan
1187        //
1188        // The bug: opencode_interactive() uses `opencode run "prompt"` which is headless mode.
1189        // The fix: Interactive mode should use `opencode --prompt "prompt"` (without `run`)
1190        // to launch the TUI with an initial prompt.
1191        //
1192        // From `opencode --help`:
1193        // - `opencode [project]` = start opencode tui (interactive mode) [default]
1194        // - `opencode run [message..]` = run opencode with a message (headless mode)
1195        let backend = CliBackend::opencode_interactive();
1196        let (cmd, args, _, _) = backend.build_command("test prompt", true);
1197
1198        assert_eq!(cmd, "opencode");
1199        // Interactive mode should NOT include "run" subcommand
1200        // `run` makes opencode execute headlessly, which defeats the purpose of interactive mode
1201        assert!(
1202            !args.contains(&"run".to_string()),
1203            "opencode_interactive() should not use 'run' subcommand. \
1204             'opencode run' is headless mode, but interactive mode needs TUI. \
1205             Expected: opencode --prompt \"test prompt\", got: opencode {}",
1206            args.join(" ")
1207        );
1208        // Should pass prompt via --prompt flag for TUI mode
1209        assert!(
1210            args.contains(&"--prompt".to_string()),
1211            "opencode_interactive() should use --prompt flag for TUI mode. \
1212             Expected args to contain '--prompt', got: {:?}",
1213            args
1214        );
1215    }
1216
1217    #[test]
1218    fn test_custom_args_can_be_appended() {
1219        // Verify that custom args can be appended to backend args
1220        // This is used for `ralph run -b opencode -- --model="some-model"`
1221        let mut backend = CliBackend::opencode();
1222
1223        // Append custom args
1224        let custom_args = vec!["--model=gpt-4".to_string(), "--temperature=0.7".to_string()];
1225        backend.args.extend(custom_args.clone());
1226
1227        // Build command and verify custom args are included
1228        let (cmd, args, _, _) = backend.build_command("test prompt", false);
1229
1230        assert_eq!(cmd, "opencode");
1231        // Should have: original args + custom args + prompt
1232        assert!(args.contains(&"run".to_string())); // Original arg
1233        assert!(args.contains(&"--model=gpt-4".to_string())); // Custom arg
1234        assert!(args.contains(&"--temperature=0.7".to_string())); // Custom arg
1235        assert!(args.contains(&"test prompt".to_string())); // Prompt
1236
1237        // Verify order: original args come before custom args
1238        let run_idx = args.iter().position(|a| a == "run").unwrap();
1239        let model_idx = args.iter().position(|a| a == "--model=gpt-4").unwrap();
1240        assert!(
1241            run_idx < model_idx,
1242            "Original args should come before custom args"
1243        );
1244    }
1245}