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