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