1use std::process::Stdio;
23use std::time::Duration;
24
25use crate::config::{
26 CliPreset, CliSection, JsonPaths, LineMatchers, OutputMode, PromptDelivery, Provider,
27};
28use crate::error::RecallError;
29use crate::graph::error::GraphError;
30use crate::graph::llm::LlmProvider;
31
32const STDERR_EXCERPT: usize = 300;
34const DEFAULT_TIMEOUT_SECS: u64 = 300;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct CliSpec {
45 pub command: String,
47 pub command_env: Option<String>,
49 pub args: Vec<String>,
51 pub prompt_delivery: PromptDelivery,
53 pub prompt_flag: String,
55 pub model_flag: String,
57 pub default_model: String,
59 pub output_format_flag: String,
61 pub output_format_value: String,
63 pub system_prompt_flag: String,
65 pub output_mode: OutputMode,
67 pub result_json_paths: JsonPaths,
70 pub ndjson_match: LineMatchers,
72 pub extra_args: Vec<String>,
74 pub timeout: Option<Duration>,
76 pub env_remove: Vec<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Invocation {
83 pub argv: Vec<String>,
85 pub stdin: Option<String>,
87}
88
89impl CliSpec {
90 #[must_use]
98 pub fn preset(preset: CliPreset) -> Self {
99 match preset {
100 CliPreset::ClaudeCode => Self {
105 command: "claude".into(),
106 command_env: Some("CLAUDE_BIN".into()),
107 args: vec!["-p".into()],
108 prompt_delivery: PromptDelivery::Stdin,
109 prompt_flag: String::new(),
110 model_flag: "--model".into(),
111 default_model: "sonnet".into(),
112 output_format_flag: "--output-format".into(),
113 output_format_value: "text".into(),
114 system_prompt_flag: "--system-prompt".into(),
115 output_mode: OutputMode::Raw,
116 result_json_paths: JsonPaths::default(),
117 ndjson_match: LineMatchers::default(),
118 extra_args: vec!["--no-session-persistence".into()],
119 timeout: default_timeout(),
120 env_remove: vec!["CLAUDECODE".into()],
121 },
122 CliPreset::Gemini => Self {
127 command: "gemini".into(),
128 command_env: Some("GEMINI_BIN".into()),
129 args: Vec::new(),
130 prompt_delivery: PromptDelivery::Flag,
131 prompt_flag: "-p".into(),
132 model_flag: "-m".into(),
133 default_model: String::new(),
134 output_format_flag: "-o".into(),
135 output_format_value: "json".into(),
136 system_prompt_flag: String::new(),
137 output_mode: OutputMode::SingleJson,
138 result_json_paths: JsonPaths::new(["response".into(), "result".into()]),
139 ndjson_match: LineMatchers::default(),
140 extra_args: Vec::new(),
141 timeout: default_timeout(),
142 env_remove: Vec::new(),
143 },
144 CliPreset::Grok => Self {
149 command: "grok".into(),
150 command_env: Some("GROK_BIN".into()),
151 args: Vec::new(),
152 prompt_delivery: PromptDelivery::Flag,
153 prompt_flag: "-p".into(),
154 model_flag: "-m".into(),
155 default_model: String::new(),
156 output_format_flag: "--output-format".into(),
157 output_format_value: "json".into(),
158 system_prompt_flag: String::new(),
159 output_mode: OutputMode::SingleJson,
160 result_json_paths: JsonPaths::new(["text".into()]),
161 ndjson_match: LineMatchers::default(),
162 extra_args: Vec::new(),
163 timeout: default_timeout(),
164 env_remove: Vec::new(),
165 },
166 CliPreset::Codex => Self {
175 command: "codex".into(),
176 command_env: Some("CODEX_BIN".into()),
177 args: vec!["exec".into()],
178 prompt_delivery: PromptDelivery::Stdin,
179 prompt_flag: String::new(),
180 model_flag: "-m".into(),
181 default_model: String::new(),
182 output_format_flag: "--json".into(),
183 output_format_value: String::new(),
184 system_prompt_flag: String::new(),
185 output_mode: OutputMode::Ndjson,
186 result_json_paths: JsonPaths::new(["item.text".into()]),
187 ndjson_match: LineMatchers::new([
188 "type=item.completed".into(),
189 "item.type=agent_message".into(),
190 ]),
191 extra_args: vec!["--skip-git-repo-check".into()],
192 timeout: default_timeout(),
193 env_remove: Vec::new(),
194 },
195 CliPreset::Custom => Self {
197 command: String::new(),
198 command_env: Some("RECALL_CLI_BIN".into()),
199 args: Vec::new(),
200 prompt_delivery: PromptDelivery::Stdin,
201 prompt_flag: String::new(),
202 model_flag: String::new(),
203 default_model: String::new(),
204 output_format_flag: String::new(),
205 output_format_value: String::new(),
206 system_prompt_flag: String::new(),
207 output_mode: OutputMode::Raw,
208 result_json_paths: JsonPaths::default(),
209 ndjson_match: LineMatchers::default(),
210 extra_args: Vec::new(),
211 timeout: default_timeout(),
212 env_remove: Vec::new(),
213 },
214 }
215 }
216
217 pub fn resolve(provider: &Provider, section: &CliSection) -> Result<Self, RecallError> {
222 let preset = section
223 .preset
224 .or_else(|| provider.default_cli_preset())
225 .ok_or_else(|| {
226 RecallError::Config(format!(
227 "provider {provider} is not a CLI provider — use create_provider()"
228 ))
229 })?;
230
231 let mut spec = Self::preset(preset);
232 spec.apply(section);
233 spec.validate(provider)?;
234 Ok(spec)
235 }
236
237 fn apply(&mut self, section: &CliSection) {
238 if let Some(command) = §ion.command {
239 self.command = command.clone();
240 self.command_env = None;
241 }
242 if let Some(args) = §ion.args {
243 self.args = args.clone();
244 }
245 if let Some(delivery) = section.prompt_delivery {
246 self.prompt_delivery = delivery;
247 }
248 if let Some(flag) = §ion.prompt_flag {
249 self.prompt_flag = flag.clone();
250 }
251 if let Some(flag) = §ion.model_flag {
252 self.model_flag = flag.clone();
253 }
254 if let Some(flag) = §ion.output_format_flag {
255 self.output_format_flag = flag.clone();
256 }
257 if let Some(value) = §ion.output_format_value {
258 self.output_format_value = value.clone();
259 }
260 if let Some(flag) = §ion.system_prompt_flag {
261 self.system_prompt_flag = flag.clone();
262 }
263 if let Some(paths) = §ion.result_json_path {
264 self.result_json_paths = paths.clone();
265 if self.output_mode == OutputMode::Raw && !paths.is_empty() {
269 self.output_mode = OutputMode::SingleJson;
270 }
271 }
272 if let Some(matchers) = §ion.ndjson_match {
273 self.ndjson_match = matchers.clone();
274 }
275 if let Some(mode) = section.output_mode {
276 self.output_mode = mode;
277 }
278 if let Some(args) = §ion.extra_args {
279 self.extra_args = args.clone();
280 }
281 if let Some(secs) = section.timeout_secs {
282 self.timeout = (secs > 0).then(|| Duration::from_secs(secs));
283 }
284 }
285
286 fn validate(&self, provider: &Provider) -> Result<(), RecallError> {
287 if self.resolve_command().trim().is_empty() {
288 return Err(RecallError::Config(format!(
289 "provider {provider} has no command — set `[llm.cli] command = \"<binary>\"`"
290 )));
291 }
292 if self.prompt_delivery == PromptDelivery::Flag && self.prompt_flag.is_empty() {
293 return Err(RecallError::Config(format!(
294 "provider {provider} delivers the prompt by flag but sets no \
295 `[llm.cli] prompt_flag`"
296 )));
297 }
298 Ok(())
299 }
300
301 #[must_use]
304 pub fn resolve_command(&self) -> String {
305 self.command_env
306 .as_ref()
307 .and_then(|key| std::env::var(key).ok())
308 .filter(|value| !value.trim().is_empty())
309 .unwrap_or_else(|| self.command.clone())
310 }
311
312 #[must_use]
314 pub fn resolve_model(&self, configured: &str) -> String {
315 if configured.is_empty() {
316 self.default_model.clone()
317 } else {
318 configured.to_string()
319 }
320 }
321
322 #[must_use]
324 pub fn invocation(&self, model: &str, system_prompt: &str, user_message: &str) -> Invocation {
325 let mut argv = vec![self.resolve_command()];
326 argv.extend(self.args.iter().cloned());
327
328 if !self.model_flag.is_empty() && !model.is_empty() {
329 argv.push(self.model_flag.clone());
330 argv.push(model.to_string());
331 }
332 if !self.output_format_flag.is_empty() {
334 argv.push(self.output_format_flag.clone());
335 if !self.output_format_value.is_empty() {
336 argv.push(self.output_format_value.clone());
337 }
338 }
339
340 let prompt = if self.system_prompt_flag.is_empty() {
341 fold_system_prompt(system_prompt, user_message)
342 } else {
343 argv.push(self.system_prompt_flag.clone());
344 argv.push(system_prompt.to_string());
345 user_message.to_string()
346 };
347
348 argv.extend(self.extra_args.iter().cloned());
349
350 let stdin = match self.prompt_delivery {
351 PromptDelivery::Stdin => Some(prompt),
352 PromptDelivery::Flag => {
353 argv.push(self.prompt_flag.clone());
354 argv.push(prompt);
355 None
356 }
357 PromptDelivery::Arg => {
358 argv.push(prompt);
359 None
360 }
361 };
362
363 Invocation { argv, stdin }
364 }
365
366 #[must_use]
369 pub fn argv_preview(&self, model: &str) -> String {
370 let invocation = self.invocation(model, "<system>", "<prompt>");
371 let mut parts: Vec<String> = invocation.argv.iter().map(|arg| quote(arg)).collect();
372 if invocation.stdin.is_some() {
373 parts.push("< <prompt>".into());
374 }
375 parts.join(" ")
376 }
377}
378
379fn quote(arg: &str) -> String {
381 if arg.chars().any(char::is_whitespace) {
382 format!("\"{}\"", arg.replace('\n', "\\n"))
383 } else {
384 arg.to_string()
385 }
386}
387
388fn fold_system_prompt(system_prompt: &str, user_message: &str) -> String {
390 if system_prompt.is_empty() {
391 user_message.to_string()
392 } else {
393 format!("{system_prompt}\n\n{user_message}")
394 }
395}
396
397fn default_timeout() -> Option<Duration> {
398 Some(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
399}
400
401pub struct CliProvider {
408 spec: CliSpec,
409 model: String,
410}
411
412impl CliProvider {
413 #[must_use]
414 pub fn new(spec: CliSpec, model: String) -> Self {
415 Self { spec, model }
416 }
417
418 #[must_use]
420 pub fn spec(&self) -> &CliSpec {
421 &self.spec
422 }
423}
424
425#[async_trait::async_trait]
426impl LlmProvider for CliProvider {
427 async fn complete(
428 &self,
429 system_prompt: &str,
430 user_message: &str,
431 _max_tokens: u32,
432 ) -> Result<String, GraphError> {
433 let invocation = self
434 .spec
435 .invocation(&self.model, system_prompt, user_message);
436 let output = self.run(&invocation).await?;
437 extract_answer(&output, &self.spec)
438 }
439}
440
441impl CliProvider {
442 async fn run(&self, invocation: &Invocation) -> Result<String, GraphError> {
444 let (binary, args) = invocation
445 .argv
446 .split_first()
447 .ok_or_else(|| GraphError::Llm("empty CLI invocation".into()))?;
448
449 let mut command = tokio::process::Command::new(binary);
450 command
451 .args(args)
452 .stdin(if invocation.stdin.is_some() {
453 Stdio::piped()
454 } else {
455 Stdio::null()
456 })
457 .stdout(Stdio::piped())
458 .stderr(Stdio::piped())
459 .kill_on_drop(true);
462 for key in &self.spec.env_remove {
463 command.env_remove(key);
464 }
465
466 let mut child = command
467 .spawn()
468 .map_err(|e| GraphError::Llm(format!("failed to spawn {binary}: {e}")))?;
469
470 if let Some(payload) = &invocation.stdin {
471 if let Some(mut stdin) = child.stdin.take() {
472 use tokio::io::AsyncWriteExt;
473 stdin
474 .write_all(payload.as_bytes())
475 .await
476 .map_err(|e| GraphError::Llm(format!("write to {binary} stdin: {e}")))?;
477 drop(stdin);
478 }
479 }
480
481 let output = match self.spec.timeout {
482 Some(limit) => tokio::time::timeout(limit, child.wait_with_output())
483 .await
484 .map_err(|_| {
485 GraphError::Llm(format!("{binary} timed out after {}s", limit.as_secs()))
486 })?,
487 None => child.wait_with_output().await,
488 }
489 .map_err(|e| GraphError::Llm(format!("{binary} process failed: {e}")))?;
490
491 if !output.status.success() {
492 let stderr = String::from_utf8_lossy(&output.stderr);
493 return Err(GraphError::Llm(format!(
494 "{binary} exited {}: {}",
495 output.status,
496 truncate_str(stderr.trim(), STDERR_EXCERPT)
497 )));
498 }
499
500 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
501 if stdout.trim().is_empty() {
502 return Err(GraphError::Llm(format!("{binary} returned empty output")));
503 }
504 Ok(stdout)
505 }
506}
507
508fn extract_answer(stdout: &str, spec: &CliSpec) -> Result<String, GraphError> {
519 let paths = spec.result_json_paths.paths();
520 match spec.output_mode {
521 OutputMode::Raw => Ok(stdout.to_string()),
522 OutputMode::SingleJson => {
523 if paths.is_empty() {
524 return Ok(stdout.to_string());
525 }
526 let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
527 return Ok(stdout.to_string());
528 };
529 match first_match(&json, paths) {
530 Some(text) => Ok(text),
531 None => reported_error(&json).map_or_else(|| Ok(stdout.to_string()), Err),
532 }
533 }
534 OutputMode::Ndjson => extract_from_ndjson(stdout, spec),
535 }
536}
537
538fn extract_from_ndjson(stdout: &str, spec: &CliSpec) -> Result<String, GraphError> {
543 let predicates = spec.ndjson_match.predicates();
544 let paths = spec.result_json_paths.paths();
545
546 let mut answer = None;
547 let mut error = None;
548
549 for line in stdout.lines().filter(|line| !line.trim().is_empty()) {
550 let Ok(event) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
551 continue;
552 };
553 if let Some(message) = reported_error(&event) {
554 error = Some(message);
555 }
556 if !predicates
557 .iter()
558 .all(|(path, expected)| matches_scalar(&event, path, expected))
559 {
560 continue;
561 }
562 if let Some(text) = first_match(&event, paths) {
563 answer = Some(text);
564 }
565 }
566
567 match (answer, error) {
568 (Some(text), _) => Ok(text),
569 (None, Some(err)) => Err(err),
570 (None, None) => Ok(stdout.to_string()),
571 }
572}
573
574fn first_match(json: &serde_json::Value, paths: &[String]) -> Option<String> {
576 paths
577 .iter()
578 .find_map(|path| lookup(json, path).and_then(serde_json::Value::as_str))
579 .map(str::to_string)
580}
581
582fn matches_scalar(json: &serde_json::Value, path: &str, expected: &str) -> bool {
588 let Some(node) = lookup(json, path) else {
589 return false;
590 };
591 if let Some(text) = node.as_str() {
592 return text == expected;
593 }
594 serde_json::from_str::<serde_json::Value>(expected).is_ok_and(|wanted| *node == wanted)
595}
596
597fn reported_error(json: &serde_json::Value) -> Option<GraphError> {
599 lookup(json, "error.message")
600 .and_then(serde_json::Value::as_str)
601 .map(|message| {
602 GraphError::Llm(format!(
603 "CLI reported an error: {}",
604 truncate_str(message, STDERR_EXCERPT)
605 ))
606 })
607}
608
609fn lookup<'a>(json: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
611 let mut node = json;
612 for segment in path.split('.') {
613 node = match node {
614 serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
615 other => other.get(segment)?,
616 };
617 }
618 Some(node)
619}
620
621fn truncate_str(text: &str, max: usize) -> &str {
622 let end = text.len().min(max);
623 let mut i = end;
624 while i > 0 && !text.is_char_boundary(i) {
625 i -= 1;
626 }
627 &text[..i]
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633 use std::fs;
634 use std::os::unix::fs::PermissionsExt;
635 use std::path::PathBuf;
636
637 const SYSTEM: &str = "You extract entities.";
638 const USER: &str = "Dani uses NeoVim.";
639
640 fn spec_for(provider: Provider) -> CliSpec {
641 CliSpec::resolve(&provider, &CliSection::default()).expect("preset resolves")
642 }
643
644 #[test]
650 fn claude_code_argv_is_unchanged() {
651 let spec = spec_for(Provider::ClaudeCode);
652 let invocation = spec.invocation("sonnet", SYSTEM, USER);
653
654 assert_eq!(
655 invocation.argv,
656 vec![
657 "claude",
658 "-p",
659 "--model",
660 "sonnet",
661 "--output-format",
662 "text",
663 "--system-prompt",
664 SYSTEM,
665 "--no-session-persistence",
666 ]
667 );
668 assert_eq!(invocation.stdin.as_deref(), Some(USER));
669 }
670
671 #[test]
672 fn claude_code_defaults_to_sonnet() {
673 let spec = spec_for(Provider::ClaudeCode);
674 assert_eq!(spec.resolve_model(""), "sonnet");
675 assert_eq!(spec.resolve_model("opus"), "opus");
676 }
677
678 #[test]
679 fn gemini_argv_passes_the_prompt_by_flag() {
680 let spec = spec_for(Provider::Gemini);
681 let invocation = spec.invocation("gemini-2.5-pro", SYSTEM, USER);
682
683 assert_eq!(
684 invocation.argv,
685 vec![
686 "gemini",
687 "-m",
688 "gemini-2.5-pro",
689 "-o",
690 "json",
691 "-p",
692 &format!("{SYSTEM}\n\n{USER}"),
693 ]
694 );
695 assert!(invocation.stdin.is_none());
696 assert_eq!(spec.result_json_paths.paths(), ["response", "result"]);
697 }
698
699 #[test]
700 fn grok_argv_matches_the_verified_flags() {
701 let spec = spec_for(Provider::Grok);
702 let invocation = spec.invocation("grok-4", SYSTEM, USER);
703
704 assert_eq!(
705 invocation.argv,
706 vec![
707 "grok",
708 "-m",
709 "grok-4",
710 "--output-format",
711 "json",
712 "-p",
713 &format!("{SYSTEM}\n\n{USER}"),
714 ]
715 );
716 assert!(invocation.stdin.is_none());
717 assert_eq!(spec.result_json_paths.paths(), ["text"]);
718 }
719
720 #[test]
723 fn grok_extracts_text_and_not_the_reasoning() {
724 let spec = spec_for(Provider::Grok);
725 let stdout = r#"{"thought":"thinking about it","text":"OK","stopReason":"end_turn"}"#;
726 assert_eq!(extract_answer(stdout, &spec).unwrap(), "OK");
727 }
728
729 #[test]
733 fn codex_argv_uses_the_subcommand_stdin_and_the_repo_check_escape() {
734 let spec = spec_for(Provider::Codex);
735 let invocation = spec.invocation("gpt-5.1-codex", SYSTEM, USER);
736
737 assert_eq!(
738 invocation.argv,
739 vec![
740 "codex",
741 "exec",
742 "-m",
743 "gpt-5.1-codex",
744 "--json",
745 "--skip-git-repo-check",
746 ]
747 );
748 assert!(
749 !invocation.argv.iter().any(|arg| arg == "-p"),
750 "-p is --profile for codex, never the prompt"
751 );
752 assert_eq!(
753 invocation.stdin.as_deref(),
754 Some(format!("{SYSTEM}\n\n{USER}").as_str())
755 );
756 assert_eq!(spec.output_mode, OutputMode::Ndjson);
757 }
758
759 #[test]
761 fn a_value_less_output_flag_is_passed_alone() {
762 let spec = spec_for(Provider::Codex);
763 let argv = spec.invocation("", SYSTEM, USER).argv;
764 assert_eq!(
765 argv,
766 vec!["codex", "exec", "--json", "--skip-git-repo-check"]
767 );
768 }
769
770 #[test]
771 fn an_empty_model_omits_the_model_flag() {
772 let spec = spec_for(Provider::Gemini);
773 let invocation = spec.invocation("", SYSTEM, USER);
774 assert_eq!(invocation.argv[1], "-o");
775 }
776
777 #[test]
778 fn a_custom_provider_needs_a_command() {
779 let err = CliSpec::resolve(&Provider::Cli, &CliSection::default())
780 .expect_err("custom preset has no default binary");
781 assert!(err.to_string().contains("command"), "{err}");
782 }
783
784 #[test]
785 fn a_custom_provider_is_built_entirely_from_config() {
786 let section = CliSection {
787 command: Some("mycli".into()),
788 args: Some(vec!["chat".into()]),
789 prompt_delivery: Some(PromptDelivery::Arg),
790 model_flag: Some("--model".into()),
791 output_format_flag: Some("--format".into()),
792 output_format_value: Some("json".into()),
793 result_json_path: Some(JsonPaths::parse("data.text")),
794 extra_args: Some(vec!["--quiet".into()]),
795 timeout_secs: Some(0),
796 ..CliSection::default()
797 };
798 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
799
800 assert_eq!(
801 spec.invocation("m1", SYSTEM, USER).argv,
802 vec![
803 "mycli",
804 "chat",
805 "--model",
806 "m1",
807 "--format",
808 "json",
809 "--quiet",
810 &format!("{SYSTEM}\n\n{USER}"),
811 ]
812 );
813 assert!(spec.timeout.is_none(), "0 seconds means no limit");
814 assert_eq!(spec.result_json_paths.paths(), ["data.text"]);
815 }
816
817 #[test]
818 fn overrides_are_applied_on_top_of_a_preset() {
819 let section = CliSection {
820 command: Some("/opt/bin/gemini".into()),
821 model_flag: Some("--model".into()),
822 result_json_path: Some(JsonPaths::parse("output")),
823 ..CliSection::default()
824 };
825 let spec = CliSpec::resolve(&Provider::Gemini, §ion).expect("resolves");
826 let invocation = spec.invocation("flash", SYSTEM, USER);
827
828 assert_eq!(invocation.argv[0], "/opt/bin/gemini");
829 assert_eq!(invocation.argv[1], "--model");
830 assert_eq!(spec.result_json_paths.paths(), ["output"]);
831 }
832
833 #[test]
834 fn a_preset_can_be_chosen_independently_of_the_provider() {
835 let section = CliSection {
836 preset: Some(CliPreset::Gemini),
837 command: Some("gemini-next".into()),
838 ..CliSection::default()
839 };
840 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
841 assert_eq!(spec.prompt_delivery, PromptDelivery::Flag);
842 assert_eq!(spec.invocation("", "", USER).argv.last().unwrap(), USER);
843 }
844
845 #[test]
846 fn flag_delivery_without_a_flag_is_rejected() {
847 let section = CliSection {
848 command: Some("mycli".into()),
849 prompt_delivery: Some(PromptDelivery::Flag),
850 ..CliSection::default()
851 };
852 let err = CliSpec::resolve(&Provider::Cli, §ion).expect_err("no prompt flag");
853 assert!(err.to_string().contains("prompt_flag"), "{err}");
854 }
855
856 #[test]
857 fn http_providers_have_no_cli_spec() {
858 assert!(CliSpec::resolve(&Provider::Anthropic, &CliSection::default()).is_err());
859 assert!(CliSpec::resolve(&Provider::Openai, &CliSection::default()).is_err());
860 }
861
862 #[test]
863 fn a_missing_system_prompt_still_uses_the_flag_when_there_is_one() {
864 let spec = spec_for(Provider::ClaudeCode);
865 let invocation = spec.invocation("sonnet", "", USER);
866 assert!(invocation.argv.contains(&"--system-prompt".to_string()));
867 }
868
869 #[test]
870 fn argv_preview_shows_the_stdin_redirect() {
871 let preview = spec_for(Provider::ClaudeCode).argv_preview("sonnet");
872 assert!(preview.starts_with("claude -p --model sonnet"), "{preview}");
873 assert!(preview.ends_with("< <prompt>"), "{preview}");
874 }
875
876 #[test]
878 fn argv_preview_keeps_a_folded_prompt_on_one_line() {
879 let preview = spec_for(Provider::Grok).argv_preview("grok-4");
880 assert!(!preview.contains('\n'), "{preview}");
881 assert!(
882 preview.ends_with(r#"-p "<system>\n\n<prompt>""#),
883 "{preview}"
884 );
885 }
886
887 fn json_spec(paths: &[&str]) -> CliSpec {
891 CliSpec {
892 output_mode: OutputMode::SingleJson,
893 result_json_paths: JsonPaths::new(paths.iter().map(|p| (*p).to_string())),
894 ..spec_for(Provider::Gemini)
895 }
896 }
897
898 #[test]
899 fn json_path_present_returns_the_field() {
900 let spec = json_spec(&["response", "result"]);
901 let answer = extract_answer(r#"{"response":"hello","session_id":"a"}"#, &spec).unwrap();
902 assert_eq!(answer, "hello");
903 }
904
905 #[test]
906 fn json_paths_are_tried_in_order() {
907 let spec = json_spec(&["response", "result"]);
908 let answer = extract_answer(r#"{"result":"second choice"}"#, &spec).unwrap();
909 assert_eq!(answer, "second choice");
910 }
911
912 #[test]
913 fn nested_and_indexed_paths_resolve() {
914 let spec = json_spec(&["messages.0.text"]);
915 let answer = extract_answer(r#"{"messages":[{"text":"deep"}]}"#, &spec).unwrap();
916 assert_eq!(answer, "deep");
917 }
918
919 #[test]
920 fn a_missing_json_path_falls_back_to_raw_stdout() {
921 let spec = json_spec(&["response"]);
922 let stdout = r#"{"text":"renamed field"}"#;
923 assert_eq!(extract_answer(stdout, &spec).unwrap(), stdout);
924 }
925
926 #[test]
927 fn malformed_json_falls_back_to_raw_stdout() {
928 let spec = json_spec(&["response"]);
929 let stdout = "{not json at all";
930 assert_eq!(extract_answer(stdout, &spec).unwrap(), stdout);
931 }
932
933 #[test]
934 fn a_non_string_at_the_path_falls_back_to_raw_stdout() {
935 let spec = json_spec(&["response"]);
936 let stdout = r#"{"response":{"text":"nested"}}"#;
937 assert_eq!(extract_answer(stdout, &spec).unwrap(), stdout);
938 }
939
940 #[test]
941 fn an_error_envelope_is_an_error_not_an_answer() {
942 let spec = json_spec(&["response"]);
943 let err = extract_answer(r#"{"error":{"message":"quota exceeded"}}"#, &spec)
944 .expect_err("error envelope");
945 assert!(err.to_string().contains("quota exceeded"), "{err}");
946 }
947
948 #[test]
949 fn raw_mode_means_stdout_is_the_answer() {
950 let spec = spec_for(Provider::ClaudeCode);
951 assert_eq!(spec.output_mode, OutputMode::Raw);
952 assert_eq!(extract_answer("plain prose", &spec).unwrap(), "plain prose");
953 }
954
955 const CODEX_STREAM: &str = concat!(
959 r#"{"type":"thread.started","thread_id":"01999"}"#,
960 "\n",
961 r#"{"type":"turn.started"}"#,
962 "\n",
963 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"OK"}}"#,
964 "\n",
965 r#"{"type":"turn.completed","usage":{"input_tokens":13658,"output_tokens":5}}"#,
966 "\n",
967 );
968
969 #[test]
970 fn ndjson_takes_the_matching_event_and_ignores_the_rest() {
971 let spec = spec_for(Provider::Codex);
972 assert_eq!(extract_answer(CODEX_STREAM, &spec).unwrap(), "OK");
973 }
974
975 #[test]
977 fn ndjson_keeps_the_last_matching_event() {
978 let spec = spec_for(Provider::Codex);
979 let stream = concat!(
980 r#"{"type":"item.completed","item":{"type":"agent_message","text":"first"}}"#,
981 "\n",
982 r#"{"type":"item.completed","item":{"type":"reasoning","text":"ignore me"}}"#,
983 "\n",
984 r#"{"type":"item.completed","item":{"type":"agent_message","text":"final"}}"#,
985 );
986 assert_eq!(extract_answer(stream, &spec).unwrap(), "final");
987 }
988
989 #[test]
990 fn ndjson_skips_lines_that_are_not_json() {
991 let spec = spec_for(Provider::Codex);
992 let stream = format!("a banner line\n{CODEX_STREAM}");
993 assert_eq!(extract_answer(&stream, &spec).unwrap(), "OK");
994 }
995
996 #[test]
997 fn ndjson_with_no_matching_event_falls_back_to_raw_stdout() {
998 let spec = spec_for(Provider::Codex);
999 let stream = r#"{"type":"turn.completed","usage":{"output_tokens":5}}"#;
1000 assert_eq!(extract_answer(stream, &spec).unwrap(), stream);
1001 }
1002
1003 #[test]
1004 fn ndjson_surfaces_an_error_event_when_nothing_matched() {
1005 let spec = spec_for(Provider::Codex);
1006 let stream = r#"{"type":"error","error":{"message":"model overloaded"}}"#;
1007 let err = extract_answer(stream, &spec).expect_err("error event");
1008 assert!(err.to_string().contains("model overloaded"), "{err}");
1009 }
1010
1011 #[test]
1014 fn ndjson_matchers_are_configurable() {
1015 let section = CliSection {
1016 command: Some("mycli".into()),
1017 output_mode: Some(OutputMode::Ndjson),
1018 ndjson_match: Some(LineMatchers::parse("kind=message, final=true")),
1019 result_json_path: Some(JsonPaths::parse("content")),
1020 ..CliSection::default()
1021 };
1022 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
1023 let stream = concat!(
1024 r#"{"kind":"message","final":false,"content":"partial"}"#,
1025 "\n",
1026 r#"{"kind":"message","final":true,"content":"done"}"#,
1027 );
1028 assert_eq!(extract_answer(stream, &spec).unwrap(), "done");
1029 }
1030
1031 #[test]
1034 fn a_result_path_on_a_prose_preset_implies_single_json() {
1035 let section = CliSection {
1036 result_json_path: Some(JsonPaths::parse("result")),
1037 ..CliSection::default()
1038 };
1039 let spec = CliSpec::resolve(&Provider::ClaudeCode, §ion).expect("resolves");
1040 assert_eq!(spec.output_mode, OutputMode::SingleJson);
1041 assert_eq!(
1042 extract_answer(r#"{"result":"from json"}"#, &spec).unwrap(),
1043 "from json"
1044 );
1045 }
1046
1047 #[test]
1048 fn an_explicit_output_mode_wins_over_the_inference() {
1049 let section = CliSection {
1050 result_json_path: Some(JsonPaths::parse("result")),
1051 output_mode: Some(OutputMode::Raw),
1052 ..CliSection::default()
1053 };
1054 let spec = CliSpec::resolve(&Provider::ClaudeCode, §ion).expect("resolves");
1055 assert_eq!(spec.output_mode, OutputMode::Raw);
1056 assert_eq!(
1057 extract_answer(r#"{"result":"from json"}"#, &spec).unwrap(),
1058 r#"{"result":"from json"}"#
1059 );
1060 }
1061
1062 struct MockCli {
1065 _dir: tempfile::TempDir,
1066 path: PathBuf,
1067 argv_log: PathBuf,
1068 stdin_log: PathBuf,
1069 }
1070
1071 impl MockCli {
1072 fn new(body: &str) -> Self {
1074 let dir = tempfile::tempdir().expect("tempdir");
1075 let path = dir.path().join("mock-cli");
1076 let argv_log = dir.path().join("argv.txt");
1077 let stdin_log = dir.path().join("stdin.txt");
1078 let script = format!(
1081 "#!/bin/sh\n\
1082 : > '{argv}'\n\
1083 for a in \"$@\"; do printf '%s\\0' \"$a\" >> '{argv}'; done\n\
1084 cat > '{stdin}'\n\
1085 {body}\n",
1086 argv = argv_log.display(),
1087 stdin = stdin_log.display(),
1088 );
1089 fs::write(&path, script).expect("write mock");
1090 fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
1091 Self {
1092 _dir: dir,
1093 path,
1094 argv_log,
1095 stdin_log,
1096 }
1097 }
1098
1099 fn provider(&self, section: CliSection, model: &str) -> CliProvider {
1100 let section = CliSection {
1101 command: Some(self.path.display().to_string()),
1102 ..section
1103 };
1104 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
1105 let model = spec.resolve_model(model);
1106 CliProvider::new(spec, model)
1107 }
1108
1109 fn recorded_argv(&self) -> Vec<String> {
1110 fs::read_to_string(&self.argv_log)
1111 .unwrap_or_default()
1112 .split('\0')
1113 .filter(|arg| !arg.is_empty())
1114 .map(str::to_string)
1115 .collect()
1116 }
1117
1118 fn recorded_stdin(&self) -> String {
1119 fs::read_to_string(&self.stdin_log).unwrap_or_default()
1120 }
1121 }
1122
1123 #[tokio::test]
1124 async fn stdin_delivery_feeds_the_prompt_to_the_process() {
1125 let mock = MockCli::new("printf 'answer from stdin'");
1126 let provider = mock.provider(
1127 CliSection {
1128 preset: Some(CliPreset::ClaudeCode),
1129 ..CliSection::default()
1130 },
1131 "sonnet",
1132 );
1133
1134 let answer = provider.complete(SYSTEM, USER, 1024).await.unwrap();
1135
1136 assert_eq!(answer, "answer from stdin");
1137 assert_eq!(
1138 mock.recorded_argv(),
1139 vec![
1140 "-p",
1141 "--model",
1142 "sonnet",
1143 "--output-format",
1144 "text",
1145 "--system-prompt",
1146 SYSTEM,
1147 "--no-session-persistence",
1148 ]
1149 );
1150 assert_eq!(mock.recorded_stdin(), USER);
1151 }
1152
1153 #[tokio::test]
1154 async fn flag_delivery_puts_the_prompt_in_argv_and_leaves_stdin_closed() {
1155 let mock = MockCli::new(r#"printf '{"response":"answer from flag"}'"#);
1156 let provider = mock.provider(
1157 CliSection {
1158 preset: Some(CliPreset::Gemini),
1159 ..CliSection::default()
1160 },
1161 "",
1162 );
1163
1164 let answer = provider.complete(SYSTEM, USER, 1024).await.unwrap();
1165
1166 assert_eq!(answer, "answer from flag");
1167 assert_eq!(
1168 mock.recorded_argv(),
1169 vec!["-o", "json", "-p", &format!("{SYSTEM}\n\n{USER}")]
1170 );
1171 assert!(mock.recorded_stdin().is_empty());
1172 }
1173
1174 #[tokio::test]
1175 async fn a_streaming_cli_is_read_through_the_ndjson_mode() {
1176 let mock = MockCli::new(
1177 "printf '%s\\n' '{\"type\":\"turn.started\"}' \
1178 '{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"OK\"}}' \
1179 '{\"type\":\"turn.completed\"}'",
1180 );
1181 let provider = mock.provider(
1182 CliSection {
1183 preset: Some(CliPreset::Codex),
1184 ..CliSection::default()
1185 },
1186 "",
1187 );
1188
1189 let answer = provider.complete(SYSTEM, USER, 1024).await.unwrap();
1190
1191 assert_eq!(answer, "OK");
1192 assert_eq!(
1193 mock.recorded_argv(),
1194 vec!["exec", "--json", "--skip-git-repo-check"]
1195 );
1196 assert_eq!(mock.recorded_stdin(), format!("{SYSTEM}\n\n{USER}"));
1197 }
1198
1199 #[tokio::test]
1200 async fn a_non_zero_exit_surfaces_the_cli_stderr() {
1201 let mock = MockCli::new("printf 'not authenticated: run gemini auth' >&2\nexit 3");
1202 let provider = mock.provider(
1203 CliSection {
1204 preset: Some(CliPreset::Gemini),
1205 ..CliSection::default()
1206 },
1207 "",
1208 );
1209
1210 let err = provider
1211 .complete(SYSTEM, USER, 1024)
1212 .await
1213 .expect_err("non-zero exit");
1214 let message = err.to_string();
1215 assert!(message.contains("not authenticated"), "{message}");
1216 assert!(message.contains("exited"), "{message}");
1217 }
1218
1219 #[tokio::test]
1220 async fn empty_output_is_an_error() {
1221 let mock = MockCli::new("printf ''");
1222 let provider = mock.provider(CliSection::default(), "");
1223
1224 let err = provider
1225 .complete(SYSTEM, USER, 1024)
1226 .await
1227 .expect_err("no output");
1228 assert!(err.to_string().contains("empty output"), "{err}");
1229 }
1230
1231 #[tokio::test]
1232 async fn a_hung_cli_hits_the_timeout() {
1233 let mock = MockCli::new("sleep 30\nprintf 'too late'");
1234 let provider = mock.provider(
1235 CliSection {
1236 timeout_secs: Some(1),
1237 ..CliSection::default()
1238 },
1239 "",
1240 );
1241
1242 let err = provider
1243 .complete(SYSTEM, USER, 1024)
1244 .await
1245 .expect_err("timeout");
1246 assert!(err.to_string().contains("timed out after 1s"), "{err}");
1247 }
1248
1249 #[tokio::test]
1250 async fn a_missing_binary_is_reported_as_a_spawn_failure() {
1251 let section = CliSection {
1252 command: Some("/nonexistent/agent-cli".into()),
1253 ..CliSection::default()
1254 };
1255 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
1256 let provider = CliProvider::new(spec, String::new());
1257
1258 let err = provider
1259 .complete(SYSTEM, USER, 1024)
1260 .await
1261 .expect_err("missing binary");
1262 assert!(err.to_string().contains("failed to spawn"), "{err}");
1263 }
1264}