1use ralph_core::{CliConfig, HatBackend};
4use std::fmt;
5use std::io::Write;
6use tempfile::NamedTempFile;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum OutputFormat {
14 #[default]
16 Text,
17 StreamJson,
19}
20
21#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PromptMode {
36 Arg,
38 Stdin,
40}
41
42#[derive(Debug, Clone)]
44pub struct CliBackend {
45 pub command: String,
47 pub args: Vec<String>,
49 pub prompt_mode: PromptMode,
51 pub prompt_flag: Option<String>,
53 pub output_format: OutputFormat,
55}
56
57impl CliBackend {
58 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()), }
72 }
73
74 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 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, output_format: OutputFormat::Text, }
115 }
116
117 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 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 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 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 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 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, output_format: OutputFormat::Text,
205 }
206 }
207
208 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 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 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 if interactive {
250 args = self.filter_args_for_interactive(args);
251 }
252
253 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 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 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 tracing::trace!(prompt = %prompt, "Full prompt content");
298
299 (self.command.clone(), args, stdin_input, temp_file)
300 }
301
302 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, }
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()); 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 assert_eq!(args, vec!["--dangerously-skip-permissions", "test prompt"]);
344 assert!(stdin.is_none()); 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 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 assert!(temp.is_some());
359 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 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 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}