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