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