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