1use crate::ocuse::window;
11use crate::ocuse::{build, db, mcp, render, zen};
12use std::io::Write;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15const HELP: &str = "\
16ocuse — OpenCode Go/Zen usage (local, from opencode.db)
17
18USAGE:
19 ocuse watch: live frame, redrawn every 10s (colour + bars)
20 ocuse -1 one-shot dashboard
21 ocuse --json machine-readable projection
22 ocuse model [id] per-model detail (all models when no id)
23 ocuse daily [--days N] spend per day
24 ocuse hourly [--days N] spend per hour
25 ocuse session spend per session (newest first)
26 ocuse plans the Go/Zen catalogue: both plans' limits and rates
27 ocuse statusline one compact line
28 ocuse mcp MCP stdio server (usage/plans/daily/hourly/session/model)
29
30FLAGS:
31 --db <path> opencode store (default $OPENCODE_DB or ~/.local/share/opencode/opencode.db)
32 --plan <id> Go plan to measure against: go (default) or go-plus.
33 No auto-detection: there is no usage API and the local
34 store records no subscription, so the plan is explicit.
35 --days <n> how many days of history to read (default 120)
36 --window <w> report window: month (default, calendar month UTC), all, or <n>d
37 --period-start <date> pin the period start (YYYY-MM-DD, UTC)
38 --infer-anniversary infer the period start from the first Go request instead
39 --rows <n> table rows to print (default 15)
40 --plain no colour (also off for NO_COLOR or a non-tty)
41 --tz <offset> display timezone for day/hour buckets (e.g. +05:30)
42 -h, --help this text
43 -V, --version version
44";
45
46fn parse_day(value: &str) -> Option<i64> {
49 let mut parts = value.split('-');
50 let y: i64 = parts.next()?.parse().ok()?;
51 let m: i64 = parts.next()?.parse().ok()?;
52 let d: i64 = parts.next()?.parse().ok()?;
53 (1..=12).contains(&m).then_some(())?;
54 (1..=31).contains(&d).then_some(())?;
55 Some(window::days_from_civil(y, m, d) * 86_400_000)
56}
57
58fn parse_tz(value: &str) -> Option<i64> {
60 let (sign, rest) = match value.as_bytes().first()? {
61 b'+' => (1, &value[1..]),
62 b'-' => (-1, &value[1..]),
63 _ => return None,
64 };
65 let (hours, minutes) = if let Some((h, m)) = rest.split_once(':') {
66 (h.parse::<i64>().ok()?, m.parse::<i64>().ok()?)
67 } else if rest.len() == 4 {
68 (
69 rest[..2].parse::<i64>().ok()?,
70 rest[2..].parse::<i64>().ok()?,
71 )
72 } else {
73 return None;
74 };
75 (hours <= 14 && minutes < 60).then_some(sign * (hours * 3600 + minutes * 60))
76}
77
78fn colour_enabled(plain: bool) -> bool {
80 use std::io::IsTerminal;
81 !plain
82 && std::env::var("NO_COLOR")
83 .map(|v| v.is_empty())
84 .unwrap_or(true)
85 && std::io::stdout().is_terminal()
86}
87
88fn now_ms() -> i64 {
89 SystemTime::now()
90 .duration_since(UNIX_EPOCH)
91 .map(|d| d.as_millis() as i64)
92 .unwrap_or(0)
93}
94
95fn wants_live_watch(command: &str, one_shot: bool, once: bool, json: bool) -> bool {
100 command == "watch" && !one_shot && !once && !json
101}
102
103pub fn run() {
104 let args: Vec<String> = std::env::args().skip(1).collect();
105 if args.iter().any(|a| a == "-h" || a == "--help") {
106 print!("{HELP}");
107 return;
108 }
109 if args.iter().any(|a| a == "-V" || a == "--version") {
110 println!("ocuse {}", env!("CARGO_PKG_VERSION"));
111 return;
112 }
113
114 let value = |flag: &str| -> Option<String> {
115 args.iter()
116 .position(|a| a == flag)
117 .and_then(|i| args.get(i + 1))
118 .cloned()
119 };
120 let flag = |name: &str| args.iter().any(|a| a == name);
121 let db_path = value("--db").unwrap_or_else(db::default_path);
122 let days: i64 = value("--days").and_then(|v| v.parse().ok()).unwrap_or(120);
123 let rows: usize = value("--rows").and_then(|v| v.parse().ok()).unwrap_or(15);
124 let now = now_ms();
125 let since = now - days * 86_400_000;
126 let tz_secs = match value("--tz") {
130 None => 0,
131 Some(tz) => parse_tz(&tz).unwrap_or_else(|| {
132 eprintln!("ocuse: --tz expects an offset like +05:30 or -08:00");
133 std::process::exit(2);
134 }),
135 };
136 let colour = colour_enabled(flag("--plain"));
137 let window_mode = value("--window").unwrap_or_else(|| "month".into());
138 let period_start = if let Some(day) = value("--period-start").and_then(|v| parse_day(&v)) {
139 Some(day)
140 } else if window_mode == "all" {
141 Some(0)
142 } else if window_mode == "month" {
143 Some(window::calendar_month_start(now))
144 } else if let Some(days) = window_mode
145 .strip_suffix('d')
146 .and_then(|v| v.parse::<i64>().ok())
147 {
148 Some(now - days * 86_400_000)
149 } else {
150 eprintln!("ocuse: --window expects month, all, or <n>d (got {window_mode})");
151 std::process::exit(2);
152 };
153
154 let value_flags = [
157 "--db",
158 "--plan",
159 "--days",
160 "--rows",
161 "--window",
162 "--period-start",
163 "--tz",
164 ];
165 let mut consumed = vec![false; args.len()];
166 for (i, arg) in args.iter().enumerate() {
167 if value_flags.contains(&arg.as_str()) && i + 1 < args.len() {
168 consumed[i + 1] = true;
169 }
170 }
171 let command = args
172 .iter()
173 .enumerate()
174 .find(|(i, a)| !consumed[*i] && !a.starts_with('-'))
175 .map(|(_, a)| a.clone())
176 .unwrap_or_else(|| "watch".to_string());
177
178 if command == "plans" {
180 print!("{}", render::plans_text(colour));
181 return;
182 }
183
184 let catalog = zen::catalog();
187 let plan_id = value("--plan").unwrap_or_else(|| zen::DEFAULT_PLAN.to_string());
188 if catalog.plan(&plan_id).is_none() {
189 eprintln!(
190 "ocuse: --plan expects one of {} (got {plan_id})",
191 catalog.plan_ids().join(", ")
192 );
193 std::process::exit(2);
194 }
195
196 let period_label = if flag("--period-start") {
197 "period start (pinned)".to_string()
198 } else {
199 match window_mode.as_str() {
200 "all" => "all time".to_string(),
201 "month" => "calendar month".to_string(),
202 other => other.to_string(),
203 }
204 };
205 let infer = flag("--infer-anniversary");
206 let period_start = if infer && !flag("--period-start") {
207 None
208 } else {
209 period_start
210 };
211 let read = || match db::read_rows(&db_path, since) {
212 Ok(rows) => rows,
213 Err(error) => {
214 eprintln!("ocuse: {error}");
215 eprintln!("hint: --db <path>, or OPENCODE_DB; the store is created by opencode itself");
216 std::process::exit(1);
217 }
218 };
219
220 match command.as_str() {
221 "statusline" => {
222 let mut report = build(&read(), &catalog, &plan_id, now, period_start);
223 report.period_label = period_label;
224 println!("{}", render::status_line(&report, colour));
225 }
226 "daily" => print!(
227 "{}",
228 render::bucket_text(&read(), now, days, 86_400_000, "day", tz_secs, colour)
229 ),
230 "hourly" => print!(
231 "{}",
232 render::bucket_text(&read(), now, days, 3_600_000, "hour", tz_secs, colour)
233 ),
234 "session" => print!("{}", render::session_text(&read(), 20, colour)),
235 "mcp" => {
236 mcp::run(&db_path, days, tz_secs, false, &plan_id, period_start);
238 }
239 "model" => {
240 let wanted = args
241 .iter()
242 .position(|a| a == "model")
243 .and_then(|i| args.get(i + 1))
244 .filter(|v| !v.starts_with('-'))
245 .cloned();
246 let mut report = build(&read(), &catalog, &plan_id, now, period_start);
247 report.period_label = period_label;
248 match wanted {
249 Some(id) => print!("{}", render::model_text(&report, &id, colour)),
250 None => print!(
251 "{}",
252 render::render_text(&report, &db_path, usize::MAX, colour)
253 ),
254 }
255 }
256 "watch" if wants_live_watch(&command, flag("-1"), flag("--once"), flag("--json")) => {
257 const INTERVAL: u64 = 10;
261 const BURST_SAMPLES: usize = 60;
262 let ink = render::Ink::new(colour);
263 let mut out = std::io::stdout();
264 let mut prev_lines = 0usize;
265 let mut history: Vec<f64> = Vec::new();
266 let mut last_five: Option<f64> = None;
267 loop {
268 let report = build(&read(), &catalog, &plan_id, now_ms(), period_start);
269 let (_, cols) = crate::term_size().unwrap_or((0, 0));
270 let cols = (cols > 0).then_some(cols);
271 let delta = last_five
274 .map(|prev| (report.five_hour.cost - prev).max(0.0))
275 .unwrap_or(0.0);
276 last_five = Some(report.five_hour.cost);
277 history.push(delta);
278 if history.len() > BURST_SAMPLES {
279 history.remove(0);
280 }
281 let status = format!(
282 "{}refreshing every {INTERVAL}s · ctrl-c to quit{}",
283 ink.dim, ink.reset
284 );
285 let frame = format!(
286 "{}\n{status}",
287 render::watch_frame(&report, &db_path, rows, colour, &history, INTERVAL)
288 );
289 prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols);
290 let _ = out.flush();
291 for remaining in (1..INTERVAL).rev() {
292 std::thread::sleep(std::time::Duration::from_secs(1));
293 let msg = format!(
294 "\r\x1b[2K{}refreshing every {INTERVAL}s · next refresh in {remaining}s · ctrl-c to quit{}",
295 ink.dim, ink.reset
296 );
297 let _ = write!(out, "{}", crate::clip_to_width(&msg, cols));
298 let _ = out.flush();
299 }
300 }
301 }
302 _ => {
303 let mut report = build(&read(), &catalog, &plan_id, now, period_start);
304 report.period_label = period_label;
305 if flag("--json") {
306 println!("{}", render::render_json(&report));
307 } else {
308 print!("{}", render::render_text(&report, &db_path, rows, colour));
309 }
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::wants_live_watch;
317
318 #[test]
319 fn json_and_one_shot_leave_the_watch_loop() {
320 assert!(wants_live_watch("watch", false, false, false));
321 assert!(!wants_live_watch("watch", true, false, false)); assert!(!wants_live_watch("watch", false, true, false)); assert!(!wants_live_watch("watch", false, false, true)); assert!(!wants_live_watch("usage", false, false, false));
325 }
326}