Skip to main content

cmd_usage/ocuse/
cli.rs

1// ocuse — OpenCode Go/Zen usage, same CLI shape as cmduse.
2//
3// Data: opencode's own store (read-only) plus the generated docs catalogue.
4// There is no OpenCode usage API to ask (see core/zen.json's header and the
5// README), so this reports what actually ran on this machine, priced by
6// opencode itself, against the limits the docs publish.
7//
8// Lives in the library (not the bin) so `ocuse` and its `ocusedev` twin share
9// one entry point: both bins are `fn main() { cmd_usage::ocuse::run(); }`.
10use 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
46/// `YYYY-MM-DD` -> epoch ms at 00:00 UTC, via the same civil helpers the window
47/// module uses (no date dependency).
48fn 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
58/// `+05:30` / `-08:00` -> seconds east of UTC. Also accepts `+0530`.
59fn 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
78/// Colour follows cmduse: off for `--plain`, `NO_COLOR`, or a non-tty stdout.
79fn 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
95/// The live watch loop is the default command, but `-1` / `--once` / `--json`
96/// all want a single frame printed and an exit. A bare `--json` used to fall
97/// into the loop and print no JSON at all: the JSON branch lives in the
98/// catch-all arm, and `watch` is what an absent command word resolves to.
99fn 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    // The period: explicit start > window mode > calendar month. Inference from
127    // the first Go request is opt-in because it guesses badly on sparse history
128    // (an account whose usage starts mid-cycle infers the wrong anniversary).
129    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    // First non-flag argument that is not a flag's value is the command
155    // (`--window all daily` must mean `daily`, not `all`).
156    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    // `plans` needs no database at all.
179    if command == "plans" {
180        print!("{}", render::plans_text(colour));
181        return;
182    }
183
184    // Which Go plan the allowances are measured against: explicit, because the
185    // store records no subscription and there is no usage API to ask.
186    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            // stdio JSON-RPC; stdout carries protocol only, so nothing else may print.
237            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            // Live frame, cmduse's redraw contract: the frame's last line carries
258            // no newline, so the cursor parks there and the countdown can rewrite
259            // that one line each second instead of repainting the whole frame.
260            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                // 5-hour spend only rises between refreshes, so the delta is the
272                // burst; a sparkline of the cumulative figure would just stay full.
273                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)); // -1
322        assert!(!wants_live_watch("watch", false, true, false)); // --once
323        assert!(!wants_live_watch("watch", false, false, true)); // --json
324        assert!(!wants_live_watch("usage", false, false, false));
325    }
326}