Skip to main content

call_coding_clis/
help.rs

1use crate::config::load_config;
2use crate::parser::AliasDef;
3use crate::RUNNER_REGISTRY;
4use serde_json::Value;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8
9struct RunnerStatus {
10    name: String,
11    #[allow(dead_code)]
12    alias: String,
13    binary: String,
14    found: bool,
15    version: String,
16}
17
18const CANONICAL_RUNNERS: &[(&str, &str)] = &[
19    ("opencode", "oc"),
20    ("claude", "cc"),
21    ("kimi", "k"),
22    ("codex", "c/cx"),
23    ("roocode", "rc"),
24    ("crush", "cr"),
25    ("cursor", "cu"),
26    ("gemini", "g"),
27    ("pi", "p"),
28    ("grok", "gb"),
29];
30
31const HELP_TEXT: &str = r#"ccc — call coding CLIs
32
33Usage:
34  ccc [controls...] "<Prompt>"
35  ccc [controls...] -- "<Prompt starting with control-like tokens>"
36  ccc config
37  ccc config --edit [--user|--local]
38  ccc add [-g] <alias>
39  ccc --print-config
40  ccc help
41  ccc --help
42  ccc -h
43  ccc @reviewer --help
44
45Controls (free order before the prompt):
46  runner        Select which coding CLI to use (default: oc)
47                opencode (oc), claude (cc), kimi (k), codex (c/cx), roocode (rc), crush (cr), cursor (cu), gemini (g), pi (p), grok (gb)
48  +thinking     Set thinking level: +0..+5 or +none/+low/+med/+mid/+medium/+high/+xhigh/+max
49                Levels map 0 none, 1 low, 2 medium, 3 high, 4 xhigh, 5 max
50                Claude maps +0 to --thinking disabled and +1..+5 to --thinking enabled with matching --effort
51                Kimi maps +0 to --no-thinking and +1..+5 to --thinking
52                Codex maps each level to -c model_reasoning_effort (none/low/medium/high/xhigh/max) for gpt-5.6-style models
53                Runners with fewer tiers clamp +5 down to their top tier
54  :provider:model  Override provider and model
55  @name         Use a named preset from config; if no preset exists, runner names select runners before agent fallback
56                Presets can also define a default prompt when the user leaves prompt text blank
57                prompt_mode lets alias prompts prepend or append text; prepend/append require an explicit prompt argument
58  .mode / ..mode
59                Output-mode sugar with a shared dot identity:
60                  .text / ..text, .json / ..json, .fmt / ..fmt, .pt / ..pt, .pj / ..pj
61  --permission-mode <safe|auto|yolo|plan>
62                Request a higher-level permission profile when the selected runner supports it
63  --yolo / -y   Request the runner's lowest-friction auto-approval mode when supported
64  --fast / --no-fast
65                Toggle the runner's fast mode when supported (codex: --enable/--disable fast_mode);
66                unsupported runners warn and ignore it
67  --save-session
68                Allow the selected runner to save this run in its normal session history
69  --cleanup-session
70                Try to clean up the created session after the run when no no-persist flag exists
71
72Flags:
73  --print-config                         Print the canonical example config.toml and exit
74  help / --help / -h                    Print help and exit, even when mixed with other args
75  --version / -v                        Print the ccc version and resolved client versions
76  --show-thinking / --no-show-thinking  Request visible thinking output when the selected runner supports it
77                                        (default: on; config key: show_thinking)
78  --sanitize-osc / --no-sanitize-osc    Strip disruptive OSC control output in human-facing modes
79                                        while preserving OSC 8 hyperlinks
80                                        (config key: defaults.sanitize_osc)
81  --output-log-path / --no-output-log-path
82                                        Print the parseable run-artifact footer line on stderr
83  --output-mode / -o <text|stream-text|json|stream-json|formatted|stream-formatted|pass-text|pt|stream-pass-text|stream-pt|pass-json|pj|stream-pass-json|stream-pj>
84                                        Select raw, streamed, or formatted output handling
85                                        (config key: defaults.output_mode)
86  --forward-unknown-json                In formatted modes, forward unhandled JSON objects to stderr
87  --timeout-secs <N>                    Kill the runner after N seconds and exit 124
88  --runner-arg <ARG>                    Append one raw argument to the resolved runner command before the prompt
89  Environment:
90    CCC_FWD_UNKNOWN_JSON                Also controls unknown-JSON forwarding; defaults on for now
91    FORCE_COLOR / NO_COLOR              Override TTY detection for formatted human output
92                                        (FORCE_COLOR wins if both are set)
93    CCC_UPDATE_CHECK / CCC_AUTO_UPDATE    Override [update] check / auto_update after runs
94    CCC_UPDATE_CACHE / CCC_UPDATE_INTERVAL_HOURS
95                                        Override update cache path and re-check interval
96  --            Treat all remaining args as prompt text, even if they look like controls
97
98Examples:
99  ccc "Fix the failing tests"
100  ccc oc "Refactor auth module"
101  ccc cc +2 :anthropic:claude-sonnet-4-20250514 @reviewer "Add tests"
102  ccc c +4 :openai:gpt-5.4-mini @agent "Debug the parser"
103  ccc --permission-mode auto c "Add tests"
104  ccc --yolo cc +2 :anthropic:claude-sonnet-4-20250514 "Add tests"
105  ccc --permission-mode plan k "Think before editing"
106  ccc ..fmt cc +3 "Investigate the failing test"
107  ccc -o stream-json k "Reply with exactly pong"
108  ccc @reviewer k +4 "Debug the parser"
109  ccc @reviewer "Audit the API boundary"
110  ccc codex "Write a unit test"
111  ccc c --fast "Write a unit test"
112  ccc -y -- +1 @agent :model
113  ccc --print-config
114
115Config:
116  ccc config                            — print every resolved config file path and contents
117  ccc config --edit                     — open the selected config in $EDITOR
118  ccc config --edit --user              — open XDG_CONFIG_HOME/ccc/config.toml or ~/.config/ccc/config.toml
119  ccc config --edit --local             — open the nearest .ccc.toml, or create one in CWD
120  ccc add [-g] <alias>                  — prompt for alias settings and write them to config
121  ccc add <alias> --runner cc --prompt "Review" --yes
122                                        — write an alias non-interactively
123  ccc --print-config                    — print the canonical example config.toml
124  .ccc.toml (searched upward from CWD)  — project-local presets and defaults
125  XDG_CONFIG_HOME/ccc/config.toml       — global defaults when XDG is set
126  ~/.config/ccc/config.toml             — legacy global fallback
127  [update] check / auto_update / interval_hours
128                                        — after each run, warn when a newer ccc is available;
129                                          auto_update can background `cargo install ccc`
130
131Agent tips:
132  Run `ccc config` before relying on aliases or defaults; `ccc --help` lists the aliases visible from the current directory.
133  Use `ccc @alias "task"` when an alias matches the job, then add explicit runner/model/thinking controls only when they matter.
134  Use `--` before prompt text that starts with control-like tokens such as `+1`, `@agent`, or `:model`.
135"#;
136
137fn get_version(binary: &str) -> String {
138    match Command::new(binary)
139        .arg("--version")
140        .stdout(Stdio::piped())
141        .stderr(Stdio::null())
142        .output()
143    {
144        Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout)
145            .lines()
146            .next()
147            .unwrap_or("")
148            .to_string(),
149        _ => String::new(),
150    }
151}
152
153fn ccc_version() -> String {
154    option_env!("CCC_VERSION")
155        .unwrap_or(env!("CARGO_PKG_VERSION"))
156        .to_string()
157}
158
159fn read_json_version(package_json_path: &Path, expected_name: &str) -> String {
160    let payload = match fs::read_to_string(package_json_path) {
161        Ok(text) => text,
162        Err(_) => return String::new(),
163    };
164    let parsed: Value = match serde_json::from_str(&payload) {
165        Ok(value) => value,
166        Err(_) => return String::new(),
167    };
168    if parsed.get("name").and_then(Value::as_str) != Some(expected_name) {
169        return String::new();
170    }
171    parsed
172        .get("version")
173        .and_then(Value::as_str)
174        .unwrap_or("")
175        .to_string()
176}
177
178fn discover_opencode_version(binary_path: &Path) -> String {
179    read_json_version(
180        &binary_path
181            .parent()
182            .unwrap_or(binary_path)
183            .parent()
184            .unwrap_or(binary_path)
185            .join("package.json"),
186        "opencode-ai",
187    )
188}
189
190fn discover_codex_version(binary_path: &Path) -> String {
191    let version = read_json_version(
192        &binary_path
193            .parent()
194            .unwrap_or(binary_path)
195            .parent()
196            .unwrap_or(binary_path)
197            .join("package.json"),
198        "@openai/codex",
199    );
200    if version.is_empty() {
201        String::new()
202    } else {
203        format!("codex-cli {version}")
204    }
205}
206
207fn discover_claude_version(binary_path: &Path) -> String {
208    let parts: Vec<_> = binary_path
209        .components()
210        .map(|component| component.as_os_str().to_string_lossy().into_owned())
211        .collect();
212    if parts.len() < 3 || parts[parts.len() - 3] != "claude" || parts[parts.len() - 2] != "versions"
213    {
214        return String::new();
215    }
216    let version = &parts[parts.len() - 1];
217    if version.is_empty() {
218        String::new()
219    } else {
220        format!("{version} (Claude Code)")
221    }
222}
223
224fn discover_kimi_version(binary_path: &Path) -> String {
225    if binary_path
226        .parent()
227        .and_then(Path::file_name)
228        .and_then(|value| value.to_str())
229        != Some("bin")
230    {
231        return String::new();
232    }
233    let lib_dir = match binary_path.parent().and_then(Path::parent) {
234        Some(parent) => parent.join("lib"),
235        None => return String::new(),
236    };
237    let lib_entries = match fs::read_dir(&lib_dir) {
238        Ok(entries) => entries,
239        Err(_) => return String::new(),
240    };
241    for lib_entry in lib_entries.flatten() {
242        let python_dir = lib_entry.path();
243        let site_packages = python_dir.join("site-packages");
244        let dist_entries = match fs::read_dir(&site_packages) {
245            Ok(entries) => entries,
246            Err(_) => continue,
247        };
248        for dist_entry in dist_entries.flatten() {
249            let dist_path = dist_entry.path();
250            let Some(name) = dist_path.file_name().and_then(|value| value.to_str()) else {
251                continue;
252            };
253            if !name.starts_with("kimi_cli-") || !name.ends_with(".dist-info") {
254                continue;
255            }
256            let metadata_path = dist_path.join("METADATA");
257            let Ok(metadata) = fs::read_to_string(metadata_path) else {
258                continue;
259            };
260            for line in metadata.lines() {
261                if let Some(version) = line.strip_prefix("Version: ") {
262                    if !version.trim().is_empty() {
263                        return format!("kimi, version {}", version.trim());
264                    }
265                    return String::new();
266                }
267            }
268        }
269    }
270    String::new()
271}
272
273fn json_name_matches(package_json_path: &Path, expected_name: &str) -> bool {
274    let payload = match fs::read_to_string(package_json_path) {
275        Ok(text) => text,
276        Err(_) => return false,
277    };
278    let parsed: Value = match serde_json::from_str(&payload) {
279        Ok(value) => value,
280        Err(_) => return false,
281    };
282    parsed.get("name").and_then(Value::as_str) == Some(expected_name)
283}
284
285fn read_cursor_release_version(index_path: &Path) -> String {
286    let text = match fs::read_to_string(index_path) {
287        Ok(text) => text,
288        Err(_) => return String::new(),
289    };
290    let marker = "agent-cli@";
291    let Some(start) = text.find(marker) else {
292        return String::new();
293    };
294    text[start + marker.len()..]
295        .chars()
296        .take_while(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
297        .collect()
298}
299
300fn discover_cursor_version(binary_path: &Path) -> String {
301    let package_root = binary_path.parent().unwrap_or(binary_path);
302    if !json_name_matches(
303        &package_root.join("package.json"),
304        "@anysphere/agent-cli-runtime",
305    ) {
306        return String::new();
307    }
308    read_cursor_release_version(&package_root.join("index.js"))
309}
310
311fn discover_gemini_version(binary_path: &Path) -> String {
312    let home = std::env::var_os("HOME").map(PathBuf::from);
313    discover_gemini_version_with_home(binary_path, home.as_deref())
314}
315
316fn discover_gemini_version_with_home(binary_path: &Path, home: Option<&Path>) -> String {
317    let mut candidates = vec![
318        binary_path
319            .parent()
320            .unwrap_or(binary_path)
321            .join("package.json"),
322        binary_path
323            .parent()
324            .unwrap_or(binary_path)
325            .parent()
326            .unwrap_or(binary_path)
327            .join("package.json"),
328    ];
329    let mut is_npx_launcher = false;
330    if let Ok(launcher) = fs::read_to_string(binary_path) {
331        if launcher.contains("@google/gemini-cli") {
332            is_npx_launcher = true;
333            if let Some(home) = home {
334                let npx_root = home.join(".npm").join("_npx");
335                if let Ok(entries) = fs::read_dir(npx_root) {
336                    for entry in entries.flatten() {
337                        candidates.push(
338                            entry
339                                .path()
340                                .join("node_modules")
341                                .join("@google")
342                                .join("gemini-cli")
343                                .join("package.json"),
344                        );
345                    }
346                }
347            }
348        }
349    }
350    for candidate in candidates {
351        let version = read_json_version(&candidate, "@google/gemini-cli");
352        if !version.is_empty() {
353            return version;
354        }
355    }
356    if is_npx_launcher {
357        return "npx @google/gemini-cli".to_string();
358    }
359    String::new()
360}
361
362fn discover_grok_version(_binary_path: &Path) -> String {
363    let grok_home = std::env::var_os("GROK_HOME")
364        .map(PathBuf::from)
365        .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".grok")));
366    let Some(grok_home) = grok_home else {
367        return String::new();
368    };
369    let version_json = grok_home.join("version.json");
370    let Ok(text) = fs::read_to_string(version_json) else {
371        return String::new();
372    };
373    let Ok(payload) = serde_json::from_str::<Value>(&text) else {
374        return String::new();
375    };
376    payload
377        .get("version")
378        .and_then(|v| v.as_str())
379        .map(str::trim)
380        .filter(|v| !v.is_empty())
381        .map(|v| format!("grok {v}"))
382        .unwrap_or_default()
383}
384
385fn get_runner_version(runner_name: &str, binary: &str, binary_path: &Path) -> String {
386    let real_path = match fs::canonicalize(binary_path) {
387        Ok(path) => path,
388        Err(_) => binary_path.to_path_buf(),
389    };
390    let version = match runner_name {
391        "opencode" => discover_opencode_version(&real_path),
392        "codex" => discover_codex_version(&real_path),
393        "claude" => discover_claude_version(&real_path),
394        "kimi" => discover_kimi_version(&real_path),
395        "cursor" => discover_cursor_version(&real_path),
396        "gemini" => discover_gemini_version(&real_path),
397        "pi" => get_version(binary),
398        "grok" => discover_grok_version(&real_path),
399        _ => String::new(),
400    };
401    if version.is_empty() {
402        get_version(binary)
403    } else {
404        version
405    }
406}
407
408fn is_on_path(binary: &str) -> bool {
409    resolve_binary_path(binary).is_some()
410}
411
412fn resolve_binary_path(binary: &str) -> Option<String> {
413    Command::new("which")
414        .arg(binary)
415        .stdout(Stdio::piped())
416        .stderr(Stdio::null())
417        .output()
418        .ok()
419        .and_then(|output| {
420            if output.status.success() {
421                String::from_utf8(output.stdout).ok()
422            } else {
423                None
424            }
425        })
426        .map(|text| text.trim().to_string())
427        .filter(|text| !text.is_empty())
428}
429
430fn runner_checklist() -> Vec<RunnerStatus> {
431    let mut statuses = Vec::new();
432    for &(name, alias) in CANONICAL_RUNNERS {
433        let registry = RUNNER_REGISTRY.read().unwrap();
434        let binary = registry
435            .get(name)
436            .map(|info| info.binary.clone())
437            .unwrap_or_else(|| name.to_string());
438        drop(registry);
439
440        let found = is_on_path(&binary);
441        let version = if found {
442            let binary_path = resolve_binary_path(&binary);
443            match binary_path {
444                Some(path) => get_runner_version(name, &binary, Path::new(&path)),
445                None => get_version(&binary),
446            }
447        } else {
448            String::new()
449        };
450        statuses.push(RunnerStatus {
451            name: name.to_string(),
452            alias: alias.to_string(),
453            binary,
454            found,
455            version,
456        });
457    }
458    statuses
459}
460
461fn format_runner_checklist() -> String {
462    let mut out = String::from("Runners:\n");
463    for s in runner_checklist() {
464        if s.found {
465            let tag = if s.version.is_empty() {
466                "found"
467            } else {
468                &s.version
469            };
470            out.push_str(&format!("  [+] {:10} ({})  {}\n", s.name, s.binary, tag));
471        } else {
472            out.push_str(&format!("  [-] {:10} ({})  not found\n", s.name, s.binary));
473        }
474    }
475    out
476}
477
478fn format_alias_value(value: &str) -> String {
479    let text = value.replace(['\r', '\n'], " ");
480    if text
481        .chars()
482        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | ':' | '/' | '+' | '-'))
483    {
484        return text;
485    }
486    format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
487}
488
489fn format_alias_summary(alias: &AliasDef) -> String {
490    let mut fields = Vec::new();
491    if let Some(value) = &alias.runner {
492        fields.push(format!("runner={}", format_alias_value(value)));
493    }
494    if let Some(value) = &alias.provider {
495        fields.push(format!("provider={}", format_alias_value(value)));
496    }
497    if let Some(value) = &alias.model {
498        fields.push(format!("model={}", format_alias_value(value)));
499    }
500    if let Some(value) = alias.thinking {
501        fields.push(format!("thinking={value}"));
502    }
503    if let Some(value) = alias.show_thinking {
504        fields.push(format!(
505            "show_thinking={}",
506            if value { "true" } else { "false" }
507        ));
508    }
509    if let Some(value) = alias.sanitize_osc {
510        fields.push(format!(
511            "sanitize_osc={}",
512            if value { "true" } else { "false" }
513        ));
514    }
515    if let Some(value) = &alias.output_mode {
516        fields.push(format!("output_mode={}", format_alias_value(value)));
517    }
518    if let Some(value) = &alias.agent {
519        fields.push(format!("agent={}", format_alias_value(value)));
520    }
521    if let Some(value) = &alias.prompt {
522        fields.push(format!("prompt={}", format_alias_value(value)));
523    }
524    if let Some(value) = &alias.prompt_mode {
525        fields.push(format!("prompt_mode={}", format_alias_value(value)));
526    }
527    if fields.is_empty() {
528        "(empty)".to_string()
529    } else {
530        fields.join(" ")
531    }
532}
533
534fn format_alias_checklist() -> String {
535    let config = load_config(None);
536    let mut out = String::from("Configured aliases:\n");
537    if config.aliases.is_empty() {
538        out.push_str("  (none)\n");
539        return out;
540    }
541    for (name, alias) in config.aliases {
542        out.push_str(&format!("  @{name:<11} {}\n", format_alias_summary(&alias)));
543    }
544    out
545}
546
547fn format_version_report(version: &str, statuses: &[RunnerStatus]) -> String {
548    let mut out = format!("ccc version {version}\nResolved clients:\n");
549    let mut resolved = 0usize;
550    for s in statuses {
551        if s.version.is_empty() {
552            continue;
553        }
554        resolved += 1;
555        out.push_str(&format!(
556            "  [+] {:10} ({})  {}\n",
557            s.name, s.binary, s.version
558        ));
559    }
560    let unresolved = statuses.len().saturating_sub(resolved);
561    if unresolved > 0 {
562        out.push_str(&format!("  (and {unresolved} unresolved)\n"));
563    }
564    out.trim_end_matches('\n').to_string()
565}
566
567pub fn print_help() {
568    print!("{}", HELP_TEXT);
569    println!();
570    println!("{}", format_alias_checklist().trim_end());
571    println!();
572    print!("{}", format_runner_checklist());
573}
574
575pub fn print_version() {
576    println!(
577        "{}",
578        format_version_report(&ccc_version(), &runner_checklist())
579    );
580}
581
582pub fn print_usage() {
583    eprintln!("usage: ccc [controls...] \"<Prompt>\"");
584    eprint!("{}", format_runner_checklist());
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    use std::time::{SystemTime, UNIX_EPOCH};
591
592    fn unique_temp_dir(label: &str) -> PathBuf {
593        let unique = SystemTime::now()
594            .duration_since(UNIX_EPOCH)
595            .unwrap()
596            .as_nanos();
597        let path = std::env::temp_dir().join(format!("ccc-help-{label}-{unique}"));
598        fs::create_dir_all(&path).unwrap();
599        path
600    }
601
602    #[test]
603    fn test_get_runner_version_reads_opencode_package_json_before_command() {
604        let root = unique_temp_dir("opencode");
605        let package_root = root.join("node_modules").join("opencode-ai");
606        let binary_path = package_root.join("bin").join("opencode");
607        fs::create_dir_all(binary_path.parent().unwrap()).unwrap();
608        fs::write(
609            package_root.join("package.json"),
610            r#"{"name":"opencode-ai","version":"1.2.3"}"#,
611        )
612        .unwrap();
613        fs::write(&binary_path, "#!/bin/sh\nexit 99\n").unwrap();
614
615        assert_eq!(
616            get_runner_version("opencode", "definitely-missing-binary", &binary_path),
617            "1.2.3"
618        );
619    }
620
621    #[test]
622    fn test_get_runner_version_reads_codex_package_json_before_command() {
623        let root = unique_temp_dir("codex");
624        let package_root = root.join("node_modules").join("@openai").join("codex");
625        let binary_path = package_root.join("bin").join("codex.js");
626        fs::create_dir_all(binary_path.parent().unwrap()).unwrap();
627        fs::write(
628            package_root.join("package.json"),
629            r#"{"name":"@openai/codex","version":"0.118.0"}"#,
630        )
631        .unwrap();
632        fs::write(&binary_path, "#!/usr/bin/env node\n").unwrap();
633
634        assert_eq!(
635            get_runner_version("codex", "definitely-missing-binary", &binary_path),
636            "codex-cli 0.118.0"
637        );
638    }
639
640    #[test]
641    fn test_get_runner_version_reads_claude_version_from_install_path() {
642        let root = unique_temp_dir("claude");
643        let versions_dir = root.join("claude").join("versions");
644        fs::create_dir_all(&versions_dir).unwrap();
645        let binary_path = versions_dir.join("2.1.98");
646        fs::write(&binary_path, "").unwrap();
647
648        assert_eq!(
649            get_runner_version("claude", "definitely-missing-binary", &binary_path),
650            "2.1.98 (Claude Code)"
651        );
652    }
653
654    #[test]
655    fn test_get_runner_version_reads_kimi_metadata_before_command() {
656        let root = unique_temp_dir("kimi");
657        let binary_path = root.join("bin").join("kimi");
658        let metadata_dir = root
659            .join("lib")
660            .join("python3.13")
661            .join("site-packages")
662            .join("kimi_cli-1.30.0.dist-info");
663        fs::create_dir_all(binary_path.parent().unwrap()).unwrap();
664        fs::create_dir_all(&metadata_dir).unwrap();
665        fs::write(&binary_path, "#!/usr/bin/env python3\n").unwrap();
666        fs::write(
667            metadata_dir.join("METADATA"),
668            "Metadata-Version: 2.3\nName: kimi-cli\nVersion: 1.30.0\n",
669        )
670        .unwrap();
671
672        assert_eq!(
673            get_runner_version("kimi", "definitely-missing-binary", &binary_path),
674            "kimi, version 1.30.0"
675        );
676    }
677
678    #[test]
679    fn test_get_runner_version_reads_cursor_release_marker_before_command() {
680        let root = unique_temp_dir("cursor");
681        let package_root = root.join("cursor-agent");
682        let binary_path = package_root.join("cursor-agent");
683        fs::create_dir_all(&package_root).unwrap();
684        fs::write(
685            package_root.join("package.json"),
686            r#"{"name":"@anysphere/agent-cli-runtime","private":true}"#,
687        )
688        .unwrap();
689        fs::write(
690            package_root.join("index.js"),
691            r#"globalThis.SENTRY_RELEASE={id:"agent-cli@2026.03.30-a5d3e17"};"#,
692        )
693        .unwrap();
694        fs::write(&binary_path, "#!/bin/sh\nexit 99\n").unwrap();
695
696        assert_eq!(
697            get_runner_version("cursor", "definitely-missing-binary", &binary_path),
698            "2026.03.30-a5d3e17"
699        );
700    }
701
702    #[test]
703    fn test_get_runner_version_reads_gemini_package_json_before_command() {
704        let root = unique_temp_dir("gemini");
705        let package_root = root.join("node_modules").join("@google").join("gemini-cli");
706        let binary_path = package_root.join("dist").join("index.js");
707        fs::create_dir_all(binary_path.parent().unwrap()).unwrap();
708        fs::write(
709            package_root.join("package.json"),
710            r#"{"name":"@google/gemini-cli","version":"0.37.2"}"#,
711        )
712        .unwrap();
713        fs::write(&binary_path, "#!/usr/bin/env node\n").unwrap();
714
715        assert_eq!(
716            get_runner_version("gemini", "definitely-missing-binary", &binary_path),
717            "0.37.2"
718        );
719    }
720
721    #[test]
722    fn test_get_runner_version_identifies_gemini_npx_launcher_without_command() {
723        let root = unique_temp_dir("gemini-npx");
724        let binary_path = root.join("gemini");
725        fs::write(
726            &binary_path,
727            "#!/bin/bash\nexec npx --yes @google/gemini-cli \"$@\"\n",
728        )
729        .unwrap();
730
731        assert_eq!(
732            discover_gemini_version_with_home(&binary_path, Some(&root)),
733            "npx @google/gemini-cli"
734        );
735    }
736
737    #[test]
738    fn test_get_runner_version_falls_back_when_metadata_is_missing() {
739        assert_eq!(
740            get_runner_version(
741                "opencode",
742                "definitely-missing-binary",
743                Path::new("/tmp/missing/opencode")
744            ),
745            ""
746        );
747    }
748}