Skip to main content

ai_usagebar/
catalog.rs

1//! The one answer to "which providers exist, how does each authenticate, and
2//! is this one switched on and credentialed on this machine".
3//!
4//! `usage --json` reports only the providers that are *enabled*, which makes
5//! the switched-off and the never-credentialed exactly the rows it cannot
6//! describe — and those are the rows a "is anything broken?" list exists to
7//! show. Filling that gap used to mean a frontend keeping its own provider
8//! table, and two of them did: the GNOME extension carried sixteen of the
9//! twenty-one providers plus a hand-written TOML reader mirroring
10//! `Config::default`, and the macOS menu bar re-derived Claude's, Codex's,
11//! Cursor's and Antigravity's credential locations in Swift. Both drifted the
12//! moment a provider was added in Rust — Antigravity, Cursor, Kiro, Nous
13//! Research and SuperGrok were invisible to the GNOME section for that reason.
14//!
15//! `ai-usagebar vendors --json` emits this, so a frontend can list every
16//! provider, and say what an unusable one is missing, while knowing none of
17//! them. It is the `CLAUDE.md` rule that frontend adapters stay thin, applied
18//! to the one table that had escaped it.
19
20use std::path::{Path, PathBuf};
21
22use crate::config::Config;
23use crate::vendor::{AuthKind, VendorId};
24
25/// Injected IO, so [`statuses_with`] is a pure function of config plus these
26/// answers. Tests pass closures over a fixture and never touch a real `$HOME`,
27/// environment variable, or Keychain.
28pub struct Probes<'a> {
29    /// Whether an environment variable is set to a non-empty value.
30    pub env_set: &'a dyn Fn(&str) -> bool,
31    /// Whether a path exists.
32    pub exists: &'a dyn Fn(&Path) -> bool,
33    /// Whether the macOS login Keychain holds Claude Code's OAuth blob. Always
34    /// `false` off macOS; a subprocess (`security(1)`) when it is consulted,
35    /// which is why it is injected and asked only once Claude's credential
36    /// file has already been ruled out.
37    pub keychain_has_claude: &'a dyn Fn() -> bool,
38}
39
40/// One provider's row in the catalog.
41#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
42pub struct VendorStatus {
43    /// Machine id, the same string `usage --json` keys its entries by.
44    pub id: &'static str,
45    /// Canonical product name, from [`VendorId::display_name`].
46    pub name: &'static str,
47    pub short_name: &'static str,
48    pub kind: AuthKind,
49    /// Whether config has this provider switched on.
50    pub enabled: bool,
51    /// Whether this provider has everything it needs to be fetched. Always
52    /// `true` when `needs_credential` is `false`.
53    pub configured: bool,
54    /// Whether the provider has a credential to be missing at all. Antigravity
55    /// has none: there is no file, no key and no login — the binary probes
56    /// whichever local product is running — so "not configured" is not a state
57    /// it can be in, and a frontend must not offer to fix one.
58    pub needs_credential: bool,
59    /// Effective environment variable holding this provider's key, honoring an
60    /// `api_key_env` override; empty when the provider takes no key.
61    pub env: String,
62    /// Command that signs this provider in; empty when signing in happens in a
63    /// desktop app's own window.
64    pub login: &'static str,
65}
66
67/// The catalog against the real environment.
68pub 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
77/// One row per [`VendorId::all`], in that canonical order — so a provider
78/// added to the enum appears in every frontend with no frontend change, which
79/// is the whole point.
80pub fn statuses_with(cfg: &Config, probes: &Probes) -> Vec<VendorStatus> {
81    VendorId::all()
82        .iter()
83        .copied()
84        .map(|id| {
85            // Antigravity is the only provider with nothing to configure.
86            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
102/// Whether this provider's credential is present. Every provider that
103/// documents an environment variable is satisfied by it — the OAuth ones
104/// included, where it is the headless override — and then by an inline
105/// `api_key`, and only then by its own login artifact.
106fn 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        // A Keychain-only login is what Claude Code leaves on macOS when no
116        // `.credentials.json` was written, so the file alone would report a
117        // signed-in user as unconfigured.
118        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        // Kimi takes a key or the Kimi Code CLI's own OAuth login.
134        VendorId::Kimi => any_exists(probes, [kimi_credentials_path(cfg)]),
135        // Cursor reads the IDE's state database, falling back to the headless
136        // `cursor-agent` CLI's login file — either one means signed in.
137        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        // SuperGrok rides the Grok Build CLI's own login; its executable is the
158        // only local artifact, and config pins the trusted path.
159        VendorId::Supergrok => (probes.exists)(&cfg.supergrok.grok_binary),
160        // The Grok Bot desktop app's own credential file is the login.
161        VendorId::Grokbot => any_exists(probes, [crate::grokbot::secrets_path(&cfg.grokbot)]),
162        // Nothing to check: handled by `needs_credential`, never reached.
163        VendorId::Antigravity => true,
164        // Key-only providers: the environment and inline checks above are the
165        // whole answer.
166        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
189/// True when any resolvable path exists. A path that cannot be resolved at all
190/// (no `$HOME`) counts as absent rather than as an error: the row still has to
191/// render, and "not configured" is the honest thing to draw.
192fn 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
209/// `vendors --json`: the catalog as one JSON document.
210pub 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    /// Every probe answers "no", so a row is configured only because config
248    /// says so. Nothing here reads a real `$HOME`, variable or Keychain.
249    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    /// The guard this module exists for. A provider added to `VendorId` shows
269    /// up here for free; the two frontends that kept their own tables had
270    /// silently dropped five of them (Antigravity, Cursor, Kiro, Nous
271    /// Research, SuperGrok), in a list whose whole job is to be complete.
272    #[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        // The default name must no longer count once overridden.
303        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    /// Antigravity has no credential of any kind — the binary probes whichever
325    /// local product is running — so a frontend must not draw it as missing
326    /// one, and must not offer to fix it.
327    #[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    /// Claude Code on macOS may leave the OAuth blob only in the login
338    /// Keychain, so the credential file alone would report a signed-in user as
339    /// unconfigured.
340    #[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    /// A provider is only ever fetched when config has it on, and `enabled` is
362    /// the one fact `usage --json` cannot report for the rows it omits.
363    #[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    /// Auth metadata has to be usable, not merely present: a key provider that
374    /// names no variable leaves a frontend with nothing to tell the user.
375    #[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    /// The settings form's credential fields are a *view* over the catalog, so
396    /// each one must be a provider the catalog agrees takes a key. This is what
397    /// keeps the two from drifting now that the variable name lives in one
398    /// place.
399    #[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        // The macOS menu bar decides a vendor's default state solely by
426        // `enabled`, so the wire name is frontend contract: a serde rename
427        // here would silently empty its selector, failing no Swift test.
428        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}