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    args_conflicts_with_subcommands = true,
14    about = "Waybar widget for AI plan usage (Anthropic / OpenAI / Z.AI / OpenRouter / DeepSeek / Kimi)",
15    long_about = "\
16Drop-in replacement for `claudebar` with multi-vendor support.
17
18Output modes:
19  - Default: Waybar JSON ({text, tooltip, class}). Used when stdout is piped.
20  - --pretty: human-readable terminal output for local testing. Auto-enabled
21    when stdout is a TTY, so just running `ai-usagebar --vendor anthropic`
22    in a terminal Does The Right Thing.
23  - --watch N: like --pretty but refreshes every N seconds, clearing the screen
24    between ticks. Useful while iterating on `--format` or `--tooltip-format`.
25  - --json: force JSON output even when stdout is a TTY (for scripting)."
26)]
27pub struct Cli {
28    /// Which vendor to query. When omitted, reads `[ui] primary` from
29    /// `~/.config/ai-usagebar/config.toml`; falls back to `anthropic` if
30    /// neither is set.
31    #[arg(long, value_enum)]
32    pub vendor: Option<Vendor>,
33
34    /// Optional icon prepended to the bar text (Nerd Font glyph / emoji /
35    /// Pango span). claudebar `--icon`.
36    #[arg(long)]
37    pub icon: Option<String>,
38
39    /// Bar-text format string with `{placeholder}` substitutions. Defaults to
40    /// a vendor-specific format (e.g. `{session_pct}% · {session_reset}` for
41    /// Anthropic, `{kimi_weekly_pct}%` for Kimi).
42    #[arg(long)]
43    pub format: Option<String>,
44
45    /// Custom tooltip format. Overrides the default bordered tooltip when
46    /// set; identical placeholder set as `--format`.
47    #[arg(long)]
48    pub tooltip_format: Option<String>,
49
50    /// Tolerance band (in percentage points) for ratio-based pacing icons.
51    #[arg(long, default_value_t = 5)]
52    pub pace_tolerance: u32,
53
54    /// Color pace placeholders individually per window (instead of the
55    /// global usage-based color). Claudebar `--format-pace-color`.
56    #[arg(long)]
57    pub format_pace_color: bool,
58
59    /// Use point-based pacing in the tooltip's pace column (vs ratio-based).
60    /// Also enables an elapsed-position marker on the tooltip progress bars.
61    /// Claudebar `--tooltip-pace-pts`.
62    #[arg(long)]
63    pub tooltip_pace_pts: bool,
64
65    /// Override the low-usage color (#RRGGBB).
66    #[arg(long)]
67    pub color_low: Option<String>,
68    /// Override the mid-usage color (#RRGGBB).
69    #[arg(long)]
70    pub color_mid: Option<String>,
71    /// Override the high-usage color (#RRGGBB).
72    #[arg(long)]
73    pub color_high: Option<String>,
74    /// Override the critical-usage color (#RRGGBB).
75    #[arg(long)]
76    pub color_critical: Option<String>,
77
78    /// Render human-readable terminal output (ANSI colors + box drawing)
79    /// instead of Waybar JSON. Auto-on when stdout is a TTY.
80    #[arg(long)]
81    pub pretty: bool,
82
83    /// Force JSON output even on a TTY (useful when piping into `jq` from
84    /// an interactive shell).
85    #[arg(long, conflicts_with = "pretty")]
86    pub json: bool,
87
88    /// Re-render every N seconds, clearing the screen between ticks. Implies
89    /// `--pretty`. Press Ctrl-C to exit.
90    #[arg(long, value_name = "SECS")]
91    pub watch: Option<u64>,
92
93    /// Cycle the persisted "active vendor" forward and exit. Wire to
94    /// Waybar's `on-scroll-up` to scroll-cycle through enabled vendors.
95    /// Sends SIGRTMIN+13 to waybar afterwards so the bar refreshes
96    /// immediately rather than waiting for the next interval tick.
97    #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
98    pub cycle_next: bool,
99
100    /// Cycle backwards. Wire to `on-scroll-down`.
101    #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
102    pub cycle_prev: bool,
103
104    /// Override the cache directory (default: ~/.cache/ai-usagebar/<vendor>).
105    /// Give each instance its own directory to track multiple accounts of
106    /// the same vendor side by side — see "Multiple accounts" in the README.
107    #[arg(long, value_name = "DIR")]
108    pub cache_dir: Option<std::path::PathBuf>,
109
110    /// Override the Anthropic credentials file (default:
111    /// ~/.claude/.credentials.json, or `[anthropic] credentials_path` from
112    /// config). Only the Anthropic vendor reads this flag. Combine with
113    /// --cache-dir to track multiple Claude accounts — see "Multiple
114    /// accounts" in the README.
115    #[arg(long, value_name = "FILE")]
116    pub creds_path: Option<std::path::PathBuf>,
117
118    /// Select a named Anthropic account from `[[anthropic.accounts]]` in
119    /// config (issue #14). Without it, `--vendor anthropic` uses the default
120    /// account — the singular `[anthropic] credentials_path` — with unchanged
121    /// output and cache path. Anthropic only; conflicts with the lower-level
122    /// `--creds-path` (they both name a credentials file).
123    #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
124    pub account: Option<String>,
125
126    /// Administrative command. Omit it to run the normal usage widget.
127    #[command(subcommand)]
128    pub command: Option<Command>,
129}
130
131#[derive(clap::Subcommand, Debug, Clone)]
132pub enum Command {
133    /// Manage named Claude (Anthropic) accounts.
134    Account {
135        #[command(subcommand)]
136        action: AccountAction,
137    },
138}
139
140#[derive(clap::Subcommand, Debug, Clone)]
141pub enum AccountAction {
142    /// Register an isolated account and open Claude Code to sign it in.
143    Add {
144        /// Stable name used by `--account`, the TUI, and desktop apps.
145        label: String,
146
147        /// Only register the account; do not launch interactive login.
148        #[arg(long)]
149        no_login: bool,
150    },
151}
152
153#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
154pub enum Vendor {
155    Anthropic,
156    #[value(name = "anthropic_api")]
157    AnthropicApi,
158    Openai,
159    Zai,
160    Openrouter,
161    Deepseek,
162    Kimi,
163    Kilo,
164    Novita,
165    Moonshot,
166    Grok,
167    Antigravity,
168    Cursor,
169}
170
171impl Vendor {
172    pub fn to_id(self) -> crate::vendor::VendorId {
173        match self {
174            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
175            Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
176            Vendor::Openai => crate::vendor::VendorId::Openai,
177            Vendor::Zai => crate::vendor::VendorId::Zai,
178            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
179            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
180            Vendor::Kimi => crate::vendor::VendorId::Kimi,
181            Vendor::Kilo => crate::vendor::VendorId::Kilo,
182            Vendor::Novita => crate::vendor::VendorId::Novita,
183            Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
184            Vendor::Grok => crate::vendor::VendorId::Grok,
185            Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
186            Vendor::Cursor => crate::vendor::VendorId::Cursor,
187        }
188    }
189}
190
191impl Cli {
192    /// Whether the selected vendor came from an explicit `--vendor` opt-in.
193    pub fn has_explicit_vendor(&self) -> bool {
194        self.vendor.is_some()
195    }
196
197    /// Resolve the vendor with full precedence:
198    ///   1. explicit `--vendor` (highest)
199    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
200    ///   3. `[ui] primary` from config
201    ///   4. anthropic (lowest)
202    ///
203    /// This reads the persisted scroll-cycle state from disk via
204    /// [`crate::active::read`]. The pure precedence logic lives in
205    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
206    /// `~/.cache/ai-usagebar/active_vendor`.
207    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
208        // Only consult the scroll-cycle state file when it could actually
209        // matter. An explicit `--vendor` wins outright (precedence #1), so we
210        // skip the disk read entirely in that case — preserving the original
211        // short-circuit and keeping the documented `--vendor` widget config off
212        // the `active_vendor` read path.
213        let active = if self.has_explicit_vendor() {
214            None
215        } else {
216            crate::active::read()
217        };
218        self.resolve_vendor_with(config, active)
219    }
220
221    /// Pure precedence resolution given an explicit scroll-cycle `active`
222    /// override (i.e. whatever [`crate::active::read`] returned). Split out
223    /// from the disk read so tests exercise the precedence rules hermetically
224    /// instead of depending on the developer's real `active_vendor` file.
225    pub fn resolve_vendor_with(
226        &self,
227        config: &crate::config::Config,
228        active: Option<crate::vendor::VendorId>,
229    ) -> Vendor {
230        if let Some(v) = self.vendor {
231            return v;
232        }
233        if let Some(id) = active
234            && config.is_enabled(id)
235        {
236            return id_to_vendor(id);
237        }
238        if let Some(id) = config.ui.primary
239            && config.is_enabled(id)
240        {
241            return id_to_vendor(id);
242        }
243        if config.is_enabled(crate::vendor::VendorId::Anthropic) {
244            return Vendor::Anthropic;
245        }
246        config
247            .enabled_vendors()
248            .into_iter()
249            .next()
250            .map(id_to_vendor)
251            // A completely disabled configuration has no enabled choice; keep
252            // the historic final fallback rather than rejecting widget startup.
253            .unwrap_or(Vendor::Anthropic)
254    }
255}
256
257fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
258    match id {
259        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
260        crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
261        crate::vendor::VendorId::Openai => Vendor::Openai,
262        crate::vendor::VendorId::Zai => Vendor::Zai,
263        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
264        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
265        crate::vendor::VendorId::Kimi => Vendor::Kimi,
266        crate::vendor::VendorId::Kilo => Vendor::Kilo,
267        crate::vendor::VendorId::Novita => Vendor::Novita,
268        crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
269        crate::vendor::VendorId::Grok => Vendor::Grok,
270        crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
271        crate::vendor::VendorId::Cursor => Vendor::Cursor,
272    }
273}
274
275impl Cli {
276    /// True when we should emit Waybar JSON. Default behavior: JSON when
277    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
278    pub fn output_json(&self) -> bool {
279        if self.json {
280            return true;
281        }
282        if self.pretty || self.watch.is_some() {
283            return false;
284        }
285        // Auto-detect: emit pretty when stdout is a TTY.
286        !is_stdout_tty()
287    }
288}
289
290fn is_stdout_tty() -> bool {
291    use std::io::IsTerminal;
292    std::io::stdout().is_terminal()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use clap::Parser;
299
300    #[test]
301    fn defaults_match_claudebar() {
302        let cli = Cli::parse_from(["ai-usagebar"]);
303        assert_eq!(cli.vendor, None);
304        // Without explicit --vendor, no scroll-cycle override, and default
305        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
306        // rather than `resolved_vendor` so the test never reads the real
307        // ~/.cache/ai-usagebar/active_vendor file.
308        let cfg = crate::config::Config::default();
309        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
310        assert_eq!(cli.pace_tolerance, 5);
311        assert!(cli.format.is_none());
312        assert!(cli.tooltip_format.is_none());
313        assert!(cli.icon.is_none());
314        assert!(!cli.format_pace_color);
315        assert!(!cli.tooltip_pace_pts);
316        assert!(!cli.pretty);
317        assert!(!cli.json);
318        assert!(cli.watch.is_none());
319        assert!(cli.command.is_none());
320    }
321
322    #[test]
323    fn account_add_subcommand_parses_without_widget_flags() {
324        let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
325        assert!(matches!(
326            cli.command,
327            Some(Command::Account {
328                action: AccountAction::Add { ref label, no_login: true }
329            }) if label == "work"
330        ));
331    }
332
333    #[test]
334    fn account_subcommand_rejects_ignored_widget_flags() {
335        assert!(
336            Cli::try_parse_from([
337                "ai-usagebar",
338                "--vendor",
339                "anthropic",
340                "account",
341                "add",
342                "work",
343            ])
344            .is_err()
345        );
346    }
347
348    #[test]
349    fn multi_account_flags_are_stable_api() {
350        // --cache-dir and --creds-path are the documented multi-account
351        // mechanism (README "Multiple accounts") since they were promoted
352        // from hidden debug flags. Renaming either is a breaking change.
353        let cli = Cli::parse_from([
354            "ai-usagebar",
355            "--vendor",
356            "anthropic",
357            "--cache-dir",
358            "/tmp/acct-a",
359            "--creds-path",
360            "/tmp/acct-a/credentials.json",
361        ]);
362        assert_eq!(
363            cli.cache_dir.as_deref(),
364            Some(std::path::Path::new("/tmp/acct-a"))
365        );
366        assert_eq!(
367            cli.creds_path.as_deref(),
368            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
369        );
370    }
371
372    #[test]
373    fn primary_from_config_wins_when_vendor_unset() {
374        // No --vendor and no scroll-cycle override → [ui] primary wins.
375        let cli = Cli::parse_from(["ai-usagebar"]);
376        let mut cfg = crate::config::Config::default();
377        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
378        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
379    }
380
381    #[test]
382    fn explicit_vendor_overrides_everything() {
383        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
384        // [ui] primary.
385        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
386        let mut cfg = crate::config::Config::default();
387        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
388        let active = Some(crate::vendor::VendorId::Openai);
389        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
390    }
391
392    #[test]
393    fn vendor_kimi_parses_to_kimi_variant() {
394        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
395        assert_eq!(cli.vendor, Some(Vendor::Kimi));
396        assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
397    }
398
399    #[test]
400    fn vendor_anthropic_api_uses_the_documented_slug() {
401        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
402        assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
403        assert_eq!(
404            cli.vendor.unwrap().to_id(),
405            crate::vendor::VendorId::AnthropicApi
406        );
407    }
408
409    #[test]
410    fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
411        let cli = Cli::parse_from(["ai-usagebar"]);
412        let mut cfg = crate::config::Config::default();
413        cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
414        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
415    }
416
417    #[test]
418    fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
419        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
420        assert_eq!(
421            cli.resolve_vendor_with(&crate::config::Config::default(), None),
422            Vendor::Kimi
423        );
424    }
425
426    #[test]
427    fn active_override_wins_over_config_primary_when_enabled() {
428        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
429        // primary, as long as it is still enabled.
430        let cli = Cli::parse_from(["ai-usagebar"]);
431        let mut cfg = crate::config::Config::default();
432        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
433        let active = Some(crate::vendor::VendorId::Zai);
434        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
435    }
436
437    #[test]
438    fn disabled_active_override_falls_back_to_config_primary() {
439        // A persisted active vendor the user has since disabled is skipped;
440        // resolution falls through to [ui] primary.
441        let cli = Cli::parse_from(["ai-usagebar"]);
442        let mut cfg = crate::config::Config::default();
443        cfg.zai.enabled = false;
444        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
445        let active = Some(crate::vendor::VendorId::Zai);
446        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
447    }
448
449    #[test]
450    fn claudebar_compatible_flag_surface() {
451        let cli = Cli::parse_from([
452            "ai-usagebar",
453            "--icon",
454            "󰚩",
455            "--format",
456            "{session_pct}% · {session_reset}",
457            "--tooltip-format",
458            "S:{session_pct}",
459            "--pace-tolerance",
460            "10",
461            "--format-pace-color",
462            "--tooltip-pace-pts",
463            "--color-low",
464            "#50fa7b",
465            "--color-mid",
466            "#f1fa8c",
467            "--color-high",
468            "#ffb86c",
469            "--color-critical",
470            "#ff5555",
471        ]);
472        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
473        assert_eq!(
474            cli.format.as_deref(),
475            Some("{session_pct}% · {session_reset}")
476        );
477        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
478        assert_eq!(cli.pace_tolerance, 10);
479        assert!(cli.format_pace_color);
480        assert!(cli.tooltip_pace_pts);
481        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
482        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
483    }
484
485    #[test]
486    fn pretty_and_json_conflict() {
487        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
488        assert!(res.is_err());
489    }
490
491    #[test]
492    fn watch_disables_json_output() {
493        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
494        assert_eq!(cli.watch, Some(5));
495        assert!(!cli.output_json());
496    }
497}