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::{Completion, LlmProvider, TokenUsage};
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    /// Candidate paths to the prompt-token count the CLI reports; empty means
71    /// it reports none.
72    pub usage_input_paths: JsonPaths,
73    /// Candidate paths to the completion-token count the CLI reports.
74    pub usage_output_paths: JsonPaths,
75    /// Predicates selecting the answer's line under [`OutputMode::Ndjson`].
76    pub ndjson_match: LineMatchers,
77    /// Arguments after the generated flags.
78    pub extra_args: Vec<String>,
79    /// Per-call limit; `None` waits forever.
80    pub timeout: Option<Duration>,
81    /// Variables unset before spawning.
82    pub env_remove: Vec<String>,
83}
84
85/// One resolved call: the exact argv, and what to write to stdin.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Invocation {
88    /// `argv[0]` is the binary.
89    pub argv: Vec<String>,
90    /// `None` closes stdin.
91    pub stdin: Option<String>,
92}
93
94impl CliSpec {
95    /// Defaults for a known CLI.
96    ///
97    /// Verified on live calls against the installed binaries: `claude` 2.1.x,
98    /// `grok` (JSON envelope confirmed) and `codex` 0.146.x (NDJSON stream
99    /// confirmed). `gemini` 0.27.x is flags-only — its success field could not
100    /// be checked without auth, so that preset tries several and falls back to
101    /// stdout. See the provider table in the README.
102    #[must_use]
103    pub fn preset(preset: CliPreset) -> Self {
104        match preset {
105            // `claude -p --model M --output-format text --system-prompt S
106            //  --no-session-persistence`, prompt on stdin. Unchanged since the
107            // provider was claude-only; CLAUDECODE is cleared so a session can
108            // spawn one.
109            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                // `--output-format text` is prose: there is no envelope to
123                // carry counts, so claude-code calls are estimated.
124                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            // `gemini -m M -o json -p <prompt>`. The envelope's success field
132            // is not documented; `response` then `result` are tried and raw
133            // stdout is the fallback, so a rename degrades to noisier output
134            // rather than a broken provider.
135            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                // Unverified, like the result field: both the snake_case
149                // spelling and the Gemini API's own camelCase are tried, and a
150                // miss costs an estimate rather than a wrong number.
151                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            // `grok -m M --output-format json -p <prompt>`. The envelope was
167            // read off a live call: the answer is `text`, and `thought` holds
168            // reasoning — which is why the path is pinned rather than "first
169            // string field".
170            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                // grok reports a `usage` object; the two spellings the vendors
184                // use for the same two numbers are both accepted.
185                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            // `codex exec -m M --json --skip-git-repo-check`, prompt on stdin.
199            // Three traps, all encoded here rather than left to the user:
200            // `exec` is a subcommand and not a flag; `-p` is `--profile`, so
201            // the prompt goes by stdin and never by `-p`; and without
202            // `--skip-git-repo-check` codex refuses outside a trusted git
203            // directory — which a memory directory usually is not. Its `--json`
204            // is a stream of events, so the answer is the last
205            // `item.completed` carrying an `agent_message`.
206            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                // Read off the same live run as the answer path: the counts
220                // arrive on the `turn.completed` event, not on the message.
221                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            // Nothing assumed: a binary, the prompt on stdin, prose out.
232            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    /// Resolve the spec for a provider: its preset, then `[llm.cli]` on top.
256    ///
257    /// Fails for the HTTP providers, which have no CLI to spawn, and for a spec
258    /// left unusable — no command, or flag delivery with no flag.
259    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) = &section.command {
277            self.command = command.clone();
278            self.command_env = None;
279        }
280        if let Some(args) = &section.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) = &section.prompt_flag {
287            self.prompt_flag = flag.clone();
288        }
289        if let Some(flag) = &section.model_flag {
290            self.model_flag = flag.clone();
291        }
292        if let Some(flag) = &section.output_format_flag {
293            self.output_format_flag = flag.clone();
294        }
295        if let Some(value) = &section.output_format_value {
296            self.output_format_value = value.clone();
297        }
298        if let Some(flag) = &section.system_prompt_flag {
299            self.system_prompt_flag = flag.clone();
300        }
301        if let Some(paths) = &section.result_json_path {
302            self.result_json_paths = paths.clone();
303            // Naming a path on a preset that prints prose can only mean the
304            // output is JSON; asking for a second key to say so again would be
305            // a papercut with no upside.
306            if self.output_mode == OutputMode::Raw && !paths.is_empty() {
307                self.output_mode = OutputMode::SingleJson;
308            }
309        }
310        if let Some(paths) = &section.usage_input_path {
311            self.usage_input_paths = paths.clone();
312        }
313        if let Some(paths) = &section.usage_output_path {
314            self.usage_output_paths = paths.clone();
315        }
316        if let Some(matchers) = &section.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) = &section.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    /// The binary to spawn: config first, then the preset's environment
346    /// override, then the preset default.
347    #[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    /// The model this spec uses, given what the config asked for.
357    #[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    /// Build the exact argv and stdin payload for one completion.
367    #[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        // A value-less output flag is a boolean switch (`codex --json`).
377        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    /// A human-readable, single-line argv for `config show`, with the prompts
411    /// as placeholders.
412    #[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
423/// Render one argument as a shell reader would expect to see it.
424fn 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
432/// CLIs with no system-prompt flag get one message, instructions first.
433fn 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
445// ── Provider ─────────────────────────────────────────────────────────────
446
447/// Completes by spawning an agent CLI described by a [`CliSpec`].
448///
449/// Costs nothing per token: the CLI authenticates with the subscription the
450/// user already has.
451pub 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    /// The spec this provider calls.
463    #[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    /// One spawn, read twice: for the answer, and for the counts the CLI
484    /// printed beside it. Nothing is spawned to measure a call.
485    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    /// Spawn, feed, wait, and hand back stdout — or the CLI's own complaint.
505    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            // A dropped future (a timeout, a cancelled task) must not leave an
522            // agent running against the user's subscription.
523            .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
570// ── Output handling ──────────────────────────────────────────────────────
571
572/// Pull the answer out of a CLI's stdout, according to its [`OutputMode`].
573///
574/// Forgiving by design: not JSON, no such path, a non-string there — all fall
575/// back to stdout rather than failing, because a CLI that renamed a field is
576/// still returning a usable answer, and a hard failure here would stall
577/// extraction on every archive. The one exception is an envelope that reports
578/// its own failure while exiting zero: that is not an answer, and passing it on
579/// as one would poison the graph.
580fn 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
600/// Read a stream of events and keep the last one that matches.
601///
602/// Later events supersede earlier ones — an agent may speak more than once per
603/// turn, and it is the final message that answers the prompt.
604fn 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
636/// The token counts a CLI printed for this call, if it printed any.
637///
638/// Never inferred from the text: a spec with no usage paths, a CLI that renamed
639/// its counters, or prose output all return `None`, and the caller falls back
640/// to estimating — an honest estimate beats a fabricated measurement.
641///
642/// Under [`OutputMode::Ndjson`] the counts live on their own event, separate
643/// from the answer (codex reports them on `turn.completed`, never on the
644/// message), so every line is searched rather than only the matched one, and
645/// the last event carrying counts wins — the same rule the answer follows.
646fn 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        // Prose has no envelope to carry counts.
652        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
665/// The counts inside one JSON document, under this spec's usage paths.
666fn 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
673/// The first configured path that resolves to a non-negative whole number.
674fn 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
680/// The first configured path that resolves to a string.
681fn 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
688/// True when the scalar at `path` equals `expected`.
689///
690/// Strings compare directly — `type=item.completed` needs no quotes in the
691/// config — and anything else is read as the JSON literal it is written as, so
692/// `final=true` and `index=0` work too.
693fn 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
703/// A self-reported failure carried in a JSON document, if there is one.
704fn 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
715/// Walk a dotted path; numeric segments index arrays.
716fn 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    // ── argv construction ────────────────────────────────────────────────
751
752    /// The claude-code argv is a compatibility surface: it is what every
753    /// existing install already runs, so the generic path must reproduce it
754    /// exactly.
755    #[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    /// grok's envelope carries reasoning in `thought` beside the answer in
827    /// `text`; extraction must not take whichever string it meets first.
828    #[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    /// codex breaks all three habits the other CLIs share: `exec` is a
836    /// subcommand, `-p` means `--profile` there, and it refuses to run outside
837    /// a trusted git directory without `--skip-git-repo-check`.
838    #[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    /// `--json` takes no value; the flag must still reach the argv.
866    #[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, &section).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, &section).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, &section).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, &section).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    /// A preview is one line, whatever the prompt looks like.
983    #[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    // ── JSON extraction ──────────────────────────────────────────────────
994
995    /// A single-JSON spec reading the given paths.
996    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    // ── NDJSON extraction ────────────────────────────────────────────────
1062
1063    /// Verbatim from a live `codex exec --json` run.
1064    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    /// An agent may speak more than once; the final message is the answer.
1082    #[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    // ── Usage extraction ─────────────────────────────────────────────────
1118
1119    /// codex prints its counts on `turn.completed`, a different event from the
1120    /// one carrying the answer — so usage is read from the whole stream, not
1121    /// from the matched line.
1122    #[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    /// A stream that never reports usage is estimated, not invented.
1135    #[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    /// Later events supersede earlier ones here too.
1143    #[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    /// The vendors spell the same two numbers two ways; both presets accept
1168    /// both, because the alternative is a wrong bill on a rename.
1169    #[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    /// claude-code answers in prose: there is no envelope, so its calls are
1180    /// estimated rather than measured. This is the case the display must own.
1181    #[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    /// One side reported is still a measurement — half a real number beats a
1196    /// whole invented one.
1197    #[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    /// Usage paths are config, like everything else about a vendor.
1211    #[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, &section).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    /// Predicates address any dotted path, so the mode serves the next
1273    /// streaming CLI without touching this file.
1274    #[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, &section).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    /// Naming a result path where the preset expected prose is an unambiguous
1293    /// statement that the output is JSON.
1294    #[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, &section).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, &section).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    // ── Spawning, against generated mock CLIs ────────────────────────────
1324
1325    struct MockCli {
1326        _dir: tempfile::TempDir,
1327        path: PathBuf,
1328        argv_log: PathBuf,
1329        stdin_log: PathBuf,
1330    }
1331
1332    impl MockCli {
1333        /// A shell script that records how it was called, then behaves as told.
1334        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            // NUL-separated: arguments carry newlines when a system prompt is
1340            // folded into the message.
1341            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, &section).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, &section).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}