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