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    Arkuse,
11}
12
13impl Binary {
14    /// The name used in `-V/--version` and the usage header.
15    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    /// The binary this argv was parsed for (gates `--local`/`--gated` vs the
27    /// ocuse-only flags).
28    pub binary: Binary,
29    pub interval: Option<u64>,
30    pub once: bool,
31    /// Explicit `-W`/`watch` seen (as opposed to the implicit default mode).
32    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    /// ISO-8601 start of the window, for `model`/`session`.
48    pub since: Option<String>,
49    // --- ocuse-only (the cmduse binary rejects these) ---
50    /// opencode store path (`--db`).
51    pub db: Option<String>,
52    /// Go plan / ARK tier to measure against (`--plan`).
53    pub plan: Option<String>,
54    /// ARK control-plane profile (`--profile`), resolved from arkcli otherwise.
55    pub profile: Option<String>,
56    /// Report window (`--window`): month, all, or `<n>d`.
57    pub window: Option<String>,
58    /// Pinned period start (`--period-start`), `YYYY-MM-DD`.
59    pub period_start: Option<String>,
60    /// Infer the period start from the first Go request (`--infer-anniversary`).
61    pub infer_anniversary: bool,
62    /// Table rows to print (`--rows`).
63    pub rows: Option<usize>,
64    /// Optional model id after `model` (a plain positional, like ocuse's and
65    /// arkuse's): shows that one model instead of the table.
66    pub model_id: Option<String>,
67    /// Skip OpenCode Go's usage endpoint (`--no-api`): stay local-only.
68    pub no_api: bool,
69}
70
71impl Args {
72    /// `--rows` resolved against a caller's default: `Some(0)` means every row,
73    /// absent means the caller's own default. One rule for all three binaries —
74    /// the report tables print all, the live frame keeps its own default.
75    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
120/// Parse a full argv (binary name first, as `std::env::args_os` yields) for the
121/// named binary. Split out so tests can drive it without touching the real
122/// process args.
123pub(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; // explicit default mode
142                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            // Shared: `--gated` filters `models`, and both binaries have a model
153            // catalogue (cmduse's live API list, ocuse's Go/plan allowance table).
154            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            // --- cmduse-only flags ---
220            Long("local") => {
221                if binary != Binary::Cmduse {
222                    return Err(unknown_long("local"));
223                }
224                a.local = true;
225            }
226            // --- ocuse/arkuse-only flags ---
227            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                    // `model <id>` — a plain positional, as in ocuse/arkuse.
301                    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
332/// `config set [key=value …]` — the rest of argv after the `config` positional.
333fn 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
403/// Parse a refresh duration: bare seconds ("30"), or with a unit suffix
404/// ("30s", "5m", "1h", "2d"). Returns whole seconds.
405pub 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
420/// Parse a UTC offset "+05:30" / "-08:00" / "+0530" / "-8" → seconds east.
421/// Splitting lives in `cmduse_core::dates`; this adds the CLI's range check.
422pub 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
434/// The `cmduse` help text — byte-identical to what it has always printed.
435const 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      --days <n>        daily: number of days back (default 7, max 365)
466      --hours <n>       hourly: number of hours back (default 24, max 168)
467      --since <ISO>     model/session: count from this instant instead of --days
468      --rows <n>        table rows to print (0 = all)
469      --local           daily: use local CLI logs only (skip account API)
470  -V, --version         Print version
471  -h, --help            This help
472  Respects NO_COLOR; colors auto-off when stdout is not a terminal.
473
474Config: ~/.config/cmd-usage/config.json
475  { \"interval_secs\": 5, \"bar_width\": 20, \"burst_enabled\": true,
476    \"burst_samples\": 40, \"notify_on_cap\": true, \"statusline_template\": \"…\",
477    \"statusline_colors\": true, \"statusline_ascii\": false,
478    \"check_updates\": true, \"dismissed_update\": null }
479
480daily/model/session read ~/.commandcode/projects offline (no API calls).
481Dashboard needs: logged-in Command Code CLI (~/.commandcode/auth.json)";
482
483/// The `ocuse` help text: the shared block plus its own extras and the
484/// ocuse-only flags. Kept in sync with `CMDUSE_HELP` by the parity test in
485/// `cli_tests` (every shared token cmduse prints must print here too).
486const OCUSE_HELP: &str = "ocuse — OpenCode Go/Zen usage (opencode.db + Go's usage endpoint)
487
488Usage: ocuse [options]            Live plan dashboard (watch mode)
489       ocuse -1                   One-shot dashboard
490       ocuse daily [--days N] [--json]     Spend by day
491       ocuse hourly [--hours N] [--json]   Spend by hour
492       ocuse model [<id>] [--since ISO] [--json]  Local usage by model
493       ocuse session [--json]              Local usage by session
494       ocuse models               Model list
495       ocuse plans                The Go/Zen catalogue: both plans' limits and rates
496       ocuse statusline           Compact one-liner for prompts/tmux
497       ocuse mcp                  MCP stdio server (usage/plans/models/daily/hourly/session/model tools)
498       ocuse config set interval=<s> width=<n> burst_on=true burst=40
499
500Options:
501  -1, --once            Fetch once, print, exit (no watch)
502  -W, --watch           Force watch mode (default; conflicts with -1)
503  -p, --plain           No colors / no live redraw (for scripts, pipes)
504  -i, --interval <dur>  Refresh interval (default: config or 5s). Accepts a
505                        unit suffix: 30s, 5m, 1h (bare number = seconds)
506  -w, --bar-width <n>   Progress bar width in chars (default: config or 20)
507  -b, --bursts <n>      Spend-burst sparkline samples (default: 40; hide when
508                        idle via config burst_on=false)
509      --json            Machine-readable JSON output where supported
510      --csv             CSV output for daily/hourly/model/session
511      --dismiss-update  Ack the current update notice (hides it until a newer
512                        version; same as config set dismissed_update=<ver>)
513      --tz <±HH:MM>     daily/hourly: bucket by this UTC offset instead of UTC
514                        (applies to local logs too)
515      --days <n>        daily: number of days back (default 120, max 365)
516      --hours <n>       hourly: number of hours back (default 24, max 168)
517      --since <ISO>     model/session: count from this instant instead of --days
518      --gated           models: filter to what the selected plan grants
519      --db <path>       opencode store (default $OPENCODE_DB or
520                        ~/.local/share/opencode/opencode.db)
521      --plan <id>       Go plan to measure against: go (default) or go-plus
522      --no-api          Skip OpenCode Go's usage endpoint and stay local-only
523      --window <w>      report window: month (default), all, or <n>d
524      --period-start <date>  pin the period start (YYYY-MM-DD, UTC)
525      --infer-anniversary   infer the period start from the first Go request
526      --rows <n>        table rows to print (0 = all)
527  -V, --version         Print version
528  -h, --help            This help
529  Respects NO_COLOR; colors auto-off when stdout is not a terminal.
530
531Config: ~/.config/ocuse/config.json
532  { \"interval_secs\": 5, \"bar_width\": 20, \"burst_enabled\": true,
533    \"burst_samples\": 40, \"notify_on_cap\": true, \"statusline_template\": \"…\",
534    \"statusline_colors\": true, \"statusline_ascii\": false,
535    \"check_updates\": true, \"dismissed_update\": null }
536
537Reads opencode's own store, and OpenCode Go's usage endpoint when a Go key is
538present (the account's own meters — what opencode.ai/console shows). Without a
539key, no network, or with --no-api, the windows fall back to a local estimate.";
540
541/// The `arkuse` help text: the shared block plus ARK's own flags. Same parity
542/// rule as ocuse's (the shared tokens are asserted by `cli_tests`).
543const ARKUSE_HELP: &str = "arkuse — Volcengine ARK Agent Plan usage (opencode.db + arkcli control plane)
544
545Usage: arkuse [options]           Live plan dashboard (watch mode)
546       arkuse -1                  One-shot dashboard
547       arkuse daily [--days N] [--json]     AFP by day
548       arkuse hourly [--hours N] [--json]   AFP by hour
549       arkuse model [<id>] [--since ISO] [--json]  Local AFP by model
550       arkuse session [--json]              Local AFP by session
551       arkuse models              Model coefficients and tier gates
552       arkuse plans               The Agent Plan catalogue: tiers, AFP, prices
553       arkuse statusline          Compact one-liner for prompts/tmux
554       arkuse mcp                 MCP stdio server (usage/plans/models/daily/hourly/session/model tools)
555       arkuse config set interval=<s> width=<n> burst_on=true burst=40
556
557Options:
558  -1, --once            Fetch once, print, exit (no watch)
559  -W, --watch           Force watch mode (default; conflicts with -1)
560  -p, --plain           No colors / no live redraw (for scripts, pipes)
561  -i, --interval <dur>  Refresh interval (default: config or 5s). Accepts a
562                        unit suffix: 30s, 5m, 1h (bare number = seconds)
563  -w, --bar-width <n>   Progress bar width in chars (default: config or 20)
564  -b, --bursts <n>      AFP-burst sparkline samples (default: 40; hide when
565                        idle via config burst_on=false)
566      --json            Machine-readable JSON output where supported
567      --csv             CSV output for daily/hourly/model/session
568      --dismiss-update  Ack the current update notice (hides it until a newer
569                        version; same as config set dismissed_update=<ver>)
570      --tz <±HH:MM>     daily/hourly: bucket by this UTC offset instead of UTC
571                        (applies to local logs too)
572      --days <n>        daily: number of days back (default 120, max 365)
573      --hours <n>       hourly: number of hours back (default 24, max 168)
574      --since <ISO>     model/session: count from this instant instead of --days
575      --gated           models: filter to what the selected tier grants
576      --db <path>       opencode store (default $OPENCODE_DB or
577                        ~/.local/share/opencode/opencode.db)
578      --plan <id>       ARK tier: small, medium (default), large, max
579      --profile <name>  arkcli control-plane profile (default: the first
580                        agent-plan profile, or $ARKUSE_PROFILE)
581      --no-api          Skip arkcli and stay on the local AFP estimate
582      --window <w>      report window: month (default), all, or <n>d
583      --period-start <date>  pin the period start (YYYY-MM-DD)
584      --infer-anniversary   infer the period start from the first plan request
585      --rows <n>        table rows to print (0 = all)
586  -V, --version         Print version
587  -h, --help            This help
588  Respects NO_COLOR; colors auto-off when stdout is not a terminal.
589
590Config: ~/.config/arkuse/config.json
591  { \"interval_secs\": 5, \"bar_width\": 20, \"burst_enabled\": true,
592    \"burst_samples\": 40, \"notify_on_cap\": true, \"statusline_template\": \"…\",
593    \"statusline_colors\": true, \"statusline_ascii\": false,
594    \"check_updates\": true, \"dismissed_update\": null }
595
596AFP is the plan's own currency (1 AFP = ¥0.002). Window rows prefer the control
597plane's meters (`arkcli usage plan`); the local figure is computed from the
598documented coefficients and reconciled against the meter every poll.";
599
600pub fn usage_text(binary: Binary) -> &'static str {
601    match binary {
602        Binary::Cmduse => CMDUSE_HELP,
603        Binary::Ocuse => OCUSE_HELP,
604        Binary::Arkuse => ARKUSE_HELP,
605    }
606}