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