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}
302
303impl Vendor {
304 pub fn to_id(self) -> crate::vendor::VendorId {
305 match self {
306 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
307 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
308 Vendor::Openai => crate::vendor::VendorId::Openai,
309 Vendor::Zai => crate::vendor::VendorId::Zai,
310 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
311 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
312 Vendor::Kimi => crate::vendor::VendorId::Kimi,
313 Vendor::Kilo => crate::vendor::VendorId::Kilo,
314 Vendor::Novita => crate::vendor::VendorId::Novita,
315 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
316 Vendor::Grok => crate::vendor::VendorId::Grok,
317 Vendor::Supergrok => crate::vendor::VendorId::Supergrok,
318 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
319 Vendor::Cursor => crate::vendor::VendorId::Cursor,
320 Vendor::Minimax => crate::vendor::VendorId::Minimax,
321 Vendor::Kiro => crate::vendor::VendorId::Kiro,
322 Vendor::NousResearch => crate::vendor::VendorId::NousResearch,
323 Vendor::OpenCodeGo => crate::vendor::VendorId::OpenCodeGo,
324 }
325 }
326}
327
328impl Cli {
329 pub fn has_explicit_vendor(&self) -> bool {
331 self.vendor.is_some()
332 }
333
334 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
345 let active = if self.has_explicit_vendor() {
351 None
352 } else {
353 crate::active::read()
354 };
355 self.resolve_vendor_with(config, active)
356 }
357
358 pub fn resolve_vendor_with(
363 &self,
364 config: &crate::config::Config,
365 active: Option<crate::vendor::VendorId>,
366 ) -> Vendor {
367 if let Some(v) = self.vendor {
368 return v;
369 }
370 if let Some(id) = active
371 && config.is_enabled(id)
372 {
373 return id_to_vendor(id);
374 }
375 if let Some(id) = config.ui.primary
376 && config.is_enabled(id)
377 {
378 return id_to_vendor(id);
379 }
380 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
381 return Vendor::Anthropic;
382 }
383 config
384 .enabled_vendors()
385 .into_iter()
386 .next()
387 .map(id_to_vendor)
388 .unwrap_or(Vendor::Anthropic)
391 }
392}
393
394fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
395 match id {
396 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
397 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
398 crate::vendor::VendorId::Openai => Vendor::Openai,
399 crate::vendor::VendorId::Zai => Vendor::Zai,
400 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
401 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
402 crate::vendor::VendorId::Kimi => Vendor::Kimi,
403 crate::vendor::VendorId::Kilo => Vendor::Kilo,
404 crate::vendor::VendorId::Novita => Vendor::Novita,
405 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
406 crate::vendor::VendorId::Grok => Vendor::Grok,
407 crate::vendor::VendorId::Supergrok => Vendor::Supergrok,
408 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
409 crate::vendor::VendorId::Cursor => Vendor::Cursor,
410 crate::vendor::VendorId::Minimax => Vendor::Minimax,
411 crate::vendor::VendorId::Kiro => Vendor::Kiro,
412 crate::vendor::VendorId::NousResearch => Vendor::NousResearch,
413 crate::vendor::VendorId::OpenCodeGo => Vendor::OpenCodeGo,
414 }
415}
416
417impl Cli {
418 pub fn output_json(&self) -> bool {
421 if self.json {
422 return true;
423 }
424 if self.pretty || self.watch.is_some() {
425 return false;
426 }
427 !is_stdout_tty()
429 }
430}
431
432fn is_stdout_tty() -> bool {
433 use std::io::IsTerminal;
434 std::io::stdout().is_terminal()
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use clap::{Parser, error::ErrorKind};
441
442 #[test]
443 fn version_flags_report_the_crate_version() {
444 let expected = format!("ai-usagebar {}\n", env!("CARGO_PKG_VERSION"));
445
446 for flag in ["--version", "-V"] {
447 let err = Cli::try_parse_from(["ai-usagebar", flag])
448 .expect_err("a version flag exits through clap's display path");
449 assert_eq!(err.kind(), ErrorKind::DisplayVersion, "flag: {flag}");
450 assert_eq!(err.to_string(), expected, "flag: {flag}");
451 }
452 }
453
454 #[test]
455 fn usage_subcommand_parses_machine_readable_mode() {
456 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
457 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
458 }
459
460 #[test]
461 fn new_vendor_values_and_auth_commands_parse_exactly() {
462 let nous = Cli::parse_from(["ai-usagebar", "--vendor", "nous"]);
463 assert_eq!(nous.vendor, Some(Vendor::NousResearch));
464 let opencode = Cli::parse_from(["ai-usagebar", "--vendor", "opencode-go"]);
465 assert_eq!(opencode.vendor, Some(Vendor::OpenCodeGo));
466 let login = Cli::parse_from(["ai-usagebar", "auth", "nous", "login"]);
467 assert!(matches!(login.command, Some(Command::Auth { .. })));
468 }
469
470 #[test]
471 fn settings_subcommands_are_additive_and_take_no_widget_flags() {
472 let show = Cli::parse_from(["ai-usagebar", "settings", "show"]);
473 assert!(matches!(
474 show.command,
475 Some(Command::Settings {
476 action: SettingsAction::Show
477 })
478 ));
479
480 let apply = Cli::parse_from(["ai-usagebar", "settings", "apply"]);
481 assert!(matches!(
482 apply.command,
483 Some(Command::Settings {
484 action: SettingsAction::Apply
485 })
486 ));
487
488 assert!(
489 Cli::try_parse_from(["ai-usagebar", "--vendor", "kimi", "settings", "show",]).is_err()
490 );
491 }
492
493 #[test]
494 fn defaults_match_claudebar() {
495 let cli = Cli::parse_from(["ai-usagebar"]);
496 assert_eq!(cli.vendor, None);
497 let cfg = crate::config::Config::default();
502 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
503 assert_eq!(cli.pace_tolerance, 5);
504 assert!(cli.format.is_none());
505 assert!(cli.tooltip_format.is_none());
506 assert!(cli.icon.is_none());
507 assert!(!cli.format_pace_color);
508 assert!(!cli.tooltip_pace_pts);
509 assert!(!cli.pretty);
510 assert!(!cli.json);
511 assert!(cli.watch.is_none());
512 assert!(cli.command.is_none());
513 }
514
515 #[test]
516 fn account_add_subcommand_parses_without_widget_flags() {
517 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
518 assert!(matches!(
519 cli.command,
520 Some(Command::Account {
521 action: AccountAction::Add {
522 ref label,
523 no_login: true,
524 desktop: false,
525 ..
526 }
527 }) if label == "work"
528 ));
529 }
530
531 #[test]
534 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
535 let cli = Cli::parse_from([
536 "ai-usagebar",
537 "account",
538 "add",
539 "work",
540 "--desktop",
541 "--email",
542 "a@b.test",
543 "-y",
544 ]);
545 assert!(matches!(
546 cli.command,
547 Some(Command::Account {
548 action: AccountAction::Add {
549 desktop: true,
550 yes: true,
551 email: Some(ref email),
552 ..
553 }
554 }) if email == "a@b.test"
555 ));
556 assert!(
557 Cli::try_parse_from([
558 "ai-usagebar",
559 "account",
560 "add",
561 "w",
562 "--desktop",
563 "--no-login"
564 ])
565 .is_err()
566 );
567 assert!(
569 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
570 .is_err()
571 );
572 }
573
574 #[test]
575 fn account_switch_defaults_to_both_surfaces() {
576 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
577 assert!(matches!(
578 cli.command,
579 Some(Command::Account {
580 action: AccountAction::Switch {
581 ref label,
582 desktop: false,
583 cli: false,
584 dry_run: true,
585 keep_backups: 10,
586 ..
587 }
588 }) if label == "work"
589 ));
590 }
591
592 #[test]
593 fn account_subcommand_rejects_ignored_widget_flags() {
594 assert!(
595 Cli::try_parse_from([
596 "ai-usagebar",
597 "--vendor",
598 "anthropic",
599 "account",
600 "add",
601 "work",
602 ])
603 .is_err()
604 );
605 }
606
607 #[test]
608 fn multi_account_flags_are_stable_api() {
609 let cli = Cli::parse_from([
613 "ai-usagebar",
614 "--vendor",
615 "anthropic",
616 "--cache-dir",
617 "/tmp/acct-a",
618 "--creds-path",
619 "/tmp/acct-a/credentials.json",
620 ]);
621 assert_eq!(
622 cli.cache_dir.as_deref(),
623 Some(std::path::Path::new("/tmp/acct-a"))
624 );
625 assert_eq!(
626 cli.creds_path.as_deref(),
627 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
628 );
629 }
630
631 #[test]
632 fn primary_from_config_wins_when_vendor_unset() {
633 let cli = Cli::parse_from(["ai-usagebar"]);
635 let mut cfg = crate::config::Config::default();
636 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
637 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
638 }
639
640 #[test]
641 fn explicit_vendor_overrides_everything() {
642 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
645 let mut cfg = crate::config::Config::default();
646 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
647 let active = Some(crate::vendor::VendorId::Openai);
648 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
649 }
650
651 #[test]
652 fn vendor_kimi_parses_to_kimi_variant() {
653 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
654 assert_eq!(cli.vendor, Some(Vendor::Kimi));
655 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
656 }
657
658 #[test]
659 fn vendor_anthropic_api_uses_the_documented_slug() {
660 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
661 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
662 assert_eq!(
663 cli.vendor.unwrap().to_id(),
664 crate::vendor::VendorId::AnthropicApi
665 );
666 }
667
668 #[test]
669 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
670 let cli = Cli::parse_from(["ai-usagebar"]);
671 let mut cfg = crate::config::Config::default();
672 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
673 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
674 }
675
676 #[test]
677 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
678 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
679 assert_eq!(
680 cli.resolve_vendor_with(&crate::config::Config::default(), None),
681 Vendor::Kimi
682 );
683 }
684
685 #[test]
686 fn active_override_wins_over_config_primary_when_enabled() {
687 let cli = Cli::parse_from(["ai-usagebar"]);
690 let mut cfg = crate::config::Config::default();
691 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
692 let active = Some(crate::vendor::VendorId::Zai);
693 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
694 }
695
696 #[test]
697 fn disabled_active_override_falls_back_to_config_primary() {
698 let cli = Cli::parse_from(["ai-usagebar"]);
701 let mut cfg = crate::config::Config::default();
702 cfg.zai.enabled = false;
703 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
704 let active = Some(crate::vendor::VendorId::Zai);
705 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
706 }
707
708 #[test]
709 fn claudebar_compatible_flag_surface() {
710 let cli = Cli::parse_from([
711 "ai-usagebar",
712 "--icon",
713 "",
714 "--format",
715 "{session_pct}% · {session_reset}",
716 "--tooltip-format",
717 "S:{session_pct}",
718 "--pace-tolerance",
719 "10",
720 "--format-pace-color",
721 "--tooltip-pace-pts",
722 "--color-low",
723 "#50fa7b",
724 "--color-mid",
725 "#f1fa8c",
726 "--color-high",
727 "#ffb86c",
728 "--color-critical",
729 "#ff5555",
730 ]);
731 assert_eq!(cli.icon.as_deref(), Some(""));
732 assert_eq!(
733 cli.format.as_deref(),
734 Some("{session_pct}% · {session_reset}")
735 );
736 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
737 assert_eq!(cli.pace_tolerance, 10);
738 assert!(cli.format_pace_color);
739 assert!(cli.tooltip_pace_pts);
740 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
741 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
742 }
743
744 #[test]
745 fn pretty_and_json_conflict() {
746 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
747 assert!(res.is_err());
748 }
749
750 #[test]
751 fn watch_disables_json_output() {
752 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
753 assert_eq!(cli.watch, Some(5));
754 assert!(!cli.output_json());
755 }
756}