Skip to main content

call_coding_clis/
help.rs

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}