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