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    OrcaRouter,
344    #[value(name = "modelstudio")]
345    ModelStudio,
346}
347
348impl Vendor {
349    pub fn to_id(self) -> crate::vendor::VendorId {
350        match self {
351            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
352            Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
353            Vendor::Openai => crate::vendor::VendorId::Openai,
354            Vendor::Copilot => crate::vendor::VendorId::Copilot,
355            Vendor::Zai => crate::vendor::VendorId::Zai,
356            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
357            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
358            Vendor::Kimi => crate::vendor::VendorId::Kimi,
359            Vendor::Kilo => crate::vendor::VendorId::Kilo,
360            Vendor::Novita => crate::vendor::VendorId::Novita,
361            Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
362            Vendor::Grok => crate::vendor::VendorId::Grok,
363            Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
364            Vendor::Grokbot => crate::vendor::VendorId::Grokbot,
365            Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
366            Vendor::Cursor => crate::vendor::VendorId::Cursor,
367            Vendor::Minimax => crate::vendor::VendorId::Minimax,
368            Vendor::Kiro => crate::vendor::VendorId::Kiro,
369            Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
370            Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
371            Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
372            Vendor::Ollama => crate::vendor::VendorId::Ollama,
373            Vendor::OrcaRouter => crate::vendor::VendorId::OrcaRouter,
374            Vendor::ModelStudio => crate::vendor::VendorId::ModelStudio,
375        }
376    }
377}
378
379impl Cli {
380    /// Whether the selected vendor came from an explicit `--vendor` opt-in.
381    pub fn has_explicit_vendor(&self) -> bool {
382        self.vendor.is_some()
383    }
384
385    /// Resolve the vendor with full precedence:
386    ///   1. explicit `--vendor` (highest)
387    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
388    ///   3. `[ui] primary` from config
389    ///   4. anthropic (lowest)
390    ///
391    /// This reads the persisted scroll-cycle state from disk via
392    /// [`crate::active::read`]. The pure precedence logic lives in
393    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
394    /// `~/.cache/ai-usagebar/active_vendor`.
395    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
396        // Only consult the scroll-cycle state file when it could actually
397        // matter. An explicit `--vendor` wins outright (precedence #1), so we
398        // skip the disk read entirely in that case — preserving the original
399        // short-circuit and keeping the documented `--vendor` widget config off
400        // the `active_vendor` read path.
401        let active = if self.has_explicit_vendor() {
402            None
403        } else {
404            crate::active::read()
405        };
406        self.resolve_vendor_with(config, active)
407    }
408
409    /// Pure precedence resolution given an explicit scroll-cycle `active`
410    /// override (i.e. whatever [`crate::active::read`] returned). Split out
411    /// from the disk read so tests exercise the precedence rules hermetically
412    /// instead of depending on the developer's real `active_vendor` file.
413    pub fn resolve_vendor_with(
414        &self,
415        config: &crate::config::Config,
416        active: Option<crate::vendor::VendorId>,
417    ) -> Vendor {
418        if let Some(v) = self.vendor {
419            return v;
420        }
421        if let Some(id) = active
422            && config.is_enabled(id)
423        {
424            return id_to_vendor(id);
425        }
426        if let Some(id) = config.ui.primary
427            && config.is_enabled(id)
428        {
429            return id_to_vendor(id);
430        }
431        if config.is_enabled(crate::vendor::VendorId::Anthropic) {
432            return Vendor::Anthropic;
433        }
434        config
435            .enabled_vendors()
436            .into_iter()
437            .next()
438            .map(id_to_vendor)
439            // A completely disabled configuration has no enabled choice; keep
440            // the historic final fallback rather than rejecting widget startup.
441            .unwrap_or(Vendor::Anthropic)
442    }
443}
444
445fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
446    match id {
447        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
448        crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
449        crate::vendor::VendorId::Openai => Vendor::Openai,
450        crate::vendor::VendorId::Copilot => Vendor::Copilot,
451        crate::vendor::VendorId::Zai => Vendor::Zai,
452        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
453        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
454        crate::vendor::VendorId::Kimi => Vendor::Kimi,
455        crate::vendor::VendorId::Kilo => Vendor::Kilo,
456        crate::vendor::VendorId::Novita => Vendor::Novita,
457        crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
458        crate::vendor::VendorId::Grok => Vendor::Grok,
459        crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
460        crate::vendor::VendorId::Grokbot => Vendor::Grokbot,
461        crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
462        crate::vendor::VendorId::Cursor => Vendor::Cursor,
463        crate::vendor::VendorId::Minimax => Vendor::Minimax,
464        crate::vendor::VendorId::Kiro => Vendor::Kiro,
465        crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
466        crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
467        crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
468        crate::vendor::VendorId::Ollama => Vendor::Ollama,
469        crate::vendor::VendorId::OrcaRouter => Vendor::OrcaRouter,
470        crate::vendor::VendorId::ModelStudio => Vendor::ModelStudio,
471    }
472}
473
474impl Cli {
475    /// True when we should emit Waybar JSON. Default behavior: JSON when
476    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
477    pub fn output_json(&self) -> bool {
478        if self.json {
479            return true;
480        }
481        if self.pretty || self.watch.is_some() {
482            return false;
483        }
484        // Auto-detect: emit pretty when stdout is a TTY.
485        !is_stdout_tty()
486    }
487}
488
489fn is_stdout_tty() -> bool {
490    use std::io::IsTerminal;
491    std::io::stdout().is_terminal()
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use clap::{Parser, error::ErrorKind};
498
499    #[test]
500    fn settings_enable_requires_a_known_vendor() {
501        assert!(matches!(
502            Cli::try_parse_from(["ai-usagebar", "settings", "enable", "anthropic"])
503                .unwrap()
504                .command,
505            Some(Command::Settings {
506                action: SettingsAction::Enable {
507                    vendor: Vendor::Anthropic
508                }
509            })
510        ));
511        assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable", "unknown"]).is_err());
512        assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable"]).is_err());
513    }
514
515    #[test]
516    fn version_flags_report_the_crate_version() {
517        let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
518
519        for flag in ["--version", "-V"] {
520            let err = Cli::try_parse_from(["ai-usagebar", flag])
521                .expect_err("a version flag exits through clap's display path");
522            assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
523            assert_eq!(err.to_string(), expected, "flag: {flag}");
524        }
525    }
526
527    #[test]
528    fn usage_subcommand_parses_machine_readable_mode() {
529        let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
530        assert!(matches!(cli.command, Some(Command::Usage { json: true })));
531    }
532
533    #[test]
534    fn usage_help_states_complete_document_exits_zero() {
535        let err = Cli::try_parse_from(["ai-usagebar", "usage", "--help"])
536            .expect_err("help exits through clap's display path");
537        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
538        let help = err.to_string();
539        assert!(
540            help.contains("Exits 0 after printing a complete document"),
541            "{help}"
542        );
543        assert!(
544            help.contains("even when every entry") && help.contains("error"),
545            "{help}"
546        );
547        assert!(
548            help.contains("Non-zero only when the document cannot be produced"),
549            "{help}"
550        );
551    }
552
553    #[test]
554    fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
555        let bare = Cli::parse_from(["ai-usagebar", "detect"]);
556        assert!(matches!(
557            bare.command,
558            Some(Command::Detect {
559                all: false,
560                json: false
561            })
562        ));
563
564        let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
565        assert!(matches!(
566            full.command,
567            Some(Command::Detect {
568                all: true,
569                json: true
570            })
571        ));
572
573        assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
574    }
575
576    #[test]
577    fn new_vendor_values_and_auth_commands_parse_exactly() {
578        let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
579        assert_eq!(nous.vendor, Some(Vendor::NousResearch));
580        let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
581        assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
582        let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
583        assert_eq!(copilot.vendor, Some(Vendor::Copilot));
584        let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
585        assert!(matches!(login.command, Some(Command::Auth { .. })));
586    }
587
588    #[test]
589    fn settings_subcommands_are_additive_and_take_no_widget_flags() {
590        let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
591        assert!(matches!(
592            show.command,
593            Some(Command::Settings {
594                action: SettingsAction::Show
595            })
596        ));
597
598        let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
599        assert!(matches!(
600            apply.command,
601            Some(Command::Settings {
602                action: SettingsAction::Apply
603            })
604        ));
605
606        assert!(
607            Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
608        );
609    }
610
611    #[test]
612    fn defaults_match_claudebar() {
613        let cli = Cli::parse_from(["ai-usagebar"]);
614        assert_eq!(cli.vendor, None);
615        // Without explicit --vendor, no scroll-cycle override, and default
616        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
617        // rather than `resolved_vendor` so the test never reads the real
618        // ~/.cache/ai-usagebar/active_vendor file.
619        let cfg = crate::config::Config::default();
620        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
621        assert_eq!(cli.pace_tolerance, 5);
622        assert!(cli.format.is_none());
623        assert!(cli.tooltip_format.is_none());
624        assert!(cli.icon.is_none());
625        assert!(!cli.format_pace_color);
626        assert!(!cli.tooltip_pace_pts);
627        assert!(!cli.pretty);
628        assert!(!cli.json);
629        assert!(cli.watch.is_none());
630        assert!(cli.command.is_none());
631    }
632
633    #[test]
634    fn account_add_subcommand_parses_without_widget_flags() {
635        let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
636        assert!(matches!(
637            cli.command,
638            Some(Command::Account {
639                action: AccountAction::Add {
640                    ref label,
641                    no_login: true,
642                    desktop: false,
643                    ..
644                }
645            }) if label == "work"
646        ));
647    }
648
649    /// The two halves of `add` capture different things and cannot be combined:
650    /// `--no-login` skips a `claude` login the Desktop capture never runs.
651    #[test]
652    fn account_add_desktop_takes_an_email_and_rejects_no_login() {
653        let cli = Cli::parse_from([
654            "ai-usagebar",
655            "account",
656            "add",
657            "work",
658            "--desktop",
659            "--email",
660            "a@b.test",
661            "-y",
662        ]);
663        assert!(matches!(
664            cli.command,
665            Some(Command::Account {
666                action: AccountAction::Add {
667                    desktop: true,
668                    yes: true,
669                    email: Some(ref email),
670                    ..
671                }
672            }) if email == "a@b.test"
673        ));
674        assert!(
675            Cli::try_parse_from([
676                "ai-usagebar",
677                "account",
678                "add",
679                "w",
680                "--desktop",
681                "--no-login"
682            ])
683            .is_err()
684        );
685        // --email / -y only mean something for the Desktop capture.
686        assert!(
687            Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
688                .is_err()
689        );
690    }
691
692    #[test]
693    fn account_switch_defaults_to_both_surfaces() {
694        let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
695        assert!(matches!(
696            cli.command,
697            Some(Command::Account {
698                action: AccountAction::Switch {
699                    ref label,
700                    desktop: false,
701                    cli: false,
702                    dry_run: true,
703                    keep_backups: 10,
704                    ..
705                }
706            }) if label == "work"
707        ));
708    }
709
710    #[test]
711    fn account_subcommand_rejects_ignored_widget_flags() {
712        assert!(
713            Cli::try_parse_from([
714                "ai-usagebar",
715                "--vendor",
716                "anthropic",
717                "account",
718                "add",
719                "work",
720            ])
721            .is_err()
722        );
723    }
724
725    #[test]
726    fn multi_account_flags_are_stable_api() {
727        // --cache-dir and --creds-path are the documented multi-account
728        // mechanism (README "Multiple accounts") since they were promoted
729        // from hidden debug flags. Renaming either is a breaking change.
730        let cli = Cli::parse_from([
731            "ai-usagebar",
732            "--vendor",
733            "anthropic",
734            "--cache-dir",
735            "/tmp/acct-a",
736            "--creds-path",
737            "/tmp/acct-a/credentials.json",
738        ]);
739        assert_eq!(
740            cli.cache_dir.as_deref(),
741            Some(std::path::Path::new("/tmp/acct-a"))
742        );
743        assert_eq!(
744            cli.creds_path.as_deref(),
745            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
746        );
747    }
748
749    #[test]
750    fn primary_from_config_wins_when_vendor_unset() {
751        // No --vendor and no scroll-cycle override → [ui] primary wins.
752        let cli = Cli::parse_from(["ai-usagebar"]);
753        let mut cfg = crate::config::Config::default();
754        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
755        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
756    }
757
758    #[test]
759    fn explicit_vendor_overrides_everything() {
760        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
761        // [ui] primary.
762        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
763        let mut cfg = crate::config::Config::default();
764        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
765        let active = Some(crate::vendor::VendorId::Openai);
766        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
767    }
768
769    #[test]
770    fn vendor_kimi_parses_to_kimi_variant() {
771        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
772        assert_eq!(cli.vendor, Some(Vendor::Kimi));
773        assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
774    }
775
776    #[test]
777    fn vendor_grokbot_parses_to_grokbot_variant() {
778        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "grokbot"]);
779        assert_eq!(cli.vendor, Some(Vendor::Grokbot));
780        assert_eq!(
781            cli.vendor.unwrap().to_id(),
782            crate::vendor::VendorId::Grokbot
783        );
784        // …and back, for the persisted-state resolver.
785        assert_eq!(
786            id_to_vendor(crate::vendor::VendorId::Grokbot),
787            Vendor::Grokbot
788        );
789    }
790
791    #[test]
792    fn vendor_modelstudio_parses_to_modelstudio_variant() {
793        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "modelstudio"]);
794        assert_eq!(cli.vendor, Some(Vendor::ModelStudio));
795        assert_eq!(
796            cli.vendor.unwrap().to_id(),
797            crate::vendor::VendorId::ModelStudio
798        );
799        assert_eq!(
800            id_to_vendor(crate::vendor::VendorId::ModelStudio),
801            Vendor::ModelStudio
802        );
803    }
804
805    #[test]
806    fn vendor_anthropic_api_uses_the_documented_slug() {
807        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
808        assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
809        assert_eq!(
810            cli.vendor.unwrap().to_id(),
811            crate::vendor::VendorId::AnthropicApi
812        );
813    }
814
815    #[test]
816    fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
817        let cli = Cli::parse_from(["ai-usagebar"]);
818        let mut cfg = crate::config::Config::default();
819        cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
820        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
821    }
822
823    #[test]
824    fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
825        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
826        assert_eq!(
827            cli.resolve_vendor_with(&crate::config::Config::default(), None),
828            Vendor::Kimi
829        );
830    }
831
832    #[test]
833    fn active_override_wins_over_config_primary_when_enabled() {
834        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
835        // primary, as long as it is still enabled.
836        let cli = Cli::parse_from(["ai-usagebar"]);
837        let mut cfg = crate::config::Config::default();
838        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
839        let active = Some(crate::vendor::VendorId::Zai);
840        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
841    }
842
843    #[test]
844    fn disabled_active_override_falls_back_to_config_primary() {
845        // A persisted active vendor the user has since disabled is skipped;
846        // resolution falls through to [ui] primary.
847        let cli = Cli::parse_from(["ai-usagebar"]);
848        let mut cfg = crate::config::Config::default();
849        cfg.zai.enabled = false;
850        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
851        let active = Some(crate::vendor::VendorId::Zai);
852        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
853    }
854
855    #[test]
856    fn claudebar_compatible_flag_surface() {
857        let cli = Cli::parse_from([
858            "ai-usagebar",
859            "--icon",
860            "󰚩",
861            "--format",
862            "{session_pct}% · {session_reset}",
863            "--tooltip-format",
864            "S:{session_pct}",
865            "--pace-tolerance",
866            "10",
867            "--format-pace-color",
868            "--tooltip-pace-pts",
869            "--color-low",
870            "#50fa7b",
871            "--color-mid",
872            "#f1fa8c",
873            "--color-high",
874            "#ffb86c",
875            "--color-critical",
876            "#ff5555",
877        ]);
878        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
879        assert_eq!(
880            cli.format.as_deref(),
881            Some("{session_pct}% · {session_reset}")
882        );
883        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
884        assert_eq!(cli.pace_tolerance, 10);
885        assert!(cli.format_pace_color);
886        assert!(cli.tooltip_pace_pts);
887        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
888        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
889    }
890
891    #[test]
892    fn pretty_and_json_conflict() {
893        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
894        assert!(res.is_err());
895    }
896
897    #[test]
898    fn watch_disables_json_output() {
899        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
900        assert_eq!(cli.watch, Some(5));
901        assert!(!cli.output_json());
902    }
903}