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