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