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