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    Openai,
130    Zai,
131    Openrouter,
132    Deepseek,
133    Kimi,
134}
135
136impl Vendor {
137    pub fn to_id(self) -> crate::vendor::VendorId {
138        match self {
139            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
140            Vendor::Openai => crate::vendor::VendorId::Openai,
141            Vendor::Zai => crate::vendor::VendorId::Zai,
142            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
143            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
144            Vendor::Kimi => crate::vendor::VendorId::Kimi,
145        }
146    }
147}
148
149impl Cli {
150    /// Whether the selected vendor came from an explicit `--vendor` opt-in.
151    pub fn has_explicit_vendor(&self) -> bool {
152        self.vendor.is_some()
153    }
154
155    /// Resolve the vendor with full precedence:
156    ///   1. explicit `--vendor` (highest)
157    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
158    ///   3. `[ui] primary` from config
159    ///   4. anthropic (lowest)
160    ///
161    /// This reads the persisted scroll-cycle state from disk via
162    /// [`crate::active::read`]. The pure precedence logic lives in
163    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
164    /// `~/.cache/ai-usagebar/active_vendor`.
165    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
166        // Only consult the scroll-cycle state file when it could actually
167        // matter. An explicit `--vendor` wins outright (precedence #1), so we
168        // skip the disk read entirely in that case — preserving the original
169        // short-circuit and keeping the documented `--vendor` widget config off
170        // the `active_vendor` read path.
171        let active = if self.has_explicit_vendor() {
172            None
173        } else {
174            crate::active::read()
175        };
176        self.resolve_vendor_with(config, active)
177    }
178
179    /// Pure precedence resolution given an explicit scroll-cycle `active`
180    /// override (i.e. whatever [`crate::active::read`] returned). Split out
181    /// from the disk read so tests exercise the precedence rules hermetically
182    /// instead of depending on the developer's real `active_vendor` file.
183    pub fn resolve_vendor_with(
184        &self,
185        config: &crate::config::Config,
186        active: Option<crate::vendor::VendorId>,
187    ) -> Vendor {
188        if let Some(v) = self.vendor {
189            return v;
190        }
191        if let Some(id) = active
192            && config.is_enabled(id)
193        {
194            return id_to_vendor(id);
195        }
196        if let Some(id) = config.ui.primary
197            && config.is_enabled(id)
198        {
199            return id_to_vendor(id);
200        }
201        if config.is_enabled(crate::vendor::VendorId::Anthropic) {
202            return Vendor::Anthropic;
203        }
204        config
205            .enabled_vendors()
206            .into_iter()
207            .next()
208            .map(id_to_vendor)
209            // A completely disabled configuration has no enabled choice; keep
210            // the historic final fallback rather than rejecting widget startup.
211            .unwrap_or(Vendor::Anthropic)
212    }
213}
214
215fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
216    match id {
217        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
218        crate::vendor::VendorId::Openai => Vendor::Openai,
219        crate::vendor::VendorId::Zai => Vendor::Zai,
220        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
221        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
222        crate::vendor::VendorId::Kimi => Vendor::Kimi,
223    }
224}
225
226impl Cli {
227    /// True when we should emit Waybar JSON. Default behavior: JSON when
228    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
229    pub fn output_json(&self) -> bool {
230        if self.json {
231            return true;
232        }
233        if self.pretty || self.watch.is_some() {
234            return false;
235        }
236        // Auto-detect: emit pretty when stdout is a TTY.
237        !is_stdout_tty()
238    }
239}
240
241fn is_stdout_tty() -> bool {
242    use std::io::IsTerminal;
243    std::io::stdout().is_terminal()
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use clap::Parser;
250
251    #[test]
252    fn defaults_match_claudebar() {
253        let cli = Cli::parse_from(["ai-usagebar"]);
254        assert_eq!(cli.vendor, None);
255        // Without explicit --vendor, no scroll-cycle override, and default
256        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
257        // rather than `resolved_vendor` so the test never reads the real
258        // ~/.cache/ai-usagebar/active_vendor file.
259        let cfg = crate::config::Config::default();
260        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
261        assert_eq!(cli.pace_tolerance, 5);
262        assert!(cli.format.is_none());
263        assert!(cli.tooltip_format.is_none());
264        assert!(cli.icon.is_none());
265        assert!(!cli.format_pace_color);
266        assert!(!cli.tooltip_pace_pts);
267        assert!(!cli.pretty);
268        assert!(!cli.json);
269        assert!(cli.watch.is_none());
270    }
271
272    #[test]
273    fn multi_account_flags_are_stable_api() {
274        // --cache-dir and --creds-path are the documented multi-account
275        // mechanism (README "Multiple accounts") since they were promoted
276        // from hidden debug flags. Renaming either is a breaking change.
277        let cli = Cli::parse_from([
278            "ai-usagebar",
279            "--vendor",
280            "anthropic",
281            "--cache-dir",
282            "/tmp/acct-a",
283            "--creds-path",
284            "/tmp/acct-a/credentials.json",
285        ]);
286        assert_eq!(
287            cli.cache_dir.as_deref(),
288            Some(std::path::Path::new("/tmp/acct-a"))
289        );
290        assert_eq!(
291            cli.creds_path.as_deref(),
292            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
293        );
294    }
295
296    #[test]
297    fn primary_from_config_wins_when_vendor_unset() {
298        // No --vendor and no scroll-cycle override → [ui] primary wins.
299        let cli = Cli::parse_from(["ai-usagebar"]);
300        let mut cfg = crate::config::Config::default();
301        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
302        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
303    }
304
305    #[test]
306    fn explicit_vendor_overrides_everything() {
307        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
308        // [ui] primary.
309        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
310        let mut cfg = crate::config::Config::default();
311        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
312        let active = Some(crate::vendor::VendorId::Openai);
313        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
314    }
315
316    #[test]
317    fn vendor_kimi_parses_to_kimi_variant() {
318        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
319        assert_eq!(cli.vendor, Some(Vendor::Kimi));
320        assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
321    }
322
323    #[test]
324    fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
325        let cli = Cli::parse_from(["ai-usagebar"]);
326        let mut cfg = crate::config::Config::default();
327        cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
328        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
329    }
330
331    #[test]
332    fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
333        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
334        assert_eq!(
335            cli.resolve_vendor_with(&crate::config::Config::default(), None),
336            Vendor::Kimi
337        );
338    }
339
340    #[test]
341    fn active_override_wins_over_config_primary_when_enabled() {
342        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
343        // primary, as long as it is still enabled.
344        let cli = Cli::parse_from(["ai-usagebar"]);
345        let mut cfg = crate::config::Config::default();
346        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
347        let active = Some(crate::vendor::VendorId::Zai);
348        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
349    }
350
351    #[test]
352    fn disabled_active_override_falls_back_to_config_primary() {
353        // A persisted active vendor the user has since disabled is skipped;
354        // resolution falls through to [ui] primary.
355        let cli = Cli::parse_from(["ai-usagebar"]);
356        let mut cfg = crate::config::Config::default();
357        cfg.zai.enabled = false;
358        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
359        let active = Some(crate::vendor::VendorId::Zai);
360        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
361    }
362
363    #[test]
364    fn claudebar_compatible_flag_surface() {
365        let cli = Cli::parse_from([
366            "ai-usagebar",
367            "--icon",
368            "󰚩",
369            "--format",
370            "{session_pct}% · {session_reset}",
371            "--tooltip-format",
372            "S:{session_pct}",
373            "--pace-tolerance",
374            "10",
375            "--format-pace-color",
376            "--tooltip-pace-pts",
377            "--color-low",
378            "#50fa7b",
379            "--color-mid",
380            "#f1fa8c",
381            "--color-high",
382            "#ffb86c",
383            "--color-critical",
384            "#ff5555",
385        ]);
386        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
387        assert_eq!(
388            cli.format.as_deref(),
389            Some("{session_pct}% · {session_reset}")
390        );
391        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
392        assert_eq!(cli.pace_tolerance, 10);
393        assert!(cli.format_pace_color);
394        assert!(cli.tooltip_pace_pts);
395        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
396        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
397    }
398
399    #[test]
400    fn pretty_and_json_conflict() {
401        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
402        assert!(res.is_err());
403    }
404
405    #[test]
406    fn watch_disables_json_output() {
407        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
408        assert_eq!(cli.watch, Some(5));
409        assert!(!cli.output_json());
410    }
411}