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 },
170
171 Mcp,
173
174 Detect {
177 #[arg(long)]
179 all: bool,
180 #[arg(long)]
182 json: bool,
183 },
184
185 Vendors {
190 #[arg(long)]
192 json: bool,
193 },
194
195 Settings {
197 #[command(subcommand)]
198 action: SettingsAction,
199 },
200
201 Auth {
203 #[command(subcommand)]
204 provider: AuthProvider,
205 },
206}
207
208#[derive(clap::Subcommand, Debug, Clone, Copy, PartialEq, Eq)]
209pub enum AntigravityAction {
210 SetupStatusline,
212 RemoveStatusline,
214 IngestStatusline,
216}
217
218#[derive(clap::Subcommand, Debug, Clone)]
219pub enum AuthProvider {
220 Nous {
221 #[command(subcommand)]
222 action: NousAuthAction,
223 },
224}
225
226#[derive(clap::Subcommand, Debug, Clone)]
227pub enum NousAuthAction {
228 Login,
230 Logout,
232}
233
234#[derive(clap::Subcommand, Debug, Clone)]
235pub enum SettingsAction {
236 Enable {
238 #[arg(value_enum)]
239 vendor: Vendor,
240 },
241
242 Show,
244
245 Apply,
247}
248
249#[derive(clap::Subcommand, Debug, Clone)]
250pub enum AccountAction {
251 Add {
253 label: String,
255
256 #[arg(long, conflicts_with = "desktop")]
258 no_login: bool,
259
260 #[arg(long)]
265 desktop: bool,
266
267 #[arg(long, requires = "desktop")]
270 email: Option<String>,
271
272 #[arg(short = 'y', long, requires = "desktop")]
274 yes: bool,
275
276 #[arg(long, conflicts_with = "desktop")]
280 codex: bool,
281
282 #[arg(long, conflicts_with_all = ["desktop", "no_login"])]
286 adopt_current: bool,
287 },
288
289 Status {
292 #[arg(long)]
294 json: bool,
295 },
296
297 Switch {
300 label: String,
304
305 #[arg(
308 long,
309 conflicts_with_all = ["desktop", "cli", "keep_bridge", "backup_sessions", "delete_conflict"]
310 )]
311 codex: bool,
312
313 #[arg(long)]
315 desktop: bool,
316
317 #[arg(long)]
319 cli: bool,
320
321 #[arg(long)]
323 dry_run: bool,
324
325 #[arg(short = 'y', long)]
327 yes: bool,
328
329 #[arg(long)]
333 force: bool,
334
335 #[arg(long)]
338 keep_bridge: bool,
339
340 #[arg(long)]
343 backup_sessions: bool,
344
345 #[arg(long, default_value_t = 10)]
347 keep_backups: usize,
348
349 #[arg(long, value_name = "KEY")]
356 delete_conflict: Vec<String>,
357 },
358
359 MergeHistory {
374 #[arg(long, value_name = "DIR")]
378 data_dir: std::path::PathBuf,
379
380 #[arg(long, value_name = "DIR")]
387 from: Vec<std::path::PathBuf>,
388
389 #[arg(long)]
391 dry_run: bool,
392 },
393}
394
395#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
396pub enum Vendor {
397 Anthropic,
398 #[value(name = "anthropic_api")]
399 AnthropicApi,
400 Openai,
401 Copilot,
402 Zai,
403 Openrouter,
404 Deepseek,
405 Deepinfra,
406 Kimi,
407 Kilo,
408 Novita,
409 Synthetic,
410 Moonshot,
411 Grok,
412 Supergrok,
413 Grokbot,
414 Antigravity,
415 Cursor,
416 Minimax,
417 Kiro,
418 #[value(name = "nous")]
419 NousResearch,
420 #[value(name = "opencode-go")]
421 OpenCodeGo,
422 #[value(name = "commandcode")]
423 CommandCode,
424 Ollama,
425 #[value(name = "orcarouter", alias = "orca-router")]
428 OrcaRouter,
429 #[value(name = "modelstudio")]
430 ModelStudio,
431 Lyceum,
432 Devin,
433 MuseCode,
434 Cline,
435 Droid,
436}
437
438impl Vendor {
439 pub fn to_id(self) -> crate::vendor::VendorId {
440 match self {
441 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
442 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
443 Vendor::Openai => crate::vendor::VendorId::Openai,
444 Vendor::Copilot => crate::vendor::VendorId::Copilot,
445 Vendor::Zai => crate::vendor::VendorId::Zai,
446 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
447 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
448 Vendor::Deepinfra => crate::vendor::VendorId::Deepinfra,
449 Vendor::Kimi => crate::vendor::VendorId::Kimi,
450 Vendor::Kilo => crate::vendor::VendorId::Kilo,
451 Vendor::Novita => crate::vendor::VendorId::Novita,
452 Vendor::Synthetic => crate::vendor::VendorId::Synthetic,
453 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
454 Vendor::Grok => crate::vendor::VendorId::Grok,
455 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
456 Vendor::Grokbot => crate::vendor::VendorId::Grokbot,
457 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
458 Vendor::Cursor => crate::vendor::VendorId::Cursor,
459 Vendor::Minimax => crate::vendor::VendorId::Minimax,
460 Vendor::Kiro => crate::vendor::VendorId::Kiro,
461 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
462 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
463 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
464 Vendor::Ollama => crate::vendor::VendorId::Ollama,
465 Vendor::OrcaRouter => crate::vendor::VendorId::OrcaRouter,
466 Vendor::ModelStudio => crate::vendor::VendorId::ModelStudio,
467 Vendor::Lyceum => crate::vendor::VendorId::Lyceum,
468 Vendor::Devin => crate::vendor::VendorId::Devin,
469 Vendor::MuseCode => crate::vendor::VendorId::MuseCode,
470 Vendor::Cline => crate::vendor::VendorId::Cline,
471 Vendor::Droid => crate::vendor::VendorId::Droid,
472 }
473 }
474}
475
476impl Cli {
477 pub fn has_explicit_vendor(&self) -> bool {
479 self.vendor.is_some()
480 }
481
482 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
493 let active = if self.has_explicit_vendor() {
499 None
500 } else {
501 crate::active::read()
502 };
503 self.resolve_vendor_with(config, active)
504 }
505
506 pub fn resolve_vendor_with(
511 &self,
512 config: &crate::config::Config,
513 active: Option<crate::vendor::VendorId>,
514 ) -> Vendor {
515 if let Some(v) = self.vendor {
516 return v;
517 }
518 if let Some(id) = active
519 && config.is_enabled(id)
520 {
521 return id_to_vendor(id);
522 }
523 if let Some(id) = config.ui.primary
524 && config.is_enabled(id)
525 {
526 return id_to_vendor(id);
527 }
528 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
529 return Vendor::Anthropic;
530 }
531 config
532 .enabled_vendors()
533 .into_iter()
534 .next()
535 .map(id_to_vendor)
536 .unwrap_or(Vendor::Anthropic)
539 }
540}
541
542fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
543 match id {
544 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
545 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
546 crate::vendor::VendorId::Openai => Vendor::Openai,
547 crate::vendor::VendorId::Copilot => Vendor::Copilot,
548 crate::vendor::VendorId::Zai => Vendor::Zai,
549 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
550 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
551 crate::vendor::VendorId::Deepinfra => Vendor::Deepinfra,
552 crate::vendor::VendorId::Kimi => Vendor::Kimi,
553 crate::vendor::VendorId::Kilo => Vendor::Kilo,
554 crate::vendor::VendorId::Novita => Vendor::Novita,
555 crate::vendor::VendorId::Synthetic => Vendor::Synthetic,
556 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
557 crate::vendor::VendorId::Grok => Vendor::Grok,
558 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
559 crate::vendor::VendorId::Grokbot => Vendor::Grokbot,
560 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
561 crate::vendor::VendorId::Cursor => Vendor::Cursor,
562 crate::vendor::VendorId::Minimax => Vendor::Minimax,
563 crate::vendor::VendorId::Kiro => Vendor::Kiro,
564 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
565 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
566 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
567 crate::vendor::VendorId::Ollama => Vendor::Ollama,
568 crate::vendor::VendorId::OrcaRouter => Vendor::OrcaRouter,
569 crate::vendor::VendorId::ModelStudio => Vendor::ModelStudio,
570 crate::vendor::VendorId::Lyceum => Vendor::Lyceum,
571 crate::vendor::VendorId::Devin => Vendor::Devin,
572 crate::vendor::VendorId::MuseCode => Vendor::MuseCode,
573 crate::vendor::VendorId::Cline => Vendor::Cline,
574 crate::vendor::VendorId::Droid => Vendor::Droid,
575 }
576}
577
578impl Cli {
579 pub fn output_json(&self) -> bool {
582 if self.json {
583 return true;
584 }
585 if self.pretty || self.watch.is_some() {
586 return false;
587 }
588 !is_stdout_tty()
590 }
591}
592
593fn is_stdout_tty() -> bool {
594 use std::io::IsTerminal;
595 std::io::stdout().is_terminal()
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use clap::{Parser, error::ErrorKind};
602
603 #[test]
604 fn settings_enable_requires_a_known_vendor() {
605 assert!(matches!(
606 Cli::try_parse_from(["ai-usagebar", "settings", "enable", "anthropic"])
607 .unwrap()
608 .command,
609 Some(Command::Settings {
610 action: SettingsAction::Enable {
611 vendor: Vendor::Anthropic
612 }
613 })
614 ));
615 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable", "unknown"]).is_err());
616 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable"]).is_err());
617 }
618
619 #[test]
620 fn antigravity_statusline_actions_parse_and_require_an_action() {
621 for (arguments, expected) in [
622 (&["antigravity", "setup-statusline"][..], "setup"),
623 (&["antigravity", "remove-statusline"][..], "remove"),
624 (&["antigravity", "ingest-statusline"][..], "ingest"),
625 ] {
626 let cli = Cli::try_parse_from(
627 std::iter::once("ai-usagebar").chain(arguments.iter().copied()),
628 )
629 .unwrap();
630 assert!(
631 matches!(cli.command, Some(Command::Antigravity { .. })),
632 "{expected}"
633 );
634 }
635 assert!(Cli::try_parse_from(["ai-usagebar", "antigravity"]).is_err());
636 }
637
638 #[test]
639 fn version_flags_report_the_crate_version() {
640 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
641
642 for flag in ["--version", "-V"] {
643 let err = Cli::try_parse_from(["ai-usagebar", flag])
644 .expect_err("a version flag exits through clap's display path");
645 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
646 assert_eq!(err.to_string(), expected, "flag: {flag}");
647 }
648 }
649
650 #[test]
651 fn usage_subcommand_parses_machine_readable_mode() {
652 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
653 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
654 }
655
656 #[test]
657 fn mcp_subcommand_parses_without_widget_flags() {
658 let cli = Cli::parse_from(["ai-usagebar", "mcp"]);
659 assert!(matches!(cli.command, Some(Command::Mcp)));
660 assert!(Cli::try_parse_from(["ai-usagebar", "--pretty", "mcp"]).is_err());
661 }
662
663 #[test]
664 fn usage_help_states_complete_document_exits_zero() {
665 let err = Cli::try_parse_from(["ai-usagebar", "usage", "--help"])
666 .expect_err("help exits through clap's display path");
667 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
668 let help = err.to_string();
669 assert!(
670 help.contains("Exits 0 after printing a complete document"),
671 "{help}"
672 );
673 assert!(
674 help.contains("even when every entry") && help.contains("error"),
675 "{help}"
676 );
677 assert!(
678 help.contains("Non-zero only when the document cannot be produced"),
679 "{help}"
680 );
681 }
682
683 #[test]
684 fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
685 let bare = Cli::parse_from(["ai-usagebar", "detect"]);
686 assert!(matches!(
687 bare.command,
688 Some(Command::Detect {
689 all: false,
690 json: false
691 })
692 ));
693
694 let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
695 assert!(matches!(
696 full.command,
697 Some(Command::Detect {
698 all: true,
699 json: true
700 })
701 ));
702
703 assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
704 }
705
706 #[test]
707 fn new_vendor_values_and_auth_commands_parse_exactly() {
708 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
709 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
710 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
711 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
712 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
713 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
714 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
715 assert!(matches!(login.command, Some(Command::Auth { .. })));
716 }
717
718 #[test]
719 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
720 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
721 assert!(matches!(
722 show.command,
723 Some(Command::Settings {
724 action: SettingsAction::Show
725 })
726 ));
727
728 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
729 assert!(matches!(
730 apply.command,
731 Some(Command::Settings {
732 action: SettingsAction::Apply
733 })
734 ));
735
736 assert!(
737 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
738 );
739 }
740
741 #[test]
742 fn defaults_match_claudebar() {
743 let cli = Cli::parse_from(["ai-usagebar"]);
744 assert_eq!(cli.vendor, None);
745 let cfg = crate::config::Config::default();
750 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
751 assert_eq!(cli.pace_tolerance, 5);
752 assert!(cli.format.is_none());
753 assert!(cli.tooltip_format.is_none());
754 assert!(cli.icon.is_none());
755 assert!(!cli.format_pace_color);
756 assert!(!cli.tooltip_pace_pts);
757 assert!(!cli.pretty);
758 assert!(!cli.json);
759 assert!(cli.watch.is_none());
760 assert!(cli.command.is_none());
761 }
762
763 #[test]
764 fn account_add_subcommand_parses_without_widget_flags() {
765 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
766 assert!(matches!(
767 cli.command,
768 Some(Command::Account {
769 action: AccountAction::Add {
770 ref label,
771 no_login: true,
772 desktop: false,
773 ..
774 }
775 }) if label == "work"
776 ));
777 }
778
779 #[test]
782 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
783 let cli = Cli::parse_from([
784 "ai-usagebar",
785 "account",
786 "add",
787 "work",
788 "--desktop",
789 "--email",
790 "a@b.test",
791 "-y",
792 ]);
793 assert!(matches!(
794 cli.command,
795 Some(Command::Account {
796 action: AccountAction::Add {
797 desktop: true,
798 yes: true,
799 email: Some(ref email),
800 ..
801 }
802 }) if email == "a@b.test"
803 ));
804 assert!(
805 Cli::try_parse_from([
806 "ai-usagebar",
807 "account",
808 "add",
809 "w",
810 "--desktop",
811 "--no-login"
812 ])
813 .is_err()
814 );
815 assert!(
817 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
818 .is_err()
819 );
820 }
821
822 #[test]
823 fn account_switch_defaults_to_both_surfaces() {
824 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
825 assert!(matches!(
826 cli.command,
827 Some(Command::Account {
828 action: AccountAction::Switch {
829 ref label,
830 desktop: false,
831 cli: false,
832 dry_run: true,
833 keep_backups: 10,
834 ..
835 }
836 }) if label == "work"
837 ));
838 }
839
840 #[test]
841 fn account_subcommand_rejects_ignored_widget_flags() {
842 assert!(
843 Cli::try_parse_from([
844 "ai-usagebar",
845 "--vendor",
846 "anthropic",
847 "account",
848 "add",
849 "work",
850 ])
851 .is_err()
852 );
853 }
854
855 #[test]
856 fn multi_account_flags_are_stable_api() {
857 let cli = Cli::parse_from([
861 "ai-usagebar",
862 "--vendor",
863 "anthropic",
864 "--cache-dir",
865 "/tmp/acct-a",
866 "--creds-path",
867 "/tmp/acct-a/credentials.json",
868 ]);
869 assert_eq!(
870 cli.cache_dir.as_deref(),
871 Some(std::path::Path::new("/tmp/acct-a"))
872 );
873 assert_eq!(
874 cli.creds_path.as_deref(),
875 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
876 );
877 }
878
879 #[test]
880 fn primary_from_config_wins_when_vendor_unset() {
881 let cli = Cli::parse_from(["ai-usagebar"]);
883 let mut cfg = crate::config::Config::default();
884 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
885 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
886 }
887
888 #[test]
889 fn explicit_vendor_overrides_everything() {
890 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
893 let mut cfg = crate::config::Config::default();
894 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
895 let active = Some(crate::vendor::VendorId::Openai);
896 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
897 }
898
899 #[test]
900 fn vendor_kimi_parses_to_kimi_variant() {
901 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
902 assert_eq!(cli.vendor, Some(Vendor::Kimi));
903 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
904 }
905
906 #[test]
907 fn vendor_grokbot_parses_to_grokbot_variant() {
908 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "grokbot"]);
909 assert_eq!(cli.vendor, Some(Vendor::Grokbot));
910 assert_eq!(
911 cli.vendor.unwrap().to_id(),
912 crate::vendor::VendorId::Grokbot
913 );
914 assert_eq!(
916 id_to_vendor(crate::vendor::VendorId::Grokbot),
917 Vendor::Grokbot
918 );
919 }
920
921 #[test]
922 fn vendor_modelstudio_parses_to_modelstudio_variant() {
923 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "modelstudio"]);
924 assert_eq!(cli.vendor, Some(Vendor::ModelStudio));
925 assert_eq!(
926 cli.vendor.unwrap().to_id(),
927 crate::vendor::VendorId::ModelStudio
928 );
929 assert_eq!(
930 id_to_vendor(crate::vendor::VendorId::ModelStudio),
931 Vendor::ModelStudio
932 );
933 }
934
935 #[test]
936 fn vendor_anthropic_api_uses_the_documented_slug() {
937 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
938 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
939 assert_eq!(
940 cli.vendor.unwrap().to_id(),
941 crate::vendor::VendorId::AnthropicApi
942 );
943 }
944
945 #[test]
946 fn every_vendor_parses_from_its_slug() {
947 for vendor in <Vendor as clap::ValueEnum>::value_variants() {
950 let slug = vendor.to_id().slug();
951 let cli = Cli::parse_from(["ai-usagebar", "--vendor", slug]);
952 assert_eq!(cli.vendor, Some(*vendor), "{slug}");
953 }
954 let legacy = Cli::parse_from(["ai-usagebar", "--vendor", "orca-router"]);
955 assert_eq!(legacy.vendor, Some(Vendor::OrcaRouter));
956 }
957
958 #[test]
959 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
960 let cli = Cli::parse_from(["ai-usagebar"]);
961 let mut cfg = crate::config::Config::default();
962 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
963 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
964 }
965
966 #[test]
967 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
968 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
969 assert_eq!(
970 cli.resolve_vendor_with(&crate::config::Config::default(), None),
971 Vendor::Kimi
972 );
973 }
974
975 #[test]
976 fn active_override_wins_over_config_primary_when_enabled() {
977 let cli = Cli::parse_from(["ai-usagebar"]);
980 let mut cfg = crate::config::Config::default();
981 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
982 let active = Some(crate::vendor::VendorId::Zai);
983 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
984 }
985
986 #[test]
987 fn disabled_active_override_falls_back_to_config_primary() {
988 let cli = Cli::parse_from(["ai-usagebar"]);
991 let mut cfg = crate::config::Config::default();
992 cfg.zai.enabled = false;
993 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
994 let active = Some(crate::vendor::VendorId::Zai);
995 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
996 }
997
998 #[test]
999 fn claudebar_compatible_flag_surface() {
1000 let cli = Cli::parse_from([
1001 "ai-usagebar",
1002 "--icon",
1003 "",
1004 "--format",
1005 "{session_pct}% · {session_reset}",
1006 "--tooltip-format",
1007 "S:{session_pct}",
1008 "--pace-tolerance",
1009 "10",
1010 "--format-pace-color",
1011 "--tooltip-pace-pts",
1012 "--color-low",
1013 "#50fa7b",
1014 "--color-mid",
1015 "#f1fa8c",
1016 "--color-high",
1017 "#ffb86c",
1018 "--color-critical",
1019 "#ff5555",
1020 ]);
1021 assert_eq!(cli.icon.as_deref(), Some(""));
1022 assert_eq!(
1023 cli.format.as_deref(),
1024 Some("{session_pct}% · {session_reset}")
1025 );
1026 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
1027 assert_eq!(cli.pace_tolerance, 10);
1028 assert!(cli.format_pace_color);
1029 assert!(cli.tooltip_pace_pts);
1030 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
1031 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
1032 }
1033
1034 #[test]
1035 fn pretty_and_json_conflict() {
1036 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
1037 assert!(res.is_err());
1038 }
1039
1040 #[test]
1041 fn watch_disables_json_output() {
1042 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
1043 assert_eq!(cli.watch, Some(5));
1044 assert!(!cli.output_json());
1045 }
1046}