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