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