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