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