1use clap::{Parser, ValueEnum};
9
10#[derive(Parser, Debug, Clone)]
11#[command(
12 name = "ai-usagebar",
13 version,
14 args_conflicts_with_subcommands = true,
15 about = "Waybar widget and terminal dashboard for multi-provider AI plan usage",
16 long_about = "\
17Drop-in replacement for `claudebar` with multi-vendor support.
18
19Output modes:
20 - Default: Waybar JSON ({text, tooltip, class}). Used when stdout is piped.
21 - --pretty: human-readable terminal output for local testing. Auto-enabled
22 when stdout is a TTY, so just running `ai-usagebar --vendor anthropic`
23 in a terminal Does The Right Thing.
24 - --watch N: like --pretty but refreshes every N seconds, clearing the screen
25 between ticks. Useful while iterating on `--format` or `--tooltip-format`.
26 - --json: force JSON output even when stdout is a TTY (for scripting)."
27)]
28pub struct Cli {
29 #[arg(long, value_enum)]
33 pub vendor: Option<Vendor>,
34
35 #[arg(long)]
38 pub icon: Option<String>,
39
40 #[arg(long)]
44 pub format: Option<String>,
45
46 #[arg(long)]
49 pub tooltip_format: Option<String>,
50
51 #[arg(long, default_value_t = 5)]
53 pub pace_tolerance: u32,
54
55 #[arg(long)]
58 pub format_pace_color: bool,
59
60 #[arg(long)]
64 pub tooltip_pace_pts: bool,
65
66 #[arg(long)]
68 pub color_low: Option<String>,
69 #[arg(long)]
71 pub color_mid: Option<String>,
72 #[arg(long)]
74 pub color_high: Option<String>,
75 #[arg(long)]
77 pub color_critical: Option<String>,
78
79 #[arg(long)]
82 pub pretty: bool,
83
84 #[arg(long, conflicts_with = "pretty")]
87 pub json: bool,
88
89 #[arg(long, value_name = "SECS")]
92 pub watch: Option<u64>,
93
94 #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
99 pub cycle_next: bool,
100
101 #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
103 pub cycle_prev: bool,
104
105 #[arg(long, value_name = "DIR")]
109 pub cache_dir: Option<std::path::PathBuf>,
110
111 #[arg(long, value_name = "FILE")]
117 pub creds_path: Option<std::path::PathBuf>,
118
119 #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
125 pub account: Option<String>,
126
127 #[arg(long, requires = "account")]
132 pub desktop: bool,
133
134 #[command(subcommand)]
136 pub command: Option<Command>,
137}
138
139#[derive(clap::Subcommand, Debug, Clone)]
140pub enum Command {
141 Account {
143 #[command(subcommand)]
144 action: AccountAction,
145 },
146
147 Usage {
149 #[arg(long)]
151 json: bool,
152 },
153}
154
155#[derive(clap::Subcommand, Debug, Clone)]
156pub enum AccountAction {
157 Add {
159 label: String,
161
162 #[arg(long, conflicts_with = "desktop")]
164 no_login: bool,
165
166 #[arg(long)]
171 desktop: bool,
172
173 #[arg(long, requires = "desktop")]
176 email: Option<String>,
177
178 #[arg(short = 'y', long, requires = "desktop")]
180 yes: bool,
181 },
182
183 Status {
185 #[arg(long)]
187 json: bool,
188 },
189
190 Switch {
192 label: String,
195
196 #[arg(long)]
198 desktop: bool,
199
200 #[arg(long)]
202 cli: bool,
203
204 #[arg(long)]
206 dry_run: bool,
207
208 #[arg(short = 'y', long)]
210 yes: bool,
211
212 #[arg(long)]
215 force: bool,
216
217 #[arg(long)]
220 keep_bridge: bool,
221
222 #[arg(long)]
225 backup_sessions: bool,
226
227 #[arg(long, default_value_t = 10)]
229 keep_backups: usize,
230
231 #[arg(long, value_name = "KEY")]
238 delete_conflict: Vec<String>,
239 },
240}
241
242#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
243pub enum Vendor {
244 Anthropic,
245 #[value(name = "anthropic_api")]
246 AnthropicApi,
247 Openai,
248 Zai,
249 Openrouter,
250 Deepseek,
251 Kimi,
252 Kilo,
253 Novita,
254 Moonshot,
255 Grok,
256 Supergrok,
257 Antigravity,
258 Cursor,
259 Minimax,
260 Kiro,
261}
262
263impl Vendor {
264 pub fn to_id(self) -> crate::vendor::VendorId {
265 match self {
266 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
267 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
268 Vendor::Openai => crate::vendor::VendorId::Openai,
269 Vendor::Zai => crate::vendor::VendorId::Zai,
270 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
271 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
272 Vendor::Kimi => crate::vendor::VendorId::Kimi,
273 Vendor::Kilo => crate::vendor::VendorId::Kilo,
274 Vendor::Novita => crate::vendor::VendorId::Novita,
275 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
276 Vendor::Grok => crate::vendor::VendorId::Grok,
277 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
278 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
279 Vendor::Cursor => crate::vendor::VendorId::Cursor,
280 Vendor::Minimax => crate::vendor::VendorId::Minimax,
281 Vendor::Kiro => crate::vendor::VendorId::Kiro,
282 }
283 }
284}
285
286impl Cli {
287 pub fn has_explicit_vendor(&self) -> bool {
289 self.vendor.is_some()
290 }
291
292 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
303 let active = if self.has_explicit_vendor() {
309 None
310 } else {
311 crate::active::read()
312 };
313 self.resolve_vendor_with(config, active)
314 }
315
316 pub fn resolve_vendor_with(
321 &self,
322 config: &crate::config::Config,
323 active: Option<crate::vendor::VendorId>,
324 ) -> Vendor {
325 if let Some(v) = self.vendor {
326 return v;
327 }
328 if let Some(id) = active
329 && config.is_enabled(id)
330 {
331 return id_to_vendor(id);
332 }
333 if let Some(id) = config.ui.primary
334 && config.is_enabled(id)
335 {
336 return id_to_vendor(id);
337 }
338 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
339 return Vendor::Anthropic;
340 }
341 config
342 .enabled_vendors()
343 .into_iter()
344 .next()
345 .map(id_to_vendor)
346 .unwrap_or(Vendor::Anthropic)
349 }
350}
351
352fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
353 match id {
354 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
355 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
356 crate::vendor::VendorId::Openai => Vendor::Openai,
357 crate::vendor::VendorId::Zai => Vendor::Zai,
358 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
359 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
360 crate::vendor::VendorId::Kimi => Vendor::Kimi,
361 crate::vendor::VendorId::Kilo => Vendor::Kilo,
362 crate::vendor::VendorId::Novita => Vendor::Novita,
363 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
364 crate::vendor::VendorId::Grok => Vendor::Grok,
365 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
366 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
367 crate::vendor::VendorId::Cursor => Vendor::Cursor,
368 crate::vendor::VendorId::Minimax => Vendor::Minimax,
369 crate::vendor::VendorId::Kiro => Vendor::Kiro,
370 }
371}
372
373impl Cli {
374 pub fn output_json(&self) -> bool {
377 if self.json {
378 return true;
379 }
380 if self.pretty || self.watch.is_some() {
381 return false;
382 }
383 !is_stdout_tty()
385 }
386}
387
388fn is_stdout_tty() -> bool {
389 use std::io::IsTerminal;
390 std::io::stdout().is_terminal()
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use clap::{Parser, error::ErrorKind};
397
398 #[test]
399 fn version_flags_report_the_crate_version() {
400 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
401
402 for flag in ["--version", "-V"] {
403 let err = Cli::try_parse_from(["ai-usagebar", flag])
404 .expect_err("a version flag exits through clap's display path");
405 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
406 assert_eq!(err.to_string(), expected, "flag: {flag}");
407 }
408 }
409
410 #[test]
411 fn usage_subcommand_parses_machine_readable_mode() {
412 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
413 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
414 }
415
416 #[test]
417 fn defaults_match_claudebar() {
418 let cli = Cli::parse_from(["ai-usagebar"]);
419 assert_eq!(cli.vendor, None);
420 let cfg = crate::config::Config::default();
425 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
426 assert_eq!(cli.pace_tolerance, 5);
427 assert!(cli.format.is_none());
428 assert!(cli.tooltip_format.is_none());
429 assert!(cli.icon.is_none());
430 assert!(!cli.format_pace_color);
431 assert!(!cli.tooltip_pace_pts);
432 assert!(!cli.pretty);
433 assert!(!cli.json);
434 assert!(cli.watch.is_none());
435 assert!(cli.command.is_none());
436 }
437
438 #[test]
439 fn account_add_subcommand_parses_without_widget_flags() {
440 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
441 assert!(matches!(
442 cli.command,
443 Some(Command::Account {
444 action: AccountAction::Add {
445 ref label,
446 no_login: true,
447 desktop: false,
448 ..
449 }
450 }) if label == "work"
451 ));
452 }
453
454 #[test]
457 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
458 let cli = Cli::parse_from([
459 "ai-usagebar",
460 "account",
461 "add",
462 "work",
463 "--desktop",
464 "--email",
465 "a@b.test",
466 "-y",
467 ]);
468 assert!(matches!(
469 cli.command,
470 Some(Command::Account {
471 action: AccountAction::Add {
472 desktop: true,
473 yes: true,
474 email: Some(ref email),
475 ..
476 }
477 }) if email == "a@b.test"
478 ));
479 assert!(
480 Cli::try_parse_from([
481 "ai-usagebar",
482 "account",
483 "add",
484 "w",
485 "--desktop",
486 "--no-login"
487 ])
488 .is_err()
489 );
490 assert!(
492 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
493 .is_err()
494 );
495 }
496
497 #[test]
498 fn account_switch_defaults_to_both_surfaces() {
499 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
500 assert!(matches!(
501 cli.command,
502 Some(Command::Account {
503 action: AccountAction::Switch {
504 ref label,
505 desktop: false,
506 cli: false,
507 dry_run: true,
508 keep_backups: 10,
509 ..
510 }
511 }) if label == "work"
512 ));
513 }
514
515 #[test]
516 fn account_subcommand_rejects_ignored_widget_flags() {
517 assert!(
518 Cli::try_parse_from([
519 "ai-usagebar",
520 "--vendor",
521 "anthropic",
522 "account",
523 "add",
524 "work",
525 ])
526 .is_err()
527 );
528 }
529
530 #[test]
531 fn multi_account_flags_are_stable_api() {
532 let cli = Cli::parse_from([
536 "ai-usagebar",
537 "--vendor",
538 "anthropic",
539 "--cache-dir",
540 "/tmp/acct-a",
541 "--creds-path",
542 "/tmp/acct-a/credentials.json",
543 ]);
544 assert_eq!(
545 cli.cache_dir.as_deref(),
546 Some(std::path::Path::new("/tmp/acct-a"))
547 );
548 assert_eq!(
549 cli.creds_path.as_deref(),
550 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
551 );
552 }
553
554 #[test]
555 fn primary_from_config_wins_when_vendor_unset() {
556 let cli = Cli::parse_from(["ai-usagebar"]);
558 let mut cfg = crate::config::Config::default();
559 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
560 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
561 }
562
563 #[test]
564 fn explicit_vendor_overrides_everything() {
565 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
568 let mut cfg = crate::config::Config::default();
569 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
570 let active = Some(crate::vendor::VendorId::Openai);
571 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
572 }
573
574 #[test]
575 fn vendor_kimi_parses_to_kimi_variant() {
576 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
577 assert_eq!(cli.vendor, Some(Vendor::Kimi));
578 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
579 }
580
581 #[test]
582 fn vendor_anthropic_api_uses_the_documented_slug() {
583 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
584 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
585 assert_eq!(
586 cli.vendor.unwrap().to_id(),
587 crate::vendor::VendorId::AnthropicApi
588 );
589 }
590
591 #[test]
592 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
593 let cli = Cli::parse_from(["ai-usagebar"]);
594 let mut cfg = crate::config::Config::default();
595 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
596 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
597 }
598
599 #[test]
600 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
601 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
602 assert_eq!(
603 cli.resolve_vendor_with(&crate::config::Config::default(), None),
604 Vendor::Kimi
605 );
606 }
607
608 #[test]
609 fn active_override_wins_over_config_primary_when_enabled() {
610 let cli = Cli::parse_from(["ai-usagebar"]);
613 let mut cfg = crate::config::Config::default();
614 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
615 let active = Some(crate::vendor::VendorId::Zai);
616 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
617 }
618
619 #[test]
620 fn disabled_active_override_falls_back_to_config_primary() {
621 let cli = Cli::parse_from(["ai-usagebar"]);
624 let mut cfg = crate::config::Config::default();
625 cfg.zai.enabled = false;
626 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
627 let active = Some(crate::vendor::VendorId::Zai);
628 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
629 }
630
631 #[test]
632 fn claudebar_compatible_flag_surface() {
633 let cli = Cli::parse_from([
634 "ai-usagebar",
635 "--icon",
636 "",
637 "--format",
638 "{session_pct}% · {session_reset}",
639 "--tooltip-format",
640 "S:{session_pct}",
641 "--pace-tolerance",
642 "10",
643 "--format-pace-color",
644 "--tooltip-pace-pts",
645 "--color-low",
646 "#50fa7b",
647 "--color-mid",
648 "#f1fa8c",
649 "--color-high",
650 "#ffb86c",
651 "--color-critical",
652 "#ff5555",
653 ]);
654 assert_eq!(cli.icon.as_deref(), Some(""));
655 assert_eq!(
656 cli.format.as_deref(),
657 Some("{session_pct}% · {session_reset}")
658 );
659 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
660 assert_eq!(cli.pace_tolerance, 10);
661 assert!(cli.format_pace_color);
662 assert!(cli.tooltip_pace_pts);
663 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
664 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
665 }
666
667 #[test]
668 fn pretty_and_json_conflict() {
669 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
670 assert!(res.is_err());
671 }
672
673 #[test]
674 fn watch_disables_json_output() {
675 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
676 assert_eq!(cli.watch, Some(5));
677 assert!(!cli.output_json());
678 }
679}