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