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)",
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)]
41 pub format: Option<String>,
42
43 #[arg(long)]
46 pub tooltip_format: Option<String>,
47
48 #[arg(long, default_value_t = 5)]
50 pub pace_tolerance: u32,
51
52 #[arg(long)]
55 pub format_pace_color: bool,
56
57 #[arg(long)]
61 pub tooltip_pace_pts: bool,
62
63 #[arg(long)]
65 pub color_low: Option<String>,
66 #[arg(long)]
68 pub color_mid: Option<String>,
69 #[arg(long)]
71 pub color_high: Option<String>,
72 #[arg(long)]
74 pub color_critical: Option<String>,
75
76 #[arg(long)]
79 pub pretty: bool,
80
81 #[arg(long, conflicts_with = "pretty")]
84 pub json: bool,
85
86 #[arg(long, value_name = "SECS")]
89 pub watch: Option<u64>,
90
91 #[arg(long, conflicts_with_all = ["cycle_prev", "watch", "pretty", "json"])]
96 pub cycle_next: bool,
97
98 #[arg(long, conflicts_with_all = ["cycle_next", "watch", "pretty", "json"])]
100 pub cycle_prev: bool,
101
102 #[arg(long, value_name = "DIR")]
106 pub cache_dir: Option<std::path::PathBuf>,
107
108 #[arg(long, value_name = "FILE")]
114 pub creds_path: Option<std::path::PathBuf>,
115
116 #[arg(long, value_name = "LABEL", conflicts_with = "creds_path")]
122 pub account: Option<String>,
123}
124
125#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
126pub enum Vendor {
127 Anthropic,
128 Openai,
129 Zai,
130 Openrouter,
131 Deepseek,
132}
133
134impl Vendor {
135 pub fn to_id(self) -> crate::vendor::VendorId {
136 match self {
137 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
138 Vendor::Openai => crate::vendor::VendorId::Openai,
139 Vendor::Zai => crate::vendor::VendorId::Zai,
140 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
141 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
142 }
143 }
144}
145
146impl Cli {
147 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
158 let active = if self.vendor.is_some() {
164 None
165 } else {
166 crate::active::read()
167 };
168 self.resolve_vendor_with(config, active)
169 }
170
171 pub fn resolve_vendor_with(
176 &self,
177 config: &crate::config::Config,
178 active: Option<crate::vendor::VendorId>,
179 ) -> Vendor {
180 if let Some(v) = self.vendor {
181 return v;
182 }
183 if let Some(id) = active
184 && config.is_enabled(id)
185 {
186 return id_to_vendor(id);
187 }
188 match config.ui.primary {
189 Some(id) => id_to_vendor(id),
190 None => Vendor::Anthropic,
191 }
192 }
193}
194
195fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
196 match id {
197 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
198 crate::vendor::VendorId::Openai => Vendor::Openai,
199 crate::vendor::VendorId::Zai => Vendor::Zai,
200 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
201 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
202 }
203}
204
205impl Cli {
206 pub fn output_json(&self) -> bool {
209 if self.json {
210 return true;
211 }
212 if self.pretty || self.watch.is_some() {
213 return false;
214 }
215 !is_stdout_tty()
217 }
218}
219
220fn is_stdout_tty() -> bool {
221 use std::io::IsTerminal;
222 std::io::stdout().is_terminal()
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use clap::Parser;
229
230 #[test]
231 fn defaults_match_claudebar() {
232 let cli = Cli::parse_from(["ai-usagebar"]);
233 assert_eq!(cli.vendor, None);
234 let cfg = crate::config::Config::default();
239 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
240 assert_eq!(cli.pace_tolerance, 5);
241 assert!(cli.format.is_none());
242 assert!(cli.tooltip_format.is_none());
243 assert!(cli.icon.is_none());
244 assert!(!cli.format_pace_color);
245 assert!(!cli.tooltip_pace_pts);
246 assert!(!cli.pretty);
247 assert!(!cli.json);
248 assert!(cli.watch.is_none());
249 }
250
251 #[test]
252 fn multi_account_flags_are_stable_api() {
253 let cli = Cli::parse_from([
257 "ai-usagebar",
258 "--vendor",
259 "anthropic",
260 "--cache-dir",
261 "/tmp/acct-a",
262 "--creds-path",
263 "/tmp/acct-a/credentials.json",
264 ]);
265 assert_eq!(
266 cli.cache_dir.as_deref(),
267 Some(std::path::Path::new("/tmp/acct-a"))
268 );
269 assert_eq!(
270 cli.creds_path.as_deref(),
271 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
272 );
273 }
274
275 #[test]
276 fn primary_from_config_wins_when_vendor_unset() {
277 let cli = Cli::parse_from(["ai-usagebar"]);
279 let mut cfg = crate::config::Config::default();
280 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
281 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
282 }
283
284 #[test]
285 fn explicit_vendor_overrides_everything() {
286 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
289 let mut cfg = crate::config::Config::default();
290 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
291 let active = Some(crate::vendor::VendorId::Openai);
292 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
293 }
294
295 #[test]
296 fn active_override_wins_over_config_primary_when_enabled() {
297 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 let active = Some(crate::vendor::VendorId::Zai);
303 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
304 }
305
306 #[test]
307 fn disabled_active_override_falls_back_to_config_primary() {
308 let cli = Cli::parse_from(["ai-usagebar"]);
311 let mut cfg = crate::config::Config::default();
312 cfg.zai.enabled = false;
313 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
314 let active = Some(crate::vendor::VendorId::Zai);
315 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
316 }
317
318 #[test]
319 fn claudebar_compatible_flag_surface() {
320 let cli = Cli::parse_from([
321 "ai-usagebar",
322 "--icon",
323 "",
324 "--format",
325 "{session_pct}% · {session_reset}",
326 "--tooltip-format",
327 "S:{session_pct}",
328 "--pace-tolerance",
329 "10",
330 "--format-pace-color",
331 "--tooltip-pace-pts",
332 "--color-low",
333 "#50fa7b",
334 "--color-mid",
335 "#f1fa8c",
336 "--color-high",
337 "#ffb86c",
338 "--color-critical",
339 "#ff5555",
340 ]);
341 assert_eq!(cli.icon.as_deref(), Some(""));
342 assert_eq!(
343 cli.format.as_deref(),
344 Some("{session_pct}% · {session_reset}")
345 );
346 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
347 assert_eq!(cli.pace_tolerance, 10);
348 assert!(cli.format_pace_color);
349 assert!(cli.tooltip_pace_pts);
350 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
351 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
352 }
353
354 #[test]
355 fn pretty_and_json_conflict() {
356 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
357 assert!(res.is_err());
358 }
359
360 #[test]
361 fn watch_disables_json_output() {
362 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
363 assert_eq!(cli.watch, Some(5));
364 assert!(!cli.output_json());
365 }
366}