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}
331
332impl Vendor {
333    pub fn to_id(self) -> crate::vendor::VendorId {
334        match self {
335            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
336            Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
337            Vendor::Openai => crate::vendor::VendorId::Openai,
338            Vendor::Copilot => crate::vendor::VendorId::Copilot,
339            Vendor::Zai => crate::vendor::VendorId::Zai,
340            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
341            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
342            Vendor::Kimi => crate::vendor::VendorId::Kimi,
343            Vendor::Kilo => crate::vendor::VendorId::Kilo,
344            Vendor::Novita => crate::vendor::VendorId::Novita,
345            Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
346            Vendor::Grok => crate::vendor::VendorId::Grok,
347            Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
348            Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
349            Vendor::Cursor => crate::vendor::VendorId::Cursor,
350            Vendor::Minimax => crate::vendor::VendorId::Minimax,
351            Vendor::Kiro => crate::vendor::VendorId::Kiro,
352            Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
353            Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
354            Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
355        }
356    }
357}
358
359impl Cli {
360    /// Whether the selected vendor came from an explicit `--vendor` opt-in.
361    pub fn has_explicit_vendor(&self) -> bool {
362        self.vendor.is_some()
363    }
364
365    /// Resolve the vendor with full precedence:
366    ///   1. explicit `--vendor` (highest)
367    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
368    ///   3. `[ui] primary` from config
369    ///   4. anthropic (lowest)
370    ///
371    /// This reads the persisted scroll-cycle state from disk via
372    /// [`crate::active::read`]. The pure precedence logic lives in
373    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
374    /// `~/.cache/ai-usagebar/active_vendor`.
375    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
376        // Only consult the scroll-cycle state file when it could actually
377        // matter. An explicit `--vendor` wins outright (precedence #1), so we
378        // skip the disk read entirely in that case — preserving the original
379        // short-circuit and keeping the documented `--vendor` widget config off
380        // the `active_vendor` read path.
381        let active = if self.has_explicit_vendor() {
382            None
383        } else {
384            crate::active::read()
385        };
386        self.resolve_vendor_with(config, active)
387    }
388
389    /// Pure precedence resolution given an explicit scroll-cycle `active`
390    /// override (i.e. whatever [`crate::active::read`] returned). Split out
391    /// from the disk read so tests exercise the precedence rules hermetically
392    /// instead of depending on the developer's real `active_vendor` file.
393    pub fn resolve_vendor_with(
394        &self,
395        config: &crate::config::Config,
396        active: Option<crate::vendor::VendorId>,
397    ) -> Vendor {
398        if let Some(v) = self.vendor {
399            return v;
400        }
401        if let Some(id) = active
402            && config.is_enabled(id)
403        {
404            return id_to_vendor(id);
405        }
406        if let Some(id) = config.ui.primary
407            && config.is_enabled(id)
408        {
409            return id_to_vendor(id);
410        }
411        if config.is_enabled(crate::vendor::VendorId::Anthropic) {
412            return Vendor::Anthropic;
413        }
414        config
415            .enabled_vendors()
416            .into_iter()
417            .next()
418            .map(id_to_vendor)
419            // A completely disabled configuration has no enabled choice; keep
420            // the historic final fallback rather than rejecting widget startup.
421            .unwrap_or(Vendor::Anthropic)
422    }
423}
424
425fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
426    match id {
427        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
428        crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
429        crate::vendor::VendorId::Openai => Vendor::Openai,
430        crate::vendor::VendorId::Copilot => Vendor::Copilot,
431        crate::vendor::VendorId::Zai => Vendor::Zai,
432        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
433        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
434        crate::vendor::VendorId::Kimi => Vendor::Kimi,
435        crate::vendor::VendorId::Kilo => Vendor::Kilo,
436        crate::vendor::VendorId::Novita => Vendor::Novita,
437        crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
438        crate::vendor::VendorId::Grok => Vendor::Grok,
439        crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
440        crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
441        crate::vendor::VendorId::Cursor => Vendor::Cursor,
442        crate::vendor::VendorId::Minimax => Vendor::Minimax,
443        crate::vendor::VendorId::Kiro => Vendor::Kiro,
444        crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
445        crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
446        crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
447    }
448}
449
450impl Cli {
451    /// True when we should emit Waybar JSON. Default behavior: JSON when
452    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
453    pub fn output_json(&self) -> bool {
454        if self.json {
455            return true;
456        }
457        if self.pretty || self.watch.is_some() {
458            return false;
459        }
460        // Auto-detect: emit pretty when stdout is a TTY.
461        !is_stdout_tty()
462    }
463}
464
465fn is_stdout_tty() -> bool {
466    use std::io::IsTerminal;
467    std::io::stdout().is_terminal()
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473    use clap::{Parser, error::ErrorKind};
474
475    #[test]
476    fn version_flags_report_the_crate_version() {
477        let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
478
479        for flag in ["--version", "-V"] {
480            let err = Cli::try_parse_from(["ai-usagebar", flag])
481                .expect_err("a version flag exits through clap's display path");
482            assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
483            assert_eq!(err.to_string(), expected, "flag: {flag}");
484        }
485    }
486
487    #[test]
488    fn usage_subcommand_parses_machine_readable_mode() {
489        let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
490        assert!(matches!(cli.command, Some(Command::Usage { json: true })));
491    }
492
493    #[test]
494    fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
495        let bare = Cli::parse_from(["ai-usagebar", "detect"]);
496        assert!(matches!(
497            bare.command,
498            Some(Command::Detect {
499                all: false,
500                json: false
501            })
502        ));
503
504        let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
505        assert!(matches!(
506            full.command,
507            Some(Command::Detect {
508                all: true,
509                json: true
510            })
511        ));
512
513        assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
514    }
515
516    #[test]
517    fn new_vendor_values_and_auth_commands_parse_exactly() {
518        let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
519        assert_eq!(nous.vendor, Some(Vendor::NousResearch));
520        let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
521        assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
522        let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
523        assert_eq!(copilot.vendor, Some(Vendor::Copilot));
524        let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
525        assert!(matches!(login.command, Some(Command::Auth { .. })));
526    }
527
528    #[test]
529    fn settings_subcommands_are_additive_and_take_no_widget_flags() {
530        let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
531        assert!(matches!(
532            show.command,
533            Some(Command::Settings {
534                action: SettingsAction::Show
535            })
536        ));
537
538        let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
539        assert!(matches!(
540            apply.command,
541            Some(Command::Settings {
542                action: SettingsAction::Apply
543            })
544        ));
545
546        assert!(
547            Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
548        );
549    }
550
551    #[test]
552    fn defaults_match_claudebar() {
553        let cli = Cli::parse_from(["ai-usagebar"]);
554        assert_eq!(cli.vendor, None);
555        // Without explicit --vendor, no scroll-cycle override, and default
556        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
557        // rather than `resolved_vendor` so the test never reads the real
558        // ~/.cache/ai-usagebar/active_vendor file.
559        let cfg = crate::config::Config::default();
560        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
561        assert_eq!(cli.pace_tolerance, 5);
562        assert!(cli.format.is_none());
563        assert!(cli.tooltip_format.is_none());
564        assert!(cli.icon.is_none());
565        assert!(!cli.format_pace_color);
566        assert!(!cli.tooltip_pace_pts);
567        assert!(!cli.pretty);
568        assert!(!cli.json);
569        assert!(cli.watch.is_none());
570        assert!(cli.command.is_none());
571    }
572
573    #[test]
574    fn account_add_subcommand_parses_without_widget_flags() {
575        let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
576        assert!(matches!(
577            cli.command,
578            Some(Command::Account {
579                action: AccountAction::Add {
580                    ref label,
581                    no_login: true,
582                    desktop: false,
583                    ..
584                }
585            }) if label == "work"
586        ));
587    }
588
589    /// The two halves of `add` capture different things and cannot be combined:
590    /// `--no-login` skips a `claude` login the Desktop capture never runs.
591    #[test]
592    fn account_add_desktop_takes_an_email_and_rejects_no_login() {
593        let cli = Cli::parse_from([
594            "ai-usagebar",
595            "account",
596            "add",
597            "work",
598            "--desktop",
599            "--email",
600            "a@b.test",
601            "-y",
602        ]);
603        assert!(matches!(
604            cli.command,
605            Some(Command::Account {
606                action: AccountAction::Add {
607                    desktop: true,
608                    yes: true,
609                    email: Some(ref email),
610                    ..
611                }
612            }) if email == "a@b.test"
613        ));
614        assert!(
615            Cli::try_parse_from([
616                "ai-usagebar",
617                "account",
618                "add",
619                "w",
620                "--desktop",
621                "--no-login"
622            ])
623            .is_err()
624        );
625        // --email / -y only mean something for the Desktop capture.
626        assert!(
627            Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
628                .is_err()
629        );
630    }
631
632    #[test]
633    fn account_switch_defaults_to_both_surfaces() {
634        let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
635        assert!(matches!(
636            cli.command,
637            Some(Command::Account {
638                action: AccountAction::Switch {
639                    ref label,
640                    desktop: false,
641                    cli: false,
642                    dry_run: true,
643                    keep_backups: 10,
644                    ..
645                }
646            }) if label == "work"
647        ));
648    }
649
650    #[test]
651    fn account_subcommand_rejects_ignored_widget_flags() {
652        assert!(
653            Cli::try_parse_from([
654                "ai-usagebar",
655                "--vendor",
656                "anthropic",
657                "account",
658                "add",
659                "work",
660            ])
661            .is_err()
662        );
663    }
664
665    #[test]
666    fn multi_account_flags_are_stable_api() {
667        // --cache-dir and --creds-path are the documented multi-account
668        // mechanism (README "Multiple accounts") since they were promoted
669        // from hidden debug flags. Renaming either is a breaking change.
670        let cli = Cli::parse_from([
671            "ai-usagebar",
672            "--vendor",
673            "anthropic",
674            "--cache-dir",
675            "/tmp/acct-a",
676            "--creds-path",
677            "/tmp/acct-a/credentials.json",
678        ]);
679        assert_eq!(
680            cli.cache_dir.as_deref(),
681            Some(std::path::Path::new("/tmp/acct-a"))
682        );
683        assert_eq!(
684            cli.creds_path.as_deref(),
685            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
686        );
687    }
688
689    #[test]
690    fn primary_from_config_wins_when_vendor_unset() {
691        // No --vendor and no scroll-cycle override → [ui] primary wins.
692        let cli = Cli::parse_from(["ai-usagebar"]);
693        let mut cfg = crate::config::Config::default();
694        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
695        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
696    }
697
698    #[test]
699    fn explicit_vendor_overrides_everything() {
700        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
701        // [ui] primary.
702        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
703        let mut cfg = crate::config::Config::default();
704        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
705        let active = Some(crate::vendor::VendorId::Openai);
706        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
707    }
708
709    #[test]
710    fn vendor_kimi_parses_to_kimi_variant() {
711        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
712        assert_eq!(cli.vendor, Some(Vendor::Kimi));
713        assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
714    }
715
716    #[test]
717    fn vendor_anthropic_api_uses_the_documented_slug() {
718        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
719        assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
720        assert_eq!(
721            cli.vendor.unwrap().to_id(),
722            crate::vendor::VendorId::AnthropicApi
723        );
724    }
725
726    #[test]
727    fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
728        let cli = Cli::parse_from(["ai-usagebar"]);
729        let mut cfg = crate::config::Config::default();
730        cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
731        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
732    }
733
734    #[test]
735    fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
736        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
737        assert_eq!(
738            cli.resolve_vendor_with(&crate::config::Config::default(), None),
739            Vendor::Kimi
740        );
741    }
742
743    #[test]
744    fn active_override_wins_over_config_primary_when_enabled() {
745        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
746        // primary, as long as it is still enabled.
747        let cli = Cli::parse_from(["ai-usagebar"]);
748        let mut cfg = crate::config::Config::default();
749        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
750        let active = Some(crate::vendor::VendorId::Zai);
751        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
752    }
753
754    #[test]
755    fn disabled_active_override_falls_back_to_config_primary() {
756        // A persisted active vendor the user has since disabled is skipped;
757        // resolution falls through to [ui] primary.
758        let cli = Cli::parse_from(["ai-usagebar"]);
759        let mut cfg = crate::config::Config::default();
760        cfg.zai.enabled = false;
761        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
762        let active = Some(crate::vendor::VendorId::Zai);
763        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
764    }
765
766    #[test]
767    fn claudebar_compatible_flag_surface() {
768        let cli = Cli::parse_from([
769            "ai-usagebar",
770            "--icon",
771            "󰚩",
772            "--format",
773            "{session_pct}% · {session_reset}",
774            "--tooltip-format",
775            "S:{session_pct}",
776            "--pace-tolerance",
777            "10",
778            "--format-pace-color",
779            "--tooltip-pace-pts",
780            "--color-low",
781            "#50fa7b",
782            "--color-mid",
783            "#f1fa8c",
784            "--color-high",
785            "#ffb86c",
786            "--color-critical",
787            "#ff5555",
788        ]);
789        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
790        assert_eq!(
791            cli.format.as_deref(),
792            Some("{session_pct}% · {session_reset}")
793        );
794        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
795        assert_eq!(cli.pace_tolerance, 10);
796        assert!(cli.format_pace_color);
797        assert!(cli.tooltip_pace_pts);
798        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
799        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
800    }
801
802    #[test]
803    fn pretty_and_json_conflict() {
804        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
805        assert!(res.is_err());
806    }
807
808    #[test]
809    fn watch_disables_json_output() {
810        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
811        assert_eq!(cli.watch, Some(5));
812        assert!(!cli.output_json());
813    }
814}