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}