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
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
187/// True when any resolvable path exists. A path that cannot be resolved at all
188/// (no `$HOME`) counts as absent rather than as an error: the row still has to
189/// render, and "not configured" is the honest thing to draw.
190fn 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
207/// `vendors --json`: the catalog as one JSON document.
208pub 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    /// Every probe answers "no", so a row is configured only because config
246    /// says so. Nothing here reads a real `$HOME`, variable or Keychain.
247    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    /// The guard this module exists for. A provider added to `VendorId` shows
267    /// up here for free; the two frontends that kept their own tables had
268    /// silently dropped five of them (Antigravity, Cursor, Kiro, Nous
269    /// Research, SuperGrok), in a list whose whole job is to be complete.
270    #[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        // The default name must no longer count once overridden.
301        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    /// Antigravity has no credential of any kind — the binary probes whichever
323    /// local product is running — so a frontend must not draw it as missing
324    /// one, and must not offer to fix it.
325    #[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    /// Claude Code on macOS may leave the OAuth blob only in the login
336    /// Keychain, so the credential file alone would report a signed-in user as
337    /// unconfigured.
338    #[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    /// A provider is only ever fetched when config has it on, and `enabled` is
360    /// the one fact `usage --json` cannot report for the rows it omits.
361    #[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    /// Auth metadata has to be usable, not merely present: a key provider that
372    /// names no variable leaves a frontend with nothing to tell the user.
373    #[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    /// The settings form's credential fields are a *view* over the catalog, so
394    /// each one must be a provider the catalog agrees takes a key. This is what
395    /// keeps the two from drifting now that the variable name lives in one
396    /// place.
397    #[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        // The macOS menu bar decides a vendor's default state solely by
424        // `enabled`, so the wire name is frontend contract: a serde rename
425        // here would silently empty its selector, failing no Swift test.
426        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}