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