1use crate::cli::{self, Binary, SubCmd};
15use crate::ocuse::window;
16use crate::ocuse::{api, build, db, mcp, render, zen};
17use crate::report_render::Fmt;
18use std::collections::HashSet;
19use std::ffi::OsString;
20use std::io::Write;
21
22fn parse_day(value: &str) -> Option<i64> {
25 let mut parts = value.split('-');
26 let y: i64 = parts.next()?.parse().ok()?;
27 let m: i64 = parts.next()?.parse().ok()?;
28 let d: i64 = parts.next()?.parse().ok()?;
29 (1..=12).contains(&m).then_some(())?;
30 (1..=31).contains(&d).then_some(())?;
31 Some(window::days_from_civil(y, m, d) * 86_400_000)
32}
33
34const VALUE_FLAGS: &[&str] = &[
37 "-i",
38 "--interval",
39 "-w",
40 "--bar-width",
41 "-b",
42 "--bursts",
43 "--days",
44 "--last",
45 "-l",
46 "--since",
47 "--hours",
48 "--tz",
49 "--db",
50 "--plan",
51 "--window",
52 "--period-start",
53 "--rows",
54];
55
56fn split_model_id(argv: Vec<OsString>) -> (Vec<OsString>, Option<String>) {
61 let strs: Vec<String> = argv
62 .iter()
63 .map(|a| a.to_string_lossy().into_owned())
64 .collect();
65 let mut drop_at = None;
66 let mut skip_value = false;
67 let mut after_model = false;
68 for (i, tok) in strs.iter().enumerate() {
69 if skip_value {
70 skip_value = false;
71 continue;
72 }
73 if tok.starts_with('-') {
74 if VALUE_FLAGS.contains(&tok.as_str()) {
77 skip_value = true;
78 }
79 continue;
80 }
81 if after_model {
82 drop_at = Some(i);
83 break;
84 }
85 after_model = tok == "model";
86 }
87 let id = drop_at.map(|i| strs[i].clone());
88 let argv = argv
89 .into_iter()
90 .enumerate()
91 .filter(|(i, _)| Some(*i) != drop_at)
92 .map(|(_, a)| a)
93 .collect();
94 (argv, id)
95}
96
97fn wants_watch(args: &cli::Args) -> bool {
101 !args.once && !args.json
102}
103
104fn compact_frame(rows: usize, cols: usize) -> bool {
106 cols > 0 && (rows < 16 || cols < 40)
107}
108
109fn burst_config(bursts: Option<usize>, enabled: bool, samples: usize) -> (bool, usize) {
113 let on = bursts.is_some() || enabled;
114 let cap = if on {
115 bursts.or(Some(samples)).unwrap_or(40).clamp(5, 240)
116 } else {
117 0
118 };
119 (on, cap)
120}
121
122fn output_fmt(args: &cli::Args) -> Fmt {
124 if args.json {
125 Fmt::Json
126 } else if args.csv {
127 Fmt::Csv
128 } else {
129 Fmt::Table
130 }
131}
132
133fn period_end(report: &window::Report, window_mode: &str, inferred: bool) -> Option<i64> {
140 if inferred {
142 return Some(window::next_month(report.since_ms));
143 }
144 if window_mode == "all" {
145 None
146 } else if let Some(d) = window_mode
147 .strip_suffix('d')
148 .and_then(|v| v.parse::<i64>().ok())
149 {
150 Some(report.since_ms + d * 86_400_000)
151 } else {
152 Some(window::next_month(report.since_ms))
153 }
154}
155
156pub fn run() {
157 let (argv, model_id) = split_model_id(std::env::args_os().collect());
158 let args = match cli::parse_args_from(argv, Binary::Ocuse) {
159 Ok(a) => a,
160 Err(e) => {
161 eprintln!("{e}");
162 std::process::exit(2);
163 }
164 };
165 if args.help {
166 cli::usage(Binary::Ocuse);
167 return;
168 }
169 if let Some(cs) = args.config_set {
170 if let Err(e) = crate::config::set(Binary::Ocuse, &cs) {
171 eprintln!("error: {e}");
172 std::process::exit(1);
173 }
174 return;
175 }
176 if args.dismiss_update {
177 match crate::update_check::latest_newer() {
178 Ok(Some(v)) => match crate::config::set_dismissed(Binary::Ocuse, &v) {
179 Ok(()) => println!("dismissed update notice for {v}"),
180 Err(e) => {
181 eprintln!("error: {e}");
182 std::process::exit(1);
183 }
184 },
185 Ok(None) => println!("ocuse is up to date ({})", env!("CARGO_PKG_VERSION")),
186 Err(e) => {
187 eprintln!("error: {e}");
188 std::process::exit(1);
189 }
190 }
191 return;
192 }
193
194 let cfg = crate::config::load(Binary::Ocuse);
195 let colours = !args.plain && crate::render::color_enabled();
196 let fmt = output_fmt(&args);
197 let db_path = args.db.clone().unwrap_or_else(db::default_path);
198 let days: i64 = args.last.unwrap_or(120) as i64;
199 let hours: i64 = args.hours.unwrap_or(24) as i64;
200 let rows: usize = args.rows.unwrap_or(15);
201 let now = window::now_ms();
202 let tz_secs = args.tz.unwrap_or(0);
203
204 if let Some(SubCmd::Plans) = args.subcmd {
206 if args.json {
207 print!("{}", render::plans_json());
208 } else {
209 print!("{}", render::plans_text(colours));
210 }
211 return;
212 }
213
214 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 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 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 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 let period_inferred = period_start.is_none();
290
291 let fetch_account = || {
296 if args.no_api {
297 return None;
298 }
299 api::key().and_then(|key| api::fetch(&key).ok())
300 };
301 let mut account = fetch_account();
302 let account_month = account
307 .as_ref()
308 .and_then(|a| a.monthly.reset_ms())
309 .map(|reset| (window::prev_month(reset), reset));
310 let account_month = if window_mode == "month"
311 && !period_inferred
312 && since_override.is_none()
313 && args.period_start.is_none()
314 {
315 account_month
316 } else {
317 None
318 };
319 let period_start = account_month.map(|(start, _)| start).or(period_start);
320 let period_label = match account_month {
321 Some(_) => "Go billing month".to_string(),
322 None => period_label,
323 };
324
325 let read_since = |since_ms: i64| match db::read_rows(&db_path, since_ms) {
326 Ok(rows) => rows,
327 Err(error) => {
328 eprintln!("ocuse: {error}");
329 eprintln!("hint: --db <path>, or OPENCODE_DB; the store is created by opencode itself");
330 std::process::exit(1);
331 }
332 };
333 let report_window = since_override.unwrap_or(now - days * 86_400_000);
335 let build_at = |t: i64, account: Option<&api::Usage>| {
336 let mut report = build(
337 &read_since(report_window),
338 &catalog,
339 &plan_id,
340 t,
341 period_start,
342 );
343 report.period_label = period_label.clone();
344 report.account = account.cloned();
345 report.period_end_ms = account_month
346 .map(|(_, reset)| reset)
347 .or_else(|| period_end(&report, &window_mode, period_inferred));
348 report
349 };
350
351 match args.subcmd {
352 Some(SubCmd::Statusline) => {
353 let report = build_at(now, account.as_ref());
354 if args.json {
355 println!("{}", render::statusline_json(&report, &plan_id));
356 } else {
357 print!(
358 "{}",
359 render::statusline_template(
360 &report,
361 &plan_id,
362 &cfg.statusline_template,
363 cfg.statusline_colors,
364 cfg.statusline_ascii,
365 cfg.bar_width.min(30),
366 )
367 );
368 }
369 return;
370 }
371 Some(SubCmd::Daily) => {
372 print!(
373 "{}",
374 render::bucket_text(
375 &read_since(now - days * 86_400_000),
376 now,
377 days * 86_400_000,
378 86_400_000,
379 tz_secs,
380 colours,
381 fmt,
382 )
383 );
384 return;
385 }
386 Some(SubCmd::Hours) => {
387 print!(
388 "{}",
389 render::bucket_text(
390 &read_since(now - hours * 3_600_000),
391 now,
392 hours * 3_600_000,
393 3_600_000,
394 tz_secs,
395 colours,
396 fmt,
397 )
398 );
399 return;
400 }
401 Some(SubCmd::Session) => {
402 print!(
403 "{}",
404 render::session_text(&read_since(report_window), 20, colours, fmt)
405 );
406 return;
407 }
408 Some(SubCmd::Mcp) => {
409 mcp::run(mcp::Settings {
411 db_path: &db_path,
412 days,
413 tz_secs,
414 colour: false,
415 plan: &plan_id,
416 period_start,
417 account: account.as_ref(),
418 });
419 return;
420 }
421 Some(SubCmd::Model) => {
422 let report = build_at(now, account.as_ref());
423 if fmt == Fmt::Table {
424 match &model_id {
425 Some(id) => print!("{}", render::model_text(&report, id, colours)),
426 None => print!(
427 "{}",
428 render::render_text(
429 &report,
430 &db_path,
431 usize::MAX,
432 colours,
433 args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200),
434 )
435 ),
436 }
437 } else {
438 print!(
439 "{}",
440 render::model_report(&report, model_id.as_deref(), fmt)
441 );
442 }
443 return;
444 }
445 Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
447 None => {}
448 }
449
450 let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
452 let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
453 let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
454 let notify_on_cap = cfg.notify_on_cap;
455
456 let update =
460 crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
461
462 if !wants_watch(&args) {
463 if let Some(msg) = &update {
464 eprintln!("{msg}");
465 }
466 let report = build_at(now, account.as_ref());
467 if args.json {
468 println!("{}", render::render_json(&report));
469 } else {
470 print!(
471 "{}",
472 render::render_text(&report, &db_path, rows, colours, bar_width)
473 );
474 }
475 return;
476 }
477
478 let ink = render::Ink::new(colours);
482 let mut out = std::io::stdout();
483 let mut prev_lines = 0usize;
484 let mut history: Vec<f64> = Vec::new();
485 let mut last_five: Option<f64> = None;
486 let mut over: HashSet<String> = HashSet::new();
488 let use_color = crate::render::color_enabled();
489 loop {
490 if let Some(fresh) = fetch_account() {
494 account = Some(fresh);
495 }
496 let report = build_at(window::now_ms(), account.as_ref());
497 let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
498 let cols_opt = (cols > 0).then_some(cols);
499 let compact = compact_frame(term_rows, cols);
500 let delta = last_five
503 .map(|prev| (report.five_hour.cost - prev).max(0.0))
504 .unwrap_or(0.0);
505 last_five = Some(report.five_hour.cost);
506 if burst_on {
507 history.push(delta);
508 if history.len() > burst_cap {
509 history.remove(0);
510 }
511 }
512 if notify_on_cap {
513 let mut over_now: HashSet<String> = HashSet::new();
514 if let Some(a) = &account {
517 for (key, label, meter) in [
518 ("window:5h", "5-hour window", &a.rolling),
519 ("window:wk", "weekly window", &a.weekly),
520 ("window:mo", "monthly limit", &a.monthly),
521 ] {
522 if meter.exceeded() {
523 over_now.insert(key.to_string());
524 if !over.contains(key) {
525 crate::notify::cap(
526 "ocuse: limit exceeded",
527 &format!("OpenCode Go {label} is over its limit"),
528 );
529 }
530 }
531 }
532 }
533 for model in &report.models {
534 if let Some(window) = window::over_window(model) {
535 over_now.insert(model.id.clone());
536 if !over.contains(&model.id) {
537 crate::notify::cap(
538 "ocuse: limit exceeded",
539 &format!("OpenCode Go {} is over its {window} limit", model.id),
540 );
541 }
542 }
543 }
544 over = over_now;
545 }
546 let burst_slice: &[f64] = if burst_on { &history } else { &[] };
547 let text = if compact {
548 render::compact_line(&report, burst_slice, colours)
549 } else {
550 render::watch_frame(
551 &report,
552 &db_path,
553 rows,
554 colours,
555 burst_slice,
556 interval,
557 bar_width,
558 )
559 };
560 let update_box = update
561 .as_ref()
562 .map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
563 let status = format!(
564 "{}refreshing every {interval}s · ctrl-c to quit{}",
565 ink.dim, ink.reset
566 );
567 let frame = match &update_box {
568 Some(u) => format!("{u}{text}\n{status}"),
569 None => format!("{text}\n{status}"),
570 };
571 prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
572 let _ = out.flush();
573 for remaining in (1..interval).rev() {
574 std::thread::sleep(std::time::Duration::from_secs(1));
575 let msg = format!(
576 "\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
577 ink.dim, ink.reset
578 );
579 let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
580 let _ = out.flush();
581 }
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 fn argv(args: &[&str]) -> Vec<OsString> {
590 std::iter::once(OsString::from("ocuse"))
591 .chain(args.iter().map(OsString::from))
592 .collect()
593 }
594
595 fn parse(args: &[&str]) -> cli::Args {
596 cli::parse_args_from(argv(args), Binary::Ocuse).expect("parses")
597 }
598
599 #[test]
600 fn json_and_one_shot_leave_the_watch_loop() {
601 assert!(wants_watch(&parse(&[]))); assert!(wants_watch(&parse(&["-W"])));
603 assert!(!wants_watch(&parse(&["-1"]))); assert!(!wants_watch(&parse(&["--once"])));
605 assert!(!wants_watch(&parse(&["--json"]))); }
607
608 #[test]
609 fn compact_frame_switches_on_small_terminals() {
610 assert!(!compact_frame(40, 120)); assert!(compact_frame(10, 120)); assert!(compact_frame(40, 30)); assert!(!compact_frame(0, 0)); assert!(compact_frame(0, 20)); }
616
617 #[test]
618 fn burst_state_derives_from_args_and_config() {
619 assert_eq!(burst_config(None, false, 40), (false, 0));
621 assert_eq!(burst_config(Some(30), false, 40), (true, 30));
623 assert_eq!(burst_config(None, true, 25), (true, 25));
625 assert_eq!(burst_config(Some(60), true, 25), (true, 60));
627 assert_eq!(burst_config(None, true, 3), (true, 5));
629 }
630
631 #[test]
632 fn gated_is_a_shared_flag() {
633 let a = cli::parse_args_from(argv(&["models", "--gated", "--plan", "go"]), Binary::Ocuse)
636 .expect("parses");
637 assert!(a.gated);
638 assert_eq!(a.subcmd, Some(SubCmd::Models));
639 assert_eq!(a.plan.as_deref(), Some("go"));
640 assert!(
641 !cli::parse_args_from(argv(&["models"]), Binary::Ocuse)
642 .expect("parses")
643 .gated
644 );
645 }
646
647 #[test]
648 fn no_api_is_an_ocuse_flag() {
649 assert!(parse(&["-1", "--no-api"]).no_api);
652 assert!(!parse(&["-1"]).no_api);
653 assert!(
654 cli::parse_args_from(argv(&["--no-api"]), Binary::Cmduse).is_err(),
655 "cmduse rejects --no-api"
656 );
657 }
658
659 #[test]
660 fn output_fmt_maps_the_flags() {
661 let of = |args: &[&str]| output_fmt(&parse(args));
662 assert!(matches!(of(&[]), Fmt::Table));
663 assert!(matches!(of(&["--json"]), Fmt::Json));
664 assert!(matches!(of(&["--csv"]), Fmt::Csv));
665 }
666
667 #[test]
668 fn period_end_resolves_from_the_reports_own_start() {
669 let since = window::days_from_civil(2026, 9, 15) * 86_400_000;
670 let report = window::Report {
671 period_label: "calendar month".into(),
672 now_ms: since,
673 since_ms: since,
674 period_end_ms: None,
675 month_inferred: false,
676 account: None,
677 five_hour: Default::default(),
678 weekly: Default::default(),
679 month: Default::default(),
680 models: Vec::new(),
681 };
682 assert_eq!(
684 period_end(&report, "month", false),
685 Some(window::next_month(since))
686 );
687 assert_eq!(
689 period_end(&report, "30d", false),
690 Some(since + 30 * 86_400_000)
691 );
692 assert_eq!(period_end(&report, "all", false), None);
694 assert_eq!(
696 period_end(&report, "30d", true),
697 Some(window::next_month(since))
698 );
699 }
700
701 #[test]
702 fn model_id_is_pulled_out_before_parsing() {
703 let (rest, id) = split_model_id(argv(&["model", "glm-5.3-flash"]));
704 assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
705 assert_eq!(
707 cli::parse_args_from(rest, Binary::Ocuse).unwrap().subcmd,
708 Some(SubCmd::Model)
709 );
710
711 let (_, id) = split_model_id(argv(&["model"]));
713 assert!(id.is_none());
714
715 let (rest, id) = split_model_id(argv(&["--plan", "go", "model", "glm-5.3-flash"]));
717 assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
718 assert!(cli::parse_args_from(rest, Binary::Ocuse).is_ok());
719
720 let (_, id) = split_model_id(argv(&["model", "--days", "7", "glm-5.3-flash"]));
722 assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
723
724 let (_, id) = split_model_id(argv(&["daily", "--days", "7"]));
726 assert!(id.is_none());
727 }
728
729 #[test]
730 fn ocuse_reaches_shared_flag_parity() {
731 for args in [
733 vec!["-1"],
734 vec!["-W"],
735 vec!["-p"],
736 vec!["-i", "30"],
737 vec!["-w", "20"],
738 vec!["-b", "40"],
739 vec!["--json"],
740 vec!["--csv"],
741 vec!["--tz", "+05:30"],
742 vec!["--days", "7"],
743 vec!["--hours", "6"],
744 vec!["--since", "2026-01-01T00:00:00Z"],
745 vec!["--dismiss-update"],
746 vec!["--gated"],
747 vec!["config", "set", "interval=10"],
748 ] {
749 assert!(
750 cli::parse_args_from(argv(&args), Binary::Ocuse).is_ok(),
751 "ocuse must accept {args:?}"
752 );
753 }
754 assert!(cli::parse_args_from(argv(&["--local"]), Binary::Ocuse).is_err());
757 assert!(cli::parse_args_from(argv(&["--gated"]), Binary::Ocuse).is_ok());
758 }
759
760 #[test]
761 fn ocuse_parses_every_shared_subcommand() {
762 for (word, want) in [
765 ("daily", SubCmd::Daily),
766 ("days", SubCmd::Daily),
767 ("hourly", SubCmd::Hours),
768 ("hours", SubCmd::Hours),
769 ("model", SubCmd::Model),
770 ("session", SubCmd::Session),
771 ("sessions", SubCmd::Session),
772 ("project", SubCmd::Session),
773 ("statusline", SubCmd::Statusline),
774 ("models", SubCmd::Models),
775 ("plans", SubCmd::Plans),
776 ("mcp", SubCmd::Mcp),
777 ] {
778 for binary in [Binary::Cmduse, Binary::Ocuse] {
779 let a = cli::parse_args_from(
780 std::iter::once(OsString::from("x"))
781 .chain(std::iter::once(OsString::from(word))),
782 binary,
783 )
784 .unwrap_or_else(|e| panic!("{binary:?} must parse {word}: {e}"));
785 assert_eq!(a.subcmd.as_ref(), Some(&want), "{binary:?} {word}");
786 }
787 }
788 }
789
790 #[test]
791 fn ocuse_help_carries_the_shared_tokens() {
792 let c = cli::usage_text(Binary::Cmduse);
793 let o = cli::usage_text(Binary::Ocuse);
794 for tok in [
796 "-1",
797 "--once",
798 "-W",
799 "--watch",
800 "-p",
801 "--plain",
802 "-h",
803 "--help",
804 "-V",
805 "--version",
806 "--json",
807 "--csv",
808 "-i",
809 "--interval",
810 "-w",
811 "--bar-width",
812 "-b",
813 "--bursts",
814 "--days",
815 "--since",
816 "--hours",
817 "--tz",
818 "--dismiss-update",
819 "NO_COLOR",
820 "config set",
821 ] {
822 if c.contains(tok) {
823 assert!(o.contains(tok), "ocuse help missing shared token {tok}");
824 }
825 }
826 }
827}