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 #[command(subcommand)]
128 pub command: Option<Command>,
129}
130
131#[derive(clap::Subcommand, Debug, Clone)]
132pub enum Command {
133 Account {
135 #[command(subcommand)]
136 action: AccountAction,
137 },
138}
139
140#[derive(clap::Subcommand, Debug, Clone)]
141pub enum AccountAction {
142 Add {
144 label: String,
146
147 #[arg(long, conflicts_with = "desktop")]
149 no_login: bool,
150
151 #[arg(long)]
156 desktop: bool,
157
158 #[arg(long, requires = "desktop")]
161 email: Option<String>,
162
163 #[arg(short = 'y', long, requires = "desktop")]
165 yes: bool,
166 },
167
168 Status {
170 #[arg(long)]
172 json: bool,
173 },
174
175 Switch {
177 label: String,
180
181 #[arg(long)]
183 desktop: bool,
184
185 #[arg(long)]
187 cli: bool,
188
189 #[arg(long)]
191 dry_run: bool,
192
193 #[arg(short = 'y', long)]
195 yes: bool,
196
197 #[arg(long)]
200 force: bool,
201
202 #[arg(long)]
205 keep_bridge: bool,
206
207 #[arg(long)]
210 backup_sessions: bool,
211
212 #[arg(long, default_value_t = 10)]
214 keep_backups: usize,
215 },
216}
217
218#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
219pub enum Vendor {
220 Anthropic,
221 #[value(name = "anthropic_api")]
222 AnthropicApi,
223 Openai,
224 Zai,
225 Openrouter,
226 Deepseek,
227 Kimi,
228 Kilo,
229 Novita,
230 Moonshot,
231 Grok,
232 Antigravity,
233 Cursor,
234 Minimax,
235}
236
237impl Vendor {
238 pub fn to_id(self) -> crate::vendor::VendorId {
239 match self {
240 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
241 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
242 Vendor::Openai => crate::vendor::VendorId::Openai,
243 Vendor::Zai => crate::vendor::VendorId::Zai,
244 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
245 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
246 Vendor::Kimi => crate::vendor::VendorId::Kimi,
247 Vendor::Kilo => crate::vendor::VendorId::Kilo,
248 Vendor::Novita => crate::vendor::VendorId::Novita,
249 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
250 Vendor::Grok => crate::vendor::VendorId::Grok,
251 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
252 Vendor::Cursor => crate::vendor::VendorId::Cursor,
253 Vendor::Minimax => crate::vendor::VendorId::Minimax,
254 }
255 }
256}
257
258impl Cli {
259 pub fn has_explicit_vendor(&self) -> bool {
261 self.vendor.is_some()
262 }
263
264 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
275 let active = if self.has_explicit_vendor() {
281 None
282 } else {
283 crate::active::read()
284 };
285 self.resolve_vendor_with(config, active)
286 }
287
288 pub fn resolve_vendor_with(
293 &self,
294 config: &crate::config::Config,
295 active: Option<crate::vendor::VendorId>,
296 ) -> Vendor {
297 if let Some(v) = self.vendor {
298 return v;
299 }
300 if let Some(id) = active
301 && config.is_enabled(id)
302 {
303 return id_to_vendor(id);
304 }
305 if let Some(id) = config.ui.primary
306 && config.is_enabled(id)
307 {
308 return id_to_vendor(id);
309 }
310 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
311 return Vendor::Anthropic;
312 }
313 config
314 .enabled_vendors()
315 .into_iter()
316 .next()
317 .map(id_to_vendor)
318 .unwrap_or(Vendor::Anthropic)
321 }
322}
323
324fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
325 match id {
326 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
327 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
328 crate::vendor::VendorId::Openai => Vendor::Openai,
329 crate::vendor::VendorId::Zai => Vendor::Zai,
330 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
331 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
332 crate::vendor::VendorId::Kimi => Vendor::Kimi,
333 crate::vendor::VendorId::Kilo => Vendor::Kilo,
334 crate::vendor::VendorId::Novita => Vendor::Novita,
335 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
336 crate::vendor::VendorId::Grok => Vendor::Grok,
337 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
338 crate::vendor::VendorId::Cursor => Vendor::Cursor,
339 crate::vendor::VendorId::Minimax => Vendor::Minimax,
340 }
341}
342
343impl Cli {
344 pub fn output_json(&self) -> bool {
347 if self.json {
348 return true;
349 }
350 if self.pretty || self.watch.is_some() {
351 return false;
352 }
353 !is_stdout_tty()
355 }
356}
357
358fn is_stdout_tty() -> bool {
359 use std::io::IsTerminal;
360 std::io::stdout().is_terminal()
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use clap::Parser;
367
368 #[test]
369 fn defaults_match_claudebar() {
370 let cli = Cli::parse_from(["ai-usagebar"]);
371 assert_eq!(cli.vendor, None);
372 let cfg = crate::config::Config::default();
377 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
378 assert_eq!(cli.pace_tolerance, 5);
379 assert!(cli.format.is_none());
380 assert!(cli.tooltip_format.is_none());
381 assert!(cli.icon.is_none());
382 assert!(!cli.format_pace_color);
383 assert!(!cli.tooltip_pace_pts);
384 assert!(!cli.pretty);
385 assert!(!cli.json);
386 assert!(cli.watch.is_none());
387 assert!(cli.command.is_none());
388 }
389
390 #[test]
391 fn account_add_subcommand_parses_without_widget_flags() {
392 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
393 assert!(matches!(
394 cli.command,
395 Some(Command::Account {
396 action: AccountAction::Add {
397 ref label,
398 no_login: true,
399 desktop: false,
400 ..
401 }
402 }) if label == "work"
403 ));
404 }
405
406 #[test]
409 fn account_add_desktop_takes_an_email_and_rejects_no_login() {
410 let cli = Cli::parse_from([
411 "ai-usagebar",
412 "account",
413 "add",
414 "work",
415 "--desktop",
416 "--email",
417 "a@b.test",
418 "-y",
419 ]);
420 assert!(matches!(
421 cli.command,
422 Some(Command::Account {
423 action: AccountAction::Add {
424 desktop: true,
425 yes: true,
426 email: Some(ref email),
427 ..
428 }
429 }) if email == "a@b.test"
430 ));
431 assert!(
432 Cli::try_parse_from([
433 "ai-usagebar",
434 "account",
435 "add",
436 "w",
437 "--desktop",
438 "--no-login"
439 ])
440 .is_err()
441 );
442 assert!(
444 Cli::try_parse_from(["ai-usagebar", "account", "add", "w", "--email", "a@b.test"])
445 .is_err()
446 );
447 }
448
449 #[test]
450 fn account_switch_defaults_to_both_surfaces() {
451 let cli = Cli::parse_from(["ai-usagebar", "account", "switch", "work", "--dry-run"]);
452 assert!(matches!(
453 cli.command,
454 Some(Command::Account {
455 action: AccountAction::Switch {
456 ref label,
457 desktop: false,
458 cli: false,
459 dry_run: true,
460 keep_backups: 10,
461 ..
462 }
463 }) if label == "work"
464 ));
465 }
466
467 #[test]
468 fn account_subcommand_rejects_ignored_widget_flags() {
469 assert!(
470 Cli::try_parse_from([
471 "ai-usagebar",
472 "--vendor",
473 "anthropic",
474 "account",
475 "add",
476 "work",
477 ])
478 .is_err()
479 );
480 }
481
482 #[test]
483 fn multi_account_flags_are_stable_api() {
484 let cli = Cli::parse_from([
488 "ai-usagebar",
489 "--vendor",
490 "anthropic",
491 "--cache-dir",
492 "/tmp/acct-a",
493 "--creds-path",
494 "/tmp/acct-a/credentials.json",
495 ]);
496 assert_eq!(
497 cli.cache_dir.as_deref(),
498 Some(std::path::Path::new("/tmp/acct-a"))
499 );
500 assert_eq!(
501 cli.creds_path.as_deref(),
502 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
503 );
504 }
505
506 #[test]
507 fn primary_from_config_wins_when_vendor_unset() {
508 let cli = Cli::parse_from(["ai-usagebar"]);
510 let mut cfg = crate::config::Config::default();
511 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
512 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
513 }
514
515 #[test]
516 fn explicit_vendor_overrides_everything() {
517 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
520 let mut cfg = crate::config::Config::default();
521 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
522 let active = Some(crate::vendor::VendorId::Openai);
523 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
524 }
525
526 #[test]
527 fn vendor_kimi_parses_to_kimi_variant() {
528 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
529 assert_eq!(cli.vendor, Some(Vendor::Kimi));
530 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
531 }
532
533 #[test]
534 fn vendor_anthropic_api_uses_the_documented_slug() {
535 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
536 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
537 assert_eq!(
538 cli.vendor.unwrap().to_id(),
539 crate::vendor::VendorId::AnthropicApi
540 );
541 }
542
543 #[test]
544 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
545 let cli = Cli::parse_from(["ai-usagebar"]);
546 let mut cfg = crate::config::Config::default();
547 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
548 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
549 }
550
551 #[test]
552 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
553 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
554 assert_eq!(
555 cli.resolve_vendor_with(&crate::config::Config::default(), None),
556 Vendor::Kimi
557 );
558 }
559
560 #[test]
561 fn active_override_wins_over_config_primary_when_enabled() {
562 let cli = Cli::parse_from(["ai-usagebar"]);
565 let mut cfg = crate::config::Config::default();
566 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
567 let active = Some(crate::vendor::VendorId::Zai);
568 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
569 }
570
571 #[test]
572 fn disabled_active_override_falls_back_to_config_primary() {
573 let cli = Cli::parse_from(["ai-usagebar"]);
576 let mut cfg = crate::config::Config::default();
577 cfg.zai.enabled = false;
578 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
579 let active = Some(crate::vendor::VendorId::Zai);
580 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
581 }
582
583 #[test]
584 fn claudebar_compatible_flag_surface() {
585 let cli = Cli::parse_from([
586 "ai-usagebar",
587 "--icon",
588 "",
589 "--format",
590 "{session_pct}% · {session_reset}",
591 "--tooltip-format",
592 "S:{session_pct}",
593 "--pace-tolerance",
594 "10",
595 "--format-pace-color",
596 "--tooltip-pace-pts",
597 "--color-low",
598 "#50fa7b",
599 "--color-mid",
600 "#f1fa8c",
601 "--color-high",
602 "#ffb86c",
603 "--color-critical",
604 "#ff5555",
605 ]);
606 assert_eq!(cli.icon.as_deref(), Some(""));
607 assert_eq!(
608 cli.format.as_deref(),
609 Some("{session_pct}% · {session_reset}")
610 );
611 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
612 assert_eq!(cli.pace_tolerance, 10);
613 assert!(cli.format_pace_color);
614 assert!(cli.tooltip_pace_pts);
615 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
616 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
617 }
618
619 #[test]
620 fn pretty_and_json_conflict() {
621 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
622 assert!(res.is_err());
623 }
624
625 #[test]
626 fn watch_disables_json_output() {
627 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
628 assert_eq!(cli.watch, Some(5));
629 assert!(!cli.output_json());
630 }
631}