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
134pub fn run() {
135    let (argv, model_id) = split_model_id(std::env::args_os().collect());
136    let args = match cli::parse_args_from(argv, Binary::Ocuse) {
137        Ok(a) => a,
138        Err(e) => {
139            eprintln!("{e}");
140            std::process::exit(2);
141        }
142    };
143    if args.help {
144        cli::usage(Binary::Ocuse);
145        return;
146    }
147    if let Some(cs) = args.config_set {
148        if let Err(e) = crate::config::set(Binary::Ocuse, &cs) {
149            eprintln!("error: {e}");
150            std::process::exit(1);
151        }
152        return;
153    }
154    if args.dismiss_update {
155        match crate::update_check::latest_newer() {
156            Ok(Some(v)) => match crate::config::set_dismissed(Binary::Ocuse, &v) {
157                Ok(()) => println!("dismissed update notice for {v}"),
158                Err(e) => {
159                    eprintln!("error: {e}");
160                    std::process::exit(1);
161                }
162            },
163            Ok(None) => println!("ocuse is up to date ({})", env!("CARGO_PKG_VERSION")),
164            Err(e) => {
165                eprintln!("error: {e}");
166                std::process::exit(1);
167            }
168        }
169        return;
170    }
171
172    let cfg = crate::config::load(Binary::Ocuse);
173    let colours = !args.plain && crate::render::color_enabled();
174    let fmt = output_fmt(&args);
175    let db_path = args.db.clone().unwrap_or_else(db::default_path);
176    let days: i64 = args.last.unwrap_or(120) as i64;
177    let hours: i64 = args.hours.unwrap_or(24) as i64;
178    let rows: usize = args.rows.unwrap_or(15);
179    let now = window::now_ms();
180    let tz_secs = args.tz.unwrap_or(0);
181
182    // `plans` needs neither the store nor a plan id: the catalogue is static.
183    if let Some(SubCmd::Plans) = args.subcmd {
184        if args.json {
185            print!("{}", render::plans_json());
186        } else {
187            print!("{}", render::plans_text(colours));
188        }
189        return;
190    }
191
192    // Which Go plan the allowances are measured against: explicit, because the
193    // store records no subscription and there is no usage API to ask.
194    let catalog = zen::catalog();
195    let plan_id = args
196        .plan
197        .clone()
198        .unwrap_or_else(|| zen::DEFAULT_PLAN.to_string());
199    if catalog.plan(&plan_id).is_none() {
200        eprintln!(
201            "ocuse: --plan expects one of {} (got {plan_id})",
202            catalog.plan_ids().join(", ")
203        );
204        std::process::exit(2);
205    }
206
207    // `models` is the catalogue joined with one plan's allowances: no store read.
208    if let Some(SubCmd::Models) = args.subcmd {
209        if args.json {
210            print!("{}", render::models_json(&plan_id, args.gated));
211        } else {
212            print!("{}", render::models_text(&plan_id, args.gated, colours));
213        }
214        return;
215    }
216
217    // `--since` (an ISO-8601 instant) pins the report window for model/session,
218    // as in cmduse; it outranks --period-start and --window. `parse_iso_utc`
219    // yields epoch milliseconds (the same units as the store's timestamps).
220    let since_override = args
221        .since
222        .as_deref()
223        .and_then(cmduse_core::dates::parse_iso_utc)
224        .map(|ms| ms as i64);
225
226    // The period: explicit start > window mode > calendar month. Inference from
227    // the first Go request is opt-in because it guesses badly on sparse history
228    // (an account whose usage starts mid-cycle infers the wrong anniversary).
229    let window_mode = args.window.clone().unwrap_or_else(|| "month".into());
230    let period_start = if let Some(ms) = since_override {
231        Some(ms)
232    } else if let Some(day) = args.period_start.as_deref().and_then(parse_day) {
233        Some(day)
234    } else if window_mode == "all" {
235        Some(0)
236    } else if window_mode == "month" {
237        Some(window::calendar_month_start(now))
238    } else if let Some(d) = window_mode
239        .strip_suffix('d')
240        .and_then(|v| v.parse::<i64>().ok())
241    {
242        Some(now - d * 86_400_000)
243    } else {
244        eprintln!("ocuse: --window expects month, all, or <n>d (got {window_mode})");
245        std::process::exit(2);
246    };
247    let period_label = if let Some(iso) = &args.since {
248        format!("since {iso}")
249    } else if args.period_start.is_some() {
250        "period start (pinned)".to_string()
251    } else {
252        match window_mode.as_str() {
253            "all" => "all time".to_string(),
254            "month" => "calendar month".to_string(),
255            other => other.to_string(),
256        }
257    };
258    let period_start =
259        if args.infer_anniversary && args.period_start.is_none() && args.since.is_none() {
260            None
261        } else {
262            period_start
263        };
264
265    let read_since = |since_ms: i64| match db::read_rows(&db_path, since_ms) {
266        Ok(rows) => rows,
267        Err(error) => {
268            eprintln!("ocuse: {error}");
269            eprintln!("hint: --db <path>, or OPENCODE_DB; the store is created by opencode itself");
270            std::process::exit(1);
271        }
272    };
273    // The report's own read window: an explicit --since pins it, else --days.
274    let report_window = since_override.unwrap_or(now - days * 86_400_000);
275    let build_at = |t: i64| {
276        let mut report = build(
277            &read_since(report_window),
278            &catalog,
279            &plan_id,
280            t,
281            period_start,
282        );
283        report.period_label = period_label.clone();
284        report
285    };
286
287    match args.subcmd {
288        Some(SubCmd::Statusline) => {
289            let report = build_at(now);
290            if args.json {
291                println!("{}", render::statusline_json(&report, &plan_id));
292            } else {
293                print!(
294                    "{}",
295                    render::statusline_template(
296                        &report,
297                        &plan_id,
298                        &cfg.statusline_template,
299                        cfg.statusline_colors,
300                        cfg.statusline_ascii,
301                        cfg.bar_width.min(30),
302                    )
303                );
304            }
305            return;
306        }
307        Some(SubCmd::Daily) => {
308            print!(
309                "{}",
310                render::bucket_text(
311                    &read_since(now - days * 86_400_000),
312                    now,
313                    days * 86_400_000,
314                    86_400_000,
315                    tz_secs,
316                    colours,
317                    fmt,
318                )
319            );
320            return;
321        }
322        Some(SubCmd::Hours) => {
323            print!(
324                "{}",
325                render::bucket_text(
326                    &read_since(now - hours * 3_600_000),
327                    now,
328                    hours * 3_600_000,
329                    3_600_000,
330                    tz_secs,
331                    colours,
332                    fmt,
333                )
334            );
335            return;
336        }
337        Some(SubCmd::Session) => {
338            print!(
339                "{}",
340                render::session_text(&read_since(report_window), 20, colours, fmt)
341            );
342            return;
343        }
344        Some(SubCmd::Mcp) => {
345            // stdio JSON-RPC; stdout carries protocol only, so nothing else may print.
346            mcp::run(&db_path, days, tz_secs, false, &plan_id, period_start);
347            return;
348        }
349        Some(SubCmd::Model) => {
350            let report = build_at(now);
351            if fmt == Fmt::Table {
352                match &model_id {
353                    Some(id) => print!("{}", render::model_text(&report, id, colours)),
354                    None => print!(
355                        "{}",
356                        render::render_text(
357                            &report,
358                            &db_path,
359                            usize::MAX,
360                            colours,
361                            args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200),
362                        )
363                    ),
364                }
365            } else {
366                print!(
367                    "{}",
368                    render::model_report(&report, model_id.as_deref(), fmt)
369                );
370            }
371            return;
372        }
373        // served above: plans and models need no store read
374        Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
375        None => {}
376    }
377
378    // Dashboard (the default command): one-shot or live watch.
379    let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
380    let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
381    let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
382    let notify_on_cap = cfg.notify_on_cap;
383
384    // Update check before any output. Watch mode draws the notice inside the
385    // redrawn frame (an eprintln sits outside the in-place UI and scrolls away);
386    // one-shot has no frame, so it goes to stderr.
387    let update =
388        crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
389
390    if !wants_watch(&args) {
391        if let Some(msg) = &update {
392            eprintln!("{msg}");
393        }
394        let report = build_at(now);
395        if args.json {
396            println!("{}", render::render_json(&report));
397        } else {
398            print!(
399                "{}",
400                render::render_text(&report, &db_path, rows, colours, bar_width)
401            );
402        }
403        return;
404    }
405
406    // Live frame, cmduse's redraw contract: the frame's last line carries no
407    // newline, so the cursor parks there and the countdown can rewrite that one
408    // line each second instead of repainting the whole frame.
409    let ink = render::Ink::new(colours);
410    let mut out = std::io::stdout();
411    let mut prev_lines = 0usize;
412    let mut history: Vec<f64> = Vec::new();
413    let mut last_five: Option<f64> = None;
414    // Per-model cap-hit notifications: fire once on the rising edge into "over".
415    let mut over: HashSet<String> = HashSet::new();
416    let use_color = crate::render::color_enabled();
417    loop {
418        let report = build_at(window::now_ms());
419        let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
420        let cols_opt = (cols > 0).then_some(cols);
421        let compact = compact_frame(term_rows, cols);
422        // 5-hour spend only rises between refreshes, so the delta is the burst;
423        // a sparkline of the cumulative figure would just stay full.
424        let delta = last_five
425            .map(|prev| (report.five_hour.cost - prev).max(0.0))
426            .unwrap_or(0.0);
427        last_five = Some(report.five_hour.cost);
428        if burst_on {
429            history.push(delta);
430            if history.len() > burst_cap {
431                history.remove(0);
432            }
433        }
434        if notify_on_cap {
435            let mut over_now: HashSet<String> = HashSet::new();
436            for model in &report.models {
437                if let Some(window) = window::over_window(model) {
438                    over_now.insert(model.id.clone());
439                    if !over.contains(&model.id) {
440                        crate::notify::cap(
441                            "ocuse: limit exceeded",
442                            &format!("OpenCode Go {} is over its {window} limit", model.id),
443                        );
444                    }
445                }
446            }
447            over = over_now;
448        }
449        let burst_slice: &[f64] = if burst_on { &history } else { &[] };
450        let text = if compact {
451            render::compact_line(&report, burst_slice, colours)
452        } else {
453            render::watch_frame(
454                &report,
455                &db_path,
456                rows,
457                colours,
458                burst_slice,
459                interval,
460                bar_width,
461            )
462        };
463        let update_box = update
464            .as_ref()
465            .map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
466        let status = format!(
467            "{}refreshing every {interval}s · ctrl-c to quit{}",
468            ink.dim, ink.reset
469        );
470        let frame = match &update_box {
471            Some(u) => format!("{u}{text}\n{status}"),
472            None => format!("{text}\n{status}"),
473        };
474        prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
475        let _ = out.flush();
476        for remaining in (1..interval).rev() {
477            std::thread::sleep(std::time::Duration::from_secs(1));
478            let msg = format!(
479                "\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
480                ink.dim, ink.reset
481            );
482            let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
483            let _ = out.flush();
484        }
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    fn argv(args: &[&str]) -> Vec<OsString> {
493        std::iter::once(OsString::from("ocuse"))
494            .chain(args.iter().map(OsString::from))
495            .collect()
496    }
497
498    fn parse(args: &[&str]) -> cli::Args {
499        cli::parse_args_from(argv(args), Binary::Ocuse).expect("parses")
500    }
501
502    #[test]
503    fn json_and_one_shot_leave_the_watch_loop() {
504        assert!(wants_watch(&parse(&[]))); // default: live frame
505        assert!(wants_watch(&parse(&["-W"])));
506        assert!(!wants_watch(&parse(&["-1"]))); // -1
507        assert!(!wants_watch(&parse(&["--once"])));
508        assert!(!wants_watch(&parse(&["--json"]))); // bare --json too
509    }
510
511    #[test]
512    fn compact_frame_switches_on_small_terminals() {
513        assert!(!compact_frame(40, 120)); // roomy
514        assert!(compact_frame(10, 120)); // too few rows
515        assert!(compact_frame(40, 30)); // too few columns
516        assert!(!compact_frame(0, 0)); // unknown size: never compact
517        assert!(compact_frame(0, 20)); // cols known, tiny
518    }
519
520    #[test]
521    fn burst_state_derives_from_args_and_config() {
522        // config off, no CLI flag → off
523        assert_eq!(burst_config(None, false, 40), (false, 0));
524        // CLI flag turns it on for the run, using its own sample count
525        assert_eq!(burst_config(Some(30), false, 40), (true, 30));
526        // config on → on with the configured samples
527        assert_eq!(burst_config(None, true, 25), (true, 25));
528        // CLI value wins over config's
529        assert_eq!(burst_config(Some(60), true, 25), (true, 60));
530        // a hand-edited config below the floor is clamped, like cmduse's
531        assert_eq!(burst_config(None, true, 3), (true, 5));
532    }
533
534    #[test]
535    fn gated_is_a_shared_flag() {
536        // `--gated` filters `models` on both binaries, so it goes through the
537        // shared parser instead of being lifted out of argv first.
538        let a = cli::parse_args_from(argv(&["models", "--gated", "--plan", "go"]), Binary::Ocuse)
539            .expect("parses");
540        assert!(a.gated);
541        assert_eq!(a.subcmd, Some(SubCmd::Models));
542        assert_eq!(a.plan.as_deref(), Some("go"));
543        assert!(
544            !cli::parse_args_from(argv(&["models"]), Binary::Ocuse)
545                .expect("parses")
546                .gated
547        );
548    }
549
550    #[test]
551    fn output_fmt_maps_the_flags() {
552        let of = |args: &[&str]| output_fmt(&parse(args));
553        assert!(matches!(of(&[]), Fmt::Table));
554        assert!(matches!(of(&["--json"]), Fmt::Json));
555        assert!(matches!(of(&["--csv"]), Fmt::Csv));
556    }
557
558    #[test]
559    fn model_id_is_pulled_out_before_parsing() {
560        let (rest, id) = split_model_id(argv(&["model", "glm-5.3-flash"]));
561        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
562        // the id is gone; the rest still parses as the shared parser expects
563        assert_eq!(
564            cli::parse_args_from(rest, Binary::Ocuse).unwrap().subcmd,
565            Some(SubCmd::Model)
566        );
567
568        // no id → the parser sees a bare `model`
569        let (_, id) = split_model_id(argv(&["model"]));
570        assert!(id.is_none());
571
572        // a flag value that happens to be a word is not mistaken for the id
573        let (rest, id) = split_model_id(argv(&["--plan", "go", "model", "glm-5.3-flash"]));
574        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
575        assert!(cli::parse_args_from(rest, Binary::Ocuse).is_ok());
576
577        // value flags between `model` and the id are skipped, not consumed
578        let (_, id) = split_model_id(argv(&["model", "--days", "7", "glm-5.3-flash"]));
579        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
580
581        // other commands pass through untouched
582        let (_, id) = split_model_id(argv(&["daily", "--days", "7"]));
583        assert!(id.is_none());
584    }
585
586    #[test]
587    fn ocuse_reaches_shared_flag_parity() {
588        // every shared flag cmduse's help prints is accepted by ocuse's parser
589        for args in [
590            vec!["-1"],
591            vec!["-W"],
592            vec!["-p"],
593            vec!["-i", "30"],
594            vec!["-w", "20"],
595            vec!["-b", "40"],
596            vec!["--json"],
597            vec!["--csv"],
598            vec!["--tz", "+05:30"],
599            vec!["--days", "7"],
600            vec!["--hours", "6"],
601            vec!["--since", "2026-01-01T00:00:00Z"],
602            vec!["--dismiss-update"],
603            vec!["--gated"],
604            vec!["config", "set", "interval=10"],
605        ] {
606            assert!(
607                cli::parse_args_from(argv(&args), Binary::Ocuse).is_ok(),
608                "ocuse must accept {args:?}"
609            );
610        }
611        // `--local` stays cmduse-only (ocuse is always local); `--gated` is a
612        // shared flag now, so it must parse for ocuse too.
613        assert!(cli::parse_args_from(argv(&["--local"]), Binary::Ocuse).is_err());
614        assert!(cli::parse_args_from(argv(&["--gated"]), Binary::Ocuse).is_ok());
615    }
616
617    #[test]
618    fn ocuse_parses_every_shared_subcommand() {
619        // The shared parser hands both binaries the same command surface,
620        // including `models` (implemented for ocuse now).
621        for (word, want) in [
622            ("daily", SubCmd::Daily),
623            ("days", SubCmd::Daily),
624            ("hourly", SubCmd::Hours),
625            ("hours", SubCmd::Hours),
626            ("model", SubCmd::Model),
627            ("session", SubCmd::Session),
628            ("sessions", SubCmd::Session),
629            ("project", SubCmd::Session),
630            ("statusline", SubCmd::Statusline),
631            ("models", SubCmd::Models),
632            ("plans", SubCmd::Plans),
633            ("mcp", SubCmd::Mcp),
634        ] {
635            for binary in [Binary::Cmduse, Binary::Ocuse] {
636                let a = cli::parse_args_from(
637                    std::iter::once(OsString::from("x"))
638                        .chain(std::iter::once(OsString::from(word))),
639                    binary,
640                )
641                .unwrap_or_else(|e| panic!("{binary:?} must parse {word}: {e}"));
642                assert_eq!(a.subcmd.as_ref(), Some(&want), "{binary:?} {word}");
643            }
644        }
645    }
646
647    #[test]
648    fn ocuse_help_carries_the_shared_tokens() {
649        let c = cli::usage_text(Binary::Cmduse);
650        let o = cli::usage_text(Binary::Ocuse);
651        // every shared flag token cmduse prints appears in ocuse's help too
652        for tok in [
653            "-1",
654            "--once",
655            "-W",
656            "--watch",
657            "-p",
658            "--plain",
659            "-h",
660            "--help",
661            "-V",
662            "--version",
663            "--json",
664            "--csv",
665            "-i",
666            "--interval",
667            "-w",
668            "--bar-width",
669            "-b",
670            "--bursts",
671            "--days",
672            "--since",
673            "--hours",
674            "--tz",
675            "--dismiss-update",
676            "NO_COLOR",
677            "config set",
678        ] {
679            if c.contains(tok) {
680                assert!(o.contains(tok), "ocuse help missing shared token {tok}");
681            }
682        }
683    }
684}