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 Settings {
156 #[command(subcommand)]
157 action: SettingsAction,
158 },
159}
160
161#[derive(clap::Subcommand, Debug, Clone)]
162pub enum SettingsAction {
163 Show,
165
166 Apply,
168}
169
170#[derive(clap::Subcommand, Debug, Clone)]
171pub enum AccountAction {
172 Add {
174 label: String,
176
177 #[arg(long, conflicts_with = "desktop")]
179 no_login: bool,
180
181 #[arg(long)]
186 desktop: bool,
187
188 #[arg(long, requires = "desktop")]
191 email: Option<String>,
192
193 #[arg(short = 'y', long, requires = "desktop")]
195 yes: bool,
196 },
197
198 Status {
200 #[arg(long)]
202 json: bool,
203 },
204
205 Switch {
207 label: String,
210
211 #[arg(long)]
213 desktop: bool,
214
215 #[arg(long)]
217 cli: bool,
218
219 #[arg(long)]
221 dry_run: bool,
222
223 #[arg(short = 'y', long)]
225 yes: bool,
226
227 #[arg(long)]
230 force: bool,
231
232 #[arg(long)]
235 keep_bridge: bool,
236
237 #[arg(long)]
240 backup_sessions: bool,
241
242 #[arg(long, default_value_t = 10)]
244 keep_backups: usize,
245
246 #[arg(long, value_name = "KEY")]
253 delete_conflict: Vec<String>,
254 },
255}
256
257#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
258pub enum Vendor {
259 Anthropic,
260 #[value(name = "anthropic_api")]
261 AnthropicApi,
262 Openai,
263 Zai,
264 Openrouter,
265 Deepseek,
266 Kimi,
267 Kilo,
268 Novita,
269 Moonshot,
270 Grok,
271 Supergrok,
272 Antigravity,
273 Cursor,
274 Minimax,
275 Kiro,
276}
277
278impl Vendor {
279 pub fn to_id(self) -> crate::vendor::VendorId {
280 match self {
281 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
282 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
283 Vendor::Openai => crate::vendor::VendorId::Openai,
284 Vendor::Zai => crate::vendor::VendorId::Zai,
285 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
286 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
287 Vendor::Kimi => crate::vendor::VendorId::Kimi,
288 Vendor::Kilo => crate::vendor::VendorId::Kilo,
289 Vendor::Novita => crate::vendor::VendorId::Novita,
290 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
291 Vendor::Grok => crate::vendor::VendorId::Grok,
292 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
293 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
294 Vendor::Cursor => crate::vendor::VendorId::Cursor,
295 Vendor::Minimax => crate::vendor::VendorId::Minimax,
296 Vendor::Kiro => crate::vendor::VendorId::Kiro,
297 }
298 }
299}
300
301impl Cli {
302 pub fn has_explicit_vendor(&self) -> bool {
304 self.vendor.is_some()
305 }
306
307 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
318 let active = if self.has_explicit_vendor() {
324 None
325 } else {
326 crate::active::read()
327 };
328 self.resolve_vendor_with(config, active)
329 }
330
331 pub fn resolve_vendor_with(
336 &self,
337 config: &crate::config::Config,
338 active: Option<crate::vendor::VendorId>,
339 ) -> Vendor {
340 if let Some(v) = self.vendor {
341 return v;
342 }
343 if let Some(id) = active
344 && config.is_enabled(id)
345 {
346 return id_to_vendor(id);
347 }
348 if let Some(id) = config.ui.primary
349 && config.is_enabled(id)
350 {
351 return id_to_vendor(id);
352 }
353 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
354 return Vendor::Anthropic;
355 }
356 config
357 .enabled_vendors()
358 .into_iter()
359 .next()
360 .map(id_to_vendor)
361 .unwrap_or(Vendor::Anthropic)
364 }
365}
366
367fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
368 match id {
369 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
370 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
371 crate::vendor::VendorId::Openai => Vendor::Openai,
372 crate::vendor::VendorId::Zai => Vendor::Zai,
373 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
374 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
375 crate::vendor::VendorId::Kimi => Vendor::Kimi,
376 crate::vendor::VendorId::Kilo => Vendor::Kilo,
377 crate::vendor::VendorId::Novita => Vendor::Novita,
378 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
379 crate::vendor::VendorId::Grok => Vendor::Grok,
380 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
381 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
382 crate::vendor::VendorId::Cursor => Vendor::Cursor,
383 crate::vendor::VendorId::Minimax => Vendor::Minimax,
384 crate::vendor::VendorId::Kiro => Vendor::Kiro,
385 }
386}
387
388impl Cli {
389 pub fn output_json(&self) -> bool {
392 if self.json {
393 return true;
394 }
395 if self.pretty || self.watch.is_some() {
396 return false;
397 }
398 !is_stdout_tty()
400 }
401}
402
403fn is_stdout_tty() -> bool {
404 use std::io::IsTerminal;
405 std::io::stdout().is_terminal()
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use clap::{Parser, error::ErrorKind};
412
413 #[test]
414 fn version_flags_report_the_crate_version() {
415 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
416
417 for flag in ["--version", "-V"] {
418 let err = Cli::try_parse_from(["ai-usagebar", flag])
419 .expect_err("a version flag exits through clap's display path");
420 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
421 assert_eq!(err.to_string(), expected, "flag: {flag}");
422 }
423 }
424
425 #[test]
426 fn usage_subcommand_parses_machine_readable_mode() {
427 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
428 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
429 }
430
431 #[test]
432 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
433 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
434 assert!(matches!(
435 show.command,
436 Some(Command::Settings {
437 action: SettingsAction::Show
438 })
439 ));
440
441 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
442 assert!(matches!(
443 apply.command,
444 Some(Command::Settings {
445 action: SettingsAction::Apply
446 })
447 ));
448
449 assert!(
450 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
451 );
452 }
453
454 #[test]
455 fn defaults_match_claudebar() {
456 let cli = Cli::parse_from(["ai-usagebar"]);
457 assert_eq!(cli.vendor, None);
458 let cfg = crate::config::Config::default();
463 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
464 assert_eq!(cli.pace_tolerance, 5);
465 assert!(cli.format.is_none());
466 assert!(cli.tooltip_format.is_none());
467 assert!(cli.icon.is_none());
468 assert!(!cli.format_pace_color);
469 assert!(!cli.tooltip_pace_pts);
470 assert!(!cli.pretty);
471 assert!(!cli.json);
472 assert!(cli.watch.is_none());
473 assert!(cli.command.is_none());
474 }
475
476 #[test]
477 fn account_add_subcommand_parses_without_widget_flags() {
478 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
479 assert!(matches!(
480 cli.command,
481 Some(Command::Account {
482 action: AccountAction::Add {
483 ref label,
484 no_login: true,
485 desktop: false,
486 ..
487 }
488 }) if label == "work"
489 ));
490 }
491
492 #[test]
495 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
496 let cli = Cli::parse_from([
497 "ai-usagebar",
498 "account",
499 "add",
500 "work",
501 "--desktop",
502 "--email",
503 "a@b.test",
504 "-y",
505 ]);
506 assert!(matches!(
507 cli.command,
508 Some(Command::Account {
509 action: AccountAction::Add {
510 desktop: true,
511 yes: true,
512 email: Some(ref email),
513 ..
514 }
515 }) if email == "a@b.test"
516 ));
517 assert!(
518 Cli::try_parse_from([
519 "ai-usagebar",
520 "account",
521 "add",
522 "w",
523 "--desktop",
524 "--no-login"
525 ])
526 .is_err()
527 );
528 assert!(
530 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
531 .is_err()
532 );
533 }
534
535 #[test]
536 fn account_switch_defaults_to_both_surfaces() {
537 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
538 assert!(matches!(
539 cli.command,
540 Some(Command::Account {
541 action: AccountAction::Switch {
542 ref label,
543 desktop: false,
544 cli: false,
545 dry_run: true,
546 keep_backups: 10,
547 ..
548 }
549 }) if label == "work"
550 ));
551 }
552
553 #[test]
554 fn account_subcommand_rejects_ignored_widget_flags() {
555 assert!(
556 Cli::try_parse_from([
557 "ai-usagebar",
558 "--vendor",
559 "anthropic",
560 "account",
561 "add",
562 "work",
563 ])
564 .is_err()
565 );
566 }
567
568 #[test]
569 fn multi_account_flags_are_stable_api() {
570 let cli = Cli::parse_from([
574 "ai-usagebar",
575 "--vendor",
576 "anthropic",
577 "--cache-dir",
578 "/tmp/acct-a",
579 "--creds-path",
580 "/tmp/acct-a/credentials.json",
581 ]);
582 assert_eq!(
583 cli.cache_dir.as_deref(),
584 Some(std::path::Path::new("/tmp/acct-a"))
585 );
586 assert_eq!(
587 cli.creds_path.as_deref(),
588 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
589 );
590 }
591
592 #[test]
593 fn primary_from_config_wins_when_vendor_unset() {
594 let cli = Cli::parse_from(["ai-usagebar"]);
596 let mut cfg = crate::config::Config::default();
597 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
598 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
599 }
600
601 #[test]
602 fn explicit_vendor_overrides_everything() {
603 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
606 let mut cfg = crate::config::Config::default();
607 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
608 let active = Some(crate::vendor::VendorId::Openai);
609 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
610 }
611
612 #[test]
613 fn vendor_kimi_parses_to_kimi_variant() {
614 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
615 assert_eq!(cli.vendor, Some(Vendor::Kimi));
616 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
617 }
618
619 #[test]
620 fn vendor_anthropic_api_uses_the_documented_slug() {
621 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
622 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
623 assert_eq!(
624 cli.vendor.unwrap().to_id(),
625 crate::vendor::VendorId::AnthropicApi
626 );
627 }
628
629 #[test]
630 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
631 let cli = Cli::parse_from(["ai-usagebar"]);
632 let mut cfg = crate::config::Config::default();
633 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
634 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
635 }
636
637 #[test]
638 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
639 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
640 assert_eq!(
641 cli.resolve_vendor_with(&crate::config::Config::default(), None),
642 Vendor::Kimi
643 );
644 }
645
646 #[test]
647 fn active_override_wins_over_config_primary_when_enabled() {
648 let cli = Cli::parse_from(["ai-usagebar"]);
651 let mut cfg = crate::config::Config::default();
652 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
653 let active = Some(crate::vendor::VendorId::Zai);
654 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
655 }
656
657 #[test]
658 fn disabled_active_override_falls_back_to_config_primary() {
659 let cli = Cli::parse_from(["ai-usagebar"]);
662 let mut cfg = crate::config::Config::default();
663 cfg.zai.enabled = false;
664 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
665 let active = Some(crate::vendor::VendorId::Zai);
666 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
667 }
668
669 #[test]
670 fn claudebar_compatible_flag_surface() {
671 let cli = Cli::parse_from([
672 "ai-usagebar",
673 "--icon",
674 "",
675 "--format",
676 "{session_pct}% · {session_reset}",
677 "--tooltip-format",
678 "S:{session_pct}",
679 "--pace-tolerance",
680 "10",
681 "--format-pace-color",
682 "--tooltip-pace-pts",
683 "--color-low",
684 "#50fa7b",
685 "--color-mid",
686 "#f1fa8c",
687 "--color-high",
688 "#ffb86c",
689 "--color-critical",
690 "#ff5555",
691 ]);
692 assert_eq!(cli.icon.as_deref(), Some(""));
693 assert_eq!(
694 cli.format.as_deref(),
695 Some("{session_pct}% · {session_reset}")
696 );
697 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
698 assert_eq!(cli.pace_tolerance, 10);
699 assert!(cli.format_pace_color);
700 assert!(cli.tooltip_pace_pts);
701 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
702 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
703 }
704
705 #[test]
706 fn pretty_and_json_conflict() {
707 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
708 assert!(res.is_err());
709 }
710
711 #[test]
712 fn watch_disables_json_output() {
713 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
714 assert_eq!(cli.watch, Some(5));
715 assert!(!cli.output_json());
716 }
717}