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 Vendors {
163 #[arg(long)]
165 json: bool,
166 },
167
168 Settings {
170 #[command(subcommand)]
171 action: SettingsAction,
172 },
173
174 Auth {
176 #[command(subcommand)]
177 provider: AuthProvider,
178 },
179}
180
181#[derive(clap::Subcommand, Debug, Clone)]
182pub enum AuthProvider {
183 Nous {
184 #[command(subcommand)]
185 action: NousAuthAction,
186 },
187}
188
189#[derive(clap::Subcommand, Debug, Clone)]
190pub enum NousAuthAction {
191 Login,
193 Logout,
195}
196
197#[derive(clap::Subcommand, Debug, Clone)]
198pub enum SettingsAction {
199 Show,
201
202 Apply,
204}
205
206#[derive(clap::Subcommand, Debug, Clone)]
207pub enum AccountAction {
208 Add {
210 label: String,
212
213 #[arg(long, conflicts_with = "desktop")]
215 no_login: bool,
216
217 #[arg(long)]
222 desktop: bool,
223
224 #[arg(long, requires = "desktop")]
227 email: Option<String>,
228
229 #[arg(short = 'y', long, requires = "desktop")]
231 yes: bool,
232 },
233
234 Status {
236 #[arg(long)]
238 json: bool,
239 },
240
241 Switch {
243 label: String,
246
247 #[arg(long)]
249 desktop: bool,
250
251 #[arg(long)]
253 cli: bool,
254
255 #[arg(long)]
257 dry_run: bool,
258
259 #[arg(short = 'y', long)]
261 yes: bool,
262
263 #[arg(long)]
266 force: bool,
267
268 #[arg(long)]
271 keep_bridge: bool,
272
273 #[arg(long)]
276 backup_sessions: bool,
277
278 #[arg(long, default_value_t = 10)]
280 keep_backups: usize,
281
282 #[arg(long, value_name = "KEY")]
289 delete_conflict: Vec<String>,
290 },
291}
292
293#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
294pub enum Vendor {
295 Anthropic,
296 #[value(name = "anthropic_api")]
297 AnthropicApi,
298 Openai,
299 Copilot,
300 Zai,
301 Openrouter,
302 Deepseek,
303 Kimi,
304 Kilo,
305 Novita,
306 Moonshot,
307 Grok,
308 Supergrok,
309 Antigravity,
310 Cursor,
311 Minimax,
312 Kiro,
313 #[value(name = "nous")]
314 NousResearch,
315 #[value(name = "opencode-go")]
316 OpenCodeGo,
317 #[value(name = "commandcode")]
318 CommandCode,
319}
320
321impl Vendor {
322 pub fn to_id(self) -> crate::vendor::VendorId {
323 match self {
324 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
325 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
326 Vendor::Openai => crate::vendor::VendorId::Openai,
327 Vendor::Copilot => crate::vendor::VendorId::Copilot,
328 Vendor::Zai => crate::vendor::VendorId::Zai,
329 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
330 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
331 Vendor::Kimi => crate::vendor::VendorId::Kimi,
332 Vendor::Kilo => crate::vendor::VendorId::Kilo,
333 Vendor::Novita => crate::vendor::VendorId::Novita,
334 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
335 Vendor::Grok => crate::vendor::VendorId::Grok,
336 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
337 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
338 Vendor::Cursor => crate::vendor::VendorId::Cursor,
339 Vendor::Minimax => crate::vendor::VendorId::Minimax,
340 Vendor::Kiro => crate::vendor::VendorId::Kiro,
341 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
342 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
343 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
344 }
345 }
346}
347
348impl Cli {
349 pub fn has_explicit_vendor(&self) -> bool {
351 self.vendor.is_some()
352 }
353
354 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
365 let active = if self.has_explicit_vendor() {
371 None
372 } else {
373 crate::active::read()
374 };
375 self.resolve_vendor_with(config, active)
376 }
377
378 pub fn resolve_vendor_with(
383 &self,
384 config: &crate::config::Config,
385 active: Option<crate::vendor::VendorId>,
386 ) -> Vendor {
387 if let Some(v) = self.vendor {
388 return v;
389 }
390 if let Some(id) = active
391 && config.is_enabled(id)
392 {
393 return id_to_vendor(id);
394 }
395 if let Some(id) = config.ui.primary
396 && config.is_enabled(id)
397 {
398 return id_to_vendor(id);
399 }
400 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
401 return Vendor::Anthropic;
402 }
403 config
404 .enabled_vendors()
405 .into_iter()
406 .next()
407 .map(id_to_vendor)
408 .unwrap_or(Vendor::Anthropic)
411 }
412}
413
414fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
415 match id {
416 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
417 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
418 crate::vendor::VendorId::Openai => Vendor::Openai,
419 crate::vendor::VendorId::Copilot => Vendor::Copilot,
420 crate::vendor::VendorId::Zai => Vendor::Zai,
421 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
422 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
423 crate::vendor::VendorId::Kimi => Vendor::Kimi,
424 crate::vendor::VendorId::Kilo => Vendor::Kilo,
425 crate::vendor::VendorId::Novita => Vendor::Novita,
426 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
427 crate::vendor::VendorId::Grok => Vendor::Grok,
428 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
429 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
430 crate::vendor::VendorId::Cursor => Vendor::Cursor,
431 crate::vendor::VendorId::Minimax => Vendor::Minimax,
432 crate::vendor::VendorId::Kiro => Vendor::Kiro,
433 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
434 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
435 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
436 }
437}
438
439impl Cli {
440 pub fn output_json(&self) -> bool {
443 if self.json {
444 return true;
445 }
446 if self.pretty || self.watch.is_some() {
447 return false;
448 }
449 !is_stdout_tty()
451 }
452}
453
454fn is_stdout_tty() -> bool {
455 use std::io::IsTerminal;
456 std::io::stdout().is_terminal()
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use clap::{Parser, error::ErrorKind};
463
464 #[test]
465 fn version_flags_report_the_crate_version() {
466 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
467
468 for flag in ["--version", "-V"] {
469 let err = Cli::try_parse_from(["ai-usagebar", flag])
470 .expect_err("a version flag exits through clap's display path");
471 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
472 assert_eq!(err.to_string(), expected, "flag: {flag}");
473 }
474 }
475
476 #[test]
477 fn usage_subcommand_parses_machine_readable_mode() {
478 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
479 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
480 }
481
482 #[test]
483 fn new_vendor_values_and_auth_commands_parse_exactly() {
484 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
485 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
486 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
487 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
488 let copilot = Cli::parse_from(["ai-usagebar", "--vendor", "copilot"]);
489 assert_eq!(copilot.vendor, Some(Vendor::Copilot));
490 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
491 assert!(matches!(login.command, Some(Command::Auth { .. })));
492 }
493
494 #[test]
495 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
496 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
497 assert!(matches!(
498 show.command,
499 Some(Command::Settings {
500 action: SettingsAction::Show
501 })
502 ));
503
504 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
505 assert!(matches!(
506 apply.command,
507 Some(Command::Settings {
508 action: SettingsAction::Apply
509 })
510 ));
511
512 assert!(
513 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
514 );
515 }
516
517 #[test]
518 fn defaults_match_claudebar() {
519 let cli = Cli::parse_from(["ai-usagebar"]);
520 assert_eq!(cli.vendor, None);
521 let cfg = crate::config::Config::default();
526 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
527 assert_eq!(cli.pace_tolerance, 5);
528 assert!(cli.format.is_none());
529 assert!(cli.tooltip_format.is_none());
530 assert!(cli.icon.is_none());
531 assert!(!cli.format_pace_color);
532 assert!(!cli.tooltip_pace_pts);
533 assert!(!cli.pretty);
534 assert!(!cli.json);
535 assert!(cli.watch.is_none());
536 assert!(cli.command.is_none());
537 }
538
539 #[test]
540 fn account_add_subcommand_parses_without_widget_flags() {
541 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
542 assert!(matches!(
543 cli.command,
544 Some(Command::Account {
545 action: AccountAction::Add {
546 ref label,
547 no_login: true,
548 desktop: false,
549 ..
550 }
551 }) if label == "work"
552 ));
553 }
554
555 #[test]
558 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
559 let cli = Cli::parse_from([
560 "ai-usagebar",
561 "account",
562 "add",
563 "work",
564 "--desktop",
565 "--email",
566 "a@b.test",
567 "-y",
568 ]);
569 assert!(matches!(
570 cli.command,
571 Some(Command::Account {
572 action: AccountAction::Add {
573 desktop: true,
574 yes: true,
575 email: Some(ref email),
576 ..
577 }
578 }) if email == "a@b.test"
579 ));
580 assert!(
581 Cli::try_parse_from([
582 "ai-usagebar",
583 "account",
584 "add",
585 "w",
586 "--desktop",
587 "--no-login"
588 ])
589 .is_err()
590 );
591 assert!(
593 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
594 .is_err()
595 );
596 }
597
598 #[test]
599 fn account_switch_defaults_to_both_surfaces() {
600 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
601 assert!(matches!(
602 cli.command,
603 Some(Command::Account {
604 action: AccountAction::Switch {
605 ref label,
606 desktop: false,
607 cli: false,
608 dry_run: true,
609 keep_backups: 10,
610 ..
611 }
612 }) if label == "work"
613 ));
614 }
615
616 #[test]
617 fn account_subcommand_rejects_ignored_widget_flags() {
618 assert!(
619 Cli::try_parse_from([
620 "ai-usagebar",
621 "--vendor",
622 "anthropic",
623 "account",
624 "add",
625 "work",
626 ])
627 .is_err()
628 );
629 }
630
631 #[test]
632 fn multi_account_flags_are_stable_api() {
633 let cli = Cli::parse_from([
637 "ai-usagebar",
638 "--vendor",
639 "anthropic",
640 "--cache-dir",
641 "/tmp/acct-a",
642 "--creds-path",
643 "/tmp/acct-a/credentials.json",
644 ]);
645 assert_eq!(
646 cli.cache_dir.as_deref(),
647 Some(std::path::Path::new("/tmp/acct-a"))
648 );
649 assert_eq!(
650 cli.creds_path.as_deref(),
651 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
652 );
653 }
654
655 #[test]
656 fn primary_from_config_wins_when_vendor_unset() {
657 let cli = Cli::parse_from(["ai-usagebar"]);
659 let mut cfg = crate::config::Config::default();
660 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
661 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
662 }
663
664 #[test]
665 fn explicit_vendor_overrides_everything() {
666 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
669 let mut cfg = crate::config::Config::default();
670 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
671 let active = Some(crate::vendor::VendorId::Openai);
672 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
673 }
674
675 #[test]
676 fn vendor_kimi_parses_to_kimi_variant() {
677 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
678 assert_eq!(cli.vendor, Some(Vendor::Kimi));
679 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
680 }
681
682 #[test]
683 fn vendor_anthropic_api_uses_the_documented_slug() {
684 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
685 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
686 assert_eq!(
687 cli.vendor.unwrap().to_id(),
688 crate::vendor::VendorId::AnthropicApi
689 );
690 }
691
692 #[test]
693 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
694 let cli = Cli::parse_from(["ai-usagebar"]);
695 let mut cfg = crate::config::Config::default();
696 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
697 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
698 }
699
700 #[test]
701 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
702 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
703 assert_eq!(
704 cli.resolve_vendor_with(&crate::config::Config::default(), None),
705 Vendor::Kimi
706 );
707 }
708
709 #[test]
710 fn active_override_wins_over_config_primary_when_enabled() {
711 let cli = Cli::parse_from(["ai-usagebar"]);
714 let mut cfg = crate::config::Config::default();
715 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
716 let active = Some(crate::vendor::VendorId::Zai);
717 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
718 }
719
720 #[test]
721 fn disabled_active_override_falls_back_to_config_primary() {
722 let cli = Cli::parse_from(["ai-usagebar"]);
725 let mut cfg = crate::config::Config::default();
726 cfg.zai.enabled = false;
727 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
728 let active = Some(crate::vendor::VendorId::Zai);
729 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
730 }
731
732 #[test]
733 fn claudebar_compatible_flag_surface() {
734 let cli = Cli::parse_from([
735 "ai-usagebar",
736 "--icon",
737 "",
738 "--format",
739 "{session_pct}% · {session_reset}",
740 "--tooltip-format",
741 "S:{session_pct}",
742 "--pace-tolerance",
743 "10",
744 "--format-pace-color",
745 "--tooltip-pace-pts",
746 "--color-low",
747 "#50fa7b",
748 "--color-mid",
749 "#f1fa8c",
750 "--color-high",
751 "#ffb86c",
752 "--color-critical",
753 "#ff5555",
754 ]);
755 assert_eq!(cli.icon.as_deref(), Some(""));
756 assert_eq!(
757 cli.format.as_deref(),
758 Some("{session_pct}% · {session_reset}")
759 );
760 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
761 assert_eq!(cli.pace_tolerance, 10);
762 assert!(cli.format_pace_color);
763 assert!(cli.tooltip_pace_pts);
764 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
765 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
766 }
767
768 #[test]
769 fn pretty_and_json_conflict() {
770 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
771 assert!(res.is_err());
772 }
773
774 #[test]
775 fn watch_disables_json_output() {
776 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
777 assert_eq!(cli.watch, Some(5));
778 assert!(!cli.output_json());
779 }
780}