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, hide = true)]
104 pub cache_dir: Option<std::path::PathBuf>,
105
106 #[arg(long, hide = true)]
108 pub creds_path: Option<std::path::PathBuf>,
109}
110
111#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
112pub enum Vendor {
113 Anthropic,
114 Openai,
115 Zai,
116 Openrouter,
117 Deepseek,
118}
119
120impl Vendor {
121 pub fn to_id(self) -> crate::vendor::VendorId {
122 match self {
123 Vendor::Anthropic => crate::vendor::VendorId::Anthropic,
124 Vendor::Openai => crate::vendor::VendorId::Openai,
125 Vendor::Zai => crate::vendor::VendorId::Zai,
126 Vendor::Openrouter => crate::vendor::VendorId::Openrouter,
127 Vendor::Deepseek => crate::vendor::VendorId::Deepseek,
128 }
129 }
130}
131
132impl Cli {
133 pub fn resolved_vendor(&self, config: &crate::config::Config) -> Vendor {
144 let active = if self.vendor.is_some() {
150 None
151 } else {
152 crate::active::read()
153 };
154 self.resolve_vendor_with(config, active)
155 }
156
157 pub fn resolve_vendor_with(
162 &self,
163 config: &crate::config::Config,
164 active: Option<crate::vendor::VendorId>,
165 ) -> Vendor {
166 if let Some(v) = self.vendor {
167 return v;
168 }
169 if let Some(id) = active
170 && config.is_enabled(id)
171 {
172 return id_to_vendor(id);
173 }
174 match config.ui.primary {
175 Some(id) => id_to_vendor(id),
176 None => Vendor::Anthropic,
177 }
178 }
179}
180
181fn id_to_vendor(id: crate::vendor::VendorId) -> Vendor {
182 match id {
183 crate::vendor::VendorId::Anthropic => Vendor::Anthropic,
184 crate::vendor::VendorId::Openai => Vendor::Openai,
185 crate::vendor::VendorId::Zai => Vendor::Zai,
186 crate::vendor::VendorId::Openrouter => Vendor::Openrouter,
187 crate::vendor::VendorId::Deepseek => Vendor::Deepseek,
188 }
189}
190
191impl Cli {
192 pub fn output_json(&self) -> bool {
195 if self.json {
196 return true;
197 }
198 if self.pretty || self.watch.is_some() {
199 return false;
200 }
201 !is_stdout_tty()
203 }
204}
205
206fn is_stdout_tty() -> bool {
207 use std::io::IsTerminal;
208 std::io::stdout().is_terminal()
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use clap::Parser;
215
216 #[test]
217 fn defaults_match_claudebar() {
218 let cli = Cli::parse_from(["ai-usagebar"]);
219 assert_eq!(cli.vendor, None);
220 let cfg = crate::config::Config::default();
225 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Anthropic);
226 assert_eq!(cli.pace_tolerance, 5);
227 assert!(cli.format.is_none());
228 assert!(cli.tooltip_format.is_none());
229 assert!(cli.icon.is_none());
230 assert!(!cli.format_pace_color);
231 assert!(!cli.tooltip_pace_pts);
232 assert!(!cli.pretty);
233 assert!(!cli.json);
234 assert!(cli.watch.is_none());
235 }
236
237 #[test]
238 fn primary_from_config_wins_when_vendor_unset() {
239 let cli = Cli::parse_from(["ai-usagebar"]);
241 let mut cfg = crate::config::Config::default();
242 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
243 assert_eq!(cli.resolve_vendor_with(&cfg, None), Vendor::Openrouter);
244 }
245
246 #[test]
247 fn explicit_vendor_overrides_everything() {
248 let cli = Cli::parse_from(["ai-usagebar", "--vendor", "zai"]);
251 let mut cfg = crate::config::Config::default();
252 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
253 let active = Some(crate::vendor::VendorId::Openai);
254 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
255 }
256
257 #[test]
258 fn active_override_wins_over_config_primary_when_enabled() {
259 let cli = Cli::parse_from(["ai-usagebar"]);
262 let mut cfg = crate::config::Config::default();
263 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
264 let active = Some(crate::vendor::VendorId::Zai);
265 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Zai);
266 }
267
268 #[test]
269 fn disabled_active_override_falls_back_to_config_primary() {
270 let cli = Cli::parse_from(["ai-usagebar"]);
273 let mut cfg = crate::config::Config::default();
274 cfg.zai.enabled = false;
275 cfg.ui.primary = Some(crate::vendor::VendorId::Openrouter);
276 let active = Some(crate::vendor::VendorId::Zai);
277 assert_eq!(cli.resolve_vendor_with(&cfg, active), Vendor::Openrouter);
278 }
279
280 #[test]
281 fn claudebar_compatible_flag_surface() {
282 let cli = Cli::parse_from([
283 "ai-usagebar",
284 "--icon",
285 "",
286 "--format",
287 "{session_pct}% · {session_reset}",
288 "--tooltip-format",
289 "S:{session_pct}",
290 "--pace-tolerance",
291 "10",
292 "--format-pace-color",
293 "--tooltip-pace-pts",
294 "--color-low",
295 "#50fa7b",
296 "--color-mid",
297 "#f1fa8c",
298 "--color-high",
299 "#ffb86c",
300 "--color-critical",
301 "#ff5555",
302 ]);
303 assert_eq!(cli.icon.as_deref(), Some(""));
304 assert_eq!(
305 cli.format.as_deref(),
306 Some("{session_pct}% · {session_reset}")
307 );
308 assert_eq!(cli.tooltip_format.as_deref(), Some("S:{session_pct}"));
309 assert_eq!(cli.pace_tolerance, 10);
310 assert!(cli.format_pace_color);
311 assert!(cli.tooltip_pace_pts);
312 assert_eq!(cli.color_low.as_deref(), Some("#50fa7b"));
313 assert_eq!(cli.color_critical.as_deref(), Some("#ff5555"));
314 }
315
316 #[test]
317 fn pretty_and_json_conflict() {
318 let res = Cli::try_parse_from(["ai-usagebar", "--pretty", "--json"]);
319 assert!(res.is_err());
320 }
321
322 #[test]
323 fn watch_disables_json_output() {
324 let cli = Cli::parse_from(["ai-usagebar", "--watch", "5"]);
325 assert_eq!(cli.watch, Some(5));
326 assert!(!cli.output_json());
327 }
328}