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::Grokbot => any_exists(probes, [crate::grokbot::secrets_path(&cfg.grokbot)]),
162 VendorId::Antigravity => true,
164 VendorId::AnthropicApi
167 | VendorId::Zai
168 | VendorId::Openrouter
169 | VendorId::Deepseek
170 | VendorId::Kilo
171 | VendorId::Novita
172 | VendorId::Moonshot
173 | VendorId::Grok
174 | VendorId::Minimax
175 | VendorId::OpenCodeGo
176 | VendorId::Ollama => false,
177 }
178}
179
180fn kimi_credentials_path(cfg: &Config) -> crate::error::Result<PathBuf> {
181 match &cfg.kimi.credentials_path {
182 Some(path) => Ok(path.clone()),
183 None => Ok(crate::kimi::oauth::credentials_path_in(
184 &crate::cache::home_dir()?,
185 )),
186 }
187}
188
189fn any_exists<const N: usize>(probes: &Probes, paths: [crate::error::Result<PathBuf>; N]) -> bool {
193 paths
194 .iter()
195 .filter_map(|path| path.as_ref().ok())
196 .any(|path| (probes.exists)(path))
197}
198
199#[cfg(target_os = "macos")]
200fn keychain_has_claude() -> bool {
201 matches!(crate::anthropic::keychain::read_raw(), Ok(Some(_)))
202}
203
204#[cfg(not(target_os = "macos"))]
205fn keychain_has_claude() -> bool {
206 false
207}
208
209pub fn run(json: bool) -> i32 {
211 let cfg = match Config::load() {
212 Ok(cfg) => cfg,
213 Err(error) => {
214 eprintln!("vendors: {error}");
215 return 1;
216 }
217 };
218 let rows = statuses(&cfg);
219 if json {
220 match serde_json::to_string(&serde_json::json!({"vendors": rows})) {
221 Ok(text) => println!("{text}"),
222 Err(error) => {
223 eprintln!("vendors: {error}");
224 return 1;
225 }
226 }
227 return 0;
228 }
229 for row in rows {
230 let state = if !row.enabled {
231 "off"
232 } else if row.configured {
233 "ready"
234 } else {
235 "needs credential"
236 };
237 println!("{:<14} {:<10} {}", row.id, row.kind.as_str(), state);
238 }
239 0
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::tui::settings::KEY_VENDORS;
246
247 fn probes<'a>(env: &'a dyn Fn(&str) -> bool, exists: &'a dyn Fn(&Path) -> bool) -> Probes<'a> {
250 Probes {
251 env_set: env,
252 exists,
253 keychain_has_claude: &|| false,
254 }
255 }
256
257 fn bare<'a>() -> Probes<'a> {
258 probes(&|_| false, &|_| false)
259 }
260
261 fn row(rows: &[VendorStatus], id: &str) -> VendorStatus {
262 rows.iter()
263 .find(|row| row.id == id)
264 .unwrap_or_else(|| panic!("{id} is missing from the catalog"))
265 .clone()
266 }
267
268 #[test]
273 fn every_provider_has_exactly_one_row_in_canonical_order() {
274 let rows = statuses_with(&Config::default(), &bare());
275 let ids: Vec<&str> = rows.iter().map(|row| row.id).collect();
276 let expected: Vec<&str> = VendorId::all().iter().map(|id| id.slug()).collect();
277 assert_eq!(ids, expected);
278 }
279
280 #[test]
281 fn a_key_vendor_is_configured_by_its_environment_variable() {
282 let cfg = Config::default();
283 let set = |name: &str| name == "ZAI_API_KEY";
284 let rows = statuses_with(&cfg, &probes(&set, &|_| false));
285 assert!(row(&rows, "zai").configured);
286 assert!(!row(&rows, "deepseek").configured);
287 }
288
289 #[test]
290 fn an_api_key_env_override_is_the_variable_both_reported_and_read() {
291 let mut cfg = Config::default();
292 cfg.zai.api_key_env = "WORK_ZAI_KEY".to_string();
293 let set = |name: &str| name == "WORK_ZAI_KEY";
294 let rows = statuses_with(&cfg, &probes(&set, &|_| false));
295 let zai = row(&rows, "zai");
296 assert_eq!(
297 zai.env, "WORK_ZAI_KEY",
298 "the row names the effective variable"
299 );
300 assert!(zai.configured, "and is satisfied by it, not by the default");
301
302 let stale = |name: &str| name == "ZAI_API_KEY";
304 let rows = statuses_with(&cfg, &probes(&stale, &|_| false));
305 assert!(!row(&rows, "zai").configured);
306 }
307
308 #[test]
309 fn an_inline_key_configures_without_the_environment() {
310 let mut cfg = Config::default();
311 cfg.zai.api_key = Some("sk-inline".to_string());
312 let rows = statuses_with(&cfg, &bare());
313 assert!(row(&rows, "zai").configured);
314 }
315
316 #[test]
317 fn an_empty_inline_key_is_not_a_credential() {
318 let mut cfg = Config::default();
319 cfg.zai.api_key = Some(String::new());
320 let rows = statuses_with(&cfg, &bare());
321 assert!(!row(&rows, "zai").configured);
322 }
323
324 #[test]
328 fn antigravity_has_nothing_to_configure() {
329 let rows = statuses_with(&Config::default(), &bare());
330 let agy = row(&rows, "antigravity");
331 assert!(!agy.needs_credential);
332 assert!(agy.configured);
333 assert_eq!(agy.env, "");
334 assert_eq!(agy.login, "");
335 }
336
337 #[test]
341 fn a_keychain_only_claude_login_counts_as_configured() {
342 let cfg = Config::default();
343 let with_keychain = Probes {
344 env_set: &|_| false,
345 exists: &|_| false,
346 keychain_has_claude: &|| true,
347 };
348 assert!(row(&statuses_with(&cfg, &with_keychain), "anthropic").configured);
349 assert!(!row(&statuses_with(&cfg, &bare()), "anthropic").configured);
350 }
351
352 #[test]
353 fn an_oauth_provider_with_no_artifact_names_the_command_that_fixes_it() {
354 let rows = statuses_with(&Config::default(), &bare());
355 let codex = row(&rows, "openai");
356 assert_eq!(codex.kind, AuthKind::Oauth);
357 assert!(!codex.configured);
358 assert_eq!(codex.login, "codex login");
359 }
360
361 #[test]
364 fn enabled_follows_config_not_the_credential() {
365 let mut cfg = Config::default();
366 cfg.zai.enabled = false;
367 let set = |name: &str| name == "ZAI_API_KEY";
368 let zai = row(&statuses_with(&cfg, &probes(&set, &|_| false)), "zai");
369 assert!(!zai.enabled, "switched off in config");
370 assert!(zai.configured, "but its key is still there");
371 }
372
373 #[test]
376 fn every_key_provider_names_a_variable_and_every_oauth_one_a_login() {
377 let cfg = Config::default();
378 for row in statuses_with(&cfg, &bare()) {
379 match row.kind {
380 AuthKind::ApiKey => assert!(
381 !row.env.is_empty(),
382 "{} authenticates by key but names no variable",
383 row.id
384 ),
385 AuthKind::Oauth => assert!(
386 !row.login.is_empty(),
387 "{} authenticates by login but names no command",
388 row.id
389 ),
390 AuthKind::Local => {}
391 }
392 }
393 }
394
395 #[test]
400 fn the_settings_key_form_covers_only_catalog_key_providers() {
401 for kv in KEY_VENDORS {
402 assert_eq!(
403 kv.id.auth_kind(),
404 AuthKind::ApiKey,
405 "{} has a key field in Settings but is not a key provider",
406 kv.id.slug()
407 );
408 assert!(
409 !kv.id.api_key_env().is_empty(),
410 "{} has a key field in Settings but names no variable",
411 kv.id.slug()
412 );
413 }
414 }
415
416 #[test]
417 fn the_json_document_is_keyed_by_vendors_and_uses_wire_names() {
418 let rows = statuses_with(&Config::default(), &bare());
419 let text = serde_json::to_string(&serde_json::json!({"vendors": rows})).unwrap();
420 let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
421 let vendors = parsed["vendors"].as_array().unwrap();
422 assert_eq!(vendors.len(), VendorId::all().len());
423 assert_eq!(vendors[0]["id"], "anthropic");
424 assert_eq!(vendors[0]["kind"], "oauth");
425 assert_eq!(vendors[0]["enabled"], true);
429 assert_eq!(vendors[0]["configured"], false);
430 assert_eq!(vendors[0]["short_name"], "cld");
431 let agy = vendors
432 .iter()
433 .find(|v| v["id"] == "antigravity")
434 .expect("antigravity is in the report");
435 assert_eq!(agy["kind"], "local");
436 assert_eq!(agy["needs_credential"], false);
437 }
438}