Skip to main content

cmd_usage/
lib.rs

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