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 Moonshot,
407 Grok,
408 Supergrok,
409 Grokbot,
410 Antigravity,
411 Cursor,
412 Minimax,
413 Kiro,
414 #[value(name = "nous")]
415 NousResearch,
416 #[value(name = "opencode-go")]
417 OpenCodeGo,
418 #[value(name = "commandcode")]
419 CommandCode,
420 Ollama,
421 #[value(name = "orcarouter", alias = "orca-router")]
424 OrcaRouter,
425 #[value(name = "modelstudio")]
426 ModelStudio,
427 Lyceum,
428}
429
430impl Vendor {
431 pub fn to_id(self) -> crate::vendor::VendorId {
432 match self {
433 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
434 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
435 Vendor::Openai => crate::vendor::VendorId::Openai,
436 Vendor::Copilot => crate::vendor::VendorId::Copilot,
437 Vendor::Zai => crate::vendor::VendorId::Zai,
438 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
439 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
440 Vendor::Deepinfra => crate::vendor::VendorId::Deepinfra,
441 Vendor::Kimi => crate::vendor::VendorId::Kimi,
442 Vendor::Kilo => crate::vendor::VendorId::Kilo,
443 Vendor::Novita => crate::vendor::VendorId::Novita,
444 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
445 Vendor::Grok => crate::vendor::VendorId::Grok,
446 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
447 Vendor::Grokbot => crate::vendor::VendorId::Grokbot,
448 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
449 Vendor::Cursor => crate::vendor::VendorId::Cursor,
450 Vendor::Minimax => crate::vendor::VendorId::Minimax,
451 Vendor::Kiro => crate::vendor::VendorId::Kiro,
452 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
453 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
454 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
455 Vendor::Ollama => crate::vendor::VendorId::Ollama,
456 Vendor::OrcaRouter => crate::vendor::VendorId::OrcaRouter,
457 Vendor::ModelStudio => crate::vendor::VendorId::ModelStudio,
458 Vendor::Lyceum => crate::vendor::VendorId::Lyceum,
459 }
460 }
461}
462
463impl Cli {
464 pub fn has_explicit_vendor(&self) -> bool {
466 self.vendor.is_some()
467 }
468
469 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
480 let active = if self.has_explicit_vendor() {
486 None
487 } else {
488 crate::active::read()
489 };
490 self.resolve_vendor_with(config, active)
491 }
492
493 pub fn resolve_vendor_with(
498 &self,
499 config: &crate::config::Config,
500 active: Option<crate::vendor::VendorId>,
501 ) -> Vendor {
502 if let Some(v) = self.vendor {
503 return v;
504 }
505 if let Some(id) = active
506 && config.is_enabled(id)
507 {
508 return id_to_vendor(id);
509 }
510 if let Some(id) = config.ui.primary
511 && config.is_enabled(id)
512 {
513 return id_to_vendor(id);
514 }
515 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
516 return Vendor::Anthropic;
517 }
518 config
519 .enabled_vendors()
520 .into_iter()
521 .next()
522 .map(id_to_vendor)
523 .unwrap_or(Vendor::Anthropic)
526 }
527}
528
529fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
530 match id {
531 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
532 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
533 crate::vendor::VendorId::Openai => Vendor::Openai,
534 crate::vendor::VendorId::Copilot => Vendor::Copilot,
535 crate::vendor::VendorId::Zai => Vendor::Zai,
536 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
537 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
538 crate::vendor::VendorId::Deepinfra => Vendor::Deepinfra,
539 crate::vendor::VendorId::Kimi => Vendor::Kimi,
540 crate::vendor::VendorId::Kilo => Vendor::Kilo,
541 crate::vendor::VendorId::Novita => Vendor::Novita,
542 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
543 crate::vendor::VendorId::Grok => Vendor::Grok,
544 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
545 crate::vendor::VendorId::Grokbot => Vendor::Grokbot,
546 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
547 crate::vendor::VendorId::Cursor => Vendor::Cursor,
548 crate::vendor::VendorId::Minimax => Vendor::Minimax,
549 crate::vendor::VendorId::Kiro => Vendor::Kiro,
550 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
551 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
552 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
553 crate::vendor::VendorId::Ollama => Vendor::Ollama,
554 crate::vendor::VendorId::OrcaRouter => Vendor::OrcaRouter,
555 crate::vendor::VendorId::ModelStudio => Vendor::ModelStudio,
556 crate::vendor::VendorId::Lyceum => Vendor::Lyceum,
557 }
558}
559
560impl Cli {
561 pub fn output_json(&self) -> bool {
564 if self.json {
565 return true;
566 }
567 if self.pretty || self.watch.is_some() {
568 return false;
569 }
570 !is_stdout_tty()
572 }
573}
574
575fn is_stdout_tty() -> bool {
576 use std::io::IsTerminal;
577 std::io::stdout().is_terminal()
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use clap::{Parser, error::ErrorKind};
584
585 #[test]
586 fn settings_enable_requires_a_known_vendor() {
587 assert!(matches!(
588 Cli::try_parse_from(["ai-usagebar", "settings", "enable", "anthropic"])
589 .unwrap()
590 .command,
591 Some(Command::Settings {
592 action: SettingsAction::Enable {
593 vendor: Vendor::Anthropic
594 }
595 })
596 ));
597 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable", "unknown"]).is_err());
598 assert!(Cli::try_parse_from(["ai-usagebar", "settings", "enable"]).is_err());
599 }
600
601 #[test]
602 fn antigravity_statusline_actions_parse_and_require_an_action() {
603 for (arguments, expected) in [
604 (&["antigravity", "setup-statusline"][..], "setup"),
605 (&["antigravity", "remove-statusline"][..], "remove"),
606 (&["antigravity", "ingest-statusline"][..], "ingest"),
607 ] {
608 let cli = Cli::try_parse_from(
609 std::iter::once("ai-usagebar").chain(arguments.iter().copied()),
610 )
611 .unwrap();
612 assert!(
613 matches!(cli.command, Some(Command::Antigravity { .. })),
614 "{expected}"
615 );
616 }
617 assert!(Cli::try_parse_from(["ai-usagebar", "antigravity"]).is_err());
618 }
619
620 #[test]
621 fn version_flags_report_the_crate_version() {
622 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
623
624 for flag in ["--version", "-V"] {
625 let err = Cli::try_parse_from(["ai-usagebar", flag])
626 .expect_err("a version flag exits through clap's display path");
627 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
628 assert_eq!(err.to_string(), expected, "flag: {flag}");
629 }
630 }
631
632 #[test]
633 fn usage_subcommand_parses_machine_readable_mode() {
634 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
635 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
636 }
637
638 #[test]
639 fn usage_help_states_complete_document_exits_zero() {
640 let err = Cli::try_parse_from(["ai-usagebar", "usage", "--help"])
641 .expect_err("help exits through clap's display path");
642 assert_eq!(err.kind(), ErrorKind::DisplayHelp);
643 let help = err.to_string();
644 assert!(
645 help.contains("Exits 0 after printing a complete document"),
646 "{help}"
647 );
648 assert!(
649 help.contains("even when every entry") && help.contains("error"),
650 "{help}"
651 );
652 assert!(
653 help.contains("Non-zero only when the document cannot be produced"),
654 "{help}"
655 );
656 }
657
658 #[test]
659 fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
660 let bare = Cli::parse_from(["ai-usagebar", "detect"]);
661 assert!(matches!(
662 bare.command,
663 Some(Command::Detect {
664 all: false,
665 json: false
666 })
667 ));
668
669 let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
670 assert!(matches!(
671 full.command,
672 Some(Command::Detect {
673 all: true,
674 json: true
675 })
676 ));
677
678 assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
679 }
680
681 #[test]
682 fn new_vendor_values_and_auth_commands_parse_exactly() {
683 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
684 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
685 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
686 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
687 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
688 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
689 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
690 assert!(matches!(login.command, Some(Command::Auth { .. })));
691 }
692
693 #[test]
694 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
695 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
696 assert!(matches!(
697 show.command,
698 Some(Command::Settings {
699 action: SettingsAction::Show
700 })
701 ));
702
703 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
704 assert!(matches!(
705 apply.command,
706 Some(Command::Settings {
707 action: SettingsAction::Apply
708 })
709 ));
710
711 assert!(
712 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
713 );
714 }
715
716 #[test]
717 fn defaults_match_claudebar() {
718 let cli = Cli::parse_from(["ai-usagebar"]);
719 assert_eq!(cli.vendor, None);
720 let cfg = crate::config::Config::default();
725 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
726 assert_eq!(cli.pace_tolerance, 5);
727 assert!(cli.format.is_none());
728 assert!(cli.tooltip_format.is_none());
729 assert!(cli.icon.is_none());
730 assert!(!cli.format_pace_color);
731 assert!(!cli.tooltip_pace_pts);
732 assert!(!cli.pretty);
733 assert!(!cli.json);
734 assert!(cli.watch.is_none());
735 assert!(cli.command.is_none());
736 }
737
738 #[test]
739 fn account_add_subcommand_parses_without_widget_flags() {
740 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
741 assert!(matches!(
742 cli.command,
743 Some(Command::Account {
744 action: AccountAction::Add {
745 ref label,
746 no_login: true,
747 desktop: false,
748 ..
749 }
750 }) if label == "work"
751 ));
752 }
753
754 #[test]
757 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
758 let cli = Cli::parse_from([
759 "ai-usagebar",
760 "account",
761 "add",
762 "work",
763 "--desktop",
764 "--email",
765 "a@b.test",
766 "-y",
767 ]);
768 assert!(matches!(
769 cli.command,
770 Some(Command::Account {
771 action: AccountAction::Add {
772 desktop: true,
773 yes: true,
774 email: Some(ref email),
775 ..
776 }
777 }) if email == "a@b.test"
778 ));
779 assert!(
780 Cli::try_parse_from([
781 "ai-usagebar",
782 "account",
783 "add",
784 "w",
785 "--desktop",
786 "--no-login"
787 ])
788 .is_err()
789 );
790 assert!(
792 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
793 .is_err()
794 );
795 }
796
797 #[test]
798 fn account_switch_defaults_to_both_surfaces() {
799 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
800 assert!(matches!(
801 cli.command,
802 Some(Command::Account {
803 action: AccountAction::Switch {
804 ref label,
805 desktop: false,
806 cli: false,
807 dry_run: true,
808 keep_backups: 10,
809 ..
810 }
811 }) if label == "work"
812 ));
813 }
814
815 #[test]
816 fn account_subcommand_rejects_ignored_widget_flags() {
817 assert!(
818 Cli::try_parse_from([
819 "ai-usagebar",
820 "--vendor",
821 "anthropic",
822 "account",
823 "add",
824 "work",
825 ])
826 .is_err()
827 );
828 }
829
830 #[test]
831 fn multi_account_flags_are_stable_api() {
832 let cli = Cli::parse_from([
836 "ai-usagebar",
837 "--vendor",
838 "anthropic",
839 "--cache-dir",
840 "/tmp/acct-a",
841 "--creds-path",
842 "/tmp/acct-a/credentials.json",
843 ]);
844 assert_eq!(
845 cli.cache_dir.as_deref(),
846 Some(std::path::Path::new("/tmp/acct-a"))
847 );
848 assert_eq!(
849 cli.creds_path.as_deref(),
850 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
851 );
852 }
853
854 #[test]
855 fn primary_from_config_wins_when_vendor_unset() {
856 let cli = Cli::parse_from(["ai-usagebar"]);
858 let mut cfg = crate::config::Config::default();
859 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
860 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
861 }
862
863 #[test]
864 fn explicit_vendor_overrides_everything() {
865 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
868 let mut cfg = crate::config::Config::default();
869 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
870 let active = Some(crate::vendor::VendorId::Openai);
871 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
872 }
873
874 #[test]
875 fn vendor_kimi_parses_to_kimi_variant() {
876 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
877 assert_eq!(cli.vendor, Some(Vendor::Kimi));
878 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
879 }
880
881 #[test]
882 fn vendor_grokbot_parses_to_grokbot_variant() {
883 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "grokbot"]);
884 assert_eq!(cli.vendor, Some(Vendor::Grokbot));
885 assert_eq!(
886 cli.vendor.unwrap().to_id(),
887 crate::vendor::VendorId::Grokbot
888 );
889 assert_eq!(
891 id_to_vendor(crate::vendor::VendorId::Grokbot),
892 Vendor::Grokbot
893 );
894 }
895
896 #[test]
897 fn vendor_modelstudio_parses_to_modelstudio_variant() {
898 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "modelstudio"]);
899 assert_eq!(cli.vendor, Some(Vendor::ModelStudio));
900 assert_eq!(
901 cli.vendor.unwrap().to_id(),
902 crate::vendor::VendorId::ModelStudio
903 );
904 assert_eq!(
905 id_to_vendor(crate::vendor::VendorId::ModelStudio),
906 Vendor::ModelStudio
907 );
908 }
909
910 #[test]
911 fn vendor_anthropic_api_uses_the_documented_slug() {
912 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
913 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
914 assert_eq!(
915 cli.vendor.unwrap().to_id(),
916 crate::vendor::VendorId::AnthropicApi
917 );
918 }
919
920 #[test]
921 fn every_vendor_parses_from_its_slug() {
922 for vendor in <Vendor as clap::ValueEnum>::value_variants() {
925 let slug = vendor.to_id().slug();
926 let cli = Cli::parse_from(["ai-usagebar", "--vendor", slug]);
927 assert_eq!(cli.vendor, Some(*vendor), "{slug}");
928 }
929 let legacy = Cli::parse_from(["ai-usagebar", "--vendor", "orca-router"]);
930 assert_eq!(legacy.vendor, Some(Vendor::OrcaRouter));
931 }
932
933 #[test]
934 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
935 let cli = Cli::parse_from(["ai-usagebar"]);
936 let mut cfg = crate::config::Config::default();
937 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
938 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
939 }
940
941 #[test]
942 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
943 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
944 assert_eq!(
945 cli.resolve_vendor_with(&crate::config::Config::default(), None),
946 Vendor::Kimi
947 );
948 }
949
950 #[test]
951 fn active_override_wins_over_config_primary_when_enabled() {
952 let cli = Cli::parse_from(["ai-usagebar"]);
955 let mut cfg = crate::config::Config::default();
956 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
957 let active = Some(crate::vendor::VendorId::Zai);
958 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
959 }
960
961 #[test]
962 fn disabled_active_override_falls_back_to_config_primary() {
963 let cli = Cli::parse_from(["ai-usagebar"]);
966 let mut cfg = crate::config::Config::default();
967 cfg.zai.enabled = false;
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::Openrouter);
971 }
972
973 #[test]
974 fn claudebar_compatible_flag_surface() {
975 let cli = Cli::parse_from([
976 "ai-usagebar",
977 "--icon",
978 "",
979 "--format",
980 "{session_pct}% · {session_reset}",
981 "--tooltip-format",
982 "S:{session_pct}",
983 "--pace-tolerance",
984 "10",
985 "--format-pace-color",
986 "--tooltip-pace-pts",
987 "--color-low",
988 "#50fa7b",
989 "--color-mid",
990 "#f1fa8c",
991 "--color-high",
992 "#ffb86c",
993 "--color-critical",
994 "#ff5555",
995 ]);
996 assert_eq!(cli.icon.as_deref(), Some(""));
997 assert_eq!(
998 cli.format.as_deref(),
999 Some("{session_pct}% · {session_reset}")
1000 );
1001 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
1002 assert_eq!(cli.pace_tolerance, 10);
1003 assert!(cli.format_pace_color);
1004 assert!(cli.tooltip_pace_pts);
1005 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
1006 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
1007 }
1008
1009 #[test]
1010 fn pretty_and_json_conflict() {
1011 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
1012 assert!(res.is_err());
1013 }
1014
1015 #[test]
1016 fn watch_disables_json_output() {
1017 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
1018 assert_eq!(cli.watch, Some(5));
1019 assert!(!cli.output_json());
1020 }
1021}