Skip to main content

cmd_usage/ocuse/
cli.rs

1// ocuse — OpenCode Go/Zen usage, same CLI shape as cmduse.
2//
3// Data: opencode's own store (read-only) plus the generated docs catalogue.
4// There is no OpenCode usage API to ask (see core/zen.json's header and the
5// README), so this reports what actually ran on this machine, priced by
6// opencode itself, against the limits the docs publish.
7//
8// The front-end is the shared one: `cmduse/src/cli.rs` parses argv for both
9// binaries, `config.rs` owns both config files, `notify.rs` the desktop
10// notification, `update_check.rs` the crates.io notice. ocuse only supplies its
11// own source (the store) and its own renderers.
12//
13// Lives in the library (not the bin) so `ocuse` and its `ocusedev` twin share
14// one entry point: both bins are `fn main() { cmd_usage::ocuse::run(); }`.
15use crate::cli::{self, Binary, SubCmd};
16use crate::ocuse::window;
17use crate::ocuse::{build, db, mcp, render, zen};
18use crate::report_render::Fmt;
19use std::collections::HashSet;
20use std::ffi::OsString;
21use std::io::Write;
22
23/// `YYYY-MM-DD` -> epoch ms at 00:00 UTC, via the same civil helpers the window
24/// module uses (no date dependency).
25fn parse_day(value: &str) -> Option<i64> {
26    let mut parts = value.split('-');
27    let y: i64 = parts.next()?.parse().ok()?;
28    let m: i64 = parts.next()?.parse().ok()?;
29    let d: i64 = parts.next()?.parse().ok()?;
30    (1..=12).contains(&m).then_some(())?;
31    (1..=31).contains(&d).then_some(())?;
32    Some(window::days_from_civil(y, m, d) * 86_400_000)
33}
34
35/// Flags whose value is the next argv token (mirrors the shared parser). Used
36/// only to locate ocuse's `model <id>` positional before it parses.
37const VALUE_FLAGS: &[&str] = &[
38    "-i",
39    "--interval",
40    "-w",
41    "--bar-width",
42    "-b",
43    "--bursts",
44    "--days",
45    "--last",
46    "-l",
47    "--since",
48    "--hours",
49    "--tz",
50    "--db",
51    "--plan",
52    "--window",
53    "--period-start",
54    "--rows",
55];
56
57/// ocuse's `model` command takes an optional model id, which is a plain
58/// positional; the shared parser (written for cmduse, whose `model` takes none)
59/// rejects a stray positional. Pull the id out first so `ocuse model <id>`
60/// keeps working — the rest of argv parses exactly as the shared parser does.
61fn split_model_id(argv: Vec<OsString>) -> (Vec<OsString>, Option<String>) {
62    let strs: Vec<String> = argv
63        .iter()
64        .map(|a| a.to_string_lossy().into_owned())
65        .collect();
66    let mut drop_at = None;
67    let mut skip_value = false;
68    let mut after_model = false;
69    for (i, tok) in strs.iter().enumerate() {
70        if skip_value {
71            skip_value = false;
72            continue;
73        }
74        if tok.starts_with('-') {
75            // `--flag=value` carries its own value; `-w40` is attached, not a
76            // separate token. Only the bare value flags consume the next token.
77            if VALUE_FLAGS.contains(&tok.as_str()) {
78                skip_value = true;
79            }
80            continue;
81        }
82        if after_model {
83            drop_at = Some(i);
84            break;
85        }
86        after_model = tok == "model";
87    }
88    let id = drop_at.map(|i| strs[i].clone());
89    let argv = argv
90        .into_iter()
91        .enumerate()
92        .filter(|(i, _)| Some(*i) != drop_at)
93        .map(|(_, a)| a)
94        .collect();
95    (argv, id)
96}
97
98/// The live watch loop is the default command, but `-1`/`--once` and `--json`
99/// both want a single frame printed and an exit. A bare `--json` used to fall
100/// into the loop and print no JSON at all, so it is one-shot too.
101fn wants_watch(args: &cli::Args) -> bool {
102    !args.once && !args.json
103}
104
105/// cmduse's small-terminal rule: one clipped line instead of the full frame.
106fn compact_frame(rows: usize, cols: usize) -> bool {
107    cols > 0 && (rows < 16 || cols < 40)
108}
109
110/// Spend-burst sparkline derivation, identical to cmduse's `lib.rs`: a CLI
111/// `-b/--bursts` turns it on for the run; otherwise it follows the config flag.
112/// The cap is the CLI value, else the config sample count, clamped 5–240.
113fn burst_config(bursts: Option<usize>, enabled: bool, samples: usize) -> (bool, usize) {
114    let on = bursts.is_some() || enabled;
115    let cap = if on {
116        bursts.or(Some(samples)).unwrap_or(40).clamp(5, 240)
117    } else {
118        0
119    };
120    (on, cap)
121}
122
123/// Report output format, mirroring cmduse's (`--json` / `--csv` / table).
124fn output_fmt(args: &cli::Args) -> Fmt {
125    if args.json {
126        Fmt::Json
127    } else if args.csv {
128        Fmt::Csv
129    } else {
130        Fmt::Table
131    }
132}
133
134/// The period's end (epoch ms), resolved from the report's own resolved start so
135/// an inferred anniversary gets one too. `--window all` has no boundary (`None`);
136/// an `<n>d` window ends n days after it started; every other mode — the calendar
137/// month, `--period-start`, an inferred anniversary — runs to the same
138/// day-of-month next month. The rolling 5h/weekly windows never carry an end:
139/// they have no real reset (see `render_text`).
140fn period_end(report: &window::Report, window_mode: &str, inferred: bool) -> Option<i64> {
141    // Inference pins the start itself, so it outranks the window mode.
142    if inferred {
143        return Some(window::next_month(report.since_ms));
144    }
145    if window_mode == "all" {
146        None
147    } else if let Some(d) = window_mode
148        .strip_suffix('d')
149        .and_then(|v| v.parse::<i64>().ok())
150    {
151        Some(report.since_ms + d * 86_400_000)
152    } else {
153        Some(window::next_month(report.since_ms))
154    }
155}
156
157pub fn run() {
158    let (argv, model_id) = split_model_id(std::env::args_os().collect());
159    let args = match cli::parse_args_from(argv, Binary::Ocuse) {
160        Ok(a) => a,
161        Err(e) => {
162            eprintln!("{e}");
163            std::process::exit(2);
164        }
165    };
166    if args.help {
167        cli::usage(Binary::Ocuse);
168        return;
169    }
170    if let Some(cs) = args.config_set {
171        if let Err(e) = crate::config::set(Binary::Ocuse, &cs) {
172            eprintln!("error: {e}");
173            std::process::exit(1);
174        }
175        return;
176    }
177    if args.dismiss_update {
178        match crate::update_check::latest_newer() {
179            Ok(Some(v)) => match crate::config::set_dismissed(Binary::Ocuse, &v) {
180                Ok(()) => println!("dismissed update notice for {v}"),
181                Err(e) => {
182                    eprintln!("error: {e}");
183                    std::process::exit(1);
184                }
185            },
186            Ok(None) => println!("ocuse is up to date ({})", env!("CARGO_PKG_VERSION")),
187            Err(e) => {
188                eprintln!("error: {e}");
189                std::process::exit(1);
190            }
191        }
192        return;
193    }
194
195    let cfg = crate::config::load(Binary::Ocuse);
196    let colours = !args.plain && crate::render::color_enabled();
197    let fmt = output_fmt(&args);
198    let db_path = args.db.clone().unwrap_or_else(db::default_path);
199    let days: i64 = args.last.unwrap_or(120) as i64;
200    let hours: i64 = args.hours.unwrap_or(24) as i64;
201    let rows: usize = args.rows.unwrap_or(15);
202    let now = window::now_ms();
203    let tz_secs = args.tz.unwrap_or(0);
204
205    // `plans` needs neither the store nor a plan id: the catalogue is static.
206    if let Some(SubCmd::Plans) = args.subcmd {
207        if args.json {
208            print!("{}", render::plans_json());
209        } else {
210            print!("{}", render::plans_text(colours));
211        }
212        return;
213    }
214
215    // Which Go plan the allowances are measured against: explicit, because the
216    // store records no subscription and there is no usage API to ask.
217    let catalog = zen::catalog();
218    let plan_id = args
219        .plan
220        .clone()
221        .unwrap_or_else(|| zen::DEFAULT_PLAN.to_string());
222    if catalog.plan(&plan_id).is_none() {
223        eprintln!(
224            "ocuse: --plan expects one of {} (got {plan_id})",
225            catalog.plan_ids().join(", ")
226        );
227        std::process::exit(2);
228    }
229
230    // `models` is the catalogue joined with one plan's allowances: no store read.
231    if let Some(SubCmd::Models) = args.subcmd {
232        if args.json {
233            print!("{}", render::models_json(&plan_id, args.gated));
234        } else {
235            print!("{}", render::models_text(&plan_id, args.gated, colours));
236        }
237        return;
238    }
239
240    // `--since` (an ISO-8601 instant) pins the report window for model/session,
241    // as in cmduse; it outranks --period-start and --window. `parse_iso_utc`
242    // yields epoch milliseconds (the same units as the store's timestamps).
243    let since_override = args
244        .since
245        .as_deref()
246        .and_then(cmduse_core::dates::parse_iso_utc)
247        .map(|ms| ms as i64);
248
249    // The period: explicit start > window mode > calendar month. Inference from
250    // the first Go request is opt-in because it guesses badly on sparse history
251    // (an account whose usage starts mid-cycle infers the wrong anniversary).
252    let window_mode = args.window.clone().unwrap_or_else(|| "month".into());
253    let period_start = if let Some(ms) = since_override {
254        Some(ms)
255    } else if let Some(day) = args.period_start.as_deref().and_then(parse_day) {
256        Some(day)
257    } else if window_mode == "all" {
258        Some(0)
259    } else if window_mode == "month" {
260        Some(window::calendar_month_start(now))
261    } else if let Some(d) = window_mode
262        .strip_suffix('d')
263        .and_then(|v| v.parse::<i64>().ok())
264    {
265        Some(now - d * 86_400_000)
266    } else {
267        eprintln!("ocuse: --window expects month, all, or <n>d (got {window_mode})");
268        std::process::exit(2);
269    };
270    let period_label = if let Some(iso) = &args.since {
271        format!("since {iso}")
272    } else if args.period_start.is_some() {
273        "period start (pinned)".to_string()
274    } else {
275        match window_mode.as_str() {
276            "all" => "all time".to_string(),
277            "month" => "calendar month".to_string(),
278            other => other.to_string(),
279        }
280    };
281    let period_start =
282        if args.infer_anniversary && args.period_start.is_none() && args.since.is_none() {
283            None
284        } else {
285            period_start
286        };
287    // Only the inference branch leaves `period_start` unset; the report then
288    // resolves the anniversary itself, so remember which mode produced it.
289    let period_inferred = period_start.is_none();
290
291    let read_since = |since_ms: i64| match db::read_rows(&db_path, since_ms) {
292        Ok(rows) => rows,
293        Err(error) => {
294            eprintln!("ocuse: {error}");
295            eprintln!("hint: --db <path>, or OPENCODE_DB; the store is created by opencode itself");
296            std::process::exit(1);
297        }
298    };
299    // The report's own read window: an explicit --since pins it, else --days.
300    let report_window = since_override.unwrap_or(now - days * 86_400_000);
301    let build_at = |t: i64| {
302        let mut report = build(
303            &read_since(report_window),
304            &catalog,
305            &plan_id,
306            t,
307            period_start,
308        );
309        report.period_label = period_label.clone();
310        report.period_end_ms = period_end(&report, &window_mode, period_inferred);
311        report
312    };
313
314    match args.subcmd {
315        Some(SubCmd::Statusline) => {
316            let report = build_at(now);
317            if args.json {
318                println!("{}", render::statusline_json(&report, &plan_id));
319            } else {
320                print!(
321                    "{}",
322                    render::statusline_template(
323                        &report,
324                        &plan_id,
325                        &cfg.statusline_template,
326                        cfg.statusline_colors,
327                        cfg.statusline_ascii,
328                        cfg.bar_width.min(30),
329                    )
330                );
331            }
332            return;
333        }
334        Some(SubCmd::Daily) => {
335            print!(
336                "{}",
337                render::bucket_text(
338                    &read_since(now - days * 86_400_000),
339                    now,
340                    days * 86_400_000,
341                    86_400_000,
342                    tz_secs,
343                    colours,
344                    fmt,
345                )
346            );
347            return;
348        }
349        Some(SubCmd::Hours) => {
350            print!(
351                "{}",
352                render::bucket_text(
353                    &read_since(now - hours * 3_600_000),
354                    now,
355                    hours * 3_600_000,
356                    3_600_000,
357                    tz_secs,
358                    colours,
359                    fmt,
360                )
361            );
362            return;
363        }
364        Some(SubCmd::Session) => {
365            print!(
366                "{}",
367                render::session_text(&read_since(report_window), 20, colours, fmt)
368            );
369            return;
370        }
371        Some(SubCmd::Mcp) => {
372            // stdio JSON-RPC; stdout carries protocol only, so nothing else may print.
373            mcp::run(&db_path, days, tz_secs, false, &plan_id, period_start);
374            return;
375        }
376        Some(SubCmd::Model) => {
377            let report = build_at(now);
378            if fmt == Fmt::Table {
379                match &model_id {
380                    Some(id) => print!("{}", render::model_text(&report, id, colours)),
381                    None => print!(
382                        "{}",
383                        render::render_text(
384                            &report,
385                            &db_path,
386                            usize::MAX,
387                            colours,
388                            args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200),
389                        )
390                    ),
391                }
392            } else {
393                print!(
394                    "{}",
395                    render::model_report(&report, model_id.as_deref(), fmt)
396                );
397            }
398            return;
399        }
400        // served above: plans and models need no store read
401        Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
402        None => {}
403    }
404
405    // Dashboard (the default command): one-shot or live watch.
406    let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
407    let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
408    let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
409    let notify_on_cap = cfg.notify_on_cap;
410
411    // Update check before any output. Watch mode draws the notice inside the
412    // redrawn frame (an eprintln sits outside the in-place UI and scrolls away);
413    // one-shot has no frame, so it goes to stderr.
414    let update =
415        crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
416
417    if !wants_watch(&args) {
418        if let Some(msg) = &update {
419            eprintln!("{msg}");
420        }
421        let report = build_at(now);
422        if args.json {
423            println!("{}", render::render_json(&report));
424        } else {
425            print!(
426                "{}",
427                render::render_text(&report, &db_path, rows, colours, bar_width)
428            );
429        }
430        return;
431    }
432
433    // Live frame, cmduse's redraw contract: the frame's last line carries no
434    // newline, so the cursor parks there and the countdown can rewrite that one
435    // line each second instead of repainting the whole frame.
436    let ink = render::Ink::new(colours);
437    let mut out = std::io::stdout();
438    let mut prev_lines = 0usize;
439    let mut history: Vec<f64> = Vec::new();
440    let mut last_five: Option<f64> = None;
441    // Per-model cap-hit notifications: fire once on the rising edge into "over".
442    let mut over: HashSet<String> = HashSet::new();
443    let use_color = crate::render::color_enabled();
444    loop {
445        let report = build_at(window::now_ms());
446        let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
447        let cols_opt = (cols > 0).then_some(cols);
448        let compact = compact_frame(term_rows, cols);
449        // 5-hour spend only rises between refreshes, so the delta is the burst;
450        // a sparkline of the cumulative figure would just stay full.
451        let delta = last_five
452            .map(|prev| (report.five_hour.cost - prev).max(0.0))
453            .unwrap_or(0.0);
454        last_five = Some(report.five_hour.cost);
455        if burst_on {
456            history.push(delta);
457            if history.len() > burst_cap {
458                history.remove(0);
459            }
460        }
461        if notify_on_cap {
462            let mut over_now: HashSet<String> = HashSet::new();
463            for model in &report.models {
464                if let Some(window) = window::over_window(model) {
465                    over_now.insert(model.id.clone());
466                    if !over.contains(&model.id) {
467                        crate::notify::cap(
468                            "ocuse: limit exceeded",
469                            &format!("OpenCode Go {} is over its {window} limit", model.id),
470                        );
471                    }
472                }
473            }
474            over = over_now;
475        }
476        let burst_slice: &[f64] = if burst_on { &history } else { &[] };
477        let text = if compact {
478            render::compact_line(&report, burst_slice, colours)
479        } else {
480            render::watch_frame(
481                &report,
482                &db_path,
483                rows,
484                colours,
485                burst_slice,
486                interval,
487                bar_width,
488            )
489        };
490        let update_box = update
491            .as_ref()
492            .map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
493        let status = format!(
494            "{}refreshing every {interval}s · ctrl-c to quit{}",
495            ink.dim, ink.reset
496        );
497        let frame = match &update_box {
498            Some(u) => format!("{u}{text}\n{status}"),
499            None => format!("{text}\n{status}"),
500        };
501        prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
502        let _ = out.flush();
503        for remaining in (1..interval).rev() {
504            std::thread::sleep(std::time::Duration::from_secs(1));
505            let msg = format!(
506                "\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
507                ink.dim, ink.reset
508            );
509            let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
510            let _ = out.flush();
511        }
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    fn argv(args: &[&str]) -> Vec<OsString> {
520        std::iter::once(OsString::from("ocuse"))
521            .chain(args.iter().map(OsString::from))
522            .collect()
523    }
524
525    fn parse(args: &[&str]) -> cli::Args {
526        cli::parse_args_from(argv(args), Binary::Ocuse).expect("parses")
527    }
528
529    #[test]
530    fn json_and_one_shot_leave_the_watch_loop() {
531        assert!(wants_watch(&parse(&[]))); // default: live frame
532        assert!(wants_watch(&parse(&["-W"])));
533        assert!(!wants_watch(&parse(&["-1"]))); // -1
534        assert!(!wants_watch(&parse(&["--once"])));
535        assert!(!wants_watch(&parse(&["--json"]))); // bare --json too
536    }
537
538    #[test]
539    fn compact_frame_switches_on_small_terminals() {
540        assert!(!compact_frame(40, 120)); // roomy
541        assert!(compact_frame(10, 120)); // too few rows
542        assert!(compact_frame(40, 30)); // too few columns
543        assert!(!compact_frame(0, 0)); // unknown size: never compact
544        assert!(compact_frame(0, 20)); // cols known, tiny
545    }
546
547    #[test]
548    fn burst_state_derives_from_args_and_config() {
549        // config off, no CLI flag → off
550        assert_eq!(burst_config(None, false, 40), (false, 0));
551        // CLI flag turns it on for the run, using its own sample count
552        assert_eq!(burst_config(Some(30), false, 40), (true, 30));
553        // config on → on with the configured samples
554        assert_eq!(burst_config(None, true, 25), (true, 25));
555        // CLI value wins over config's
556        assert_eq!(burst_config(Some(60), true, 25), (true, 60));
557        // a hand-edited config below the floor is clamped, like cmduse's
558        assert_eq!(burst_config(None, true, 3), (true, 5));
559    }
560
561    #[test]
562    fn gated_is_a_shared_flag() {
563        // `--gated` filters `models` on both binaries, so it goes through the
564        // shared parser instead of being lifted out of argv first.
565        let a = cli::parse_args_from(argv(&["models", "--gated", "--plan", "go"]), Binary::Ocuse)
566            .expect("parses");
567        assert!(a.gated);
568        assert_eq!(a.subcmd, Some(SubCmd::Models));
569        assert_eq!(a.plan.as_deref(), Some("go"));
570        assert!(
571            !cli::parse_args_from(argv(&["models"]), Binary::Ocuse)
572                .expect("parses")
573                .gated
574        );
575    }
576
577    #[test]
578    fn output_fmt_maps_the_flags() {
579        let of = |args: &[&str]| output_fmt(&parse(args));
580        assert!(matches!(of(&[]), Fmt::Table));
581        assert!(matches!(of(&["--json"]), Fmt::Json));
582        assert!(matches!(of(&["--csv"]), Fmt::Csv));
583    }
584
585    #[test]
586    fn period_end_resolves_from_the_reports_own_start() {
587        let since = window::days_from_civil(2026, 9, 15) * 86_400_000;
588        let report = window::Report {
589            period_label: "calendar month".into(),
590            now_ms: since,
591            since_ms: since,
592            period_end_ms: None,
593            month_inferred: false,
594            five_hour: Default::default(),
595            weekly: Default::default(),
596            month: Default::default(),
597            models: Vec::new(),
598        };
599        // Month / --period-start: same day-of-month next month.
600        assert_eq!(
601            period_end(&report, "month", false),
602            Some(window::next_month(since))
603        );
604        // An <n>d window ends n days after it started.
605        assert_eq!(
606            period_end(&report, "30d", false),
607            Some(since + 30 * 86_400_000)
608        );
609        // All time has no boundary.
610        assert_eq!(period_end(&report, "all", false), None);
611        // Inference pins the start itself, so it outranks the window mode.
612        assert_eq!(
613            period_end(&report, "30d", true),
614            Some(window::next_month(since))
615        );
616    }
617
618    #[test]
619    fn model_id_is_pulled_out_before_parsing() {
620        let (rest, id) = split_model_id(argv(&["model", "glm-5.3-flash"]));
621        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
622        // the id is gone; the rest still parses as the shared parser expects
623        assert_eq!(
624            cli::parse_args_from(rest, Binary::Ocuse).unwrap().subcmd,
625            Some(SubCmd::Model)
626        );
627
628        // no id → the parser sees a bare `model`
629        let (_, id) = split_model_id(argv(&["model"]));
630        assert!(id.is_none());
631
632        // a flag value that happens to be a word is not mistaken for the id
633        let (rest, id) = split_model_id(argv(&["--plan", "go", "model", "glm-5.3-flash"]));
634        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
635        assert!(cli::parse_args_from(rest, Binary::Ocuse).is_ok());
636
637        // value flags between `model` and the id are skipped, not consumed
638        let (_, id) = split_model_id(argv(&["model", "--days", "7", "glm-5.3-flash"]));
639        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
640
641        // other commands pass through untouched
642        let (_, id) = split_model_id(argv(&["daily", "--days", "7"]));
643        assert!(id.is_none());
644    }
645
646    #[test]
647    fn ocuse_reaches_shared_flag_parity() {
648        // every shared flag cmduse's help prints is accepted by ocuse's parser
649        for args in [
650            vec!["-1"],
651            vec!["-W"],
652            vec!["-p"],
653            vec!["-i", "30"],
654            vec!["-w", "20"],
655            vec!["-b", "40"],
656            vec!["--json"],
657            vec!["--csv"],
658            vec!["--tz", "+05:30"],
659            vec!["--days", "7"],
660            vec!["--hours", "6"],
661            vec!["--since", "2026-01-01T00:00:00Z"],
662            vec!["--dismiss-update"],
663            vec!["--gated"],
664            vec!["config", "set", "interval=10"],
665        ] {
666            assert!(
667                cli::parse_args_from(argv(&args), Binary::Ocuse).is_ok(),
668                "ocuse must accept {args:?}"
669            );
670        }
671        // `--local` stays cmduse-only (ocuse is always local); `--gated` is a
672        // shared flag now, so it must parse for ocuse too.
673        assert!(cli::parse_args_from(argv(&["--local"]), Binary::Ocuse).is_err());
674        assert!(cli::parse_args_from(argv(&["--gated"]), Binary::Ocuse).is_ok());
675    }
676
677    #[test]
678    fn ocuse_parses_every_shared_subcommand() {
679        // The shared parser hands both binaries the same command surface,
680        // including `models` (implemented for ocuse now).
681        for (word, want) in [
682            ("daily", SubCmd::Daily),
683            ("days", SubCmd::Daily),
684            ("hourly", SubCmd::Hours),
685            ("hours", SubCmd::Hours),
686            ("model", SubCmd::Model),
687            ("session", SubCmd::Session),
688            ("sessions", SubCmd::Session),
689            ("project", SubCmd::Session),
690            ("statusline", SubCmd::Statusline),
691            ("models", SubCmd::Models),
692            ("plans", SubCmd::Plans),
693            ("mcp", SubCmd::Mcp),
694        ] {
695            for binary in [Binary::Cmduse, Binary::Ocuse] {
696                let a = cli::parse_args_from(
697                    std::iter::once(OsString::from("x"))
698                        .chain(std::iter::once(OsString::from(word))),
699                    binary,
700                )
701                .unwrap_or_else(|e| panic!("{binary:?} must parse {word}: {e}"));
702                assert_eq!(a.subcmd.as_ref(), Some(&want), "{binary:?} {word}");
703            }
704        }
705    }
706
707    #[test]
708    fn ocuse_help_carries_the_shared_tokens() {
709        let c = cli::usage_text(Binary::Cmduse);
710        let o = cli::usage_text(Binary::Ocuse);
711        // every shared flag token cmduse prints appears in ocuse's help too
712        for tok in [
713            "-1",
714            "--once",
715            "-W",
716            "--watch",
717            "-p",
718            "--plain",
719            "-h",
720            "--help",
721            "-V",
722            "--version",
723            "--json",
724            "--csv",
725            "-i",
726            "--interval",
727            "-w",
728            "--bar-width",
729            "-b",
730            "--bursts",
731            "--days",
732            "--since",
733            "--hours",
734            "--tz",
735            "--dismiss-update",
736            "NO_COLOR",
737            "config set",
738        ] {
739            if c.contains(tok) {
740                assert!(o.contains(tok), "ocuse help missing shared token {tok}");
741            }
742        }
743    }
744}