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