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/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")]
131 pub account: Option<String>,
132
133 #[arg(long, requires = "account")]
138 pub desktop: bool,
139
140 #[command(subcommand)]
142 pub command: Option<Command>,
143}
144
145#[derive(clap::Subcommand, Debug, Clone)]
146pub enum Command {
147 Account {
149 #[command(subcommand)]
150 action: AccountAction,
151 },
152
153 Antigravity {
155 #[command(subcommand)]
156 action: AntigravityAction,
157 },
158
159 Usage {
166 #[arg(long)]
168 json: bool,
169 #[arg(long, requires = "json")]
174 priorities: bool,
175 #[arg(long, requires = "priorities",
177 default_value_t = crate::recommendation::DEFAULT_RECENT_HOURS,
178 value_parser = clap::value_parser!(u8).range(1..=24))]
179 recent_hours: u8,
180 #[arg(long, requires = "priorities",
182 default_value_t = crate::recommendation::DEFAULT_ALERT_UNUSED_PERCENT,
183 value_parser = clap::value_parser!(u8).range(1..=100))]
184 alert_unused_percent: u8,
185 #[arg(long, requires = "priorities",
187 default_value_t = crate::recommendation::DEFAULT_ALERT_HOURS,
188 value_parser = clap::value_parser!(u8).range(1..=168))]
189 alert_hours: u8,
190 },
191
192 Mcp,
194
195 Detect {
198 #[arg(long)]
200 all: bool,
201 #[arg(long)]
203 json: bool,
204 },
205
206 Vendors {
211 #[arg(long)]
213 json: bool,
214 },
215
216 Settings {
218 #[command(subcommand)]
219 action: SettingsAction,
220 },
221
222 Auth {
224 #[command(subcommand)]
225 provider: AuthProvider,
226 },
227}
228
229#[derive(clap::Subcommand, Debug, Clone, Copy, PartialEq, Eq)]
230pub enum AntigravityAction {
231 SetupStatusline,
233 RemoveStatusline,
235 IngestStatusline,
237}
238
239#[derive(clap::Subcommand, Debug, Clone)]
240pub enum AuthProvider {
241 Nous {
242 #[command(subcommand)]
243 action: NousAuthAction,
244 },
245}
246
247#[derive(clap::Subcommand, Debug, Clone)]
248pub enum NousAuthAction {
249 Login,
251 Logout,
253}
254
255#[derive(clap::Subcommand, Debug, Clone)]
256pub enum SettingsAction {
257 Enable {
259 #[arg(value_enum)]
260 vendor: Vendor,
261 },
262
263 Show,
265
266 Apply,
268}
269
270#[derive(clap::Subcommand, Debug, Clone)]
271pub enum AccountAction {
272 Add {
274 label: String,
276
277 #[arg(long, conflicts_with = "desktop")]
279 no_login: bool,
280
281 #[arg(long)]
286 desktop: bool,
287
288 #[arg(long, requires = "desktop")]
291 email: Option<String>,
292
293 #[arg(short = 'y', long, requires = "desktop")]
295 yes: bool,
296
297 #[arg(long, conflicts_with = "desktop")]
301 codex: bool,
302
303 #[arg(long, conflicts_with_all = ["desktop", "no_login"])]
307 adopt_current: bool,
308 },
309
310 Status {
313 #[arg(long)]
315 json: bool,
316 },
317
318 Switch {
321 label: String,
325
326 #[arg(
329 long,
330 conflicts_with_all = ["desktop", "cli", "keep_bridge", "backup_sessions", "delete_conflict"]
331 )]
332 codex: bool,
333
334 #[arg(long)]
336 desktop: bool,
337
338 #[arg(long)]
340 cli: bool,
341
342 #[arg(long)]
344 dry_run: bool,
345
346 #[arg(short = 'y', long)]
348 yes: bool,
349
350 #[arg(long)]
354 force: bool,
355
356 #[arg(long)]
359 keep_bridge: bool,
360
361 #[arg(long)]
364 backup_sessions: bool,
365
366 #[arg(long, default_value_t = 10)]
368 keep_backups: usize,
369
370 #[arg(long, value_name = "KEY")]
377 delete_conflict: Vec<String>,
378 },
379
380 MergeHistory {
395 #[arg(long, value_name = "DIR")]
399 data_dir: std::path::PathBuf,
400
401 #[arg(long, value_name = "DIR")]
408 from: Vec<std::path::PathBuf>,
409
410 #[arg(long)]
412 dry_run: bool,
413 },
414}
415
416#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
417pub enum Vendor {
418 Anthropic,
419 #[value(name = "anthropic_api")]
420 AnthropicApi,
421 Openai,
422 Copilot,
423 Zai,
424 Openrouter,
425 Deepseek,
426 Deepinfra,
427 Kimi,
428 Kilo,
429 Novita,
430 Synthetic,
431 Moonshot,
432 Grok,
433 Supergrok,
434 Grokbot,
435 Antigravity,
436 Cursor,
437 Minimax,
438 Kiro,
439 #[value(name = "nous")]
440 NousResearch,
441 #[value(name = "opencode-go")]
442 OpenCodeGo,
443 #[value(name = "commandcode")]
444 CommandCode,
445 Ollama,
446 #[value(name = "orcarouter", alias = "orca-router")]
449 OrcaRouter,
450 #[value(name = "modelstudio")]
451 ModelStudio,
452 Lyceum,
453 Devin,
454 MuseCode,
455 Cline,
456 Droid,
457 #[value(name = "deeprelay")]
459 DeepRelay,
460 Mistral,
461}
462
463impl Vendor {
464 pub fn to_id(self) -> crate::vendor::VendorId {
465 match self {
466 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
467 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
468 Vendor::Openai => crate::vendor::VendorId::Openai,
469 Vendor::Copilot => crate::vendor::VendorId::Copilot,
470 Vendor::Zai => crate::vendor::VendorId::Zai,
471 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
472 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
473 Vendor::Deepinfra => crate::vendor::VendorId::Deepinfra,
474 Vendor::Kimi => crate::vendor::VendorId::Kimi,
475 Vendor::Kilo => crate::vendor::VendorId::Kilo,
476 Vendor::Novita => crate::vendor::VendorId::Novita,
477 Vendor::Synthetic => crate::vendor::VendorId::Synthetic,
478 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
479 Vendor::Grok => crate::vendor::VendorId::Grok,
480 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
481 Vendor::Grokbot => crate::vendor::VendorId::Grokbot,
482 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
483 Vendor::Cursor => crate::vendor::VendorId::Cursor,
484 Vendor::Minimax => crate::vendor::VendorId::Minimax,
485 Vendor::Kiro => crate::vendor::VendorId::Kiro,
486 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
487 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
488 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
489 Vendor::Ollama => crate::vendor::VendorId::Ollama,
490 Vendor::OrcaRouter => crate::vendor::VendorId::OrcaRouter,
491 Vendor::ModelStudio => crate::vendor::VendorId::ModelStudio,
492 Vendor::Lyceum => crate::vendor::VendorId::Lyceum,
493 Vendor::Devin => crate::vendor::VendorId::Devin,
494 Vendor::MuseCode => crate::vendor::VendorId::MuseCode,
495 Vendor::Cline => crate::vendor::VendorId::Cline,
496 Vendor::Droid => crate::vendor::VendorId::Droid,
497 Vendor::DeepRelay => crate::vendor::VendorId::DeepRelay,
498 Vendor::Mistral => crate::vendor::VendorId::Mistral,
499 }
500 }
501}
502
503impl Cli {
504 pub fn has_explicit_vendor(&self) -> bool {
506 self.vendor.is_some()
507 }
508
509 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
520 let active = if self.has_explicit_vendor() {
526 None
527 } else {
528 crate::active::read()
529 };
530 self.resolve_vendor_with(config, active)
531 }
532
533 pub fn resolve_vendor_with(
538 &self,
539 config: &crate::config::Config,
540 active: Option<crate::vendor::VendorId>,
541 ) -> Vendor {
542 if let Some(v) = self.vendor {
543 return v;
544 }
545 if let Some(id) = active
546 && config.is_enabled(id)
547 {
548 return id_to_vendor(id);
549 }
550 if let Some(id) = config.ui.primary
551 && config.is_enabled(id)
552 {
553 return id_to_vendor(id);
554 }
555 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
556 return Vendor::Anthropic;
557 }
558 config
559 .enabled_vendors()
560 .into_iter()
561 .next()
562 .map(id_to_vendor)
563 .unwrap_or(Vendor::Anthropic)
566 }
567}
568
569fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
570 match id {
571 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
572 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
573 crate::vendor::VendorId::Openai => Vendor::Openai,
574 crate::vendor::VendorId::Copilot => Vendor::Copilot,
575 crate::vendor::VendorId::Zai => Vendor::Zai,
576 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
577 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
578 crate::vendor::VendorId::Deepinfra => Vendor::Deepinfra,
579 crate::vendor::VendorId::Kimi => Vendor::Kimi,
580 crate::vendor::VendorId::Kilo => Vendor::Kilo,
581 crate::vendor::VendorId::Novita => Vendor::Novita,
582 crate::vendor::VendorId::Synthetic => Vendor::Synthetic,
583 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
584 crate::vendor::VendorId::Grok => Vendor::Grok,
585 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
586 crate::vendor::VendorId::Grokbot => Vendor::Grokbot,
587 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
588 crate::vendor::VendorId::Cursor => Vendor::Cursor,
589 crate::vendor::VendorId::Minimax => Vendor::Minimax,
590 crate::vendor::VendorId::Kiro => Vendor::Kiro,
591 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
592 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
593 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
594 crate::vendor::VendorId::Ollama => Vendor::Ollama,
595 crate::vendor::VendorId::OrcaRouter => Vendor::OrcaRouter,
596 crate::vendor::VendorId::ModelStudio => Vendor::ModelStudio,
597 crate::vendor::VendorId::Lyceum => Vendor::Lyceum,
598 crate::vendor::VendorId::Devin => Vendor::Devin,
599 crate::vendor::VendorId::MuseCode => Vendor::MuseCode,
600 crate::vendor::VendorId::Cline => Vendor::Cline,
601 crate::vendor::VendorId::Droid => Vendor::Droid,
602 crate::vendor::VendorId::DeepRelay => Vendor::DeepRelay,
603 crate::vendor::VendorId::Mistral => Vendor::Mistral,
604 }
605}
606
607impl Cli {
608 pub fn output_json(&self) -> bool {
611 if self.json {
612 return true;
613 }
614 if self.pretty || self.watch.is_some() {
615 return false;
616 }
617 !is_stdout_tty()
619 }
620}
621
622fn is_stdout_tty() -> bool {
623 use std::io::IsTerminal;
624 std::io::stdout().is_terminal()
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630 use clap::{Parser, error::ErrorKind};
631
632 #[test]
633 fn settings_enable_requires_a_known_vendor() {
634 assert!(matches!(
635 Cli::try_parse_from(["ai-usagebar", "settings", "enable", "anthropic"])
636 .unwrap()
637 .command,
638 Some(Command::Settings {
639 action: SettingsAction::Enable {
640 vendor: Vendor::Anthropic
641 }
642 })
643 ));
644 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable", "unknown"]).is_err());
645 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable"]).is_err());
646 }
647
648 #[test]
649 fn antigravity_statusline_actions_parse_and_require_an_action() {
650 for (arguments, expected) in [
651 (&["antigravity", "setup-statusline"][..], "setup"),
652 (&["antigravity", "remove-statusline"][..], "remove"),
653 (&["antigravity", "ingest-statusline"][..], "ingest"),
654 ] {
655 let cli = Cli::try_parse_from(
656 std::iter::once("ai-usagebar").chain(arguments.iter().copied()),
657 )
658 .unwrap();
659 assert!(
660 matches!(cli.command, Some(Command::Antigravity { .. })),
661 "{expected}"
662 );
663 }
664 assert!(Cli::try_parse_from(["ai-usagebar", "antigravity"]).is_err());
665 }
666
667 #[test]
668 fn version_flags_report_the_crate_version() {
669 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
670
671 for flag in ["--version", "-V"] {
672 let err = Cli::try_parse_from(["ai-usagebar", flag])
673 .expect_err("a version flag exits through clap's display path");
674 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
675 assert_eq!(err.to_string(), expected, "flag: {flag}");
676 }
677 }
678
679 #[test]
680 fn usage_recent_hours_are_bounded_and_explicit() {
681 for hours in ["1", "6", "24"] {
682 let cli = Cli::try_parse_from([
683 "ai-usagebar",
684 "usage",
685 "--json",
686 "--priorities",
687 "--recent-hours",
688 hours,
689 ])
690 .unwrap();
691 assert!(
692 matches!(cli.command, Some(Command::Usage { recent_hours, .. }) if recent_hours == hours.parse::<u8>().unwrap())
693 );
694 }
695 let cli = Cli::try_parse_from([
696 "ai-usagebar",
697 "usage",
698 "--json",
699 "--priorities",
700 "--alert-unused-percent",
701 "30",
702 "--alert-hours",
703 "48",
704 ])
705 .unwrap();
706 assert!(matches!(
707 cli.command,
708 Some(Command::Usage {
709 alert_unused_percent: 30,
710 alert_hours: 48,
711 ..
712 })
713 ));
714 for (flag, value) in [
715 ("--alert-unused-percent", "0"),
716 ("--alert-unused-percent", "101"),
717 ("--alert-hours", "0"),
718 ("--alert-hours", "169"),
719 ] {
720 assert!(
721 Cli::try_parse_from([
722 "ai-usagebar",
723 "usage",
724 "--json",
725 "--priorities",
726 flag,
727 value
728 ])
729 .is_err()
730 );
731 }
732 for hours in ["0", "25", "-1", "1.5", "invalid"] {
733 assert!(
734 Cli::try_parse_from([
735 "ai-usagebar",
736 "usage",
737 "--json",
738 "--priorities",
739 "--recent-hours",
740 hours
741 ])
742 .is_err()
743 );
744 }
745 }
746
747 #[test]
748 fn priorities_are_opt_in_and_only_with_json() {
749 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
751 assert!(matches!(
752 cli.command,
753 Some(Command::Usage {
754 priorities: false,
755 ..
756 })
757 ));
758 assert!(Cli::try_parse_from(["ai-usagebar", "usage", "--priorities"]).is_err());
760 for flag in ["--recent-hours", "--alert-unused-percent", "--alert-hours"] {
761 assert!(
762 Cli::try_parse_from(["ai-usagebar", "usage", "--json", flag, "6"]).is_err(),
763 "{flag} requires --priorities"
764 );
765 }
766 }
767
768 #[test]
769 fn usage_subcommand_parses_machine_readable_mode() {
770 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
771 assert!(matches!(
772 cli.command,
773 Some(Command::Usage {
774 json: true,
775 priorities: false,
776 recent_hours: 3,
777 alert_unused_percent: 50,
778 alert_hours: 24,
779 })
780 ));
781 }
782
783 #[test]
784 fn mcp_subcommand_parses_without_widget_flags() {
785 let cli = Cli::parse_from(["ai-usagebar", "mcp"]);
786 assert!(matches!(cli.command, Some(Command::Mcp)));
787 assert!(Cli::try_parse_from(["ai-usagebar", "--pretty", "mcp"]).is_err());
788 }
789
790 #[test]
791 fn usage_help_states_complete_document_exits_zero() {
792 let err = Cli::try_parse_from(["ai-usagebar", "usage", "--help"])
793 .expect_err("help exits through clap's display path");
794 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
795 let help = err.to_string();
796 assert!(
797 help.contains("Exits 0 after printing a complete document"),
798 "{help}"
799 );
800 assert!(
801 help.contains("even when every entry") && help.contains("error"),
802 "{help}"
803 );
804 assert!(
805 help.contains("Non-zero only when the document cannot be produced"),
806 "{help}"
807 );
808 }
809
810 #[test]
811 fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
812 let bare = Cli::parse_from(["ai-usagebar", "detect"]);
813 assert!(matches!(
814 bare.command,
815 Some(Command::Detect {
816 all: false,
817 json: false
818 })
819 ));
820
821 let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
822 assert!(matches!(
823 full.command,
824 Some(Command::Detect {
825 all: true,
826 json: true
827 })
828 ));
829
830 assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
831 }
832
833 #[test]
834 fn new_vendor_values_and_auth_commands_parse_exactly() {
835 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
836 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
837 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
838 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
839 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
840 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
841 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
842 assert!(matches!(login.command, Some(Command::Auth { .. })));
843 }
844
845 #[test]
846 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
847 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
848 assert!(matches!(
849 show.command,
850 Some(Command::Settings {
851 action: SettingsAction::Show
852 })
853 ));
854
855 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
856 assert!(matches!(
857 apply.command,
858 Some(Command::Settings {
859 action: SettingsAction::Apply
860 })
861 ));
862
863 assert!(
864 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
865 );
866 }
867
868 #[test]
869 fn defaults_match_claudebar() {
870 let cli = Cli::parse_from(["ai-usagebar"]);
871 assert_eq!(cli.vendor, None);
872 let cfg = crate::config::Config::default();
877 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
878 assert_eq!(cli.pace_tolerance, 5);
879 assert!(cli.format.is_none());
880 assert!(cli.tooltip_format.is_none());
881 assert!(cli.icon.is_none());
882 assert!(!cli.format_pace_color);
883 assert!(!cli.tooltip_pace_pts);
884 assert!(!cli.pretty);
885 assert!(!cli.json);
886 assert!(cli.watch.is_none());
887 assert!(cli.command.is_none());
888 }
889
890 #[test]
891 fn account_add_subcommand_parses_without_widget_flags() {
892 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
893 assert!(matches!(
894 cli.command,
895 Some(Command::Account {
896 action: AccountAction::Add {
897 ref label,
898 no_login: true,
899 desktop: false,
900 ..
901 }
902 }) if label == "work"
903 ));
904 }
905
906 #[test]
909 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
910 let cli = Cli::parse_from([
911 "ai-usagebar",
912 "account",
913 "add",
914 "work",
915 "--desktop",
916 "--email",
917 "a@b.test",
918 "-y",
919 ]);
920 assert!(matches!(
921 cli.command,
922 Some(Command::Account {
923 action: AccountAction::Add {
924 desktop: true,
925 yes: true,
926 email: Some(ref email),
927 ..
928 }
929 }) if email == "a@b.test"
930 ));
931 assert!(
932 Cli::try_parse_from([
933 "ai-usagebar",
934 "account",
935 "add",
936 "w",
937 "--desktop",
938 "--no-login"
939 ])
940 .is_err()
941 );
942 assert!(
944 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
945 .is_err()
946 );
947 }
948
949 #[test]
950 fn account_switch_defaults_to_both_surfaces() {
951 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
952 assert!(matches!(
953 cli.command,
954 Some(Command::Account {
955 action: AccountAction::Switch {
956 ref label,
957 desktop: false,
958 cli: false,
959 dry_run: true,
960 keep_backups: 10,
961 ..
962 }
963 }) if label == "work"
964 ));
965 }
966
967 #[test]
968 fn account_subcommand_rejects_ignored_widget_flags() {
969 assert!(
970 Cli::try_parse_from([
971 "ai-usagebar",
972 "--vendor",
973 "anthropic",
974 "account",
975 "add",
976 "work",
977 ])
978 .is_err()
979 );
980 }
981
982 #[test]
983 fn multi_account_flags_are_stable_api() {
984 let cli = Cli::parse_from([
988 "ai-usagebar",
989 "--vendor",
990 "anthropic",
991 "--cache-dir",
992 "/tmp/acct-a",
993 "--creds-path",
994 "/tmp/acct-a/credentials.json",
995 ]);
996 assert_eq!(
997 cli.cache_dir.as_deref(),
998 Some(std::path::Path::new("/tmp/acct-a"))
999 );
1000 assert_eq!(
1001 cli.creds_path.as_deref(),
1002 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
1003 );
1004 }
1005
1006 #[test]
1007 fn primary_from_config_wins_when_vendor_unset() {
1008 let cli = Cli::parse_from(["ai-usagebar"]);
1010 let mut cfg = crate::config::Config::default();
1011 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1012 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
1013 }
1014
1015 #[test]
1016 fn explicit_vendor_overrides_everything() {
1017 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
1020 let mut cfg = crate::config::Config::default();
1021 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1022 let active = Some(crate::vendor::VendorId::Openai);
1023 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
1024 }
1025
1026 #[test]
1027 fn vendor_kimi_parses_to_kimi_variant() {
1028 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
1029 assert_eq!(cli.vendor, Some(Vendor::Kimi));
1030 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
1031 }
1032
1033 #[test]
1034 fn vendor_grokbot_parses_to_grokbot_variant() {
1035 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "grokbot"]);
1036 assert_eq!(cli.vendor, Some(Vendor::Grokbot));
1037 assert_eq!(
1038 cli.vendor.unwrap().to_id(),
1039 crate::vendor::VendorId::Grokbot
1040 );
1041 assert_eq!(
1043 id_to_vendor(crate::vendor::VendorId::Grokbot),
1044 Vendor::Grokbot
1045 );
1046 }
1047
1048 #[test]
1049 fn vendor_modelstudio_parses_to_modelstudio_variant() {
1050 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "modelstudio"]);
1051 assert_eq!(cli.vendor, Some(Vendor::ModelStudio));
1052 assert_eq!(
1053 cli.vendor.unwrap().to_id(),
1054 crate::vendor::VendorId::ModelStudio
1055 );
1056 assert_eq!(
1057 id_to_vendor(crate::vendor::VendorId::ModelStudio),
1058 Vendor::ModelStudio
1059 );
1060 }
1061
1062 #[test]
1063 fn vendor_anthropic_api_uses_the_documented_slug() {
1064 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
1065 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
1066 assert_eq!(
1067 cli.vendor.unwrap().to_id(),
1068 crate::vendor::VendorId::AnthropicApi
1069 );
1070 }
1071
1072 #[test]
1073 fn every_vendor_parses_from_its_slug() {
1074 for vendor in <Vendor as clap::ValueEnum>::value_variants() {
1077 let slug = vendor.to_id().slug();
1078 let cli = Cli::parse_from(["ai-usagebar", "--vendor", slug]);
1079 assert_eq!(cli.vendor, Some(*vendor), "{slug}");
1080 }
1081 let legacy = Cli::parse_from(["ai-usagebar", "--vendor", "orca-router"]);
1082 assert_eq!(legacy.vendor, Some(Vendor::OrcaRouter));
1083 }
1084
1085 #[test]
1086 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
1087 let cli = Cli::parse_from(["ai-usagebar"]);
1088 let mut cfg = crate::config::Config::default();
1089 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
1090 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
1091 }
1092
1093 #[test]
1094 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
1095 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
1096 assert_eq!(
1097 cli.resolve_vendor_with(&crate::config::Config::default(), None),
1098 Vendor::Kimi
1099 );
1100 }
1101
1102 #[test]
1103 fn active_override_wins_over_config_primary_when_enabled() {
1104 let cli = Cli::parse_from(["ai-usagebar"]);
1107 let mut cfg = crate::config::Config::default();
1108 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1109 let active = Some(crate::vendor::VendorId::Zai);
1110 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
1111 }
1112
1113 #[test]
1114 fn disabled_active_override_falls_back_to_config_primary() {
1115 let cli = Cli::parse_from(["ai-usagebar"]);
1118 let mut cfg = crate::config::Config::default();
1119 cfg.zai.enabled = false;
1120 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1121 let active = Some(crate::vendor::VendorId::Zai);
1122 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
1123 }
1124
1125 #[test]
1126 fn claudebar_compatible_flag_surface() {
1127 let cli = Cli::parse_from([
1128 "ai-usagebar",
1129 "--icon",
1130 "",
1131 "--format",
1132 "{session_pct}% · {session_reset}",
1133 "--tooltip-format",
1134 "S:{session_pct}",
1135 "--pace-tolerance",
1136 "10",
1137 "--format-pace-color",
1138 "--tooltip-pace-pts",
1139 "--color-low",
1140 "#50fa7b",
1141 "--color-mid",
1142 "#f1fa8c",
1143 "--color-high",
1144 "#ffb86c",
1145 "--color-critical",
1146 "#ff5555",
1147 ]);
1148 assert_eq!(cli.icon.as_deref(), Some(""));
1149 assert_eq!(
1150 cli.format.as_deref(),
1151 Some("{session_pct}% · {session_reset}")
1152 );
1153 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
1154 assert_eq!(cli.pace_tolerance, 10);
1155 assert!(cli.format_pace_color);
1156 assert!(cli.tooltip_pace_pts);
1157 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
1158 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
1159 }
1160
1161 #[test]
1162 fn pretty_and_json_conflict() {
1163 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
1164 assert!(res.is_err());
1165 }
1166
1167 #[test]
1168 fn watch_disables_json_output() {
1169 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
1170 assert_eq!(cli.watch, Some(5));
1171 assert!(!cli.output_json());
1172 }
1173}