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            "custom" => Self::custom(config),
70            _ => Ok(Self::claude()), // Default to claude
71        }
72    }
73
74    /// Creates the Claude backend.
75    ///
76    /// Uses `-p` flag for headless/print mode execution. This runs Claude
77    /// in non-interactive mode where it executes the prompt and exits.
78    /// For interactive mode, stdin is used instead (handled in build_command).
79    ///
80    /// Emits `--output-format stream-json` for NDJSON streaming output.
81    /// Note: `--verbose` is required when using `--output-format stream-json` with `-p`.
82    pub fn claude() -> Self {
83        Self {
84            command: "claude".to_string(),
85            args: vec![
86                "--dangerously-skip-permissions".to_string(),
87                "--verbose".to_string(),
88                "--output-format".to_string(),
89                "stream-json".to_string(),
90            ],
91            prompt_mode: PromptMode::Arg,
92            prompt_flag: Some("-p".to_string()),
93            output_format: OutputFormat::StreamJson,
94        }
95    }
96
97    /// Creates the Claude TUI backend for interactive mode.
98    ///
99    /// Runs Claude in full interactive mode (no -p flag), allowing
100    /// Claude's native TUI to render. The prompt is passed as a
101    /// positional argument.
102    ///
103    /// Unlike the standard `claude()` backend:
104    /// - No `-p` flag (enters interactive TUI mode)
105    /// - No `--output-format stream-json` (raw terminal output)
106    /// - Prompt is a positional argument, not a flag value
107    pub fn claude_tui() -> Self {
108        Self {
109            command: "claude".to_string(),
110            args: vec!["--dangerously-skip-permissions".to_string()],
111            prompt_mode: PromptMode::Arg,
112            prompt_flag: None, // No -p flag - prompt is positional
113            output_format: OutputFormat::Text, // Not stream-json
114        }
115    }
116
117    /// Creates the Kiro backend.
118    ///
119    /// Uses kiro-cli in headless mode with all tools trusted.
120    pub fn kiro() -> Self {
121        Self {
122            command: "kiro-cli".to_string(),
123            args: vec![
124                "chat".to_string(),
125                "--no-interactive".to_string(),
126                "--trust-all-tools".to_string(),
127            ],
128            prompt_mode: PromptMode::Arg,
129            prompt_flag: None,
130            output_format: OutputFormat::Text,
131        }
132    }
133
134    /// Creates the Kiro backend with a specific agent.
135    ///
136    /// Uses kiro-cli with --agent flag to select a specific agent.
137    pub fn kiro_with_agent(agent: String) -> Self {
138        Self {
139            command: "kiro-cli".to_string(),
140            args: vec![
141                "chat".to_string(),
142                "--no-interactive".to_string(),
143                "--trust-all-tools".to_string(),
144                "--agent".to_string(),
145                agent,
146            ],
147            prompt_mode: PromptMode::Arg,
148            prompt_flag: None,
149            output_format: OutputFormat::Text,
150        }
151    }
152
153    /// Creates a backend from a named backend string.
154    ///
155    /// # Errors
156    /// Returns error if the backend name is invalid.
157    pub fn from_name(name: &str) -> Result<Self, CustomBackendError> {
158        match name {
159            "claude" => Ok(Self::claude()),
160            "kiro" => Ok(Self::kiro()),
161            "gemini" => Ok(Self::gemini()),
162            "codex" => Ok(Self::codex()),
163            "amp" => Ok(Self::amp()),
164            _ => Err(CustomBackendError),
165        }
166    }
167
168    /// Creates a backend from a HatBackend configuration.
169    ///
170    /// # Errors
171    /// Returns error if the backend configuration is invalid.
172    pub fn from_hat_backend(hat_backend: &HatBackend) -> Result<Self, CustomBackendError> {
173        match hat_backend {
174            HatBackend::Named(name) => Self::from_name(name),
175            HatBackend::KiroAgent { agent, .. } => Ok(Self::kiro_with_agent(agent.clone())),
176            HatBackend::Custom { command, args } => Ok(Self {
177                command: command.clone(),
178                args: args.clone(),
179                prompt_mode: PromptMode::Arg,
180                prompt_flag: None,
181                output_format: OutputFormat::Text,
182            }),
183        }
184    }
185
186    /// Creates the Gemini backend.
187    pub fn gemini() -> Self {
188        Self {
189            command: "gemini".to_string(),
190            args: vec!["--yolo".to_string()],
191            prompt_mode: PromptMode::Arg,
192            prompt_flag: Some("-p".to_string()),
193            output_format: OutputFormat::Text,
194        }
195    }
196
197    /// Creates the Codex backend.
198    pub fn codex() -> Self {
199        Self {
200            command: "codex".to_string(),
201            args: vec!["exec".to_string(), "--full-auto".to_string()],
202            prompt_mode: PromptMode::Arg,
203            prompt_flag: None, // Positional argument
204            output_format: OutputFormat::Text,
205        }
206    }
207
208    /// Creates the Amp backend.
209    pub fn amp() -> Self {
210        Self {
211            command: "amp".to_string(),
212            args: vec!["--dangerously-allow-all".to_string()],
213            prompt_mode: PromptMode::Arg,
214            prompt_flag: Some("-x".to_string()),
215            output_format: OutputFormat::Text,
216        }
217    }
218
219    /// Creates a custom backend from configuration.
220    ///
221    /// # Errors
222    /// Returns `CustomBackendError` if no command is specified.
223    pub fn custom(config: &CliConfig) -> Result<Self, CustomBackendError> {
224        let command = config.command.clone().ok_or(CustomBackendError)?;
225        let prompt_mode = if config.prompt_mode == "stdin" {
226            PromptMode::Stdin
227        } else {
228            PromptMode::Arg
229        };
230
231        Ok(Self {
232            command,
233            args: config.args.clone(),
234            prompt_mode,
235            prompt_flag: config.prompt_flag.clone(),
236            output_format: OutputFormat::Text,
237        })
238    }
239
240    /// Builds the full command with arguments for execution.
241    ///
242    /// # Arguments
243    /// * `prompt` - The prompt text to pass to the agent
244    /// * `interactive` - Whether to run in interactive mode (affects agent flags)
245    pub fn build_command(&self, prompt: &str, interactive: bool) -> (String, Vec<String>, Option<String>, Option<NamedTempFile>) {
246        let mut args = self.args.clone();
247
248        // Filter args based on execution mode per interactive-mode.spec.md
249        if interactive {
250            args = self.filter_args_for_interactive(args);
251        }
252
253        // Handle large prompts for Claude (>7000 chars)
254        let (stdin_input, temp_file) = match self.prompt_mode {
255            PromptMode::Arg => {
256                let (prompt_text, temp_file) = if self.command == "claude" && prompt.len() > 7000 {
257                    // Write to temp file and instruct Claude to read it
258                    match NamedTempFile::new() {
259                        Ok(mut file) => {
260                            if let Err(e) = file.write_all(prompt.as_bytes()) {
261                                tracing::warn!("Failed to write prompt to temp file: {}", e);
262                                (prompt.to_string(), None)
263                            } else {
264                                let path = file.path().display().to_string();
265                                (format!("Please read and execute the task in {}", path), Some(file))
266                            }
267                        }
268                        Err(e) => {
269                            tracing::warn!("Failed to create temp file: {}", e);
270                            (prompt.to_string(), None)
271                        }
272                    }
273                } else {
274                    (prompt.to_string(), None)
275                };
276
277                if let Some(ref flag) = self.prompt_flag {
278                    args.push(flag.clone());
279                }
280                args.push(prompt_text);
281                (None, temp_file)
282            }
283            PromptMode::Stdin => (Some(prompt.to_string()), None),
284        };
285
286        // Log the full command being built
287        tracing::debug!(
288            command = %self.command,
289            args_count = args.len(),
290            prompt_len = prompt.len(),
291            interactive = interactive,
292            uses_stdin = stdin_input.is_some(),
293            uses_temp_file = temp_file.is_some(),
294            "Built CLI command"
295        );
296        // Log full prompt at trace level for debugging
297        tracing::trace!(prompt = %prompt, "Full prompt content");
298
299        (self.command.clone(), args, stdin_input, temp_file)
300    }
301
302    /// Filters args for interactive mode per spec table.
303    fn filter_args_for_interactive(&self, args: Vec<String>) -> Vec<String> {
304        match self.command.as_str() {
305            "kiro-cli" => args.into_iter().filter(|a| a != "--no-interactive").collect(),
306            "codex" => args.into_iter().filter(|a| a != "--full-auto").collect(),
307            "amp" => args.into_iter().filter(|a| a != "--dangerously-allow-all").collect(),
308            _ => args, // claude, gemini unchanged
309        }
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_claude_backend() {
319        let backend = CliBackend::claude();
320        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
321
322        assert_eq!(cmd, "claude");
323        assert_eq!(args, vec![
324            "--dangerously-skip-permissions",
325            "--verbose",
326            "--output-format",
327            "stream-json",
328            "-p",
329            "test prompt"
330        ]);
331        assert!(stdin.is_none()); // Uses -p flag, not stdin
332        assert_eq!(backend.output_format, OutputFormat::StreamJson);
333    }
334
335    #[test]
336    fn test_claude_tui_backend() {
337        let backend = CliBackend::claude_tui();
338        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
339
340        assert_eq!(cmd, "claude");
341        // Should have --dangerously-skip-permissions and prompt as positional arg
342        // No -p flag, no --output-format, no --verbose
343        assert_eq!(args, vec!["--dangerously-skip-permissions", "test prompt"]);
344        assert!(stdin.is_none()); // Uses positional arg, not stdin
345        assert_eq!(backend.output_format, OutputFormat::Text);
346        assert_eq!(backend.prompt_flag, None);
347    }
348
349    #[test]
350    fn test_claude_large_prompt_uses_temp_file() {
351        // With -p mode, large prompts (>7000 chars) use temp file to avoid CLI issues
352        let backend = CliBackend::claude();
353        let large_prompt = "x".repeat(7001);
354        let (cmd, args, _stdin, temp) = backend.build_command(&large_prompt, false);
355
356        assert_eq!(cmd, "claude");
357        // Should have temp file for large prompts
358        assert!(temp.is_some());
359        // Args should contain instruction to read from temp file
360        assert!(args.iter().any(|a| a.contains("Please read and execute")));
361    }
362
363    #[test]
364    fn test_non_claude_large_prompt() {
365        let backend = CliBackend::kiro();
366        let large_prompt = "x".repeat(7001);
367        let (cmd, args, stdin, temp) = backend.build_command(&large_prompt, false);
368
369        assert_eq!(cmd, "kiro-cli");
370        assert_eq!(args[3], large_prompt);
371        assert!(stdin.is_none());
372        assert!(temp.is_none());
373    }
374
375    #[test]
376    fn test_kiro_backend() {
377        let backend = CliBackend::kiro();
378        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
379
380        assert_eq!(cmd, "kiro-cli");
381        assert_eq!(
382            args,
383            vec!["chat", "--no-interactive", "--trust-all-tools", "test prompt"]
384        );
385        assert!(stdin.is_none());
386    }
387
388    #[test]
389    fn test_gemini_backend() {
390        let backend = CliBackend::gemini();
391        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
392
393        assert_eq!(cmd, "gemini");
394        assert_eq!(args, vec!["--yolo", "-p", "test prompt"]);
395        assert!(stdin.is_none());
396    }
397
398    #[test]
399    fn test_codex_backend() {
400        let backend = CliBackend::codex();
401        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
402
403        assert_eq!(cmd, "codex");
404        assert_eq!(args, vec!["exec", "--full-auto", "test prompt"]);
405        assert!(stdin.is_none());
406    }
407
408    #[test]
409    fn test_amp_backend() {
410        let backend = CliBackend::amp();
411        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
412
413        assert_eq!(cmd, "amp");
414        assert_eq!(args, vec!["--dangerously-allow-all", "-x", "test prompt"]);
415        assert!(stdin.is_none());
416    }
417
418    #[test]
419    fn test_from_config() {
420        // Claude backend uses -p arg mode for headless execution
421        let config = CliConfig {
422            backend: "claude".to_string(),
423            command: None,
424            prompt_mode: "arg".to_string(),
425            ..Default::default()
426        };
427        let backend = CliBackend::from_config(&config).unwrap();
428
429        assert_eq!(backend.command, "claude");
430        assert_eq!(backend.prompt_mode, PromptMode::Arg);
431        assert_eq!(backend.prompt_flag, Some("-p".to_string()));
432    }
433
434    #[test]
435    fn test_kiro_interactive_mode_omits_no_interactive_flag() {
436        let backend = CliBackend::kiro();
437        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
438
439        assert_eq!(cmd, "kiro-cli");
440        assert_eq!(args, vec!["chat", "--trust-all-tools", "test prompt"]);
441        assert!(stdin.is_none());
442        assert!(!args.contains(&"--no-interactive".to_string()));
443    }
444
445    #[test]
446    fn test_codex_interactive_mode_omits_full_auto() {
447        let backend = CliBackend::codex();
448        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
449
450        assert_eq!(cmd, "codex");
451        assert_eq!(args, vec!["exec", "test prompt"]);
452        assert!(stdin.is_none());
453        assert!(!args.contains(&"--full-auto".to_string()));
454    }
455
456    #[test]
457    fn test_amp_interactive_mode_no_flags() {
458        let backend = CliBackend::amp();
459        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", true);
460
461        assert_eq!(cmd, "amp");
462        assert_eq!(args, vec!["-x", "test prompt"]);
463        assert!(stdin.is_none());
464        assert!(!args.contains(&"--dangerously-allow-all".to_string()));
465    }
466
467    #[test]
468    fn test_claude_interactive_mode_unchanged() {
469        let backend = CliBackend::claude();
470        let (cmd, args_auto, stdin_auto, _) = backend.build_command("test prompt", false);
471        let (_, args_interactive, stdin_interactive, _) = backend.build_command("test prompt", true);
472
473        assert_eq!(cmd, "claude");
474        assert_eq!(args_auto, args_interactive);
475        assert_eq!(args_auto, vec![
476            "--dangerously-skip-permissions",
477            "--verbose",
478            "--output-format",
479            "stream-json",
480            "-p",
481            "test prompt"
482        ]);
483        // -p mode is used for both auto and interactive
484        assert!(stdin_auto.is_none());
485        assert!(stdin_interactive.is_none());
486    }
487
488    #[test]
489    fn test_gemini_interactive_mode_unchanged() {
490        let backend = CliBackend::gemini();
491        let (cmd, args_auto, stdin_auto, _) = backend.build_command("test prompt", false);
492        let (_, args_interactive, stdin_interactive, _) = backend.build_command("test prompt", true);
493
494        assert_eq!(cmd, "gemini");
495        assert_eq!(args_auto, args_interactive);
496        assert_eq!(args_auto, vec!["--yolo", "-p", "test prompt"]);
497        assert_eq!(stdin_auto, stdin_interactive);
498        assert!(stdin_auto.is_none());
499    }
500
501    #[test]
502    fn test_custom_backend_with_prompt_flag_short() {
503        let config = CliConfig {
504            backend: "custom".to_string(),
505            command: Some("my-agent".to_string()),
506            prompt_mode: "arg".to_string(),
507            prompt_flag: Some("-p".to_string()),
508            ..Default::default()
509        };
510        let backend = CliBackend::from_config(&config).unwrap();
511        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
512
513        assert_eq!(cmd, "my-agent");
514        assert_eq!(args, vec!["-p", "test prompt"]);
515        assert!(stdin.is_none());
516    }
517
518    #[test]
519    fn test_custom_backend_with_prompt_flag_long() {
520        let config = CliConfig {
521            backend: "custom".to_string(),
522            command: Some("my-agent".to_string()),
523            prompt_mode: "arg".to_string(),
524            prompt_flag: Some("--prompt".to_string()),
525            ..Default::default()
526        };
527        let backend = CliBackend::from_config(&config).unwrap();
528        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
529
530        assert_eq!(cmd, "my-agent");
531        assert_eq!(args, vec!["--prompt", "test prompt"]);
532        assert!(stdin.is_none());
533    }
534
535    #[test]
536    fn test_custom_backend_without_prompt_flag_positional() {
537        let config = CliConfig {
538            backend: "custom".to_string(),
539            command: Some("my-agent".to_string()),
540            prompt_mode: "arg".to_string(),
541            prompt_flag: None,
542            ..Default::default()
543        };
544        let backend = CliBackend::from_config(&config).unwrap();
545        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
546
547        assert_eq!(cmd, "my-agent");
548        assert_eq!(args, vec!["test prompt"]);
549        assert!(stdin.is_none());
550    }
551
552    #[test]
553    fn test_custom_backend_without_command_returns_error() {
554        let config = CliConfig {
555            backend: "custom".to_string(),
556            command: None,
557            prompt_mode: "arg".to_string(),
558            ..Default::default()
559        };
560        let result = CliBackend::from_config(&config);
561
562        assert!(result.is_err());
563        let err = result.unwrap_err();
564        assert_eq!(
565            err.to_string(),
566            "custom backend requires a command to be specified"
567        );
568    }
569
570    #[test]
571    fn test_kiro_with_agent() {
572        let backend = CliBackend::kiro_with_agent("my-agent".to_string());
573        let (cmd, args, stdin, _temp) = backend.build_command("test prompt", false);
574
575        assert_eq!(cmd, "kiro-cli");
576        assert_eq!(
577            args,
578            vec!["chat", "--no-interactive", "--trust-all-tools", "--agent", "my-agent", "test prompt"]
579        );
580        assert!(stdin.is_none());
581    }
582
583    #[test]
584    fn test_from_name_claude() {
585        let backend = CliBackend::from_name("claude").unwrap();
586        assert_eq!(backend.command, "claude");
587        assert_eq!(backend.prompt_flag, Some("-p".to_string()));
588    }
589
590    #[test]
591    fn test_from_name_kiro() {
592        let backend = CliBackend::from_name("kiro").unwrap();
593        assert_eq!(backend.command, "kiro-cli");
594    }
595
596    #[test]
597    fn test_from_name_gemini() {
598        let backend = CliBackend::from_name("gemini").unwrap();
599        assert_eq!(backend.command, "gemini");
600    }
601
602    #[test]
603    fn test_from_name_codex() {
604        let backend = CliBackend::from_name("codex").unwrap();
605        assert_eq!(backend.command, "codex");
606    }
607
608    #[test]
609    fn test_from_name_amp() {
610        let backend = CliBackend::from_name("amp").unwrap();
611        assert_eq!(backend.command, "amp");
612    }
613
614    #[test]
615    fn test_from_name_invalid() {
616        let result = CliBackend::from_name("invalid");
617        assert!(result.is_err());
618    }
619
620    #[test]
621    fn test_from_hat_backend_named() {
622        let hat_backend = HatBackend::Named("claude".to_string());
623        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
624        assert_eq!(backend.command, "claude");
625    }
626
627    #[test]
628    fn test_from_hat_backend_kiro_agent() {
629        let hat_backend = HatBackend::KiroAgent {
630            backend_type: "kiro".to_string(),
631            agent: "my-agent".to_string(),
632        };
633        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
634        let (cmd, args, _, _) = backend.build_command("test", false);
635        assert_eq!(cmd, "kiro-cli");
636        assert!(args.contains(&"--agent".to_string()));
637        assert!(args.contains(&"my-agent".to_string()));
638    }
639
640    #[test]
641    fn test_from_hat_backend_custom() {
642        let hat_backend = HatBackend::Custom {
643            command: "my-cli".to_string(),
644            args: vec!["--flag".to_string()],
645        };
646        let backend = CliBackend::from_hat_backend(&hat_backend).unwrap();
647        assert_eq!(backend.command, "my-cli");
648        assert_eq!(backend.args, vec!["--flag"]);
649    }
650}