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