1mod api;
2pub mod cli;
3mod config;
4mod mcp;
5mod notify;
6pub mod ocuse;
9mod paths;
10mod render;
11mod report_render;
12mod reports;
13mod snapshot;
14mod update_check;
15
16#[cfg(test)]
17mod cli_tests;
18
19#[cfg(test)]
20mod config_tests;
21
22#[cfg(test)]
23mod mcp_tests;
24
25#[cfg(test)]
26mod render_tests;
27
28#[cfg(test)]
29mod main_tests;
30
31use std::io::{IsTerminal, Write};
32
33use crate::render::{BOLD, DIM, RESET};
34
35pub fn run() {
36 let args = cli::parse_args(cli::Binary::Cmduse);
37 if args.help {
38 cli::usage(cli::Binary::Cmduse);
39 return;
40 }
41
42 if let Some(cs) = args.config_set {
43 if let Err(e) = config::set(cli::Binary::Cmduse, &cs) {
44 eprintln!("error: {e}");
45 std::process::exit(1);
46 }
47 return;
48 }
49
50 if args.dismiss_update {
51 match update_check::latest_newer() {
52 Ok(Some(v)) => match config::set_dismissed(cli::Binary::Cmduse, &v) {
53 Ok(()) => println!("dismissed update notice for {v}"),
54 Err(e) => {
55 eprintln!("error: {e}");
56 std::process::exit(1);
57 }
58 },
59 Ok(None) => println!("cmduse is up to date ({})", env!("CARGO_PKG_VERSION")),
60 Err(e) => {
61 eprintln!("error: {e}");
62 std::process::exit(1);
63 }
64 }
65 return;
66 }
67
68 if let Some(cli::SubCmd::Mcp) = args.subcmd {
69 mcp::run();
70 return;
71 }
72
73 let fmt = output_fmt(&args);
74 let colors = !args.plain && render::color_enabled();
75 let tz = args.tz.unwrap_or(0);
76
77 match args.subcmd {
79 Some(cli::SubCmd::Daily) => {
80 match daily_output(args.last, tz, args.local, fmt) {
81 Ok(text) => print!("{text}"),
82 Err(e) => {
83 eprintln!("error: {e}");
84 std::process::exit(1);
85 }
86 }
87 return;
88 }
89 Some(cli::SubCmd::Hours) => {
90 match hourly_output(args.hours, tz, args.local, fmt) {
91 Ok(text) => print!("{text}"),
92 Err(e) => {
93 eprintln!("error: {e}");
94 std::process::exit(1);
95 }
96 }
97 return;
98 }
99 Some(cli::SubCmd::Model) => {
100 print!(
101 "{}",
102 model_output(args.since.as_deref(), args.last, tz, fmt, colors)
103 );
104 return;
105 }
106 Some(cli::SubCmd::Session) => {
107 print!(
108 "{}",
109 session_output(args.since.as_deref(), args.last, tz, fmt, colors)
110 );
111 return;
112 }
113 Some(cli::SubCmd::Statusline) => {
114 statusline_cmd(&args);
115 return;
116 }
117 Some(cli::SubCmd::Models) => {
118 models_cmd(&args);
119 return;
120 }
121 Some(cli::SubCmd::Plans) => {
122 plans_cmd(&args);
123 return;
124 }
125 Some(cli::SubCmd::Mcp) => {}
127 None => {}
128 }
129
130 let cfg = config::load(cli::Binary::Cmduse);
131 let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
132 let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
133 let burst_on = args.bursts.is_some() || cfg.burst_enabled;
136 let burst_cap = if burst_on {
137 args.bursts
138 .or(Some(cfg.burst_samples))
139 .unwrap_or(40)
140 .clamp(5, 240)
141 } else {
142 0
143 };
144 let notify_on_cap = cfg.notify_on_cap;
145
146 let update = update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
151
152 if args.once {
153 if let Some(msg) = &update {
154 eprintln!("{msg}");
155 }
156 let s = snapshot::snapshot(false);
157 if args.json {
158 println!("{}", render::render_json(&s));
159 } else if args.plain || !render::color_enabled() {
160 print!("{}", render::render_plain(&s));
161 } else {
162 print!("{}", render::render(&s, bar_width));
163 }
164 return;
165 }
166
167 let stdout = std::io::stdout();
176 let mut out = stdout.lock();
177 let mut prev_lines = 0usize;
178 let use_color = render::color_enabled();
181 let mut history: Vec<f64> = Vec::new();
184 let mut history_used: Vec<f64> = Vec::new(); let mut was_exceeded_5h = false;
187 let mut was_exceeded_wk = false;
188 let spinner = !args.plain && std::io::stdout().is_terminal();
191 loop {
192 let s = snapshot::snapshot(spinner);
193 let (rows, cols) = term_size().unwrap_or((0, 0));
194 let compact = cols > 0 && (rows < 16 || cols < 40);
197 let used = s
200 .credits
201 .window_limits
202 .five_hour
203 .as_ref()
204 .map(|w| w.used)
205 .unwrap_or(0.0);
206 let delta = history_used
207 .last()
208 .map(|prev| (used - prev).max(0.0))
209 .unwrap_or(0.0);
210 history_used.push(used);
211 if history_used.len() > 60 {
212 history_used.remove(0);
213 }
214 if burst_on {
215 history.push(delta);
216 if history.len() > burst_cap {
217 history.remove(0);
218 }
219 }
220 if notify_on_cap {
221 let ex5 = s
222 .credits
223 .window_limits
224 .five_hour
225 .as_ref()
226 .map(|w| w.exceeded)
227 .unwrap_or(false);
228 let exwk = s
229 .credits
230 .window_limits
231 .weekly
232 .as_ref()
233 .map(|w| w.exceeded)
234 .unwrap_or(false);
235 if ex5 && !was_exceeded_5h {
236 notify::cap(
237 "cmduse: limit exceeded",
238 "Command Code 5-hour window is over its cap",
239 );
240 }
241 if exwk && !was_exceeded_wk {
242 notify::cap(
243 "cmduse: limit exceeded",
244 "Command Code weekly window is over its cap",
245 );
246 }
247 was_exceeded_5h = ex5;
248 was_exceeded_wk = exwk;
249 }
250 let text = if compact {
251 compact_dashboard(&s, if burst_on { &history } else { &[] })
252 } else if args.plain || !use_color {
253 render::render_plain(&s)
254 } else {
255 render::render(&s, bar_width)
256 };
257 let spark = if compact || !burst_on {
258 String::new()
259 } else if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
260 format!(
261 "{DIM}spend bursts ({}s samples){RESET} {}\n",
262 interval,
263 render::sparkline(&history)
264 )
265 } else {
266 String::new() };
268 let update_box = update
269 .as_ref()
270 .map(|m| render::update_box(m, (cols > 0).then_some(cols), use_color && !args.plain));
271 let status_line = format!("{DIM}refreshing every {interval}s · ctrl-c to quit{RESET}");
272 let frame = match &update_box {
273 Some(u) => format!("{u}{text}{spark}{status_line}"),
274 None => format!("{text}{spark}{status_line}"),
275 };
276 prev_lines = redraw_frame(&mut out, &frame, prev_lines, (cols > 0).then_some(cols));
277 out.flush().ok();
278 for remaining in (1..interval).rev() {
280 std::thread::sleep(std::time::Duration::from_secs(1));
281 let msg = format!(
282 "\r\x1b[2K{DIM}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{RESET}"
283 );
284 write!(out, "{}", clip_to_width(&msg, (cols > 0).then_some(cols))).ok();
285 out.flush().ok();
286 }
287 }
288}
289
290fn compact_dashboard(s: &crate::render::Snapshot, history: &[f64]) -> String {
296 use crate::render::plan_name;
297 if let Some(e) = &s.err {
298 return format!("{BOLD}error{RESET} {e}\n");
299 }
300 let cap = cmduse_core::plan_monthly_cap(&s.sub.plan_id);
301 let cap_txt = cap.map(cmduse_core::money).unwrap_or_else(|| "-".into());
302 let rem_txt = cmduse_core::money(s.credits.credits.monthly_credits);
303 let h5 = s
304 .credits
305 .window_limits
306 .five_hour
307 .as_ref()
308 .map(|w| cmduse_core::money(w.used))
309 .unwrap_or_else(|| "-".into());
310 let status = if s.sub.status.is_empty() {
311 String::new()
312 } else {
313 format!(" · {}", s.sub.status)
314 };
315 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
316 format!(" {DIM}{}{RESET}", crate::render::sparkline(history))
317 } else {
318 String::new()
319 };
320 format!(
321 "{BOLD}{}{RESET} {rem_txt}/{cap_txt} · 5h {h5}{status}{spark}\n",
322 plan_name(&s.sub.plan_id)
323 )
324}
325
326pub fn term_size() -> Option<(usize, usize)> {
331 let out = std::process::Command::new("sh")
332 .arg("-c")
333 .arg("stty size < /dev/tty")
334 .output()
335 .ok()?;
336 if !out.status.success() {
337 return None;
338 }
339 let text = String::from_utf8_lossy(&out.stdout);
340 let mut it = text.split_whitespace();
341 let rows = it.next()?.parse().ok()?;
342 let cols = it.next()?.parse().ok()?;
343 Some((rows, cols))
344}
345
346pub fn clip_to_width(line: &str, cols: Option<usize>) -> String {
350 let Some(cols) = cols else { return line.into() };
351 if cols == 0 {
352 return String::new();
353 }
354 let mut vis = 0usize;
355 let mut cs = line.chars().peekable();
356 while let Some(&c) = cs.peek() {
357 match c {
358 '\x1b' => {
359 cs.next();
360 for e in cs.by_ref() {
361 if e.is_ascii_alphabetic() {
362 break;
363 }
364 }
365 }
366 c if (c as u32) < 0x20 => {
367 cs.next(); }
369 _ => {
370 vis += 1;
371 cs.next();
372 }
373 }
374 }
375 if vis <= cols {
376 return line.into();
377 }
378 let mut out = String::new();
379 let mut kept = 0usize;
380 let mut cs = line.chars();
381 while let Some(c) = cs.next() {
382 match c {
383 '\x1b' => {
384 out.push(c);
385 for e in cs.by_ref() {
386 out.push(e);
387 if e.is_ascii_alphabetic() {
388 break;
389 }
390 }
391 }
392 '\r' => out.push(c), c if (c as u32) < 0x20 => {}
394 _ => {
395 if kept < cols {
396 out.push(c);
397 kept += 1;
398 }
399 }
400 }
401 }
402 if out.ends_with("\x1b[0m") {
403 out
404 } else {
405 out + "\x1b[0m"
406 }
407}
408
409pub fn redraw_frame(
413 out: &mut impl Write,
414 frame: &str,
415 prev_lines: usize,
416 cols: Option<usize>,
417) -> usize {
418 if prev_lines > 0 {
419 write!(out, "\x1b[{}F", prev_lines - 1).ok();
421 }
422 let lines: Vec<&str> = frame.lines().collect();
423 let n = lines.len();
424 for (i, line) in lines.iter().enumerate() {
425 let line = clip_to_width(line, cols);
426 if i + 1 < n {
427 write!(out, "\x1b[2K\r{line}\n").ok();
428 } else {
429 write!(out, "\x1b[2K\r{line}").ok();
431 }
432 }
433 if n < prev_lines {
434 write!(out, "\x1b[1B").ok();
442 for _ in (n + 1)..prev_lines {
443 write!(out, "\x1b[2K\x1b[1B").ok();
444 }
445 write!(out, "\x1b[2K").ok();
446 write!(out, "\x1b[{}F", prev_lines - n).ok();
447 }
448 n
449}
450
451fn models_cmd(args: &cli::Args) {
452 if args.gated {
453 if let Some(days) = cmduse_core::gate_age_days(cmduse_core::dates::now_secs()) {
454 if days > 30 {
455 eprintln!(
456 "warning: gating snapshot is {days}d old (CLI {}) — run `bun run extract` from the repo root",
457 cmduse_core::GATE_CLI_VERSION
458 );
459 }
460 }
461 }
462 let key = match api::api_key() {
463 Ok(k) => k,
464 Err(e) => {
465 eprintln!("error: {e}");
466 std::process::exit(1);
467 }
468 };
469 let (plan_id, unlocked) = if args.gated {
471 match (api::subscriptions(&key), api::credits(&key)) {
472 (Ok(s), Ok(c)) => (
473 s.plan_id,
474 c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
475 ),
476 _ => (String::new(), true), }
478 } else {
479 (String::new(), true)
480 };
481 match api::models(&key) {
482 Ok(list) => {
483 if args.json {
484 for m in &list {
485 if args.gated {
486 let (allowed, reason) = cmduse_core::gate(&m.id, &plan_id, unlocked);
488 let line = serde_json::json!({
489 "id": m.id,
490 "name": m.name,
491 "context_length": m.context_length,
492 "owned_by": m.owned_by,
493 "allowed": allowed,
494 "reason": reason,
495 });
496 println!("{line}");
497 } else {
498 let line = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
499 println!("{line}");
500 }
501 }
502 return;
503 }
504 let list: Vec<_> = if args.gated {
505 list.into_iter()
506 .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
507 .collect()
508 } else {
509 list
510 };
511 if list.is_empty() {
512 println!("no models returned");
513 return;
514 }
515 let mut rows: Vec<_> = list
516 .iter()
517 .map(|m| {
518 let ctx = m
519 .context_length
520 .map(|n| format!("{} ctx", render::compact(n)))
521 .unwrap_or_else(|| "-".into());
522 let name = m.name.as_deref().unwrap_or(&m.id);
523 (m.id.as_str(), name, ctx)
524 })
525 .collect();
526 rows.sort_by(|a, b| a.1.cmp(b.1));
527 for (id, name, ctx) in rows {
528 println!("{name:<36} {ctx:<10} {id}");
529 }
530 }
531 Err(e) => {
532 eprintln!("error: {e}");
533 std::process::exit(1);
534 }
535 }
536}
537
538fn plans_cmd(args: &cli::Args) {
539 let current = api::api_key()
541 .ok()
542 .and_then(|k| api::subscriptions(&k).ok())
543 .map(|s| s.plan_id)
544 .unwrap_or_default();
545 if args.json {
546 print!("{}", render::plans_json(¤t));
547 } else {
548 print!("{}", render::plans_table(¤t, render::color_enabled()));
549 }
550}
551
552pub(crate) fn usage_output() -> String {
557 render::render_plain(&snapshot::snapshot(false))
558}
559
560pub(crate) fn plans_output(colors: bool) -> String {
563 let current = api::api_key()
564 .ok()
565 .and_then(|k| api::subscriptions(&k).ok())
566 .map(|s| s.plan_id)
567 .unwrap_or_default();
568 render::plans_table(¤t, colors)
569}
570
571pub(crate) fn models_output(gated: bool) -> Result<String, String> {
573 let key = api::api_key().map_err(|e| e.to_string())?;
574 let (plan_id, unlocked) = if gated {
575 match (api::subscriptions(&key), api::credits(&key)) {
576 (Ok(s), Ok(c)) => (
577 s.plan_id,
578 c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
579 ),
580 _ => (String::new(), true), }
582 } else {
583 (String::new(), true)
584 };
585 let list = api::models(&key).map_err(|e| e.to_string())?;
586 let list: Vec<_> = if gated {
587 list.into_iter()
588 .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
589 .collect()
590 } else {
591 list
592 };
593 if list.is_empty() {
594 return Ok("no models returned\n".into());
595 }
596 let mut rows: Vec<_> = list
597 .iter()
598 .map(|m| {
599 let ctx = m
600 .context_length
601 .map(|n| format!("{} ctx", render::compact(n)))
602 .unwrap_or_else(|| "-".into());
603 let name = m.name.as_deref().unwrap_or(&m.id);
604 (m.id.as_str(), name, ctx)
605 })
606 .collect();
607 rows.sort_by(|a, b| a.1.cmp(b.1));
608 let mut out = String::new();
609 for (id, name, ctx) in rows {
610 out.push_str(&format!("{name:<36} {ctx:<10} {id}\n"));
611 }
612 Ok(out)
613}
614
615pub(crate) fn daily_output(
617 days: Option<usize>,
618 tz: i64,
619 local: bool,
620 fmt: report_render::Fmt,
621) -> Result<String, String> {
622 let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
623 let data_source = if local {
625 None
626 } else {
627 api::api_key()
628 .ok()
629 .map(|k| reports::load_account_daily(days.unwrap_or(7), &k, tz))
630 };
631 match data_source {
632 Some(Ok(by_day)) => {
633 let total = reports::sum_days(&by_day);
634 Ok(report_render::table(
635 "account",
636 "all harnesses",
637 &by_day,
638 &total,
639 None,
640 fmt,
641 false,
642 ))
643 }
644 Some(Err(e)) => Err(e.to_string()),
645 None => {
646 let d = reports::load_local(tz);
647 let subtitle = if tz != 0 {
648 format!("offline, ~/.commandcode/projects, UTC{}", tz_suffix(tz))
649 } else {
650 "offline, ~/.commandcode/projects".to_string()
651 };
652 Ok(report_render::table(
653 "local", &subtitle, &d.by_day, &d.total, days, fmt, false,
654 ))
655 }
656 }
657}
658
659pub(crate) fn hourly_output(
661 hours: Option<usize>,
662 tz: i64,
663 local: bool,
664 fmt: report_render::Fmt,
665) -> Result<String, String> {
666 let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
667 let rows = if local {
668 reports::load_local_hourly(hours.unwrap_or(24), tz)
669 } else {
670 let key = api::api_key().map_err(|e| e.to_string())?;
671 reports::load_account_hourly(hours.unwrap_or(24), &key, tz).map_err(|e| e.to_string())?
672 };
673 let subtitle = if local {
674 if tz != 0 {
675 format!("local, CLI sessions, UTC{}", tz_suffix(tz))
676 } else {
677 "local, CLI sessions".to_string()
678 }
679 } else if tz != 0 {
680 format!("all harnesses, UTC{}", tz_suffix(tz))
681 } else {
682 "all harnesses, UTC".to_string()
683 };
684 Ok(report_render::hourly_table(&rows, fmt, &subtitle, false))
685}
686
687fn local_since(since_iso: Option<&str>, days: Option<usize>) -> Option<f64> {
693 match since_iso {
694 Some(iso) => cmduse_core::dates::parse_iso_utc(iso),
695 None => {
696 days.map(|d| (cmduse_core::dates::now_secs() as f64 - (d as f64) * 86_400.0) * 1000.0)
697 }
698 }
699}
700
701pub(crate) fn model_output(
704 since_iso: Option<&str>,
705 days: Option<usize>,
706 tz: i64,
707 fmt: report_render::Fmt,
708 colors: bool,
709) -> String {
710 let d = reports::load_local_since(tz, local_since(since_iso, days));
711 report_render::model_table(&d.by_model, fmt, colors, since_iso)
712}
713
714pub(crate) fn session_output(
716 since_iso: Option<&str>,
717 days: Option<usize>,
718 tz: i64,
719 fmt: report_render::Fmt,
720 colors: bool,
721) -> String {
722 let d = reports::load_local_since(tz, local_since(since_iso, days));
723 report_render::project_table(&d.by_project, fmt, colors)
724}
725
726fn output_fmt(args: &cli::Args) -> report_render::Fmt {
728 if args.json {
729 report_render::Fmt::Json
730 } else if args.csv {
731 report_render::Fmt::Csv
732 } else {
733 report_render::Fmt::Table
734 }
735}
736
737fn statusline_cmd(args: &cli::Args) {
738 let cfg = config::load(cli::Binary::Cmduse);
739 let s = snapshot::snapshot(false);
740 let plan = render::plan_name(&s.sub.plan_id);
741 let cap = render::plan_monthly_cap(&s.sub.plan_id).unwrap_or(0.0);
742 if let Some(e) = &s.err {
743 println!("cmduse: {e}");
744 std::process::exit(1);
745 }
746 if args.json {
747 let w = |o: &Option<api::Window>| -> (f64, f64) {
748 (
749 o.as_ref().map(|w| w.used).unwrap_or(0.0),
750 o.as_ref().map(|w| w.cap).unwrap_or(0.0),
751 )
752 };
753 let (h5u, h5c) = w(&s.credits.window_limits.five_hour);
754 let (wku, wkc) = w(&s.credits.window_limits.weekly);
755 let out = serde_json::json!({
756 "plan": plan,
757 "monthlyRemaining": s.credits.credits.monthly_credits,
758 "monthlyCap": cap,
759 "fiveHourUsed": h5u,
760 "fiveHourCap": h5c,
761 "weeklyUsed": wku,
762 "weeklyCap": wkc,
763 });
764 print!("{out}");
765 return;
766 }
767 let h5 = s
768 .credits
769 .window_limits
770 .five_hour
771 .as_ref()
772 .map(|w| (w.used, w.cap));
773 let wk = s
774 .credits
775 .window_limits
776 .weekly
777 .as_ref()
778 .map(|w| (w.used, w.cap));
779 let now = s.now;
781 let eta_of = |w: &Option<api::Window>, dur: u64| -> Option<String> {
782 let w = w.as_ref()?;
783 let secs = cmduse_core::pace_eta(w.reset_at, dur, w.used, w.cap, now)?;
784 Some(format!(
785 "on pace to hit cap in {}",
786 cmduse_core::duration(secs as u64)
787 ))
788 };
789 let h5_eta = eta_of(
790 &s.credits.window_limits.five_hour,
791 cmduse_core::FIVE_HOUR_SECS,
792 );
793 let wk_eta = eta_of(&s.credits.window_limits.weekly, cmduse_core::WEEKLY_SECS);
794 let d = report_render::StatusData {
795 plan,
796 monthly_remaining: s.credits.credits.monthly_credits,
797 monthly_cap: cap,
798 five_hour: &h5,
799 weekly: &wk,
800 five_hour_eta: h5_eta,
801 weekly_eta: wk_eta,
802 bar_width: cfg.bar_width.min(30),
803 colors: cfg.statusline_colors,
804 ascii: cfg.statusline_ascii,
805 };
806 print!(
807 "{}",
808 report_render::render_statusline(&cfg.statusline_template, &d)
809 );
810}