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 Enable {
212 #[arg(value_enum)]
213 vendor: Vendor,
214 },
215
216 Show,
218
219 Apply,
221}
222
223#[derive(clap::Subcommand, Debug, Clone)]
224pub enum AccountAction {
225 Add {
227 label: String,
229
230 #[arg(long, conflicts_with = "desktop")]
232 no_login: bool,
233
234 #[arg(long)]
239 desktop: bool,
240
241 #[arg(long, requires = "desktop")]
244 email: Option<String>,
245
246 #[arg(short = 'y', long, requires = "desktop")]
248 yes: bool,
249 },
250
251 Status {
253 #[arg(long)]
255 json: bool,
256 },
257
258 Switch {
260 label: String,
263
264 #[arg(long)]
266 desktop: bool,
267
268 #[arg(long)]
270 cli: bool,
271
272 #[arg(long)]
274 dry_run: bool,
275
276 #[arg(short = 'y', long)]
278 yes: bool,
279
280 #[arg(long)]
283 force: bool,
284
285 #[arg(long)]
288 keep_bridge: bool,
289
290 #[arg(long)]
293 backup_sessions: bool,
294
295 #[arg(long, default_value_t = 10)]
297 keep_backups: usize,
298
299 #[arg(long, value_name = "KEY")]
306 delete_conflict: Vec<String>,
307 },
308}
309
310#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
311pub enum Vendor {
312 Anthropic,
313 #[value(name = "anthropic_api")]
314 AnthropicApi,
315 Openai,
316 Copilot,
317 Zai,
318 Openrouter,
319 Deepseek,
320 Kimi,
321 Kilo,
322 Novita,
323 Moonshot,
324 Grok,
325 Supergrok,
326 Grokbot,
327 Antigravity,
328 Cursor,
329 Minimax,
330 Kiro,
331 #[value(name = "nous")]
332 NousResearch,
333 #[value(name = "opencode-go")]
334 OpenCodeGo,
335 #[value(name = "commandcode")]
336 CommandCode,
337 Ollama,
338}
339
340impl Vendor {
341 pub fn to_id(self) -> crate::vendor::VendorId {
342 match self {
343 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
344 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
345 Vendor::Openai => crate::vendor::VendorId::Openai,
346 Vendor::Copilot => crate::vendor::VendorId::Copilot,
347 Vendor::Zai => crate::vendor::VendorId::Zai,
348 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
349 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
350 Vendor::Kimi => crate::vendor::VendorId::Kimi,
351 Vendor::Kilo => crate::vendor::VendorId::Kilo,
352 Vendor::Novita => crate::vendor::VendorId::Novita,
353 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
354 Vendor::Grok => crate::vendor::VendorId::Grok,
355 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
356 Vendor::Grokbot => crate::vendor::VendorId::Grokbot,
357 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
358 Vendor::Cursor => crate::vendor::VendorId::Cursor,
359 Vendor::Minimax => crate::vendor::VendorId::Minimax,
360 Vendor::Kiro => crate::vendor::VendorId::Kiro,
361 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
362 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
363 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
364 Vendor::Ollama => crate::vendor::VendorId::Ollama,
365 }
366 }
367}
368
369impl Cli {
370 pub fn has_explicit_vendor(&self) -> bool {
372 self.vendor.is_some()
373 }
374
375 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
386 let active = if self.has_explicit_vendor() {
392 None
393 } else {
394 crate::active::read()
395 };
396 self.resolve_vendor_with(config, active)
397 }
398
399 pub fn resolve_vendor_with(
404 &self,
405 config: &crate::config::Config,
406 active: Option<crate::vendor::VendorId>,
407 ) -> Vendor {
408 if let Some(v) = self.vendor {
409 return v;
410 }
411 if let Some(id) = active
412 && config.is_enabled(id)
413 {
414 return id_to_vendor(id);
415 }
416 if let Some(id) = config.ui.primary
417 && config.is_enabled(id)
418 {
419 return id_to_vendor(id);
420 }
421 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
422 return Vendor::Anthropic;
423 }
424 config
425 .enabled_vendors()
426 .into_iter()
427 .next()
428 .map(id_to_vendor)
429 .unwrap_or(Vendor::Anthropic)
432 }
433}
434
435fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
436 match id {
437 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
438 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
439 crate::vendor::VendorId::Openai => Vendor::Openai,
440 crate::vendor::VendorId::Copilot => Vendor::Copilot,
441 crate::vendor::VendorId::Zai => Vendor::Zai,
442 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
443 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
444 crate::vendor::VendorId::Kimi => Vendor::Kimi,
445 crate::vendor::VendorId::Kilo => Vendor::Kilo,
446 crate::vendor::VendorId::Novita => Vendor::Novita,
447 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
448 crate::vendor::VendorId::Grok => Vendor::Grok,
449 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
450 crate::vendor::VendorId::Grokbot => Vendor::Grokbot,
451 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
452 crate::vendor::VendorId::Cursor => Vendor::Cursor,
453 crate::vendor::VendorId::Minimax => Vendor::Minimax,
454 crate::vendor::VendorId::Kiro => Vendor::Kiro,
455 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
456 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
457 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
458 crate::vendor::VendorId::Ollama => Vendor::Ollama,
459 }
460}
461
462impl Cli {
463 pub fn output_json(&self) -> bool {
466 if self.json {
467 return true;
468 }
469 if self.pretty || self.watch.is_some() {
470 return false;
471 }
472 !is_stdout_tty()
474 }
475}
476
477fn is_stdout_tty() -> bool {
478 use std::io::IsTerminal;
479 std::io::stdout().is_terminal()
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use clap::{Parser, error::ErrorKind};
486
487 #[test]
488 fn settings_enable_requires_a_known_vendor() {
489 assert!(matches!(
490 Cli::try_parse_from(["ai-usagebar", "settings", "enable", "anthropic"])
491 .unwrap()
492 .command,
493 Some(Command::Settings {
494 action: SettingsAction::Enable {
495 vendor: Vendor::Anthropic
496 }
497 })
498 ));
499 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable", "unknown"]).is_err());
500 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable"]).is_err());
501 }
502
503 #[test]
504 fn version_flags_report_the_crate_version() {
505 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
506
507 for flag in ["--version", "-V"] {
508 let err = Cli::try_parse_from(["ai-usagebar", flag])
509 .expect_err("a version flag exits through clap's display path");
510 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
511 assert_eq!(err.to_string(), expected, "flag: {flag}");
512 }
513 }
514
515 #[test]
516 fn usage_subcommand_parses_machine_readable_mode() {
517 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
518 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
519 }
520
521 #[test]
522 fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
523 let bare = Cli::parse_from(["ai-usagebar", "detect"]);
524 assert!(matches!(
525 bare.command,
526 Some(Command::Detect {
527 all: false,
528 json: false
529 })
530 ));
531
532 let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
533 assert!(matches!(
534 full.command,
535 Some(Command::Detect {
536 all: true,
537 json: true
538 })
539 ));
540
541 assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
542 }
543
544 #[test]
545 fn new_vendor_values_and_auth_commands_parse_exactly() {
546 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
547 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
548 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
549 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
550 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
551 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
552 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
553 assert!(matches!(login.command, Some(Command::Auth { .. })));
554 }
555
556 #[test]
557 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
558 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
559 assert!(matches!(
560 show.command,
561 Some(Command::Settings {
562 action: SettingsAction::Show
563 })
564 ));
565
566 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
567 assert!(matches!(
568 apply.command,
569 Some(Command::Settings {
570 action: SettingsAction::Apply
571 })
572 ));
573
574 assert!(
575 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
576 );
577 }
578
579 #[test]
580 fn defaults_match_claudebar() {
581 let cli = Cli::parse_from(["ai-usagebar"]);
582 assert_eq!(cli.vendor, None);
583 let cfg = crate::config::Config::default();
588 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
589 assert_eq!(cli.pace_tolerance, 5);
590 assert!(cli.format.is_none());
591 assert!(cli.tooltip_format.is_none());
592 assert!(cli.icon.is_none());
593 assert!(!cli.format_pace_color);
594 assert!(!cli.tooltip_pace_pts);
595 assert!(!cli.pretty);
596 assert!(!cli.json);
597 assert!(cli.watch.is_none());
598 assert!(cli.command.is_none());
599 }
600
601 #[test]
602 fn account_add_subcommand_parses_without_widget_flags() {
603 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
604 assert!(matches!(
605 cli.command,
606 Some(Command::Account {
607 action: AccountAction::Add {
608 ref label,
609 no_login: true,
610 desktop: false,
611 ..
612 }
613 }) if label == "work"
614 ));
615 }
616
617 #[test]
620 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
621 let cli = Cli::parse_from([
622 "ai-usagebar",
623 "account",
624 "add",
625 "work",
626 "--desktop",
627 "--email",
628 "a@b.test",
629 "-y",
630 ]);
631 assert!(matches!(
632 cli.command,
633 Some(Command::Account {
634 action: AccountAction::Add {
635 desktop: true,
636 yes: true,
637 email: Some(ref email),
638 ..
639 }
640 }) if email == "a@b.test"
641 ));
642 assert!(
643 Cli::try_parse_from([
644 "ai-usagebar",
645 "account",
646 "add",
647 "w",
648 "--desktop",
649 "--no-login"
650 ])
651 .is_err()
652 );
653 assert!(
655 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
656 .is_err()
657 );
658 }
659
660 #[test]
661 fn account_switch_defaults_to_both_surfaces() {
662 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
663 assert!(matches!(
664 cli.command,
665 Some(Command::Account {
666 action: AccountAction::Switch {
667 ref label,
668 desktop: false,
669 cli: false,
670 dry_run: true,
671 keep_backups: 10,
672 ..
673 }
674 }) if label == "work"
675 ));
676 }
677
678 #[test]
679 fn account_subcommand_rejects_ignored_widget_flags() {
680 assert!(
681 Cli::try_parse_from([
682 "ai-usagebar",
683 "--vendor",
684 "anthropic",
685 "account",
686 "add",
687 "work",
688 ])
689 .is_err()
690 );
691 }
692
693 #[test]
694 fn multi_account_flags_are_stable_api() {
695 let cli = Cli::parse_from([
699 "ai-usagebar",
700 "--vendor",
701 "anthropic",
702 "--cache-dir",
703 "/tmp/acct-a",
704 "--creds-path",
705 "/tmp/acct-a/credentials.json",
706 ]);
707 assert_eq!(
708 cli.cache_dir.as_deref(),
709 Some(std::path::Path::new("/tmp/acct-a"))
710 );
711 assert_eq!(
712 cli.creds_path.as_deref(),
713 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
714 );
715 }
716
717 #[test]
718 fn primary_from_config_wins_when_vendor_unset() {
719 let cli = Cli::parse_from(["ai-usagebar"]);
721 let mut cfg = crate::config::Config::default();
722 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
723 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
724 }
725
726 #[test]
727 fn explicit_vendor_overrides_everything() {
728 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
731 let mut cfg = crate::config::Config::default();
732 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
733 let active = Some(crate::vendor::VendorId::Openai);
734 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
735 }
736
737 #[test]
738 fn vendor_kimi_parses_to_kimi_variant() {
739 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
740 assert_eq!(cli.vendor, Some(Vendor::Kimi));
741 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
742 }
743
744 #[test]
745 fn vendor_grokbot_parses_to_grokbot_variant() {
746 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "grokbot"]);
747 assert_eq!(cli.vendor, Some(Vendor::Grokbot));
748 assert_eq!(
749 cli.vendor.unwrap().to_id(),
750 crate::vendor::VendorId::Grokbot
751 );
752 assert_eq!(
754 id_to_vendor(crate::vendor::VendorId::Grokbot),
755 Vendor::Grokbot
756 );
757 }
758
759 #[test]
760 fn vendor_anthropic_api_uses_the_documented_slug() {
761 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
762 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
763 assert_eq!(
764 cli.vendor.unwrap().to_id(),
765 crate::vendor::VendorId::AnthropicApi
766 );
767 }
768
769 #[test]
770 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
771 let cli = Cli::parse_from(["ai-usagebar"]);
772 let mut cfg = crate::config::Config::default();
773 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
774 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
775 }
776
777 #[test]
778 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
779 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
780 assert_eq!(
781 cli.resolve_vendor_with(&crate::config::Config::default(), None),
782 Vendor::Kimi
783 );
784 }
785
786 #[test]
787 fn active_override_wins_over_config_primary_when_enabled() {
788 let cli = Cli::parse_from(["ai-usagebar"]);
791 let mut cfg = crate::config::Config::default();
792 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
793 let active = Some(crate::vendor::VendorId::Zai);
794 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
795 }
796
797 #[test]
798 fn disabled_active_override_falls_back_to_config_primary() {
799 let cli = Cli::parse_from(["ai-usagebar"]);
802 let mut cfg = crate::config::Config::default();
803 cfg.zai.enabled = false;
804 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
805 let active = Some(crate::vendor::VendorId::Zai);
806 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
807 }
808
809 #[test]
810 fn claudebar_compatible_flag_surface() {
811 let cli = Cli::parse_from([
812 "ai-usagebar",
813 "--icon",
814 "",
815 "--format",
816 "{session_pct}% · {session_reset}",
817 "--tooltip-format",
818 "S:{session_pct}",
819 "--pace-tolerance",
820 "10",
821 "--format-pace-color",
822 "--tooltip-pace-pts",
823 "--color-low",
824 "#50fa7b",
825 "--color-mid",
826 "#f1fa8c",
827 "--color-high",
828 "#ffb86c",
829 "--color-critical",
830 "#ff5555",
831 ]);
832 assert_eq!(cli.icon.as_deref(), Some(""));
833 assert_eq!(
834 cli.format.as_deref(),
835 Some("{session_pct}% · {session_reset}")
836 );
837 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
838 assert_eq!(cli.pace_tolerance, 10);
839 assert!(cli.format_pace_color);
840 assert!(cli.tooltip_pace_pts);
841 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
842 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
843 }
844
845 #[test]
846 fn pretty_and_json_conflict() {
847 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
848 assert!(res.is_err());
849 }
850
851 #[test]
852 fn watch_disables_json_output() {
853 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
854 assert_eq!(cli.watch, Some(5));
855 assert!(!cli.output_json());
856 }
857}