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