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