1use 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
23fn 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
35const 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
57fn 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 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
98fn wants_watch(args: &cli::Args) -> bool {
102 !args.once && !args.json
103}
104
105fn compact_frame(rows: usize, cols: usize) -> bool {
107 cols > 0 && (rows < 16 || cols < 40)
108}
109
110fn 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
123fn 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 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 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 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 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 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 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 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 Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
375 None => {}
376 }
377
378 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 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 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 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 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(&[]))); assert!(wants_watch(&parse(&["-W"])));
506 assert!(!wants_watch(&parse(&["-1"]))); assert!(!wants_watch(&parse(&["--once"])));
508 assert!(!wants_watch(&parse(&["--json"]))); }
510
511 #[test]
512 fn compact_frame_switches_on_small_terminals() {
513 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)); }
519
520 #[test]
521 fn burst_state_derives_from_args_and_config() {
522 assert_eq!(burst_config(None, false, 40), (false, 0));
524 assert_eq!(burst_config(Some(30), false, 40), (true, 30));
526 assert_eq!(burst_config(None, true, 25), (true, 25));
528 assert_eq!(burst_config(Some(60), true, 25), (true, 60));
530 assert_eq!(burst_config(None, true, 3), (true, 5));
532 }
533
534 #[test]
535 fn gated_is_a_shared_flag() {
536 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 assert_eq!(
564 cli::parse_args_from(rest, Binary::Ocuse).unwrap().subcmd,
565 Some(SubCmd::Model)
566 );
567
568 let (_, id) = split_model_id(argv(&["model"]));
570 assert!(id.is_none());
571
572 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 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 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 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 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 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 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}