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