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