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