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