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
117#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
118pub enum Vendor {
119 Anthropic,
120 Openai,
121 Zai,
122 Openrouter,
123 Deepseek,
124}
125
126impl Vendor {
127 pub fn to_id(self) -> crate::vendor::VendorId {
128 match self {
129 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
130 Vendor::Openai => crate::vendor::VendorId::Openai,
131 Vendor::Zai => crate::vendor::VendorId::Zai,
132 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
133 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
134 }
135 }
136}
137
138impl Cli {
139 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
150 let active = if self.vendor.is_some() {
156 None
157 } else {
158 crate::active::read()
159 };
160 self.resolve_vendor_with(config, active)
161 }
162
163 pub fn resolve_vendor_with(
168 &self,
169 config: &crate::config::Config,
170 active: Option<crate::vendor::VendorId>,
171 ) -> Vendor {
172 if let Some(v) = self.vendor {
173 return v;
174 }
175 if let Some(id) = active
176 && config.is_enabled(id)
177 {
178 return id_to_vendor(id);
179 }
180 match config.ui.primary {
181 Some(id) => id_to_vendor(id),
182 None => Vendor::Anthropic,
183 }
184 }
185}
186
187fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
188 match id {
189 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
190 crate::vendor::VendorId::Openai => Vendor::Openai,
191 crate::vendor::VendorId::Zai => Vendor::Zai,
192 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
193 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
194 }
195}
196
197impl Cli {
198 pub fn output_json(&self) -> bool {
201 if self.json {
202 return true;
203 }
204 if self.pretty || self.watch.is_some() {
205 return false;
206 }
207 !is_stdout_tty()
209 }
210}
211
212fn is_stdout_tty() -> bool {
213 use std::io::IsTerminal;
214 std::io::stdout().is_terminal()
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use clap::Parser;
221
222 #[test]
223 fn defaults_match_claudebar() {
224 let cli = Cli::parse_from(["ai-usagebar"]);
225 assert_eq!(cli.vendor, None);
226 let cfg = crate::config::Config::default();
231 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
232 assert_eq!(cli.pace_tolerance, 5);
233 assert!(cli.format.is_none());
234 assert!(cli.tooltip_format.is_none());
235 assert!(cli.icon.is_none());
236 assert!(!cli.format_pace_color);
237 assert!(!cli.tooltip_pace_pts);
238 assert!(!cli.pretty);
239 assert!(!cli.json);
240 assert!(cli.watch.is_none());
241 }
242
243 #[test]
244 fn multi_account_flags_are_stable_api() {
245 let cli = Cli::parse_from([
249 "ai-usagebar",
250 "--vendor",
251 "anthropic",
252 "--cache-dir",
253 "/tmp/acct-a",
254 "--creds-path",
255 "/tmp/acct-a/credentials.json",
256 ]);
257 assert_eq!(
258 cli.cache_dir.as_deref(),
259 Some(std::path::Path::new("/tmp/acct-a"))
260 );
261 assert_eq!(
262 cli.creds_path.as_deref(),
263 Some(std::path::Path::new("/tmp/acct-a/credentials.json"))
264 );
265 }
266
267 #[test]
268 fn primary_from_config_wins_when_vendor_unset() {
269 let cli = Cli::parse_from(["ai-usagebar"]);
271 let mut cfg = crate::config::Config::default();
272 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
273 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
274 }
275
276 #[test]
277 fn explicit_vendor_overrides_everything() {
278 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
281 let mut cfg = crate::config::Config::default();
282 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
283 let active = Some(crate::vendor::VendorId::Openai);
284 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
285 }
286
287 #[test]
288 fn active_override_wins_over_config_primary_when_enabled() {
289 let cli = Cli::parse_from(["ai-usagebar"]);
292 let mut cfg = crate::config::Config::default();
293 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
294 let active = Some(crate::vendor::VendorId::Zai);
295 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
296 }
297
298 #[test]
299 fn disabled_active_override_falls_back_to_config_primary() {
300 let cli = Cli::parse_from(["ai-usagebar"]);
303 let mut cfg = crate::config::Config::default();
304 cfg.zai.enabled = false;
305 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
306 let active = Some(crate::vendor::VendorId::Zai);
307 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
308 }
309
310 #[test]
311 fn claudebar_compatible_flag_surface() {
312 let cli = Cli::parse_from([
313 "ai-usagebar",
314 "--icon",
315 "",
316 "--format",
317 "{session_pct}% · {session_reset}",
318 "--tooltip-format",
319 "S:{session_pct}",
320 "--pace-tolerance",
321 "10",
322 "--format-pace-color",
323 "--tooltip-pace-pts",
324 "--color-low",
325 "#50fa7b",
326 "--color-mid",
327 "#f1fa8c",
328 "--color-high",
329 "#ffb86c",
330 "--color-critical",
331 "#ff5555",
332 ]);
333 assert_eq!(cli.icon.as_deref(), Some(""));
334 assert_eq!(
335 cli.format.as_deref(),
336 Some("{session_pct}% · {session_reset}")
337 );
338 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
339 assert_eq!(cli.pace_tolerance, 10);
340 assert!(cli.format_pace_color);
341 assert!(cli.tooltip_pace_pts);
342 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
343 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
344 }
345
346 #[test]
347 fn pretty_and_json_conflict() {
348 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
349 assert!(res.is_err());
350 }
351
352 #[test]
353 fn watch_disables_json_output() {
354 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
355 assert_eq!(cli.watch, Some(5));
356 assert!(!cli.output_json());
357 }
358}