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