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