1use std::path::{Path, PathBuf};
21
22use crate::config::Config;
23use crate::vendor::{AuthKind, VendorId};
24
25pub struct Probes<'a> {
29 pub env_set: &'a dyn Fn(&str) -> bool,
31 pub exists: &'a dyn Fn(&Path) -> bool,
33 pub keychain_has_claude: &'a dyn Fn() -> bool,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
42pub struct VendorStatus {
43 pub id: &'static str,
45 pub name: &'static str,
47 pub short_name: &'static str,
48 pub kind: AuthKind,
49 pub enabled: bool,
51 pub configured: bool,
54 pub needs_credential: bool,
59 pub env: String,
62 pub login: &'static str,
65}
66
67pub fn statuses(cfg: &Config) -> Vec<VendorStatus> {
69 let probes = Probes {
70 env_set: &|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()),
71 exists: &|path| path.exists(),
72 keychain_has_claude: &keychain_has_claude,
73 };
74 statuses_with(cfg, &probes)
75}
76
77pub fn statuses_with(cfg: &Config, probes: &Probes) -> Vec<VendorStatus> {
81 VendorId::all()
82 .iter()
83 .copied()
84 .map(|id| {
85 let needs_credential = id != VendorId::Antigravity;
87 VendorStatus {
88 id: id.slug(),
89 name: id.display_name(),
90 short_name: id.short_name(),
91 kind: id.auth_kind(),
92 enabled: cfg.is_enabled(id),
93 configured: !needs_credential || credential_present(cfg, id, probes),
94 needs_credential,
95 env: cfg.api_key_env_for(id).to_string(),
96 login: id.login_command(),
97 }
98 })
99 .collect()
100}
101
102fn credential_present(cfg: &Config, id: VendorId, probes: &Probes) -> bool {
107 let env = cfg.api_key_env_for(id);
108 if !env.is_empty() && (probes.env_set)(env) {
109 return true;
110 }
111 if cfg.inline_api_key(id).is_some() {
112 return true;
113 }
114 match id {
115 VendorId::Anthropic => {
119 any_exists(probes, [crate::anthropic::creds::default_path()])
120 || (probes.keychain_has_claude)()
121 }
122 VendorId::Openai => any_exists(probes, [crate::openai::creds::default_path()]),
123 VendorId::Copilot => {
124 any_exists(probes, [crate::copilot::credentials::default_hosts_path()])
125 }
126 VendorId::CommandCode => match crate::commandcode::creds::default_paths() {
127 Ok(paths) => paths.iter().any(|path| (probes.exists)(path)),
128 Err(_) => false,
129 },
130 VendorId::NousResearch => {
131 (probes.exists)(&crate::nous::credentials::default_credentials_path())
132 }
133 VendorId::Kimi => any_exists(probes, [kimi_credentials_path(cfg)]),
135 VendorId::Cursor => any_exists(
138 probes,
139 [
140 cfg.cursor
141 .db_path
142 .clone()
143 .map_or_else(crate::cursor::db::default_db_path, Ok),
144 cfg.cursor
145 .agent_auth_path
146 .clone()
147 .map_or_else(crate::cursor::db::default_agent_auth_path, Ok),
148 ],
149 ),
150 VendorId::Kiro => any_exists(
151 probes,
152 [cfg.kiro
153 .db_path
154 .clone()
155 .map_or_else(crate::kiro::db::default_db_path, Ok)],
156 ),
157 VendorId::Supergrok => (probes.exists)(&cfg.supergrok.grok_binary),
160 VendorId::Antigravity => true,
162 VendorId::AnthropicApi
165 | VendorId::Zai
166 | VendorId::Openrouter
167 | VendorId::Deepseek
168 | VendorId::Kilo
169 | VendorId::Novita
170 | VendorId::Moonshot
171 | VendorId::Grok
172 | VendorId::Minimax
173 | VendorId::OpenCodeGo
174 | VendorId::Ollama => false,
175 }
176}
177
178fn kimi_credentials_path(cfg: &Config) -> crate::error::Result<PathBuf> {
179 match &cfg.kimi.credentials_path {
180 Some(path) => Ok(path.clone()),
181 None => Ok(crate::kimi::oauth::credentials_path_in(
182 &crate::cache::home_dir()?,
183 )),
184 }
185}
186
187fn any_exists<const N: usize>(probes: &Probes, paths: [crate::error::Result<PathBuf>; N]) -> bool {
191 paths
192 .iter()
193 .filter_map(|path| path.as_ref().ok())
194 .any(|path| (probes.exists)(path))
195}
196
197#[cfg(target_os = "macos")]
198fn keychain_has_claude() -> bool {
199 matches!(crate::anthropic::keychain::read_raw(), Ok(Some(_)))
200}
201
202#[cfg(not(target_os = "macos"))]
203fn keychain_has_claude() -> bool {
204 false
205}
206
207pub fn run(json: bool) -> i32 {
209 let cfg = match Config::load() {
210 Ok(cfg) => cfg,
211 Err(error) => {
212 eprintln!("vendors: {error}");
213 return 1;
214 }
215 };
216 let rows = statuses(&cfg);
217 if json {
218 match serde_json::to_string(&serde_json::json!({"vendors": rows})) {
219 Ok(text) => println!("{text}"),
220 Err(error) => {
221 eprintln!("vendors: {error}");
222 return 1;
223 }
224 }
225 return 0;
226 }
227 for row in rows {
228 let state = if !row.enabled {
229 "off"
230 } else if row.configured {
231 "ready"
232 } else {
233 "needs credential"
234 };
235 println!("{:<14} {:<10} {}", row.id, row.kind.as_str(), state);
236 }
237 0
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::tui::settings::KEY_VENDORS;
244
245 fn probes<'a>(env: &'a dyn Fn(&str) -> bool, exists: &'a dyn Fn(&Path) -> bool) -> Probes<'a> {
248 Probes {
249 env_set: env,
250 exists,
251 keychain_has_claude: &|| false,
252 }
253 }
254
255 fn bare<'a>() -> Probes<'a> {
256 probes(&|_| false, &|_| false)
257 }
258
259 fn row(rows: &[VendorStatus], id: &str) -> VendorStatus {
260 rows.iter()
261 .find(|row| row.id == id)
262 .unwrap_or_else(|| panic!("{id} is missing from the catalog"))
263 .clone()
264 }
265
266 #[test]
271 fn every_provider_has_exactly_one_row_in_canonical_order() {
272 let rows = statuses_with(&Config::default(), &bare());
273 let ids: Vec<&str> = rows.iter().map(|row| row.id).collect();
274 let expected: Vec<&str> = VendorId::all().iter().map(|id| id.slug()).collect();
275 assert_eq!(ids, expected);
276 }
277
278 #[test]
279 fn a_key_vendor_is_configured_by_its_environment_variable() {
280 let cfg = Config::default();
281 let set = |name: &str| name == "ZAI_API_KEY";
282 let rows = statuses_with(&cfg, &probes(&set, &|_| false));
283 assert!(row(&rows, "zai").configured);
284 assert!(!row(&rows, "deepseek").configured);
285 }
286
287 #[test]
288 fn an_api_key_env_override_is_the_variable_both_reported_and_read() {
289 let mut cfg = Config::default();
290 cfg.zai.api_key_env = "WORK_ZAI_KEY".to_string();
291 let set = |name: &str| name == "WORK_ZAI_KEY";
292 let rows = statuses_with(&cfg, &probes(&set, &|_| false));
293 let zai = row(&rows, "zai");
294 assert_eq!(
295 zai.env, "WORK_ZAI_KEY",
296 "the row names the effective variable"
297 );
298 assert!(zai.configured, "and is satisfied by it, not by the default");
299
300 let stale = |name: &str| name == "ZAI_API_KEY";
302 let rows = statuses_with(&cfg, &probes(&stale, &|_| false));
303 assert!(!row(&rows, "zai").configured);
304 }
305
306 #[test]
307 fn an_inline_key_configures_without_the_environment() {
308 let mut cfg = Config::default();
309 cfg.zai.api_key = Some("sk-inline".to_string());
310 let rows = statuses_with(&cfg, &bare());
311 assert!(row(&rows, "zai").configured);
312 }
313
314 #[test]
315 fn an_empty_inline_key_is_not_a_credential() {
316 let mut cfg = Config::default();
317 cfg.zai.api_key = Some(String::new());
318 let rows = statuses_with(&cfg, &bare());
319 assert!(!row(&rows, "zai").configured);
320 }
321
322 #[test]
326 fn antigravity_has_nothing_to_configure() {
327 let rows = statuses_with(&Config::default(), &bare());
328 let agy = row(&rows, "antigravity");
329 assert!(!agy.needs_credential);
330 assert!(agy.configured);
331 assert_eq!(agy.env, "");
332 assert_eq!(agy.login, "");
333 }
334
335 #[test]
339 fn a_keychain_only_claude_login_counts_as_configured() {
340 let cfg = Config::default();
341 let with_keychain = Probes {
342 env_set: &|_| false,
343 exists: &|_| false,
344 keychain_has_claude: &|| true,
345 };
346 assert!(row(&statuses_with(&cfg, &with_keychain), "anthropic").configured);
347 assert!(!row(&statuses_with(&cfg, &bare()), "anthropic").configured);
348 }
349
350 #[test]
351 fn an_oauth_provider_with_no_artifact_names_the_command_that_fixes_it() {
352 let rows = statuses_with(&Config::default(), &bare());
353 let codex = row(&rows, "openai");
354 assert_eq!(codex.kind, AuthKind::Oauth);
355 assert!(!codex.configured);
356 assert_eq!(codex.login, "codex login");
357 }
358
359 #[test]
362 fn enabled_follows_config_not_the_credential() {
363 let mut cfg = Config::default();
364 cfg.zai.enabled = false;
365 let set = |name: &str| name == "ZAI_API_KEY";
366 let zai = row(&statuses_with(&cfg, &probes(&set, &|_| false)), "zai");
367 assert!(!zai.enabled, "switched off in config");
368 assert!(zai.configured, "but its key is still there");
369 }
370
371 #[test]
374 fn every_key_provider_names_a_variable_and_every_oauth_one_a_login() {
375 let cfg = Config::default();
376 for row in statuses_with(&cfg, &bare()) {
377 match row.kind {
378 AuthKind::ApiKey => assert!(
379 !row.env.is_empty(),
380 "{} authenticates by key but names no variable",
381 row.id
382 ),
383 AuthKind::Oauth => assert!(
384 !row.login.is_empty(),
385 "{} authenticates by login but names no command",
386 row.id
387 ),
388 AuthKind::Local => {}
389 }
390 }
391 }
392
393 #[test]
398 fn the_settings_key_form_covers_only_catalog_key_providers() {
399 for kv in KEY_VENDORS {
400 assert_eq!(
401 kv.id.auth_kind(),
402 AuthKind::ApiKey,
403 "{} has a key field in Settings but is not a key provider",
404 kv.id.slug()
405 );
406 assert!(
407 !kv.id.api_key_env().is_empty(),
408 "{} has a key field in Settings but names no variable",
409 kv.id.slug()
410 );
411 }
412 }
413
414 #[test]
415 fn the_json_document_is_keyed_by_vendors_and_uses_wire_names() {
416 let rows = statuses_with(&Config::default(), &bare());
417 let text = serde_json::to_string(&serde_json::json!({"vendors": rows})).unwrap();
418 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
419 let vendors = parsed["vendors"].as_array().unwrap();
420 assert_eq!(vendors.len(), VendorId::all().len());
421 assert_eq!(vendors[0]["id"], "anthropic");
422 assert_eq!(vendors[0]["kind"], "oauth");
423 assert_eq!(vendors[0]["enabled"], true);
427 assert_eq!(vendors[0]["configured"], false);
428 assert_eq!(vendors[0]["short_name"], "cld");
429 let agy = vendors
430 .iter()
431 .find(|v| v["id"] == "antigravity")
432 .expect("antigravity is in the report");
433 assert_eq!(agy["kind"], "local");
434 assert_eq!(agy["needs_credential"], false);
435 }
436}