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