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