Skip to main content

recall_echo/
cli_provider.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Completions from whichever agent CLI the user already pays for.
6//!
7//! Every agent CLI worth using has the same headless shape: a binary, a way to
8//! hand it a prompt, optional model and output-format flags, and an answer on
9//! stdout. So there is one implementation here, and a vendor is a
10//! [`CliPreset`] — a set of defaults for the same [`CliSpec`] fields a user can
11//! set by hand in `[llm.cli]`. Supporting a CLI nobody has heard of yet is
12//! config, not a release.
13//!
14//! ```text
15//! preset defaults ──▶ [llm.cli] overrides ──▶ CliSpec ──▶ argv + stdin
16//!                                                     └─▶ OutputMode ──▶ answer
17//! ```
18//!
19//! What the vendors do *not* agree on is stdout: one JSON object, one JSON
20//! object per line, or prose. That is an [`OutputMode`], not a code path.
21
22use 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
32/// Bytes of a failing CLI's stderr carried into the error.
33const STDERR_EXCERPT: usize = 300;
34/// Per-call wall-clock limit when no preset or config sets one.
35const DEFAULT_TIMEOUT_SECS: u64 = 300;
36
37// ── Resolved spec ────────────────────────────────────────────────────────
38
39/// Everything needed to call one agent CLI once.
40///
41/// Built by [`CliSpec::resolve`] from a preset plus `[llm.cli]`; pure data, so
42/// [`CliSpec::invocation`] is testable without spawning anything.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct CliSpec {
45    /// Binary name or path.
46    pub command: String,
47    /// Environment variable that overrides `command` when config does not.
48    pub command_env: Option<String>,
49    /// Fixed arguments before every generated flag.
50    pub args: Vec<String>,
51    /// How the prompt reaches the process.
52    pub prompt_delivery: PromptDelivery,
53    /// Flag carrying the prompt under [`PromptDelivery::Flag`].
54    pub prompt_flag: String,
55    /// Flag selecting the model; empty omits it.
56    pub model_flag: String,
57    /// Model used when the config names none.
58    pub default_model: String,
59    /// Flag selecting the output format; empty omits it.
60    pub output_format_flag: String,
61    /// Value for `output_format_flag`; empty passes the flag alone.
62    pub output_format_value: String,
63    /// Flag carrying the system prompt; empty prepends it to the message.
64    pub system_prompt_flag: String,
65    /// Shape of the CLI's stdout.
66    pub output_mode: OutputMode,
67    /// Candidate paths to the answer inside JSON output; empty means stdout is
68    /// the answer.
69    pub result_json_paths: JsonPaths,
70    /// Predicates selecting the answer's line under [`OutputMode::Ndjson`].
71    pub ndjson_match: LineMatchers,
72    /// Arguments after the generated flags.
73    pub extra_args: Vec<String>,
74    /// Per-call limit; `None` waits forever.
75    pub timeout: Option<Duration>,
76    /// Variables unset before spawning.
77    pub env_remove: Vec<String>,
78}
79
80/// One resolved call: the exact argv, and what to write to stdin.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Invocation {
83    /// `argv[0]` is the binary.
84    pub argv: Vec<String>,
85    /// `None` closes stdin.
86    pub stdin: Option<String>,
87}
88
89impl CliSpec {
90    /// Defaults for a known CLI.
91    ///
92    /// Verified on live calls against the installed binaries: `claude` 2.1.x,
93    /// `grok` (JSON envelope confirmed) and `codex` 0.146.x (NDJSON stream
94    /// confirmed). `gemini` 0.27.x is flags-only — its success field could not
95    /// be checked without auth, so that preset tries several and falls back to
96    /// stdout. See the provider table in the README.
97    #[must_use]
98    pub fn preset(preset: CliPreset) -> Self {
99        match preset {
100            // `claude -p --model M --output-format text --system-prompt S
101            //  --no-session-persistence`, prompt on stdin. Unchanged since the
102            // provider was claude-only; CLAUDECODE is cleared so a session can
103            // spawn one.
104            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            // `gemini -m M -o json -p <prompt>`. The envelope's success field
123            // is not documented; `response` then `result` are tried and raw
124            // stdout is the fallback, so a rename degrades to noisier output
125            // rather than a broken provider.
126            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            // `grok -m M --output-format json -p <prompt>`. The envelope was
145            // read off a live call: the answer is `text`, and `thought` holds
146            // reasoning — which is why the path is pinned rather than "first
147            // string field".
148            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            // `codex exec -m M --json --skip-git-repo-check`, prompt on stdin.
167            // Three traps, all encoded here rather than left to the user:
168            // `exec` is a subcommand and not a flag; `-p` is `--profile`, so
169            // the prompt goes by stdin and never by `-p`; and without
170            // `--skip-git-repo-check` codex refuses outside a trusted git
171            // directory — which a memory directory usually is not. Its `--json`
172            // is a stream of events, so the answer is the last
173            // `item.completed` carrying an `agent_message`.
174            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            // Nothing assumed: a binary, the prompt on stdin, prose out.
196            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    /// Resolve the spec for a provider: its preset, then `[llm.cli]` on top.
218    ///
219    /// Fails for the HTTP providers, which have no CLI to spawn, and for a spec
220    /// left unusable — no command, or flag delivery with no flag.
221    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) = &section.command {
239            self.command = command.clone();
240            self.command_env = None;
241        }
242        if let Some(args) = &section.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) = &section.prompt_flag {
249            self.prompt_flag = flag.clone();
250        }
251        if let Some(flag) = &section.model_flag {
252            self.model_flag = flag.clone();
253        }
254        if let Some(flag) = &section.output_format_flag {
255            self.output_format_flag = flag.clone();
256        }
257        if let Some(value) = &section.output_format_value {
258            self.output_format_value = value.clone();
259        }
260        if let Some(flag) = &section.system_prompt_flag {
261            self.system_prompt_flag = flag.clone();
262        }
263        if let Some(paths) = &section.result_json_path {
264            self.result_json_paths = paths.clone();
265            // Naming a path on a preset that prints prose can only mean the
266            // output is JSON; asking for a second key to say so again would be
267            // a papercut with no upside.
268            if self.output_mode == OutputMode::Raw && !paths.is_empty() {
269                self.output_mode = OutputMode::SingleJson;
270            }
271        }
272        if let Some(matchers) = &section.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) = &section.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    /// The binary to spawn: config first, then the preset's environment
302    /// override, then the preset default.
303    #[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    /// The model this spec uses, given what the config asked for.
313    #[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    /// Build the exact argv and stdin payload for one completion.
323    #[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        // A value-less output flag is a boolean switch (`codex --json`).
333        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    /// A human-readable, single-line argv for `config show`, with the prompts
367    /// as placeholders.
368    #[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
379/// Render one argument as a shell reader would expect to see it.
380fn 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
388/// CLIs with no system-prompt flag get one message, instructions first.
389fn 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
401// ── Provider ─────────────────────────────────────────────────────────────
402
403/// Completes by spawning an agent CLI described by a [`CliSpec`].
404///
405/// Costs nothing per token: the CLI authenticates with the subscription the
406/// user already has.
407pub 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    /// The spec this provider calls.
419    #[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    /// Spawn, feed, wait, and hand back stdout — or the CLI's own complaint.
443    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            // A dropped future (a timeout, a cancelled task) must not leave an
460            // agent running against the user's subscription.
461            .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
508// ── Output handling ──────────────────────────────────────────────────────
509
510/// Pull the answer out of a CLI's stdout, according to its [`OutputMode`].
511///
512/// Forgiving by design: not JSON, no such path, a non-string there — all fall
513/// back to stdout rather than failing, because a CLI that renamed a field is
514/// still returning a usable answer, and a hard failure here would stall
515/// extraction on every archive. The one exception is an envelope that reports
516/// its own failure while exiting zero: that is not an answer, and passing it on
517/// as one would poison the graph.
518fn 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
538/// Read a stream of events and keep the last one that matches.
539///
540/// Later events supersede earlier ones — an agent may speak more than once per
541/// turn, and it is the final message that answers the prompt.
542fn 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
574/// The first configured path that resolves to a string.
575fn 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
582/// True when the scalar at `path` equals `expected`.
583///
584/// Strings compare directly — `type=item.completed` needs no quotes in the
585/// config — and anything else is read as the JSON literal it is written as, so
586/// `final=true` and `index=0` work too.
587fn 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
597/// A self-reported failure carried in a JSON document, if there is one.
598fn 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
609/// Walk a dotted path; numeric segments index arrays.
610fn 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    // ── argv construction ────────────────────────────────────────────────
645
646    /// The claude-code argv is a compatibility surface: it is what every
647    /// existing install already runs, so the generic path must reproduce it
648    /// exactly.
649    #[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    /// grok's envelope carries reasoning in `thought` beside the answer in
721    /// `text`; extraction must not take whichever string it meets first.
722    #[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    /// codex breaks all three habits the other CLIs share: `exec` is a
730    /// subcommand, `-p` means `--profile` there, and it refuses to run outside
731    /// a trusted git directory without `--skip-git-repo-check`.
732    #[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    /// `--json` takes no value; the flag must still reach the argv.
760    #[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, &section).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, &section).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, &section).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, &section).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    /// A preview is one line, whatever the prompt looks like.
877    #[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    // ── JSON extraction ──────────────────────────────────────────────────
888
889    /// A single-JSON spec reading the given paths.
890    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    // ── NDJSON extraction ────────────────────────────────────────────────
956
957    /// Verbatim from a live `codex exec --json` run.
958    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    /// An agent may speak more than once; the final message is the answer.
976    #[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    /// Predicates address any dotted path, so the mode serves the next
1012    /// streaming CLI without touching this file.
1013    #[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, &section).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    /// Naming a result path where the preset expected prose is an unambiguous
1032    /// statement that the output is JSON.
1033    #[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, &section).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, &section).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    // ── Spawning, against generated mock CLIs ────────────────────────────
1063
1064    struct MockCli {
1065        _dir: tempfile::TempDir,
1066        path: PathBuf,
1067        argv_log: PathBuf,
1068        stdin_log: PathBuf,
1069    }
1070
1071    impl MockCli {
1072        /// A shell script that records how it was called, then behaves as told.
1073        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            // NUL-separated: arguments carry newlines when a system prompt is
1079            // folded into the message.
1080            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, &section).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, &section).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}