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
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}