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)",
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.
39    /// Defaults to `{session_pct}% · {session_reset}`.
40    #[arg(long)]
41    pub format: Option<String>,
42
43    /// Custom tooltip format. Overrides the default bordered tooltip when
44    /// set; identical placeholder set as `--format`.
45    #[arg(long)]
46    pub tooltip_format: Option<String>,
47
48    /// Tolerance band (in percentage points) for ratio-based pacing icons.
49    #[arg(long, default_value_t = 5)]
50    pub pace_tolerance: u32,
51
52    /// Color pace placeholders individually per window (instead of the
53    /// global usage-based color). Claudebar `--format-pace-color`.
54    #[arg(long)]
55    pub format_pace_color: bool,
56
57    /// Use point-based pacing in the tooltip's pace column (vs ratio-based).
58    /// Also enables an elapsed-position marker on the tooltip progress bars.
59    /// Claudebar `--tooltip-pace-pts`.
60    #[arg(long)]
61    pub tooltip_pace_pts: bool,
62
63    /// Override the low-usage color (#RRGGBB).
64    #[arg(long)]
65    pub color_low: Option<String>,
66    /// Override the mid-usage color (#RRGGBB).
67    #[arg(long)]
68    pub color_mid: Option<String>,
69    /// Override the high-usage color (#RRGGBB).
70    #[arg(long)]
71    pub color_high: Option<String>,
72    /// Override the critical-usage color (#RRGGBB).
73    #[arg(long)]
74    pub color_critical: Option<String>,
75
76    /// Render human-readable terminal output (ANSI colors + box drawing)
77    /// instead of Waybar JSON. Auto-on when stdout is a TTY.
78    #[arg(long)]
79    pub pretty: bool,
80
81    /// Force JSON output even on a TTY (useful when piping into `jq` from
82    /// an interactive shell).
83    #[arg(long, conflicts_with = "pretty")]
84    pub json: bool,
85
86    /// Re-render every N seconds, clearing the screen between ticks. Implies
87    /// `--pretty`. Press Ctrl-C to exit.
88    #[arg(long, value_name = "SECS")]
89    pub watch: Option<u64>,
90
91    /// Cycle the persisted "active vendor" forward and exit. Wire to
92    /// Waybar's `on-scroll-up` to scroll-cycle through enabled vendors.
93    /// Sends SIGRTMIN+13 to waybar afterwards so the bar refreshes
94    /// immediately rather than waiting for the next interval tick.
95    #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
96    pub cycle_next: bool,
97
98    /// Cycle backwards. Wire to `on-scroll-down`.
99    #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
100    pub cycle_prev: bool,
101
102    /// Override the cache directory (default: ~/.cache/ai-usagebar/<vendor>).
103    /// Give each instance its own directory to track multiple accounts of
104    /// the same vendor side by side — see "Multiple accounts" in the README.
105    #[arg(long, value_name = "DIR")]
106    pub cache_dir: Option<std::path::PathBuf>,
107
108    /// Override the Anthropic credentials file (default:
109    /// ~/.claude/.credentials.json, or `[anthropic] credentials_path` from
110    /// config). Only the Anthropic vendor reads this flag. Combine with
111    /// --cache-dir to track multiple Claude accounts — see "Multiple
112    /// accounts" in the README.
113    #[arg(long, value_name = "FILE")]
114    pub creds_path: Option<std::path::PathBuf>,
115}
116
117#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
118pub enum Vendor {
119    Anthropic,
120    Openai,
121    Zai,
122    Openrouter,
123    Deepseek,
124}
125
126impl Vendor {
127    pub fn to_id(self) -> crate::vendor::VendorId {
128        match self {
129            Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
130            Vendor::Openai => crate::vendor::VendorId::Openai,
131            Vendor::Zai => crate::vendor::VendorId::Zai,
132            Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
133            Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
134        }
135    }
136}
137
138impl Cli {
139    /// Resolve the vendor with full precedence:
140    ///   1. explicit `--vendor` (highest)
141    ///   2. persisted scroll-cycle state (`~/.cache/ai-usagebar/active_vendor`)
142    ///   3. `[ui] primary` from config
143    ///   4. anthropic (lowest)
144    ///
145    /// This reads the persisted scroll-cycle state from disk via
146    /// [`crate::active::read`]. The pure precedence logic lives in
147    /// [`Cli::resolve_vendor_with`] so it can be unit-tested without touching
148    /// `~/.cache/ai-usagebar/active_vendor`.
149    pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
150        // Only consult the scroll-cycle state file when it could actually
151        // matter. An explicit `--vendor` wins outright (precedence #1), so we
152        // skip the disk read entirely in that case — preserving the original
153        // short-circuit and keeping the documented `--vendor` widget config off
154        // the `active_vendor` read path.
155        let active = if self.vendor.is_some() {
156            None
157        } else {
158            crate::active::read()
159        };
160        self.resolve_vendor_with(config, active)
161    }
162
163    /// Pure precedence resolution given an explicit scroll-cycle `active`
164    /// override (i.e. whatever [`crate::active::read`] returned). Split out
165    /// from the disk read so tests exercise the precedence rules hermetically
166    /// instead of depending on the developer's real `active_vendor` file.
167    pub fn resolve_vendor_with(
168        &self,
169        config: &crate::config::Config,
170        active: Option<crate::vendor::VendorId>,
171    ) -> Vendor {
172        if let Some(v) = self.vendor {
173            return v;
174        }
175        if let Some(id) = active
176            && config.is_enabled(id)
177        {
178            return id_to_vendor(id);
179        }
180        match config.ui.primary {
181            Some(id) => id_to_vendor(id),
182            None => Vendor::Anthropic,
183        }
184    }
185}
186
187fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
188    match id {
189        crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
190        crate::vendor::VendorId::Openai => Vendor::Openai,
191        crate::vendor::VendorId::Zai => Vendor::Zai,
192        crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
193        crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
194    }
195}
196
197impl Cli {
198    /// True when we should emit Waybar JSON. Default behavior: JSON when
199    /// stdout is piped, pretty when on a TTY (unless `--json` is set).
200    pub fn output_json(&self) -> bool {
201        if self.json {
202            return true;
203        }
204        if self.pretty || self.watch.is_some() {
205            return false;
206        }
207        // Auto-detect: emit pretty when stdout is a TTY.
208        !is_stdout_tty()
209    }
210}
211
212fn is_stdout_tty() -> bool {
213    use std::io::IsTerminal;
214    std::io::stdout().is_terminal()
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use clap::Parser;
221
222    #[test]
223    fn defaults_match_claudebar() {
224        let cli = Cli::parse_from(["ai-usagebar"]);
225        assert_eq!(cli.vendor, None);
226        // Without explicit --vendor, no scroll-cycle override, and default
227        // config, resolve to anthropic. Use `resolve_vendor_with(.., None)`
228        // rather than `resolved_vendor` so the test never reads the real
229        // ~/.cache/ai-usagebar/active_vendor file.
230        let cfg = crate::config::Config::default();
231        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
232        assert_eq!(cli.pace_tolerance, 5);
233        assert!(cli.format.is_none());
234        assert!(cli.tooltip_format.is_none());
235        assert!(cli.icon.is_none());
236        assert!(!cli.format_pace_color);
237        assert!(!cli.tooltip_pace_pts);
238        assert!(!cli.pretty);
239        assert!(!cli.json);
240        assert!(cli.watch.is_none());
241    }
242
243    #[test]
244    fn multi_account_flags_are_stable_api() {
245        // --cache-dir and --creds-path are the documented multi-account
246        // mechanism (README "Multiple accounts") since they were promoted
247        // from hidden debug flags. Renaming either is a breaking change.
248        let cli = Cli::parse_from([
249            "ai-usagebar",
250            "--vendor",
251            "anthropic",
252            "--cache-dir",
253            "/tmp/acct-a",
254            "--creds-path",
255            "/tmp/acct-a/credentials.json",
256        ]);
257        assert_eq!(
258            cli.cache_dir.as_deref(),
259            Some(std::path::Path::new("/tmp/acct-a"))
260        );
261        assert_eq!(
262            cli.creds_path.as_deref(),
263            Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
264        );
265    }
266
267    #[test]
268    fn primary_from_config_wins_when_vendor_unset() {
269        // No --vendor and no scroll-cycle override → [ui] primary wins.
270        let cli = Cli::parse_from(["ai-usagebar"]);
271        let mut cfg = crate::config::Config::default();
272        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
273        assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
274    }
275
276    #[test]
277    fn explicit_vendor_overrides_everything() {
278        // Explicit --vendor beats BOTH a persisted scroll-cycle override and
279        // [ui] primary.
280        let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
281        let mut cfg = crate::config::Config::default();
282        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
283        let active = Some(crate::vendor::VendorId::Openai);
284        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
285    }
286
287    #[test]
288    fn active_override_wins_over_config_primary_when_enabled() {
289        // Precedence rule #2: a persisted scroll-cycle vendor beats [ui]
290        // primary, as long as it is still enabled.
291        let cli = Cli::parse_from(["ai-usagebar"]);
292        let mut cfg = crate::config::Config::default();
293        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
294        let active = Some(crate::vendor::VendorId::Zai);
295        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
296    }
297
298    #[test]
299    fn disabled_active_override_falls_back_to_config_primary() {
300        // A persisted active vendor the user has since disabled is skipped;
301        // resolution falls through to [ui] primary.
302        let cli = Cli::parse_from(["ai-usagebar"]);
303        let mut cfg = crate::config::Config::default();
304        cfg.zai.enabled = false;
305        cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
306        let active = Some(crate::vendor::VendorId::Zai);
307        assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
308    }
309
310    #[test]
311    fn claudebar_compatible_flag_surface() {
312        let cli = Cli::parse_from([
313            "ai-usagebar",
314            "--icon",
315            "󰚩",
316            "--format",
317            "{session_pct}% · {session_reset}",
318            "--tooltip-format",
319            "S:{session_pct}",
320            "--pace-tolerance",
321            "10",
322            "--format-pace-color",
323            "--tooltip-pace-pts",
324            "--color-low",
325            "#50fa7b",
326            "--color-mid",
327            "#f1fa8c",
328            "--color-high",
329            "#ffb86c",
330            "--color-critical",
331            "#ff5555",
332        ]);
333        assert_eq!(cli.icon.as_deref(), Some("󰚩"));
334        assert_eq!(
335            cli.format.as_deref(),
336            Some("{session_pct}% · {session_reset}")
337        );
338        assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
339        assert_eq!(cli.pace_tolerance, 10);
340        assert!(cli.format_pace_color);
341        assert!(cli.tooltip_pace_pts);
342        assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
343        assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
344    }
345
346    #[test]
347    fn pretty_and_json_conflict() {
348        let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
349        assert!(res.is_err());
350    }
351
352    #[test]
353    fn watch_disables_json_output() {
354        let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
355        assert_eq!(cli.watch, Some(5));
356        assert!(!cli.output_json());
357    }
358}