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