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)]
28pub struct Cli {
29 #[arg(long, value_enum)]
33 pub vendor: Option<Vendor>,
34
35 #[arg(long)]
38 pub icon: Option<String>,
39
40 #[arg(long)]
44 pub format: Option<String>,
45
46 #[arg(long)]
49 pub tooltip_format: Option<String>,
50
51 #[arg(long, default_value_t = 5)]
53 pub pace_tolerance: u32,
54
55 #[arg(long)]
58 pub format_pace_color: bool,
59
60 #[arg(long)]
64 pub tooltip_pace_pts: bool,
65
66 #[arg(long)]
68 pub color_low: Option<String>,
69 #[arg(long)]
71 pub color_mid: Option<String>,
72 #[arg(long)]
74 pub color_high: Option<String>,
75 #[arg(long)]
77 pub color_critical: Option<String>,
78
79 #[arg(long)]
82 pub pretty: bool,
83
84 #[arg(long, conflicts_with = "pretty")]
87 pub json: bool,
88
89 #[arg(long, value_name = "SECS")]
92 pub watch: Option<u64>,
93
94 #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
99 pub cycle_next: bool,
100
101 #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
103 pub cycle_prev: bool,
104
105 #[arg(long, value_name = "DIR")]
109 pub cache_dir: Option<std::path::PathBuf>,
110
111 #[arg(long, value_name = "FILE")]
117 pub creds_path: Option<std::path::PathBuf>,
118
119 #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
124 pub account: Option<String>,
125
126 #[arg(long, requires = "account")]
131 pub desktop: bool,
132
133 #[command(subcommand)]
135 pub command: Option<Command>,
136}
137
138#[derive(clap::Subcommand, Debug, Clone)]
139pub enum Command {
140 Account {
142 #[command(subcommand)]
143 action: AccountAction,
144 },
145
146 Usage {
148 #[arg(long)]
150 json: bool,
151 },
152
153 Settings {
155 #[command(subcommand)]
156 action: SettingsAction,
157 },
158
159 Auth {
161 #[command(subcommand)]
162 provider: AuthProvider,
163 },
164}
165
166#[derive(clap::Subcommand, Debug, Clone)]
167pub enum AuthProvider {
168 Nous {
169 #[command(subcommand)]
170 action: NousAuthAction,
171 },
172}
173
174#[derive(clap::Subcommand, Debug, Clone)]
175pub enum NousAuthAction {
176 Login,
178 Logout,
180}
181
182#[derive(clap::Subcommand, Debug, Clone)]
183pub enum SettingsAction {
184 Show,
186
187 Apply,
189}
190
191#[derive(clap::Subcommand, Debug, Clone)]
192pub enum AccountAction {
193 Add {
195 label: String,
197
198 #[arg(long, conflicts_with = "desktop")]
200 no_login: bool,
201
202 #[arg(long)]
207 desktop: bool,
208
209 #[arg(long, requires = "desktop")]
212 email: Option<String>,
213
214 #[arg(short = 'y', long, requires = "desktop")]
216 yes: bool,
217 },
218
219 Status {
221 #[arg(long)]
223 json: bool,
224 },
225
226 Switch {
228 label: String,
231
232 #[arg(long)]
234 desktop: bool,
235
236 #[arg(long)]
238 cli: bool,
239
240 #[arg(long)]
242 dry_run: bool,
243
244 #[arg(short = 'y', long)]
246 yes: bool,
247
248 #[arg(long)]
251 force: bool,
252
253 #[arg(long)]
256 keep_bridge: bool,
257
258 #[arg(long)]
261 backup_sessions: bool,
262
263 #[arg(long, default_value_t = 10)]
265 keep_backups: usize,
266
267 #[arg(long, value_name = "KEY")]
274 delete_conflict: Vec<String>,
275 },
276}
277
278#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
279pub enum Vendor {
280 Anthropic,
281 #[value(name = "anthropic_api")]
282 AnthropicApi,
283 Openai,
284 Zai,
285 Openrouter,
286 Deepseek,
287 Kimi,
288 Kilo,
289 Novita,
290 Moonshot,
291 Grok,
292 Supergrok,
293 Antigravity,
294 Cursor,
295 Minimax,
296 Kiro,
297 #[value(name = "nous")]
298 NousResearch,
299 #[value(name = "opencode-go")]
300 OpenCodeGo,
301 #[value(name = "commandcode")]
302 CommandCode,
303}
304
305impl Vendor {
306 pub fn to_id(self) -> crate::vendor::VendorId {
307 match self {
308 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
309 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
310 Vendor::Openai => crate::vendor::VendorId::Openai,
311 Vendor::Zai => crate::vendor::VendorId::Zai,
312 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
313 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
314 Vendor::Kimi => crate::vendor::VendorId::Kimi,
315 Vendor::Kilo => crate::vendor::VendorId::Kilo,
316 Vendor::Novita => crate::vendor::VendorId::Novita,
317 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
318 Vendor::Grok => crate::vendor::VendorId::Grok,
319 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
320 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
321 Vendor::Cursor => crate::vendor::VendorId::Cursor,
322 Vendor::Minimax => crate::vendor::VendorId::Minimax,
323 Vendor::Kiro => crate::vendor::VendorId::Kiro,
324 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
325 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
326 Vendor::CommandCode => crate::vendor::VendorId::CommandCode,
327 }
328 }
329}
330
331impl Cli {
332 pub fn has_explicit_vendor(&self) -> bool {
334 self.vendor.is_some()
335 }
336
337 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
348 let active = if self.has_explicit_vendor() {
354 None
355 } else {
356 crate::active::read()
357 };
358 self.resolve_vendor_with(config, active)
359 }
360
361 pub fn resolve_vendor_with(
366 &self,
367 config: &crate::config::Config,
368 active: Option<crate::vendor::VendorId>,
369 ) -> Vendor {
370 if let Some(v) = self.vendor {
371 return v;
372 }
373 if let Some(id) = active
374 && config.is_enabled(id)
375 {
376 return id_to_vendor(id);
377 }
378 if let Some(id) = config.ui.primary
379 && config.is_enabled(id)
380 {
381 return id_to_vendor(id);
382 }
383 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
384 return Vendor::Anthropic;
385 }
386 config
387 .enabled_vendors()
388 .into_iter()
389 .next()
390 .map(id_to_vendor)
391 .unwrap_or(Vendor::Anthropic)
394 }
395}
396
397fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
398 match id {
399 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
400 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
401 crate::vendor::VendorId::Openai => Vendor::Openai,
402 crate::vendor::VendorId::Zai => Vendor::Zai,
403 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
404 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
405 crate::vendor::VendorId::Kimi => Vendor::Kimi,
406 crate::vendor::VendorId::Kilo => Vendor::Kilo,
407 crate::vendor::VendorId::Novita => Vendor::Novita,
408 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
409 crate::vendor::VendorId::Grok => Vendor::Grok,
410 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
411 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
412 crate::vendor::VendorId::Cursor => Vendor::Cursor,
413 crate::vendor::VendorId::Minimax => Vendor::Minimax,
414 crate::vendor::VendorId::Kiro => Vendor::Kiro,
415 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
416 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
417 crate::vendor::VendorId::CommandCode => Vendor::CommandCode,
418 }
419}
420
421impl Cli {
422 pub fn output_json(&self) -> bool {
425 if self.json {
426 return true;
427 }
428 if self.pretty || self.watch.is_some() {
429 return false;
430 }
431 !is_stdout_tty()
433 }
434}
435
436fn is_stdout_tty() -> bool {
437 use std::io::IsTerminal;
438 std::io::stdout().is_terminal()
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use clap::{Parser, error::ErrorKind};
445
446 #[test]
447 fn version_flags_report_the_crate_version() {
448 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
449
450 for flag in ["--version", "-V"] {
451 let err = Cli::try_parse_from(["ai-usagebar", flag])
452 .expect_err("a version flag exits through clap's display path");
453 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
454 assert_eq!(err.to_string(), expected, "flag: {flag}");
455 }
456 }
457
458 #[test]
459 fn usage_subcommand_parses_machine_readable_mode() {
460 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
461 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
462 }
463
464 #[test]
465 fn new_vendor_values_and_auth_commands_parse_exactly() {
466 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
467 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
468 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
469 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
470 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
471 assert!(matches!(login.command, Some(Command::Auth { .. })));
472 }
473
474 #[test]
475 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
476 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
477 assert!(matches!(
478 show.command,
479 Some(Command::Settings {
480 action: SettingsAction::Show
481 })
482 ));
483
484 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
485 assert!(matches!(
486 apply.command,
487 Some(Command::Settings {
488 action: SettingsAction::Apply
489 })
490 ));
491
492 assert!(
493 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
494 );
495 }
496
497 #[test]
498 fn defaults_match_claudebar() {
499 let cli = Cli::parse_from(["ai-usagebar"]);
500 assert_eq!(cli.vendor, None);
501 let cfg = crate::config::Config::default();
506 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
507 assert_eq!(cli.pace_tolerance, 5);
508 assert!(cli.format.is_none());
509 assert!(cli.tooltip_format.is_none());
510 assert!(cli.icon.is_none());
511 assert!(!cli.format_pace_color);
512 assert!(!cli.tooltip_pace_pts);
513 assert!(!cli.pretty);
514 assert!(!cli.json);
515 assert!(cli.watch.is_none());
516 assert!(cli.command.is_none());
517 }
518
519 #[test]
520 fn account_add_subcommand_parses_without_widget_flags() {
521 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
522 assert!(matches!(
523 cli.command,
524 Some(Command::Account {
525 action: AccountAction::Add {
526 ref label,
527 no_login: true,
528 desktop: false,
529 ..
530 }
531 }) if label == "work"
532 ));
533 }
534
535 #[test]
538 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
539 let cli = Cli::parse_from([
540 "ai-usagebar",
541 "account",
542 "add",
543 "work",
544 "--desktop",
545 "--email",
546 "a@b.test",
547 "-y",
548 ]);
549 assert!(matches!(
550 cli.command,
551 Some(Command::Account {
552 action: AccountAction::Add {
553 desktop: true,
554 yes: true,
555 email: Some(ref email),
556 ..
557 }
558 }) if email == "a@b.test"
559 ));
560 assert!(
561 Cli::try_parse_from([
562 "ai-usagebar",
563 "account",
564 "add",
565 "w",
566 "--desktop",
567 "--no-login"
568 ])
569 .is_err()
570 );
571 assert!(
573 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
574 .is_err()
575 );
576 }
577
578 #[test]
579 fn account_switch_defaults_to_both_surfaces() {
580 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
581 assert!(matches!(
582 cli.command,
583 Some(Command::Account {
584 action: AccountAction::Switch {
585 ref label,
586 desktop: false,
587 cli: false,
588 dry_run: true,
589 keep_backups: 10,
590 ..
591 }
592 }) if label == "work"
593 ));
594 }
595
596 #[test]
597 fn account_subcommand_rejects_ignored_widget_flags() {
598 assert!(
599 Cli::try_parse_from([
600 "ai-usagebar",
601 "--vendor",
602 "anthropic",
603 "account",
604 "add",
605 "work",
606 ])
607 .is_err()
608 );
609 }
610
611 #[test]
612 fn multi_account_flags_are_stable_api() {
613 let cli = Cli::parse_from([
617 "ai-usagebar",
618 "--vendor",
619 "anthropic",
620 "--cache-dir",
621 "/tmp/acct-a",
622 "--creds-path",
623 "/tmp/acct-a/credentials.json",
624 ]);
625 assert_eq!(
626 cli.cache_dir.as_deref(),
627 Some(std::path::Path::new("/tmp/acct-a"))
628 );
629 assert_eq!(
630 cli.creds_path.as_deref(),
631 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
632 );
633 }
634
635 #[test]
636 fn primary_from_config_wins_when_vendor_unset() {
637 let cli = Cli::parse_from(["ai-usagebar"]);
639 let mut cfg = crate::config::Config::default();
640 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
641 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
642 }
643
644 #[test]
645 fn explicit_vendor_overrides_everything() {
646 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
649 let mut cfg = crate::config::Config::default();
650 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
651 let active = Some(crate::vendor::VendorId::Openai);
652 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
653 }
654
655 #[test]
656 fn vendor_kimi_parses_to_kimi_variant() {
657 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
658 assert_eq!(cli.vendor, Some(Vendor::Kimi));
659 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
660 }
661
662 #[test]
663 fn vendor_anthropic_api_uses_the_documented_slug() {
664 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
665 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
666 assert_eq!(
667 cli.vendor.unwrap().to_id(),
668 crate::vendor::VendorId::AnthropicApi
669 );
670 }
671
672 #[test]
673 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
674 let cli = Cli::parse_from(["ai-usagebar"]);
675 let mut cfg = crate::config::Config::default();
676 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
677 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
678 }
679
680 #[test]
681 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
682 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
683 assert_eq!(
684 cli.resolve_vendor_with(&crate::config::Config::default(), None),
685 Vendor::Kimi
686 );
687 }
688
689 #[test]
690 fn active_override_wins_over_config_primary_when_enabled() {
691 let cli = Cli::parse_from(["ai-usagebar"]);
694 let mut cfg = crate::config::Config::default();
695 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
696 let active = Some(crate::vendor::VendorId::Zai);
697 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
698 }
699
700 #[test]
701 fn disabled_active_override_falls_back_to_config_primary() {
702 let cli = Cli::parse_from(["ai-usagebar"]);
705 let mut cfg = crate::config::Config::default();
706 cfg.zai.enabled = false;
707 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
708 let active = Some(crate::vendor::VendorId::Zai);
709 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
710 }
711
712 #[test]
713 fn claudebar_compatible_flag_surface() {
714 let cli = Cli::parse_from([
715 "ai-usagebar",
716 "--icon",
717 "",
718 "--format",
719 "{session_pct}% · {session_reset}",
720 "--tooltip-format",
721 "S:{session_pct}",
722 "--pace-tolerance",
723 "10",
724 "--format-pace-color",
725 "--tooltip-pace-pts",
726 "--color-low",
727 "#50fa7b",
728 "--color-mid",
729 "#f1fa8c",
730 "--color-high",
731 "#ffb86c",
732 "--color-critical",
733 "#ff5555",
734 ]);
735 assert_eq!(cli.icon.as_deref(), Some(""));
736 assert_eq!(
737 cli.format.as_deref(),
738 Some("{session_pct}% · {session_reset}")
739 );
740 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
741 assert_eq!(cli.pace_tolerance, 10);
742 assert!(cli.format_pace_color);
743 assert!(cli.tooltip_pace_pts);
744 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
745 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
746 }
747
748 #[test]
749 fn pretty_and_json_conflict() {
750 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
751 assert!(res.is_err());
752 }
753
754 #[test]
755 fn watch_disables_json_output() {
756 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
757 assert_eq!(cli.watch, Some(5));
758 assert!(!cli.output_json());
759 }
760}