Skip to main content

cmd_usage/
cli.rs

1use lexopt::prelude::*;
2
3/// Which binary is running. The two share one parser/config/notify front-end;
4/// only a handful of flags are binary-gated (see [`Args`]).
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
6pub enum Binary {
7    #[default]
8    Cmduse,
9    Ocuse,
10}
11
12impl Binary {
13    /// The name used in `-V/--version` and the usage header.
14    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    /// The binary this argv was parsed for (gates `--local`/`--gated` vs the
25    /// ocuse-only flags).
26    pub binary: Binary,
27    pub interval: Option<u64>,
28    pub once: bool,
29    /// Explicit `-W`/`watch` seen (as opposed to the implicit default mode).
30    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    /// ISO-8601 start of the window, for `model`/`session`.
46    pub since: Option<String>,
47    // --- ocuse-only (the cmduse binary rejects these) ---
48    /// opencode store path (`--db`).
49    pub db: Option<String>,
50    /// Go plan to measure against (`--plan`).
51    pub plan: Option<String>,
52    /// Report window (`--window`): month, all, or `<n>d`.
53    pub window: Option<String>,
54    /// Pinned period start (`--period-start`), `YYYY-MM-DD`.
55    pub period_start: Option<String>,
56    /// Infer the period start from the first Go request (`--infer-anniversary`).
57    pub infer_anniversary: bool,
58    /// Table rows to print (`--rows`).
59    pub rows: Option<usize>,
60    /// Skip OpenCode Go's usage endpoint (`--no-api`): stay local-only.
61    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
100/// Parse a full argv (binary name first, as `std::env::args_os` yields) for the
101/// named binary. Split out so tests can drive it without touching the real
102/// process args.
103pub(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; // explicit default mode
122                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            // Shared: `--gated` filters `models`, and both binaries have a model
133            // catalogue (cmduse's live API list, ocuse's Go/plan allowance table).
134            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            // --- cmduse-only flags ---
200            Long("local") => {
201                if binary != Binary::Cmduse {
202                    return Err(unknown_long("local"));
203                }
204                a.local = true;
205            }
206            // --- ocuse-only flags ---
207            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
302/// `config set [key=value …]` — the rest of argv after the `config` positional.
303fn 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
373/// Parse a refresh duration: bare seconds ("30"), or with a unit suffix
374/// ("30s", "5m", "1h", "2d"). Returns whole seconds.
375pub 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
390/// Parse a UTC offset "+05:30" / "-08:00" / "+0530" / "-8" → seconds east.
391/// Splitting lives in `cmduse_core::dates`; this adds the CLI's range check.
392pub 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
404/// The `cmduse` help text — byte-identical to what it has always printed.
405const 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
452/// The `ocuse` help text: the shared block plus its own extras and the
453/// ocuse-only flags. Kept in sync with `CMDUSE_HELP` by the parity test in
454/// `cli_tests` (every shared token cmduse prints must print here too).
455const 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}