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}
141
142impl Vendor {
143 pub fn to_id(self) -> crate::vendor::VendorId {
144 match self {
145 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
146 Vendor::AnthropicApi => crate::vendor::VendorId::AnthropicApi,
147 Vendor::Openai => crate::vendor::VendorId::Openai,
148 Vendor::Zai => crate::vendor::VendorId::Zai,
149 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
150 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
151 Vendor::Kimi => crate::vendor::VendorId::Kimi,
152 Vendor::Kilo => crate::vendor::VendorId::Kilo,
153 Vendor::Novita => crate::vendor::VendorId::Novita,
154 Vendor::Moonshot => crate::vendor::VendorId::Moonshot,
155 Vendor::Grok => crate::vendor::VendorId::Grok,
156 }
157 }
158}
159
160impl Cli {
161 pub fn has_explicit_vendor(&self) -> bool {
163 self.vendor.is_some()
164 }
165
166 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
177 let active = if self.has_explicit_vendor() {
183 None
184 } else {
185 crate::active::read()
186 };
187 self.resolve_vendor_with(config, active)
188 }
189
190 pub fn resolve_vendor_with(
195 &self,
196 config: &crate::config::Config,
197 active: Option<crate::vendor::VendorId>,
198 ) -> Vendor {
199 if let Some(v) = self.vendor {
200 return v;
201 }
202 if let Some(id) = active
203 && config.is_enabled(id)
204 {
205 return id_to_vendor(id);
206 }
207 if let Some(id) = config.ui.primary
208 && config.is_enabled(id)
209 {
210 return id_to_vendor(id);
211 }
212 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
213 return Vendor::Anthropic;
214 }
215 config
216 .enabled_vendors()
217 .into_iter()
218 .next()
219 .map(id_to_vendor)
220 .unwrap_or(Vendor::Anthropic)
223 }
224}
225
226fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
227 match id {
228 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
229 crate::vendor::VendorId::AnthropicApi => Vendor::AnthropicApi,
230 crate::vendor::VendorId::Openai => Vendor::Openai,
231 crate::vendor::VendorId::Zai => Vendor::Zai,
232 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
233 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
234 crate::vendor::VendorId::Kimi => Vendor::Kimi,
235 crate::vendor::VendorId::Kilo => Vendor::Kilo,
236 crate::vendor::VendorId::Novita => Vendor::Novita,
237 crate::vendor::VendorId::Moonshot => Vendor::Moonshot,
238 crate::vendor::VendorId::Grok => Vendor::Grok,
239 }
240}
241
242impl Cli {
243 pub fn output_json(&self) -> bool {
246 if self.json {
247 return true;
248 }
249 if self.pretty || self.watch.is_some() {
250 return false;
251 }
252 !is_stdout_tty()
254 }
255}
256
257fn is_stdout_tty() -> bool {
258 use std::io::IsTerminal;
259 std::io::stdout().is_terminal()
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use clap::Parser;
266
267 #[test]
268 fn defaults_match_claudebar() {
269 let cli = Cli::parse_from(["ai-usagebar"]);
270 assert_eq!(cli.vendor, None);
271 let cfg = crate::config::Config::default();
276 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
277 assert_eq!(cli.pace_tolerance, 5);
278 assert!(cli.format.is_none());
279 assert!(cli.tooltip_format.is_none());
280 assert!(cli.icon.is_none());
281 assert!(!cli.format_pace_color);
282 assert!(!cli.tooltip_pace_pts);
283 assert!(!cli.pretty);
284 assert!(!cli.json);
285 assert!(cli.watch.is_none());
286 }
287
288 #[test]
289 fn multi_account_flags_are_stable_api() {
290 let cli = Cli::parse_from([
294 "ai-usagebar",
295 "--vendor",
296 "anthropic",
297 "--cache-dir",
298 "/tmp/acct-a",
299 "--creds-path",
300 "/tmp/acct-a/credentials.json",
301 ]);
302 assert_eq!(
303 cli.cache_dir.as_deref(),
304 Some(std::path::Path::new("/tmp/acct-a"))
305 );
306 assert_eq!(
307 cli.creds_path.as_deref(),
308 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
309 );
310 }
311
312 #[test]
313 fn primary_from_config_wins_when_vendor_unset() {
314 let cli = Cli::parse_from(["ai-usagebar"]);
316 let mut cfg = crate::config::Config::default();
317 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
318 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
319 }
320
321 #[test]
322 fn explicit_vendor_overrides_everything() {
323 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
326 let mut cfg = crate::config::Config::default();
327 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
328 let active = Some(crate::vendor::VendorId::Openai);
329 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
330 }
331
332 #[test]
333 fn vendor_kimi_parses_to_kimi_variant() {
334 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
335 assert_eq!(cli.vendor, Some(Vendor::Kimi));
336 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
337 }
338
339 #[test]
340 fn vendor_anthropic_api_uses_the_documented_slug() {
341 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "anthropic_api"]);
342 assert_eq!(cli.vendor, Some(Vendor::AnthropicApi));
343 assert_eq!(
344 cli.vendor.unwrap().to_id(),
345 crate::vendor::VendorId::AnthropicApi
346 );
347 }
348
349 #[test]
350 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
351 let cli = Cli::parse_from(["ai-usagebar"]);
352 let mut cfg = crate::config::Config::default();
353 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
354 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
355 }
356
357 #[test]
358 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
359 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
360 assert_eq!(
361 cli.resolve_vendor_with(&crate::config::Config::default(), None),
362 Vendor::Kimi
363 );
364 }
365
366 #[test]
367 fn active_override_wins_over_config_primary_when_enabled() {
368 let cli = Cli::parse_from(["ai-usagebar"]);
371 let mut cfg = crate::config::Config::default();
372 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
373 let active = Some(crate::vendor::VendorId::Zai);
374 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
375 }
376
377 #[test]
378 fn disabled_active_override_falls_back_to_config_primary() {
379 let cli = Cli::parse_from(["ai-usagebar"]);
382 let mut cfg = crate::config::Config::default();
383 cfg.zai.enabled = false;
384 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
385 let active = Some(crate::vendor::VendorId::Zai);
386 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
387 }
388
389 #[test]
390 fn claudebar_compatible_flag_surface() {
391 let cli = Cli::parse_from([
392 "ai-usagebar",
393 "--icon",
394 "",
395 "--format",
396 "{session_pct}% · {session_reset}",
397 "--tooltip-format",
398 "S:{session_pct}",
399 "--pace-tolerance",
400 "10",
401 "--format-pace-color",
402 "--tooltip-pace-pts",
403 "--color-low",
404 "#50fa7b",
405 "--color-mid",
406 "#f1fa8c",
407 "--color-high",
408 "#ffb86c",
409 "--color-critical",
410 "#ff5555",
411 ]);
412 assert_eq!(cli.icon.as_deref(), Some(""));
413 assert_eq!(
414 cli.format.as_deref(),
415 Some("{session_pct}% · {session_reset}")
416 );
417 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
418 assert_eq!(cli.pace_tolerance, 10);
419 assert!(cli.format_pace_color);
420 assert!(cli.tooltip_pace_pts);
421 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
422 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
423 }
424
425 #[test]
426 fn pretty_and_json_conflict() {
427 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
428 assert!(res.is_err());
429 }
430
431 #[test]
432 fn watch_disables_json_output() {
433 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
434 assert_eq!(cli.watch, Some(5));
435 assert!(!cli.output_json());
436 }
437}