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}
458
459impl Vendor {
460 pub fn to_id(self) -> crate::vendor::VendorId {
461 match self {
462 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
463 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
464 Vendor::Openai => crate::vendor::VendorId::Openai,
465 Vendor::Copilot => crate::vendor::VendorId::Copilot,
466 Vendor::Zai => crate::vendor::VendorId::Zai,
467 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
468 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
469 Vendor::Deepinfra => crate::vendor::VendorId::Deepinfra,
470 Vendor::Kimi => crate::vendor::VendorId::Kimi,
471 Vendor::Kilo => crate::vendor::VendorId::Kilo,
472 Vendor::Novita => crate::vendor::VendorId::Novita,
473 Vendor::Synthetic => crate::vendor::VendorId::Synthetic,
474 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
475 Vendor::Grok => crate::vendor::VendorId::Grok,
476 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
477 Vendor::Grokbot => crate::vendor::VendorId::Grokbot,
478 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
479 Vendor::Cursor => crate::vendor::VendorId::Cursor,
480 Vendor::Minimax => crate::vendor::VendorId::Minimax,
481 Vendor::Kiro => crate::vendor::VendorId::Kiro,
482 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
483 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
484 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
485 Vendor::Ollama => crate::vendor::VendorId::Ollama,
486 Vendor::OrcaRouter => crate::vendor::VendorId::OrcaRouter,
487 Vendor::ModelStudio => crate::vendor::VendorId::ModelStudio,
488 Vendor::Lyceum => crate::vendor::VendorId::Lyceum,
489 Vendor::Devin => crate::vendor::VendorId::Devin,
490 Vendor::MuseCode => crate::vendor::VendorId::MuseCode,
491 Vendor::Cline => crate::vendor::VendorId::Cline,
492 Vendor::Droid => crate::vendor::VendorId::Droid,
493 }
494 }
495}
496
497impl Cli {
498 pub fn has_explicit_vendor(&self) -> bool {
500 self.vendor.is_some()
501 }
502
503 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
514 let active = if self.has_explicit_vendor() {
520 None
521 } else {
522 crate::active::read()
523 };
524 self.resolve_vendor_with(config, active)
525 }
526
527 pub fn resolve_vendor_with(
532 &self,
533 config: &crate::config::Config,
534 active: Option<crate::vendor::VendorId>,
535 ) -> Vendor {
536 if let Some(v) = self.vendor {
537 return v;
538 }
539 if let Some(id) = active
540 && config.is_enabled(id)
541 {
542 return id_to_vendor(id);
543 }
544 if let Some(id) = config.ui.primary
545 && config.is_enabled(id)
546 {
547 return id_to_vendor(id);
548 }
549 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
550 return Vendor::Anthropic;
551 }
552 config
553 .enabled_vendors()
554 .into_iter()
555 .next()
556 .map(id_to_vendor)
557 .unwrap_or(Vendor::Anthropic)
560 }
561}
562
563fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
564 match id {
565 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
566 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
567 crate::vendor::VendorId::Openai => Vendor::Openai,
568 crate::vendor::VendorId::Copilot => Vendor::Copilot,
569 crate::vendor::VendorId::Zai => Vendor::Zai,
570 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
571 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
572 crate::vendor::VendorId::Deepinfra => Vendor::Deepinfra,
573 crate::vendor::VendorId::Kimi => Vendor::Kimi,
574 crate::vendor::VendorId::Kilo => Vendor::Kilo,
575 crate::vendor::VendorId::Novita => Vendor::Novita,
576 crate::vendor::VendorId::Synthetic => Vendor::Synthetic,
577 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
578 crate::vendor::VendorId::Grok => Vendor::Grok,
579 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
580 crate::vendor::VendorId::Grokbot => Vendor::Grokbot,
581 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
582 crate::vendor::VendorId::Cursor => Vendor::Cursor,
583 crate::vendor::VendorId::Minimax => Vendor::Minimax,
584 crate::vendor::VendorId::Kiro => Vendor::Kiro,
585 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
586 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
587 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
588 crate::vendor::VendorId::Ollama => Vendor::Ollama,
589 crate::vendor::VendorId::OrcaRouter => Vendor::OrcaRouter,
590 crate::vendor::VendorId::ModelStudio => Vendor::ModelStudio,
591 crate::vendor::VendorId::Lyceum => Vendor::Lyceum,
592 crate::vendor::VendorId::Devin => Vendor::Devin,
593 crate::vendor::VendorId::MuseCode => Vendor::MuseCode,
594 crate::vendor::VendorId::Cline => Vendor::Cline,
595 crate::vendor::VendorId::Droid => Vendor::Droid,
596 }
597}
598
599impl Cli {
600 pub fn output_json(&self) -> bool {
603 if self.json {
604 return true;
605 }
606 if self.pretty || self.watch.is_some() {
607 return false;
608 }
609 !is_stdout_tty()
611 }
612}
613
614fn is_stdout_tty() -> bool {
615 use std::io::IsTerminal;
616 std::io::stdout().is_terminal()
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use clap::{Parser, error::ErrorKind};
623
624 #[test]
625 fn settings_enable_requires_a_known_vendor() {
626 assert!(matches!(
627 Cli::try_parse_from(["ai-usagebar", "settings", "enable", "anthropic"])
628 .unwrap()
629 .command,
630 Some(Command::Settings {
631 action: SettingsAction::Enable {
632 vendor: Vendor::Anthropic
633 }
634 })
635 ));
636 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable", "unknown"]).is_err());
637 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable"]).is_err());
638 }
639
640 #[test]
641 fn antigravity_statusline_actions_parse_and_require_an_action() {
642 for (arguments, expected) in [
643 (&["antigravity", "setup-statusline"][..], "setup"),
644 (&["antigravity", "remove-statusline"][..], "remove"),
645 (&["antigravity", "ingest-statusline"][..], "ingest"),
646 ] {
647 let cli = Cli::try_parse_from(
648 std::iter::once("ai-usagebar").chain(arguments.iter().copied()),
649 )
650 .unwrap();
651 assert!(
652 matches!(cli.command, Some(Command::Antigravity { .. })),
653 "{expected}"
654 );
655 }
656 assert!(Cli::try_parse_from(["ai-usagebar", "antigravity"]).is_err());
657 }
658
659 #[test]
660 fn version_flags_report_the_crate_version() {
661 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
662
663 for flag in ["--version", "-V"] {
664 let err = Cli::try_parse_from(["ai-usagebar", flag])
665 .expect_err("a version flag exits through clap's display path");
666 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
667 assert_eq!(err.to_string(), expected, "flag: {flag}");
668 }
669 }
670
671 #[test]
672 fn usage_recent_hours_are_bounded_and_explicit() {
673 for hours in ["1", "6", "24"] {
674 let cli = Cli::try_parse_from([
675 "ai-usagebar",
676 "usage",
677 "--json",
678 "--priorities",
679 "--recent-hours",
680 hours,
681 ])
682 .unwrap();
683 assert!(
684 matches!(cli.command, Some(Command::Usage { recent_hours, .. }) if recent_hours == hours.parse::<u8>().unwrap())
685 );
686 }
687 let cli = Cli::try_parse_from([
688 "ai-usagebar",
689 "usage",
690 "--json",
691 "--priorities",
692 "--alert-unused-percent",
693 "30",
694 "--alert-hours",
695 "48",
696 ])
697 .unwrap();
698 assert!(matches!(
699 cli.command,
700 Some(Command::Usage {
701 alert_unused_percent: 30,
702 alert_hours: 48,
703 ..
704 })
705 ));
706 for (flag, value) in [
707 ("--alert-unused-percent", "0"),
708 ("--alert-unused-percent", "101"),
709 ("--alert-hours", "0"),
710 ("--alert-hours", "169"),
711 ] {
712 assert!(
713 Cli::try_parse_from([
714 "ai-usagebar",
715 "usage",
716 "--json",
717 "--priorities",
718 flag,
719 value
720 ])
721 .is_err()
722 );
723 }
724 for hours in ["0", "25", "-1", "1.5", "invalid"] {
725 assert!(
726 Cli::try_parse_from([
727 "ai-usagebar",
728 "usage",
729 "--json",
730 "--priorities",
731 "--recent-hours",
732 hours
733 ])
734 .is_err()
735 );
736 }
737 }
738
739 #[test]
740 fn priorities_are_opt_in_and_only_with_json() {
741 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
743 assert!(matches!(
744 cli.command,
745 Some(Command::Usage {
746 priorities: false,
747 ..
748 })
749 ));
750 assert!(Cli::try_parse_from(["ai-usagebar", "usage", "--priorities"]).is_err());
752 for flag in ["--recent-hours", "--alert-unused-percent", "--alert-hours"] {
753 assert!(
754 Cli::try_parse_from(["ai-usagebar", "usage", "--json", flag, "6"]).is_err(),
755 "{flag} requires --priorities"
756 );
757 }
758 }
759
760 #[test]
761 fn usage_subcommand_parses_machine_readable_mode() {
762 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
763 assert!(matches!(
764 cli.command,
765 Some(Command::Usage {
766 json: true,
767 priorities: false,
768 recent_hours: 3,
769 alert_unused_percent: 50,
770 alert_hours: 24,
771 })
772 ));
773 }
774
775 #[test]
776 fn mcp_subcommand_parses_without_widget_flags() {
777 let cli = Cli::parse_from(["ai-usagebar", "mcp"]);
778 assert!(matches!(cli.command, Some(Command::Mcp)));
779 assert!(Cli::try_parse_from(["ai-usagebar", "--pretty", "mcp"]).is_err());
780 }
781
782 #[test]
783 fn usage_help_states_complete_document_exits_zero() {
784 let err = Cli::try_parse_from(["ai-usagebar", "usage", "--help"])
785 .expect_err("help exits through clap's display path");
786 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
787 let help = err.to_string();
788 assert!(
789 help.contains("Exits 0 after printing a complete document"),
790 "{help}"
791 );
792 assert!(
793 help.contains("even when every entry") && help.contains("error"),
794 "{help}"
795 );
796 assert!(
797 help.contains("Non-zero only when the document cannot be produced"),
798 "{help}"
799 );
800 }
801
802 #[test]
803 fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
804 let bare = Cli::parse_from(["ai-usagebar", "detect"]);
805 assert!(matches!(
806 bare.command,
807 Some(Command::Detect {
808 all: false,
809 json: false
810 })
811 ));
812
813 let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
814 assert!(matches!(
815 full.command,
816 Some(Command::Detect {
817 all: true,
818 json: true
819 })
820 ));
821
822 assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
823 }
824
825 #[test]
826 fn new_vendor_values_and_auth_commands_parse_exactly() {
827 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
828 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
829 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
830 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
831 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
832 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
833 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
834 assert!(matches!(login.command, Some(Command::Auth { .. })));
835 }
836
837 #[test]
838 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
839 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
840 assert!(matches!(
841 show.command,
842 Some(Command::Settings {
843 action: SettingsAction::Show
844 })
845 ));
846
847 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
848 assert!(matches!(
849 apply.command,
850 Some(Command::Settings {
851 action: SettingsAction::Apply
852 })
853 ));
854
855 assert!(
856 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
857 );
858 }
859
860 #[test]
861 fn defaults_match_claudebar() {
862 let cli = Cli::parse_from(["ai-usagebar"]);
863 assert_eq!(cli.vendor, None);
864 let cfg = crate::config::Config::default();
869 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
870 assert_eq!(cli.pace_tolerance, 5);
871 assert!(cli.format.is_none());
872 assert!(cli.tooltip_format.is_none());
873 assert!(cli.icon.is_none());
874 assert!(!cli.format_pace_color);
875 assert!(!cli.tooltip_pace_pts);
876 assert!(!cli.pretty);
877 assert!(!cli.json);
878 assert!(cli.watch.is_none());
879 assert!(cli.command.is_none());
880 }
881
882 #[test]
883 fn account_add_subcommand_parses_without_widget_flags() {
884 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
885 assert!(matches!(
886 cli.command,
887 Some(Command::Account {
888 action: AccountAction::Add {
889 ref label,
890 no_login: true,
891 desktop: false,
892 ..
893 }
894 }) if label == "work"
895 ));
896 }
897
898 #[test]
901 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
902 let cli = Cli::parse_from([
903 "ai-usagebar",
904 "account",
905 "add",
906 "work",
907 "--desktop",
908 "--email",
909 "a@b.test",
910 "-y",
911 ]);
912 assert!(matches!(
913 cli.command,
914 Some(Command::Account {
915 action: AccountAction::Add {
916 desktop: true,
917 yes: true,
918 email: Some(ref email),
919 ..
920 }
921 }) if email == "a@b.test"
922 ));
923 assert!(
924 Cli::try_parse_from([
925 "ai-usagebar",
926 "account",
927 "add",
928 "w",
929 "--desktop",
930 "--no-login"
931 ])
932 .is_err()
933 );
934 assert!(
936 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
937 .is_err()
938 );
939 }
940
941 #[test]
942 fn account_switch_defaults_to_both_surfaces() {
943 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
944 assert!(matches!(
945 cli.command,
946 Some(Command::Account {
947 action: AccountAction::Switch {
948 ref label,
949 desktop: false,
950 cli: false,
951 dry_run: true,
952 keep_backups: 10,
953 ..
954 }
955 }) if label == "work"
956 ));
957 }
958
959 #[test]
960 fn account_subcommand_rejects_ignored_widget_flags() {
961 assert!(
962 Cli::try_parse_from([
963 "ai-usagebar",
964 "--vendor",
965 "anthropic",
966 "account",
967 "add",
968 "work",
969 ])
970 .is_err()
971 );
972 }
973
974 #[test]
975 fn multi_account_flags_are_stable_api() {
976 let cli = Cli::parse_from([
980 "ai-usagebar",
981 "--vendor",
982 "anthropic",
983 "--cache-dir",
984 "/tmp/acct-a",
985 "--creds-path",
986 "/tmp/acct-a/credentials.json",
987 ]);
988 assert_eq!(
989 cli.cache_dir.as_deref(),
990 Some(std::path::Path::new("/tmp/acct-a"))
991 );
992 assert_eq!(
993 cli.creds_path.as_deref(),
994 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
995 );
996 }
997
998 #[test]
999 fn primary_from_config_wins_when_vendor_unset() {
1000 let cli = Cli::parse_from(["ai-usagebar"]);
1002 let mut cfg = crate::config::Config::default();
1003 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1004 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
1005 }
1006
1007 #[test]
1008 fn explicit_vendor_overrides_everything() {
1009 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
1012 let mut cfg = crate::config::Config::default();
1013 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1014 let active = Some(crate::vendor::VendorId::Openai);
1015 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
1016 }
1017
1018 #[test]
1019 fn vendor_kimi_parses_to_kimi_variant() {
1020 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
1021 assert_eq!(cli.vendor, Some(Vendor::Kimi));
1022 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
1023 }
1024
1025 #[test]
1026 fn vendor_grokbot_parses_to_grokbot_variant() {
1027 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "grokbot"]);
1028 assert_eq!(cli.vendor, Some(Vendor::Grokbot));
1029 assert_eq!(
1030 cli.vendor.unwrap().to_id(),
1031 crate::vendor::VendorId::Grokbot
1032 );
1033 assert_eq!(
1035 id_to_vendor(crate::vendor::VendorId::Grokbot),
1036 Vendor::Grokbot
1037 );
1038 }
1039
1040 #[test]
1041 fn vendor_modelstudio_parses_to_modelstudio_variant() {
1042 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "modelstudio"]);
1043 assert_eq!(cli.vendor, Some(Vendor::ModelStudio));
1044 assert_eq!(
1045 cli.vendor.unwrap().to_id(),
1046 crate::vendor::VendorId::ModelStudio
1047 );
1048 assert_eq!(
1049 id_to_vendor(crate::vendor::VendorId::ModelStudio),
1050 Vendor::ModelStudio
1051 );
1052 }
1053
1054 #[test]
1055 fn vendor_anthropic_api_uses_the_documented_slug() {
1056 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
1057 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
1058 assert_eq!(
1059 cli.vendor.unwrap().to_id(),
1060 crate::vendor::VendorId::AnthropicApi
1061 );
1062 }
1063
1064 #[test]
1065 fn every_vendor_parses_from_its_slug() {
1066 for vendor in <Vendor as clap::ValueEnum>::value_variants() {
1069 let slug = vendor.to_id().slug();
1070 let cli = Cli::parse_from(["ai-usagebar", "--vendor", slug]);
1071 assert_eq!(cli.vendor, Some(*vendor), "{slug}");
1072 }
1073 let legacy = Cli::parse_from(["ai-usagebar", "--vendor", "orca-router"]);
1074 assert_eq!(legacy.vendor, Some(Vendor::OrcaRouter));
1075 }
1076
1077 #[test]
1078 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
1079 let cli = Cli::parse_from(["ai-usagebar"]);
1080 let mut cfg = crate::config::Config::default();
1081 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
1082 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
1083 }
1084
1085 #[test]
1086 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
1087 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
1088 assert_eq!(
1089 cli.resolve_vendor_with(&crate::config::Config::default(), None),
1090 Vendor::Kimi
1091 );
1092 }
1093
1094 #[test]
1095 fn active_override_wins_over_config_primary_when_enabled() {
1096 let cli = Cli::parse_from(["ai-usagebar"]);
1099 let mut cfg = crate::config::Config::default();
1100 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1101 let active = Some(crate::vendor::VendorId::Zai);
1102 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
1103 }
1104
1105 #[test]
1106 fn disabled_active_override_falls_back_to_config_primary() {
1107 let cli = Cli::parse_from(["ai-usagebar"]);
1110 let mut cfg = crate::config::Config::default();
1111 cfg.zai.enabled = false;
1112 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
1113 let active = Some(crate::vendor::VendorId::Zai);
1114 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
1115 }
1116
1117 #[test]
1118 fn claudebar_compatible_flag_surface() {
1119 let cli = Cli::parse_from([
1120 "ai-usagebar",
1121 "--icon",
1122 "",
1123 "--format",
1124 "{session_pct}% · {session_reset}",
1125 "--tooltip-format",
1126 "S:{session_pct}",
1127 "--pace-tolerance",
1128 "10",
1129 "--format-pace-color",
1130 "--tooltip-pace-pts",
1131 "--color-low",
1132 "#50fa7b",
1133 "--color-mid",
1134 "#f1fa8c",
1135 "--color-high",
1136 "#ffb86c",
1137 "--color-critical",
1138 "#ff5555",
1139 ]);
1140 assert_eq!(cli.icon.as_deref(), Some(""));
1141 assert_eq!(
1142 cli.format.as_deref(),
1143 Some("{session_pct}% · {session_reset}")
1144 );
1145 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
1146 assert_eq!(cli.pace_tolerance, 10);
1147 assert!(cli.format_pace_color);
1148 assert!(cli.tooltip_pace_pts);
1149 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
1150 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
1151 }
1152
1153 #[test]
1154 fn pretty_and_json_conflict() {
1155 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
1156 assert!(res.is_err());
1157 }
1158
1159 #[test]
1160 fn watch_disables_json_output() {
1161 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
1162 assert_eq!(cli.watch, Some(5));
1163 assert!(!cli.output_json());
1164 }
1165}