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