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