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