Skip to main content

cmd_usage/
lib.rs

1mod api;
2pub mod cli;
3mod config;
4mod mcp;
5mod notify;
6// OpenCode Go/Zen half (the `ocuse` bin): local usage from opencode.db plus the
7// docs catalogue in core/zen.json. Independent of the CommandCode code paths.
8pub mod ocuse;
9// Volcengine ARK Agent Plan half (the `arkuse` bin): the control plane's AFP
10// meters through arkcli, plus opencode's `agent-plan` rows and core/ark.json.
11pub mod arkuse;
12mod paths;
13mod render;
14mod report_render;
15mod reports;
16mod snapshot;
17// The other agent CLIs on this machine (`pi`, `dsh`), as usage sources: ocuse
18// counts the Go traffic they ran, not only opencode's own.
19mod stores;
20mod update_check;
21
22#[cfg(test)]
23mod cli_tests;
24
25#[cfg(test)]
26mod config_tests;
27
28#[cfg(test)]
29mod mcp_tests;
30
31#[cfg(test)]
32mod render_tests;
33
34#[cfg(test)]
35mod main_tests;
36
37use std::io::{IsTerminal, Write};
38
39use crate::render::{BOLD, DIM, RESET};
40
41pub fn run() {
42    let args = cli::parse_args(cli::Binary::Cmduse);
43    if args.help {
44        cli::usage(cli::Binary::Cmduse);
45        return;
46    }
47
48    if let Some(cs) = args.config_set {
49        if let Err(e) = config::set(cli::Binary::Cmduse, &cs) {
50            eprintln!("error: {e}");
51            std::process::exit(1);
52        }
53        return;
54    }
55
56    if args.dismiss_update {
57        match update_check::latest_newer() {
58            Ok(Some(v)) => match config::set_dismissed(cli::Binary::Cmduse, &v) {
59                Ok(()) => println!("dismissed update notice for {v}"),
60                Err(e) => {
61                    eprintln!("error: {e}");
62                    std::process::exit(1);
63                }
64            },
65            Ok(None) => println!("cmduse is up to date ({})", env!("CARGO_PKG_VERSION")),
66            Err(e) => {
67                eprintln!("error: {e}");
68                std::process::exit(1);
69            }
70        }
71        return;
72    }
73
74    if let Some(cli::SubCmd::Mcp) = args.subcmd {
75        mcp::run();
76        return;
77    }
78
79    let fmt = output_fmt(&args);
80    let colors = !args.plain && render::color_enabled();
81    let tz = args.tz.unwrap_or(0);
82
83    // offline local reports — no API, no key needed
84    match args.subcmd {
85        Some(cli::SubCmd::Daily) => {
86            match daily_output(args.last, tz, args.local, fmt) {
87                Ok(text) => print!("{text}"),
88                Err(e) => {
89                    eprintln!("error: {e}");
90                    std::process::exit(1);
91                }
92            }
93            return;
94        }
95        Some(cli::SubCmd::Hours) => {
96            match hourly_output(args.hours, tz, args.local, fmt) {
97                Ok(text) => print!("{text}"),
98                Err(e) => {
99                    eprintln!("error: {e}");
100                    std::process::exit(1);
101                }
102            }
103            return;
104        }
105        Some(cli::SubCmd::Model) => {
106            print!(
107                "{}",
108                match args.model_id.as_deref() {
109                    Some(id) =>
110                        model_detail_output(id, args.since.as_deref(), args.last, tz, fmt, colors),
111                    None => model_output(
112                        args.since.as_deref(),
113                        args.last,
114                        tz,
115                        fmt,
116                        colors,
117                        rows_cap(&args, usize::MAX)
118                    ),
119                }
120            );
121            return;
122        }
123        Some(cli::SubCmd::Session) => {
124            print!(
125                "{}",
126                session_output(
127                    args.since.as_deref(),
128                    args.last,
129                    tz,
130                    fmt,
131                    colors,
132                    rows_cap(&args, 20)
133                )
134            );
135            return;
136        }
137        Some(cli::SubCmd::Statusline) => {
138            statusline_cmd(&args);
139            return;
140        }
141        Some(cli::SubCmd::Models) => {
142            models_cmd(&args);
143            return;
144        }
145        Some(cli::SubCmd::Plans) => {
146            plans_cmd(&args);
147            return;
148        }
149        // handled (and served) before this match; unreachable
150        Some(cli::SubCmd::Mcp) => {}
151        None => {}
152    }
153
154    let cfg = config::load(cli::Binary::Cmduse);
155    let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
156    let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
157    // spend-burst sparkline is opt-in: -b/--bursts on the CLI turns it on for
158    // this run; otherwise it follows the config flag (default off).
159    let burst_on = args.bursts.is_some() || cfg.burst_enabled;
160    let burst_cap = if burst_on {
161        args.bursts
162            .or(Some(cfg.burst_samples))
163            .unwrap_or(40)
164            .clamp(5, 240)
165    } else {
166        0
167    };
168    let notify_on_cap = cfg.notify_on_cap;
169
170    // Update check before any output. Watch mode draws the notice inside the
171    // redrawn frame (an eprintln sits outside the in-place UI and scrolls
172    // away); one-shot has no frame, so it goes to stderr. Report subcommands
173    // returned earlier, so this only covers the dashboard.
174    let update = update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
175
176    if args.once {
177        if let Some(msg) = &update {
178            eprintln!("{msg}");
179        }
180        let s = snapshot::snapshot(false, render::color_enabled());
181        if args.json {
182            println!("{}", render::render_json(&s));
183        } else if args.plain || !render::color_enabled() {
184            print!("{}", render::render_plain(&s));
185        } else {
186            print!("{}", render::render(&s, bar_width));
187        }
188        return;
189    }
190
191    // live mode: true in-place redraw. Frame's last line = status line,
192    // drawn WITHOUT trailing newline so the cursor stays on it. Spinner
193    // and countdown rewrite that line in place. No scroll, no drift.
194    //
195    // Terminal size is re-queried every refresh: on a window too small for
196    // the full dashboard we swap to a compact single-line frame instead of
197    // exiting, so shrinking/widening mid-run follows live (term_size is NOT
198    // cached).
199    let stdout = std::io::stdout();
200    let mut out = stdout.lock();
201    let mut prev_lines = 0usize;
202    // One-shot guards ANSI with color_enabled(); the watch path must too, or a
203    // pipe (e.g. `cmduse | tee`) leaks SGR. Reuse the same decision.
204    let use_color = render::color_enabled();
205    // deltas ($ per refresh) feed the spend-burst sparkline. Session-only:
206    // no disk persistence — a fresh run shows a fresh trend, never old data.
207    let mut history: Vec<f64> = Vec::new();
208    let mut history_used: Vec<f64> = Vec::new(); // raw cumulative 5h spend
209                                                 // cap-hit notifications: fire once on the rising edge into "exceeded"
210    let mut was_exceeded_5h = false;
211    let mut was_exceeded_wk = false;
212    // Only the live dashboard wants the /dev/tty spinner; a piped run must not
213    // paint the terminal it was spawned from.
214    let spinner = !args.plain && std::io::stdout().is_terminal();
215    loop {
216        let s = snapshot::snapshot(spinner, use_color && !args.plain);
217        let (rows, cols) = term_size().unwrap_or((0, 0));
218        // <8x4 is not a usable dashboard even in compact form: keep drawing
219        // the compact line anyway (clip keeps it stable), no hard exit.
220        let compact = cols > 0 && (rows < 16 || cols < 40);
221        // track 5-hour spend DELTA between refreshes — cumulative spend is
222        // monotonic (sparkline of it is all-full); deltas show burst vs idle
223        let used = s
224            .credits
225            .window_limits
226            .five_hour
227            .as_ref()
228            .map(|w| w.used)
229            .unwrap_or(0.0);
230        let delta = history_used
231            .last()
232            .map(|prev| (used - prev).max(0.0))
233            .unwrap_or(0.0);
234        history_used.push(used);
235        if history_used.len() > 60 {
236            history_used.remove(0);
237        }
238        if burst_on {
239            history.push(delta);
240            if history.len() > burst_cap {
241                history.remove(0);
242            }
243        }
244        if notify_on_cap {
245            let ex5 = s
246                .credits
247                .window_limits
248                .five_hour
249                .as_ref()
250                .map(|w| w.exceeded)
251                .unwrap_or(false);
252            let exwk = s
253                .credits
254                .window_limits
255                .weekly
256                .as_ref()
257                .map(|w| w.exceeded)
258                .unwrap_or(false);
259            if ex5 && !was_exceeded_5h {
260                notify::cap(
261                    "cmduse: limit exceeded",
262                    "Command Code 5-hour window is over its cap",
263                );
264            }
265            if exwk && !was_exceeded_wk {
266                notify::cap(
267                    "cmduse: limit exceeded",
268                    "Command Code weekly window is over its cap",
269                );
270            }
271            was_exceeded_5h = ex5;
272            was_exceeded_wk = exwk;
273        }
274        let text = if compact {
275            compact_dashboard(
276                &s,
277                if burst_on { &history } else { &[] },
278                use_color && !args.plain,
279            )
280        } else if args.plain || !use_color {
281            render::render_plain(&s)
282        } else {
283            render::render(&s, bar_width)
284        };
285        let spark = if compact || !burst_on {
286            String::new()
287        } else if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
288            if use_color && !args.plain {
289                format!(
290                    "{DIM}spend bursts ({}s samples){RESET} {}\n",
291                    interval,
292                    render::sparkline(&history)
293                )
294            } else {
295                format!(
296                    "spend bursts ({}s samples) {}\n",
297                    interval,
298                    render::sparkline(&history)
299                )
300            }
301        } else {
302            String::new() // idle (all-zero deltas) → no row, no flat-line noise
303        };
304        let update_box = update
305            .as_ref()
306            .map(|m| render::update_box(m, (cols > 0).then_some(cols), use_color && !args.plain));
307        let status_line = if use_color && !args.plain {
308            format!("{DIM}refreshing every {interval}s · ctrl-c to quit{RESET}")
309        } else {
310            format!("refreshing every {interval}s · ctrl-c to quit")
311        };
312        let frame = match &update_box {
313            Some(u) => format!("{u}{text}{spark}{status_line}"),
314            None => format!("{text}{spark}{status_line}"),
315        };
316        prev_lines = redraw_frame(&mut out, &frame, prev_lines, (cols > 0).then_some(cols));
317        out.flush().ok();
318        // countdown: rewrite just the status line each second (cursor already on it)
319        for remaining in (1..interval).rev() {
320            std::thread::sleep(std::time::Duration::from_secs(1));
321            let msg = if use_color && !args.plain {
322                format!(
323                    "\r\x1b[2K{DIM}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{RESET}"
324                )
325            } else {
326                format!("\r\x1b[2Krefreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit")
327            };
328            write!(out, "{}", clip_to_width(&msg, (cols > 0).then_some(cols))).ok();
329            out.flush().ok();
330        }
331    }
332}
333
334/// Minimal one-line dashboard for windows too small for the full frame:
335/// plan + remaining monthly credits (clip handles the rest). Stays stable
336/// where the full 14-row dashboard would scroll every refresh. When the
337/// spend-burst sparkline is active it is inlined at the end of the same line
338/// (clip trims it first on very narrow windows).
339fn compact_dashboard(s: &crate::render::Snapshot, history: &[f64], colors: bool) -> String {
340    use crate::render::plan_name;
341    if let Some(e) = &s.err {
342        if colors {
343            return format!("{BOLD}error{RESET} {e}\n");
344        } else {
345            return format!("error {e}\n");
346        }
347    }
348    let cap = cmduse_core::plan_monthly_cap(&s.sub.plan_id);
349    let cap_txt = cap.map(cmduse_core::money).unwrap_or_else(|| "-".into());
350    let rem_txt = cmduse_core::money(s.credits.credits.monthly_credits);
351    let h5 = s
352        .credits
353        .window_limits
354        .five_hour
355        .as_ref()
356        .map(|w| cmduse_core::money(w.used))
357        .unwrap_or_else(|| "-".into());
358    let status = if s.sub.status.is_empty() {
359        String::new()
360    } else {
361        format!(" · {}", s.sub.status)
362    };
363    let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
364        if colors {
365            format!(" {DIM}{}{RESET}", crate::render::sparkline(history))
366        } else {
367            format!(" {}", crate::render::sparkline(history))
368        }
369    } else {
370        String::new()
371    };
372    if colors {
373        format!(
374            "{BOLD}{}{RESET} {rem_txt}/{cap_txt} · 5h {h5}{status}{spark}\n",
375            plan_name(&s.sub.plan_id)
376        )
377    } else {
378        format!(
379            "{} {rem_txt}/{cap_txt} · 5h {h5}{status}{spark}\n",
380            plan_name(&s.sub.plan_id)
381        )
382    }
383}
384
385/// Terminal (rows, cols) from `stty size`. None when not a tty or the probe
386/// fails — redraw then skips clipping (safe: non-tty can't wrap).
387/// Deliberately UNcached: the watch loop re-queries every refresh so resizing
388/// mid-run switches between the full and compact dashboards live.
389pub fn term_size() -> Option<(usize, usize)> {
390    let out = std::process::Command::new("sh")
391        .arg("-c")
392        .arg("stty size < /dev/tty")
393        .output()
394        .ok()?;
395    if !out.status.success() {
396        return None;
397    }
398    let text = String::from_utf8_lossy(&out.stdout);
399    let mut it = text.split_whitespace();
400    let rows = it.next()?.parse().ok()?;
401    let cols = it.next()?.parse().ok()?;
402    Some((rows, cols))
403}
404
405/// Keep a frame line from auto-wrapping: count visible columns ignoring SGR
406/// escapes and control chars, and drop trailing visible chars that would
407/// push past `cols`. Appends a reset so a truncated color run can't leak.
408pub fn clip_to_width(line: &str, cols: Option<usize>) -> String {
409    let Some(cols) = cols else { return line.into() };
410    if cols == 0 {
411        return String::new();
412    }
413    let mut vis = 0usize;
414    let mut cs = line.chars().peekable();
415    while let Some(&c) = cs.peek() {
416        match c {
417            '\x1b' => {
418                cs.next();
419                for e in cs.by_ref() {
420                    if e.is_ascii_alphabetic() {
421                        break;
422                    }
423                }
424            }
425            c if (c as u32) < 0x20 => {
426                cs.next(); // \r etc: zero width
427            }
428            _ => {
429                vis += 1;
430                cs.next();
431            }
432        }
433    }
434    if vis <= cols {
435        return line.into();
436    }
437    let mut out = String::new();
438    let mut kept = 0usize;
439    let mut cs = line.chars();
440    while let Some(c) = cs.next() {
441        match c {
442            '\x1b' => {
443                out.push(c);
444                for e in cs.by_ref() {
445                    out.push(e);
446                    if e.is_ascii_alphabetic() {
447                        break;
448                    }
449                }
450            }
451            '\r' => out.push(c), // carriage return is meaningful: keep it
452            c if (c as u32) < 0x20 => {}
453            _ => {
454                if kept < cols {
455                    out.push(c);
456                    kept += 1;
457                }
458            }
459        }
460    }
461    if out.ends_with("\x1b[0m") {
462        out
463    } else {
464        out + "\x1b[0m"
465    }
466}
467
468/// In-place terminal redraw of one frame. Assumes the cursor parks on the
469/// previous frame's last line (no trailing newline). Returns the new frame's
470/// line count so the caller can pass it back as `prev_lines`.
471pub fn redraw_frame(
472    out: &mut impl Write,
473    frame: &str,
474    prev_lines: usize,
475    cols: Option<usize>,
476) -> usize {
477    if prev_lines > 0 {
478        // move cursor up to top of previous frame (we're ON its last line)
479        write!(out, "\x1b[{}F", prev_lines - 1).ok();
480    }
481    let lines: Vec<&str> = frame.lines().collect();
482    let n = lines.len();
483    for (i, line) in lines.iter().enumerate() {
484        let line = clip_to_width(line, cols);
485        if i + 1 < n {
486            write!(out, "\x1b[2K\r{line}\n").ok();
487        } else {
488            // last line: clear and write, NO newline — cursor stays here
489            write!(out, "\x1b[2K\r{line}").ok();
490        }
491    }
492    if n < prev_lines {
493        // new frame shorter than old (error frame): clear the stale rows
494        // below the new frame WITHOUT touching the frame we just wrote.
495        // First move down one row (no clear) so the frame's last line
496        // survives, then clear-and-advance each remaining stale row,
497        // clear the old bottom row, and step back up to the new bottom.
498        // Writing \n at the very bottom row would scroll and desync —
499        // clear in place with \x1b[2K\x1b[1B instead.
500        write!(out, "\x1b[1B").ok();
501        for _ in (n + 1)..prev_lines {
502            write!(out, "\x1b[2K\x1b[1B").ok();
503        }
504        write!(out, "\x1b[2K").ok();
505        write!(out, "\x1b[{}F", prev_lines - n).ok();
506    }
507    n
508}
509
510fn models_cmd(args: &cli::Args) {
511    if args.gated {
512        if let Some(days) = cmduse_core::gate_age_days(cmduse_core::dates::now_secs()) {
513            if days > 30 {
514                eprintln!(
515                    "warning: gating snapshot is {days}d old (CLI {}) — run `bun run extract` from the repo root",
516                    cmduse_core::GATE_CLI_VERSION
517                );
518            }
519        }
520    }
521    let key = match api::api_key() {
522        Ok(k) => k,
523        Err(e) => {
524            eprintln!("error: {e}");
525            std::process::exit(1);
526        }
527    };
528    // --gated needs the plan + credits to know what the account may use.
529    let (plan_id, unlocked) = if args.gated {
530        match (api::subscriptions(&key), api::credits(&key)) {
531            (Ok(s), Ok(c)) => (
532                s.plan_id,
533                c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
534            ),
535            _ => (String::new(), true), // unknown → show everything
536        }
537    } else {
538        (String::new(), true)
539    };
540    match api::models(&key) {
541        Ok(list) => {
542            if args.json {
543                for m in &list {
544                    if args.gated {
545                        // annotate every model with the gating verdict
546                        let (allowed, reason) = cmduse_core::gate(&m.id, &plan_id, unlocked);
547                        let line = serde_json::json!({
548                            "id": m.id,
549                            "name": m.name,
550                            "context_length": m.context_length,
551                            "owned_by": m.owned_by,
552                            "allowed": allowed,
553                            "reason": reason,
554                        });
555                        println!("{line}");
556                    } else {
557                        let line = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
558                        println!("{line}");
559                    }
560                }
561                return;
562            }
563            let list: Vec<_> = if args.gated {
564                list.into_iter()
565                    .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
566                    .collect()
567            } else {
568                list
569            };
570            if list.is_empty() {
571                println!("no models returned");
572                return;
573            }
574            let mut rows: Vec<_> = list
575                .iter()
576                .map(|m| {
577                    let ctx = m
578                        .context_length
579                        .map(|n| format!("{} ctx", render::compact(n)))
580                        .unwrap_or_else(|| "-".into());
581                    let name = m.name.as_deref().unwrap_or(&m.id);
582                    (m.id.as_str(), name, ctx)
583                })
584                .collect();
585            rows.sort_by(|a, b| a.1.cmp(b.1));
586            let more = rows.len().saturating_sub(rows_cap(args, usize::MAX));
587            for (id, name, ctx) in rows.into_iter().take(rows_cap(args, usize::MAX)) {
588                println!("{name:<36} {ctx:<10} {id}");
589            }
590            if more > 0 {
591                println!("… {more} more (--rows N to widen)");
592            }
593        }
594        Err(e) => {
595            eprintln!("error: {e}");
596            std::process::exit(1);
597        }
598    }
599}
600
601fn plans_cmd(args: &cli::Args) {
602    // current plan id is best-effort: without a key/network, show no mark.
603    let current = api::api_key()
604        .ok()
605        .and_then(|k| api::subscriptions(&k).ok())
606        .map(|s| s.plan_id)
607        .unwrap_or_default();
608    if args.json {
609        print!("{}", render::plans_json(&current));
610    } else {
611        print!("{}", render::plans_table(&current, render::color_enabled()));
612    }
613}
614
615// --- shared tool/report bodies (CLI subcommands AND `cmduse mcp` tools) ---
616// One implementation each; the MCP layer is a thin adapter over these.
617
618/// One-shot dashboard, plain text (no SGR) — the MCP `usage` tool body.
619pub(crate) fn usage_output() -> String {
620    render::render_plain(&snapshot::snapshot(false, render::color_enabled()))
621}
622
623/// Plan comparison table. `colors` off for MCP, `render::color_enabled()` for
624/// the CLI. Current-plan mark is best-effort (no key/network → no mark).
625pub(crate) fn plans_output(colors: bool) -> String {
626    let current = api::api_key()
627        .ok()
628        .and_then(|k| api::subscriptions(&k).ok())
629        .map(|s| s.plan_id)
630        .unwrap_or_default();
631    render::plans_table(&current, colors)
632}
633
634/// Gated live model list as aligned text. `Err` = API/network failure.
635pub(crate) fn models_output(gated: bool) -> Result<String, String> {
636    let key = api::api_key().map_err(|e| e.to_string())?;
637    let (plan_id, unlocked) = if gated {
638        match (api::subscriptions(&key), api::credits(&key)) {
639            (Ok(s), Ok(c)) => (
640                s.plan_id,
641                c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
642            ),
643            _ => (String::new(), true), // unknown → show everything
644        }
645    } else {
646        (String::new(), true)
647    };
648    let list = api::models(&key).map_err(|e| e.to_string())?;
649    let list: Vec<_> = if gated {
650        list.into_iter()
651            .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
652            .collect()
653    } else {
654        list
655    };
656    if list.is_empty() {
657        return Ok("no models returned\n".into());
658    }
659    let mut rows: Vec<_> = list
660        .iter()
661        .map(|m| {
662            let ctx = m
663                .context_length
664                .map(|n| format!("{} ctx", render::compact(n)))
665                .unwrap_or_else(|| "-".into());
666            let name = m.name.as_deref().unwrap_or(&m.id);
667            (m.id.as_str(), name, ctx)
668        })
669        .collect();
670    rows.sort_by(|a, b| a.1.cmp(b.1));
671    let mut out = String::new();
672    for (id, name, ctx) in rows {
673        out.push_str(&format!("{name:<36} {ctx:<10} {id}\n"));
674    }
675    Ok(out)
676}
677
678/// Account-wide daily report; local CLI-log fallback when no key/--local.
679pub(crate) fn daily_output(
680    days: Option<usize>,
681    tz: i64,
682    local: bool,
683    fmt: report_render::Fmt,
684) -> Result<String, String> {
685    let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
686    // account-wide (all harnesses) via API when key available; local fallback
687    let data_source = if local {
688        None
689    } else {
690        api::api_key()
691            .ok()
692            .map(|k| reports::load_account_daily(days.unwrap_or(7), &k, tz))
693    };
694    match data_source {
695        Some(Ok(by_day)) => {
696            let total = reports::sum_days(&by_day);
697            Ok(report_render::table(
698                "account",
699                "all harnesses",
700                &by_day,
701                &total,
702                None,
703                fmt,
704                false,
705            ))
706        }
707        Some(Err(e)) => Err(e.to_string()),
708        None => {
709            let d = reports::load_local(tz);
710            // The extra clients are named, not implied: the figures below now
711            // include a `pi`/`dsh` session on a `command-code*` provider.
712            let subtitle = if tz != 0 {
713                format!(
714                    "offline, ~/.commandcode/projects + pi, dsh, UTC{}",
715                    tz_suffix(tz)
716                )
717            } else {
718                "offline, ~/.commandcode/projects + pi, dsh".to_string()
719            };
720            Ok(report_render::table(
721                "local", &subtitle, &d.by_day, &d.total, days, fmt, false,
722            ))
723        }
724    }
725}
726
727/// Hourly report; local CLI sessions with --local, else all harnesses.
728pub(crate) fn hourly_output(
729    hours: Option<usize>,
730    tz: i64,
731    local: bool,
732    fmt: report_render::Fmt,
733) -> Result<String, String> {
734    let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
735    let rows = if local {
736        reports::load_local_hourly(hours.unwrap_or(24), tz)
737    } else {
738        let key = api::api_key().map_err(|e| e.to_string())?;
739        reports::load_account_hourly(hours.unwrap_or(24), &key, tz).map_err(|e| e.to_string())?
740    };
741    let subtitle = if local {
742        if tz != 0 {
743            format!("local, CLI sessions, UTC{}", tz_suffix(tz))
744        } else {
745            "local, CLI sessions".to_string()
746        }
747    } else if tz != 0 {
748        format!("all harnesses, UTC{}", tz_suffix(tz))
749    } else {
750        "all harnesses, UTC".to_string()
751    };
752    Ok(report_render::hourly_table(&rows, fmt, &subtitle, false))
753}
754
755/// The local-report window bound, in epoch MILLISECONDS — the unit both
756/// `parse_iso_utc` and `UsageRecord::timestamp` use. ISO `since` wins; else
757/// `days` back from now. (The `days` branch used to emit SECONDS here while the
758/// records parse to ms, so `--days N` compared ms against seconds and silently
759/// kept every record — the filter was a no-op.)
760fn local_since(since_iso: Option<&str>, days: Option<usize>) -> Option<f64> {
761    match since_iso {
762        Some(iso) => cmduse_core::dates::parse_iso_utc(iso),
763        None => {
764            days.map(|d| (cmduse_core::dates::now_secs() as f64 - (d as f64) * 86_400.0) * 1000.0)
765        }
766    }
767}
768
769/// Local usage by model (offline CLI logs). `since_iso` is echoed into `--json`
770/// output as-is.
771pub(crate) fn model_output(
772    since_iso: Option<&str>,
773    days: Option<usize>,
774    tz: i64,
775    fmt: report_render::Fmt,
776    colors: bool,
777    rows: usize,
778) -> String {
779    let d = reports::load_local_since(tz, local_since(since_iso, days));
780    report_render::model_table(&d.by_model, fmt, colors, since_iso, rows)
781}
782
783/// One model's row from the local logs (`model <id>`), same window flags as the
784/// table it sits in.
785pub(crate) fn model_detail_output(
786    id: &str,
787    since_iso: Option<&str>,
788    days: Option<usize>,
789    tz: i64,
790    fmt: report_render::Fmt,
791    colors: bool,
792) -> String {
793    let d = reports::load_local_since(tz, local_since(since_iso, days));
794    report_render::model_detail(&d.by_model, id, fmt, colors, since_iso)
795}
796
797/// Local usage by project/session (offline CLI logs), same window as `model`.
798pub(crate) fn session_output(
799    since_iso: Option<&str>,
800    days: Option<usize>,
801    tz: i64,
802    fmt: report_render::Fmt,
803    colors: bool,
804    rows: usize,
805) -> String {
806    let d = reports::load_local_since(tz, local_since(since_iso, days));
807    report_render::project_table(&d.by_project, fmt, colors, rows)
808}
809
810/// Table rows to print, resolved against this command's own default. `--rows 0`
811/// means every row; absent falls back to `default`. Capping is a display concern
812/// only — the machine formats always carry the full set.
813fn rows_cap(args: &cli::Args, default: usize) -> usize {
814    args.rows_cap(default)
815}
816
817/// Report output format. `--json`/`--csv` are mutually exclusive (parse-guarded).
818fn output_fmt(args: &cli::Args) -> report_render::Fmt {
819    if args.json {
820        report_render::Fmt::Json
821    } else if args.csv {
822        report_render::Fmt::Csv
823    } else {
824        report_render::Fmt::Table
825    }
826}
827
828/// The statusline's machine view. The window names are the family's
829/// (`*Spent`/`*Cap`, which `ocuse` and `arkuse` already used): CommandCode's
830/// month is a credit pool, so its spent half is the pool minus what the account
831/// reports left, clamped at zero. `fiveHourUsed`/`weeklyUsed`/`monthlyRemaining`
832/// ride along as aliases so an existing prompt keeps working.
833fn statusline_json(
834    plan: &str,
835    month_cap: f64,
836    month_remaining: f64,
837    five_hour: (f64, f64),
838    weekly: (f64, f64),
839) -> serde_json::Value {
840    let (h5u, h5c) = five_hour;
841    let (wku, wkc) = weekly;
842    serde_json::json!({
843        "plan": plan,
844        "monthSpent": (month_cap - month_remaining).max(0.0),
845        "monthCap": month_cap,
846        "fiveHourSpent": h5u,
847        "fiveHourCap": h5c,
848        "weeklySpent": wku,
849        "weeklyCap": wkc,
850        "monthlyRemaining": month_remaining,
851        "monthlyCap": month_cap,
852        "fiveHourUsed": h5u,
853        "weeklyUsed": wku,
854    })
855}
856
857fn statusline_cmd(args: &cli::Args) {
858    let cfg = config::load(cli::Binary::Cmduse);
859    let s = snapshot::snapshot(false, render::color_enabled());
860    let plan = render::plan_name(&s.sub.plan_id);
861    let cap = render::plan_monthly_cap(&s.sub.plan_id).unwrap_or(0.0);
862    if let Some(e) = &s.err {
863        println!("cmduse: {e}");
864        std::process::exit(1);
865    }
866    if args.json {
867        let w = |o: &Option<api::Window>| -> (f64, f64) {
868            (
869                o.as_ref().map(|w| w.used).unwrap_or(0.0),
870                o.as_ref().map(|w| w.cap).unwrap_or(0.0),
871            )
872        };
873        let out = statusline_json(
874            plan,
875            cap,
876            s.credits.credits.monthly_credits,
877            w(&s.credits.window_limits.five_hour),
878            w(&s.credits.window_limits.weekly),
879        );
880        print!("{out}");
881        return;
882    }
883    let h5 = s
884        .credits
885        .window_limits
886        .five_hour
887        .as_ref()
888        .map(|w| (w.used, w.cap));
889    let wk = s
890        .credits
891        .window_limits
892        .weekly
893        .as_ref()
894        .map(|w| (w.used, w.cap));
895    // pace ETAs for the {5h_eta}/{wk_eta} placeholders
896    let now = s.now;
897    let eta_of = |w: &Option<api::Window>, dur: u64| -> Option<String> {
898        let w = w.as_ref()?;
899        let secs = cmduse_core::pace_eta(w.reset_at, dur, w.used, w.cap, now)?;
900        Some(format!(
901            "on pace to hit cap in {}",
902            cmduse_core::duration(secs as u64)
903        ))
904    };
905    let h5_eta = eta_of(
906        &s.credits.window_limits.five_hour,
907        cmduse_core::FIVE_HOUR_SECS,
908    );
909    let wk_eta = eta_of(&s.credits.window_limits.weekly, cmduse_core::WEEKLY_SECS);
910    let d = report_render::StatusData {
911        plan,
912        monthly_remaining: s.credits.credits.monthly_credits,
913        monthly_cap: cap,
914        five_hour: &h5,
915        weekly: &wk,
916        five_hour_eta: h5_eta,
917        weekly_eta: wk_eta,
918        bar_width: cfg.bar_width.min(30),
919        colors: cfg.statusline_colors,
920        ascii: cfg.statusline_ascii,
921    };
922    print!(
923        "{}",
924        report_render::render_statusline(&cfg.statusline_template, &d)
925    );
926}