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