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