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