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    Mistral,
461}
462
463impl Vendor {
464    pub fn to_id(self) -> crate::vendor::VendorId {
465        match self {
466            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
467            Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
468            Vendor::Openai => crate::vendor::VendorId::Openai,
469            Vendor::Copilot => crate::vendor::VendorId::Copilot,
470            Vendor::Zai => crate::vendor::VendorId::Zai,
471            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
472            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
473            Vendor::Deepinfra => crate::vendor::VendorId::Deepinfra,
474            Vendor::Kimi => crate::vendor::VendorId::Kimi,
475            Vendor::Kilo => crate::vendor::VendorId::Kilo,
476            Vendor::Novita => crate::vendor::VendorId::Novita,
477            Vendor::Synthetic => crate::vendor::VendorId::Synthetic,
478            Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
479            Vendor::Grok => crate::vendor::VendorId::Grok,
480            Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
481            Vendor::Grokbot => crate::vendor::VendorId::Grokbot,
482            Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
483            Vendor::Cursor => crate::vendor::VendorId::Cursor,
484            Vendor::Minimax => crate::vendor::VendorId::Minimax,
485            Vendor::Kiro => crate::vendor::VendorId::Kiro,
486            Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
487            Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
488            Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
489            Vendor::Ollama => crate::vendor::VendorId::Ollama,
490            Vendor::OrcaRouter => crate::vendor::VendorId::OrcaRouter,
491            Vendor::ModelStudio => crate::vendor::VendorId::ModelStudio,
492            Vendor::Lyceum => crate::vendor::VendorId::Lyceum,
493            Vendor::Devin => crate::vendor::VendorId::Devin,
494            Vendor::MuseCode => crate::vendor::VendorId::MuseCode,
495            Vendor::Cline => crate::vendor::VendorId::Cline,
496            Vendor::Droid => crate::vendor::VendorId::Droid,
497            Vendor::DeepRelay => crate::vendor::VendorId::DeepRelay,
498            Vendor::Mistral => crate::vendor::VendorId::Mistral,
499        }
500    }
501}
502
503impl Cli {
504    /// Whether the selected vendor came from an explicit `--vendor` opt-in.
505    pub fn has_explicit_vendor(&self) -> bool {
506        self.vendor.is_some()
507    }
508
509    /// Resolve the vendor with full precedence:
510    ///   1. explicit `--vendor` (highest)
511    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
512    ///   3. `[ui] primary` from config
513    ///   4. anthropic (lowest)
514    ///
515    /// This reads the persisted scroll-cycle state from disk via
516    /// [`crate::active::read`]. The pure precedence logic lives in
517    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
518    /// `~/.cache/ai-usagebar/active_vendor`.
519    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
520        // Only consult the scroll-cycle state file when it could actually
521        // matter. An explicit `--vendor` wins outright (precedence #1), so we
522        // skip the disk read entirely in that case — preserving the original
523        // short-circuit and keeping the documented `--vendor` widget config off
524        // the `active_vendor` read path.
525        let active = if self.has_explicit_vendor() {
526            None
527        } else {
528            crate::active::read()
529        };
530        self.resolve_vendor_with(config, active)
531    }
532
533    /// Pure precedence resolution given an explicit scroll-cycle `active`
534    /// override (i.e. whatever [`crate::active::read`] returned). Split out
535    /// from the disk read so tests exercise the precedence rules hermetically
536    /// instead of depending on the developer's real `active_vendor` file.
537    pub fn resolve_vendor_with(
538        &self,
539        config: &crate::config::Config,
540        active: Option<crate::vendor::VendorId>,
541    ) -> Vendor {
542        if let Some(v) = self.vendor {
543            return v;
544        }
545        if let Some(id) = active
546            && config.is_enabled(id)
547        {
548            return id_to_vendor(id);
549        }
550        if let Some(id) = config.ui.primary
551            && config.is_enabled(id)
552        {
553            return id_to_vendor(id);
554        }
555        if config.is_enabled(crate::vendor::VendorId::Anthropic) {
556            return Vendor::Anthropic;
557        }
558        config
559            .enabled_vendors()
560            .into_iter()
561            .next()
562            .map(id_to_vendor)
563            // A completely disabled configuration has no enabled choice; keep
564            // the historic final fallback rather than rejecting widget startup.
565            .unwrap_or(Vendor::Anthropic)
566    }
567}
568
569fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
570    match id {
571        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
572        crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
573        crate::vendor::VendorId::Openai => Vendor::Openai,
574        crate::vendor::VendorId::Copilot => Vendor::Copilot,
575        crate::vendor::VendorId::Zai => Vendor::Zai,
576        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
577        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
578        crate::vendor::VendorId::Deepinfra => Vendor::Deepinfra,
579        crate::vendor::VendorId::Kimi => Vendor::Kimi,
580        crate::vendor::VendorId::Kilo => Vendor::Kilo,
581        crate::vendor::VendorId::Novita => Vendor::Novita,
582        crate::vendor::VendorId::Synthetic => Vendor::Synthetic,
583        crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
584        crate::vendor::VendorId::Grok => Vendor::Grok,
585        crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
586        crate::vendor::VendorId::Grokbot => Vendor::Grokbot,
587        crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
588        crate::vendor::VendorId::Cursor => Vendor::Cursor,
589        crate::vendor::VendorId::Minimax => Vendor::Minimax,
590        crate::vendor::VendorId::Kiro => Vendor::Kiro,
591        crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
592        crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
593        crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
594        crate::vendor::VendorId::Ollama => Vendor::Ollama,
595        crate::vendor::VendorId::OrcaRouter => Vendor::OrcaRouter,
596        crate::vendor::VendorId::ModelStudio => Vendor::ModelStudio,
597        crate::vendor::VendorId::Lyceum => Vendor::Lyceum,
598        crate::vendor::VendorId::Devin => Vendor::Devin,
599        crate::vendor::VendorId::MuseCode => Vendor::MuseCode,
600        crate::vendor::VendorId::Cline => Vendor::Cline,
601        crate::vendor::VendorId::Droid => Vendor::Droid,
602        crate::vendor::VendorId::DeepRelay => Vendor::DeepRelay,
603        crate::vendor::VendorId::Mistral => Vendor::Mistral,
604    }
605}
606
607impl Cli {
608    /// True when we should emit Waybar JSON. Default behavior: JSON when
609    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
610    pub fn output_json(&self) -> bool {
611        if self.json {
612            return true;
613        }
614        if self.pretty || self.watch.is_some() {
615            return false;
616        }
617        // Auto-detect: emit pretty when stdout is a TTY.
618        !is_stdout_tty()
619    }
620}
621
622fn is_stdout_tty() -> bool {
623    use std::io::IsTerminal;
624    std::io::stdout().is_terminal()
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630    use clap::{Parser, error::ErrorKind};
631
632    #[test]
633    fn settings_enable_requires_a_known_vendor() {
634        assert!(matches!(
635            Cli::try_parse_from(["ai-usagebar", "settings", "enable", "anthropic"])
636                .unwrap()
637                .command,
638            Some(Command::Settings {
639                action: SettingsAction::Enable {
640                    vendor: Vendor::Anthropic
641                }
642            })
643        ));
644        assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable", "unknown"]).is_err());
645        assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable"]).is_err());
646    }
647
648    #[test]
649    fn antigravity_statusline_actions_parse_and_require_an_action() {
650        for (arguments, expected) in [
651            (&["antigravity", "setup-statusline"][..], "setup"),
652            (&["antigravity", "remove-statusline"][..], "remove"),
653            (&["antigravity", "ingest-statusline"][..], "ingest"),
654        ] {
655            let cli = Cli::try_parse_from(
656                std::iter::once("ai-usagebar").chain(arguments.iter().copied()),
657            )
658            .unwrap();
659            assert!(
660                matches!(cli.command, Some(Command::Antigravity { .. })),
661                "{expected}"
662            );
663        }
664        assert!(Cli::try_parse_from(["ai-usagebar", "antigravity"]).is_err());
665    }
666
667    #[test]
668    fn version_flags_report_the_crate_version() {
669        let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
670
671        for flag in ["--version", "-V"] {
672            let err = Cli::try_parse_from(["ai-usagebar", flag])
673                .expect_err("a version flag exits through clap's display path");
674            assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
675            assert_eq!(err.to_string(), expected, "flag: {flag}");
676        }
677    }
678
679    #[test]
680    fn usage_recent_hours_are_bounded_and_explicit() {
681        for hours in ["1", "6", "24"] {
682            let cli = Cli::try_parse_from([
683                "ai-usagebar",
684                "usage",
685                "--json",
686                "--priorities",
687                "--recent-hours",
688                hours,
689            ])
690            .unwrap();
691            assert!(
692                matches!(cli.command, Some(Command::Usage { recent_hours, .. }) if recent_hours == hours.parse::<u8>().unwrap())
693            );
694        }
695        let cli = Cli::try_parse_from([
696            "ai-usagebar",
697            "usage",
698            "--json",
699            "--priorities",
700            "--alert-unused-percent",
701            "30",
702            "--alert-hours",
703            "48",
704        ])
705        .unwrap();
706        assert!(matches!(
707            cli.command,
708            Some(Command::Usage {
709                alert_unused_percent: 30,
710                alert_hours: 48,
711                ..
712            })
713        ));
714        for (flag, value) in [
715            ("--alert-unused-percent", "0"),
716            ("--alert-unused-percent", "101"),
717            ("--alert-hours", "0"),
718            ("--alert-hours", "169"),
719        ] {
720            assert!(
721                Cli::try_parse_from([
722                    "ai-usagebar",
723                    "usage",
724                    "--json",
725                    "--priorities",
726                    flag,
727                    value
728                ])
729                .is_err()
730            );
731        }
732        for hours in ["0", "25", "-1", "1.5", "invalid"] {
733            assert!(
734                Cli::try_parse_from([
735                    "ai-usagebar",
736                    "usage",
737                    "--json",
738                    "--priorities",
739                    "--recent-hours",
740                    hours
741                ])
742                .is_err()
743            );
744        }
745    }
746
747    #[test]
748    fn priorities_are_opt_in_and_only_with_json() {
749        // The plain report stays exactly what every other reader consumes.
750        let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
751        assert!(matches!(
752            cli.command,
753            Some(Command::Usage {
754                priorities: false,
755                ..
756            })
757        ));
758        // Ranking is a JSON field, and its tuning flags mean nothing without it.
759        assert!(Cli::try_parse_from(["ai-usagebar", "usage", "--priorities"]).is_err());
760        for flag in ["--recent-hours", "--alert-unused-percent", "--alert-hours"] {
761            assert!(
762                Cli::try_parse_from(["ai-usagebar", "usage", "--json", flag, "6"]).is_err(),
763                "{flag} requires --priorities"
764            );
765        }
766    }
767
768    #[test]
769    fn usage_subcommand_parses_machine_readable_mode() {
770        let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
771        assert!(matches!(
772            cli.command,
773            Some(Command::Usage {
774                json: true,
775                priorities: false,
776                recent_hours: 3,
777                alert_unused_percent: 50,
778                alert_hours: 24,
779            })
780        ));
781    }
782
783    #[test]
784    fn mcp_subcommand_parses_without_widget_flags() {
785        let cli = Cli::parse_from(["ai-usagebar", "mcp"]);
786        assert!(matches!(cli.command, Some(Command::Mcp)));
787        assert!(Cli::try_parse_from(["ai-usagebar", "--pretty", "mcp"]).is_err());
788    }
789
790    #[test]
791    fn usage_help_states_complete_document_exits_zero() {
792        let err = Cli::try_parse_from(["ai-usagebar", "usage", "--help"])
793            .expect_err("help exits through clap's display path");
794        assert_eq!(err.kind(), ErrorKind::DisplayHelp);
795        let help = err.to_string();
796        assert!(
797            help.contains("Exits 0 after printing a complete document"),
798            "{help}"
799        );
800        assert!(
801            help.contains("even when every entry") && help.contains("error"),
802            "{help}"
803        );
804        assert!(
805            help.contains("Non-zero only when the document cannot be produced"),
806            "{help}"
807        );
808    }
809
810    #[test]
811    fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
812        let bare = Cli::parse_from(["ai-usagebar", "detect"]);
813        assert!(matches!(
814            bare.command,
815            Some(Command::Detect {
816                all: false,
817                json: false
818            })
819        ));
820
821        let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
822        assert!(matches!(
823            full.command,
824            Some(Command::Detect {
825                all: true,
826                json: true
827            })
828        ));
829
830        assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
831    }
832
833    #[test]
834    fn new_vendor_values_and_auth_commands_parse_exactly() {
835        let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
836        assert_eq!(nous.vendor, Some(Vendor::NousResearch));
837        let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
838        assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
839        let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
840        assert_eq!(copilot.vendor, Some(Vendor::Copilot));
841        let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
842        assert!(matches!(login.command, Some(Command::Auth { .. })));
843    }
844
845    #[test]
846    fn settings_subcommands_are_additive_and_take_no_widget_flags() {
847        let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
848        assert!(matches!(
849            show.command,
850            Some(Command::Settings {
851                action: SettingsAction::Show
852            })
853        ));
854
855        let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
856        assert!(matches!(
857            apply.command,
858            Some(Command::Settings {
859                action: SettingsAction::Apply
860            })
861        ));
862
863        assert!(
864            Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
865        );
866    }
867
868    #[test]
869    fn defaults_match_claudebar() {
870        let cli = Cli::parse_from(["ai-usagebar"]);
871        assert_eq!(cli.vendor, None);
872        // Without explicit --vendor, no scroll-cycle override, and default
873        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
874        // rather than `resolved_vendor` so the test never reads the real
875        // ~/.cache/ai-usagebar/active_vendor file.
876        let cfg = crate::config::Config::default();
877        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
878        assert_eq!(cli.pace_tolerance, 5);
879        assert!(cli.format.is_none());
880        assert!(cli.tooltip_format.is_none());
881        assert!(cli.icon.is_none());
882        assert!(!cli.format_pace_color);
883        assert!(!cli.tooltip_pace_pts);
884        assert!(!cli.pretty);
885        assert!(!cli.json);
886        assert!(cli.watch.is_none());
887        assert!(cli.command.is_none());
888    }
889
890    #[test]
891    fn account_add_subcommand_parses_without_widget_flags() {
892        let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
893        assert!(matches!(
894            cli.command,
895            Some(Command::Account {
896                action: AccountAction::Add {
897                    ref label,
898                    no_login: true,
899                    desktop: false,
900                    ..
901                }
902            }) if label == "work"
903        ));
904    }
905
906    /// The two halves of `add` capture different things and cannot be combined:
907    /// `--no-login` skips a `claude` login the Desktop capture never runs.
908    #[test]
909    fn account_add_desktop_takes_an_email_and_rejects_no_login() {
910        let cli = Cli::parse_from([
911            "ai-usagebar",
912            "account",
913            "add",
914            "work",
915            "--desktop",
916            "--email",
917            "a@b.test",
918            "-y",
919        ]);
920        assert!(matches!(
921            cli.command,
922            Some(Command::Account {
923                action: AccountAction::Add {
924                    desktop: true,
925                    yes: true,
926                    email: Some(ref email),
927                    ..
928                }
929            }) if email == "a@b.test"
930        ));
931        assert!(
932            Cli::try_parse_from([
933                "ai-usagebar",
934                "account",
935                "add",
936                "w",
937                "--desktop",
938                "--no-login"
939            ])
940            .is_err()
941        );
942        // --email / -y only mean something for the Desktop capture.
943        assert!(
944            Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
945                .is_err()
946        );
947    }
948
949    #[test]
950    fn account_switch_defaults_to_both_surfaces() {
951        let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
952        assert!(matches!(
953            cli.command,
954            Some(Command::Account {
955                action: AccountAction::Switch {
956                    ref label,
957                    desktop: false,
958                    cli: false,
959                    dry_run: true,
960                    keep_backups: 10,
961                    ..
962                }
963            }) if label == "work"
964        ));
965    }
966
967    #[test]
968    fn account_subcommand_rejects_ignored_widget_flags() {
969        assert!(
970            Cli::try_parse_from([
971                "ai-usagebar",
972                "--vendor",
973                "anthropic",
974                "account",
975                "add",
976                "work",
977            ])
978            .is_err()
979        );
980    }
981
982    #[test]
983    fn multi_account_flags_are_stable_api() {
984        // --cache-dir and --creds-path are the documented multi-account
985        // mechanism (README "Multiple accounts") since they were promoted
986        // from hidden debug flags. Renaming either is a breaking change.
987        let cli = Cli::parse_from([
988            "ai-usagebar",
989            "--vendor",
990            "anthropic",
991            "--cache-dir",
992            "/tmp/acct-a",
993            "--creds-path",
994            "/tmp/acct-a/credentials.json",
995        ]);
996        assert_eq!(
997            cli.cache_dir.as_deref(),
998            Some(std::path::Path::new("/tmp/acct-a"))
999        );
1000        assert_eq!(
1001            cli.creds_path.as_deref(),
1002            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
1003        );
1004    }
1005
1006    #[test]
1007    fn primary_from_config_wins_when_vendor_unset() {
1008        // No --vendor and no scroll-cycle override → [ui] primary wins.
1009        let cli = Cli::parse_from(["ai-usagebar"]);
1010        let mut cfg = crate::config::Config::default();
1011        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1012        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
1013    }
1014
1015    #[test]
1016    fn explicit_vendor_overrides_everything() {
1017        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
1018        // [ui] primary.
1019        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
1020        let mut cfg = crate::config::Config::default();
1021        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1022        let active = Some(crate::vendor::VendorId::Openai);
1023        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
1024    }
1025
1026    #[test]
1027    fn vendor_kimi_parses_to_kimi_variant() {
1028        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
1029        assert_eq!(cli.vendor, Some(Vendor::Kimi));
1030        assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
1031    }
1032
1033    #[test]
1034    fn vendor_grokbot_parses_to_grokbot_variant() {
1035        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "grokbot"]);
1036        assert_eq!(cli.vendor, Some(Vendor::Grokbot));
1037        assert_eq!(
1038            cli.vendor.unwrap().to_id(),
1039            crate::vendor::VendorId::Grokbot
1040        );
1041        // …and back, for the persisted-state resolver.
1042        assert_eq!(
1043            id_to_vendor(crate::vendor::VendorId::Grokbot),
1044            Vendor::Grokbot
1045        );
1046    }
1047
1048    #[test]
1049    fn vendor_modelstudio_parses_to_modelstudio_variant() {
1050        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "modelstudio"]);
1051        assert_eq!(cli.vendor, Some(Vendor::ModelStudio));
1052        assert_eq!(
1053            cli.vendor.unwrap().to_id(),
1054            crate::vendor::VendorId::ModelStudio
1055        );
1056        assert_eq!(
1057            id_to_vendor(crate::vendor::VendorId::ModelStudio),
1058            Vendor::ModelStudio
1059        );
1060    }
1061
1062    #[test]
1063    fn vendor_anthropic_api_uses_the_documented_slug() {
1064        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
1065        assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
1066        assert_eq!(
1067            cli.vendor.unwrap().to_id(),
1068            crate::vendor::VendorId::AnthropicApi
1069        );
1070    }
1071
1072    #[test]
1073    fn every_vendor_parses_from_its_slug() {
1074        // Frontends build `--vendor` from `VendorId::slug()`; a clap name that
1075        // drifts from the slug makes that vendor unreachable from them.
1076        for vendor in <Vendor as clap::ValueEnum>::value_variants() {
1077            let slug = vendor.to_id().slug();
1078            let cli = Cli::parse_from(["ai-usagebar", "--vendor", slug]);
1079            assert_eq!(cli.vendor, Some(*vendor), "{slug}");
1080        }
1081        let legacy = Cli::parse_from(["ai-usagebar", "--vendor", "orca-router"]);
1082        assert_eq!(legacy.vendor, Some(Vendor::OrcaRouter));
1083    }
1084
1085    #[test]
1086    fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
1087        let cli = Cli::parse_from(["ai-usagebar"]);
1088        let mut cfg = crate::config::Config::default();
1089        cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
1090        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
1091    }
1092
1093    #[test]
1094    fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
1095        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
1096        assert_eq!(
1097            cli.resolve_vendor_with(&crate::config::Config::default(), None),
1098            Vendor::Kimi
1099        );
1100    }
1101
1102    #[test]
1103    fn active_override_wins_over_config_primary_when_enabled() {
1104        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
1105        // primary, as long as it is still enabled.
1106        let cli = Cli::parse_from(["ai-usagebar"]);
1107        let mut cfg = crate::config::Config::default();
1108        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1109        let active = Some(crate::vendor::VendorId::Zai);
1110        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
1111    }
1112
1113    #[test]
1114    fn disabled_active_override_falls_back_to_config_primary() {
1115        // A persisted active vendor the user has since disabled is skipped;
1116        // resolution falls through to [ui] primary.
1117        let cli = Cli::parse_from(["ai-usagebar"]);
1118        let mut cfg = crate::config::Config::default();
1119        cfg.zai.enabled = false;
1120        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1121        let active = Some(crate::vendor::VendorId::Zai);
1122        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
1123    }
1124
1125    #[test]
1126    fn claudebar_compatible_flag_surface() {
1127        let cli = Cli::parse_from([
1128            "ai-usagebar",
1129            "--icon",
1130            "󰚩",
1131            "--format",
1132            "{session_pct}% · {session_reset}",
1133            "--tooltip-format",
1134            "S:{session_pct}",
1135            "--pace-tolerance",
1136            "10",
1137            "--format-pace-color",
1138            "--tooltip-pace-pts",
1139            "--color-low",
1140            "#50fa7b",
1141            "--color-mid",
1142            "#f1fa8c",
1143            "--color-high",
1144            "#ffb86c",
1145            "--color-critical",
1146            "#ff5555",
1147        ]);
1148        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
1149        assert_eq!(
1150            cli.format.as_deref(),
1151            Some("{session_pct}% · {session_reset}")
1152        );
1153        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
1154        assert_eq!(cli.pace_tolerance, 10);
1155        assert!(cli.format_pace_color);
1156        assert!(cli.tooltip_pace_pts);
1157        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
1158        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
1159    }
1160
1161    #[test]
1162    fn pretty_and_json_conflict() {
1163        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
1164        assert!(res.is_err());
1165    }
1166
1167    #[test]
1168    fn watch_disables_json_output() {
1169        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
1170        assert_eq!(cli.watch, Some(5));
1171        assert!(!cli.output_json());
1172    }
1173}