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