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