use crate::cli::{self, Binary, SubCmd};
use crate::ocuse::window;
use crate::ocuse::{api, build, db, mcp, render, zen};
use crate::report_render::Fmt;
use std::collections::HashSet;
use std::ffi::OsString;
use std::io::Write;
fn parse_day(value: &str) -> Option<i64> {
let mut parts = value.split('-');
let y: i64 = parts.next()?.parse().ok()?;
let m: i64 = parts.next()?.parse().ok()?;
let d: i64 = parts.next()?.parse().ok()?;
(1..=12).contains(&m).then_some(())?;
(1..=31).contains(&d).then_some(())?;
Some(window::days_from_civil(y, m, d) * 86_400_000)
}
const VALUE_FLAGS: &[&str] = &[
"-i",
"--interval",
"-w",
"--bar-width",
"-b",
"--bursts",
"--days",
"--last",
"-l",
"--since",
"--hours",
"--tz",
"--db",
"--plan",
"--window",
"--period-start",
"--rows",
];
fn split_model_id(argv: Vec<OsString>) -> (Vec<OsString>, Option<String>) {
let strs: Vec<String> = argv
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let mut drop_at = None;
let mut skip_value = false;
let mut after_model = false;
for (i, tok) in strs.iter().enumerate() {
if skip_value {
skip_value = false;
continue;
}
if tok.starts_with('-') {
if VALUE_FLAGS.contains(&tok.as_str()) {
skip_value = true;
}
continue;
}
if after_model {
drop_at = Some(i);
break;
}
after_model = tok == "model";
}
let id = drop_at.map(|i| strs[i].clone());
let argv = argv
.into_iter()
.enumerate()
.filter(|(i, _)| Some(*i) != drop_at)
.map(|(_, a)| a)
.collect();
(argv, id)
}
fn wants_watch(args: &cli::Args) -> bool {
!args.once && !args.json
}
fn compact_frame(rows: usize, cols: usize) -> bool {
cols > 0 && (rows < 16 || cols < 40)
}
fn burst_config(bursts: Option<usize>, enabled: bool, samples: usize) -> (bool, usize) {
let on = bursts.is_some() || enabled;
let cap = if on {
bursts.or(Some(samples)).unwrap_or(40).clamp(5, 240)
} else {
0
};
(on, cap)
}
fn output_fmt(args: &cli::Args) -> Fmt {
if args.json {
Fmt::Json
} else if args.csv {
Fmt::Csv
} else {
Fmt::Table
}
}
fn period_end(report: &window::Report, window_mode: &str, inferred: bool) -> Option<i64> {
if inferred {
return Some(window::next_month(report.since_ms));
}
if window_mode == "all" {
None
} else if let Some(d) = window_mode
.strip_suffix('d')
.and_then(|v| v.parse::<i64>().ok())
{
Some(report.since_ms + d * 86_400_000)
} else {
Some(window::next_month(report.since_ms))
}
}
pub fn run() {
let (argv, model_id) = split_model_id(std::env::args_os().collect());
let args = match cli::parse_args_from(argv, Binary::Ocuse) {
Ok(a) => a,
Err(e) => {
eprintln!("{e}");
std::process::exit(2);
}
};
if args.help {
cli::usage(Binary::Ocuse);
return;
}
if let Some(cs) = args.config_set {
if let Err(e) = crate::config::set(Binary::Ocuse, &cs) {
eprintln!("error: {e}");
std::process::exit(1);
}
return;
}
if args.dismiss_update {
match crate::update_check::latest_newer() {
Ok(Some(v)) => match crate::config::set_dismissed(Binary::Ocuse, &v) {
Ok(()) => println!("dismissed update notice for {v}"),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
},
Ok(None) => println!("ocuse is up to date ({})", env!("CARGO_PKG_VERSION")),
Err(e) => {
eprintln!("error: {e}");
std::process::exit(1);
}
}
return;
}
let cfg = crate::config::load(Binary::Ocuse);
let colours = !args.plain && crate::render::color_enabled();
let fmt = output_fmt(&args);
let db_path = args.db.clone().unwrap_or_else(db::default_path);
let days: i64 = args.last.unwrap_or(120) as i64;
let hours: i64 = args.hours.unwrap_or(24) as i64;
let rows: usize = args.rows.unwrap_or(15);
let now = window::now_ms();
let tz_secs = args.tz.unwrap_or(0);
if let Some(SubCmd::Plans) = args.subcmd {
if args.json {
print!("{}", render::plans_json());
} else {
print!("{}", render::plans_text(colours));
}
return;
}
let catalog = zen::catalog();
let plan_id = args
.plan
.clone()
.unwrap_or_else(|| zen::DEFAULT_PLAN.to_string());
if catalog.plan(&plan_id).is_none() {
eprintln!(
"ocuse: --plan expects one of {} (got {plan_id})",
catalog.plan_ids().join(", ")
);
std::process::exit(2);
}
if let Some(SubCmd::Models) = args.subcmd {
if args.json {
print!("{}", render::models_json(&plan_id, args.gated));
} else {
print!("{}", render::models_text(&plan_id, args.gated, colours));
}
return;
}
let since_override = args
.since
.as_deref()
.and_then(cmduse_core::dates::parse_iso_utc)
.map(|ms| ms as i64);
let window_mode = args.window.clone().unwrap_or_else(|| "month".into());
let period_start = if let Some(ms) = since_override {
Some(ms)
} else if let Some(day) = args.period_start.as_deref().and_then(parse_day) {
Some(day)
} else if window_mode == "all" {
Some(0)
} else if window_mode == "month" {
Some(window::calendar_month_start(now))
} else if let Some(d) = window_mode
.strip_suffix('d')
.and_then(|v| v.parse::<i64>().ok())
{
Some(now - d * 86_400_000)
} else {
eprintln!("ocuse: --window expects month, all, or <n>d (got {window_mode})");
std::process::exit(2);
};
let period_label = if let Some(iso) = &args.since {
format!("since {iso}")
} else if args.period_start.is_some() {
"period start (pinned)".to_string()
} else {
match window_mode.as_str() {
"all" => "all time".to_string(),
"month" => "calendar month".to_string(),
other => other.to_string(),
}
};
let period_start =
if args.infer_anniversary && args.period_start.is_none() && args.since.is_none() {
None
} else {
period_start
};
let period_inferred = period_start.is_none();
let fetch_account = || {
if args.no_api {
return None;
}
api::key().and_then(|key| api::fetch(&key).ok())
};
let mut account = fetch_account();
let account_month = account
.as_ref()
.and_then(|a| a.monthly.reset_ms())
.map(|reset| (window::prev_month(reset), reset));
let account_month = if window_mode == "month"
&& !period_inferred
&& since_override.is_none()
&& args.period_start.is_none()
{
account_month
} else {
None
};
let period_start = account_month.map(|(start, _)| start).or(period_start);
let period_label = match account_month {
Some(_) => "Go billing month".to_string(),
None => period_label,
};
let read_since = |since_ms: i64| match db::read_rows(&db_path, since_ms) {
Ok(rows) => rows,
Err(error) => {
eprintln!("ocuse: {error}");
eprintln!("hint: --db <path>, or OPENCODE_DB; the store is created by opencode itself");
std::process::exit(1);
}
};
let report_window = since_override.unwrap_or(now - days * 86_400_000);
let build_at = |t: i64, account: Option<&api::Usage>| {
let mut report = build(
&read_since(report_window),
&catalog,
&plan_id,
t,
period_start,
);
report.period_label = period_label.clone();
report.account = account.cloned();
report.period_end_ms = account_month
.map(|(_, reset)| reset)
.or_else(|| period_end(&report, &window_mode, period_inferred));
report
};
match args.subcmd {
Some(SubCmd::Statusline) => {
let report = build_at(now, account.as_ref());
if args.json {
println!("{}", render::statusline_json(&report, &plan_id));
} else {
print!(
"{}",
render::statusline_template(
&report,
&plan_id,
&cfg.statusline_template,
cfg.statusline_colors,
cfg.statusline_ascii,
cfg.bar_width.min(30),
)
);
}
return;
}
Some(SubCmd::Daily) => {
print!(
"{}",
render::bucket_text(
&read_since(now - days * 86_400_000),
now,
days * 86_400_000,
86_400_000,
tz_secs,
colours,
fmt,
)
);
return;
}
Some(SubCmd::Hours) => {
print!(
"{}",
render::bucket_text(
&read_since(now - hours * 3_600_000),
now,
hours * 3_600_000,
3_600_000,
tz_secs,
colours,
fmt,
)
);
return;
}
Some(SubCmd::Session) => {
print!(
"{}",
render::session_text(&read_since(report_window), 20, colours, fmt)
);
return;
}
Some(SubCmd::Mcp) => {
mcp::run(mcp::Settings {
db_path: &db_path,
days,
tz_secs,
colour: false,
plan: &plan_id,
period_start,
account: account.as_ref(),
});
return;
}
Some(SubCmd::Model) => {
let report = build_at(now, account.as_ref());
if fmt == Fmt::Table {
match &model_id {
Some(id) => print!("{}", render::model_text(&report, id, colours)),
None => print!(
"{}",
render::render_text(
&report,
&db_path,
usize::MAX,
colours,
args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200),
)
),
}
} else {
print!(
"{}",
render::model_report(&report, model_id.as_deref(), fmt)
);
}
return;
}
Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
None => {}
}
let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
let notify_on_cap = cfg.notify_on_cap;
let update =
crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
if !wants_watch(&args) {
if let Some(msg) = &update {
eprintln!("{msg}");
}
let report = build_at(now, account.as_ref());
if args.json {
println!("{}", render::render_json(&report));
} else {
print!(
"{}",
render::render_text(&report, &db_path, rows, colours, bar_width)
);
}
return;
}
let ink = render::Ink::new(colours);
let mut out = std::io::stdout();
let mut prev_lines = 0usize;
let mut history: Vec<f64> = Vec::new();
let mut last_five: Option<f64> = None;
let mut over: HashSet<String> = HashSet::new();
let use_color = crate::render::color_enabled();
loop {
if let Some(fresh) = fetch_account() {
account = Some(fresh);
}
let report = build_at(window::now_ms(), account.as_ref());
let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
let cols_opt = (cols > 0).then_some(cols);
let compact = compact_frame(term_rows, cols);
let delta = last_five
.map(|prev| (report.five_hour.cost - prev).max(0.0))
.unwrap_or(0.0);
last_five = Some(report.five_hour.cost);
if burst_on {
history.push(delta);
if history.len() > burst_cap {
history.remove(0);
}
}
if notify_on_cap {
let mut over_now: HashSet<String> = HashSet::new();
if let Some(a) = &account {
for (key, label, meter) in [
("window:5h", "5-hour window", &a.rolling),
("window:wk", "weekly window", &a.weekly),
("window:mo", "monthly limit", &a.monthly),
] {
if meter.exceeded() {
over_now.insert(key.to_string());
if !over.contains(key) {
crate::notify::cap(
"ocuse: limit exceeded",
&format!("OpenCode Go {label} is over its limit"),
);
}
}
}
}
for model in &report.models {
if let Some(window) = window::over_window(model) {
over_now.insert(model.id.clone());
if !over.contains(&model.id) {
crate::notify::cap(
"ocuse: limit exceeded",
&format!("OpenCode Go {} is over its {window} limit", model.id),
);
}
}
}
over = over_now;
}
let burst_slice: &[f64] = if burst_on { &history } else { &[] };
let text = if compact {
render::compact_line(&report, burst_slice, colours)
} else {
render::watch_frame(
&report,
&db_path,
rows,
colours,
burst_slice,
interval,
bar_width,
)
};
let update_box = update
.as_ref()
.map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
let status = format!(
"{}refreshing every {interval}s · ctrl-c to quit{}",
ink.dim, ink.reset
);
let frame = match &update_box {
Some(u) => format!("{u}{text}\n{status}"),
None => format!("{text}\n{status}"),
};
prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
let _ = out.flush();
for remaining in (1..interval).rev() {
std::thread::sleep(std::time::Duration::from_secs(1));
let msg = format!(
"\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
ink.dim, ink.reset
);
let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
let _ = out.flush();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(args: &[&str]) -> Vec<OsString> {
std::iter::once(OsString::from("ocuse"))
.chain(args.iter().map(OsString::from))
.collect()
}
fn parse(args: &[&str]) -> cli::Args {
cli::parse_args_from(argv(args), Binary::Ocuse).expect("parses")
}
#[test]
fn json_and_one_shot_leave_the_watch_loop() {
assert!(wants_watch(&parse(&[]))); assert!(wants_watch(&parse(&["-W"])));
assert!(!wants_watch(&parse(&["-1"]))); assert!(!wants_watch(&parse(&["--once"])));
assert!(!wants_watch(&parse(&["--json"]))); }
#[test]
fn compact_frame_switches_on_small_terminals() {
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)); }
#[test]
fn burst_state_derives_from_args_and_config() {
assert_eq!(burst_config(None, false, 40), (false, 0));
assert_eq!(burst_config(Some(30), false, 40), (true, 30));
assert_eq!(burst_config(None, true, 25), (true, 25));
assert_eq!(burst_config(Some(60), true, 25), (true, 60));
assert_eq!(burst_config(None, true, 3), (true, 5));
}
#[test]
fn gated_is_a_shared_flag() {
let a = cli::parse_args_from(argv(&["models", "--gated", "--plan", "go"]), Binary::Ocuse)
.expect("parses");
assert!(a.gated);
assert_eq!(a.subcmd, Some(SubCmd::Models));
assert_eq!(a.plan.as_deref(), Some("go"));
assert!(
!cli::parse_args_from(argv(&["models"]), Binary::Ocuse)
.expect("parses")
.gated
);
}
#[test]
fn no_api_is_an_ocuse_flag() {
assert!(parse(&["-1", "--no-api"]).no_api);
assert!(!parse(&["-1"]).no_api);
assert!(
cli::parse_args_from(argv(&["--no-api"]), Binary::Cmduse).is_err(),
"cmduse rejects --no-api"
);
}
#[test]
fn output_fmt_maps_the_flags() {
let of = |args: &[&str]| output_fmt(&parse(args));
assert!(matches!(of(&[]), Fmt::Table));
assert!(matches!(of(&["--json"]), Fmt::Json));
assert!(matches!(of(&["--csv"]), Fmt::Csv));
}
#[test]
fn period_end_resolves_from_the_reports_own_start() {
let since = window::days_from_civil(2026, 9, 15) * 86_400_000;
let report = window::Report {
period_label: "calendar month".into(),
now_ms: since,
since_ms: since,
period_end_ms: None,
month_inferred: false,
account: None,
five_hour: Default::default(),
weekly: Default::default(),
month: Default::default(),
models: Vec::new(),
};
assert_eq!(
period_end(&report, "month", false),
Some(window::next_month(since))
);
assert_eq!(
period_end(&report, "30d", false),
Some(since + 30 * 86_400_000)
);
assert_eq!(period_end(&report, "all", false), None);
assert_eq!(
period_end(&report, "30d", true),
Some(window::next_month(since))
);
}
#[test]
fn model_id_is_pulled_out_before_parsing() {
let (rest, id) = split_model_id(argv(&["model", "glm-5.3-flash"]));
assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
assert_eq!(
cli::parse_args_from(rest, Binary::Ocuse).unwrap().subcmd,
Some(SubCmd::Model)
);
let (_, id) = split_model_id(argv(&["model"]));
assert!(id.is_none());
let (rest, id) = split_model_id(argv(&["--plan", "go", "model", "glm-5.3-flash"]));
assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
assert!(cli::parse_args_from(rest, Binary::Ocuse).is_ok());
let (_, id) = split_model_id(argv(&["model", "--days", "7", "glm-5.3-flash"]));
assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
let (_, id) = split_model_id(argv(&["daily", "--days", "7"]));
assert!(id.is_none());
}
#[test]
fn ocuse_reaches_shared_flag_parity() {
for args in [
vec!["-1"],
vec!["-W"],
vec!["-p"],
vec!["-i", "30"],
vec!["-w", "20"],
vec!["-b", "40"],
vec!["--json"],
vec!["--csv"],
vec!["--tz", "+05:30"],
vec!["--days", "7"],
vec!["--hours", "6"],
vec!["--since", "2026-01-01T00:00:00Z"],
vec!["--dismiss-update"],
vec!["--gated"],
vec!["config", "set", "interval=10"],
] {
assert!(
cli::parse_args_from(argv(&args), Binary::Ocuse).is_ok(),
"ocuse must accept {args:?}"
);
}
assert!(cli::parse_args_from(argv(&["--local"]), Binary::Ocuse).is_err());
assert!(cli::parse_args_from(argv(&["--gated"]), Binary::Ocuse).is_ok());
}
#[test]
fn ocuse_parses_every_shared_subcommand() {
for (word, want) in [
("daily", SubCmd::Daily),
("days", SubCmd::Daily),
("hourly", SubCmd::Hours),
("hours", SubCmd::Hours),
("model", SubCmd::Model),
("session", SubCmd::Session),
("sessions", SubCmd::Session),
("project", SubCmd::Session),
("statusline", SubCmd::Statusline),
("models", SubCmd::Models),
("plans", SubCmd::Plans),
("mcp", SubCmd::Mcp),
] {
for binary in [Binary::Cmduse, Binary::Ocuse] {
let a = cli::parse_args_from(
std::iter::once(OsString::from("x"))
.chain(std::iter::once(OsString::from(word))),
binary,
)
.unwrap_or_else(|e| panic!("{binary:?} must parse {word}: {e}"));
assert_eq!(a.subcmd.as_ref(), Some(&want), "{binary:?} {word}");
}
}
}
#[test]
fn ocuse_help_carries_the_shared_tokens() {
let c = cli::usage_text(Binary::Cmduse);
let o = cli::usage_text(Binary::Ocuse);
for tok in [
"-1",
"--once",
"-W",
"--watch",
"-p",
"--plain",
"-h",
"--help",
"-V",
"--version",
"--json",
"--csv",
"-i",
"--interval",
"-w",
"--bar-width",
"-b",
"--bursts",
"--days",
"--since",
"--hours",
"--tz",
"--dismiss-update",
"NO_COLOR",
"config set",
] {
if c.contains(tok) {
assert!(o.contains(tok), "ocuse help missing shared token {tok}");
}
}
}
}