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::{Completion, LlmProvider, TokenUsage};
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 usage_input_paths: JsonPaths,
73 pub usage_output_paths: JsonPaths,
75 pub ndjson_match: LineMatchers,
77 pub extra_args: Vec<String>,
79 pub timeout: Option<Duration>,
81 pub env_remove: Vec<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Invocation {
88 pub argv: Vec<String>,
90 pub stdin: Option<String>,
92}
93
94impl CliSpec {
95 #[must_use]
103 pub fn preset(preset: CliPreset) -> Self {
104 match preset {
105 CliPreset::ClaudeCode => Self {
110 command: "claude".into(),
111 command_env: Some("CLAUDE_BIN".into()),
112 args: vec!["-p".into()],
113 prompt_delivery: PromptDelivery::Stdin,
114 prompt_flag: String::new(),
115 model_flag: "--model".into(),
116 default_model: "sonnet".into(),
117 output_format_flag: "--output-format".into(),
118 output_format_value: "text".into(),
119 system_prompt_flag: "--system-prompt".into(),
120 output_mode: OutputMode::Raw,
121 result_json_paths: JsonPaths::default(),
122 usage_input_paths: JsonPaths::default(),
125 usage_output_paths: JsonPaths::default(),
126 ndjson_match: LineMatchers::default(),
127 extra_args: vec!["--no-session-persistence".into()],
128 timeout: default_timeout(),
129 env_remove: vec!["CLAUDECODE".into()],
130 },
131 CliPreset::Gemini => Self {
136 command: "gemini".into(),
137 command_env: Some("GEMINI_BIN".into()),
138 args: Vec::new(),
139 prompt_delivery: PromptDelivery::Flag,
140 prompt_flag: "-p".into(),
141 model_flag: "-m".into(),
142 default_model: String::new(),
143 output_format_flag: "-o".into(),
144 output_format_value: "json".into(),
145 system_prompt_flag: String::new(),
146 output_mode: OutputMode::SingleJson,
147 result_json_paths: JsonPaths::new(["response".into(), "result".into()]),
148 usage_input_paths: JsonPaths::new([
152 "usage.input_tokens".into(),
153 "usage.promptTokenCount".into(),
154 "stats.promptTokenCount".into(),
155 ]),
156 usage_output_paths: JsonPaths::new([
157 "usage.output_tokens".into(),
158 "usage.candidatesTokenCount".into(),
159 "stats.candidatesTokenCount".into(),
160 ]),
161 ndjson_match: LineMatchers::default(),
162 extra_args: Vec::new(),
163 timeout: default_timeout(),
164 env_remove: Vec::new(),
165 },
166 CliPreset::Grok => Self {
171 command: "grok".into(),
172 command_env: Some("GROK_BIN".into()),
173 args: Vec::new(),
174 prompt_delivery: PromptDelivery::Flag,
175 prompt_flag: "-p".into(),
176 model_flag: "-m".into(),
177 default_model: String::new(),
178 output_format_flag: "--output-format".into(),
179 output_format_value: "json".into(),
180 system_prompt_flag: String::new(),
181 output_mode: OutputMode::SingleJson,
182 result_json_paths: JsonPaths::new(["text".into()]),
183 usage_input_paths: JsonPaths::new([
186 "usage.input_tokens".into(),
187 "usage.prompt_tokens".into(),
188 ]),
189 usage_output_paths: JsonPaths::new([
190 "usage.output_tokens".into(),
191 "usage.completion_tokens".into(),
192 ]),
193 ndjson_match: LineMatchers::default(),
194 extra_args: Vec::new(),
195 timeout: default_timeout(),
196 env_remove: Vec::new(),
197 },
198 CliPreset::Codex => Self {
207 command: "codex".into(),
208 command_env: Some("CODEX_BIN".into()),
209 args: vec!["exec".into()],
210 prompt_delivery: PromptDelivery::Stdin,
211 prompt_flag: String::new(),
212 model_flag: "-m".into(),
213 default_model: String::new(),
214 output_format_flag: "--json".into(),
215 output_format_value: String::new(),
216 system_prompt_flag: String::new(),
217 output_mode: OutputMode::Ndjson,
218 result_json_paths: JsonPaths::new(["item.text".into()]),
219 usage_input_paths: JsonPaths::new(["usage.input_tokens".into()]),
222 usage_output_paths: JsonPaths::new(["usage.output_tokens".into()]),
223 ndjson_match: LineMatchers::new([
224 "type=item.completed".into(),
225 "item.type=agent_message".into(),
226 ]),
227 extra_args: vec!["--skip-git-repo-check".into()],
228 timeout: default_timeout(),
229 env_remove: Vec::new(),
230 },
231 CliPreset::Custom => Self {
233 command: String::new(),
234 command_env: Some("RECALL_CLI_BIN".into()),
235 args: Vec::new(),
236 prompt_delivery: PromptDelivery::Stdin,
237 prompt_flag: String::new(),
238 model_flag: String::new(),
239 default_model: String::new(),
240 output_format_flag: String::new(),
241 output_format_value: String::new(),
242 system_prompt_flag: String::new(),
243 output_mode: OutputMode::Raw,
244 result_json_paths: JsonPaths::default(),
245 usage_input_paths: JsonPaths::default(),
246 usage_output_paths: JsonPaths::default(),
247 ndjson_match: LineMatchers::default(),
248 extra_args: Vec::new(),
249 timeout: default_timeout(),
250 env_remove: Vec::new(),
251 },
252 }
253 }
254
255 pub fn resolve(provider: &Provider, section: &CliSection) -> Result<Self, RecallError> {
260 let preset = section
261 .preset
262 .or_else(|| provider.default_cli_preset())
263 .ok_or_else(|| {
264 RecallError::Config(format!(
265 "provider {provider} is not a CLI provider — use create_provider()"
266 ))
267 })?;
268
269 let mut spec = Self::preset(preset);
270 spec.apply(section);
271 spec.validate(provider)?;
272 Ok(spec)
273 }
274
275 fn apply(&mut self, section: &CliSection) {
276 if let Some(command) = §ion.command {
277 self.command = command.clone();
278 self.command_env = None;
279 }
280 if let Some(args) = §ion.args {
281 self.args = args.clone();
282 }
283 if let Some(delivery) = section.prompt_delivery {
284 self.prompt_delivery = delivery;
285 }
286 if let Some(flag) = §ion.prompt_flag {
287 self.prompt_flag = flag.clone();
288 }
289 if let Some(flag) = §ion.model_flag {
290 self.model_flag = flag.clone();
291 }
292 if let Some(flag) = §ion.output_format_flag {
293 self.output_format_flag = flag.clone();
294 }
295 if let Some(value) = §ion.output_format_value {
296 self.output_format_value = value.clone();
297 }
298 if let Some(flag) = §ion.system_prompt_flag {
299 self.system_prompt_flag = flag.clone();
300 }
301 if let Some(paths) = §ion.result_json_path {
302 self.result_json_paths = paths.clone();
303 if self.output_mode == OutputMode::Raw && !paths.is_empty() {
307 self.output_mode = OutputMode::SingleJson;
308 }
309 }
310 if let Some(paths) = §ion.usage_input_path {
311 self.usage_input_paths = paths.clone();
312 }
313 if let Some(paths) = §ion.usage_output_path {
314 self.usage_output_paths = paths.clone();
315 }
316 if let Some(matchers) = §ion.ndjson_match {
317 self.ndjson_match = matchers.clone();
318 }
319 if let Some(mode) = section.output_mode {
320 self.output_mode = mode;
321 }
322 if let Some(args) = §ion.extra_args {
323 self.extra_args = args.clone();
324 }
325 if let Some(secs) = section.timeout_secs {
326 self.timeout = (secs > 0).then(|| Duration::from_secs(secs));
327 }
328 }
329
330 fn validate(&self, provider: &Provider) -> Result<(), RecallError> {
331 if self.resolve_command().trim().is_empty() {
332 return Err(RecallError::Config(format!(
333 "provider {provider} has no command — set `[llm.cli] command = \"<binary>\"`"
334 )));
335 }
336 if self.prompt_delivery == PromptDelivery::Flag && self.prompt_flag.is_empty() {
337 return Err(RecallError::Config(format!(
338 "provider {provider} delivers the prompt by flag but sets no \
339 `[llm.cli] prompt_flag`"
340 )));
341 }
342 Ok(())
343 }
344
345 #[must_use]
348 pub fn resolve_command(&self) -> String {
349 self.command_env
350 .as_ref()
351 .and_then(|key| std::env::var(key).ok())
352 .filter(|value| !value.trim().is_empty())
353 .unwrap_or_else(|| self.command.clone())
354 }
355
356 #[must_use]
358 pub fn resolve_model(&self, configured: &str) -> String {
359 if configured.is_empty() {
360 self.default_model.clone()
361 } else {
362 configured.to_string()
363 }
364 }
365
366 #[must_use]
368 pub fn invocation(&self, model: &str, system_prompt: &str, user_message: &str) -> Invocation {
369 let mut argv = vec![self.resolve_command()];
370 argv.extend(self.args.iter().cloned());
371
372 if !self.model_flag.is_empty() && !model.is_empty() {
373 argv.push(self.model_flag.clone());
374 argv.push(model.to_string());
375 }
376 if !self.output_format_flag.is_empty() {
378 argv.push(self.output_format_flag.clone());
379 if !self.output_format_value.is_empty() {
380 argv.push(self.output_format_value.clone());
381 }
382 }
383
384 let prompt = if self.system_prompt_flag.is_empty() {
385 fold_system_prompt(system_prompt, user_message)
386 } else {
387 argv.push(self.system_prompt_flag.clone());
388 argv.push(system_prompt.to_string());
389 user_message.to_string()
390 };
391
392 argv.extend(self.extra_args.iter().cloned());
393
394 let stdin = match self.prompt_delivery {
395 PromptDelivery::Stdin => Some(prompt),
396 PromptDelivery::Flag => {
397 argv.push(self.prompt_flag.clone());
398 argv.push(prompt);
399 None
400 }
401 PromptDelivery::Arg => {
402 argv.push(prompt);
403 None
404 }
405 };
406
407 Invocation { argv, stdin }
408 }
409
410 #[must_use]
413 pub fn argv_preview(&self, model: &str) -> String {
414 let invocation = self.invocation(model, "<system>", "<prompt>");
415 let mut parts: Vec<String> = invocation.argv.iter().map(|arg| quote(arg)).collect();
416 if invocation.stdin.is_some() {
417 parts.push("< <prompt>".into());
418 }
419 parts.join(" ")
420 }
421}
422
423fn quote(arg: &str) -> String {
425 if arg.chars().any(char::is_whitespace) {
426 format!("\"{}\"", arg.replace('\n', "\\n"))
427 } else {
428 arg.to_string()
429 }
430}
431
432fn fold_system_prompt(system_prompt: &str, user_message: &str) -> String {
434 if system_prompt.is_empty() {
435 user_message.to_string()
436 } else {
437 format!("{system_prompt}\n\n{user_message}")
438 }
439}
440
441fn default_timeout() -> Option<Duration> {
442 Some(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
443}
444
445pub struct CliProvider {
452 spec: CliSpec,
453 model: String,
454}
455
456impl CliProvider {
457 #[must_use]
458 pub fn new(spec: CliSpec, model: String) -> Self {
459 Self { spec, model }
460 }
461
462 #[must_use]
464 pub fn spec(&self) -> &CliSpec {
465 &self.spec
466 }
467}
468
469#[async_trait::async_trait]
470impl LlmProvider for CliProvider {
471 async fn complete(
472 &self,
473 system_prompt: &str,
474 user_message: &str,
475 max_tokens: u32,
476 ) -> Result<String, GraphError> {
477 Ok(self
478 .complete_measured(system_prompt, user_message, max_tokens)
479 .await?
480 .text)
481 }
482
483 async fn complete_measured(
486 &self,
487 system_prompt: &str,
488 user_message: &str,
489 _max_tokens: u32,
490 ) -> Result<Completion, GraphError> {
491 let invocation = self
492 .spec
493 .invocation(&self.model, system_prompt, user_message);
494 let output = self.run(&invocation).await?;
495 let usage = extract_usage(&output, &self.spec);
496 Ok(Completion::measured(
497 extract_answer(&output, &self.spec)?,
498 usage,
499 ))
500 }
501}
502
503impl CliProvider {
504 async fn run(&self, invocation: &Invocation) -> Result<String, GraphError> {
506 let (binary, args) = invocation
507 .argv
508 .split_first()
509 .ok_or_else(|| GraphError::Llm("empty CLI invocation".into()))?;
510
511 let mut command = tokio::process::Command::new(binary);
512 command
513 .args(args)
514 .stdin(if invocation.stdin.is_some() {
515 Stdio::piped()
516 } else {
517 Stdio::null()
518 })
519 .stdout(Stdio::piped())
520 .stderr(Stdio::piped())
521 .kill_on_drop(true);
524 for key in &self.spec.env_remove {
525 command.env_remove(key);
526 }
527
528 let mut child = command
529 .spawn()
530 .map_err(|e| GraphError::Llm(format!("failed to spawn {binary}: {e}")))?;
531
532 if let Some(payload) = &invocation.stdin {
533 if let Some(mut stdin) = child.stdin.take() {
534 use tokio::io::AsyncWriteExt;
535 stdin
536 .write_all(payload.as_bytes())
537 .await
538 .map_err(|e| GraphError::Llm(format!("write to {binary} stdin: {e}")))?;
539 drop(stdin);
540 }
541 }
542
543 let output = match self.spec.timeout {
544 Some(limit) => tokio::time::timeout(limit, child.wait_with_output())
545 .await
546 .map_err(|_| {
547 GraphError::Llm(format!("{binary} timed out after {}s", limit.as_secs()))
548 })?,
549 None => child.wait_with_output().await,
550 }
551 .map_err(|e| GraphError::Llm(format!("{binary} process failed: {e}")))?;
552
553 if !output.status.success() {
554 let stderr = String::from_utf8_lossy(&output.stderr);
555 return Err(GraphError::Llm(format!(
556 "{binary} exited {}: {}",
557 output.status,
558 truncate_str(stderr.trim(), STDERR_EXCERPT)
559 )));
560 }
561
562 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
563 if stdout.trim().is_empty() {
564 return Err(GraphError::Llm(format!("{binary} returned empty output")));
565 }
566 Ok(stdout)
567 }
568}
569
570fn extract_answer(stdout: &str, spec: &CliSpec) -> Result<String, GraphError> {
581 let paths = spec.result_json_paths.paths();
582 match spec.output_mode {
583 OutputMode::Raw => Ok(stdout.to_string()),
584 OutputMode::SingleJson => {
585 if paths.is_empty() {
586 return Ok(stdout.to_string());
587 }
588 let Ok(json) = serde_json::from_str::<serde_json::Value>(stdout.trim()) else {
589 return Ok(stdout.to_string());
590 };
591 match first_match(&json, paths) {
592 Some(text) => Ok(text),
593 None => reported_error(&json).map_or_else(|| Ok(stdout.to_string()), Err),
594 }
595 }
596 OutputMode::Ndjson => extract_from_ndjson(stdout, spec),
597 }
598}
599
600fn extract_from_ndjson(stdout: &str, spec: &CliSpec) -> Result<String, GraphError> {
605 let predicates = spec.ndjson_match.predicates();
606 let paths = spec.result_json_paths.paths();
607
608 let mut answer = None;
609 let mut error = None;
610
611 for line in stdout.lines().filter(|line| !line.trim().is_empty()) {
612 let Ok(event) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
613 continue;
614 };
615 if let Some(message) = reported_error(&event) {
616 error = Some(message);
617 }
618 if !predicates
619 .iter()
620 .all(|(path, expected)| matches_scalar(&event, path, expected))
621 {
622 continue;
623 }
624 if let Some(text) = first_match(&event, paths) {
625 answer = Some(text);
626 }
627 }
628
629 match (answer, error) {
630 (Some(text), _) => Ok(text),
631 (None, Some(err)) => Err(err),
632 (None, None) => Ok(stdout.to_string()),
633 }
634}
635
636fn extract_usage(stdout: &str, spec: &CliSpec) -> Option<TokenUsage> {
647 if spec.usage_input_paths.is_empty() && spec.usage_output_paths.is_empty() {
648 return None;
649 }
650 match spec.output_mode {
651 OutputMode::Raw => None,
653 OutputMode::SingleJson => {
654 let json = serde_json::from_str::<serde_json::Value>(stdout.trim()).ok()?;
655 usage_in(&json, spec)
656 }
657 OutputMode::Ndjson => stdout
658 .lines()
659 .filter_map(|line| serde_json::from_str::<serde_json::Value>(line.trim()).ok())
660 .filter_map(|event| usage_in(&event, spec))
661 .next_back(),
662 }
663}
664
665fn usage_in(json: &serde_json::Value, spec: &CliSpec) -> Option<TokenUsage> {
667 TokenUsage::from_counts(
668 first_count(json, spec.usage_input_paths.paths()),
669 first_count(json, spec.usage_output_paths.paths()),
670 )
671}
672
673fn first_count(json: &serde_json::Value, paths: &[String]) -> Option<u64> {
675 paths
676 .iter()
677 .find_map(|path| lookup(json, path).and_then(serde_json::Value::as_u64))
678}
679
680fn first_match(json: &serde_json::Value, paths: &[String]) -> Option<String> {
682 paths
683 .iter()
684 .find_map(|path| lookup(json, path).and_then(serde_json::Value::as_str))
685 .map(str::to_string)
686}
687
688fn matches_scalar(json: &serde_json::Value, path: &str, expected: &str) -> bool {
694 let Some(node) = lookup(json, path) else {
695 return false;
696 };
697 if let Some(text) = node.as_str() {
698 return text == expected;
699 }
700 serde_json::from_str::<serde_json::Value>(expected).is_ok_and(|wanted| *node == wanted)
701}
702
703fn reported_error(json: &serde_json::Value) -> Option<GraphError> {
705 lookup(json, "error.message")
706 .and_then(serde_json::Value::as_str)
707 .map(|message| {
708 GraphError::Llm(format!(
709 "CLI reported an error: {}",
710 truncate_str(message, STDERR_EXCERPT)
711 ))
712 })
713}
714
715fn lookup<'a>(json: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
717 let mut node = json;
718 for segment in path.split('.') {
719 node = match node {
720 serde_json::Value::Array(items) => items.get(segment.parse::<usize>().ok()?)?,
721 other => other.get(segment)?,
722 };
723 }
724 Some(node)
725}
726
727fn truncate_str(text: &str, max: usize) -> &str {
728 let end = text.len().min(max);
729 let mut i = end;
730 while i > 0 && !text.is_char_boundary(i) {
731 i -= 1;
732 }
733 &text[..i]
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739 use std::fs;
740 use std::os::unix::fs::PermissionsExt;
741 use std::path::PathBuf;
742
743 const SYSTEM: &str = "You extract entities.";
744 const USER: &str = "Dani uses NeoVim.";
745
746 fn spec_for(provider: Provider) -> CliSpec {
747 CliSpec::resolve(&provider, &CliSection::default()).expect("preset resolves")
748 }
749
750 #[test]
756 fn claude_code_argv_is_unchanged() {
757 let spec = spec_for(Provider::ClaudeCode);
758 let invocation = spec.invocation("sonnet", SYSTEM, USER);
759
760 assert_eq!(
761 invocation.argv,
762 vec![
763 "claude",
764 "-p",
765 "--model",
766 "sonnet",
767 "--output-format",
768 "text",
769 "--system-prompt",
770 SYSTEM,
771 "--no-session-persistence",
772 ]
773 );
774 assert_eq!(invocation.stdin.as_deref(), Some(USER));
775 }
776
777 #[test]
778 fn claude_code_defaults_to_sonnet() {
779 let spec = spec_for(Provider::ClaudeCode);
780 assert_eq!(spec.resolve_model(""), "sonnet");
781 assert_eq!(spec.resolve_model("opus"), "opus");
782 }
783
784 #[test]
785 fn gemini_argv_passes_the_prompt_by_flag() {
786 let spec = spec_for(Provider::Gemini);
787 let invocation = spec.invocation("gemini-2.5-pro", SYSTEM, USER);
788
789 assert_eq!(
790 invocation.argv,
791 vec![
792 "gemini",
793 "-m",
794 "gemini-2.5-pro",
795 "-o",
796 "json",
797 "-p",
798 &format!("{SYSTEM}\n\n{USER}"),
799 ]
800 );
801 assert!(invocation.stdin.is_none());
802 assert_eq!(spec.result_json_paths.paths(), ["response", "result"]);
803 }
804
805 #[test]
806 fn grok_argv_matches_the_verified_flags() {
807 let spec = spec_for(Provider::Grok);
808 let invocation = spec.invocation("grok-4", SYSTEM, USER);
809
810 assert_eq!(
811 invocation.argv,
812 vec![
813 "grok",
814 "-m",
815 "grok-4",
816 "--output-format",
817 "json",
818 "-p",
819 &format!("{SYSTEM}\n\n{USER}"),
820 ]
821 );
822 assert!(invocation.stdin.is_none());
823 assert_eq!(spec.result_json_paths.paths(), ["text"]);
824 }
825
826 #[test]
829 fn grok_extracts_text_and_not_the_reasoning() {
830 let spec = spec_for(Provider::Grok);
831 let stdout = r#"{"thought":"thinking about it","text":"OK","stopReason":"end_turn"}"#;
832 assert_eq!(extract_answer(stdout, &spec).unwrap(), "OK");
833 }
834
835 #[test]
839 fn codex_argv_uses_the_subcommand_stdin_and_the_repo_check_escape() {
840 let spec = spec_for(Provider::Codex);
841 let invocation = spec.invocation("gpt-5.1-codex", SYSTEM, USER);
842
843 assert_eq!(
844 invocation.argv,
845 vec![
846 "codex",
847 "exec",
848 "-m",
849 "gpt-5.1-codex",
850 "--json",
851 "--skip-git-repo-check",
852 ]
853 );
854 assert!(
855 !invocation.argv.iter().any(|arg| arg == "-p"),
856 "-p is --profile for codex, never the prompt"
857 );
858 assert_eq!(
859 invocation.stdin.as_deref(),
860 Some(format!("{SYSTEM}\n\n{USER}").as_str())
861 );
862 assert_eq!(spec.output_mode, OutputMode::Ndjson);
863 }
864
865 #[test]
867 fn a_value_less_output_flag_is_passed_alone() {
868 let spec = spec_for(Provider::Codex);
869 let argv = spec.invocation("", SYSTEM, USER).argv;
870 assert_eq!(
871 argv,
872 vec!["codex", "exec", "--json", "--skip-git-repo-check"]
873 );
874 }
875
876 #[test]
877 fn an_empty_model_omits_the_model_flag() {
878 let spec = spec_for(Provider::Gemini);
879 let invocation = spec.invocation("", SYSTEM, USER);
880 assert_eq!(invocation.argv[1], "-o");
881 }
882
883 #[test]
884 fn a_custom_provider_needs_a_command() {
885 let err = CliSpec::resolve(&Provider::Cli, &CliSection::default())
886 .expect_err("custom preset has no default binary");
887 assert!(err.to_string().contains("command"), "{err}");
888 }
889
890 #[test]
891 fn a_custom_provider_is_built_entirely_from_config() {
892 let section = CliSection {
893 command: Some("mycli".into()),
894 args: Some(vec!["chat".into()]),
895 prompt_delivery: Some(PromptDelivery::Arg),
896 model_flag: Some("--model".into()),
897 output_format_flag: Some("--format".into()),
898 output_format_value: Some("json".into()),
899 result_json_path: Some(JsonPaths::parse("data.text")),
900 extra_args: Some(vec!["--quiet".into()]),
901 timeout_secs: Some(0),
902 ..CliSection::default()
903 };
904 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
905
906 assert_eq!(
907 spec.invocation("m1", SYSTEM, USER).argv,
908 vec![
909 "mycli",
910 "chat",
911 "--model",
912 "m1",
913 "--format",
914 "json",
915 "--quiet",
916 &format!("{SYSTEM}\n\n{USER}"),
917 ]
918 );
919 assert!(spec.timeout.is_none(), "0 seconds means no limit");
920 assert_eq!(spec.result_json_paths.paths(), ["data.text"]);
921 }
922
923 #[test]
924 fn overrides_are_applied_on_top_of_a_preset() {
925 let section = CliSection {
926 command: Some("/opt/bin/gemini".into()),
927 model_flag: Some("--model".into()),
928 result_json_path: Some(JsonPaths::parse("output")),
929 ..CliSection::default()
930 };
931 let spec = CliSpec::resolve(&Provider::Gemini, §ion).expect("resolves");
932 let invocation = spec.invocation("flash", SYSTEM, USER);
933
934 assert_eq!(invocation.argv[0], "/opt/bin/gemini");
935 assert_eq!(invocation.argv[1], "--model");
936 assert_eq!(spec.result_json_paths.paths(), ["output"]);
937 }
938
939 #[test]
940 fn a_preset_can_be_chosen_independently_of_the_provider() {
941 let section = CliSection {
942 preset: Some(CliPreset::Gemini),
943 command: Some("gemini-next".into()),
944 ..CliSection::default()
945 };
946 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
947 assert_eq!(spec.prompt_delivery, PromptDelivery::Flag);
948 assert_eq!(spec.invocation("", "", USER).argv.last().unwrap(), USER);
949 }
950
951 #[test]
952 fn flag_delivery_without_a_flag_is_rejected() {
953 let section = CliSection {
954 command: Some("mycli".into()),
955 prompt_delivery: Some(PromptDelivery::Flag),
956 ..CliSection::default()
957 };
958 let err = CliSpec::resolve(&Provider::Cli, §ion).expect_err("no prompt flag");
959 assert!(err.to_string().contains("prompt_flag"), "{err}");
960 }
961
962 #[test]
963 fn http_providers_have_no_cli_spec() {
964 assert!(CliSpec::resolve(&Provider::Anthropic, &CliSection::default()).is_err());
965 assert!(CliSpec::resolve(&Provider::Openai, &CliSection::default()).is_err());
966 }
967
968 #[test]
969 fn a_missing_system_prompt_still_uses_the_flag_when_there_is_one() {
970 let spec = spec_for(Provider::ClaudeCode);
971 let invocation = spec.invocation("sonnet", "", USER);
972 assert!(invocation.argv.contains(&"--system-prompt".to_string()));
973 }
974
975 #[test]
976 fn argv_preview_shows_the_stdin_redirect() {
977 let preview = spec_for(Provider::ClaudeCode).argv_preview("sonnet");
978 assert!(preview.starts_with("claude -p --model sonnet"), "{preview}");
979 assert!(preview.ends_with("< <prompt>"), "{preview}");
980 }
981
982 #[test]
984 fn argv_preview_keeps_a_folded_prompt_on_one_line() {
985 let preview = spec_for(Provider::Grok).argv_preview("grok-4");
986 assert!(!preview.contains('\n'), "{preview}");
987 assert!(
988 preview.ends_with(r#"-p "<system>\n\n<prompt>""#),
989 "{preview}"
990 );
991 }
992
993 fn json_spec(paths: &[&str]) -> CliSpec {
997 CliSpec {
998 output_mode: OutputMode::SingleJson,
999 result_json_paths: JsonPaths::new(paths.iter().map(|p| (*p).to_string())),
1000 ..spec_for(Provider::Gemini)
1001 }
1002 }
1003
1004 #[test]
1005 fn json_path_present_returns_the_field() {
1006 let spec = json_spec(&["response", "result"]);
1007 let answer = extract_answer(r#"{"response":"hello","session_id":"a"}"#, &spec).unwrap();
1008 assert_eq!(answer, "hello");
1009 }
1010
1011 #[test]
1012 fn json_paths_are_tried_in_order() {
1013 let spec = json_spec(&["response", "result"]);
1014 let answer = extract_answer(r#"{"result":"second choice"}"#, &spec).unwrap();
1015 assert_eq!(answer, "second choice");
1016 }
1017
1018 #[test]
1019 fn nested_and_indexed_paths_resolve() {
1020 let spec = json_spec(&["messages.0.text"]);
1021 let answer = extract_answer(r#"{"messages":[{"text":"deep"}]}"#, &spec).unwrap();
1022 assert_eq!(answer, "deep");
1023 }
1024
1025 #[test]
1026 fn a_missing_json_path_falls_back_to_raw_stdout() {
1027 let spec = json_spec(&["response"]);
1028 let stdout = r#"{"text":"renamed field"}"#;
1029 assert_eq!(extract_answer(stdout, &spec).unwrap(), stdout);
1030 }
1031
1032 #[test]
1033 fn malformed_json_falls_back_to_raw_stdout() {
1034 let spec = json_spec(&["response"]);
1035 let stdout = "{not json at all";
1036 assert_eq!(extract_answer(stdout, &spec).unwrap(), stdout);
1037 }
1038
1039 #[test]
1040 fn a_non_string_at_the_path_falls_back_to_raw_stdout() {
1041 let spec = json_spec(&["response"]);
1042 let stdout = r#"{"response":{"text":"nested"}}"#;
1043 assert_eq!(extract_answer(stdout, &spec).unwrap(), stdout);
1044 }
1045
1046 #[test]
1047 fn an_error_envelope_is_an_error_not_an_answer() {
1048 let spec = json_spec(&["response"]);
1049 let err = extract_answer(r#"{"error":{"message":"quota exceeded"}}"#, &spec)
1050 .expect_err("error envelope");
1051 assert!(err.to_string().contains("quota exceeded"), "{err}");
1052 }
1053
1054 #[test]
1055 fn raw_mode_means_stdout_is_the_answer() {
1056 let spec = spec_for(Provider::ClaudeCode);
1057 assert_eq!(spec.output_mode, OutputMode::Raw);
1058 assert_eq!(extract_answer("plain prose", &spec).unwrap(), "plain prose");
1059 }
1060
1061 const CODEX_STREAM: &str = concat!(
1065 r#"{"type":"thread.started","thread_id":"01999"}"#,
1066 "\n",
1067 r#"{"type":"turn.started"}"#,
1068 "\n",
1069 r#"{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"OK"}}"#,
1070 "\n",
1071 r#"{"type":"turn.completed","usage":{"input_tokens":13658,"output_tokens":5}}"#,
1072 "\n",
1073 );
1074
1075 #[test]
1076 fn ndjson_takes_the_matching_event_and_ignores_the_rest() {
1077 let spec = spec_for(Provider::Codex);
1078 assert_eq!(extract_answer(CODEX_STREAM, &spec).unwrap(), "OK");
1079 }
1080
1081 #[test]
1083 fn ndjson_keeps_the_last_matching_event() {
1084 let spec = spec_for(Provider::Codex);
1085 let stream = concat!(
1086 r#"{"type":"item.completed","item":{"type":"agent_message","text":"first"}}"#,
1087 "\n",
1088 r#"{"type":"item.completed","item":{"type":"reasoning","text":"ignore me"}}"#,
1089 "\n",
1090 r#"{"type":"item.completed","item":{"type":"agent_message","text":"final"}}"#,
1091 );
1092 assert_eq!(extract_answer(stream, &spec).unwrap(), "final");
1093 }
1094
1095 #[test]
1096 fn ndjson_skips_lines_that_are_not_json() {
1097 let spec = spec_for(Provider::Codex);
1098 let stream = format!("a banner line\n{CODEX_STREAM}");
1099 assert_eq!(extract_answer(&stream, &spec).unwrap(), "OK");
1100 }
1101
1102 #[test]
1103 fn ndjson_with_no_matching_event_falls_back_to_raw_stdout() {
1104 let spec = spec_for(Provider::Codex);
1105 let stream = r#"{"type":"turn.completed","usage":{"output_tokens":5}}"#;
1106 assert_eq!(extract_answer(stream, &spec).unwrap(), stream);
1107 }
1108
1109 #[test]
1110 fn ndjson_surfaces_an_error_event_when_nothing_matched() {
1111 let spec = spec_for(Provider::Codex);
1112 let stream = r#"{"type":"error","error":{"message":"model overloaded"}}"#;
1113 let err = extract_answer(stream, &spec).expect_err("error event");
1114 assert!(err.to_string().contains("model overloaded"), "{err}");
1115 }
1116
1117 #[test]
1123 fn codex_usage_is_read_off_the_turn_event() {
1124 let spec = spec_for(Provider::Codex);
1125 assert_eq!(
1126 extract_usage(CODEX_STREAM, &spec),
1127 Some(TokenUsage {
1128 input_tokens: 13_658,
1129 output_tokens: 5,
1130 })
1131 );
1132 }
1133
1134 #[test]
1136 fn a_stream_without_counts_measures_nothing() {
1137 let spec = spec_for(Provider::Codex);
1138 let stream = r#"{"type":"item.completed","item":{"type":"agent_message","text":"OK"}}"#;
1139 assert_eq!(extract_usage(stream, &spec), None);
1140 }
1141
1142 #[test]
1144 fn the_last_reported_usage_wins() {
1145 let spec = spec_for(Provider::Codex);
1146 let stream = concat!(
1147 r#"{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":1}}"#,
1148 "\n",
1149 r#"{"type":"turn.completed","usage":{"input_tokens":20,"output_tokens":2}}"#,
1150 );
1151 assert_eq!(
1152 extract_usage(stream, &spec).map(TokenUsage::total),
1153 Some(22)
1154 );
1155 }
1156
1157 #[test]
1158 fn grok_usage_is_read_from_its_envelope() {
1159 let spec = spec_for(Provider::Grok);
1160 let stdout = r#"{"text":"OK","usage":{"input_tokens":900,"output_tokens":30}}"#;
1161 assert_eq!(
1162 extract_usage(stdout, &spec).map(TokenUsage::total),
1163 Some(930)
1164 );
1165 }
1166
1167 #[test]
1170 fn the_openai_spelling_of_the_counts_is_accepted_too() {
1171 let spec = spec_for(Provider::Grok);
1172 let stdout = r#"{"text":"OK","usage":{"prompt_tokens":900,"completion_tokens":30}}"#;
1173 assert_eq!(
1174 extract_usage(stdout, &spec).map(TokenUsage::total),
1175 Some(930)
1176 );
1177 }
1178
1179 #[test]
1182 fn a_prose_cli_reports_no_usage() {
1183 let spec = spec_for(Provider::ClaudeCode);
1184 assert!(spec.usage_input_paths.is_empty());
1185 assert_eq!(extract_usage("plain prose", &spec), None);
1186 }
1187
1188 #[test]
1189 fn a_renamed_usage_field_falls_back_to_no_measurement() {
1190 let spec = spec_for(Provider::Grok);
1191 let stdout = r#"{"text":"OK","tokenCounts":{"in":900,"out":30}}"#;
1192 assert_eq!(extract_usage(stdout, &spec), None);
1193 }
1194
1195 #[test]
1198 fn a_half_reported_envelope_is_still_measured() {
1199 let spec = spec_for(Provider::Grok);
1200 let stdout = r#"{"text":"OK","usage":{"output_tokens":30}}"#;
1201 assert_eq!(
1202 extract_usage(stdout, &spec),
1203 Some(TokenUsage {
1204 input_tokens: 0,
1205 output_tokens: 30,
1206 })
1207 );
1208 }
1209
1210 #[test]
1212 fn usage_paths_can_be_set_by_hand() {
1213 let section = CliSection {
1214 command: Some("mycli".into()),
1215 result_json_path: Some(JsonPaths::parse("data.text")),
1216 usage_input_path: Some(JsonPaths::parse("meta.tokens.in")),
1217 usage_output_path: Some(JsonPaths::parse("meta.tokens.out")),
1218 ..CliSection::default()
1219 };
1220 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
1221 let stdout = r#"{"data":{"text":"OK"},"meta":{"tokens":{"in":7,"out":3}}}"#;
1222 assert_eq!(
1223 extract_usage(stdout, &spec).map(TokenUsage::total),
1224 Some(10)
1225 );
1226 }
1227
1228 #[tokio::test]
1229 async fn a_measured_call_carries_its_counts_back_from_the_process() {
1230 let mock = MockCli::new(
1231 "printf '%s\\n' \
1232 '{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"OK\"}}' \
1233 '{\"type\":\"turn.completed\",\"usage\":{\"input_tokens\":11,\"output_tokens\":2}}'",
1234 );
1235 let provider = mock.provider(
1236 CliSection {
1237 preset: Some(CliPreset::Codex),
1238 ..CliSection::default()
1239 },
1240 "",
1241 );
1242
1243 let completion = provider
1244 .complete_measured(SYSTEM, USER, 1024)
1245 .await
1246 .unwrap();
1247
1248 assert_eq!(completion.text, "OK");
1249 assert_eq!(completion.usage.map(TokenUsage::total), Some(13));
1250 }
1251
1252 #[tokio::test]
1253 async fn a_prose_call_comes_back_unmeasured() {
1254 let mock = MockCli::new("printf 'answer from stdin'");
1255 let provider = mock.provider(
1256 CliSection {
1257 preset: Some(CliPreset::ClaudeCode),
1258 ..CliSection::default()
1259 },
1260 "sonnet",
1261 );
1262
1263 let completion = provider
1264 .complete_measured(SYSTEM, USER, 1024)
1265 .await
1266 .unwrap();
1267
1268 assert_eq!(completion.text, "answer from stdin");
1269 assert_eq!(completion.usage, None);
1270 }
1271
1272 #[test]
1275 fn ndjson_matchers_are_configurable() {
1276 let section = CliSection {
1277 command: Some("mycli".into()),
1278 output_mode: Some(OutputMode::Ndjson),
1279 ndjson_match: Some(LineMatchers::parse("kind=message, final=true")),
1280 result_json_path: Some(JsonPaths::parse("content")),
1281 ..CliSection::default()
1282 };
1283 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
1284 let stream = concat!(
1285 r#"{"kind":"message","final":false,"content":"partial"}"#,
1286 "\n",
1287 r#"{"kind":"message","final":true,"content":"done"}"#,
1288 );
1289 assert_eq!(extract_answer(stream, &spec).unwrap(), "done");
1290 }
1291
1292 #[test]
1295 fn a_result_path_on_a_prose_preset_implies_single_json() {
1296 let section = CliSection {
1297 result_json_path: Some(JsonPaths::parse("result")),
1298 ..CliSection::default()
1299 };
1300 let spec = CliSpec::resolve(&Provider::ClaudeCode, §ion).expect("resolves");
1301 assert_eq!(spec.output_mode, OutputMode::SingleJson);
1302 assert_eq!(
1303 extract_answer(r#"{"result":"from json"}"#, &spec).unwrap(),
1304 "from json"
1305 );
1306 }
1307
1308 #[test]
1309 fn an_explicit_output_mode_wins_over_the_inference() {
1310 let section = CliSection {
1311 result_json_path: Some(JsonPaths::parse("result")),
1312 output_mode: Some(OutputMode::Raw),
1313 ..CliSection::default()
1314 };
1315 let spec = CliSpec::resolve(&Provider::ClaudeCode, §ion).expect("resolves");
1316 assert_eq!(spec.output_mode, OutputMode::Raw);
1317 assert_eq!(
1318 extract_answer(r#"{"result":"from json"}"#, &spec).unwrap(),
1319 r#"{"result":"from json"}"#
1320 );
1321 }
1322
1323 struct MockCli {
1326 _dir: tempfile::TempDir,
1327 path: PathBuf,
1328 argv_log: PathBuf,
1329 stdin_log: PathBuf,
1330 }
1331
1332 impl MockCli {
1333 fn new(body: &str) -> Self {
1335 let dir = tempfile::tempdir().expect("tempdir");
1336 let path = dir.path().join("mock-cli");
1337 let argv_log = dir.path().join("argv.txt");
1338 let stdin_log = dir.path().join("stdin.txt");
1339 let script = format!(
1342 "#!/bin/sh\n\
1343 : > '{argv}'\n\
1344 for a in \"$@\"; do printf '%s\\0' \"$a\" >> '{argv}'; done\n\
1345 cat > '{stdin}'\n\
1346 {body}\n",
1347 argv = argv_log.display(),
1348 stdin = stdin_log.display(),
1349 );
1350 fs::write(&path, script).expect("write mock");
1351 fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).expect("chmod");
1352 Self {
1353 _dir: dir,
1354 path,
1355 argv_log,
1356 stdin_log,
1357 }
1358 }
1359
1360 fn provider(&self, section: CliSection, model: &str) -> CliProvider {
1361 let section = CliSection {
1362 command: Some(self.path.display().to_string()),
1363 ..section
1364 };
1365 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
1366 let model = spec.resolve_model(model);
1367 CliProvider::new(spec, model)
1368 }
1369
1370 fn recorded_argv(&self) -> Vec<String> {
1371 fs::read_to_string(&self.argv_log)
1372 .unwrap_or_default()
1373 .split('\0')
1374 .filter(|arg| !arg.is_empty())
1375 .map(str::to_string)
1376 .collect()
1377 }
1378
1379 fn recorded_stdin(&self) -> String {
1380 fs::read_to_string(&self.stdin_log).unwrap_or_default()
1381 }
1382 }
1383
1384 #[tokio::test]
1385 async fn stdin_delivery_feeds_the_prompt_to_the_process() {
1386 let mock = MockCli::new("printf 'answer from stdin'");
1387 let provider = mock.provider(
1388 CliSection {
1389 preset: Some(CliPreset::ClaudeCode),
1390 ..CliSection::default()
1391 },
1392 "sonnet",
1393 );
1394
1395 let answer = provider.complete(SYSTEM, USER, 1024).await.unwrap();
1396
1397 assert_eq!(answer, "answer from stdin");
1398 assert_eq!(
1399 mock.recorded_argv(),
1400 vec![
1401 "-p",
1402 "--model",
1403 "sonnet",
1404 "--output-format",
1405 "text",
1406 "--system-prompt",
1407 SYSTEM,
1408 "--no-session-persistence",
1409 ]
1410 );
1411 assert_eq!(mock.recorded_stdin(), USER);
1412 }
1413
1414 #[tokio::test]
1415 async fn flag_delivery_puts_the_prompt_in_argv_and_leaves_stdin_closed() {
1416 let mock = MockCli::new(r#"printf '{"response":"answer from flag"}'"#);
1417 let provider = mock.provider(
1418 CliSection {
1419 preset: Some(CliPreset::Gemini),
1420 ..CliSection::default()
1421 },
1422 "",
1423 );
1424
1425 let answer = provider.complete(SYSTEM, USER, 1024).await.unwrap();
1426
1427 assert_eq!(answer, "answer from flag");
1428 assert_eq!(
1429 mock.recorded_argv(),
1430 vec!["-o", "json", "-p", &format!("{SYSTEM}\n\n{USER}")]
1431 );
1432 assert!(mock.recorded_stdin().is_empty());
1433 }
1434
1435 #[tokio::test]
1436 async fn a_streaming_cli_is_read_through_the_ndjson_mode() {
1437 let mock = MockCli::new(
1438 "printf '%s\\n' '{\"type\":\"turn.started\"}' \
1439 '{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"OK\"}}' \
1440 '{\"type\":\"turn.completed\"}'",
1441 );
1442 let provider = mock.provider(
1443 CliSection {
1444 preset: Some(CliPreset::Codex),
1445 ..CliSection::default()
1446 },
1447 "",
1448 );
1449
1450 let answer = provider.complete(SYSTEM, USER, 1024).await.unwrap();
1451
1452 assert_eq!(answer, "OK");
1453 assert_eq!(
1454 mock.recorded_argv(),
1455 vec!["exec", "--json", "--skip-git-repo-check"]
1456 );
1457 assert_eq!(mock.recorded_stdin(), format!("{SYSTEM}\n\n{USER}"));
1458 }
1459
1460 #[tokio::test]
1461 async fn a_non_zero_exit_surfaces_the_cli_stderr() {
1462 let mock = MockCli::new("printf 'not authenticated: run gemini auth' >&2\nexit 3");
1463 let provider = mock.provider(
1464 CliSection {
1465 preset: Some(CliPreset::Gemini),
1466 ..CliSection::default()
1467 },
1468 "",
1469 );
1470
1471 let err = provider
1472 .complete(SYSTEM, USER, 1024)
1473 .await
1474 .expect_err("non-zero exit");
1475 let message = err.to_string();
1476 assert!(message.contains("not authenticated"), "{message}");
1477 assert!(message.contains("exited"), "{message}");
1478 }
1479
1480 #[tokio::test]
1481 async fn empty_output_is_an_error() {
1482 let mock = MockCli::new("printf ''");
1483 let provider = mock.provider(CliSection::default(), "");
1484
1485 let err = provider
1486 .complete(SYSTEM, USER, 1024)
1487 .await
1488 .expect_err("no output");
1489 assert!(err.to_string().contains("empty output"), "{err}");
1490 }
1491
1492 #[tokio::test]
1493 async fn a_hung_cli_hits_the_timeout() {
1494 let mock = MockCli::new("sleep 30\nprintf 'too late'");
1495 let provider = mock.provider(
1496 CliSection {
1497 timeout_secs: Some(1),
1498 ..CliSection::default()
1499 },
1500 "",
1501 );
1502
1503 let err = provider
1504 .complete(SYSTEM, USER, 1024)
1505 .await
1506 .expect_err("timeout");
1507 assert!(err.to_string().contains("timed out after 1s"), "{err}");
1508 }
1509
1510 #[tokio::test]
1511 async fn a_missing_binary_is_reported_as_a_spawn_failure() {
1512 let section = CliSection {
1513 command: Some("/nonexistent/agent-cli".into()),
1514 ..CliSection::default()
1515 };
1516 let spec = CliSpec::resolve(&Provider::Cli, §ion).expect("resolves");
1517 let provider = CliProvider::new(spec, String::new());
1518
1519 let err = provider
1520 .complete(SYSTEM, USER, 1024)
1521 .await
1522 .expect_err("missing binary");
1523 assert!(err.to_string().contains("failed to spawn"), "{err}");
1524 }
1525}