1use clap::{Parser, ValueEnum};
9
10#[derive(Parser, Debug, Clone)]
11#[command(
12 name = "ai-usagebar",
13 args_conflicts_with_subcommands = true,
14 about = "Waybar widget and terminal dashboard for multi-provider AI plan usage",
15 long_about = "\
16Drop-in replacement for `claudebar` with multi-vendor support.
17
18Output modes:
19 - Default: Waybar JSON ({text, tooltip, class}). Used when stdout is piped.
20 - --pretty: human-readable terminal output for local testing. Auto-enabled
21 when stdout is a TTY, so just running `ai-usagebar --vendor anthropic`
22 in a terminal Does The Right Thing.
23 - --watch N: like --pretty but refreshes every N seconds, clearing the screen
24 between ticks. Useful while iterating on `--format` or `--tooltip-format`.
25 - --json: force JSON output even when stdout is a TTY (for scripting)."
26)]
27pub struct Cli {
28 #[arg(long, value_enum)]
32 pub vendor: Option<Vendor>,
33
34 #[arg(long)]
37 pub icon: Option<String>,
38
39 #[arg(long)]
43 pub format: Option<String>,
44
45 #[arg(long)]
48 pub tooltip_format: Option<String>,
49
50 #[arg(long, default_value_t = 5)]
52 pub pace_tolerance: u32,
53
54 #[arg(long)]
57 pub format_pace_color: bool,
58
59 #[arg(long)]
63 pub tooltip_pace_pts: bool,
64
65 #[arg(long)]
67 pub color_low: Option<String>,
68 #[arg(long)]
70 pub color_mid: Option<String>,
71 #[arg(long)]
73 pub color_high: Option<String>,
74 #[arg(long)]
76 pub color_critical: Option<String>,
77
78 #[arg(long)]
81 pub pretty: bool,
82
83 #[arg(long, conflicts_with = "pretty")]
86 pub json: bool,
87
88 #[arg(long, value_name = "SECS")]
91 pub watch: Option<u64>,
92
93 #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
98 pub cycle_next: bool,
99
100 #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
102 pub cycle_prev: bool,
103
104 #[arg(long, value_name = "DIR")]
108 pub cache_dir: Option<std::path::PathBuf>,
109
110 #[arg(long, value_name = "FILE")]
116 pub creds_path: Option<std::path::PathBuf>,
117
118 #[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
154#[derive(clap::Subcommand, Debug, Clone)]
155pub enum AccountAction {
156 Add {
158 label: String,
160
161 #[arg(long, conflicts_with = "desktop")]
163 no_login: bool,
164
165 #[arg(long)]
170 desktop: bool,
171
172 #[arg(long, requires = "desktop")]
175 email: Option<String>,
176
177 #[arg(short = 'y', long, requires = "desktop")]
179 yes: bool,
180 },
181
182 Status {
184 #[arg(long)]
186 json: bool,
187 },
188
189 Switch {
191 label: String,
194
195 #[arg(long)]
197 desktop: bool,
198
199 #[arg(long)]
201 cli: bool,
202
203 #[arg(long)]
205 dry_run: bool,
206
207 #[arg(short = 'y', long)]
209 yes: bool,
210
211 #[arg(long)]
214 force: bool,
215
216 #[arg(long)]
219 keep_bridge: bool,
220
221 #[arg(long)]
224 backup_sessions: bool,
225
226 #[arg(long, default_value_t = 10)]
228 keep_backups: usize,
229
230 #[arg(long, value_name = "KEY")]
237 delete_conflict: Vec<String>,
238 },
239}
240
241#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
242pub enum Vendor {
243 Anthropic,
244 #[value(name = "anthropic_api")]
245 AnthropicApi,
246 Openai,
247 Zai,
248 Openrouter,
249 Deepseek,
250 Kimi,
251 Kilo,
252 Novita,
253 Moonshot,
254 Grok,
255 Antigravity,
256 Cursor,
257 Minimax,
258}
259
260impl Vendor {
261 pub fn to_id(self) -> crate::vendor::VendorId {
262 match self {
263 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
264 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
265 Vendor::Openai => crate::vendor::VendorId::Openai,
266 Vendor::Zai => crate::vendor::VendorId::Zai,
267 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
268 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
269 Vendor::Kimi => crate::vendor::VendorId::Kimi,
270 Vendor::Kilo => crate::vendor::VendorId::Kilo,
271 Vendor::Novita => crate::vendor::VendorId::Novita,
272 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
273 Vendor::Grok => crate::vendor::VendorId::Grok,
274 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
275 Vendor::Cursor => crate::vendor::VendorId::Cursor,
276 Vendor::Minimax => crate::vendor::VendorId::Minimax,
277 }
278 }
279}
280
281impl Cli {
282 pub fn has_explicit_vendor(&self) -> bool {
284 self.vendor.is_some()
285 }
286
287 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
298 let active = if self.has_explicit_vendor() {
304 None
305 } else {
306 crate::active::read()
307 };
308 self.resolve_vendor_with(config, active)
309 }
310
311 pub fn resolve_vendor_with(
316 &self,
317 config: &crate::config::Config,
318 active: Option<crate::vendor::VendorId>,
319 ) -> Vendor {
320 if let Some(v) = self.vendor {
321 return v;
322 }
323 if let Some(id) = active
324 && config.is_enabled(id)
325 {
326 return id_to_vendor(id);
327 }
328 if let Some(id) = config.ui.primary
329 && config.is_enabled(id)
330 {
331 return id_to_vendor(id);
332 }
333 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
334 return Vendor::Anthropic;
335 }
336 config
337 .enabled_vendors()
338 .into_iter()
339 .next()
340 .map(id_to_vendor)
341 .unwrap_or(Vendor::Anthropic)
344 }
345}
346
347fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
348 match id {
349 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
350 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
351 crate::vendor::VendorId::Openai => Vendor::Openai,
352 crate::vendor::VendorId::Zai => Vendor::Zai,
353 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
354 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
355 crate::vendor::VendorId::Kimi => Vendor::Kimi,
356 crate::vendor::VendorId::Kilo => Vendor::Kilo,
357 crate::vendor::VendorId::Novita => Vendor::Novita,
358 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
359 crate::vendor::VendorId::Grok => Vendor::Grok,
360 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
361 crate::vendor::VendorId::Cursor => Vendor::Cursor,
362 crate::vendor::VendorId::Minimax => Vendor::Minimax,
363 }
364}
365
366impl Cli {
367 pub fn output_json(&self) -> bool {
370 if self.json {
371 return true;
372 }
373 if self.pretty || self.watch.is_some() {
374 return false;
375 }
376 !is_stdout_tty()
378 }
379}
380
381fn is_stdout_tty() -> bool {
382 use std::io::IsTerminal;
383 std::io::stdout().is_terminal()
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use clap::Parser;
390
391 #[test]
392 fn usage_subcommand_parses_machine_readable_mode() {
393 let cli = Cli::parse_from(["ai-usagebar", "usage", "--json"]);
394 assert!(matches!(cli.command, Some(Command::Usage { json: true })));
395 }
396
397 #[test]
398 fn defaults_match_claudebar() {
399 let cli = Cli::parse_from(["ai-usagebar"]);
400 assert_eq!(cli.vendor, None);
401 let cfg = crate::config::Config::default();
406 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
407 assert_eq!(cli.pace_tolerance, 5);
408 assert!(cli.format.is_none());
409 assert!(cli.tooltip_format.is_none());
410 assert!(cli.icon.is_none());
411 assert!(!cli.format_pace_color);
412 assert!(!cli.tooltip_pace_pts);
413 assert!(!cli.pretty);
414 assert!(!cli.json);
415 assert!(cli.watch.is_none());
416 assert!(cli.command.is_none());
417 }
418
419 #[test]
420 fn account_add_subcommand_parses_without_widget_flags() {
421 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
422 assert!(matches!(
423 cli.command,
424 Some(Command::Account {
425 action: AccountAction::Add {
426 ref label,
427 no_login: true,
428 desktop: false,
429 ..
430 }
431 }) if label == "work"
432 ));
433 }
434
435 #[test]
438 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
439 let cli = Cli::parse_from([
440 "ai-usagebar",
441 "account",
442 "add",
443 "work",
444 "--desktop",
445 "--email",
446 "a@b.test",
447 "-y",
448 ]);
449 assert!(matches!(
450 cli.command,
451 Some(Command::Account {
452 action: AccountAction::Add {
453 desktop: true,
454 yes: true,
455 email: Some(ref email),
456 ..
457 }
458 }) if email == "a@b.test"
459 ));
460 assert!(
461 Cli::try_parse_from([
462 "ai-usagebar",
463 "account",
464 "add",
465 "w",
466 "--desktop",
467 "--no-login"
468 ])
469 .is_err()
470 );
471 assert!(
473 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
474 .is_err()
475 );
476 }
477
478 #[test]
479 fn account_switch_defaults_to_both_surfaces() {
480 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
481 assert!(matches!(
482 cli.command,
483 Some(Command::Account {
484 action: AccountAction::Switch {
485 ref label,
486 desktop: false,
487 cli: false,
488 dry_run: true,
489 keep_backups: 10,
490 ..
491 }
492 }) if label == "work"
493 ));
494 }
495
496 #[test]
497 fn account_subcommand_rejects_ignored_widget_flags() {
498 assert!(
499 Cli::try_parse_from([
500 "ai-usagebar",
501 "--vendor",
502 "anthropic",
503 "account",
504 "add",
505 "work",
506 ])
507 .is_err()
508 );
509 }
510
511 #[test]
512 fn multi_account_flags_are_stable_api() {
513 let cli = Cli::parse_from([
517 "ai-usagebar",
518 "--vendor",
519 "anthropic",
520 "--cache-dir",
521 "/tmp/acct-a",
522 "--creds-path",
523 "/tmp/acct-a/credentials.json",
524 ]);
525 assert_eq!(
526 cli.cache_dir.as_deref(),
527 Some(std::path::Path::new("/tmp/acct-a"))
528 );
529 assert_eq!(
530 cli.creds_path.as_deref(),
531 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
532 );
533 }
534
535 #[test]
536 fn primary_from_config_wins_when_vendor_unset() {
537 let cli = Cli::parse_from(["ai-usagebar"]);
539 let mut cfg = crate::config::Config::default();
540 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
541 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
542 }
543
544 #[test]
545 fn explicit_vendor_overrides_everything() {
546 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
549 let mut cfg = crate::config::Config::default();
550 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
551 let active = Some(crate::vendor::VendorId::Openai);
552 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
553 }
554
555 #[test]
556 fn vendor_kimi_parses_to_kimi_variant() {
557 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
558 assert_eq!(cli.vendor, Some(Vendor::Kimi));
559 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
560 }
561
562 #[test]
563 fn vendor_anthropic_api_uses_the_documented_slug() {
564 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
565 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
566 assert_eq!(
567 cli.vendor.unwrap().to_id(),
568 crate::vendor::VendorId::AnthropicApi
569 );
570 }
571
572 #[test]
573 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
574 let cli = Cli::parse_from(["ai-usagebar"]);
575 let mut cfg = crate::config::Config::default();
576 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
577 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
578 }
579
580 #[test]
581 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
582 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
583 assert_eq!(
584 cli.resolve_vendor_with(&crate::config::Config::default(), None),
585 Vendor::Kimi
586 );
587 }
588
589 #[test]
590 fn active_override_wins_over_config_primary_when_enabled() {
591 let cli = Cli::parse_from(["ai-usagebar"]);
594 let mut cfg = crate::config::Config::default();
595 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
596 let active = Some(crate::vendor::VendorId::Zai);
597 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
598 }
599
600 #[test]
601 fn disabled_active_override_falls_back_to_config_primary() {
602 let cli = Cli::parse_from(["ai-usagebar"]);
605 let mut cfg = crate::config::Config::default();
606 cfg.zai.enabled = false;
607 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
608 let active = Some(crate::vendor::VendorId::Zai);
609 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
610 }
611
612 #[test]
613 fn claudebar_compatible_flag_surface() {
614 let cli = Cli::parse_from([
615 "ai-usagebar",
616 "--icon",
617 "",
618 "--format",
619 "{session_pct}% · {session_reset}",
620 "--tooltip-format",
621 "S:{session_pct}",
622 "--pace-tolerance",
623 "10",
624 "--format-pace-color",
625 "--tooltip-pace-pts",
626 "--color-low",
627 "#50fa7b",
628 "--color-mid",
629 "#f1fa8c",
630 "--color-high",
631 "#ffb86c",
632 "--color-critical",
633 "#ff5555",
634 ]);
635 assert_eq!(cli.icon.as_deref(), Some(""));
636 assert_eq!(
637 cli.format.as_deref(),
638 Some("{session_pct}% · {session_reset}")
639 );
640 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
641 assert_eq!(cli.pace_tolerance, 10);
642 assert!(cli.format_pace_color);
643 assert!(cli.tooltip_pace_pts);
644 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
645 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
646 }
647
648 #[test]
649 fn pretty_and_json_conflict() {
650 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
651 assert!(res.is_err());
652 }
653
654 #[test]
655 fn watch_disables_json_output() {
656 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
657 assert_eq!(cli.watch, Some(5));
658 assert!(!cli.output_json());
659 }
660}