1use clap::{Parser, ValueEnum};
9
10#[derive(Parser, Debug, Clone)]
11#[command(
12 name = "ai-usagebar",
13 about = "Waybar widget for AI plan usage (Anthropic / OpenAI / Z.AI / OpenRouter / DeepSeek / Kimi)",
14 long_about = "\
15Drop-in replacement for `claudebar` with multi-vendor support.
16
17Output modes:
18 - Default: Waybar JSON ({text, tooltip, class}). Used when stdout is piped.
19 - --pretty: human-readable terminal output for local testing. Auto-enabled
20 when stdout is a TTY, so just running `ai-usagebar --vendor anthropic`
21 in a terminal Does The Right Thing.
22 - --watch N: like --pretty but refreshes every N seconds, clearing the screen
23 between ticks. Useful while iterating on `--format` or `--tooltip-format`.
24 - --json: force JSON output even when stdout is a TTY (for scripting)."
25)]
26pub struct Cli {
27 #[arg(long, value_enum)]
31 pub vendor: Option<Vendor>,
32
33 #[arg(long)]
36 pub icon: Option<String>,
37
38 #[arg(long)]
42 pub format: Option<String>,
43
44 #[arg(long)]
47 pub tooltip_format: Option<String>,
48
49 #[arg(long, default_value_t = 5)]
51 pub pace_tolerance: u32,
52
53 #[arg(long)]
56 pub format_pace_color: bool,
57
58 #[arg(long)]
62 pub tooltip_pace_pts: bool,
63
64 #[arg(long)]
66 pub color_low: Option<String>,
67 #[arg(long)]
69 pub color_mid: Option<String>,
70 #[arg(long)]
72 pub color_high: Option<String>,
73 #[arg(long)]
75 pub color_critical: Option<String>,
76
77 #[arg(long)]
80 pub pretty: bool,
81
82 #[arg(long, conflicts_with = "pretty")]
85 pub json: bool,
86
87 #[arg(long, value_name = "SECS")]
90 pub watch: Option<u64>,
91
92 #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
97 pub cycle_next: bool,
98
99 #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
101 pub cycle_prev: bool,
102
103 #[arg(long, value_name = "DIR")]
107 pub cache_dir: Option<std::path::PathBuf>,
108
109 #[arg(long, value_name = "FILE")]
115 pub creds_path: Option<std::path::PathBuf>,
116
117 #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
123 pub account: Option<String>,
124}
125
126#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
127pub enum Vendor {
128 Anthropic,
129 #[value(name = "anthropic_api")]
130 AnthropicApi,
131 Openai,
132 Zai,
133 Openrouter,
134 Deepseek,
135 Kimi,
136 Kilo,
137 Novita,
138 Moonshot,
139 Grok,
140 Antigravity,
141}
142
143impl Vendor {
144 pub fn to_id(self) -> crate::vendor::VendorId {
145 match self {
146 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
147 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
148 Vendor::Openai => crate::vendor::VendorId::Openai,
149 Vendor::Zai => crate::vendor::VendorId::Zai,
150 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
151 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
152 Vendor::Kimi => crate::vendor::VendorId::Kimi,
153 Vendor::Kilo => crate::vendor::VendorId::Kilo,
154 Vendor::Novita => crate::vendor::VendorId::Novita,
155 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
156 Vendor::Grok => crate::vendor::VendorId::Grok,
157 Vendor::Antigravity => crate::vendor::VendorId::Antigravity,
158 }
159 }
160}
161
162impl Cli {
163 pub fn has_explicit_vendor(&self) -> bool {
165 self.vendor.is_some()
166 }
167
168 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
179 let active = if self.has_explicit_vendor() {
185 None
186 } else {
187 crate::active::read()
188 };
189 self.resolve_vendor_with(config, active)
190 }
191
192 pub fn resolve_vendor_with(
197 &self,
198 config: &crate::config::Config,
199 active: Option<crate::vendor::VendorId>,
200 ) -> Vendor {
201 if let Some(v) = self.vendor {
202 return v;
203 }
204 if let Some(id) = active
205 && config.is_enabled(id)
206 {
207 return id_to_vendor(id);
208 }
209 if let Some(id) = config.ui.primary
210 && config.is_enabled(id)
211 {
212 return id_to_vendor(id);
213 }
214 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
215 return Vendor::Anthropic;
216 }
217 config
218 .enabled_vendors()
219 .into_iter()
220 .next()
221 .map(id_to_vendor)
222 .unwrap_or(Vendor::Anthropic)
225 }
226}
227
228fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
229 match id {
230 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
231 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
232 crate::vendor::VendorId::Openai => Vendor::Openai,
233 crate::vendor::VendorId::Zai => Vendor::Zai,
234 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
235 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
236 crate::vendor::VendorId::Kimi => Vendor::Kimi,
237 crate::vendor::VendorId::Kilo => Vendor::Kilo,
238 crate::vendor::VendorId::Novita => Vendor::Novita,
239 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
240 crate::vendor::VendorId::Grok => Vendor::Grok,
241 crate::vendor::VendorId::Antigravity => Vendor::Antigravity,
242 }
243}
244
245impl Cli {
246 pub fn output_json(&self) -> bool {
249 if self.json {
250 return true;
251 }
252 if self.pretty || self.watch.is_some() {
253 return false;
254 }
255 !is_stdout_tty()
257 }
258}
259
260fn is_stdout_tty() -> bool {
261 use std::io::IsTerminal;
262 std::io::stdout().is_terminal()
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use clap::Parser;
269
270 #[test]
271 fn defaults_match_claudebar() {
272 let cli = Cli::parse_from(["ai-usagebar"]);
273 assert_eq!(cli.vendor, None);
274 let cfg = crate::config::Config::default();
279 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
280 assert_eq!(cli.pace_tolerance, 5);
281 assert!(cli.format.is_none());
282 assert!(cli.tooltip_format.is_none());
283 assert!(cli.icon.is_none());
284 assert!(!cli.format_pace_color);
285 assert!(!cli.tooltip_pace_pts);
286 assert!(!cli.pretty);
287 assert!(!cli.json);
288 assert!(cli.watch.is_none());
289 }
290
291 #[test]
292 fn multi_account_flags_are_stable_api() {
293 let cli = Cli::parse_from([
297 "ai-usagebar",
298 "--vendor",
299 "anthropic",
300 "--cache-dir",
301 "/tmp/acct-a",
302 "--creds-path",
303 "/tmp/acct-a/credentials.json",
304 ]);
305 assert_eq!(
306 cli.cache_dir.as_deref(),
307 Some(std::path::Path::new("/tmp/acct-a"))
308 );
309 assert_eq!(
310 cli.creds_path.as_deref(),
311 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
312 );
313 }
314
315 #[test]
316 fn primary_from_config_wins_when_vendor_unset() {
317 let cli = Cli::parse_from(["ai-usagebar"]);
319 let mut cfg = crate::config::Config::default();
320 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
321 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
322 }
323
324 #[test]
325 fn explicit_vendor_overrides_everything() {
326 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
329 let mut cfg = crate::config::Config::default();
330 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
331 let active = Some(crate::vendor::VendorId::Openai);
332 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
333 }
334
335 #[test]
336 fn vendor_kimi_parses_to_kimi_variant() {
337 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
338 assert_eq!(cli.vendor, Some(Vendor::Kimi));
339 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
340 }
341
342 #[test]
343 fn vendor_anthropic_api_uses_the_documented_slug() {
344 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
345 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
346 assert_eq!(
347 cli.vendor.unwrap().to_id(),
348 crate::vendor::VendorId::AnthropicApi
349 );
350 }
351
352 #[test]
353 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
354 let cli = Cli::parse_from(["ai-usagebar"]);
355 let mut cfg = crate::config::Config::default();
356 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
357 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
358 }
359
360 #[test]
361 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
362 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
363 assert_eq!(
364 cli.resolve_vendor_with(&crate::config::Config::default(), None),
365 Vendor::Kimi
366 );
367 }
368
369 #[test]
370 fn active_override_wins_over_config_primary_when_enabled() {
371 let cli = Cli::parse_from(["ai-usagebar"]);
374 let mut cfg = crate::config::Config::default();
375 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
376 let active = Some(crate::vendor::VendorId::Zai);
377 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
378 }
379
380 #[test]
381 fn disabled_active_override_falls_back_to_config_primary() {
382 let cli = Cli::parse_from(["ai-usagebar"]);
385 let mut cfg = crate::config::Config::default();
386 cfg.zai.enabled = false;
387 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
388 let active = Some(crate::vendor::VendorId::Zai);
389 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
390 }
391
392 #[test]
393 fn claudebar_compatible_flag_surface() {
394 let cli = Cli::parse_from([
395 "ai-usagebar",
396 "--icon",
397 "",
398 "--format",
399 "{session_pct}% · {session_reset}",
400 "--tooltip-format",
401 "S:{session_pct}",
402 "--pace-tolerance",
403 "10",
404 "--format-pace-color",
405 "--tooltip-pace-pts",
406 "--color-low",
407 "#50fa7b",
408 "--color-mid",
409 "#f1fa8c",
410 "--color-high",
411 "#ffb86c",
412 "--color-critical",
413 "#ff5555",
414 ]);
415 assert_eq!(cli.icon.as_deref(), Some(""));
416 assert_eq!(
417 cli.format.as_deref(),
418 Some("{session_pct}% · {session_reset}")
419 );
420 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
421 assert_eq!(cli.pace_tolerance, 10);
422 assert!(cli.format_pace_color);
423 assert!(cli.tooltip_pace_pts);
424 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
425 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
426 }
427
428 #[test]
429 fn pretty_and_json_conflict() {
430 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
431 assert!(res.is_err());
432 }
433
434 #[test]
435 fn watch_disables_json_output() {
436 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
437 assert_eq!(cli.watch, Some(5));
438 assert!(!cli.output_json());
439 }
440}