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.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")]
129 pub account: Option<String>,
130
131 #[arg(long, requires = "account")]
136 pub desktop: bool,
137
138 #[command(subcommand)]
140 pub command: Option<Command>,
141}
142
143#[derive(clap::Subcommand, Debug, Clone)]
144pub enum Command {
145 Account {
147 #[command(subcommand)]
148 action: AccountAction,
149 },
150
151 Usage {
153 #[arg(long)]
155 json: bool,
156 },
157
158 Detect {
161 #[arg(long)]
163 all: bool,
164 #[arg(long)]
166 json: bool,
167 },
168
169 Vendors {
174 #[arg(long)]
176 json: bool,
177 },
178
179 Settings {
181 #[command(subcommand)]
182 action: SettingsAction,
183 },
184
185 Auth {
187 #[command(subcommand)]
188 provider: AuthProvider,
189 },
190}
191
192#[derive(clap::Subcommand, Debug, Clone)]
193pub enum AuthProvider {
194 Nous {
195 #[command(subcommand)]
196 action: NousAuthAction,
197 },
198}
199
200#[derive(clap::Subcommand, Debug, Clone)]
201pub enum NousAuthAction {
202 Login,
204 Logout,
206}
207
208#[derive(clap::Subcommand, Debug, Clone)]
209pub enum SettingsAction {
210 Show,
212
213 Apply,
215}
216
217#[derive(clap::Subcommand, Debug, Clone)]
218pub enum AccountAction {
219 Add {
221 label: String,
223
224 #[arg(long, conflicts_with = "desktop")]
226 no_login: bool,
227
228 #[arg(long)]
233 desktop: bool,
234
235 #[arg(long, requires = "desktop")]
238 email: Option<String>,
239
240 #[arg(short = 'y', long, requires = "desktop")]
242 yes: bool,
243 },
244
245 Status {
247 #[arg(long)]
249 json: bool,
250 },
251
252 Switch {
254 label: String,
257
258 #[arg(long)]
260 desktop: bool,
261
262 #[arg(long)]
264 cli: bool,
265
266 #[arg(long)]
268 dry_run: bool,
269
270 #[arg(short = 'y', long)]
272 yes: bool,
273
274 #[arg(long)]
277 force: bool,
278
279 #[arg(long)]
282 keep_bridge: bool,
283
284 #[arg(long)]
287 backup_sessions: bool,
288
289 #[arg(long, default_value_t = 10)]
291 keep_backups: usize,
292
293 #[arg(long, value_name = "KEY")]
300 delete_conflict: Vec<String>,
301 },
302}
303
304#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
305pub enum Vendor {
306 Anthropic,
307 #[value(name = "anthropic_api")]
308 AnthropicApi,
309 Openai,
310 Copilot,
311 Zai,
312 Openrouter,
313 Deepseek,
314 Kimi,
315 Kilo,
316 Novita,
317 Moonshot,
318 Grok,
319 Supergrok,
320 Antigravity,
321 Cursor,
322 Minimax,
323 Kiro,
324 #[value(name = "nous")]
325 NousResearch,
326 #[value(name = "opencode-go")]
327 OpenCodeGo,
328 #[value(name = "commandcode")]
329 CommandCode,
330 Ollama,
331}
332
333impl Vendor {
334 pub fn to_id(self) -> crate::vendor::VendorId {
335 match self {
336 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
337 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
338 Vendor::Openai => crate::vendor::VendorId::Openai,
339 Vendor::Copilot => crate::vendor::VendorId::Copilot,
340 Vendor::Zai => crate::vendor::VendorId::Zai,
341 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
342 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
343 Vendor::Kimi => crate::vendor::VendorId::Kimi,
344 Vendor::Kilo => crate::vendor::VendorId::Kilo,
345 Vendor::Novita => crate::vendor::VendorId::Novita,
346 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
347 Vendor::Grok => crate::vendor::VendorId::Grok,
348 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
349 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
350 Vendor::Cursor => crate::vendor::VendorId::Cursor,
351 Vendor::Minimax => crate::vendor::VendorId::Minimax,
352 Vendor::Kiro => crate::vendor::VendorId::Kiro,
353 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
354 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
355 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
356 Vendor::Ollama => crate::vendor::VendorId::Ollama,
357 }
358 }
359}
360
361impl Cli {
362 pub fn has_explicit_vendor(&self) -> bool {
364 self.vendor.is_some()
365 }
366
367 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
378 let active = if self.has_explicit_vendor() {
384 None
385 } else {
386 crate::active::read()
387 };
388 self.resolve_vendor_with(config, active)
389 }
390
391 pub fn resolve_vendor_with(
396 &self,
397 config: &crate::config::Config,
398 active: Option<crate::vendor::VendorId>,
399 ) -> Vendor {
400 if let Some(v) = self.vendor {
401 return v;
402 }
403 if let Some(id) = active
404 && config.is_enabled(id)
405 {
406 return id_to_vendor(id);
407 }
408 if let Some(id) = config.ui.primary
409 && config.is_enabled(id)
410 {
411 return id_to_vendor(id);
412 }
413 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
414 return Vendor::Anthropic;
415 }
416 config
417 .enabled_vendors()
418 .into_iter()
419 .next()
420 .map(id_to_vendor)
421 .unwrap_or(Vendor::Anthropic)
424 }
425}
426
427fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
428 match id {
429 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
430 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
431 crate::vendor::VendorId::Openai => Vendor::Openai,
432 crate::vendor::VendorId::Copilot => Vendor::Copilot,
433 crate::vendor::VendorId::Zai => Vendor::Zai,
434 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
435 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
436 crate::vendor::VendorId::Kimi => Vendor::Kimi,
437 crate::vendor::VendorId::Kilo => Vendor::Kilo,
438 crate::vendor::VendorId::Novita => Vendor::Novita,
439 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
440 crate::vendor::VendorId::Grok => Vendor::Grok,
441 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
442 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
443 crate::vendor::VendorId::Cursor => Vendor::Cursor,
444 crate::vendor::VendorId::Minimax => Vendor::Minimax,
445 crate::vendor::VendorId::Kiro => Vendor::Kiro,
446 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
447 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
448 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
449 crate::vendor::VendorId::Ollama => Vendor::Ollama,
450 }
451}
452
453impl Cli {
454 pub fn output_json(&self) -> bool {
457 if self.json {
458 return true;
459 }
460 if self.pretty || self.watch.is_some() {
461 return false;
462 }
463 !is_stdout_tty()
465 }
466}
467
468fn is_stdout_tty() -> bool {
469 use std::io::IsTerminal;
470 std::io::stdout().is_terminal()
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 use clap::{Parser, error::ErrorKind};
477
478 #[test]
479 fn version_flags_report_the_crate_version() {
480 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
481
482 for flag in ["--version", "-V"] {
483 let err = Cli::try_parse_from(["ai-usagebar", flag])
484 .expect_err("a version flag exits through clap's display path");
485 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
486 assert_eq!(err.to_string(), expected, "flag: {flag}");
487 }
488 }
489
490 #[test]
491 fn usage_subcommand_parses_machine_readable_mode() {
492 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
493 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
494 }
495
496 #[test]
497 fn detect_subcommand_parses_its_flags_and_takes_no_widget_flags() {
498 let bare = Cli::parse_from(["ai-usagebar", "detect"]);
499 assert!(matches!(
500 bare.command,
501 Some(Command::Detect {
502 all: false,
503 json: false
504 })
505 ));
506
507 let full = Cli::parse_from(["ai-usagebar", "detect", "--all", "--json"]);
508 assert!(matches!(
509 full.command,
510 Some(Command::Detect {
511 all: true,
512 json: true
513 })
514 ));
515
516 assert!(Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "detect"]).is_err());
517 }
518
519 #[test]
520 fn new_vendor_values_and_auth_commands_parse_exactly() {
521 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
522 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
523 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
524 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
525 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
526 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
527 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
528 assert!(matches!(login.command, Some(Command::Auth { .. })));
529 }
530
531 #[test]
532 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
533 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
534 assert!(matches!(
535 show.command,
536 Some(Command::Settings {
537 action: SettingsAction::Show
538 })
539 ));
540
541 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
542 assert!(matches!(
543 apply.command,
544 Some(Command::Settings {
545 action: SettingsAction::Apply
546 })
547 ));
548
549 assert!(
550 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
551 );
552 }
553
554 #[test]
555 fn defaults_match_claudebar() {
556 let cli = Cli::parse_from(["ai-usagebar"]);
557 assert_eq!(cli.vendor, None);
558 let cfg = crate::config::Config::default();
563 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
564 assert_eq!(cli.pace_tolerance, 5);
565 assert!(cli.format.is_none());
566 assert!(cli.tooltip_format.is_none());
567 assert!(cli.icon.is_none());
568 assert!(!cli.format_pace_color);
569 assert!(!cli.tooltip_pace_pts);
570 assert!(!cli.pretty);
571 assert!(!cli.json);
572 assert!(cli.watch.is_none());
573 assert!(cli.command.is_none());
574 }
575
576 #[test]
577 fn account_add_subcommand_parses_without_widget_flags() {
578 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
579 assert!(matches!(
580 cli.command,
581 Some(Command::Account {
582 action: AccountAction::Add {
583 ref label,
584 no_login: true,
585 desktop: false,
586 ..
587 }
588 }) if label == "work"
589 ));
590 }
591
592 #[test]
595 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
596 let cli = Cli::parse_from([
597 "ai-usagebar",
598 "account",
599 "add",
600 "work",
601 "--desktop",
602 "--email",
603 "a@b.test",
604 "-y",
605 ]);
606 assert!(matches!(
607 cli.command,
608 Some(Command::Account {
609 action: AccountAction::Add {
610 desktop: true,
611 yes: true,
612 email: Some(ref email),
613 ..
614 }
615 }) if email == "a@b.test"
616 ));
617 assert!(
618 Cli::try_parse_from([
619 "ai-usagebar",
620 "account",
621 "add",
622 "w",
623 "--desktop",
624 "--no-login"
625 ])
626 .is_err()
627 );
628 assert!(
630 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
631 .is_err()
632 );
633 }
634
635 #[test]
636 fn account_switch_defaults_to_both_surfaces() {
637 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
638 assert!(matches!(
639 cli.command,
640 Some(Command::Account {
641 action: AccountAction::Switch {
642 ref label,
643 desktop: false,
644 cli: false,
645 dry_run: true,
646 keep_backups: 10,
647 ..
648 }
649 }) if label == "work"
650 ));
651 }
652
653 #[test]
654 fn account_subcommand_rejects_ignored_widget_flags() {
655 assert!(
656 Cli::try_parse_from([
657 "ai-usagebar",
658 "--vendor",
659 "anthropic",
660 "account",
661 "add",
662 "work",
663 ])
664 .is_err()
665 );
666 }
667
668 #[test]
669 fn multi_account_flags_are_stable_api() {
670 let cli = Cli::parse_from([
674 "ai-usagebar",
675 "--vendor",
676 "anthropic",
677 "--cache-dir",
678 "/tmp/acct-a",
679 "--creds-path",
680 "/tmp/acct-a/credentials.json",
681 ]);
682 assert_eq!(
683 cli.cache_dir.as_deref(),
684 Some(std::path::Path::new("/tmp/acct-a"))
685 );
686 assert_eq!(
687 cli.creds_path.as_deref(),
688 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
689 );
690 }
691
692 #[test]
693 fn primary_from_config_wins_when_vendor_unset() {
694 let cli = Cli::parse_from(["ai-usagebar"]);
696 let mut cfg = crate::config::Config::default();
697 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
698 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
699 }
700
701 #[test]
702 fn explicit_vendor_overrides_everything() {
703 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
706 let mut cfg = crate::config::Config::default();
707 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
708 let active = Some(crate::vendor::VendorId::Openai);
709 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
710 }
711
712 #[test]
713 fn vendor_kimi_parses_to_kimi_variant() {
714 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
715 assert_eq!(cli.vendor, Some(Vendor::Kimi));
716 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
717 }
718
719 #[test]
720 fn vendor_anthropic_api_uses_the_documented_slug() {
721 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
722 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
723 assert_eq!(
724 cli.vendor.unwrap().to_id(),
725 crate::vendor::VendorId::AnthropicApi
726 );
727 }
728
729 #[test]
730 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
731 let cli = Cli::parse_from(["ai-usagebar"]);
732 let mut cfg = crate::config::Config::default();
733 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
734 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
735 }
736
737 #[test]
738 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
739 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
740 assert_eq!(
741 cli.resolve_vendor_with(&crate::config::Config::default(), None),
742 Vendor::Kimi
743 );
744 }
745
746 #[test]
747 fn active_override_wins_over_config_primary_when_enabled() {
748 let cli = Cli::parse_from(["ai-usagebar"]);
751 let mut cfg = crate::config::Config::default();
752 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
753 let active = Some(crate::vendor::VendorId::Zai);
754 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
755 }
756
757 #[test]
758 fn disabled_active_override_falls_back_to_config_primary() {
759 let cli = Cli::parse_from(["ai-usagebar"]);
762 let mut cfg = crate::config::Config::default();
763 cfg.zai.enabled = false;
764 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
765 let active = Some(crate::vendor::VendorId::Zai);
766 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
767 }
768
769 #[test]
770 fn claudebar_compatible_flag_surface() {
771 let cli = Cli::parse_from([
772 "ai-usagebar",
773 "--icon",
774 "",
775 "--format",
776 "{session_pct}% · {session_reset}",
777 "--tooltip-format",
778 "S:{session_pct}",
779 "--pace-tolerance",
780 "10",
781 "--format-pace-color",
782 "--tooltip-pace-pts",
783 "--color-low",
784 "#50fa7b",
785 "--color-mid",
786 "#f1fa8c",
787 "--color-high",
788 "#ffb86c",
789 "--color-critical",
790 "#ff5555",
791 ]);
792 assert_eq!(cli.icon.as_deref(), Some(""));
793 assert_eq!(
794 cli.format.as_deref(),
795 Some("{session_pct}% · {session_reset}")
796 );
797 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
798 assert_eq!(cli.pace_tolerance, 10);
799 assert!(cli.format_pace_color);
800 assert!(cli.tooltip_pace_pts);
801 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
802 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
803 }
804
805 #[test]
806 fn pretty_and_json_conflict() {
807 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
808 assert!(res.is_err());
809 }
810
811 #[test]
812 fn watch_disables_json_output() {
813 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
814 assert_eq!(cli.watch, Some(5));
815 assert!(!cli.output_json());
816 }
817}