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}
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
98/// Parse a full argv (binary name first, as `std::env::args_os` yields) for the
99/// named binary. Split out so tests can drive it without touching the real
100/// process args.
101pub(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; // explicit default mode
120                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            // Shared: `--gated` filters `models`, and both binaries have a model
131            // catalogue (cmduse's live API list, ocuse's Go/plan allowance table).
132            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            // --- cmduse-only flags ---
198            Long("local") => {
199                if binary != Binary::Cmduse {
200                    return Err(unknown_long("local"));
201                }
202                a.local = true;
203            }
204            // --- ocuse-only flags ---
205            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
294/// `config set [key=value …]` — the rest of argv after the `config` positional.
295fn 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
365/// Parse a refresh duration: bare seconds ("30"), or with a unit suffix
366/// ("30s", "5m", "1h", "2d"). Returns whole seconds.
367pub 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
382/// Parse a UTC offset "+05:30" / "-08:00" / "+0530" / "-8" → seconds east.
383/// Splitting lives in `cmduse_core::dates`; this adds the CLI's range check.
384pub 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
396/// The `cmduse` help text — byte-identical to what it has always printed.
397const 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
444/// The `ocuse` help text: the shared block plus its own extras and the
445/// ocuse-only flags. Kept in sync with `CMDUSE_HELP` by the parity test in
446/// `cli_tests` (every shared token cmduse prints must print here too).
447const 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}