Skip to main content

call_coding_clis/
help.rs

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}