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)]
28pub struct Cli {
29    /// Which vendor to query. When omitted, reads `[ui] primary` from
30    /// `~/.config/ai-usagebar/config.toml`; falls back to `anthropic` if
31    /// neither is set.
32    #[arg(long, value_enum)]
33    pub vendor: Option<Vendor>,
34
35    /// Optional icon prepended to the bar text (Nerd Font glyph / emoji /
36    /// Pango span). claudebar `--icon`.
37    #[arg(long)]
38    pub icon: Option<String>,
39
40    /// Bar-text format string with `{placeholder}` substitutions. Defaults to
41    /// a vendor-specific format (e.g. `{session_pct}% · {session_reset}` for
42    /// Anthropic, `{kimi_weekly_pct}%` for Kimi).
43    #[arg(long)]
44    pub format: Option<String>,
45
46    /// Custom tooltip format. Overrides the default bordered tooltip when
47    /// set; identical placeholder set as `--format`.
48    #[arg(long)]
49    pub tooltip_format: Option<String>,
50
51    /// Tolerance band (in percentage points) for ratio-based pacing icons.
52    #[arg(long, default_value_t = 5)]
53    pub pace_tolerance: u32,
54
55    /// Color pace placeholders individually per window (instead of the
56    /// global usage-based color). Claudebar `--format-pace-color`.
57    #[arg(long)]
58    pub format_pace_color: bool,
59
60    /// Use point-based pacing in the tooltip's pace column (vs ratio-based).
61    /// Also enables an elapsed-position marker on the tooltip progress bars.
62    /// Claudebar `--tooltip-pace-pts`.
63    #[arg(long)]
64    pub tooltip_pace_pts: bool,
65
66    /// Override the low-usage color (#RRGGBB).
67    #[arg(long)]
68    pub color_low: Option<String>,
69    /// Override the mid-usage color (#RRGGBB).
70    #[arg(long)]
71    pub color_mid: Option<String>,
72    /// Override the high-usage color (#RRGGBB).
73    #[arg(long)]
74    pub color_high: Option<String>,
75    /// Override the critical-usage color (#RRGGBB).
76    #[arg(long)]
77    pub color_critical: Option<String>,
78
79    /// Render human-readable terminal output (ANSI colors + box drawing)
80    /// instead of Waybar JSON. Auto-on when stdout is a TTY.
81    #[arg(long)]
82    pub pretty: bool,
83
84    /// Force JSON output even on a TTY (useful when piping into `jq` from
85    /// an interactive shell).
86    #[arg(long, conflicts_with = "pretty")]
87    pub json: bool,
88
89    /// Re-render every N seconds, clearing the screen between ticks. Implies
90    /// `--pretty`. Press Ctrl-C to exit.
91    #[arg(long, value_name = "SECS")]
92    pub watch: Option<u64>,
93
94    /// Cycle the persisted "active vendor" forward and exit. Wire to
95    /// Waybar's `on-scroll-up` to scroll-cycle through enabled vendors.
96    /// Sends SIGRTMIN+13 to waybar afterwards so the bar refreshes
97    /// immediately rather than waiting for the next interval tick.
98    #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
99    pub cycle_next: bool,
100
101    /// Cycle backwards. Wire to `on-scroll-down`.
102    #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
103    pub cycle_prev: bool,
104
105    /// Override the cache directory (default: ~/.cache/ai-usagebar/<vendor>).
106    /// Give each instance its own directory to track multiple accounts of
107    /// the same vendor side by side — see "Multiple accounts" in the README.
108    #[arg(long, value_name = "DIR")]
109    pub cache_dir: Option<std::path::PathBuf>,
110
111    /// Override the Anthropic credentials file (default:
112    /// ~/.claude/.credentials.json, or `[anthropic] credentials_path` from
113    /// config). Only the Anthropic vendor reads this flag. Combine with
114    /// --cache-dir to track multiple Claude accounts — see "Multiple
115    /// accounts" in the README.
116    #[arg(long, value_name = "FILE")]
117    pub creds_path: Option<std::path::PathBuf>,
118
119    /// Select a named Anthropic account from `[[anthropic.accounts]]` in
120    /// config (issue #14). Without it, `--vendor anthropic` uses the default
121    /// account — the singular `[anthropic] credentials_path` — with unchanged
122    /// output and cache path. Anthropic only; conflicts with the lower-level
123    /// `--creds-path` (they both name a credentials file).
124    #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
125    pub account: Option<String>,
126
127    /// Read `--account <LABEL>`'s usage from the Claude **Desktop app's** own
128    /// token instead of a `claude` CLI credential — a saved
129    /// `~/.claude-acc/profiles/<LABEL>` account, no CLI login required (macOS).
130    /// This is how the menu bar shows Desktop accounts in its overview.
131    #[arg(long, requires = "account")]
132    pub desktop: bool,
133
134    /// Administrative command. Omit it to run the normal usage widget.
135    #[command(subcommand)]
136    pub command: Option<Command>,
137}
138
139#[derive(clap::Subcommand, Debug, Clone)]
140pub enum Command {
141    /// Manage named Claude (Anthropic) accounts.
142    Account {
143        #[command(subcommand)]
144        action: AccountAction,
145    },
146
147    /// Quota and time-to-reset for every configured vendor and account.
148    Usage {
149        /// Machine-readable output.
150        #[arg(long)]
151        json: bool,
152    },
153}
154
155#[derive(clap::Subcommand, Debug, Clone)]
156pub enum AccountAction {
157    /// Register an isolated account and open Claude Code to sign it in.
158    Add {
159        /// Stable name used by `--account`, the TUI, and desktop apps.
160        label: String,
161
162        /// Only register the account; do not launch interactive login.
163        #[arg(long, conflicts_with = "desktop")]
164        no_login: bool,
165
166        /// Capture a Claude **Desktop app** account under this label instead
167        /// of a `claude` CLI one (macOS). The app has a single login slot, so
168        /// this signs it out, waits for you to sign in as the new account, and
169        /// saves what it writes. Your current login is restored if you cancel.
170        #[arg(long)]
171        desktop: bool,
172
173        /// E-mail to label a `--desktop` account with. Asked for at the prompt
174        /// if omitted; purely cosmetic, and skipped when not interactive.
175        #[arg(long, requires = "desktop")]
176        email: Option<String>,
177
178        /// Skip the confirmation before signing the Desktop app out.
179        #[arg(short = 'y', long, requires = "desktop")]
180        yes: bool,
181    },
182
183    /// Show which Claude account the Desktop app and the `claude` CLI use.
184    Status {
185        /// Machine-readable output, consumed by the macOS menu bar.
186        #[arg(long)]
187        json: bool,
188    },
189
190    /// Make <LABEL> the active Claude account (macOS).
191    Switch {
192        /// Account to switch to. Desktop profiles come from claude-acc's store;
193        /// CLI accounts from `[[anthropic.accounts]]` / `accounts_dir`.
194        label: String,
195
196        /// Only switch the Claude Desktop app. Neither flag switches both.
197        #[arg(long)]
198        desktop: bool,
199
200        /// Only switch the `claude` CLI's default login.
201        #[arg(long)]
202        cli: bool,
203
204        /// Report what would change and exit without touching anything.
205        #[arg(long)]
206        dry_run: bool,
207
208        /// Skip the confirmation before quitting the Claude Desktop app.
209        #[arg(short = 'y', long)]
210        yes: bool,
211
212        /// Overwrite a `claude` CLI login that belongs to no managed account.
213        /// That login cannot be saved first, so this discards it.
214        #[arg(long)]
215        force: bool,
216
217        /// Keep `bridge-state.json` rather than clearing it. Diagnostic only:
218        /// a stale remote-control session id breaks `/remote-control`.
219        #[arg(long)]
220        keep_bridge: bool,
221
222        /// Also archive the whole session tree, as claude-acc does. Off by
223        /// default because the history merge is additive.
224        #[arg(long)]
225        backup_sessions: bool,
226
227        /// Rollback archives to retain.
228        #[arg(long, default_value_t = 10)]
229        keep_backups: usize,
230
231        /// Confirm that this type-scoped conflict key, deleted in one account
232        /// but still held by another, should be removed everywhere. Repeatable.
233        /// Supplying any suppresses the interactive prompt — keys not listed
234        /// are kept — which is how the macOS menu bar passes an answered dialog
235        /// through.
236        /// `account status --json` lists the candidates as `deletion_conflicts`.
237        #[arg(long, value_name = "KEY")]
238        delete_conflict: Vec<String>,
239    },
240}
241
242#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
243pub enum Vendor {
244    Anthropic,
245    #[value(name = "anthropic_api")]
246    AnthropicApi,
247    Openai,
248    Zai,
249    Openrouter,
250    Deepseek,
251    Kimi,
252    Kilo,
253    Novita,
254    Moonshot,
255    Grok,
256    Supergrok,
257    Antigravity,
258    Cursor,
259    Minimax,
260    Kiro,
261}
262
263impl Vendor {
264    pub fn to_id(self) -> crate::vendor::VendorId {
265        match self {
266            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
267            Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
268            Vendor::Openai => crate::vendor::VendorId::Openai,
269            Vendor::Zai => crate::vendor::VendorId::Zai,
270            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
271            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
272            Vendor::Kimi => crate::vendor::VendorId::Kimi,
273            Vendor::Kilo => crate::vendor::VendorId::Kilo,
274            Vendor::Novita => crate::vendor::VendorId::Novita,
275            Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
276            Vendor::Grok => crate::vendor::VendorId::Grok,
277            Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
278            Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
279            Vendor::Cursor => crate::vendor::VendorId::Cursor,
280            Vendor::Minimax => crate::vendor::VendorId::Minimax,
281            Vendor::Kiro => crate::vendor::VendorId::Kiro,
282        }
283    }
284}
285
286impl Cli {
287    /// Whether the selected vendor came from an explicit `--vendor` opt-in.
288    pub fn has_explicit_vendor(&self) -> bool {
289        self.vendor.is_some()
290    }
291
292    /// Resolve the vendor with full precedence:
293    ///   1. explicit `--vendor` (highest)
294    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
295    ///   3. `[ui] primary` from config
296    ///   4. anthropic (lowest)
297    ///
298    /// This reads the persisted scroll-cycle state from disk via
299    /// [`crate::active::read`]. The pure precedence logic lives in
300    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
301    /// `~/.cache/ai-usagebar/active_vendor`.
302    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
303        // Only consult the scroll-cycle state file when it could actually
304        // matter. An explicit `--vendor` wins outright (precedence #1), so we
305        // skip the disk read entirely in that case — preserving the original
306        // short-circuit and keeping the documented `--vendor` widget config off
307        // the `active_vendor` read path.
308        let active = if self.has_explicit_vendor() {
309            None
310        } else {
311            crate::active::read()
312        };
313        self.resolve_vendor_with(config, active)
314    }
315
316    /// Pure precedence resolution given an explicit scroll-cycle `active`
317    /// override (i.e. whatever [`crate::active::read`] returned). Split out
318    /// from the disk read so tests exercise the precedence rules hermetically
319    /// instead of depending on the developer's real `active_vendor` file.
320    pub fn resolve_vendor_with(
321        &self,
322        config: &crate::config::Config,
323        active: Option<crate::vendor::VendorId>,
324    ) -> Vendor {
325        if let Some(v) = self.vendor {
326            return v;
327        }
328        if let Some(id) = active
329            && config.is_enabled(id)
330        {
331            return id_to_vendor(id);
332        }
333        if let Some(id) = config.ui.primary
334            && config.is_enabled(id)
335        {
336            return id_to_vendor(id);
337        }
338        if config.is_enabled(crate::vendor::VendorId::Anthropic) {
339            return Vendor::Anthropic;
340        }
341        config
342            .enabled_vendors()
343            .into_iter()
344            .next()
345            .map(id_to_vendor)
346            // A completely disabled configuration has no enabled choice; keep
347            // the historic final fallback rather than rejecting widget startup.
348            .unwrap_or(Vendor::Anthropic)
349    }
350}
351
352fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
353    match id {
354        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
355        crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
356        crate::vendor::VendorId::Openai => Vendor::Openai,
357        crate::vendor::VendorId::Zai => Vendor::Zai,
358        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
359        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
360        crate::vendor::VendorId::Kimi => Vendor::Kimi,
361        crate::vendor::VendorId::Kilo => Vendor::Kilo,
362        crate::vendor::VendorId::Novita => Vendor::Novita,
363        crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
364        crate::vendor::VendorId::Grok => Vendor::Grok,
365        crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
366        crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
367        crate::vendor::VendorId::Cursor => Vendor::Cursor,
368        crate::vendor::VendorId::Minimax => Vendor::Minimax,
369        crate::vendor::VendorId::Kiro => Vendor::Kiro,
370    }
371}
372
373impl Cli {
374    /// True when we should emit Waybar JSON. Default behavior: JSON when
375    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
376    pub fn output_json(&self) -> bool {
377        if self.json {
378            return true;
379        }
380        if self.pretty || self.watch.is_some() {
381            return false;
382        }
383        // Auto-detect: emit pretty when stdout is a TTY.
384        !is_stdout_tty()
385    }
386}
387
388fn is_stdout_tty() -> bool {
389    use std::io::IsTerminal;
390    std::io::stdout().is_terminal()
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use clap::{Parser, error::ErrorKind};
397
398    #[test]
399    fn version_flags_report_the_crate_version() {
400        let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
401
402        for flag in ["--version", "-V"] {
403            let err = Cli::try_parse_from(["ai-usagebar", flag])
404                .expect_err("a version flag exits through clap's display path");
405            assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
406            assert_eq!(err.to_string(), expected, "flag: {flag}");
407        }
408    }
409
410    #[test]
411    fn usage_subcommand_parses_machine_readable_mode() {
412        let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
413        assert!(matches!(cli.command, Some(Command::Usage { json: true })));
414    }
415
416    #[test]
417    fn defaults_match_claudebar() {
418        let cli = Cli::parse_from(["ai-usagebar"]);
419        assert_eq!(cli.vendor, None);
420        // Without explicit --vendor, no scroll-cycle override, and default
421        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
422        // rather than `resolved_vendor` so the test never reads the real
423        // ~/.cache/ai-usagebar/active_vendor file.
424        let cfg = crate::config::Config::default();
425        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
426        assert_eq!(cli.pace_tolerance, 5);
427        assert!(cli.format.is_none());
428        assert!(cli.tooltip_format.is_none());
429        assert!(cli.icon.is_none());
430        assert!(!cli.format_pace_color);
431        assert!(!cli.tooltip_pace_pts);
432        assert!(!cli.pretty);
433        assert!(!cli.json);
434        assert!(cli.watch.is_none());
435        assert!(cli.command.is_none());
436    }
437
438    #[test]
439    fn account_add_subcommand_parses_without_widget_flags() {
440        let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
441        assert!(matches!(
442            cli.command,
443            Some(Command::Account {
444                action: AccountAction::Add {
445                    ref label,
446                    no_login: true,
447                    desktop: false,
448                    ..
449                }
450            }) if label == "work"
451        ));
452    }
453
454    /// The two halves of `add` capture different things and cannot be combined:
455    /// `--no-login` skips a `claude` login the Desktop capture never runs.
456    #[test]
457    fn account_add_desktop_takes_an_email_and_rejects_no_login() {
458        let cli = Cli::parse_from([
459            "ai-usagebar",
460            "account",
461            "add",
462            "work",
463            "--desktop",
464            "--email",
465            "a@b.test",
466            "-y",
467        ]);
468        assert!(matches!(
469            cli.command,
470            Some(Command::Account {
471                action: AccountAction::Add {
472                    desktop: true,
473                    yes: true,
474                    email: Some(ref email),
475                    ..
476                }
477            }) if email == "a@b.test"
478        ));
479        assert!(
480            Cli::try_parse_from([
481                "ai-usagebar",
482                "account",
483                "add",
484                "w",
485                "--desktop",
486                "--no-login"
487            ])
488            .is_err()
489        );
490        // --email / -y only mean something for the Desktop capture.
491        assert!(
492            Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
493                .is_err()
494        );
495    }
496
497    #[test]
498    fn account_switch_defaults_to_both_surfaces() {
499        let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
500        assert!(matches!(
501            cli.command,
502            Some(Command::Account {
503                action: AccountAction::Switch {
504                    ref label,
505                    desktop: false,
506                    cli: false,
507                    dry_run: true,
508                    keep_backups: 10,
509                    ..
510                }
511            }) if label == "work"
512        ));
513    }
514
515    #[test]
516    fn account_subcommand_rejects_ignored_widget_flags() {
517        assert!(
518            Cli::try_parse_from([
519                "ai-usagebar",
520                "--vendor",
521                "anthropic",
522                "account",
523                "add",
524                "work",
525            ])
526            .is_err()
527        );
528    }
529
530    #[test]
531    fn multi_account_flags_are_stable_api() {
532        // --cache-dir and --creds-path are the documented multi-account
533        // mechanism (README "Multiple accounts") since they were promoted
534        // from hidden debug flags. Renaming either is a breaking change.
535        let cli = Cli::parse_from([
536            "ai-usagebar",
537            "--vendor",
538            "anthropic",
539            "--cache-dir",
540            "/tmp/acct-a",
541            "--creds-path",
542            "/tmp/acct-a/credentials.json",
543        ]);
544        assert_eq!(
545            cli.cache_dir.as_deref(),
546            Some(std::path::Path::new("/tmp/acct-a"))
547        );
548        assert_eq!(
549            cli.creds_path.as_deref(),
550            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
551        );
552    }
553
554    #[test]
555    fn primary_from_config_wins_when_vendor_unset() {
556        // No --vendor and no scroll-cycle override → [ui] primary wins.
557        let cli = Cli::parse_from(["ai-usagebar"]);
558        let mut cfg = crate::config::Config::default();
559        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
560        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
561    }
562
563    #[test]
564    fn explicit_vendor_overrides_everything() {
565        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
566        // [ui] primary.
567        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
568        let mut cfg = crate::config::Config::default();
569        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
570        let active = Some(crate::vendor::VendorId::Openai);
571        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
572    }
573
574    #[test]
575    fn vendor_kimi_parses_to_kimi_variant() {
576        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
577        assert_eq!(cli.vendor, Some(Vendor::Kimi));
578        assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
579    }
580
581    #[test]
582    fn vendor_anthropic_api_uses_the_documented_slug() {
583        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
584        assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
585        assert_eq!(
586            cli.vendor.unwrap().to_id(),
587            crate::vendor::VendorId::AnthropicApi
588        );
589    }
590
591    #[test]
592    fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
593        let cli = Cli::parse_from(["ai-usagebar"]);
594        let mut cfg = crate::config::Config::default();
595        cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
596        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
597    }
598
599    #[test]
600    fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
601        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
602        assert_eq!(
603            cli.resolve_vendor_with(&crate::config::Config::default(), None),
604            Vendor::Kimi
605        );
606    }
607
608    #[test]
609    fn active_override_wins_over_config_primary_when_enabled() {
610        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
611        // primary, as long as it is still enabled.
612        let cli = Cli::parse_from(["ai-usagebar"]);
613        let mut cfg = crate::config::Config::default();
614        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
615        let active = Some(crate::vendor::VendorId::Zai);
616        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
617    }
618
619    #[test]
620    fn disabled_active_override_falls_back_to_config_primary() {
621        // A persisted active vendor the user has since disabled is skipped;
622        // resolution falls through to [ui] primary.
623        let cli = Cli::parse_from(["ai-usagebar"]);
624        let mut cfg = crate::config::Config::default();
625        cfg.zai.enabled = false;
626        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
627        let active = Some(crate::vendor::VendorId::Zai);
628        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
629    }
630
631    #[test]
632    fn claudebar_compatible_flag_surface() {
633        let cli = Cli::parse_from([
634            "ai-usagebar",
635            "--icon",
636            "󰚩",
637            "--format",
638            "{session_pct}% · {session_reset}",
639            "--tooltip-format",
640            "S:{session_pct}",
641            "--pace-tolerance",
642            "10",
643            "--format-pace-color",
644            "--tooltip-pace-pts",
645            "--color-low",
646            "#50fa7b",
647            "--color-mid",
648            "#f1fa8c",
649            "--color-high",
650            "#ffb86c",
651            "--color-critical",
652            "#ff5555",
653        ]);
654        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
655        assert_eq!(
656            cli.format.as_deref(),
657            Some("{session_pct}% · {session_reset}")
658        );
659        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
660        assert_eq!(cli.pace_tolerance, 10);
661        assert!(cli.format_pace_color);
662        assert!(cli.tooltip_pace_pts);
663        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
664        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
665    }
666
667    #[test]
668    fn pretty_and_json_conflict() {
669        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
670        assert!(res.is_err());
671    }
672
673    #[test]
674    fn watch_disables_json_output() {
675        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
676        assert_eq!(cli.watch, Some(5));
677        assert!(!cli.output_json());
678    }
679}