1use crate::RUNNER_REGISTRY;
2use serde_json::Value;
3use std::fs;
4use std::path::Path;
5#[cfg(test)]
6use std::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];
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 --print-config
34 ccc --help
35 ccc -h
36 ccc @reviewer --help
37
38Controls (free order before the prompt):
39 runner Select which coding CLI to use (default: oc)
40 opencode (oc), claude (cc), kimi (k), codex (c/cx), roocode (rc), crush (cr)
41 +thinking Set thinking level: +0..+4 or +none/+low/+med/+mid/+medium/+high/+max/+xhigh
42 Claude maps +0 to --thinking disabled and +1..+4 to --thinking enabled with matching --effort
43 Kimi maps +0 to --no-thinking and +1..+4 to --thinking
44 :provider:model Override provider and model
45 @name Use a named preset from config; if no preset exists, treat it as an agent
46 Presets can also define a default prompt when the user leaves prompt text blank
47 prompt_mode lets alias prompts prepend or append text; prepend/append require an explicit prompt argument
48 .mode / ..mode
49 Output-mode sugar with a shared dot identity:
50 .text / ..text, .json / ..json, .fmt / ..fmt
51 --permission-mode <safe|auto|yolo|plan>
52 Request a higher-level permission profile when the selected runner supports it
53 --yolo / -y Request the runner's lowest-friction auto-approval mode when supported
54
55Flags:
56 --print-config Print the canonical example config.toml and exit
57 --help / -h Print help and exit, even when mixed with other args
58 --show-thinking / --no-show-thinking Request visible thinking output when the selected runner supports it
59 (default: off; config key: show_thinking)
60 --sanitize-osc / --no-sanitize-osc Strip disruptive OSC control output in human-facing modes
61 while preserving OSC 8 hyperlinks
62 (config key: defaults.sanitize_osc)
63 --output-mode / -o <text|stream-text|json|stream-json|formatted|stream-formatted>
64 Select raw, streamed, or formatted output handling
65 (config key: defaults.output_mode)
66 --forward-unknown-json In formatted modes, forward unhandled JSON objects to stderr
67 Environment:
68 FORCE_COLOR / NO_COLOR Override TTY detection for formatted human output
69 (FORCE_COLOR wins if both are set)
70 -- Treat all remaining args as prompt text, even if they look like controls
71
72Examples:
73 ccc "Fix the failing tests"
74 ccc oc "Refactor auth module"
75 ccc cc +2 :anthropic:claude-sonnet-4-20250514 @reviewer "Add tests"
76 ccc c +4 :openai:gpt-5.4-mini @agent "Debug the parser"
77 ccc --permission-mode auto c "Add tests"
78 ccc --yolo cc +2 :anthropic:claude-sonnet-4-20250514 "Add tests"
79 ccc --permission-mode plan k "Think before editing"
80 ccc ..fmt cc +3 "Investigate the failing test"
81 ccc -o stream-json k "Reply with exactly pong"
82 ccc @reviewer k +4 "Debug the parser"
83 ccc @reviewer "Audit the API boundary"
84 ccc codex "Write a unit test"
85 ccc -y -- +1 @agent :model
86 ccc --print-config
87
88Config:
89 ccc config — print the resolved config file path and contents
90 ccc --print-config — print the canonical example config.toml
91 .ccc.toml (searched upward from CWD) — project-local presets and defaults
92 XDG_CONFIG_HOME/ccc/config.toml — global defaults when XDG is set
93 ~/.config/ccc/config.toml — legacy global fallback
94"#;
95
96fn get_version(binary: &str) -> String {
97 match Command::new(binary)
98 .arg("--version")
99 .stdout(Stdio::piped())
100 .stderr(Stdio::null())
101 .output()
102 {
103 Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout)
104 .lines()
105 .next()
106 .unwrap_or("")
107 .to_string(),
108 _ => String::new(),
109 }
110}
111
112fn read_json_version(package_json_path: &Path, expected_name: &str) -> String {
113 let payload = match fs::read_to_string(package_json_path) {
114 Ok(text) => text,
115 Err(_) => return String::new(),
116 };
117 let parsed: Value = match serde_json::from_str(&payload) {
118 Ok(value) => value,
119 Err(_) => return String::new(),
120 };
121 if parsed.get("name").and_then(Value::as_str) != Some(expected_name) {
122 return String::new();
123 }
124 parsed
125 .get("version")
126 .and_then(Value::as_str)
127 .unwrap_or("")
128 .to_string()
129}
130
131fn discover_opencode_version(binary_path: &Path) -> String {
132 read_json_version(&binary_path.parent().unwrap_or(binary_path).parent().unwrap_or(binary_path).join("package.json"), "opencode-ai")
133}
134
135fn discover_codex_version(binary_path: &Path) -> String {
136 let version = read_json_version(
137 &binary_path.parent().unwrap_or(binary_path).parent().unwrap_or(binary_path).join("package.json"),
138 "@openai/codex",
139 );
140 if version.is_empty() {
141 String::new()
142 } else {
143 format!("codex-cli {version}")
144 }
145}
146
147fn discover_claude_version(binary_path: &Path) -> String {
148 let parts: Vec<_> = binary_path
149 .components()
150 .map(|component| component.as_os_str().to_string_lossy().into_owned())
151 .collect();
152 if parts.len() < 3 || parts[parts.len() - 3] != "claude" || parts[parts.len() - 2] != "versions" {
153 return String::new();
154 }
155 let version = &parts[parts.len() - 1];
156 if version.is_empty() {
157 String::new()
158 } else {
159 format!("{version} (Claude Code)")
160 }
161}
162
163fn discover_kimi_version(binary_path: &Path) -> String {
164 if binary_path.parent().and_then(Path::file_name).and_then(|value| value.to_str()) != Some("bin") {
165 return String::new();
166 }
167 let lib_dir = match binary_path.parent().and_then(Path::parent) {
168 Some(parent) => parent.join("lib"),
169 None => return String::new(),
170 };
171 let lib_entries = match fs::read_dir(&lib_dir) {
172 Ok(entries) => entries,
173 Err(_) => return String::new(),
174 };
175 for lib_entry in lib_entries.flatten() {
176 let python_dir = lib_entry.path();
177 let site_packages = python_dir.join("site-packages");
178 let dist_entries = match fs::read_dir(&site_packages) {
179 Ok(entries) => entries,
180 Err(_) => continue,
181 };
182 for dist_entry in dist_entries.flatten() {
183 let dist_path = dist_entry.path();
184 let Some(name) = dist_path.file_name().and_then(|value| value.to_str()) else {
185 continue;
186 };
187 if !name.starts_with("kimi_cli-") || !name.ends_with(".dist-info") {
188 continue;
189 }
190 let metadata_path = dist_path.join("METADATA");
191 let Ok(metadata) = fs::read_to_string(metadata_path) else {
192 continue;
193 };
194 for line in metadata.lines() {
195 if let Some(version) = line.strip_prefix("Version: ") {
196 if !version.trim().is_empty() {
197 return format!("kimi, version {}", version.trim());
198 }
199 return String::new();
200 }
201 }
202 }
203 }
204 String::new()
205}
206
207fn get_runner_version(runner_name: &str, binary: &str, binary_path: &Path) -> String {
208 let real_path = match fs::canonicalize(binary_path) {
209 Ok(path) => path,
210 Err(_) => binary_path.to_path_buf(),
211 };
212 let version = match runner_name {
213 "opencode" => discover_opencode_version(&real_path),
214 "codex" => discover_codex_version(&real_path),
215 "claude" => discover_claude_version(&real_path),
216 "kimi" => discover_kimi_version(&real_path),
217 _ => String::new(),
218 };
219 if version.is_empty() {
220 get_version(binary)
221 } else {
222 version
223 }
224}
225
226fn is_on_path(binary: &str) -> bool {
227 resolve_binary_path(binary).is_some()
228}
229
230fn resolve_binary_path(binary: &str) -> Option<String> {
231 Command::new("which")
232 .arg(binary)
233 .stdout(Stdio::piped())
234 .stderr(Stdio::null())
235 .output()
236 .ok()
237 .and_then(|output| {
238 if output.status.success() {
239 String::from_utf8(output.stdout).ok()
240 } else {
241 None
242 }
243 })
244 .map(|text| text.trim().to_string())
245 .filter(|text| !text.is_empty())
246}
247
248fn runner_checklist() -> Vec<RunnerStatus> {
249 let mut statuses = Vec::new();
250 for &(name, alias) in CANONICAL_RUNNERS {
251 let registry = RUNNER_REGISTRY.read().unwrap();
252 let binary = registry
253 .get(name)
254 .map(|info| info.binary.clone())
255 .unwrap_or_else(|| name.to_string());
256 drop(registry);
257
258 let found = is_on_path(&binary);
259 let version = if found {
260 let binary_path = resolve_binary_path(&binary);
261 match binary_path {
262 Some(path) => get_runner_version(name, &binary, Path::new(&path)),
263 None => get_version(&binary),
264 }
265 } else {
266 String::new()
267 };
268 statuses.push(RunnerStatus {
269 name: name.to_string(),
270 alias: alias.to_string(),
271 binary,
272 found,
273 version,
274 });
275 }
276 statuses
277}
278
279fn format_runner_checklist() -> String {
280 let mut out = String::from("Runners:\n");
281 for s in runner_checklist() {
282 if s.found {
283 let tag = if s.version.is_empty() {
284 "found"
285 } else {
286 &s.version
287 };
288 out.push_str(&format!(" [+] {:10} ({}) {}\n", s.name, s.binary, tag));
289 } else {
290 out.push_str(&format!(" [-] {:10} ({}) not found\n", s.name, s.binary));
291 }
292 }
293 out
294}
295
296pub fn print_help() {
297 print!("{}", HELP_TEXT);
298 print!("{}", format_runner_checklist());
299}
300
301pub fn print_usage() {
302 eprintln!(
303 "usage: ccc [controls...] \"<Prompt>\""
304 );
305 eprint!("{}", format_runner_checklist());
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use std::time::{SystemTime, UNIX_EPOCH};
312
313 fn unique_temp_dir(label: &str) -> PathBuf {
314 let unique = SystemTime::now()
315 .duration_since(UNIX_EPOCH)
316 .unwrap()
317 .as_nanos();
318 let path = std::env::temp_dir().join(format!("ccc-help-{label}-{unique}"));
319 fs::create_dir_all(&path).unwrap();
320 path
321 }
322
323 #[test]
324 fn test_get_runner_version_reads_opencode_package_json_before_command() {
325 let root = unique_temp_dir("opencode");
326 let package_root = root.join("node_modules").join("opencode-ai");
327 let binary_path = package_root.join("bin").join("opencode");
328 fs::create_dir_all(binary_path.parent().unwrap()).unwrap();
329 fs::write(
330 package_root.join("package.json"),
331 r#"{"name":"opencode-ai","version":"1.2.3"}"#,
332 )
333 .unwrap();
334 fs::write(&binary_path, "#!/bin/sh\nexit 99\n").unwrap();
335
336 assert_eq!(
337 get_runner_version("opencode", "definitely-missing-binary", &binary_path),
338 "1.2.3"
339 );
340 }
341
342 #[test]
343 fn test_get_runner_version_reads_codex_package_json_before_command() {
344 let root = unique_temp_dir("codex");
345 let package_root = root.join("node_modules").join("@openai").join("codex");
346 let binary_path = package_root.join("bin").join("codex.js");
347 fs::create_dir_all(binary_path.parent().unwrap()).unwrap();
348 fs::write(
349 package_root.join("package.json"),
350 r#"{"name":"@openai/codex","version":"0.118.0"}"#,
351 )
352 .unwrap();
353 fs::write(&binary_path, "#!/usr/bin/env node\n").unwrap();
354
355 assert_eq!(
356 get_runner_version("codex", "definitely-missing-binary", &binary_path),
357 "codex-cli 0.118.0"
358 );
359 }
360
361 #[test]
362 fn test_get_runner_version_reads_claude_version_from_install_path() {
363 let root = unique_temp_dir("claude");
364 let versions_dir = root.join("claude").join("versions");
365 fs::create_dir_all(&versions_dir).unwrap();
366 let binary_path = versions_dir.join("2.1.98");
367 fs::write(&binary_path, "").unwrap();
368
369 assert_eq!(
370 get_runner_version("claude", "definitely-missing-binary", &binary_path),
371 "2.1.98 (Claude Code)"
372 );
373 }
374
375 #[test]
376 fn test_get_runner_version_reads_kimi_metadata_before_command() {
377 let root = unique_temp_dir("kimi");
378 let binary_path = root.join("bin").join("kimi");
379 let metadata_dir = root
380 .join("lib")
381 .join("python3.13")
382 .join("site-packages")
383 .join("kimi_cli-1.30.0.dist-info");
384 fs::create_dir_all(binary_path.parent().unwrap()).unwrap();
385 fs::create_dir_all(&metadata_dir).unwrap();
386 fs::write(&binary_path, "#!/usr/bin/env python3\n").unwrap();
387 fs::write(
388 metadata_dir.join("METADATA"),
389 "Metadata-Version: 2.3\nName: kimi-cli\nVersion: 1.30.0\n",
390 )
391 .unwrap();
392
393 assert_eq!(
394 get_runner_version("kimi", "definitely-missing-binary", &binary_path),
395 "kimi, version 1.30.0"
396 );
397 }
398
399 #[test]
400 fn test_get_runner_version_falls_back_when_metadata_is_missing() {
401 assert_eq!(
402 get_runner_version("opencode", "definitely-missing-binary", Path::new("/tmp/missing/opencode")),
403 ""
404 );
405 }
406}