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