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