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 Openai,
130 Zai,
131 Openrouter,
132 Deepseek,
133 Kimi,
134}
135
136impl Vendor {
137 pub fn to_id(self) -> crate::vendor::VendorId {
138 match self {
139 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
140 Vendor::Openai => crate::vendor::VendorId::Openai,
141 Vendor::Zai => crate::vendor::VendorId::Zai,
142 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
143 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
144 Vendor::Kimi => crate::vendor::VendorId::Kimi,
145 }
146 }
147}
148
149impl Cli {
150 pub fn has_explicit_vendor(&self) -> bool {
152 self.vendor.is_some()
153 }
154
155 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
166 let active = if self.has_explicit_vendor() {
172 None
173 } else {
174 crate::active::read()
175 };
176 self.resolve_vendor_with(config, active)
177 }
178
179 pub fn resolve_vendor_with(
184 &self,
185 config: &crate::config::Config,
186 active: Option<crate::vendor::VendorId>,
187 ) -> Vendor {
188 if let Some(v) = self.vendor {
189 return v;
190 }
191 if let Some(id) = active
192 && config.is_enabled(id)
193 {
194 return id_to_vendor(id);
195 }
196 if let Some(id) = config.ui.primary
197 && config.is_enabled(id)
198 {
199 return id_to_vendor(id);
200 }
201 if config.is_enabled(crate::vendor::VendorId::Anthropic) {
202 return Vendor::Anthropic;
203 }
204 config
205 .enabled_vendors()
206 .into_iter()
207 .next()
208 .map(id_to_vendor)
209 .unwrap_or(Vendor::Anthropic)
212 }
213}
214
215fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
216 match id {
217 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
218 crate::vendor::VendorId::Openai => Vendor::Openai,
219 crate::vendor::VendorId::Zai => Vendor::Zai,
220 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
221 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
222 crate::vendor::VendorId::Kimi => Vendor::Kimi,
223 }
224}
225
226impl Cli {
227 pub fn output_json(&self) -> bool {
230 if self.json {
231 return true;
232 }
233 if self.pretty || self.watch.is_some() {
234 return false;
235 }
236 !is_stdout_tty()
238 }
239}
240
241fn is_stdout_tty() -> bool {
242 use std::io::IsTerminal;
243 std::io::stdout().is_terminal()
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use clap::Parser;
250
251 #[test]
252 fn defaults_match_claudebar() {
253 let cli = Cli::parse_from(["ai-usagebar"]);
254 assert_eq!(cli.vendor, None);
255 let cfg = crate::config::Config::default();
260 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
261 assert_eq!(cli.pace_tolerance, 5);
262 assert!(cli.format.is_none());
263 assert!(cli.tooltip_format.is_none());
264 assert!(cli.icon.is_none());
265 assert!(!cli.format_pace_color);
266 assert!(!cli.tooltip_pace_pts);
267 assert!(!cli.pretty);
268 assert!(!cli.json);
269 assert!(cli.watch.is_none());
270 }
271
272 #[test]
273 fn multi_account_flags_are_stable_api() {
274 let cli = Cli::parse_from([
278 "ai-usagebar",
279 "--vendor",
280 "anthropic",
281 "--cache-dir",
282 "/tmp/acct-a",
283 "--creds-path",
284 "/tmp/acct-a/credentials.json",
285 ]);
286 assert_eq!(
287 cli.cache_dir.as_deref(),
288 Some(std::path::Path::new("/tmp/acct-a"))
289 );
290 assert_eq!(
291 cli.creds_path.as_deref(),
292 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
293 );
294 }
295
296 #[test]
297 fn primary_from_config_wins_when_vendor_unset() {
298 let cli = Cli::parse_from(["ai-usagebar"]);
300 let mut cfg = crate::config::Config::default();
301 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
302 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
303 }
304
305 #[test]
306 fn explicit_vendor_overrides_everything() {
307 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
310 let mut cfg = crate::config::Config::default();
311 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
312 let active = Some(crate::vendor::VendorId::Openai);
313 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
314 }
315
316 #[test]
317 fn vendor_kimi_parses_to_kimi_variant() {
318 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
319 assert_eq!(cli.vendor, Some(Vendor::Kimi));
320 assert_eq!(cli.vendor.unwrap().to_id(), crate::vendor::VendorId::Kimi);
321 }
322
323 #[test]
324 fn disabled_kimi_primary_falls_back_to_an_enabled_vendor() {
325 let cli = Cli::parse_from(["ai-usagebar"]);
326 let mut cfg = crate::config::Config::default();
327 cfg.ui.primary = Some(crate::vendor::VendorId::Kimi);
328 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
329 }
330
331 #[test]
332 fn explicit_kimi_remains_an_opt_in_override_when_disabled() {
333 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "kimi"]);
334 assert_eq!(
335 cli.resolve_vendor_with(&crate::config::Config::default(), None),
336 Vendor::Kimi
337 );
338 }
339
340 #[test]
341 fn active_override_wins_over_config_primary_when_enabled() {
342 let cli = Cli::parse_from(["ai-usagebar"]);
345 let mut cfg = crate::config::Config::default();
346 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
347 let active = Some(crate::vendor::VendorId::Zai);
348 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
349 }
350
351 #[test]
352 fn disabled_active_override_falls_back_to_config_primary() {
353 let cli = Cli::parse_from(["ai-usagebar"]);
356 let mut cfg = crate::config::Config::default();
357 cfg.zai.enabled = false;
358 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
359 let active = Some(crate::vendor::VendorId::Zai);
360 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
361 }
362
363 #[test]
364 fn claudebar_compatible_flag_surface() {
365 let cli = Cli::parse_from([
366 "ai-usagebar",
367 "--icon",
368 "",
369 "--format",
370 "{session_pct}% · {session_reset}",
371 "--tooltip-format",
372 "S:{session_pct}",
373 "--pace-tolerance",
374 "10",
375 "--format-pace-color",
376 "--tooltip-pace-pts",
377 "--color-low",
378 "#50fa7b",
379 "--color-mid",
380 "#f1fa8c",
381 "--color-high",
382 "#ffb86c",
383 "--color-critical",
384 "#ff5555",
385 ]);
386 assert_eq!(cli.icon.as_deref(), Some(""));
387 assert_eq!(
388 cli.format.as_deref(),
389 Some("{session_pct}% · {session_reset}")
390 );
391 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
392 assert_eq!(cli.pace_tolerance, 10);
393 assert!(cli.format_pace_color);
394 assert!(cli.tooltip_pace_pts);
395 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
396 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
397 }
398
399 #[test]
400 fn pretty_and_json_conflict() {
401 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
402 assert!(res.is_err());
403 }
404
405 #[test]
406 fn watch_disables_json_output() {
407 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
408 assert_eq!(cli.watch, Some(5));
409 assert!(!cli.output_json());
410 }
411}