Skip to main content

ai_usagebar/widget/
cli.rs

1//! Command-line interface — claudebar-compatible flags plus the new
2//! local-testing additions (`--pretty`, `--watch`, `--json`).
3//!
4//! Mirrors claudebar:54-93. The defaults are identical so existing waybar
5//! configs that invoke `claudebar ...` can be retargeted to
6//! `ai-usagebar --vendor anthropic ...` without changing any flags.
7
8use clap::{Parser, ValueEnum};
9
10#[derive(Parser, Debug, Clone)]
11#[command(
12    name = "ai-usagebar",
13    version,
14    args_conflicts_with_subcommands = true,
15    about = "Waybar widget and terminal dashboard for multi-provider AI plan usage",
16    long_about = "\
17Drop-in replacement for `claudebar` with multi-vendor support.
18
19Output modes:
20  - Default: Waybar JSON ({text, tooltip, class}). Used when stdout is piped.
21  - --pretty: human-readable terminal output for local testing. Auto-enabled
22    when stdout is a TTY, so just running `ai-usagebar --vendor anthropic`
23    in a terminal Does The Right Thing.
24  - --watch N: like --pretty but refreshes every N seconds, clearing the screen
25    between ticks. Useful while iterating on `--format` or `--tooltip-format`.
26  - --json: force JSON output even when stdout is a TTY (for scripting).
27  - --config PATH: read and write an alternate config file instead of the
28    default `%APPDATA%/ai-usagebar/config.toml` (Windows) or
29    `~/.config/ai-usagebar/config.toml`. Accepted in any position, before or
30    after the subcommand; the file must already exist, and Settings saves
31    write back to it."
32)]
33pub struct Cli {
34    /// Which vendor to query. When omitted, reads `[ui] primary` from
35    /// `~/.config/ai-usagebar/config.toml`; falls back to `anthropic` if
36    /// neither is set.
37    #[arg(long, value_enum)]
38    pub vendor: Option<Vendor>,
39
40    /// Optional icon prepended to the bar text (Nerd Font glyph / emoji /
41    /// Pango span). claudebar `--icon`.
42    #[arg(long)]
43    pub icon: Option<String>,
44
45    /// Bar-text format string with `{placeholder}` substitutions. Defaults to
46    /// a vendor-specific format (e.g. `{session_pct}% · {session_reset}` for
47    /// Anthropic, `{kimi_weekly_pct}%` for Kimi).
48    #[arg(long)]
49    pub format: Option<String>,
50
51    /// Custom tooltip format. Overrides the default bordered tooltip when
52    /// set; identical placeholder set as `--format`.
53    #[arg(long)]
54    pub tooltip_format: Option<String>,
55
56    /// Tolerance band (in percentage points) for ratio-based pacing icons.
57    #[arg(long, default_value_t = 5)]
58    pub pace_tolerance: u32,
59
60    /// Color pace placeholders individually per window (instead of the
61    /// global usage-based color). Claudebar `--format-pace-color`.
62    #[arg(long)]
63    pub format_pace_color: bool,
64
65    /// Use point-based pacing in the tooltip's pace column (vs ratio-based).
66    /// Also enables an elapsed-position marker on the tooltip progress bars.
67    /// Claudebar `--tooltip-pace-pts`.
68    #[arg(long)]
69    pub tooltip_pace_pts: bool,
70
71    /// Override the low-usage color (#RRGGBB).
72    #[arg(long)]
73    pub color_low: Option<String>,
74    /// Override the mid-usage color (#RRGGBB).
75    #[arg(long)]
76    pub color_mid: Option<String>,
77    /// Override the high-usage color (#RRGGBB).
78    #[arg(long)]
79    pub color_high: Option<String>,
80    /// Override the critical-usage color (#RRGGBB).
81    #[arg(long)]
82    pub color_critical: Option<String>,
83
84    /// Render human-readable terminal output (ANSI colors + box drawing)
85    /// instead of Waybar JSON. Auto-on when stdout is a TTY.
86    #[arg(long)]
87    pub pretty: bool,
88
89    /// Force JSON output even on a TTY (useful when piping into `jq` from
90    /// an interactive shell).
91    #[arg(long, conflicts_with = "pretty")]
92    pub json: bool,
93
94    /// Re-render every N seconds, clearing the screen between ticks. Implies
95    /// `--pretty`. Press Ctrl-C to exit.
96    #[arg(long, value_name = "SECS")]
97    pub watch: Option<u64>,
98
99    /// Cycle the persisted "active vendor" forward and exit. Wire to
100    /// Waybar's `on-scroll-up` to scroll-cycle through enabled vendors.
101    /// Sends SIGRTMIN+13 to waybar afterwards so the bar refreshes
102    /// immediately rather than waiting for the next interval tick.
103    #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
104    pub cycle_next: bool,
105
106    /// Cycle backwards. Wire to `on-scroll-down`.
107    #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
108    pub cycle_prev: bool,
109
110    /// Override the cache directory (default: ~/.cache/ai-usagebar/<vendor>).
111    /// Give each instance its own directory to track multiple accounts of
112    /// the same vendor side by side — see "Multiple accounts" in the README.
113    #[arg(long, value_name = "DIR")]
114    pub cache_dir: Option<std::path::PathBuf>,
115
116    /// Override the Anthropic credentials file (default:
117    /// ~/.claude/.credentials.json, or `[anthropic] credentials_path` from
118    /// config). Only the Anthropic vendor reads this flag. Combine with
119    /// --cache-dir to track multiple Claude accounts — see "Multiple
120    /// accounts" in the README.
121    #[arg(long, value_name = "FILE")]
122    pub creds_path: Option<std::path::PathBuf>,
123
124    /// Select a named Claude, OpenRouter, or Codex (OpenAI) account from the matching
125    /// `[[...accounts]]` config array. Without it, the vendor's default account
126    /// and original cache path are unchanged. For Claude it conflicts with the
127    /// lower-level `--creds-path` because both select a credential source.
128    #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
129    pub account: Option<String>,
130
131    /// Read `--account <LABEL>`'s usage from the Claude **Desktop app's** own
132    /// token instead of a `claude` CLI credential — a saved
133    /// `~/.claude-acc/profiles/<LABEL>` account, no CLI login required (macOS).
134    /// This is how the menu bar shows Desktop accounts in its overview.
135    #[arg(long, requires = "account")]
136    pub desktop: bool,
137
138    /// Administrative command. Omit it to run the normal usage widget.
139    #[command(subcommand)]
140    pub command: Option<Command>,
141}
142
143#[derive(clap::Subcommand, Debug, Clone)]
144pub enum Command {
145    /// Manage named Claude (Anthropic) accounts.
146    Account {
147        #[command(subcommand)]
148        action: AccountAction,
149    },
150
151    /// Quota and time-to-reset for every configured vendor and account.
152    Usage {
153        /// Machine-readable output.
154        #[arg(long)]
155        json: bool,
156    },
157
158    /// Every provider ai-usagebar knows: how each authenticates, whether it is
159    /// switched on, and whether this machine has the credential it needs.
160    /// Unlike `usage`, this lists the switched-off and the never-configured —
161    /// it contacts nothing and is the catalog a frontend lists providers from.
162    Vendors {
163        /// Machine-readable output.
164        #[arg(long)]
165        json: bool,
166    },
167
168    /// Read or update settings for native desktop frontends.
169    Settings {
170        #[command(subcommand)]
171        action: SettingsAction,
172    },
173
174    /// Authenticate a provider without starting the widget.
175    Auth {
176        #[command(subcommand)]
177        provider: AuthProvider,
178    },
179}
180
181#[derive(clap::Subcommand, Debug, Clone)]
182pub enum AuthProvider {
183    Nous {
184        #[command(subcommand)]
185        action: NousAuthAction,
186    },
187}
188
189#[derive(clap::Subcommand, Debug, Clone)]
190pub enum NousAuthAction {
191    /// Start the Nous Research OAuth device flow.
192    Login,
193    /// Remove only the Nous Research credential.
194    Logout,
195}
196
197#[derive(clap::Subcommand, Debug, Clone)]
198pub enum SettingsAction {
199    /// Print a non-secret JSON settings description.
200    Show,
201
202    /// Apply one JSON settings patch read from standard input.
203    Apply,
204}
205
206#[derive(clap::Subcommand, Debug, Clone)]
207pub enum AccountAction {
208    /// Register an isolated account and open Claude Code to sign it in.
209    Add {
210        /// Stable name used by `--account`, the TUI, and desktop apps.
211        label: String,
212
213        /// Only register the account; do not launch interactive login.
214        #[arg(long, conflicts_with = "desktop")]
215        no_login: bool,
216
217        /// Capture a Claude **Desktop app** account under this label instead
218        /// of a `claude` CLI one (macOS). The app has a single login slot, so
219        /// this signs it out, waits for you to sign in as the new account, and
220        /// saves what it writes. Your current login is restored if you cancel.
221        #[arg(long)]
222        desktop: bool,
223
224        /// E-mail to label a `--desktop` account with. Asked for at the prompt
225        /// if omitted; purely cosmetic, and skipped when not interactive.
226        #[arg(long, requires = "desktop")]
227        email: Option<String>,
228
229        /// Skip the confirmation before signing the Desktop app out.
230        #[arg(short = 'y', long, requires = "desktop")]
231        yes: bool,
232    },
233
234    /// Show which Claude account the Desktop app and the `claude` CLI use.
235    Status {
236        /// Machine-readable output, consumed by the macOS menu bar.
237        #[arg(long)]
238        json: bool,
239    },
240
241    /// Make <LABEL> the active Claude account (macOS).
242    Switch {
243        /// Account to switch to. Desktop profiles come from claude-acc's store;
244        /// CLI accounts from `[[anthropic.accounts]]` / `accounts_dir`.
245        label: String,
246
247        /// Only switch the Claude Desktop app. Neither flag switches both.
248        #[arg(long)]
249        desktop: bool,
250
251        /// Only switch the `claude` CLI's default login.
252        #[arg(long)]
253        cli: bool,
254
255        /// Report what would change and exit without touching anything.
256        #[arg(long)]
257        dry_run: bool,
258
259        /// Skip the confirmation before quitting the Claude Desktop app.
260        #[arg(short = 'y', long)]
261        yes: bool,
262
263        /// Overwrite a `claude` CLI login that belongs to no managed account.
264        /// That login cannot be saved first, so this discards it.
265        #[arg(long)]
266        force: bool,
267
268        /// Keep `bridge-state.json` rather than clearing it. Diagnostic only:
269        /// a stale remote-control session id breaks `/remote-control`.
270        #[arg(long)]
271        keep_bridge: bool,
272
273        /// Also archive the whole session tree, as claude-acc does. Off by
274        /// default because the history merge is additive.
275        #[arg(long)]
276        backup_sessions: bool,
277
278        /// Rollback archives to retain.
279        #[arg(long, default_value_t = 10)]
280        keep_backups: usize,
281
282        /// Confirm that this type-scoped conflict key, deleted in one account
283        /// but still held by another, should be removed everywhere. Repeatable.
284        /// Supplying any suppresses the interactive prompt — keys not listed
285        /// are kept — which is how the macOS menu bar passes an answered dialog
286        /// through.
287        /// `account status --json` lists the candidates as `deletion_conflicts`.
288        #[arg(long, value_name = "KEY")]
289        delete_conflict: Vec<String>,
290    },
291}
292
293#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
294pub enum Vendor {
295    Anthropic,
296    #[value(name = "anthropic_api")]
297    AnthropicApi,
298    Openai,
299    Copilot,
300    Zai,
301    Openrouter,
302    Deepseek,
303    Kimi,
304    Kilo,
305    Novita,
306    Moonshot,
307    Grok,
308    Supergrok,
309    Antigravity,
310    Cursor,
311    Minimax,
312    Kiro,
313    #[value(name = "nous")]
314    NousResearch,
315    #[value(name = "opencode-go")]
316    OpenCodeGo,
317    #[value(name = "commandcode")]
318    CommandCode,
319}
320
321impl Vendor {
322    pub fn to_id(self) -> crate::vendor::VendorId {
323        match self {
324            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
325            Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
326            Vendor::Openai => crate::vendor::VendorId::Openai,
327            Vendor::Copilot => crate::vendor::VendorId::Copilot,
328            Vendor::Zai => crate::vendor::VendorId::Zai,
329            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
330            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
331            Vendor::Kimi => crate::vendor::VendorId::Kimi,
332            Vendor::Kilo => crate::vendor::VendorId::Kilo,
333            Vendor::Novita => crate::vendor::VendorId::Novita,
334            Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
335            Vendor::Grok => crate::vendor::VendorId::Grok,
336            Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
337            Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
338            Vendor::Cursor => crate::vendor::VendorId::Cursor,
339            Vendor::Minimax => crate::vendor::VendorId::Minimax,
340            Vendor::Kiro => crate::vendor::VendorId::Kiro,
341            Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
342            Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
343            Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
344        }
345    }
346}
347
348impl Cli {
349    /// Whether the selected vendor came from an explicit `--vendor` opt-in.
350    pub fn has_explicit_vendor(&self) -> bool {
351        self.vendor.is_some()
352    }
353
354    /// Resolve the vendor with full precedence:
355    ///   1. explicit `--vendor` (highest)
356    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
357    ///   3. `[ui] primary` from config
358    ///   4. anthropic (lowest)
359    ///
360    /// This reads the persisted scroll-cycle state from disk via
361    /// [`crate::active::read`]. The pure precedence logic lives in
362    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
363    /// `~/.cache/ai-usagebar/active_vendor`.
364    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
365        // Only consult the scroll-cycle state file when it could actually
366        // matter. An explicit `--vendor` wins outright (precedence #1), so we
367        // skip the disk read entirely in that case — preserving the original
368        // short-circuit and keeping the documented `--vendor` widget config off
369        // the `active_vendor` read path.
370        let active = if self.has_explicit_vendor() {
371            None
372        } else {
373            crate::active::read()
374        };
375        self.resolve_vendor_with(config, active)
376    }
377
378    /// Pure precedence resolution given an explicit scroll-cycle `active`
379    /// override (i.e. whatever [`crate::active::read`] returned). Split out
380    /// from the disk read so tests exercise the precedence rules hermetically
381    /// instead of depending on the developer's real `active_vendor` file.
382    pub fn resolve_vendor_with(
383        &self,
384        config: &crate::config::Config,
385        active: Option<crate::vendor::VendorId>,
386    ) -> Vendor {
387        if let Some(v) = self.vendor {
388            return v;
389        }
390        if let Some(id) = active
391            && config.is_enabled(id)
392        {
393            return id_to_vendor(id);
394        }
395        if let Some(id) = config.ui.primary
396            && config.is_enabled(id)
397        {
398            return id_to_vendor(id);
399        }
400        if config.is_enabled(crate::vendor::VendorId::Anthropic) {
401            return Vendor::Anthropic;
402        }
403        config
404            .enabled_vendors()
405            .into_iter()
406            .next()
407            .map(id_to_vendor)
408            // A completely disabled configuration has no enabled choice; keep
409            // the historic final fallback rather than rejecting widget startup.
410            .unwrap_or(Vendor::Anthropic)
411    }
412}
413
414fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
415    match id {
416        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
417        crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
418        crate::vendor::VendorId::Openai => Vendor::Openai,
419        crate::vendor::VendorId::Copilot => Vendor::Copilot,
420        crate::vendor::VendorId::Zai => Vendor::Zai,
421        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
422        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
423        crate::vendor::VendorId::Kimi => Vendor::Kimi,
424        crate::vendor::VendorId::Kilo => Vendor::Kilo,
425        crate::vendor::VendorId::Novita => Vendor::Novita,
426        crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
427        crate::vendor::VendorId::Grok => Vendor::Grok,
428        crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
429        crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
430        crate::vendor::VendorId::Cursor => Vendor::Cursor,
431        crate::vendor::VendorId::Minimax => Vendor::Minimax,
432        crate::vendor::VendorId::Kiro => Vendor::Kiro,
433        crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
434        crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
435        crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
436    }
437}
438
439impl Cli {
440    /// True when we should emit Waybar JSON. Default behavior: JSON when
441    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
442    pub fn output_json(&self) -> bool {
443        if self.json {
444            return true;
445        }
446        if self.pretty || self.watch.is_some() {
447            return false;
448        }
449        // Auto-detect: emit pretty when stdout is a TTY.
450        !is_stdout_tty()
451    }
452}
453
454fn is_stdout_tty() -> bool {
455    use std::io::IsTerminal;
456    std::io::stdout().is_terminal()
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use clap::{Parser, error::ErrorKind};
463
464    #[test]
465    fn version_flags_report_the_crate_version() {
466        let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
467
468        for flag in ["--version", "-V"] {
469            let err = Cli::try_parse_from(["ai-usagebar", flag])
470                .expect_err("a version flag exits through clap's display path");
471            assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
472            assert_eq!(err.to_string(), expected, "flag: {flag}");
473        }
474    }
475
476    #[test]
477    fn usage_subcommand_parses_machine_readable_mode() {
478        let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
479        assert!(matches!(cli.command, Some(Command::Usage { json: true })));
480    }
481
482    #[test]
483    fn new_vendor_values_and_auth_commands_parse_exactly() {
484        let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
485        assert_eq!(nous.vendor, Some(Vendor::NousResearch));
486        let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
487        assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
488        let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
489        assert_eq!(copilot.vendor, Some(Vendor::Copilot));
490        let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
491        assert!(matches!(login.command, Some(Command::Auth { .. })));
492    }
493
494    #[test]
495    fn settings_subcommands_are_additive_and_take_no_widget_flags() {
496        let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
497        assert!(matches!(
498            show.command,
499            Some(Command::Settings {
500                action: SettingsAction::Show
501            })
502        ));
503
504        let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
505        assert!(matches!(
506            apply.command,
507            Some(Command::Settings {
508                action: SettingsAction::Apply
509            })
510        ));
511
512        assert!(
513            Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
514        );
515    }
516
517    #[test]
518    fn defaults_match_claudebar() {
519        let cli = Cli::parse_from(["ai-usagebar"]);
520        assert_eq!(cli.vendor, None);
521        // Without explicit --vendor, no scroll-cycle override, and default
522        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
523        // rather than `resolved_vendor` so the test never reads the real
524        // ~/.cache/ai-usagebar/active_vendor file.
525        let cfg = crate::config::Config::default();
526        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
527        assert_eq!(cli.pace_tolerance, 5);
528        assert!(cli.format.is_none());
529        assert!(cli.tooltip_format.is_none());
530        assert!(cli.icon.is_none());
531        assert!(!cli.format_pace_color);
532        assert!(!cli.tooltip_pace_pts);
533        assert!(!cli.pretty);
534        assert!(!cli.json);
535        assert!(cli.watch.is_none());
536        assert!(cli.command.is_none());
537    }
538
539    #[test]
540    fn account_add_subcommand_parses_without_widget_flags() {
541        let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
542        assert!(matches!(
543            cli.command,
544            Some(Command::Account {
545                action: AccountAction::Add {
546                    ref label,
547                    no_login: true,
548                    desktop: false,
549                    ..
550                }
551            }) if label == "work"
552        ));
553    }
554
555    /// The two halves of `add` capture different things and cannot be combined:
556    /// `--no-login` skips a `claude` login the Desktop capture never runs.
557    #[test]
558    fn account_add_desktop_takes_an_email_and_rejects_no_login() {
559        let cli = Cli::parse_from([
560            "ai-usagebar",
561            "account",
562            "add",
563            "work",
564            "--desktop",
565            "--email",
566            "a@b.test",
567            "-y",
568        ]);
569        assert!(matches!(
570            cli.command,
571            Some(Command::Account {
572                action: AccountAction::Add {
573                    desktop: true,
574                    yes: true,
575                    email: Some(ref email),
576                    ..
577                }
578            }) if email == "a@b.test"
579        ));
580        assert!(
581            Cli::try_parse_from([
582                "ai-usagebar",
583                "account",
584                "add",
585                "w",
586                "--desktop",
587                "--no-login"
588            ])
589            .is_err()
590        );
591        // --email / -y only mean something for the Desktop capture.
592        assert!(
593            Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
594                .is_err()
595        );
596    }
597
598    #[test]
599    fn account_switch_defaults_to_both_surfaces() {
600        let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
601        assert!(matches!(
602            cli.command,
603            Some(Command::Account {
604                action: AccountAction::Switch {
605                    ref label,
606                    desktop: false,
607                    cli: false,
608                    dry_run: true,
609                    keep_backups: 10,
610                    ..
611                }
612            }) if label == "work"
613        ));
614    }
615
616    #[test]
617    fn account_subcommand_rejects_ignored_widget_flags() {
618        assert!(
619            Cli::try_parse_from([
620                "ai-usagebar",
621                "--vendor",
622                "anthropic",
623                "account",
624                "add",
625                "work",
626            ])
627            .is_err()
628        );
629    }
630
631    #[test]
632    fn multi_account_flags_are_stable_api() {
633        // --cache-dir and --creds-path are the documented multi-account
634        // mechanism (README "Multiple accounts") since they were promoted
635        // from hidden debug flags. Renaming either is a breaking change.
636        let cli = Cli::parse_from([
637            "ai-usagebar",
638            "--vendor",
639            "anthropic",
640            "--cache-dir",
641            "/tmp/acct-a",
642            "--creds-path",
643            "/tmp/acct-a/credentials.json",
644        ]);
645        assert_eq!(
646            cli.cache_dir.as_deref(),
647            Some(std::path::Path::new("/tmp/acct-a"))
648        );
649        assert_eq!(
650            cli.creds_path.as_deref(),
651            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
652        );
653    }
654
655    #[test]
656    fn primary_from_config_wins_when_vendor_unset() {
657        // No --vendor and no scroll-cycle override → [ui] primary wins.
658        let cli = Cli::parse_from(["ai-usagebar"]);
659        let mut cfg = crate::config::Config::default();
660        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
661        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
662    }
663
664    #[test]
665    fn explicit_vendor_overrides_everything() {
666        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
667        // [ui] primary.
668        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
669        let mut cfg = crate::config::Config::default();
670        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
671        let active = Some(crate::vendor::VendorId::Openai);
672        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
673    }
674
675    #[test]
676    fn vendor_kimi_parses_to_kimi_variant() {
677        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
678        assert_eq!(cli.vendor, Some(Vendor::Kimi));
679        assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
680    }
681
682    #[test]
683    fn vendor_anthropic_api_uses_the_documented_slug() {
684        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
685        assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
686        assert_eq!(
687            cli.vendor.unwrap().to_id(),
688            crate::vendor::VendorId::AnthropicApi
689        );
690    }
691
692    #[test]
693    fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
694        let cli = Cli::parse_from(["ai-usagebar"]);
695        let mut cfg = crate::config::Config::default();
696        cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
697        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
698    }
699
700    #[test]
701    fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
702        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
703        assert_eq!(
704            cli.resolve_vendor_with(&crate::config::Config::default(), None),
705            Vendor::Kimi
706        );
707    }
708
709    #[test]
710    fn active_override_wins_over_config_primary_when_enabled() {
711        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
712        // primary, as long as it is still enabled.
713        let cli = Cli::parse_from(["ai-usagebar"]);
714        let mut cfg = crate::config::Config::default();
715        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
716        let active = Some(crate::vendor::VendorId::Zai);
717        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
718    }
719
720    #[test]
721    fn disabled_active_override_falls_back_to_config_primary() {
722        // A persisted active vendor the user has since disabled is skipped;
723        // resolution falls through to [ui] primary.
724        let cli = Cli::parse_from(["ai-usagebar"]);
725        let mut cfg = crate::config::Config::default();
726        cfg.zai.enabled = false;
727        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
728        let active = Some(crate::vendor::VendorId::Zai);
729        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
730    }
731
732    #[test]
733    fn claudebar_compatible_flag_surface() {
734        let cli = Cli::parse_from([
735            "ai-usagebar",
736            "--icon",
737            "󰚩",
738            "--format",
739            "{session_pct}% · {session_reset}",
740            "--tooltip-format",
741            "S:{session_pct}",
742            "--pace-tolerance",
743            "10",
744            "--format-pace-color",
745            "--tooltip-pace-pts",
746            "--color-low",
747            "#50fa7b",
748            "--color-mid",
749            "#f1fa8c",
750            "--color-high",
751            "#ffb86c",
752            "--color-critical",
753            "#ff5555",
754        ]);
755        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
756        assert_eq!(
757            cli.format.as_deref(),
758            Some("{session_pct}% · {session_reset}")
759        );
760        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
761        assert_eq!(cli.pace_tolerance, 10);
762        assert!(cli.format_pace_color);
763        assert!(cli.tooltip_pace_pts);
764        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
765        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
766    }
767
768    #[test]
769    fn pretty_and_json_conflict() {
770        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
771        assert!(res.is_err());
772    }
773
774    #[test]
775    fn watch_disables_json_output() {
776        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
777        assert_eq!(cli.watch, Some(5));
778        assert!(!cli.output_json());
779    }
780}