1use serde::Serialize;
12use std::io::Read;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::time::{Duration, Instant};
16
17#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
18pub struct ModelInfo {
19 pub id: String,
20 #[serde(skip_serializing_if = "Vec::is_empty")]
21 pub efforts: Vec<String>,
22 #[serde(skip_serializing_if = "std::ops::Not::not")]
23 pub default: bool,
24}
25
26#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
27pub struct ProviderInfo {
28 pub provider: String,
29 pub source: String,
31 pub models: Vec<ModelInfo>,
32}
33
34pub const PROVIDERS: &[&str] = &["claude", "codex", "grok", "kimi"];
35
36fn which(bin: &str) -> Option<PathBuf> {
37 let path = std::env::var_os("PATH")?;
38 std::env::split_paths(&path).map(|d| d.join(bin)).find(|p| p.is_file())
39}
40
41fn run(bin: &Path, args: &[&str], deadline: Duration) -> Option<String> {
43 let mut child = Command::new(bin).args(args).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
44 let mut out = child.stdout.take()?;
45 let reader = std::thread::spawn(move || {
46 let mut buf = Vec::new();
47 let _ = out.by_ref().take(4 << 20).read_to_end(&mut buf);
48 buf
49 });
50 let start = Instant::now();
51 loop {
52 match child.try_wait() {
53 Ok(Some(_)) => break,
54 Ok(None) if start.elapsed() < deadline => std::thread::sleep(Duration::from_millis(50)),
55 _ => {
56 let _ = child.kill();
57 let _ = child.wait();
58 return None;
59 }
60 }
61 }
62 String::from_utf8(reader.join().ok()?).ok()
63}
64
65fn ok_id(s: &str) -> bool {
66 !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.[]".contains(&b))
67}
68
69pub fn parse_codex(json: &str) -> Vec<ModelInfo> {
70 let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else { return Vec::new() };
71 let list = v.get("models").and_then(|m| m.as_array()).cloned().unwrap_or_default();
72 list.iter()
73 .filter(|m| m.get("visibility").and_then(|x| x.as_str()).is_none_or(|x| x == "list"))
74 .filter_map(|m| {
75 let id = m.get("slug").or_else(|| m.get("id")).and_then(|x| x.as_str())?;
76 ok_id(id).then(|| ModelInfo {
77 id: id.to_owned(),
78 efforts: m
79 .get("supported_reasoning_levels")
80 .and_then(|l| l.as_array())
81 .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())
82 .unwrap_or_default(),
83 default: false,
84 })
85 })
86 .collect()
87}
88
89pub fn parse_grok(text: &str) -> Vec<ModelInfo> {
90 text.lines()
91 .filter_map(|l| {
92 let t = l.trim_start();
93 let (default, rest) = if let Some(r) = t.strip_prefix("* ") { (true, r) } else { (false, t.strip_prefix("- ")?) };
94 let id = rest.split_whitespace().next()?;
95 ok_id(id).then(|| ModelInfo { id: id.to_owned(), efforts: Vec::new(), default })
96 })
97 .collect()
98}
99
100pub fn parse_claude_help(help: &str) -> Vec<ModelInfo> {
102 let Some(start) = help.find("--model") else { return Vec::new() };
103 let para: String = help[start..].lines().take_while(|l| !l.trim().is_empty()).take(8).enumerate()
104 .filter(|(i, l)| *i == 0 || !l.trim_start().starts_with("--"))
105 .map(|(_, l)| l)
106 .collect::<Vec<_>>()
107 .join(" ");
108 let mut out: Vec<ModelInfo> = Vec::new();
109 for (i, part) in para.split('\'').enumerate() {
110 if i % 2 == 1 && ok_id(part) && !out.iter().any(|m| m.id == part) {
111 out.push(ModelInfo { id: part.to_owned(), efforts: Vec::new(), default: false });
112 }
113 }
114 out
115}
116
117pub fn parse_kimi_config(toml_text: &str) -> Vec<ModelInfo> {
118 let mut out = Vec::new();
119 let mut default = None;
120 for line in toml_text.lines() {
121 let l = line.trim();
122 if let Some(rest) = l.strip_prefix("[models.").and_then(|r| r.strip_suffix(']')) {
123 let id = rest.trim_matches('"');
124 if ok_id(id) {
125 out.push(ModelInfo { id: id.to_owned(), efforts: Vec::new(), default: false });
126 }
127 } else if let Some(v) = l.strip_prefix("default_model") {
128 default = v.trim_start().strip_prefix('=').map(|v| v.trim().trim_matches('"').to_owned());
129 }
130 }
131 for m in &mut out {
132 m.default = default.as_deref() == Some(m.id.as_str());
133 }
134 out
135}
136
137fn home() -> Option<PathBuf> {
138 std::env::var_os("HOME").map(PathBuf::from)
139}
140
141pub fn detect(provider: &str) -> Option<ProviderInfo> {
143 let deadline = Duration::from_secs(20);
144 let (bin, source, models) = match provider {
145 "codex" => {
146 let bin = which("codex")?;
147 let m = run(&bin, &["debug", "models"], deadline).map(|s| parse_codex(&s)).unwrap_or_default();
148 (bin, "cli", m)
149 }
150 "grok" => {
151 let bin = which("grok")?;
152 let m = run(&bin, &["models"], deadline).map(|s| parse_grok(&s)).unwrap_or_default();
153 (bin, "cli", m)
154 }
155 "claude" => {
156 let bin = which("claude")?;
157 let mut m = run(&bin, &["--help"], deadline).map(|s| parse_claude_help(&s)).unwrap_or_default();
158 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()) {
159 if let Some(id) = set.get("model").and_then(|x| x.as_str()).filter(|s| ok_id(s)) {
160 match m.iter_mut().find(|x| x.id == id) {
161 Some(x) => x.default = true,
162 None => m.push(ModelInfo { id: id.to_owned(), efforts: Vec::new(), default: true }),
163 }
164 }
165 }
166 (bin, "help", m)
167 }
168 "kimi" => {
169 let bin = which("kimi").filter(|b| usable(b))?;
170 let m = home().and_then(|h| std::fs::read_to_string(h.join(".kimi/config.toml")).ok()).map(|s| parse_kimi_config(&s)).unwrap_or_default();
171 (bin, "config", m)
172 }
173 _ => return None,
174 };
175 let _ = bin;
176 Some(ProviderInfo { provider: provider.to_owned(), source: source.to_owned(), models })
177}
178
179pub fn detect_all() -> Vec<ProviderInfo> {
181 PROVIDERS.iter().filter_map(|p| detect(p)).collect()
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 #[test]
189 fn parses_codex_catalog() {
190 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"}]}"#;
191 let m = parse_codex(j);
192 assert_eq!(m.len(), 1);
193 assert_eq!(m[0].id, "m-a");
194 assert_eq!(m[0].efforts, ["low", "high"]);
195 assert!(parse_codex("nope").is_empty());
196 }
197
198 #[test]
199 fn parses_grok_list() {
200 let m = parse_grok("You are not authenticated.\n\nDefault model: g-1\n\nAvailable models:\n * g-1 (default)\n - g-0\n");
201 assert_eq!(m.iter().map(|x| (x.id.as_str(), x.default)).collect::<Vec<_>>(), [("g-1", true), ("g-0", false)]);
202 }
203
204 #[test]
205 fn parses_claude_help_aliases() {
206 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";
207 let m = parse_claude_help(h);
208 assert_eq!(m.iter().map(|x| x.id.as_str()).collect::<Vec<_>>(), ["a1", "b2", "c3"]);
209 }
210
211 #[test]
212 fn parses_kimi_config() {
213 let m = parse_kimi_config("default_model = \"k2\"\n[models.k1]\nprovider = \"x\"\n[models.\"k2\"]\n");
214 assert_eq!(m.iter().map(|x| (x.id.as_str(), x.default)).collect::<Vec<_>>(), [("k1", false), ("k2", true)]);
215 }
216}
217
218pub fn aliases(provider: &str) -> &'static [&'static str] {
222 match provider {
223 "kimi" => &["kimi", "kimi-cli", "kimi-code"],
224 "claude" => &["claude", "claude-code"],
225 "codex" => &["codex"],
226 "grok" => &["grok"],
227 _ => &[],
228 }
229}
230
231fn usable(bin: &Path) -> bool {
234 let Some(out) = run(bin, &["--version"], Duration::from_secs(5)) else { return false };
235 let help = run(bin, &["--help"], Duration::from_secs(5)).unwrap_or_default();
236 !out.trim().is_empty() && !help.contains("no longer maintained") && !out.contains("no longer maintained")
237}
238
239pub fn resolve_binary(provider: &str, extra_homes: &[PathBuf]) -> Option<PathBuf> {
242 let key = format!("HATCHERY_PROVIDER_BIN_{}", provider.to_ascii_uppercase());
243 if let Some(v) = std::env::var_os(&key).filter(|v| !v.is_empty()) {
244 let p = PathBuf::from(&v);
245 let p = if p.is_absolute() { Some(p) } else { which(&p.to_string_lossy()) };
246 return p.filter(|p| usable(p));
247 }
248 for name in aliases(provider) {
249 if let Some(p) = which(name).filter(|p| usable(p)) {
250 return Some(p);
251 }
252 }
253 let vendor_dirs: &[&str] = match provider {
254 "kimi" => &[".kimi-code/bin/kimi"],
255 "claude" => &[".claude/local/claude", ".local/bin/claude"],
256 _ => &[],
257 };
258 extra_homes
259 .iter()
260 .flat_map(|h| vendor_dirs.iter().map(move |d| h.join(d)))
261 .find(|p| p.is_file() && usable(p))
262}
263
264#[cfg(unix)]
267pub fn install_shims(shim_dir: &Path, extra_homes: &[PathBuf]) -> Vec<(String, PathBuf)> {
268 let mut done = Vec::new();
269 if std::fs::create_dir_all(shim_dir).is_err() {
270 return done;
271 }
272 for p in PROVIDERS {
273 let link = shim_dir.join(p);
274 let _ = std::fs::remove_file(&link);
275 if which(p).filter(|b| usable(b)).is_some() {
277 continue;
278 }
279 if let Some(bin) = resolve_binary(p, extra_homes) {
280 if std::os::unix::fs::symlink(&bin, &link).is_ok() {
281 done.push((p.to_string(), bin));
282 }
283 }
284 }
285 let old = std::env::var_os("PATH").unwrap_or_default();
286 let mut parts = vec![shim_dir.to_path_buf()];
287 parts.extend(std::env::split_paths(&old).filter(|d| d != shim_dir));
288 if let Ok(joined) = std::env::join_paths(parts) {
289 std::env::set_var("PATH", joined);
290 }
291 done
292}
293
294#[cfg(all(test, unix))]
295mod alias_tests {
296 use super::*;
297 use std::os::unix::fs::PermissionsExt;
298
299 fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
300 let p = dir.join(name);
301 std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
302 std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
303 p
304 }
305
306 #[test]
307 fn retired_stub_is_skipped_and_vendor_install_is_found() {
308 let d = std::env::temp_dir().join(format!("alias-{}", std::process::id()));
309 let _ = std::fs::remove_dir_all(&d);
310 std::fs::create_dir_all(d.join("home/.kimi-code/bin")).unwrap();
311 let stub = script(&d, "kimi-cli", "echo 'kimi-cli is no longer maintained.'");
312 assert!(!usable(&stub));
313 let real = script(&d.join("home/.kimi-code/bin"), "kimi", "echo 2.1.1");
314 assert!(usable(&real));
315 let got = resolve_binary("kimi", &[d.join("home")]).expect("kimi resolved");
317 assert_ne!(got, stub);
318 }
319}