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