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}