1use 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 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 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
47fn 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
74fn 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
111pub 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 out
126}
127
128pub fn parse_kimi_config(toml_text: &str) -> Vec<ModelInfo> {
129 let mut out = Vec::new();
130 let mut default = None;
131 for line in toml_text.lines() {
132 let l = line.trim();
133 if let Some(rest) = l.strip_prefix("[models.").and_then(|r| r.strip_suffix(']')) {
134 let id = rest.trim_matches('"');
135 if ok_id(id) {
136 out.push(ModelInfo { id: id.to_owned(), efforts: Vec::new(), default: false });
137 }
138 } else if let Some(v) = l.strip_prefix("default_model") {
139 default = v.trim_start().strip_prefix('=').map(|v| v.trim().trim_matches('"').to_owned());
140 }
141 }
142 for m in &mut out {
143 m.default = default.as_deref() == Some(m.id.as_str());
144 }
145 out
146}
147
148fn home() -> Option<PathBuf> {
149 std::env::var_os("HOME")
150 .or_else(|| std::env::var_os("USERPROFILE"))
151 .map(PathBuf::from)
152 .filter(|p| !p.as_os_str().is_empty())
153}
154
155fn provider_homes() -> Vec<PathBuf> {
156 let mut homes = Vec::new();
157 if let Some(h) = home() {
158 homes.push(h);
159 }
160 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
161 homes.extend(std::env::split_paths(&extra));
162 }
163 homes
164}
165
166fn kimi_config_text() -> Option<String> {
167 let home = home()?;
168 for rel in [".kimi/config.toml", ".kimi-code/config.toml"] {
169 if let Ok(text) = std::fs::read_to_string(home.join(rel)) {
170 if !text.is_empty() {
171 return Some(text);
172 }
173 }
174 }
175 None
176}
177
178pub fn detect(provider: &str) -> Option<ProviderInfo> {
180 let homes = provider_homes();
181 let deadline = Duration::from_secs(20);
182 let (bin, source, models) = match provider {
183 "codex" => {
184 let bin = resolve_binary(provider, &homes)?;
185 let m = run(&bin, &["debug", "models"], deadline).map(|s| parse_codex(&s)).unwrap_or_default();
186 (bin, "cli", m)
187 }
188 "grok" => {
189 let bin = resolve_binary(provider, &homes)?;
190 let m = run(&bin, &["models"], deadline).map(|s| parse_grok(&s)).unwrap_or_default();
191 (bin, "cli", m)
192 }
193 "claude" => {
194 let bin = resolve_binary(provider, &homes)?;
195 let mut m = run(&bin, &["--help"], deadline).map(|s| parse_claude_help(&s)).unwrap_or_default();
196 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()) {
197 if let Some(id) = set.get("model").and_then(|x| x.as_str()).filter(|s| ok_id(s)) {
198 match m.iter_mut().find(|x| x.id == id) {
199 Some(x) => x.default = true,
200 None => m.push(ModelInfo { id: id.to_owned(), efforts: Vec::new(), default: true }),
201 }
202 }
203 }
204 (bin, "help", m)
205 }
206 "kimi" => {
207 let bin = resolve_binary(provider, &homes)?;
208 let m = kimi_config_text().map(|s| parse_kimi_config(&s)).unwrap_or_default();
209 (bin, "config", m)
210 }
211 _ => return None,
212 };
213 let _ = bin;
214 Some(ProviderInfo { provider: provider.to_owned(), source: source.to_owned(), models })
215}
216
217pub fn detect_all() -> Vec<ProviderInfo> {
219 PROVIDERS.iter().filter_map(|p| detect(p)).collect()
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use std::path::{Path, PathBuf};
226
227 #[test]
228 fn parses_codex_catalog() {
229 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"}]}"#;
230 let m = parse_codex(j);
231 assert_eq!(m.len(), 1);
232 assert_eq!(m[0].id, "m-a");
233 assert_eq!(m[0].efforts, ["low", "high"]);
234 assert!(parse_codex("nope").is_empty());
235 }
236
237 #[test]
238 fn parses_grok_list() {
239 let m = parse_grok("You are not authenticated.\n\nDefault model: g-1\n\nAvailable models:\n * g-1 (default)\n - g-0\n");
240 assert_eq!(m.iter().map(|x| (x.id.as_str(), x.default)).collect::<Vec<_>>(), [("g-1", true), ("g-0", false)]);
241 }
242
243 #[test]
244 fn parses_claude_help_aliases() {
245 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";
246 let m = parse_claude_help(h);
247 assert_eq!(m.iter().map(|x| x.id.as_str()).collect::<Vec<_>>(), ["a1", "b2", "c3"]);
248 }
249
250 #[test]
251 fn parses_kimi_config() {
252 let m = parse_kimi_config(
253 "default_model = \"k2\"\n[models.k1]\nprovider = \"x\"\n[models.\"k2\"]\n[models.\"kimi-code/k3\"]\n",
254 );
255 assert_eq!(
256 m.iter().map(|x| (x.id.as_str(), x.default)).collect::<Vec<_>>(),
257 [("k1", false), ("k2", true), ("kimi-code/k3", false)]
258 );
259 }
260
261 #[test]
262 fn typical_install_dirs_are_relative_to_any_home() {
263 let home = Path::new("/tmp/any-user");
264 let dirs: Vec<PathBuf> = TYPICAL_REL.iter().map(|rel| home.join(rel)).collect();
265 assert!(dirs.iter().any(|d| d.ends_with(Path::new(".local").join("bin"))));
266 assert!(dirs.iter().any(|d| d.ends_with(Path::new(".grok").join("bin"))));
267 assert!(dirs.iter().any(|d| d.ends_with(Path::new(".kimi-code").join("bin"))));
268 assert!(dirs.iter().any(|d| d.ends_with(Path::new(".claude").join("local"))));
269 assert!(dirs.iter().any(|d| d.ends_with(Path::new("AppData").join("Roaming").join("npm"))));
270 }
271
272 #[cfg(windows)]
274 #[test]
275 fn cmd_wins_over_extensionless_shim_in_the_same_directory() {
276 let d = std::env::temp_dir().join(format!("hatchery-prov-{}", std::process::id()));
277 let _ = std::fs::remove_dir_all(&d);
278 std::fs::create_dir_all(&d).unwrap();
279 std::fs::write(d.join("codex"), b"shim").unwrap();
280 std::fs::write(d.join("codex.cmd"), b"@echo off\r\necho 1.2.3\r\n").unwrap();
281 let got = find_named("codex", &[d.clone()]).expect("codex.cmd");
282 let _ = std::fs::remove_dir_all(&d);
283 assert_eq!(got, d.join("codex.cmd"));
284 }
285}
286
287pub fn aliases(provider: &str) -> &'static [&'static str] {
291 match provider {
292 "kimi" => &["kimi", "kimi-cli", "kimi-code"],
293 "claude" => &["claude", "claude-code"],
294 "codex" => &["codex"],
295 "grok" => &["grok"],
296 _ => &[],
297 }
298}
299
300fn usable(bin: &Path) -> bool {
303 let Some(out) = run(bin, &["--version"], Duration::from_secs(5)) else { return false };
304 let help = run(bin, &["--help"], Duration::from_secs(5)).unwrap_or_default();
305 !out.trim().is_empty() && !help.contains("no longer maintained") && !out.contains("no longer maintained")
306}
307
308const TYPICAL_REL: &[&str] = &[
311 ".local/bin",
312 ".claude/local",
313 ".grok/bin",
314 ".kimi-code/bin",
315 ".npm-global/bin",
316 ".volta/bin",
317 "AppData/Roaming/npm",
318 "scoop/shims",
319];
320
321fn push_unique(dirs: &mut Vec<PathBuf>, dir: PathBuf) {
322 if dir.as_os_str().is_empty() || dirs.iter().any(|d| d == &dir) {
323 return;
324 }
325 dirs.push(dir);
326}
327
328pub(crate) fn search_dirs(extra_homes: &[PathBuf]) -> Vec<PathBuf> {
330 let mut dirs = Vec::new();
331 if let Some(path) = std::env::var_os("PATH") {
332 for dir in std::env::split_paths(&path) {
333 push_unique(&mut dirs, dir);
334 }
335 }
336 let mut homes = Vec::new();
337 if let Some(h) = home() {
338 homes.push(h);
339 }
340 for h in extra_homes {
341 if !homes.iter().any(|e| e == h) {
342 homes.push(h.clone());
343 }
344 }
345 for h in &homes {
346 for rel in TYPICAL_REL {
347 push_unique(&mut dirs, h.join(rel));
348 }
349 }
350 if let Some(appdata) = std::env::var_os("APPDATA") {
351 push_unique(&mut dirs, PathBuf::from(appdata).join("npm"));
352 }
353 if let Some(local) = std::env::var_os("LOCALAPPDATA") {
354 push_unique(&mut dirs, PathBuf::from(local).join("Microsoft").join("WinGet").join("Links"));
355 }
356 dirs
357}
358
359fn find_named(name: &str, dirs: &[PathBuf]) -> Option<PathBuf> {
362 let exts: &[&str] = if cfg!(windows) { &[".exe", ".cmd", ".bat", ""] } else { &[""] };
363 for dir in dirs {
364 for ext in exts {
365 let p = dir.join(format!("{name}{ext}"));
366 if p.is_file() && usable(&p) {
367 return Some(p);
368 }
369 }
370 }
371 None
372}
373
374pub fn resolve_binary(provider: &str, extra_homes: &[PathBuf]) -> Option<PathBuf> {
377 let key = format!("HATCHERY_PROVIDER_BIN_{}", provider.to_ascii_uppercase());
378 if let Some(v) = std::env::var_os(&key).filter(|v| !v.is_empty()) {
379 let p = PathBuf::from(&v);
380 let p = if p.is_absolute() { Some(p) } else { which(&p.to_string_lossy()) };
381 return p.filter(|p| usable(p));
382 }
383 let dirs = search_dirs(extra_homes);
384 aliases(provider).iter().find_map(|name| find_named(name, &dirs))
385}
386
387#[cfg(unix)]
390pub fn install_shims(shim_dir: &Path, extra_homes: &[PathBuf]) -> Vec<(String, PathBuf)> {
391 let mut done = Vec::new();
392 if std::fs::create_dir_all(shim_dir).is_err() {
393 return done;
394 }
395 for p in PROVIDERS {
396 let link = shim_dir.join(p);
397 let _ = std::fs::remove_file(&link);
398 if which(p).filter(|b| usable(b)).is_some() {
400 continue;
401 }
402 if let Some(bin) = resolve_binary(p, extra_homes) {
403 if std::os::unix::fs::symlink(&bin, &link).is_ok() {
404 done.push((p.to_string(), bin));
405 }
406 }
407 }
408 let old = std::env::var_os("PATH").unwrap_or_default();
409 let mut parts = vec![shim_dir.to_path_buf()];
410 parts.extend(std::env::split_paths(&old).filter(|d| d != shim_dir));
411 if let Ok(joined) = std::env::join_paths(parts) {
412 std::env::set_var("PATH", joined);
413 }
414 done
415}
416
417#[cfg(all(test, unix))]
418mod alias_tests {
419 use super::*;
420 use std::os::unix::fs::PermissionsExt;
421
422 fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
423 let p = dir.join(name);
424 std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
425 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
426 p
427 }
428
429 #[test]
430 fn retired_stub_is_skipped_and_vendor_install_is_found() {
431 let d = std::env::temp_dir().join(format!("alias-{}", std::process::id()));
432 let _ = std::fs::remove_dir_all(&d);
433 std::fs::create_dir_all(d.join("home/.kimi-code/bin")).unwrap();
434 let stub = script(&d, "kimi-cli", "echo 'kimi-cli is no longer maintained.'");
435 assert!(!usable(&stub));
436 let real = script(&d.join("home/.kimi-code/bin"), "kimi", "echo 2.1.1");
437 assert!(usable(&real));
438 let got = resolve_binary("kimi", &[d.join("home")]).expect("kimi resolved");
440 assert_ne!(got, stub);
441 }
442}