1use lexopt::prelude::*;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
6pub enum Binary {
7 #[default]
8 Cmduse,
9 Ocuse,
10}
11
12impl Binary {
13 pub fn name(self) -> &'static str {
15 match self {
16 Binary::Cmduse => "cmduse",
17 Binary::Ocuse => "ocuse",
18 }
19 }
20}
21
22#[derive(Debug, Default)]
23pub struct Args {
24 pub binary: Binary,
27 pub interval: Option<u64>,
28 pub once: bool,
29 pub watch: bool,
31 pub plain: bool,
32 pub bar_width: Option<usize>,
33 pub bursts: Option<usize>,
34 pub help: bool,
35 pub config_set: Option<ConfigSet>,
36 pub subcmd: Option<SubCmd>,
37 pub last: Option<usize>,
38 pub hours: Option<usize>,
39 pub json: bool,
40 pub csv: bool,
41 pub local: bool,
42 pub gated: bool,
43 pub tz: Option<i64>,
44 pub dismiss_update: bool,
45 pub since: Option<String>,
47 pub db: Option<String>,
50 pub plan: Option<String>,
52 pub window: Option<String>,
54 pub period_start: Option<String>,
56 pub infer_anniversary: bool,
58 pub rows: Option<usize>,
60 pub no_api: bool,
62}
63
64#[derive(Debug, Default)]
65pub struct ConfigSet {
66 pub interval: Option<u64>,
67 pub width: Option<usize>,
68 pub sl_template: Option<String>,
69 pub sl_colors: Option<bool>,
70 pub sl_ascii: Option<bool>,
71 pub bursts: Option<usize>,
72 pub burst_on: Option<bool>,
73 pub notify: Option<bool>,
74 pub update_check: Option<bool>,
75 pub dismissed_update: Option<String>,
76}
77
78#[derive(Debug, PartialEq)]
79pub enum SubCmd {
80 Daily,
81 Hours,
82 Model,
83 Session,
84 Statusline,
85 Models,
86 Plans,
87 Mcp,
88}
89
90pub fn parse_args(binary: Binary) -> Args {
91 match parse_args_from(std::env::args_os(), binary) {
92 Ok(a) => a,
93 Err(e) => {
94 eprintln!("{e}");
95 std::process::exit(2);
96 }
97 }
98}
99
100pub(crate) fn parse_args_from(
104 args: impl IntoIterator<Item = std::ffi::OsString>,
105 binary: Binary,
106) -> Result<Args, String> {
107 let mut a = Args {
108 binary,
109 ..Args::default()
110 };
111 let mut parser = lexopt::Parser::from_iter(args);
112 let (mut saw_once, mut saw_watch) = (false, false);
113 while let Some(arg) = parser.next().map_err(|e| e.to_string())? {
114 match arg {
115 Short('1') | Long("once") => {
116 a.once = true;
117 saw_once = true;
118 }
119 Short('p') | Long("plain") => a.plain = true,
120 Short('W') | Long("watch") => {
121 a.once = false; a.watch = true;
123 saw_watch = true;
124 }
125 Short('h') | Long("help") => a.help = true,
126 Short('V') | Long("version") => {
127 println!("{} {}", binary.name(), env!("CARGO_PKG_VERSION"));
128 std::process::exit(0);
129 }
130 Long("json") => a.json = true,
131 Long("csv") => a.csv = true,
132 Long("gated") => a.gated = true,
135 Long("dismiss-update") => a.dismiss_update = true,
136 Long("tz") => {
137 let v = need_value(&mut parser, "--tz needs an offset like +05:30 or -08:00")?;
138 match parse_tz(&v) {
139 Some(secs) => a.tz = Some(secs),
140 None => {
141 return Err(format!(
142 "--tz needs an offset like +05:30 or -08:00 (got '{v}')"
143 ))
144 }
145 }
146 }
147 Short('i') | Long("interval") => {
148 let v = need_value(
149 &mut parser,
150 "-i needs a duration 1s–24h (e.g. 30, 30s, 5m, 1h)",
151 )?;
152 match parse_duration(&v) {
153 Some(n) if (1..=86_400).contains(&n) => a.interval = Some(n),
154 _ => {
155 return Err(format!(
156 "-i needs a duration 1s–24h (e.g. 30, 30s, 5m, 1h) (got '{v}')"
157 ))
158 }
159 }
160 }
161 Short('w') | Long("bar-width") => {
162 let v = need_value(&mut parser, "-w needs a number 5–200")?;
163 match v.parse::<usize>() {
164 Ok(n) if (5..=200).contains(&n) => a.bar_width = Some(n),
165 _ => return Err(format!("-w needs a number 5–200 (got '{v}')")),
166 }
167 }
168 Short('b') | Long("bursts") => {
169 let v = need_value(&mut parser, "-b needs a number 5–240")?;
170 match v.parse::<usize>() {
171 Ok(n) if (5..=240).contains(&n) => a.bursts = Some(n),
172 _ => return Err(format!("-b needs a number 5–240 (got '{v}')")),
173 }
174 }
175 Long("days") | Long("last") | Short('l') => {
176 let v = need_value(&mut parser, "--days needs a number (e.g. --days 7)")?;
177 match v.parse::<usize>() {
178 Ok(n) => a.last = Some(n.clamp(1, 365)),
179 Err(_) => return Err("--days needs a number (e.g. --days 7)".into()),
180 }
181 }
182 Long("since") => {
183 let v = need_value(&mut parser, "--since needs an ISO-8601 time")?;
184 if cmduse_core::dates::parse_iso_utc(&v).is_none() {
185 return Err(format!("--since: not an ISO-8601 datetime: '{v}'"));
186 }
187 a.since = Some(v);
188 }
189 Long("hours") => {
190 let v = need_value(
191 &mut parser,
192 "--hours needs a number (e.g. --hours 6, max 168)",
193 )?;
194 match v.parse::<usize>() {
195 Ok(n) => a.hours = Some(n.clamp(1, 168)),
196 Err(_) => return Err("--hours needs a number (e.g. --hours 6, max 168)".into()),
197 }
198 }
199 Long("local") => {
201 if binary != Binary::Cmduse {
202 return Err(unknown_long("local"));
203 }
204 a.local = true;
205 }
206 Long("db") => {
208 if binary != Binary::Ocuse {
209 return Err(unknown_long("db"));
210 }
211 a.db = Some(need_value(&mut parser, "--db needs a path")?);
212 }
213 Long("plan") => {
214 if binary != Binary::Ocuse {
215 return Err(unknown_long("plan"));
216 }
217 a.plan = Some(need_value(&mut parser, "--plan needs an id")?);
218 }
219 Long("no-api") => {
220 if binary != Binary::Ocuse {
221 return Err(unknown_long("no-api"));
222 }
223 a.no_api = true;
224 }
225 Long("window") => {
226 if binary != Binary::Ocuse {
227 return Err(unknown_long("window"));
228 }
229 a.window = Some(need_value(
230 &mut parser,
231 "--window needs month, all, or <n>d",
232 )?);
233 }
234 Long("period-start") => {
235 if binary != Binary::Ocuse {
236 return Err(unknown_long("period-start"));
237 }
238 a.period_start = Some(need_value(&mut parser, "--period-start needs YYYY-MM-DD")?);
239 }
240 Long("infer-anniversary") => {
241 if binary != Binary::Ocuse {
242 return Err(unknown_long("infer-anniversary"));
243 }
244 a.infer_anniversary = true;
245 }
246 Long("rows") => {
247 if binary != Binary::Ocuse {
248 return Err(unknown_long("rows"));
249 }
250 let v = need_value(&mut parser, "--rows needs a number")?;
251 match v.parse::<usize>() {
252 Ok(n) => a.rows = Some(n),
253 Err(_) => return Err(format!("--rows needs a number (got '{v}')")),
254 }
255 }
256 Value(v) => match v.to_string_lossy().as_ref() {
257 "watch" => {
258 a.once = false;
259 a.watch = true;
260 saw_watch = true;
261 }
262 "help" => a.help = true,
263 "version" => {
264 println!("{} {}", binary.name(), env!("CARGO_PKG_VERSION"));
265 std::process::exit(0);
266 }
267 "daily" | "days" => a.subcmd = Some(SubCmd::Daily),
268 "hourly" | "hours" => a.subcmd = Some(SubCmd::Hours),
269 "model" => a.subcmd = Some(SubCmd::Model),
270 "session" | "sessions" | "project" => a.subcmd = Some(SubCmd::Session),
271 "models" => a.subcmd = Some(SubCmd::Models),
272 "plans" => a.subcmd = Some(SubCmd::Plans),
273 "mcp" => a.subcmd = Some(SubCmd::Mcp),
274 "statusline" => a.subcmd = Some(SubCmd::Statusline),
275 "config" => a.config_set = Some(parse_config_set(&mut parser)?),
276 other => return Err(format!("unknown arg: {other} (try --help)")),
277 },
278 Short(c) => return Err(format!("unknown arg: -{c} (try --help)")),
279 Long(l) => return Err(unknown_long(l)),
280 }
281 }
282 if saw_once && saw_watch {
283 return Err("cannot combine -1/--once with -W/--watch".into());
284 }
285 if a.json && a.csv {
286 return Err("cannot combine --json with --csv".into());
287 }
288 Ok(a)
289}
290
291fn unknown_long(flag: &str) -> String {
292 format!("unknown arg: --{flag} (try --help)")
293}
294
295fn need_value(parser: &mut lexopt::Parser, msg: &str) -> Result<String, String> {
296 parser
297 .value()
298 .map(|v| v.to_string_lossy().into_owned())
299 .map_err(|_| msg.to_string())
300}
301
302fn parse_config_set(parser: &mut lexopt::Parser) -> Result<ConfigSet, String> {
304 match parser.next().map_err(|e| e.to_string())? {
305 Some(Value(v)) if v.to_string_lossy() == "set" => {}
306 Some(Value(v)) => {
307 return Err(format!(
308 "config: unknown subcommand '{}' (try: config set interval=10)",
309 v.to_string_lossy()
310 ))
311 }
312 _ => return Err("config: missing subcommand (try: config set interval=10)".into()),
313 }
314 let mut cs = ConfigSet::default();
315 while let Some(arg) = parser.next().map_err(|e| e.to_string())? {
316 let Value(kv) = arg else {
317 return Err("config: expected key=value".into());
318 };
319 let kv = kv.to_string_lossy();
320 let Some((k, v)) = kv.split_once('=') else {
321 return Err(format!(
322 "config: expected key=value, got '{kv}' (keys: interval, width)"
323 ));
324 };
325 match k {
326 "interval" => {
327 cs.interval = Some(
328 v.parse()
329 .map_err(|_| format!("config: interval must be a number, got '{v}'"))?,
330 )
331 }
332 "width" => {
333 cs.width = Some(
334 v.parse()
335 .map_err(|_| format!("config: width must be a number, got '{v}'"))?,
336 )
337 }
338 "sl" | "statusline" => cs.sl_template = Some(v.to_string()),
339 "sl_colors" => cs.sl_colors = Some(parse_bool(v, "sl_colors")?),
340 "sl_ascii" => cs.sl_ascii = Some(parse_bool(v, "sl_ascii")?),
341 "burst" | "bursts" => {
342 cs.bursts = Some(
343 v.parse()
344 .map_err(|_| format!("config: bursts must be a number, got '{v}'"))?,
345 )
346 }
347 "burst_on" | "burst-on" => cs.burst_on = Some(parse_bool(v, "burst_on")?),
348 "notify" | "notify_on_cap" => cs.notify = Some(parse_bool(v, "notify")?),
349 "update" | "update_check" | "check_updates" => {
350 cs.update_check = Some(parse_bool(v, "update_check")?)
351 }
352 "dismiss" | "dismissed_update" => {
353 if v.is_empty() || v.contains(char::is_whitespace) {
354 return Err("dismissed_update must be a version like 0.6.8".into());
355 }
356 cs.dismissed_update = Some(v.to_string())
357 }
358 other => {
359 return Err(format!(
360 "config: unknown key '{other}' (keys: interval, width, sl, sl_colors, sl_ascii, burst, burst_on, notify, update_check, dismissed_update)"
361 ))
362 }
363 }
364 }
365 Ok(cs)
366}
367
368fn parse_bool(v: &str, key: &str) -> Result<bool, String> {
369 v.parse()
370 .map_err(|_| format!("config: {key} must be true/false, got '{v}'"))
371}
372
373pub fn parse_duration(s: &str) -> Option<u64> {
376 let s = s.trim();
377 if s.is_empty() {
378 return None;
379 }
380 let (num, mult) = match s.chars().last()? {
381 's' | 'S' => (&s[..s.len() - 1], 1u64),
382 'm' | 'M' => (&s[..s.len() - 1], 60),
383 'h' | 'H' => (&s[..s.len() - 1], 3600),
384 'd' | 'D' => (&s[..s.len() - 1], 86400),
385 _ => (s, 1),
386 };
387 num.parse::<u64>().ok().and_then(|n| n.checked_mul(mult))
388}
389
390pub fn parse_tz(s: &str) -> Option<i64> {
393 let (sign, h, m) = cmduse_core::dates::parse_tz_parts(s)?;
394 if h > 14 || m > 59 {
395 return None;
396 }
397 Some(sign * (h * 3600 + m * 60))
398}
399
400pub fn usage(binary: Binary) {
401 println!("{}", usage_text(binary));
402}
403
404const CMDUSE_HELP: &str = "cmduse — Command Code usage dashboard
406
407Usage: cmduse [options] Live plan dashboard (watch mode)
408 cmduse -1 One-shot dashboard
409 cmduse daily [--days N] [--json] Account usage by day (all harnesses)
410 cmduse hourly [--hours N] [--json] Account usage by hour (all harnesses)
411 cmduse model [--days N | --since ISO] [--json] Local usage by model
412 cmduse session [--days N | --since ISO] [--json] Local usage by project/session
413 cmduse models Live model list from the Command Code API
414 cmduse plans Plan comparison table
415 cmduse statusline Compact one-liner for prompts/tmux
416 cmduse mcp MCP stdio server (usage/plans/models/daily/hourly/session/model tools)
417 cmduse config set interval=<s> width=<n> burst_on=true burst=40
418
419Options:
420 -1, --once Fetch once, print, exit (no watch)
421 -W, --watch Force watch mode (default; conflicts with -1)
422 -p, --plain No colors / no live redraw (for scripts, pipes)
423 -i, --interval <dur> Refresh interval (default: config or 5s). Accepts a
424 unit suffix: 30s, 5m, 1h (bare number = seconds)
425 -w, --bar-width <n> Progress bar width in chars (default: config or 20)
426 -b, --bursts <n> Spend-burst sparkline samples (default: 40; hide when
427 idle via config burst_on=false)
428 --json Machine-readable JSON output where supported
429 --csv CSV output for daily/hourly/model/session
430 --gated models: filter to what the current plan allows
431 --dismiss-update Ack the current update notice (hides it until a newer
432 version; same as config set dismissed_update=<ver>)
433 --tz <±HH:MM> daily/hourly: bucket by this UTC offset instead of UTC
434 (applies to local logs too)
435 --days <n> daily: number of days back (default 7, max 365)
436 --hours <n> hourly: number of hours back (default 24, max 168)
437 --since <ISO> model/session: count from this instant instead of --days
438 --local daily: use local CLI logs only (skip account API)
439 -V, --version Print version
440 -h, --help This help
441 Respects NO_COLOR; colors auto-off when stdout is not a terminal.
442
443Config: ~/.config/cmd-usage/config.json
444 { \"interval_secs\": 5, \"bar_width\": 20, \"burst_enabled\": true,
445 \"burst_samples\": 40, \"notify_on_cap\": true, \"statusline_template\": \"…\",
446 \"statusline_colors\": true, \"statusline_ascii\": false,
447 \"check_updates\": true, \"dismissed_update\": null }
448
449daily/model/session read ~/.commandcode/projects offline (no API calls).
450Dashboard needs: logged-in Command Code CLI (~/.commandcode/auth.json)";
451
452const OCUSE_HELP: &str = "ocuse — OpenCode Go/Zen usage (opencode.db + Go's usage endpoint)
456
457Usage: ocuse [options] Live plan dashboard (watch mode)
458 ocuse -1 One-shot dashboard
459 ocuse daily [--days N] [--json] Spend by day
460 ocuse hourly [--hours N] [--json] Spend by hour
461 ocuse model [--since ISO] [--json] Local usage by model
462 ocuse session [--json] Local usage by session
463 ocuse models Model list
464 ocuse plans The Go/Zen catalogue: both plans' limits and rates
465 ocuse statusline Compact one-liner for prompts/tmux
466 ocuse mcp MCP stdio server (usage/plans/models/daily/hourly/session/model tools)
467 ocuse config set interval=<s> width=<n> burst_on=true burst=40
468
469Options:
470 -1, --once Fetch once, print, exit (no watch)
471 -W, --watch Force watch mode (default; conflicts with -1)
472 -p, --plain No colors / no live redraw (for scripts, pipes)
473 -i, --interval <dur> Refresh interval (default: config or 10s). Accepts a
474 unit suffix: 30s, 5m, 1h (bare number = seconds)
475 -w, --bar-width <n> Progress bar width in chars (default: config or 20)
476 -b, --bursts <n> Spend-burst sparkline samples (default: 40; hide when
477 idle via config burst_on=false)
478 --json Machine-readable JSON output where supported
479 --csv CSV output for daily/hourly/model/session
480 --dismiss-update Ack the current update notice (hides it until a newer
481 version; same as config set dismissed_update=<ver>)
482 --tz <±HH:MM> daily/hourly: bucket by this UTC offset instead of UTC
483 (applies to local logs too)
484 --days <n> daily: number of days back (default 120, max 365)
485 --hours <n> hourly: number of hours back (default 24, max 168)
486 --since <ISO> model/session: count from this instant instead of --days
487 --gated models: filter to what the selected plan grants
488 --db <path> opencode store (default $OPENCODE_DB or
489 ~/.local/share/opencode/opencode.db)
490 --plan <id> Go plan to measure against: go (default) or go-plus
491 --no-api Skip OpenCode Go's usage endpoint and stay local-only
492 --window <w> report window: month (default), all, or <n>d
493 --period-start <date> pin the period start (YYYY-MM-DD, UTC)
494 --infer-anniversary infer the period start from the first Go request
495 --rows <n> table rows to print (default 15)
496 -V, --version Print version
497 -h, --help This help
498 Respects NO_COLOR; colors auto-off when stdout is not a terminal.
499
500Config: ~/.config/ocuse/config.json
501 { \"interval_secs\": 10, \"bar_width\": 20, \"burst_enabled\": true,
502 \"burst_samples\": 40, \"notify_on_cap\": true, \"statusline_template\": \"…\",
503 \"statusline_colors\": true, \"statusline_ascii\": false,
504 \"check_updates\": true, \"dismissed_update\": null }
505
506Reads opencode's own store, and OpenCode Go's usage endpoint when a Go key is
507present (the account's own meters — what opencode.ai/console shows). Without a
508key, no network, or with --no-api, the windows fall back to a local estimate.";
509
510pub fn usage_text(binary: Binary) -> &'static str {
511 match binary {
512 Binary::Cmduse => CMDUSE_HELP,
513 Binary::Ocuse => OCUSE_HELP,
514 }
515}