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