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        // Nothing to check: handled by `needs_credential`, never reached.
161        VendorId::Antigravity => true,
162        // Key-only providers: the environment and inline checks above are the
163        // whole answer.
164        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
186/// True when any resolvable path exists. A path that cannot be resolved at all
187/// (no `$HOME`) counts as absent rather than as an error: the row still has to
188/// render, and "not configured" is the honest thing to draw.
189fn 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
206/// `vendors --json`: the catalog as one JSON document.
207pub 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    /// Every probe answers "no", so a row is configured only because config
245    /// says so. Nothing here reads a real `$HOME`, variable or Keychain.
246    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    /// The guard this module exists for. A provider added to `VendorId` shows
266    /// up here for free; the two frontends that kept their own tables had
267    /// silently dropped five of them (Antigravity, Cursor, Kiro, Nous
268    /// Research, SuperGrok), in a list whose whole job is to be complete.
269    #[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        // The default name must no longer count once overridden.
300        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    /// Antigravity has no credential of any kind — the binary probes whichever
322    /// local product is running — so a frontend must not draw it as missing
323    /// one, and must not offer to fix it.
324    #[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    /// Claude Code on macOS may leave the OAuth blob only in the login
335    /// Keychain, so the credential file alone would report a signed-in user as
336    /// unconfigured.
337    #[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    /// A provider is only ever fetched when config has it on, and `enabled` is
359    /// the one fact `usage --json` cannot report for the rows it omits.
360    #[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    /// Auth metadata has to be usable, not merely present: a key provider that
371    /// names no variable leaves a frontend with nothing to tell the user.
372    #[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    /// The settings form's credential fields are a *view* over the catalog, so
393    /// each one must be a provider the catalog agrees takes a key. This is what
394    /// keeps the two from drifting now that the variable name lives in one
395    /// place.
396    #[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}