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