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 for AI plan usage (Anthropic / OpenAI / Z.AI / OpenRouter / DeepSeek / Kimi)",
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)]
149 no_login: bool,
150 },
151}
152
153#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
154pub enum Vendor {
155 Anthropic,
156 #[value(name = "anthropic_api")]
157 AnthropicApi,
158 Openai,
159 Zai,
160 Openrouter,
161 Deepseek,
162 Kimi,
163 Kilo,
164 Novita,
165 Moonshot,
166 Grok,
167 Antigravity,
168 Cursor,
169}
170
171impl Vendor {
172 pub fn to_id(self) -> crate::vendor::VendorId {
173 match self {
174 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
175 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
176 Vendor::Openai => crate::vendor::VendorId::Openai,
177 Vendor::Zai => crate::vendor::VendorId::Zai,
178 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
179 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
180 Vendor::Kimi => crate::vendor::VendorId::Kimi,
181 Vendor::Kilo => crate::vendor::VendorId::Kilo,
182 Vendor::Novita => crate::vendor::VendorId::Novita,
183 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
184 Vendor::Grok => crate::vendor::VendorId::Grok,
185 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
186 Vendor::Cursor => crate::vendor::VendorId::Cursor,
187 }
188 }
189}
190
191impl Cli {
192 pub fn has_explicit_vendor(&self) -> bool {
194 self.vendor.is_some()
195 }
196
197 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
208 let active = if self.has_explicit_vendor() {
214 None
215 } else {
216 crate::active::read()
217 };
218 self.resolve_vendor_with(config, active)
219 }
220
221 pub fn resolve_vendor_with(
226 &self,
227 config: &crate::config::Config,
228 active: Option<crate::vendor::VendorId>,
229 ) -> Vendor {
230 if let Some(v) = self.vendor {
231 return v;
232 }
233 if let Some(id) = active
234 && config.is_enabled(id)
235 {
236 return id_to_vendor(id);
237 }
238 if let Some(id) = config.ui.primary
239 && config.is_enabled(id)
240 {
241 return id_to_vendor(id);
242 }
243 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
244 return Vendor::Anthropic;
245 }
246 config
247 .enabled_vendors()
248 .into_iter()
249 .next()
250 .map(id_to_vendor)
251 .unwrap_or(Vendor::Anthropic)
254 }
255}
256
257fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
258 match id {
259 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
260 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
261 crate::vendor::VendorId::Openai => Vendor::Openai,
262 crate::vendor::VendorId::Zai => Vendor::Zai,
263 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
264 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
265 crate::vendor::VendorId::Kimi => Vendor::Kimi,
266 crate::vendor::VendorId::Kilo => Vendor::Kilo,
267 crate::vendor::VendorId::Novita => Vendor::Novita,
268 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
269 crate::vendor::VendorId::Grok => Vendor::Grok,
270 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
271 crate::vendor::VendorId::Cursor => Vendor::Cursor,
272 }
273}
274
275impl Cli {
276 pub fn output_json(&self) -> bool {
279 if self.json {
280 return true;
281 }
282 if self.pretty || self.watch.is_some() {
283 return false;
284 }
285 !is_stdout_tty()
287 }
288}
289
290fn is_stdout_tty() -> bool {
291 use std::io::IsTerminal;
292 std::io::stdout().is_terminal()
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use clap::Parser;
299
300 #[test]
301 fn defaults_match_claudebar() {
302 let cli = Cli::parse_from(["ai-usagebar"]);
303 assert_eq!(cli.vendor, None);
304 let cfg = crate::config::Config::default();
309 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
310 assert_eq!(cli.pace_tolerance, 5);
311 assert!(cli.format.is_none());
312 assert!(cli.tooltip_format.is_none());
313 assert!(cli.icon.is_none());
314 assert!(!cli.format_pace_color);
315 assert!(!cli.tooltip_pace_pts);
316 assert!(!cli.pretty);
317 assert!(!cli.json);
318 assert!(cli.watch.is_none());
319 assert!(cli.command.is_none());
320 }
321
322 #[test]
323 fn account_add_subcommand_parses_without_widget_flags() {
324 let cli = Cli::parse_from(["ai-usagebar", "account", "add", "work", "--no-login"]);
325 assert!(matches!(
326 cli.command,
327 Some(Command::Account {
328 action: AccountAction::Add { ref label, no_login: true }
329 }) if label == "work"
330 ));
331 }
332
333 #[test]
334 fn account_subcommand_rejects_ignored_widget_flags() {
335 assert!(
336 Cli::try_parse_from([
337 "ai-usagebar",
338 "--vendor",
339 "anthropic",
340 "account",
341 "add",
342 "work",
343 ])
344 .is_err()
345 );
346 }
347
348 #[test]
349 fn multi_account_flags_are_stable_api() {
350 let cli = Cli::parse_from([
354 "ai-usagebar",
355 "--vendor",
356 "anthropic",
357 "--cache-dir",
358 "/tmp/acct-a",
359 "--creds-path",
360 "/tmp/acct-a/credentials.json",
361 ]);
362 assert_eq!(
363 cli.cache_dir.as_deref(),
364 Some(std::path::Path::new("/tmp/acct-a"))
365 );
366 assert_eq!(
367 cli.creds_path.as_deref(),
368 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
369 );
370 }
371
372 #[test]
373 fn primary_from_config_wins_when_vendor_unset() {
374 let cli = Cli::parse_from(["ai-usagebar"]);
376 let mut cfg = crate::config::Config::default();
377 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
378 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
379 }
380
381 #[test]
382 fn explicit_vendor_overrides_everything() {
383 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
386 let mut cfg = crate::config::Config::default();
387 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
388 let active = Some(crate::vendor::VendorId::Openai);
389 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
390 }
391
392 #[test]
393 fn vendor_kimi_parses_to_kimi_variant() {
394 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
395 assert_eq!(cli.vendor, Some(Vendor::Kimi));
396 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
397 }
398
399 #[test]
400 fn vendor_anthropic_api_uses_the_documented_slug() {
401 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
402 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
403 assert_eq!(
404 cli.vendor.unwrap().to_id(),
405 crate::vendor::VendorId::AnthropicApi
406 );
407 }
408
409 #[test]
410 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
411 let cli = Cli::parse_from(["ai-usagebar"]);
412 let mut cfg = crate::config::Config::default();
413 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
414 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
415 }
416
417 #[test]
418 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
419 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
420 assert_eq!(
421 cli.resolve_vendor_with(&crate::config::Config::default(), None),
422 Vendor::Kimi
423 );
424 }
425
426 #[test]
427 fn active_override_wins_over_config_primary_when_enabled() {
428 let cli = Cli::parse_from(["ai-usagebar"]);
431 let mut cfg = crate::config::Config::default();
432 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
433 let active = Some(crate::vendor::VendorId::Zai);
434 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
435 }
436
437 #[test]
438 fn disabled_active_override_falls_back_to_config_primary() {
439 let cli = Cli::parse_from(["ai-usagebar"]);
442 let mut cfg = crate::config::Config::default();
443 cfg.zai.enabled = false;
444 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
445 let active = Some(crate::vendor::VendorId::Zai);
446 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
447 }
448
449 #[test]
450 fn claudebar_compatible_flag_surface() {
451 let cli = Cli::parse_from([
452 "ai-usagebar",
453 "--icon",
454 "",
455 "--format",
456 "{session_pct}% · {session_reset}",
457 "--tooltip-format",
458 "S:{session_pct}",
459 "--pace-tolerance",
460 "10",
461 "--format-pace-color",
462 "--tooltip-pace-pts",
463 "--color-low",
464 "#50fa7b",
465 "--color-mid",
466 "#f1fa8c",
467 "--color-high",
468 "#ffb86c",
469 "--color-critical",
470 "#ff5555",
471 ]);
472 assert_eq!(cli.icon.as_deref(), Some(""));
473 assert_eq!(
474 cli.format.as_deref(),
475 Some("{session_pct}% · {session_reset}")
476 );
477 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
478 assert_eq!(cli.pace_tolerance, 10);
479 assert!(cli.format_pace_color);
480 assert!(cli.tooltip_pace_pts);
481 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
482 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
483 }
484
485 #[test]
486 fn pretty_and_json_conflict() {
487 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
488 assert!(res.is_err());
489 }
490
491 #[test]
492 fn watch_disables_json_output() {
493 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
494 assert_eq!(cli.watch, Some(5));
495 assert!(!cli.output_json());
496 }
497}