1use 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 #[arg(long, value_enum)]
31 pub vendor: Option<Vendor>,
32
33 #[arg(long)]
36 pub icon: Option<String>,
37
38 #[arg(long)]
42 pub format: Option<String>,
43
44 #[arg(long)]
47 pub tooltip_format: Option<String>,
48
49 #[arg(long, default_value_t = 5)]
51 pub pace_tolerance: u32,
52
53 #[arg(long)]
56 pub format_pace_color: bool,
57
58 #[arg(long)]
62 pub tooltip_pace_pts: bool,
63
64 #[arg(long)]
66 pub color_low: Option<String>,
67 #[arg(long)]
69 pub color_mid: Option<String>,
70 #[arg(long)]
72 pub color_high: Option<String>,
73 #[arg(long)]
75 pub color_critical: Option<String>,
76
77 #[arg(long)]
80 pub pretty: bool,
81
82 #[arg(long, conflicts_with = "pretty")]
85 pub json: bool,
86
87 #[arg(long, value_name = "SECS")]
90 pub watch: Option<u64>,
91
92 #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
97 pub cycle_next: bool,
98
99 #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
101 pub cycle_prev: bool,
102
103 #[arg(long, value_name = "DIR")]
107 pub cache_dir: Option<std::path::PathBuf>,
108
109 #[arg(long, value_name = "FILE")]
115 pub creds_path: Option<std::path::PathBuf>,
116
117 #[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 pub fn has_explicit_vendor(&self) -> bool {
167 self.vendor.is_some()
168 }
169
170 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
181 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 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 .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 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 !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 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 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 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 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 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 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}