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    /// Turn on vendors whose credentials already exist on this machine
159    /// (local files, keychains, saved keys, env vars; never the network).
160    Detect {
161        /// Re-check every vendor, not only the ones never seen before.
162        #[arg(long)]
163        all: bool,
164        /// Machine-readable output.
165        #[arg(long)]
166        json: bool,
167    },
168
169    /// Every provider ai-usagebar knows: how each authenticates, whether it is
170    /// switched on, and whether this machine has the credential it needs.
171    /// Unlike `usage`, this lists the switched-off and the never-configured —
172    /// it contacts nothing and is the catalog a frontend lists providers from.
173    Vendors {
174        /// Machine-readable output.
175        #[arg(long)]
176        json: bool,
177    },
178
179    /// Read or update settings for native desktop frontends.
180    Settings {
181        #[command(subcommand)]
182        action: SettingsAction,
183    },
184
185    /// Authenticate a provider without starting the widget.
186    Auth {
187        #[command(subcommand)]
188        provider: AuthProvider,
189    },
190}
191
192#[derive(clap::Subcommand, Debug, Clone)]
193pub enum AuthProvider {
194    Nous {
195        #[command(subcommand)]
196        action: NousAuthAction,
197    },
198}
199
200#[derive(clap::Subcommand, Debug, Clone)]
201pub enum NousAuthAction {
202    /// Start the Nous Research OAuth device flow.
203    Login,
204    /// Remove only the Nous Research credential.
205    Logout,
206}
207
208#[derive(clap::Subcommand, Debug, Clone)]
209pub enum SettingsAction {
210    /// Print a non-secret JSON settings description.
211    Show,
212
213    /// Apply one JSON settings patch read from standard input.
214    Apply,
215}
216
217#[derive(clap::Subcommand, Debug, Clone)]
218pub enum AccountAction {
219    /// Register an isolated account and open Claude Code to sign it in.
220    Add {
221        /// Stable name used by `--account`, the TUI, and desktop apps.
222        label: String,
223
224        /// Only register the account; do not launch interactive login.
225        #[arg(long, conflicts_with = "desktop")]
226        no_login: bool,
227
228        /// Capture a Claude **Desktop app** account under this label instead
229        /// of a `claude` CLI one (macOS). The app has a single login slot, so
230        /// this signs it out, waits for you to sign in as the new account, and
231        /// saves what it writes. Your current login is restored if you cancel.
232        #[arg(long)]
233        desktop: bool,
234
235        /// E-mail to label a `--desktop` account with. Asked for at the prompt
236        /// if omitted; purely cosmetic, and skipped when not interactive.
237        #[arg(long, requires = "desktop")]
238        email: Option<String>,
239
240        /// Skip the confirmation before signing the Desktop app out.
241        #[arg(short = 'y', long, requires = "desktop")]
242        yes: bool,
243    },
244
245    /// Show which Claude account the Desktop app and the `claude` CLI use.
246    Status {
247        /// Machine-readable output, consumed by the macOS menu bar.
248        #[arg(long)]
249        json: bool,
250    },
251
252    /// Make <LABEL> the active Claude account (macOS).
253    Switch {
254        /// Account to switch to. Desktop profiles come from claude-acc's store;
255        /// CLI accounts from `[[anthropic.accounts]]` / `accounts_dir`.
256        label: String,
257
258        /// Only switch the Claude Desktop app. Neither flag switches both.
259        #[arg(long)]
260        desktop: bool,
261
262        /// Only switch the `claude` CLI's default login.
263        #[arg(long)]
264        cli: bool,
265
266        /// Report what would change and exit without touching anything.
267        #[arg(long)]
268        dry_run: bool,
269
270        /// Skip the confirmation before quitting the Claude Desktop app.
271        #[arg(short = 'y', long)]
272        yes: bool,
273
274        /// Overwrite a `claude` CLI login that belongs to no managed account.
275        /// That login cannot be saved first, so this discards it.
276        #[arg(long)]
277        force: bool,
278
279        /// Keep `bridge-state.json` rather than clearing it. Diagnostic only:
280        /// a stale remote-control session id breaks `/remote-control`.
281        #[arg(long)]
282        keep_bridge: bool,
283
284        /// Also archive the whole session tree, as claude-acc does. Off by
285        /// default because the history merge is additive.
286        #[arg(long)]
287        backup_sessions: bool,
288
289        /// Rollback archives to retain.
290        #[arg(long, default_value_t = 10)]
291        keep_backups: usize,
292
293        /// Confirm that this type-scoped conflict key, deleted in one account
294        /// but still held by another, should be removed everywhere. Repeatable.
295        /// Supplying any suppresses the interactive prompt — keys not listed
296        /// are kept — which is how the macOS menu bar passes an answered dialog
297        /// through.
298        /// `account status --json` lists the candidates as `deletion_conflicts`.
299        #[arg(long, value_name = "KEY")]
300        delete_conflict: Vec<String>,
301    },
302}
303
304#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
305pub enum Vendor {
306    Anthropic,
307    #[value(name = "anthropic_api")]
308    AnthropicApi,
309    Openai,
310    Copilot,
311    Zai,
312    Openrouter,
313    Deepseek,
314    Kimi,
315    Kilo,
316    Novita,
317    Moonshot,
318    Grok,
319    Supergrok,
320    Antigravity,
321    Cursor,
322    Minimax,
323    Kiro,
324    #[value(name = "nous")]
325    NousResearch,
326    #[value(name = "opencode-go")]
327    OpenCodeGo,
328    #[value(name = "commandcode")]
329    CommandCode,
330    Ollama,
331}
332
333impl Vendor {
334    pub fn to_id(self) -> crate::vendor::VendorId {
335        match self {
336            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
337            Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
338            Vendor::Openai => crate::vendor::VendorId::Openai,
339            Vendor::Copilot => crate::vendor::VendorId::Copilot,
340            Vendor::Zai => crate::vendor::VendorId::Zai,
341            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
342            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
343            Vendor::Kimi => crate::vendor::VendorId::Kimi,
344            Vendor::Kilo => crate::vendor::VendorId::Kilo,
345            Vendor::Novita => crate::vendor::VendorId::Novita,
346            Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
347            Vendor::Grok => crate::vendor::VendorId::Grok,
348            Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
349            Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
350            Vendor::Cursor => crate::vendor::VendorId::Cursor,
351            Vendor::Minimax => crate::vendor::VendorId::Minimax,
352            Vendor::Kiro => crate::vendor::VendorId::Kiro,
353            Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
354            Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
355            Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
356            Vendor::Ollama => crate::vendor::VendorId::Ollama,
357        }
358    }
359}
360
361impl Cli {
362    /// Whether the selected vendor came from an explicit `--vendor` opt-in.
363    pub fn has_explicit_vendor(&self) -> bool {
364        self.vendor.is_some()
365    }
366
367    /// Resolve the vendor with full precedence:
368    ///   1. explicit `--vendor` (highest)
369    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
370    ///   3. `[ui] primary` from config
371    ///   4. anthropic (lowest)
372    ///
373    /// This reads the persisted scroll-cycle state from disk via
374    /// [`crate::active::read`]. The pure precedence logic lives in
375    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
376    /// `~/.cache/ai-usagebar/active_vendor`.
377    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
378        // Only consult the scroll-cycle state file when it could actually
379        // matter. An explicit `--vendor` wins outright (precedence #1), so we
380        // skip the disk read entirely in that case — preserving the original
381        // short-circuit and keeping the documented `--vendor` widget config off
382        // the `active_vendor` read path.
383        let active = if self.has_explicit_vendor() {
384            None
385        } else {
386            crate::active::read()
387        };
388        self.resolve_vendor_with(config, active)
389    }
390
391    /// Pure precedence resolution given an explicit scroll-cycle `active`
392    /// override (i.e. whatever [`crate::active::read`] returned). Split out
393    /// from the disk read so tests exercise the precedence rules hermetically
394    /// instead of depending on the developer's real `active_vendor` file.
395    pub fn resolve_vendor_with(
396        &self,
397        config: &crate::config::Config,
398        active: Option<crate::vendor::VendorId>,
399    ) -> Vendor {
400        if let Some(v) = self.vendor {
401            return v;
402        }
403        if let Some(id) = active
404            && config.is_enabled(id)
405        {
406            return id_to_vendor(id);
407        }
408        if let Some(id) = config.ui.primary
409            && config.is_enabled(id)
410        {
411            return id_to_vendor(id);
412        }
413        if config.is_enabled(crate::vendor::VendorId::Anthropic) {
414            return Vendor::Anthropic;
415        }
416        config
417            .enabled_vendors()
418            .into_iter()
419            .next()
420            .map(id_to_vendor)
421            // A completely disabled configuration has no enabled choice; keep
422            // the historic final fallback rather than rejecting widget startup.
423            .unwrap_or(Vendor::Anthropic)
424    }
425}
426
427fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
428    match id {
429        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
430        crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
431        crate::vendor::VendorId::Openai => Vendor::Openai,
432        crate::vendor::VendorId::Copilot => Vendor::Copilot,
433        crate::vendor::VendorId::Zai => Vendor::Zai,
434        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
435        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
436        crate::vendor::VendorId::Kimi => Vendor::Kimi,
437        crate::vendor::VendorId::Kilo => Vendor::Kilo,
438        crate::vendor::VendorId::Novita => Vendor::Novita,
439        crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
440        crate::vendor::VendorId::Grok => Vendor::Grok,
441        crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
442        crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
443        crate::vendor::VendorId::Cursor => Vendor::Cursor,
444        crate::vendor::VendorId::Minimax => Vendor::Minimax,
445        crate::vendor::VendorId::Kiro => Vendor::Kiro,
446        crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
447        crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
448        crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
449        crate::vendor::VendorId::Ollama => Vendor::Ollama,
450    }
451}
452
453impl Cli {
454    /// True when we should emit Waybar JSON. Default behavior: JSON when
455    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
456    pub fn output_json(&self) -> bool {
457        if self.json {
458            return true;
459        }
460        if self.pretty || self.watch.is_some() {
461            return false;
462        }
463        // Auto-detect: emit pretty when stdout is a TTY.
464        !is_stdout_tty()
465    }
466}
467
468fn is_stdout_tty() -> bool {
469    use std::io::IsTerminal;
470    std::io::stdout().is_terminal()
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use clap::{Parser, error::ErrorKind};
477
478    #[test]
479    fn version_flags_report_the_crate_version() {
480        let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
481
482        for flag in ["--version", "-V"] {
483            let err = Cli::try_parse_from(["ai-usagebar", flag])
484                .expect_err("a version flag exits through clap's display path");
485            assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
486            assert_eq!(err.to_string(), expected, "flag: {flag}");
487        }
488    }
489
490    #[test]
491    fn usage_subcommand_parses_machine_readable_mode() {
492        let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
493        assert!(matches!(cli.command, Some(Command::Usage { json: true })));
494    }
495
496    #[test]
497    fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
498        let bare = Cli::parse_from(["ai-usagebar", "detect"]);
499        assert!(matches!(
500            bare.command,
501            Some(Command::Detect {
502                all: false,
503                json: false
504            })
505        ));
506
507        let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
508        assert!(matches!(
509            full.command,
510            Some(Command::Detect {
511                all: true,
512                json: true
513            })
514        ));
515
516        assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
517    }
518
519    #[test]
520    fn new_vendor_values_and_auth_commands_parse_exactly() {
521        let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
522        assert_eq!(nous.vendor, Some(Vendor::NousResearch));
523        let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
524        assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
525        let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
526        assert_eq!(copilot.vendor, Some(Vendor::Copilot));
527        let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
528        assert!(matches!(login.command, Some(Command::Auth { .. })));
529    }
530
531    #[test]
532    fn settings_subcommands_are_additive_and_take_no_widget_flags() {
533        let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
534        assert!(matches!(
535            show.command,
536            Some(Command::Settings {
537                action: SettingsAction::Show
538            })
539        ));
540
541        let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
542        assert!(matches!(
543            apply.command,
544            Some(Command::Settings {
545                action: SettingsAction::Apply
546            })
547        ));
548
549        assert!(
550            Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
551        );
552    }
553
554    #[test]
555    fn defaults_match_claudebar() {
556        let cli = Cli::parse_from(["ai-usagebar"]);
557        assert_eq!(cli.vendor, None);
558        // Without explicit --vendor, no scroll-cycle override, and default
559        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
560        // rather than `resolved_vendor` so the test never reads the real
561        // ~/.cache/ai-usagebar/active_vendor file.
562        let cfg = crate::config::Config::default();
563        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
564        assert_eq!(cli.pace_tolerance, 5);
565        assert!(cli.format.is_none());
566        assert!(cli.tooltip_format.is_none());
567        assert!(cli.icon.is_none());
568        assert!(!cli.format_pace_color);
569        assert!(!cli.tooltip_pace_pts);
570        assert!(!cli.pretty);
571        assert!(!cli.json);
572        assert!(cli.watch.is_none());
573        assert!(cli.command.is_none());
574    }
575
576    #[test]
577    fn account_add_subcommand_parses_without_widget_flags() {
578        let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
579        assert!(matches!(
580            cli.command,
581            Some(Command::Account {
582                action: AccountAction::Add {
583                    ref label,
584                    no_login: true,
585                    desktop: false,
586                    ..
587                }
588            }) if label == "work"
589        ));
590    }
591
592    /// The two halves of `add` capture different things and cannot be combined:
593    /// `--no-login` skips a `claude` login the Desktop capture never runs.
594    #[test]
595    fn account_add_desktop_takes_an_email_and_rejects_no_login() {
596        let cli = Cli::parse_from([
597            "ai-usagebar",
598            "account",
599            "add",
600            "work",
601            "--desktop",
602            "--email",
603            "a@b.test",
604            "-y",
605        ]);
606        assert!(matches!(
607            cli.command,
608            Some(Command::Account {
609                action: AccountAction::Add {
610                    desktop: true,
611                    yes: true,
612                    email: Some(ref email),
613                    ..
614                }
615            }) if email == "a@b.test"
616        ));
617        assert!(
618            Cli::try_parse_from([
619                "ai-usagebar",
620                "account",
621                "add",
622                "w",
623                "--desktop",
624                "--no-login"
625            ])
626            .is_err()
627        );
628        // --email / -y only mean something for the Desktop capture.
629        assert!(
630            Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
631                .is_err()
632        );
633    }
634
635    #[test]
636    fn account_switch_defaults_to_both_surfaces() {
637        let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
638        assert!(matches!(
639            cli.command,
640            Some(Command::Account {
641                action: AccountAction::Switch {
642                    ref label,
643                    desktop: false,
644                    cli: false,
645                    dry_run: true,
646                    keep_backups: 10,
647                    ..
648                }
649            }) if label == "work"
650        ));
651    }
652
653    #[test]
654    fn account_subcommand_rejects_ignored_widget_flags() {
655        assert!(
656            Cli::try_parse_from([
657                "ai-usagebar",
658                "--vendor",
659                "anthropic",
660                "account",
661                "add",
662                "work",
663            ])
664            .is_err()
665        );
666    }
667
668    #[test]
669    fn multi_account_flags_are_stable_api() {
670        // --cache-dir and --creds-path are the documented multi-account
671        // mechanism (README "Multiple accounts") since they were promoted
672        // from hidden debug flags. Renaming either is a breaking change.
673        let cli = Cli::parse_from([
674            "ai-usagebar",
675            "--vendor",
676            "anthropic",
677            "--cache-dir",
678            "/tmp/acct-a",
679            "--creds-path",
680            "/tmp/acct-a/credentials.json",
681        ]);
682        assert_eq!(
683            cli.cache_dir.as_deref(),
684            Some(std::path::Path::new("/tmp/acct-a"))
685        );
686        assert_eq!(
687            cli.creds_path.as_deref(),
688            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
689        );
690    }
691
692    #[test]
693    fn primary_from_config_wins_when_vendor_unset() {
694        // No --vendor and no scroll-cycle override → [ui] primary wins.
695        let cli = Cli::parse_from(["ai-usagebar"]);
696        let mut cfg = crate::config::Config::default();
697        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
698        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
699    }
700
701    #[test]
702    fn explicit_vendor_overrides_everything() {
703        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
704        // [ui] primary.
705        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
706        let mut cfg = crate::config::Config::default();
707        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
708        let active = Some(crate::vendor::VendorId::Openai);
709        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
710    }
711
712    #[test]
713    fn vendor_kimi_parses_to_kimi_variant() {
714        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
715        assert_eq!(cli.vendor, Some(Vendor::Kimi));
716        assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
717    }
718
719    #[test]
720    fn vendor_anthropic_api_uses_the_documented_slug() {
721        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
722        assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
723        assert_eq!(
724            cli.vendor.unwrap().to_id(),
725            crate::vendor::VendorId::AnthropicApi
726        );
727    }
728
729    #[test]
730    fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
731        let cli = Cli::parse_from(["ai-usagebar"]);
732        let mut cfg = crate::config::Config::default();
733        cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
734        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
735    }
736
737    #[test]
738    fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
739        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
740        assert_eq!(
741            cli.resolve_vendor_with(&crate::config::Config::default(), None),
742            Vendor::Kimi
743        );
744    }
745
746    #[test]
747    fn active_override_wins_over_config_primary_when_enabled() {
748        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
749        // primary, as long as it is still enabled.
750        let cli = Cli::parse_from(["ai-usagebar"]);
751        let mut cfg = crate::config::Config::default();
752        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
753        let active = Some(crate::vendor::VendorId::Zai);
754        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
755    }
756
757    #[test]
758    fn disabled_active_override_falls_back_to_config_primary() {
759        // A persisted active vendor the user has since disabled is skipped;
760        // resolution falls through to [ui] primary.
761        let cli = Cli::parse_from(["ai-usagebar"]);
762        let mut cfg = crate::config::Config::default();
763        cfg.zai.enabled = false;
764        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
765        let active = Some(crate::vendor::VendorId::Zai);
766        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
767    }
768
769    #[test]
770    fn claudebar_compatible_flag_surface() {
771        let cli = Cli::parse_from([
772            "ai-usagebar",
773            "--icon",
774            "󰚩",
775            "--format",
776            "{session_pct}% · {session_reset}",
777            "--tooltip-format",
778            "S:{session_pct}",
779            "--pace-tolerance",
780            "10",
781            "--format-pace-color",
782            "--tooltip-pace-pts",
783            "--color-low",
784            "#50fa7b",
785            "--color-mid",
786            "#f1fa8c",
787            "--color-high",
788            "#ffb86c",
789            "--color-critical",
790            "#ff5555",
791        ]);
792        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
793        assert_eq!(
794            cli.format.as_deref(),
795            Some("{session_pct}% · {session_reset}")
796        );
797        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
798        assert_eq!(cli.pace_tolerance, 10);
799        assert!(cli.format_pace_color);
800        assert!(cli.tooltip_pace_pts);
801        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
802        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
803    }
804
805    #[test]
806    fn pretty_and_json_conflict() {
807        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
808        assert!(res.is_err());
809    }
810
811    #[test]
812    fn watch_disables_json_output() {
813        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
814        assert_eq!(cli.watch, Some(5));
815        assert!(!cli.output_json());
816    }
817}