Skip to main content

hatchery_node/
provider_inventory.rs

1//! Provider inventory: which provider CLIs this node can run and which models each one
2//! exposes, read from the CLI itself (or its own config). Nothing is invented: a provider whose
3//! CLI does not list models reports only what its help text / config names.
4//!
5//! - codex: `codex debug models` (JSON catalog; entries with `visibility = "list"`).
6//! - grok: `grok models` (text list; `*` marks the default).
7//! - claude: the model aliases its `--help` names for `--model`, plus `model` from
8//!   `~/.claude/settings.json`.
9//! - kimi: `[models.<alias>]` and `default_model` from `~/.kimi/config.toml`
10//!   or `~/.kimi-code/config.toml`.
11
12use serde::Serialize;
13use std::io::Read;
14use std::path::{Path, PathBuf};
15use std::process::{Command, Stdio};
16use std::time::{Duration, Instant};
17
18#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
19pub struct ModelInfo {
20    pub id: String,
21    #[serde(skip_serializing_if = "Vec::is_empty")]
22    pub efforts: Vec<String>,
23    #[serde(skip_serializing_if = "std::ops::Not::not")]
24    pub default: bool,
25}
26
27#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
28pub struct ProviderInfo {
29    pub provider: String,
30    /// Where the list came from (`cli`, `help`, `config`).
31    pub source: String,
32    pub models: Vec<ModelInfo>,
33}
34
35pub const PROVIDERS: &[&str] = &["claude", "codex", "grok", "kimi"];
36
37fn which(bin: &str) -> Option<PathBuf> {
38    let path = std::env::var_os("PATH")?;
39    // Windows PATHEXT. Prefer a real launcher over an extensionless npm shim
40    // in the same directory: `Command` cannot execute that shim.
41    let exts: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat", ""] } else { &[""] };
42    std::env::split_paths(&path).find_map(|dir| {
43        exts.iter().map(|ext| dir.join(format!("{bin}{ext}"))).find(|p| p.is_file())
44    })
45}
46
47/// Run `bin args` with a deadline; stdout as text (stderr dropped, no stdin).
48fn run(bin: &Path, args: &[&str], deadline: Duration) -> Option<String> {
49    let mut cmd = Command::new(bin);
50    cmd.args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null());
51    crate::windows_process::hide_console(&mut cmd);
52    let mut child = cmd.spawn().ok()?;
53    let mut out = child.stdout.take()?;
54    let reader = std::thread::spawn(move || {
55        let mut buf = Vec::new();
56        let _ = out.by_ref().take(4 << 20).read_to_end(&mut buf);
57        buf
58    });
59    let start = Instant::now();
60    loop {
61        match child.try_wait() {
62            Ok(Some(_)) => break,
63            Ok(None) if start.elapsed() < deadline => std::thread::sleep(Duration::from_millis(50)),
64            _ => {
65                let _ = child.kill();
66                let _ = child.wait();
67                return None;
68            }
69        }
70    }
71    String::from_utf8(reader.join().ok()?).ok()
72}
73
74/// Model ids may carry a vendor path or tag (`kimi-code/k3`, `name:tag`, `x@v`); entries that
75/// fail are dropped one by one, never the whole provider.
76fn ok_id(s: &str) -> bool {
77    !s.is_empty() && s.len() <= 128 && !s.starts_with(['/', ':']) && !s.contains("..") && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.[]/:@".contains(&b))
78}
79
80pub fn parse_codex(json: &str) -> Vec<ModelInfo> {
81    let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else { return Vec::new() };
82    let list = v.get("models").and_then(|m| m.as_array()).cloned().unwrap_or_default();
83    list.iter()
84        .filter(|m| m.get("visibility").and_then(|x| x.as_str()).is_none_or(|x| x == "list"))
85        .filter_map(|m| {
86            let id = m.get("slug").or_else(|| m.get("id")).and_then(|x| x.as_str())?;
87            ok_id(id).then(|| ModelInfo {
88                id: id.to_owned(),
89                efforts: m
90                    .get("supported_reasoning_levels")
91                    .and_then(|l| l.as_array())
92                    .map(|l| l.iter().filter_map(|e| e.get("effort").and_then(|x| x.as_str())).filter(|e| ok_id(e)).map(str::to_owned).collect())
93                    .unwrap_or_default(),
94                default: false,
95            })
96        })
97        .collect()
98}
99
100pub fn parse_grok(text: &str) -> Vec<ModelInfo> {
101    text.lines()
102        .filter_map(|l| {
103            let t = l.trim_start();
104            let (default, rest) = if let Some(r) = t.strip_prefix("* ") { (true, r) } else { (false, t.strip_prefix("- ")?) };
105            let id = rest.split_whitespace().next()?;
106            ok_id(id).then(|| ModelInfo { id: id.to_owned(), efforts: Vec::new(), default })
107        })
108        .collect()
109}
110
111/// Quoted aliases in the `--model` paragraph of `claude --help`.
112pub fn parse_claude_help(help: &str) -> Vec<ModelInfo> {
113    let Some(start) = help.find("--model") else { return Vec::new() };
114    let para: String = help[start..].lines().take_while(|l| !l.trim().is_empty()).take(8).enumerate()
115        .filter(|(i, l)| *i == 0 || !l.trim_start().starts_with("--"))
116        .map(|(_, l)| l)
117        .collect::<Vec<_>>()
118        .join(" ");
119    let mut out: Vec<ModelInfo> = Vec::new();
120    for (i, part) in para.split('\'').enumerate() {
121        if i % 2 == 1 && ok_id(part) && !out.iter().any(|m| m.id == part) {
122            out.push(ModelInfo { id: part.to_owned(), efforts: Vec::new(), default: false });
123        }
124    }
125    // `--effort <level> ... (low, medium, high, xhigh, max)`: the levels the CLI names. Haiku
126    // takes no effort (the g4a claude catalog gives it none), so it gets none here either.
127    let efforts = parse_claude_efforts(help);
128    for m in out.iter_mut().filter(|m| !m.id.contains("haiku")) {
129        m.efforts = efforts.clone();
130    }
131    out
132}
133
134fn parse_claude_efforts(help: &str) -> Vec<String> {
135    let Some(start) = help.find("--effort") else { return Vec::new() };
136    let para: String = help[start..].lines().take(4).collect::<Vec<_>>().join(" ");
137    let Some(open) = para.find('(') else { return Vec::new() };
138    let Some(close) = para[open..].find(')') else { return Vec::new() };
139    para[open + 1..open + close].split(',').map(str::trim).filter(|e| !e.is_empty() && e.bytes().all(|b| b.is_ascii_lowercase())).map(str::to_owned).collect()
140}
141
142#[cfg(test)]
143mod claude_effort_tests {
144    #[test]
145    fn efforts_from_help() {
146        let help = "  --effort <level>    Effort level for the current session\n                      (low, medium, high, xhigh, max)\n  --model <model>   Model for the current session. Provide an alias ('sonnet' or 'opus') or 'haiku'\n\n";
147        let m = super::parse_claude_help(help);
148        let sonnet = m.iter().find(|x| x.id == "sonnet").unwrap();
149        assert_eq!(sonnet.efforts, ["low", "medium", "high", "xhigh", "max"]);
150        assert!(m.iter().find(|x| x.id == "haiku").unwrap().efforts.is_empty());
151    }
152}
153
154pub fn parse_kimi_config(toml_text: &str) -> Vec<ModelInfo> {
155    let mut out = Vec::new();
156    let mut default = None;
157    for line in toml_text.lines() {
158        let l = line.trim();
159        if let Some(rest) = l.strip_prefix("[models.").and_then(|r| r.strip_suffix(']')) {
160            let id = rest.trim_matches('"');
161            if ok_id(id) {
162                out.push(ModelInfo { id: id.to_owned(), efforts: Vec::new(), default: false });
163            }
164        } else if let Some(v) = l.strip_prefix("default_model") {
165            default = v.trim_start().strip_prefix('=').map(|v| v.trim().trim_matches('"').to_owned());
166        }
167    }
168    for m in &mut out {
169        m.default = default.as_deref() == Some(m.id.as_str());
170    }
171    out
172}
173
174fn home() -> Option<PathBuf> {
175    std::env::var_os("HOME")
176        .or_else(|| std::env::var_os("USERPROFILE"))
177        .map(PathBuf::from)
178        .filter(|p| !p.as_os_str().is_empty())
179}
180
181fn provider_homes() -> Vec<PathBuf> {
182    let mut homes = Vec::new();
183    if let Some(h) = home() {
184        homes.push(h);
185    }
186    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
187        homes.extend(std::env::split_paths(&extra));
188    }
189    homes
190}
191
192fn kimi_config_text() -> Option<String> {
193    let home = home()?;
194    for rel in [".kimi/config.toml", ".kimi-code/config.toml"] {
195        if let Ok(text) = std::fs::read_to_string(home.join(rel)) {
196            if !text.is_empty() {
197                return Some(text);
198            }
199        }
200    }
201    None
202}
203
204/// Detect one provider; `None` when its CLI is not on PATH and not in a standard install directory.
205pub fn detect(provider: &str) -> Option<ProviderInfo> {
206    let homes = provider_homes();
207    let deadline = Duration::from_secs(20);
208    let (bin, source, models) = match provider {
209        "codex" => {
210            let bin = resolve_binary(provider, &homes)?;
211            let m = run(&bin, &["debug", "models"], deadline).map(|s| parse_codex(&s)).unwrap_or_default();
212            (bin, "cli", m)
213        }
214        "grok" => {
215            let bin = resolve_binary(provider, &homes)?;
216            let m = run(&bin, &["models"], deadline).map(|s| parse_grok(&s)).unwrap_or_default();
217            (bin, "cli", m)
218        }
219        "claude" => {
220            let bin = resolve_binary(provider, &homes)?;
221            let mut m = run(&bin, &["--help"], deadline).map(|s| parse_claude_help(&s)).unwrap_or_default();
222            if let Some(set) = home().and_then(|h| std::fs::read(h.join(".claude/settings.json")).ok()).and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok()) {
223                if let Some(id) = set.get("model").and_then(|x| x.as_str()).filter(|s| ok_id(s)) {
224                    match m.iter_mut().find(|x| x.id == id) {
225                        Some(x) => x.default = true,
226                        None => m.push(ModelInfo { id: id.to_owned(), efforts: Vec::new(), default: true }),
227                    }
228                }
229            }
230            (bin, "help", m)
231        }
232        "kimi" => {
233            let bin = resolve_binary(provider, &homes)?;
234            let m = kimi_config_text().map(|s| parse_kimi_config(&s)).unwrap_or_default();
235            (bin, "config", m)
236        }
237        _ => return None,
238    };
239    let _ = bin;
240    Some(ProviderInfo { provider: provider.to_owned(), source: source.to_owned(), models })
241}
242
243/// All providers whose CLI this node can run.
244pub fn detect_all() -> Vec<ProviderInfo> {
245    PROVIDERS.iter().filter_map(|p| detect(p)).collect()
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use std::path::{Path, PathBuf};
252
253    #[test]
254    fn parses_codex_catalog() {
255        let j = r#"{"models":[{"slug":"m-a","visibility":"list","supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}]},{"slug":"m-hidden","visibility":"hide"},{"slug":"bad slug","visibility":"list"}]}"#;
256        let m = parse_codex(j);
257        assert_eq!(m.len(), 1);
258        assert_eq!(m[0].id, "m-a");
259        assert_eq!(m[0].efforts, ["low", "high"]);
260        assert!(parse_codex("nope").is_empty());
261    }
262
263    #[test]
264    fn parses_grok_list() {
265        let m = parse_grok("You are not authenticated.\n\nDefault model: g-1\n\nAvailable models:\n  * g-1 (default)\n  - g-0\n");
266        assert_eq!(m.iter().map(|x| (x.id.as_str(), x.default)).collect::<Vec<_>>(), [("g-1", true), ("g-0", false)]);
267    }
268
269    #[test]
270    fn parses_claude_help_aliases() {
271        let h = "  --mcp-config <c>    x\n  --model <model>     Model for the current session. Provide\n                      an alias for the latest model (e.g.\n                      'a1', 'b2', or 'c3') or a\n                      model's full name.\n  --name <n>          y 'zz'\n";
272        let m = parse_claude_help(h);
273        assert_eq!(m.iter().map(|x| x.id.as_str()).collect::<Vec<_>>(), ["a1", "b2", "c3"]);
274    }
275
276    #[test]
277    fn parses_kimi_config() {
278        let m = parse_kimi_config(
279            "default_model = \"k2\"\n[models.k1]\nprovider = \"x\"\n[models.\"k2\"]\n[models.\"kimi-code/k3\"]\n",
280        );
281        assert_eq!(
282            m.iter().map(|x| (x.id.as_str(), x.default)).collect::<Vec<_>>(),
283            [("k1", false), ("k2", true), ("kimi-code/k3", false)]
284        );
285    }
286
287    #[test]
288    fn typical_install_dirs_are_relative_to_any_home() {
289        let home = Path::new("/tmp/any-user");
290        let dirs: Vec<PathBuf> = TYPICAL_REL.iter().map(|rel| home.join(rel)).collect();
291        assert!(dirs.iter().any(|d| d.ends_with(Path::new(".local").join("bin"))));
292        assert!(dirs.iter().any(|d| d.ends_with(Path::new(".grok").join("bin"))));
293        assert!(dirs.iter().any(|d| d.ends_with(Path::new(".kimi-code").join("bin"))));
294        assert!(dirs.iter().any(|d| d.ends_with(Path::new(".claude").join("local"))));
295        assert!(dirs.iter().any(|d| d.ends_with(Path::new("AppData").join("Roaming").join("npm"))));
296    }
297
298    /// npm ships an extensionless shim next to `codex.cmd`. `Command` cannot run the shim.
299    #[cfg(windows)]
300    #[test]
301    fn cmd_wins_over_extensionless_shim_in_the_same_directory() {
302        let d = std::env::temp_dir().join(format!("hatchery-prov-{}", std::process::id()));
303        let _ = std::fs::remove_dir_all(&d);
304        std::fs::create_dir_all(&d).unwrap();
305        std::fs::write(d.join("codex"), b"shim").unwrap();
306        std::fs::write(d.join("codex.cmd"), b"@echo off\r\necho 1.2.3\r\n").unwrap();
307        let got = find_named("codex", &[d.clone()]).expect("codex.cmd");
308        let _ = std::fs::remove_dir_all(&d);
309        assert_eq!(got, d.join("codex.cmd"));
310    }
311}
312
313/// Binary names a provider may be installed under, in preference order. The canonical id
314/// (what g4a launches) comes first. Override per provider with `HATCHERY_PROVIDER_BIN_<ID>`
315/// (absolute path or a name on PATH).
316pub fn aliases(provider: &str) -> &'static [&'static str] {
317    match provider {
318        "kimi" => &["kimi", "kimi-cli", "kimi-code"],
319        "claude" => &["claude", "claude-code"],
320        "codex" => &["codex"],
321        "grok" => &["grok"],
322        _ => &[],
323    }
324}
325
326/// A candidate is usable only if `--version` answers and it is not a retired stub
327/// (kimi-cli 1.x prints "no longer maintained" and does nothing).
328fn usable(bin: &Path) -> bool {
329    let Some(out) = run(bin, &["--version"], Duration::from_secs(5)) else { return false };
330    let help = run(bin, &["--help"], Duration::from_secs(5)).unwrap_or_default();
331    !out.trim().is_empty() && !help.contains("no longer maintained") && !out.contains("no longer maintained")
332}
333
334/// Per-user directories a provider CLI is installed into. Relative to the home
335/// directory, the same list on every machine.
336const TYPICAL_REL: &[&str] = &[
337    ".local/bin",
338    ".claude/local",
339    ".grok/bin",
340    ".kimi-code/bin",
341    ".npm-global/bin",
342    ".volta/bin",
343    "AppData/Roaming/npm",
344    "scoop/shims",
345];
346
347fn push_unique(dirs: &mut Vec<PathBuf>, dir: PathBuf) {
348    if dir.as_os_str().is_empty() || dirs.iter().any(|d| d == &dir) {
349        return;
350    }
351    dirs.push(dir);
352}
353
354/// `PATH`, then the standard per-user install directories. No host-specific paths.
355pub(crate) fn search_dirs(extra_homes: &[PathBuf]) -> Vec<PathBuf> {
356    let mut dirs = Vec::new();
357    if let Some(path) = std::env::var_os("PATH") {
358        for dir in std::env::split_paths(&path) {
359            push_unique(&mut dirs, dir);
360        }
361    }
362    let mut homes = Vec::new();
363    if let Some(h) = home() {
364        homes.push(h);
365    }
366    for h in extra_homes {
367        if !homes.iter().any(|e| e == h) {
368            homes.push(h.clone());
369        }
370    }
371    for h in &homes {
372        for rel in TYPICAL_REL {
373            push_unique(&mut dirs, h.join(rel));
374        }
375    }
376    if let Some(appdata) = std::env::var_os("APPDATA") {
377        push_unique(&mut dirs, PathBuf::from(appdata).join("npm"));
378    }
379    if let Some(local) = std::env::var_os("LOCALAPPDATA") {
380        push_unique(&mut dirs, PathBuf::from(local).join("Microsoft").join("WinGet").join("Links"));
381    }
382    dirs
383}
384
385/// First usable `name` in `dirs`. On Windows, `.exe` then `.cmd` then `.bat`, and the
386/// extensionless npm shim only after those: `Command` cannot execute the shim.
387fn find_named(name: &str, dirs: &[PathBuf]) -> Option<PathBuf> {
388    let exts: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat", ""] } else { &[""] };
389    for dir in dirs {
390        for ext in exts {
391            let p = dir.join(format!("{name}{ext}"));
392            if p.is_file() && usable(&p) {
393                return Some(p);
394            }
395        }
396    }
397    None
398}
399
400/// Resolve the binary for `provider`: optional `HATCHERY_PROVIDER_BIN_<ID>`, then each
401/// alias on PATH and in the standard install directories.
402pub fn resolve_binary(provider: &str, extra_homes: &[PathBuf]) -> Option<PathBuf> {
403    let key = format!("HATCHERY_PROVIDER_BIN_{}", provider.to_ascii_uppercase());
404    if let Some(v) = std::env::var_os(&key).filter(|v| !v.is_empty()) {
405        let p = PathBuf::from(&v);
406        let p = if p.is_absolute() { Some(p) } else { which(&p.to_string_lossy()) };
407        return p.filter(|p| usable(p));
408    }
409    let dirs = search_dirs(extra_homes);
410    aliases(provider).iter().find_map(|name| find_named(name, &dirs))
411}
412
413/// Make every resolvable provider launchable under its canonical name: a node-private shim
414/// dir with `<id> -> <resolved binary>` symlinks, prepended to PATH (children inherit it).
415#[cfg(unix)]
416pub fn install_shims(shim_dir: &Path, extra_homes: &[PathBuf]) -> Vec<(String, PathBuf)> {
417    let mut done = Vec::new();
418    if std::fs::create_dir_all(shim_dir).is_err() {
419        return done;
420    }
421    for p in PROVIDERS {
422        let link = shim_dir.join(p);
423        let _ = std::fs::remove_file(&link);
424        // Already launchable under its own name and usable: nothing to do.
425        if which(p).filter(|b| usable(b)).is_some() {
426            continue;
427        }
428        if let Some(bin) = resolve_binary(p, extra_homes) {
429            if std::os::unix::fs::symlink(&bin, &link).is_ok() {
430                done.push((p.to_string(), bin));
431            }
432        }
433    }
434    let old = std::env::var_os("PATH").unwrap_or_default();
435    let mut parts = vec![shim_dir.to_path_buf()];
436    parts.extend(std::env::split_paths(&old).filter(|d| d != shim_dir));
437    if let Ok(joined) = std::env::join_paths(parts) {
438        std::env::set_var("PATH", joined);
439    }
440    done
441}
442
443#[cfg(all(test, unix))]
444mod alias_tests {
445    use super::*;
446    use std::os::unix::fs::PermissionsExt;
447
448    fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
449        let p = dir.join(name);
450        std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
451        std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
452        p
453    }
454
455    #[test]
456    fn retired_stub_is_skipped_and_vendor_install_is_found() {
457        let d = std::env::temp_dir().join(format!("alias-{}", std::process::id()));
458        let _ = std::fs::remove_dir_all(&d);
459        std::fs::create_dir_all(d.join("home/.kimi-code/bin")).unwrap();
460        let stub = script(&d, "kimi-cli", "echo 'kimi-cli is no longer maintained.'");
461        assert!(!usable(&stub));
462        let real = script(&d.join("home/.kimi-code/bin"), "kimi", "echo 2.1.1");
463        assert!(usable(&real));
464        // PATH may already carry a real kimi on dev boxes; either way the stub is never chosen.
465        let got = resolve_binary("kimi", &[d.join("home")]).expect("kimi resolved");
466        assert_ne!(got, stub);
467    }
468}