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