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 - --config PATH: read and write an alternate config file instead of the
28 default `%APPDATA%/ai-usagebar/config.toml` (Windows) or
29 `~/.config/ai-usagebar/config.toml`. Accepted in any position, before or
30 after the subcommand; the file must already exist, and Settings saves
31 write back to it."
32)]
33pub struct Cli {
34 #[arg(long, value_enum)]
38 pub vendor: Option<Vendor>,
39
40 #[arg(long)]
43 pub icon: Option<String>,
44
45 #[arg(long)]
49 pub format: Option<String>,
50
51 #[arg(long)]
54 pub tooltip_format: Option<String>,
55
56 #[arg(long, default_value_t = 5)]
58 pub pace_tolerance: u32,
59
60 #[arg(long)]
63 pub format_pace_color: bool,
64
65 #[arg(long)]
69 pub tooltip_pace_pts: bool,
70
71 #[arg(long)]
73 pub color_low: Option<String>,
74 #[arg(long)]
76 pub color_mid: Option<String>,
77 #[arg(long)]
79 pub color_high: Option<String>,
80 #[arg(long)]
82 pub color_critical: Option<String>,
83
84 #[arg(long)]
87 pub pretty: bool,
88
89 #[arg(long, conflicts_with = "pretty")]
92 pub json: bool,
93
94 #[arg(long, value_name = "SECS")]
97 pub watch: Option<u64>,
98
99 #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
104 pub cycle_next: bool,
105
106 #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
108 pub cycle_prev: bool,
109
110 #[arg(long, value_name = "DIR")]
114 pub cache_dir: Option<std::path::PathBuf>,
115
116 #[arg(long, value_name = "FILE")]
122 pub creds_path: Option<std::path::PathBuf>,
123
124 #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
129 pub account: Option<String>,
130
131 #[arg(long, requires = "account")]
136 pub desktop: bool,
137
138 #[command(subcommand)]
140 pub command: Option<Command>,
141}
142
143#[derive(clap::Subcommand, Debug, Clone)]
144pub enum Command {
145 Account {
147 #[command(subcommand)]
148 action: AccountAction,
149 },
150
151 Usage {
153 #[arg(long)]
155 json: bool,
156 },
157
158 Detect {
161 #[arg(long)]
163 all: bool,
164 #[arg(long)]
166 json: bool,
167 },
168
169 Vendors {
174 #[arg(long)]
176 json: bool,
177 },
178
179 Settings {
181 #[command(subcommand)]
182 action: SettingsAction,
183 },
184
185 Auth {
187 #[command(subcommand)]
188 provider: AuthProvider,
189 },
190}
191
192#[derive(clap::Subcommand, Debug, Clone)]
193pub enum AuthProvider {
194 Nous {
195 #[command(subcommand)]
196 action: NousAuthAction,
197 },
198}
199
200#[derive(clap::Subcommand, Debug, Clone)]
201pub enum NousAuthAction {
202 Login,
204 Logout,
206}
207
208#[derive(clap::Subcommand, Debug, Clone)]
209pub enum SettingsAction {
210 Show,
212
213 Apply,
215}
216
217#[derive(clap::Subcommand, Debug, Clone)]
218pub enum AccountAction {
219 Add {
221 label: String,
223
224 #[arg(long, conflicts_with = "desktop")]
226 no_login: bool,
227
228 #[arg(long)]
233 desktop: bool,
234
235 #[arg(long, requires = "desktop")]
238 email: Option<String>,
239
240 #[arg(short = 'y', long, requires = "desktop")]
242 yes: bool,
243 },
244
245 Status {
247 #[arg(long)]
249 json: bool,
250 },
251
252 Switch {
254 label: String,
257
258 #[arg(long)]
260 desktop: bool,
261
262 #[arg(long)]
264 cli: bool,
265
266 #[arg(long)]
268 dry_run: bool,
269
270 #[arg(short = 'y', long)]
272 yes: bool,
273
274 #[arg(long)]
277 force: bool,
278
279 #[arg(long)]
282 keep_bridge: bool,
283
284 #[arg(long)]
287 backup_sessions: bool,
288
289 #[arg(long, default_value_t = 10)]
291 keep_backups: usize,
292
293 #[arg(long, value_name = "KEY")]
300 delete_conflict: Vec<String>,
301 },
302}
303
304#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
305pub enum Vendor {
306 Anthropic,
307 #[value(name = "anthropic_api")]
308 AnthropicApi,
309 Openai,
310 Copilot,
311 Zai,
312 Openrouter,
313 Deepseek,
314 Kimi,
315 Kilo,
316 Novita,
317 Moonshot,
318 Grok,
319 Supergrok,
320 Antigravity,
321 Cursor,
322 Minimax,
323 Kiro,
324 #[value(name = "nous")]
325 NousResearch,
326 #[value(name = "opencode-go")]
327 OpenCodeGo,
328 #[value(name = "commandcode")]
329 CommandCode,
330}
331
332impl Vendor {
333 pub fn to_id(self) -> crate::vendor::VendorId {
334 match self {
335 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
336 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
337 Vendor::Openai => crate::vendor::VendorId::Openai,
338 Vendor::Copilot => crate::vendor::VendorId::Copilot,
339 Vendor::Zai => crate::vendor::VendorId::Zai,
340 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
341 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
342 Vendor::Kimi => crate::vendor::VendorId::Kimi,
343 Vendor::Kilo => crate::vendor::VendorId::Kilo,
344 Vendor::Novita => crate::vendor::VendorId::Novita,
345 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
346 Vendor::Grok => crate::vendor::VendorId::Grok,
347 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
348 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
349 Vendor::Cursor => crate::vendor::VendorId::Cursor,
350 Vendor::Minimax => crate::vendor::VendorId::Minimax,
351 Vendor::Kiro => crate::vendor::VendorId::Kiro,
352 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
353 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
354 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
355 }
356 }
357}
358
359impl Cli {
360 pub fn has_explicit_vendor(&self) -> bool {
362 self.vendor.is_some()
363 }
364
365 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
376 let active = if self.has_explicit_vendor() {
382 None
383 } else {
384 crate::active::read()
385 };
386 self.resolve_vendor_with(config, active)
387 }
388
389 pub fn resolve_vendor_with(
394 &self,
395 config: &crate::config::Config,
396 active: Option<crate::vendor::VendorId>,
397 ) -> Vendor {
398 if let Some(v) = self.vendor {
399 return v;
400 }
401 if let Some(id) = active
402 && config.is_enabled(id)
403 {
404 return id_to_vendor(id);
405 }
406 if let Some(id) = config.ui.primary
407 && config.is_enabled(id)
408 {
409 return id_to_vendor(id);
410 }
411 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
412 return Vendor::Anthropic;
413 }
414 config
415 .enabled_vendors()
416 .into_iter()
417 .next()
418 .map(id_to_vendor)
419 .unwrap_or(Vendor::Anthropic)
422 }
423}
424
425fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
426 match id {
427 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
428 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
429 crate::vendor::VendorId::Openai => Vendor::Openai,
430 crate::vendor::VendorId::Copilot => Vendor::Copilot,
431 crate::vendor::VendorId::Zai => Vendor::Zai,
432 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
433 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
434 crate::vendor::VendorId::Kimi => Vendor::Kimi,
435 crate::vendor::VendorId::Kilo => Vendor::Kilo,
436 crate::vendor::VendorId::Novita => Vendor::Novita,
437 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
438 crate::vendor::VendorId::Grok => Vendor::Grok,
439 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
440 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
441 crate::vendor::VendorId::Cursor => Vendor::Cursor,
442 crate::vendor::VendorId::Minimax => Vendor::Minimax,
443 crate::vendor::VendorId::Kiro => Vendor::Kiro,
444 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
445 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
446 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
447 }
448}
449
450impl Cli {
451 pub fn output_json(&self) -> bool {
454 if self.json {
455 return true;
456 }
457 if self.pretty || self.watch.is_some() {
458 return false;
459 }
460 !is_stdout_tty()
462 }
463}
464
465fn is_stdout_tty() -> bool {
466 use std::io::IsTerminal;
467 std::io::stdout().is_terminal()
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use clap::{Parser, error::ErrorKind};
474
475 #[test]
476 fn version_flags_report_the_crate_version() {
477 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
478
479 for flag in ["--version", "-V"] {
480 let err = Cli::try_parse_from(["ai-usagebar", flag])
481 .expect_err("a version flag exits through clap's display path");
482 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
483 assert_eq!(err.to_string(), expected, "flag: {flag}");
484 }
485 }
486
487 #[test]
488 fn usage_subcommand_parses_machine_readable_mode() {
489 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
490 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
491 }
492
493 #[test]
494 fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
495 let bare = Cli::parse_from(["ai-usagebar", "detect"]);
496 assert!(matches!(
497 bare.command,
498 Some(Command::Detect {
499 all: false,
500 json: false
501 })
502 ));
503
504 let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
505 assert!(matches!(
506 full.command,
507 Some(Command::Detect {
508 all: true,
509 json: true
510 })
511 ));
512
513 assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
514 }
515
516 #[test]
517 fn new_vendor_values_and_auth_commands_parse_exactly() {
518 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
519 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
520 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
521 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
522 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
523 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
524 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
525 assert!(matches!(login.command, Some(Command::Auth { .. })));
526 }
527
528 #[test]
529 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
530 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
531 assert!(matches!(
532 show.command,
533 Some(Command::Settings {
534 action: SettingsAction::Show
535 })
536 ));
537
538 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
539 assert!(matches!(
540 apply.command,
541 Some(Command::Settings {
542 action: SettingsAction::Apply
543 })
544 ));
545
546 assert!(
547 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
548 );
549 }
550
551 #[test]
552 fn defaults_match_claudebar() {
553 let cli = Cli::parse_from(["ai-usagebar"]);
554 assert_eq!(cli.vendor, None);
555 let cfg = crate::config::Config::default();
560 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
561 assert_eq!(cli.pace_tolerance, 5);
562 assert!(cli.format.is_none());
563 assert!(cli.tooltip_format.is_none());
564 assert!(cli.icon.is_none());
565 assert!(!cli.format_pace_color);
566 assert!(!cli.tooltip_pace_pts);
567 assert!(!cli.pretty);
568 assert!(!cli.json);
569 assert!(cli.watch.is_none());
570 assert!(cli.command.is_none());
571 }
572
573 #[test]
574 fn account_add_subcommand_parses_without_widget_flags() {
575 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
576 assert!(matches!(
577 cli.command,
578 Some(Command::Account {
579 action: AccountAction::Add {
580 ref label,
581 no_login: true,
582 desktop: false,
583 ..
584 }
585 }) if label == "work"
586 ));
587 }
588
589 #[test]
592 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
593 let cli = Cli::parse_from([
594 "ai-usagebar",
595 "account",
596 "add",
597 "work",
598 "--desktop",
599 "--email",
600 "a@b.test",
601 "-y",
602 ]);
603 assert!(matches!(
604 cli.command,
605 Some(Command::Account {
606 action: AccountAction::Add {
607 desktop: true,
608 yes: true,
609 email: Some(ref email),
610 ..
611 }
612 }) if email == "a@b.test"
613 ));
614 assert!(
615 Cli::try_parse_from([
616 "ai-usagebar",
617 "account",
618 "add",
619 "w",
620 "--desktop",
621 "--no-login"
622 ])
623 .is_err()
624 );
625 assert!(
627 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
628 .is_err()
629 );
630 }
631
632 #[test]
633 fn account_switch_defaults_to_both_surfaces() {
634 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
635 assert!(matches!(
636 cli.command,
637 Some(Command::Account {
638 action: AccountAction::Switch {
639 ref label,
640 desktop: false,
641 cli: false,
642 dry_run: true,
643 keep_backups: 10,
644 ..
645 }
646 }) if label == "work"
647 ));
648 }
649
650 #[test]
651 fn account_subcommand_rejects_ignored_widget_flags() {
652 assert!(
653 Cli::try_parse_from([
654 "ai-usagebar",
655 "--vendor",
656 "anthropic",
657 "account",
658 "add",
659 "work",
660 ])
661 .is_err()
662 );
663 }
664
665 #[test]
666 fn multi_account_flags_are_stable_api() {
667 let cli = Cli::parse_from([
671 "ai-usagebar",
672 "--vendor",
673 "anthropic",
674 "--cache-dir",
675 "/tmp/acct-a",
676 "--creds-path",
677 "/tmp/acct-a/credentials.json",
678 ]);
679 assert_eq!(
680 cli.cache_dir.as_deref(),
681 Some(std::path::Path::new("/tmp/acct-a"))
682 );
683 assert_eq!(
684 cli.creds_path.as_deref(),
685 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
686 );
687 }
688
689 #[test]
690 fn primary_from_config_wins_when_vendor_unset() {
691 let cli = Cli::parse_from(["ai-usagebar"]);
693 let mut cfg = crate::config::Config::default();
694 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
695 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
696 }
697
698 #[test]
699 fn explicit_vendor_overrides_everything() {
700 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
703 let mut cfg = crate::config::Config::default();
704 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
705 let active = Some(crate::vendor::VendorId::Openai);
706 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
707 }
708
709 #[test]
710 fn vendor_kimi_parses_to_kimi_variant() {
711 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
712 assert_eq!(cli.vendor, Some(Vendor::Kimi));
713 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
714 }
715
716 #[test]
717 fn vendor_anthropic_api_uses_the_documented_slug() {
718 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
719 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
720 assert_eq!(
721 cli.vendor.unwrap().to_id(),
722 crate::vendor::VendorId::AnthropicApi
723 );
724 }
725
726 #[test]
727 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
728 let cli = Cli::parse_from(["ai-usagebar"]);
729 let mut cfg = crate::config::Config::default();
730 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
731 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
732 }
733
734 #[test]
735 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
736 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
737 assert_eq!(
738 cli.resolve_vendor_with(&crate::config::Config::default(), None),
739 Vendor::Kimi
740 );
741 }
742
743 #[test]
744 fn active_override_wins_over_config_primary_when_enabled() {
745 let cli = Cli::parse_from(["ai-usagebar"]);
748 let mut cfg = crate::config::Config::default();
749 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
750 let active = Some(crate::vendor::VendorId::Zai);
751 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
752 }
753
754 #[test]
755 fn disabled_active_override_falls_back_to_config_primary() {
756 let cli = Cli::parse_from(["ai-usagebar"]);
759 let mut cfg = crate::config::Config::default();
760 cfg.zai.enabled = false;
761 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
762 let active = Some(crate::vendor::VendorId::Zai);
763 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
764 }
765
766 #[test]
767 fn claudebar_compatible_flag_surface() {
768 let cli = Cli::parse_from([
769 "ai-usagebar",
770 "--icon",
771 "",
772 "--format",
773 "{session_pct}% · {session_reset}",
774 "--tooltip-format",
775 "S:{session_pct}",
776 "--pace-tolerance",
777 "10",
778 "--format-pace-color",
779 "--tooltip-pace-pts",
780 "--color-low",
781 "#50fa7b",
782 "--color-mid",
783 "#f1fa8c",
784 "--color-high",
785 "#ffb86c",
786 "--color-critical",
787 "#ff5555",
788 ]);
789 assert_eq!(cli.icon.as_deref(), Some(""));
790 assert_eq!(
791 cli.format.as_deref(),
792 Some("{session_pct}% · {session_reset}")
793 );
794 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
795 assert_eq!(cli.pace_tolerance, 10);
796 assert!(cli.format_pace_color);
797 assert!(cli.tooltip_pace_pts);
798 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
799 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
800 }
801
802 #[test]
803 fn pretty_and_json_conflict() {
804 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
805 assert!(res.is_err());
806 }
807
808 #[test]
809 fn watch_disables_json_output() {
810 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
811 assert_eq!(cli.watch, Some(5));
812 assert!(!cli.output_json());
813 }
814}