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