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    /// Whether the macOS login Keychain holds Claude Code's OAuth blob for a
39    /// specific account's `CLAUDE_CONFIG_DIR`. Always `false` off macOS;
40    /// consulted only once the account's credentials file is ruled out.
41    pub keychain_has_claude_for: &'a dyn Fn(&Path) -> bool,
42    /// Whether one of Command Code's auth files holds a live credential. Its
43    /// search list includes pi's shared keystore, which exists whenever the
44    /// user signed pi into any provider, so existence alone cannot mean
45    /// "signed in" the way the provider-owned files above can.
46    pub commandcode_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
47    /// Whether an OpenCode Go store holds a usable key or access token.
48    /// Existence alone cannot mean "signed in": legacy JSON holds multiple
49    /// providers, and the v2 database can contain inactive or expired logins.
50    /// The shared reader selects the `opencode` entry or active database row.
51    pub opencode_go_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
52    /// Whether one of Cline's auth files holds a live credential.
53    pub cline_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
54}
55
56/// One provider's row in the catalog.
57#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
58pub struct VendorStatus {
59    /// Machine id, the same string `usage --json` keys its entries by.
60    pub id: &'static str,
61    /// Canonical product name, from [`VendorId::display_name`].
62    pub name: &'static str,
63    pub short_name: &'static str,
64    pub kind: AuthKind,
65    /// Whether config has this provider switched on.
66    pub enabled: bool,
67    /// Whether this provider has everything it needs to be fetched. Always
68    /// `true` when `needs_credential` is `false`.
69    pub configured: bool,
70    /// Whether the provider has a credential to be missing at all. Antigravity
71    /// has none: there is no file, no key and no login — the binary probes
72    /// whichever local product is running — so "not configured" is not a state
73    /// it can be in, and a frontend must not offer to fix one.
74    pub needs_credential: bool,
75    /// Effective environment variable holding this provider's key, honoring an
76    /// `api_key_env` override; empty when the provider takes no key.
77    pub env: String,
78    /// Command that signs this provider in; empty when signing in happens in a
79    /// desktop app's own window.
80    pub login: &'static str,
81}
82
83/// The catalog against the real environment.
84pub fn statuses(cfg: &Config) -> Vec<VendorStatus> {
85    let probes = Probes {
86        env_set: &|name| std::env::var_os(name).is_some_and(|value| !value.is_empty()),
87        exists: &|path| path.exists(),
88        keychain_has_claude: &keychain_has_claude,
89        keychain_has_claude_for: &keychain_has_claude_for,
90        commandcode_signed_in: &|paths| {
91            paths
92                .iter()
93                .any(|path| crate::commandcode::creds::read_from(path).is_some())
94        },
95        opencode_go_signed_in: &|paths| {
96            paths
97                .iter()
98                .any(|path| crate::opencode_go::creds::read_key_from(path).is_some())
99        },
100        cline_signed_in: &|paths| {
101            paths
102                .iter()
103                .any(|path| crate::cline::creds::read_from(path).is_some())
104        },
105    };
106    statuses_with(cfg, &probes)
107}
108
109/// One row per [`VendorId::all`], in that canonical order — so a provider
110/// added to the enum appears in every frontend with no frontend change, which
111/// is the whole point.
112pub fn statuses_with(cfg: &Config, probes: &Probes) -> Vec<VendorStatus> {
113    VendorId::all()
114        .iter()
115        .copied()
116        .map(|id| {
117            // Antigravity is the only provider with nothing to configure.
118            let needs_credential = id != VendorId::Antigravity;
119            VendorStatus {
120                id: id.slug(),
121                name: id.display_name(),
122                short_name: id.short_name(),
123                kind: id.auth_kind(),
124                enabled: cfg.is_enabled(id),
125                configured: !needs_credential || credential_present(cfg, id, probes),
126                needs_credential,
127                env: cfg.api_key_env_for(id).to_string(),
128                login: id.login_command(),
129            }
130        })
131        .collect()
132}
133
134/// Whether this provider's credential is present. Every provider that
135/// documents an environment variable is satisfied by it — the OAuth ones
136/// included, where it is the headless override — and then by an inline
137/// `api_key`, and only then by its own login artifact.
138fn credential_present(cfg: &Config, id: VendorId, probes: &Probes) -> bool {
139    let env = cfg.api_key_env_for(id);
140    if !env.is_empty() && (probes.env_set)(env) {
141        return true;
142    }
143    if cfg.inline_api_key(id).is_some() {
144        return true;
145    }
146    if cfg.api_key_accounts(id).is_some_and(|accounts| {
147        accounts.iter().any(|account| {
148            account
149                .api_key_env
150                .as_deref()
151                .filter(|name| !name.is_empty())
152                .is_some_and(|name| (probes.env_set)(name))
153                || account.api_key.as_deref().is_some_and(|k| !k.is_empty())
154        })
155    }) {
156        return true;
157    }
158    match id {
159        // A Keychain-only login is what Claude Code leaves on macOS when no
160        // `.credentials.json` was written, so the file alone would report a
161        // signed-in user as unconfigured.
162        VendorId::Anthropic => {
163            let default_or_explicit = any_exists(probes, [anthropic_credentials_path(cfg)]);
164            let keychain =
165                cfg.anthropic.credentials_path.is_none() && (probes.keychain_has_claude)();
166            default_or_explicit
167                || keychain
168                || cfg.anthropic.all_accounts().iter().any(|account| {
169                    (probes.exists)(&account.credentials_path)
170                        || (probes.keychain_has_claude_for)(&account.config_dir())
171                })
172        }
173        VendorId::Openai => {
174            any_exists(probes, [cfg.openai.resolve_auth_path(None)])
175                || cfg
176                    .openai
177                    .accounts
178                    .iter()
179                    .any(|account| (probes.exists)(&account.codex_auth_path))
180        }
181        VendorId::Copilot => {
182            any_exists(probes, [crate::copilot::credentials::default_hosts_path()])
183        }
184        VendorId::CommandCode => {
185            match crate::commandcode::creds::effective_paths(cfg.commandcode.auth_paths.as_deref())
186            {
187                Ok(paths) => (probes.commandcode_signed_in)(&paths),
188                Err(_) => false,
189            }
190        }
191        VendorId::Cline => {
192            let paths = match cfg.cline.auth_paths.as_deref() {
193                Some(paths) if !paths.is_empty() => paths.to_vec(),
194                _ => crate::cline::creds::default_paths().unwrap_or_default(),
195            };
196            (probes.cline_signed_in)(&paths)
197        }
198        VendorId::OpenCodeGo => {
199            match crate::opencode_go::creds::effective_paths(cfg.opencode_go.auth_paths.as_deref())
200            {
201                Ok(paths) => (probes.opencode_go_signed_in)(&paths),
202                Err(_) => false,
203            }
204        }
205        VendorId::NousResearch => {
206            (probes.exists)(&crate::nous::credentials::default_credentials_path())
207        }
208        // Kimi takes a key or the Kimi Code CLI's own OAuth login.
209        VendorId::Kimi => any_exists(probes, [kimi_credentials_path(cfg)]),
210        // Cursor reads the IDE's state database, falling back to the headless
211        // `cursor-agent` CLI's login file — either one means signed in.
212        VendorId::Cursor => any_exists(
213            probes,
214            [
215                cfg.cursor
216                    .db_path
217                    .clone()
218                    .map_or_else(crate::cursor::db::default_db_path, Ok),
219                cfg.cursor
220                    .agent_auth_path
221                    .clone()
222                    .map_or_else(crate::cursor::db::default_agent_auth_path, Ok),
223            ],
224        ),
225        VendorId::Kiro => any_exists(
226            probes,
227            [cfg.kiro
228                .db_path
229                .clone()
230                .map_or_else(crate::kiro::db::default_db_path, Ok)],
231        ),
232        // SuperGrok rides the Grok Build CLI's own login; its executable is the
233        // only local artifact, and config pins the trusted path.
234        VendorId::Supergrok => (probes.exists)(&cfg.supergrok.grok_binary),
235        // The Grok Bot desktop app's own credential file is the login.
236        VendorId::Grokbot => any_exists(probes, [crate::grokbot::secrets_path(&cfg.grokbot)]),
237        // The `bl` CLI's own console-login file is the login.
238        VendorId::ModelStudio => {
239            any_exists(probes, [crate::modelstudio::config_path(&cfg.modelstudio)])
240        }
241        VendorId::Devin => {
242            any_exists(probes, [crate::devin::credentials_path(&cfg.devin)])
243                || cfg
244                    .devin
245                    .accounts
246                    .iter()
247                    .any(|a| (probes.exists)(&a.credentials_path))
248        }
249        VendorId::Droid => any_exists(probes, [crate::droid::keyring_path()]),
250        VendorId::MuseCode => any_exists(probes, [crate::muse::creds::path(&cfg.muse)]),
251        // Nothing to check: handled by `needs_credential`, never reached.
252        VendorId::Antigravity => true,
253        // Key-only providers: the environment and inline checks above are the
254        // whole answer.
255        VendorId::AnthropicApi
256        | VendorId::Zai
257        | VendorId::Openrouter
258        | VendorId::Deepseek
259        | VendorId::Deepinfra
260        | VendorId::Kilo
261        | VendorId::Novita
262        | VendorId::Synthetic
263        | VendorId::Moonshot
264        | VendorId::Grok
265        | VendorId::Minimax
266        | VendorId::Ollama
267        | VendorId::OrcaRouter
268        | VendorId::Lyceum => false,
269    }
270}
271
272fn anthropic_credentials_path(cfg: &Config) -> crate::error::Result<PathBuf> {
273    match &cfg.anthropic.credentials_path {
274        Some(path) => Ok(path.clone()),
275        None => crate::anthropic::creds::default_path(),
276    }
277}
278
279fn kimi_credentials_path(cfg: &Config) -> crate::error::Result<PathBuf> {
280    match &cfg.kimi.credentials_path {
281        Some(path) => Ok(path.clone()),
282        None => Ok(crate::kimi::oauth::credentials_path_in(
283            &crate::cache::home_dir()?,
284        )),
285    }
286}
287
288/// True when any resolvable path exists. A path that cannot be resolved at all
289/// (no `$HOME`) counts as absent rather than as an error: the row still has to
290/// render, and "not configured" is the honest thing to draw.
291fn any_exists<const N: usize>(probes: &Probes, paths: [crate::error::Result<PathBuf>; N]) -> bool {
292    paths
293        .iter()
294        .filter_map(|path| path.as_ref().ok())
295        .any(|path| (probes.exists)(path))
296}
297
298#[cfg(target_os = "macos")]
299fn keychain_has_claude() -> bool {
300    matches!(crate::anthropic::keychain::read_raw(), Ok(Some(_)))
301}
302
303#[cfg(not(target_os = "macos"))]
304fn keychain_has_claude() -> bool {
305    false
306}
307
308#[cfg(target_os = "macos")]
309fn keychain_has_claude_for(config_dir: &Path) -> bool {
310    matches!(
311        crate::anthropic::keychain::read_raw_for(config_dir),
312        Ok(Some(_))
313    )
314}
315
316#[cfg(not(target_os = "macos"))]
317fn keychain_has_claude_for(_config_dir: &Path) -> bool {
318    false
319}
320
321/// `vendors --json`: the catalog as one JSON document.
322pub fn collect_json() -> Result<String, String> {
323    let cfg = Config::load().map_err(|error| error.user_message())?;
324    serde_json::to_string(&serde_json::json!({"vendors": statuses(&cfg)}))
325        .map_err(|error| error.to_string())
326}
327
328/// `vendors --json`: the catalog as one JSON document.
329pub fn run(json: bool) -> i32 {
330    let cfg = match Config::load() {
331        Ok(cfg) => cfg,
332        Err(error) => {
333            eprintln!("vendors: {error}");
334            return 1;
335        }
336    };
337    let rows = statuses(&cfg);
338    if json {
339        match serde_json::to_string(&serde_json::json!({"vendors": rows})) {
340            Ok(text) => println!("{text}"),
341            Err(error) => {
342                eprintln!("vendors: {error}");
343                return 1;
344            }
345        }
346        return 0;
347    }
348    for row in rows {
349        let state = if !row.enabled {
350            "off"
351        } else if row.configured {
352            "ready"
353        } else {
354            "needs credential"
355        };
356        println!("{:<14} {:<10} {}", row.id, row.kind.as_str(), state);
357    }
358    0
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use crate::tui::settings::KEY_VENDORS;
365
366    /// Every probe answers "no", so a row is configured only because config
367    /// says so. Nothing here reads a real `$HOME`, variable or Keychain.
368    fn probes<'a>(
369        env: &'a dyn Fn(&str) -> bool,
370        exists: &'a dyn Fn(&Path) -> bool,
371        commandcode_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
372    ) -> Probes<'a> {
373        probes_full(env, exists, commandcode_signed_in, &|_| false)
374    }
375
376    /// [`probes`] with the OpenCode Go file probe supplied, for the tests
377    /// that exercise the CLI credential path.
378    fn probes_full<'a>(
379        env: &'a dyn Fn(&str) -> bool,
380        exists: &'a dyn Fn(&Path) -> bool,
381        commandcode_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
382        opencode_go_signed_in: &'a dyn Fn(&[PathBuf]) -> bool,
383    ) -> Probes<'a> {
384        Probes {
385            env_set: env,
386            exists,
387            keychain_has_claude: &|| false,
388            keychain_has_claude_for: &|_| false,
389            commandcode_signed_in,
390            opencode_go_signed_in,
391            cline_signed_in: &|_| false,
392        }
393    }
394
395    fn bare<'a>() -> Probes<'a> {
396        probes(&|_| false, &|_| false, &|_| false)
397    }
398
399    fn row(rows: &[VendorStatus], id: &str) -> VendorStatus {
400        rows.iter()
401            .find(|row| row.id == id)
402            .unwrap_or_else(|| panic!("{id} is missing from the catalog"))
403            .clone()
404    }
405
406    /// The guard this module exists for. A provider added to `VendorId` shows
407    /// up here for free; the two frontends that kept their own tables had
408    /// silently dropped five of them (Antigravity, Cursor, Kiro, Nous
409    /// Research, SuperGrok), in a list whose whole job is to be complete.
410    #[test]
411    fn every_provider_has_exactly_one_row_in_canonical_order() {
412        let rows = statuses_with(&Config::default(), &bare());
413        let ids: Vec<&str> = rows.iter().map(|row| row.id).collect();
414        let expected: Vec<&str> = VendorId::all().iter().map(|id| id.slug()).collect();
415        assert_eq!(ids, expected);
416    }
417
418    #[test]
419    fn a_key_vendor_is_configured_by_its_environment_variable() {
420        let cfg = Config::default();
421        let set = |name: &str| name == "ZAI_API_KEY";
422        let rows = statuses_with(&cfg, &probes(&set, &|_| false, &|_| false));
423        assert!(row(&rows, "zai").configured);
424        assert!(!row(&rows, "deepseek").configured);
425    }
426
427    #[test]
428    fn an_api_key_env_override_is_the_variable_both_reported_and_read() {
429        let mut cfg = Config::default();
430        cfg.zai.api_key_env = "WORK_ZAI_KEY".to_string();
431        let set = |name: &str| name == "WORK_ZAI_KEY";
432        let rows = statuses_with(&cfg, &probes(&set, &|_| false, &|_| false));
433        let zai = row(&rows, "zai");
434        assert_eq!(
435            zai.env, "WORK_ZAI_KEY",
436            "the row names the effective variable"
437        );
438        assert!(zai.configured, "and is satisfied by it, not by the default");
439
440        // The default name must no longer count once overridden.
441        let stale = |name: &str| name == "ZAI_API_KEY";
442        let rows = statuses_with(&cfg, &probes(&stale, &|_| false, &|_| false));
443        assert!(!row(&rows, "zai").configured);
444    }
445
446    /// Cline's section carries the same `api_key_env` field, but the shared
447    /// resolver answered its fixed default, so a key held under another name
448    /// configured the fetch while this row kept asking for `CLINE_API_KEY`.
449    #[test]
450    fn a_cline_api_key_env_override_is_the_variable_both_reported_and_read() {
451        let mut cfg = Config::default();
452        cfg.cline.api_key_env = "WORK_CLINE_KEY".to_string();
453        let set = |name: &str| name == "WORK_CLINE_KEY";
454        let rows = statuses_with(&cfg, &probes(&set, &|_| false, &|_| false));
455        let cline = row(&rows, "cline");
456        assert_eq!(
457            cline.env, "WORK_CLINE_KEY",
458            "the row names the effective variable"
459        );
460        assert!(cline.configured, "and is satisfied by it, not the CLI");
461
462        let stale = |name: &str| name == "CLINE_API_KEY";
463        let rows = statuses_with(&cfg, &probes(&stale, &|_| false, &|_| false));
464        assert!(!row(&rows, "cline").configured);
465    }
466
467    #[test]
468    fn an_inline_key_configures_without_the_environment() {
469        let mut cfg = Config::default();
470        cfg.zai.api_key = Some("sk-inline".to_string());
471        let rows = statuses_with(&cfg, &bare());
472        assert!(row(&rows, "zai").configured);
473    }
474
475    #[test]
476    fn an_empty_inline_key_is_not_a_credential() {
477        let mut cfg = Config::default();
478        cfg.zai.api_key = Some(String::new());
479        let rows = statuses_with(&cfg, &bare());
480        assert!(!row(&rows, "zai").configured);
481    }
482
483    #[test]
484    fn a_named_api_key_account_counts_as_configured() {
485        let mut cfg = Config::default();
486        cfg.openrouter.accounts.push(crate::config::ApiKeyAccount {
487            label: "work".into(),
488            api_key_env: None,
489            api_key: Some("sk-named".into()),
490            management_api_key_env: None,
491        });
492        let rows = statuses_with(&cfg, &bare());
493        assert!(row(&rows, "openrouter").configured);
494        assert!(!row(&rows, "deepseek").configured);
495    }
496
497    #[test]
498    fn a_named_api_key_account_with_env_counts_as_configured() {
499        let mut cfg = Config::default();
500        cfg.deepseek.accounts.push(crate::config::ApiKeyAccount {
501            label: "work".into(),
502            api_key_env: Some("DEEPSEEK_WORK_API_KEY".into()),
503            api_key: None,
504            management_api_key_env: None,
505        });
506        let set = |name: &str| name == "DEEPSEEK_WORK_API_KEY";
507        let rows = statuses_with(&cfg, &probes(&set, &|_| false, &|_| false));
508        assert!(row(&rows, "deepseek").configured);
509        assert!(!row(&rows, "zai").configured);
510    }
511
512    /// Antigravity has no credential of any kind — the binary probes whichever
513    /// local product is running — so a frontend must not draw it as missing
514    /// one, and must not offer to fix it.
515    #[test]
516    fn antigravity_has_nothing_to_configure() {
517        let rows = statuses_with(&Config::default(), &bare());
518        let agy = row(&rows, "antigravity");
519        assert!(!agy.needs_credential);
520        assert!(agy.configured);
521        assert_eq!(agy.env, "");
522        assert_eq!(agy.login, "");
523    }
524
525    /// Claude Code on macOS may leave the OAuth blob only in the login
526    /// Keychain, so the credential file alone would report a signed-in user as
527    /// unconfigured.
528    #[test]
529    fn a_keychain_only_claude_login_counts_as_configured() {
530        let cfg = Config::default();
531        let with_keychain = Probes {
532            env_set: &|_| false,
533            exists: &|_| false,
534            keychain_has_claude: &|| true,
535            keychain_has_claude_for: &|_| false,
536            commandcode_signed_in: &|_| false,
537            opencode_go_signed_in: &|_| false,
538            cline_signed_in: &|_| false,
539        };
540        assert!(row(&statuses_with(&cfg, &with_keychain), "anthropic").configured);
541        assert!(!row(&statuses_with(&cfg, &bare()), "anthropic").configured);
542    }
543
544    #[test]
545    fn an_anthropic_credentials_path_override_counts_as_configured() {
546        let mut cfg = Config::default();
547        let custom = PathBuf::from("/custom/anthropic/.credentials.json");
548        cfg.anthropic.credentials_path = Some(custom.clone());
549        let exists = |path: &Path| path == custom;
550        let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
551        assert!(row(&rows, "anthropic").configured);
552    }
553
554    #[test]
555    fn an_anthropic_named_account_counts_as_configured() {
556        let mut cfg = Config::default();
557        let custom = PathBuf::from("/accounts/work/.credentials.json");
558        cfg.anthropic
559            .accounts
560            .push(crate::config::AnthropicAccount {
561                label: "work".into(),
562                credentials_path: custom.clone(),
563            });
564        let exists = |path: &Path| path == custom;
565        let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
566        assert!(row(&rows, "anthropic").configured);
567    }
568
569    #[test]
570    fn a_keychain_only_anthropic_named_account_counts_as_configured() {
571        let mut cfg = Config::default();
572        let custom = PathBuf::from("/accounts/work/.credentials.json");
573        cfg.anthropic
574            .accounts
575            .push(crate::config::AnthropicAccount {
576                label: "work".into(),
577                credentials_path: custom,
578            });
579        let with_named_keychain = Probes {
580            env_set: &|_| false,
581            exists: &|_| false,
582            keychain_has_claude: &|| false,
583            keychain_has_claude_for: &|dir| dir == Path::new("/accounts/work"),
584            commandcode_signed_in: &|_| false,
585            opencode_go_signed_in: &|_| false,
586            cline_signed_in: &|_| false,
587        };
588        assert!(row(&statuses_with(&cfg, &with_named_keychain), "anthropic").configured);
589        assert!(!row(&statuses_with(&cfg, &bare()), "anthropic").configured);
590    }
591
592    #[test]
593    fn an_oauth_provider_with_no_artifact_names_the_command_that_fixes_it() {
594        let rows = statuses_with(&Config::default(), &bare());
595        let codex = row(&rows, "openai");
596        assert_eq!(codex.kind, AuthKind::Oauth);
597        assert!(!codex.configured);
598        assert_eq!(codex.login, "codex login");
599    }
600
601    #[test]
602    fn opencode_go_cli_auth_file_counts_as_configured() {
603        let mut cfg = Config::default();
604        cfg.opencode_go
605            .auth_paths
606            .replace(vec![PathBuf::from("/custom/opencode/auth.json")]);
607        let signed_in = |paths: &[PathBuf]| {
608            paths
609                .iter()
610                .any(|path| path.ends_with("opencode/auth.json"))
611        };
612        let rows = statuses_with(
613            &cfg,
614            &probes_full(&|_| false, &|_| false, &|_| false, &signed_in),
615        );
616        assert!(row(&rows, "opencode-go").configured);
617        assert!(!row(&statuses_with(&cfg, &bare()), "opencode-go").configured);
618    }
619
620    #[test]
621    fn devin_catalog_row_uses_only_its_configured_local_credential_file() {
622        let mut cfg = Config::default();
623        let path = PathBuf::from("/fixture/devin/credentials.toml");
624        cfg.devin.credentials_path = Some(path.clone());
625        let exists = |candidate: &Path| candidate == path;
626        let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
627        let devin = row(&rows, "devin");
628        assert_eq!(devin.kind, AuthKind::Local);
629        assert!(devin.needs_credential);
630        assert!(devin.configured);
631        assert_eq!(devin.env, "");
632        assert_eq!(devin.login, "");
633    }
634
635    #[test]
636    fn an_openai_codex_auth_path_override_counts_as_configured() {
637        let mut cfg = Config::default();
638        let custom = PathBuf::from("/custom/openai/auth.json");
639        cfg.openai.codex_auth_path = Some(custom.clone());
640        let exists = |path: &Path| path == custom;
641        let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
642        assert!(row(&rows, "openai").configured);
643    }
644
645    #[test]
646    fn an_openai_named_account_counts_as_configured() {
647        let mut cfg = Config::default();
648        let custom = PathBuf::from("/accounts/work/auth.json");
649        cfg.openai.accounts.push(crate::config::OpenAiAccount {
650            label: "work".into(),
651            codex_auth_path: custom.clone(),
652        });
653        let exists = |path: &Path| path == custom;
654        let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
655        assert!(row(&rows, "openai").configured);
656    }
657
658    #[test]
659    fn a_devin_named_account_counts_as_configured() {
660        let mut cfg = Config::default();
661        let custom = PathBuf::from("/accounts/work/devin-credentials.toml");
662        cfg.devin.accounts.push(crate::config::DevinAccount {
663            label: "work".into(),
664            credentials_path: custom.clone(),
665        });
666        let exists = |path: &Path| path == custom;
667        let rows = statuses_with(&cfg, &probes(&|_| false, &exists, &|_| false));
668        assert!(row(&rows, "devin").configured);
669    }
670
671    /// A provider is only ever fetched when config has it on, and `enabled` is
672    /// the one fact `usage --json` cannot report for the rows it omits.
673    #[test]
674    fn enabled_follows_config_not_the_credential() {
675        let mut cfg = Config::default();
676        cfg.zai.enabled = false;
677        let set = |name: &str| name == "ZAI_API_KEY";
678        let zai = row(
679            &statuses_with(&cfg, &probes(&set, &|_| false, &|_| false)),
680            "zai",
681        );
682        assert!(!zai.enabled, "switched off in config");
683        assert!(zai.configured, "but its key is still there");
684    }
685
686    /// Command Code's search list includes pi's shared keystore, which exists
687    /// whenever the user signed pi into any provider. Existence of that file is
688    /// not a Command Code login — only a live credential inside it is, the same
689    /// answer the fetch's own resolver gives.
690    #[test]
691    fn a_shared_harness_keystore_is_not_a_commandcode_login() {
692        let mut cfg = Config::default();
693        cfg.commandcode.auth_paths = Some(vec![PathBuf::from("/home/x/.pi/agent/auth.json")]);
694
695        let keystore_only = statuses_with(&cfg, &probes(&|_| false, &|_| true, &|_| false));
696        assert!(
697            !row(&keystore_only, "commandcode").configured,
698            "a pi login for another provider is not a Command Code login"
699        );
700
701        let signed_in = statuses_with(&cfg, &probes(&|_| false, &|_| true, &|_| true));
702        assert!(row(&signed_in, "commandcode").configured);
703    }
704
705    /// A custom `auth_paths` install is configured by its own files, not by the
706    /// platform defaults.
707    #[test]
708    fn commandcode_configuration_follows_the_configured_auth_paths() {
709        let mut cfg = Config::default();
710        let custom = PathBuf::from("/opt/commandcode/auth.json");
711        cfg.commandcode.auth_paths = Some(vec![custom.clone()]);
712
713        let rows = statuses_with(
714            &cfg,
715            &probes(&|_| false, &|_| false, &|paths| paths == [custom.clone()]),
716        );
717        assert!(row(&rows, "commandcode").configured);
718    }
719
720    /// Cline's credential file path is checked via the injected probe so
721    /// catalog status never hits the disk or $HOME during tests.
722    #[test]
723    fn cline_cli_auth_file_counts_as_configured() {
724        let mut cfg = Config::default();
725        let custom = PathBuf::from("/opt/cline/auth.json");
726        cfg.cline.auth_paths = Some(vec![custom.clone()]);
727
728        let signed_in = Probes {
729            env_set: &|_| false,
730            exists: &|_| false,
731            keychain_has_claude: &|| false,
732            keychain_has_claude_for: &|_| false,
733            commandcode_signed_in: &|_| false,
734            opencode_go_signed_in: &|_| false,
735            cline_signed_in: &|paths| paths == [custom.clone()],
736        };
737        assert!(row(&statuses_with(&cfg, &signed_in), "cline").configured);
738        assert!(!row(&statuses_with(&cfg, &bare()), "cline").configured);
739    }
740
741    /// Auth metadata has to be usable, not merely present: a key provider that
742    /// names no variable leaves a frontend with nothing to tell the user.
743    #[test]
744    fn every_key_provider_names_a_variable_and_every_oauth_one_a_login() {
745        let cfg = Config::default();
746        for row in statuses_with(&cfg, &bare()) {
747            match row.kind {
748                AuthKind::ApiKey => assert!(
749                    !row.env.is_empty(),
750                    "{} authenticates by key but names no variable",
751                    row.id
752                ),
753                AuthKind::Oauth => assert!(
754                    !row.login.is_empty(),
755                    "{} authenticates by login but names no command",
756                    row.id
757                ),
758                AuthKind::Local => {}
759            }
760        }
761    }
762
763    /// The settings form's credential fields are a *view* over the catalog, so
764    /// each one must be a provider the catalog agrees takes a key. This is what
765    /// keeps the two from drifting now that the variable name lives in one
766    /// place.
767    #[test]
768    fn the_settings_key_form_covers_only_catalog_key_providers() {
769        for kv in KEY_VENDORS {
770            assert_eq!(
771                kv.id.auth_kind(),
772                AuthKind::ApiKey,
773                "{} has a key field in Settings but is not a key provider",
774                kv.id.slug()
775            );
776            assert!(
777                !kv.id.api_key_env().is_empty(),
778                "{} has a key field in Settings but names no variable",
779                kv.id.slug()
780            );
781        }
782    }
783
784    #[test]
785    fn the_json_document_is_keyed_by_vendors_and_uses_wire_names() {
786        let rows = statuses_with(&Config::default(), &bare());
787        let text = serde_json::to_string(&serde_json::json!({"vendors": rows})).unwrap();
788        let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
789        let vendors = parsed["vendors"].as_array().unwrap();
790        assert_eq!(vendors.len(), VendorId::all().len());
791        assert_eq!(vendors[0]["id"], "anthropic");
792        assert_eq!(vendors[0]["kind"], "oauth");
793        // The macOS menu bar decides a vendor's default state solely by
794        // `enabled`, so the wire name is frontend contract: a serde rename
795        // here would silently empty its selector, failing no Swift test.
796        assert_eq!(vendors[0]["enabled"], true);
797        assert_eq!(vendors[0]["configured"], false);
798        assert_eq!(vendors[0]["short_name"], "cld");
799        let agy = vendors
800            .iter()
801            .find(|v| v["id"] == "antigravity")
802            .expect("antigravity is in the report");
803        assert_eq!(agy["kind"], "local");
804        assert_eq!(agy["needs_credential"], false);
805    }
806}