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 = || -> Result<api::Usage, api::Unavailable> {
296 if args.no_api {
297 return Err(api::Unavailable::Disabled);
298 }
299 match api::key() {
300 Some(key) => api::fetch(&key),
301 None => Err(api::Unavailable::NoKey),
302 }
303 };
304 let (mut account, mut account_error) = match fetch_account() {
305 Ok(usage) => (Some(usage), None),
306 Err(why) => (None, why.reason()),
307 };
308 let account_month = account
313 .as_ref()
314 .and_then(|a| a.monthly.reset_ms())
315 .map(|reset| (window::prev_month(reset), reset));
316 let account_month = if window_mode == "month"
317 && !period_inferred
318 && since_override.is_none()
319 && args.period_start.is_none()
320 {
321 account_month
322 } else {
323 None
324 };
325 let period_start = account_month.map(|(start, _)| start).or(period_start);
326 let period_label = match account_month {
327 Some(_) => "Go billing month".to_string(),
328 None => period_label,
329 };
330
331 let read_since = |since_ms: i64| match db::read_rows(&db_path, since_ms) {
332 Ok(rows) => rows,
333 Err(error) => {
334 eprintln!("ocuse: {error}");
335 eprintln!("hint: --db <path>, or OPENCODE_DB; the store is created by opencode itself");
336 std::process::exit(1);
337 }
338 };
339 let report_window = since_override.unwrap_or(now - days * 86_400_000);
341 let build_at = |t: i64, account: Option<&api::Usage>, account_error: Option<&str>| {
342 let mut report = build(
343 &read_since(report_window),
344 &catalog,
345 &plan_id,
346 t,
347 period_start,
348 account,
349 );
350 report.period_label = period_label.clone();
351 report.account = account.cloned();
352 report.account_error = account_error.map(str::to_string);
353 report.period_end_ms = account_month
354 .map(|(_, reset)| reset)
355 .or_else(|| period_end(&report, &window_mode, period_inferred));
356 report
357 };
358
359 match args.subcmd {
360 Some(SubCmd::Statusline) => {
361 let report = build_at(now, account.as_ref(), account_error.as_deref());
362 if args.json {
363 println!("{}", render::statusline_json(&report, &plan_id));
364 } else {
365 print!(
366 "{}",
367 render::statusline_template(
368 &report,
369 &plan_id,
370 &cfg.statusline_template,
371 cfg.statusline_colors,
372 cfg.statusline_ascii,
373 cfg.bar_width.min(30),
374 )
375 );
376 }
377 return;
378 }
379 Some(SubCmd::Daily) => {
380 print!(
381 "{}",
382 render::bucket_text(
383 &read_since(now - days * 86_400_000),
384 now,
385 days * 86_400_000,
386 86_400_000,
387 tz_secs,
388 colours,
389 fmt,
390 )
391 );
392 return;
393 }
394 Some(SubCmd::Hours) => {
395 print!(
396 "{}",
397 render::bucket_text(
398 &read_since(now - hours * 3_600_000),
399 now,
400 hours * 3_600_000,
401 3_600_000,
402 tz_secs,
403 colours,
404 fmt,
405 )
406 );
407 return;
408 }
409 Some(SubCmd::Session) => {
410 print!(
411 "{}",
412 render::session_text(&read_since(report_window), 20, colours, fmt)
413 );
414 return;
415 }
416 Some(SubCmd::Mcp) => {
417 mcp::run(mcp::Settings {
419 db_path: &db_path,
420 days,
421 tz_secs,
422 colour: false,
423 plan: &plan_id,
424 period_start,
425 account: account.as_ref(),
426 account_error: account_error.as_deref(),
427 });
428 return;
429 }
430 Some(SubCmd::Model) => {
431 let report = build_at(now, account.as_ref(), account_error.as_deref());
432 if fmt == Fmt::Table {
433 match &model_id {
434 Some(id) => print!("{}", render::model_text(&report, id, colours)),
435 None => print!(
436 "{}",
437 render::render_text(
438 &report,
439 &db_path,
440 usize::MAX,
441 colours,
442 args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200),
443 )
444 ),
445 }
446 } else {
447 print!(
448 "{}",
449 render::model_report(&report, model_id.as_deref(), fmt)
450 );
451 }
452 return;
453 }
454 Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
456 None => {}
457 }
458
459 let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
461 let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
462 let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
463 let notify_on_cap = cfg.notify_on_cap;
464
465 let update =
469 crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
470
471 if !wants_watch(&args) {
472 if let Some(msg) = &update {
473 eprintln!("{msg}");
474 }
475 let report = build_at(now, account.as_ref(), account_error.as_deref());
476 if args.json {
477 println!("{}", render::render_json(&report));
478 } else {
479 print!(
480 "{}",
481 render::render_text(&report, &db_path, rows, colours, bar_width)
482 );
483 }
484 return;
485 }
486
487 let ink = render::Ink::new(colours);
491 let mut out = std::io::stdout();
492 let mut prev_lines = 0usize;
493 let mut history: Vec<f64> = Vec::new();
494 let mut last_five: Option<f64> = None;
495 let mut over: HashSet<String> = HashSet::new();
497 let use_color = crate::render::color_enabled();
498 loop {
499 match fetch_account() {
504 Ok(fresh) => {
505 account = Some(fresh);
506 account_error = None;
507 }
508 Err(why) => {
509 if account.is_none() {
510 account_error = why.reason();
511 }
512 }
513 }
514 let report = build_at(window::now_ms(), account.as_ref(), account_error.as_deref());
515 let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
516 let cols_opt = (cols > 0).then_some(cols);
517 let compact = compact_frame(term_rows, cols);
518 let delta = last_five
521 .map(|prev| (report.five_hour.cost - prev).max(0.0))
522 .unwrap_or(0.0);
523 last_five = Some(report.five_hour.cost);
524 if burst_on {
525 history.push(delta);
526 if history.len() > burst_cap {
527 history.remove(0);
528 }
529 }
530 if notify_on_cap {
531 let mut over_now: HashSet<String> = HashSet::new();
532 if let Some(a) = &account {
535 for (key, label, meter) in [
536 ("window:5h", "5-hour window", &a.rolling),
537 ("window:wk", "weekly window", &a.weekly),
538 ("window:mo", "monthly limit", &a.monthly),
539 ] {
540 if meter.exceeded() {
541 over_now.insert(key.to_string());
542 if !over.contains(key) {
543 crate::notify::cap(
544 "ocuse: limit exceeded",
545 &format!("OpenCode Go {label} is over its limit"),
546 );
547 }
548 }
549 }
550 }
551 for model in &report.models {
552 if let Some(window) = window::over_window(model) {
553 over_now.insert(model.id.clone());
554 if !over.contains(&model.id) {
555 crate::notify::cap(
556 "ocuse: limit exceeded",
557 &format!("OpenCode Go {} is over its {window} limit", model.id),
558 );
559 }
560 }
561 }
562 over = over_now;
563 }
564 let burst_slice: &[f64] = if burst_on { &history } else { &[] };
565 let text = if compact {
566 render::compact_line(&report, burst_slice, colours)
567 } else {
568 render::watch_frame(
569 &report,
570 &db_path,
571 rows,
572 colours,
573 burst_slice,
574 interval,
575 bar_width,
576 )
577 };
578 let update_box = update
579 .as_ref()
580 .map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
581 let status = format!(
582 "{}refreshing every {interval}s · ctrl-c to quit{}",
583 ink.dim, ink.reset
584 );
585 let frame = match &update_box {
586 Some(u) => format!("{u}{text}\n{status}"),
587 None => format!("{text}\n{status}"),
588 };
589 prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
590 let _ = out.flush();
591 for remaining in (1..interval).rev() {
592 std::thread::sleep(std::time::Duration::from_secs(1));
593 let msg = format!(
594 "\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
595 ink.dim, ink.reset
596 );
597 let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
598 let _ = out.flush();
599 }
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 fn argv(args: &[&str]) -> Vec<OsString> {
608 std::iter::once(OsString::from("ocuse"))
609 .chain(args.iter().map(OsString::from))
610 .collect()
611 }
612
613 fn parse(args: &[&str]) -> cli::Args {
614 cli::parse_args_from(argv(args), Binary::Ocuse).expect("parses")
615 }
616
617 #[test]
618 fn json_and_one_shot_leave_the_watch_loop() {
619 assert!(wants_watch(&parse(&[]))); assert!(wants_watch(&parse(&["-W"])));
621 assert!(!wants_watch(&parse(&["-1"]))); assert!(!wants_watch(&parse(&["--once"])));
623 assert!(!wants_watch(&parse(&["--json"]))); }
625
626 #[test]
627 fn compact_frame_switches_on_small_terminals() {
628 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)); }
634
635 #[test]
636 fn burst_state_derives_from_args_and_config() {
637 assert_eq!(burst_config(None, false, 40), (false, 0));
639 assert_eq!(burst_config(Some(30), false, 40), (true, 30));
641 assert_eq!(burst_config(None, true, 25), (true, 25));
643 assert_eq!(burst_config(Some(60), true, 25), (true, 60));
645 assert_eq!(burst_config(None, true, 3), (true, 5));
647 }
648
649 #[test]
650 fn gated_is_a_shared_flag() {
651 let a = cli::parse_args_from(argv(&["models", "--gated", "--plan", "go"]), Binary::Ocuse)
654 .expect("parses");
655 assert!(a.gated);
656 assert_eq!(a.subcmd, Some(SubCmd::Models));
657 assert_eq!(a.plan.as_deref(), Some("go"));
658 assert!(
659 !cli::parse_args_from(argv(&["models"]), Binary::Ocuse)
660 .expect("parses")
661 .gated
662 );
663 }
664
665 #[test]
666 fn no_api_is_an_ocuse_flag() {
667 assert!(parse(&["-1", "--no-api"]).no_api);
670 assert!(!parse(&["-1"]).no_api);
671 assert!(
672 cli::parse_args_from(argv(&["--no-api"]), Binary::Cmduse).is_err(),
673 "cmduse rejects --no-api"
674 );
675 }
676
677 #[test]
678 fn output_fmt_maps_the_flags() {
679 let of = |args: &[&str]| output_fmt(&parse(args));
680 assert!(matches!(of(&[]), Fmt::Table));
681 assert!(matches!(of(&["--json"]), Fmt::Json));
682 assert!(matches!(of(&["--csv"]), Fmt::Csv));
683 }
684
685 #[test]
686 fn period_end_resolves_from_the_reports_own_start() {
687 let since = window::days_from_civil(2026, 9, 15) * 86_400_000;
688 let report = window::Report {
689 period_label: "calendar month".into(),
690 now_ms: since,
691 since_ms: since,
692 period_end_ms: None,
693 month_inferred: false,
694 account: None,
695 account_error: None,
696 five_hour: Default::default(),
697 weekly: Default::default(),
698 month: Default::default(),
699 models: Vec::new(),
700 };
701 assert_eq!(
703 period_end(&report, "month", false),
704 Some(window::next_month(since))
705 );
706 assert_eq!(
708 period_end(&report, "30d", false),
709 Some(since + 30 * 86_400_000)
710 );
711 assert_eq!(period_end(&report, "all", false), None);
713 assert_eq!(
715 period_end(&report, "30d", true),
716 Some(window::next_month(since))
717 );
718 }
719
720 #[test]
721 fn model_id_is_pulled_out_before_parsing() {
722 let (rest, id) = split_model_id(argv(&["model", "glm-5.3-flash"]));
723 assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
724 assert_eq!(
726 cli::parse_args_from(rest, Binary::Ocuse).unwrap().subcmd,
727 Some(SubCmd::Model)
728 );
729
730 let (_, id) = split_model_id(argv(&["model"]));
732 assert!(id.is_none());
733
734 let (rest, id) = split_model_id(argv(&["--plan", "go", "model", "glm-5.3-flash"]));
736 assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
737 assert!(cli::parse_args_from(rest, Binary::Ocuse).is_ok());
738
739 let (_, id) = split_model_id(argv(&["model", "--days", "7", "glm-5.3-flash"]));
741 assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
742
743 let (_, id) = split_model_id(argv(&["daily", "--days", "7"]));
745 assert!(id.is_none());
746 }
747
748 #[test]
749 fn ocuse_reaches_shared_flag_parity() {
750 for args in [
752 vec!["-1"],
753 vec!["-W"],
754 vec!["-p"],
755 vec!["-i", "30"],
756 vec!["-w", "20"],
757 vec!["-b", "40"],
758 vec!["--json"],
759 vec!["--csv"],
760 vec!["--tz", "+05:30"],
761 vec!["--days", "7"],
762 vec!["--hours", "6"],
763 vec!["--since", "2026-01-01T00:00:00Z"],
764 vec!["--dismiss-update"],
765 vec!["--gated"],
766 vec!["config", "set", "interval=10"],
767 ] {
768 assert!(
769 cli::parse_args_from(argv(&args), Binary::Ocuse).is_ok(),
770 "ocuse must accept {args:?}"
771 );
772 }
773 assert!(cli::parse_args_from(argv(&["--local"]), Binary::Ocuse).is_err());
776 assert!(cli::parse_args_from(argv(&["--gated"]), Binary::Ocuse).is_ok());
777 }
778
779 #[test]
780 fn ocuse_parses_every_shared_subcommand() {
781 for (word, want) in [
784 ("daily", SubCmd::Daily),
785 ("days", SubCmd::Daily),
786 ("hourly", SubCmd::Hours),
787 ("hours", SubCmd::Hours),
788 ("model", SubCmd::Model),
789 ("session", SubCmd::Session),
790 ("sessions", SubCmd::Session),
791 ("project", SubCmd::Session),
792 ("statusline", SubCmd::Statusline),
793 ("models", SubCmd::Models),
794 ("plans", SubCmd::Plans),
795 ("mcp", SubCmd::Mcp),
796 ] {
797 for binary in [Binary::Cmduse, Binary::Ocuse] {
798 let a = cli::parse_args_from(
799 std::iter::once(OsString::from("x"))
800 .chain(std::iter::once(OsString::from(word))),
801 binary,
802 )
803 .unwrap_or_else(|e| panic!("{binary:?} must parse {word}: {e}"));
804 assert_eq!(a.subcmd.as_ref(), Some(&want), "{binary:?} {word}");
805 }
806 }
807 }
808
809 #[test]
810 fn ocuse_help_carries_the_shared_tokens() {
811 let c = cli::usage_text(Binary::Cmduse);
812 let o = cli::usage_text(Binary::Ocuse);
813 for tok in [
815 "-1",
816 "--once",
817 "-W",
818 "--watch",
819 "-p",
820 "--plain",
821 "-h",
822 "--help",
823 "-V",
824 "--version",
825 "--json",
826 "--csv",
827 "-i",
828 "--interval",
829 "-w",
830 "--bar-width",
831 "-b",
832 "--bursts",
833 "--days",
834 "--since",
835 "--hours",
836 "--tz",
837 "--dismiss-update",
838 "NO_COLOR",
839 "config set",
840 ] {
841 if c.contains(tok) {
842 assert!(o.contains(tok), "ocuse help missing shared token {tok}");
843 }
844 }
845 }
846}