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