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