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 #[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 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
52fn run(bin: &Path, args: &[&str], deadline: Duration) -> Option<String> {
54 run_io(bin, args, deadline, false)
55}
56
57fn 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
91fn 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
117pub 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
132pub 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
162pub 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 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
255pub 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
267pub 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
309pub 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 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 #[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
395pub 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
408fn 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
416const 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
436pub(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
467fn 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
482pub 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#[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 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 let got = resolve_binary("kimi", &[d.join("home")]).expect("kimi resolved");
548 assert_ne!(got, stub);
549 }
550}