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