1mod api;
2mod cli;
3mod config;
4mod mcp;
5pub 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 match args.subcmd {
78 Some(cli::SubCmd::Daily) => {
79 match daily_output(args.last, tz, args.local) {
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) {
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 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 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 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 let stdout = std::io::stdout();
178 let mut out = stdout.lock();
179 let mut prev_lines = 0usize;
180 let use_color = render::color_enabled();
183 let mut history: Vec<f64> = Vec::new();
186 let mut history_used: Vec<f64> = Vec::new(); let mut was_exceeded_5h = false;
189 let mut was_exceeded_wk = false;
190 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 let compact = cols > 0 && (rows < 16 || cols < 40);
199 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() };
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 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
286fn 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
313fn 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
349fn 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
369pub 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(); }
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), 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
432fn 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 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 write!(out, "\x1b[2K\r{line}").ok();
454 }
455 }
456 if n < prev_lines {
457 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
474fn 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 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), }
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 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 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(¤t));
579 } else {
580 print!("{}", render::plans_table(¤t, render::color_enabled()));
581 }
582}
583
584pub(crate) fn usage_output() -> String {
589 render::render_plain(&snapshot::snapshot(false))
590}
591
592pub(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(¤t, colors)
601}
602
603pub(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), }
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
647pub(crate) fn daily_output(days: Option<usize>, tz: i64, local: bool) -> Result<String, String> {
649 let fmt = report_render::Fmt::Table;
650 let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
651 let data_source = if local {
653 None
654 } else {
655 api::api_key()
656 .ok()
657 .map(|k| reports::load_account_daily(days.unwrap_or(7), &k, tz))
658 };
659 match data_source {
660 Some(Ok(by_day)) => {
661 let total = reports::sum_days(&by_day);
662 Ok(report_render::table(
663 "account",
664 "all harnesses",
665 &by_day,
666 &total,
667 None,
668 fmt,
669 false,
670 ))
671 }
672 Some(Err(e)) => Err(e.to_string()),
673 None => {
674 let d = reports::load_local(tz);
675 let subtitle = if tz != 0 {
676 format!("offline, ~/.commandcode/projects, UTC{}", tz_suffix(tz))
677 } else {
678 "offline, ~/.commandcode/projects".to_string()
679 };
680 Ok(report_render::table(
681 "local", &subtitle, &d.by_day, &d.total, days, fmt, false,
682 ))
683 }
684 }
685}
686
687pub(crate) fn hourly_output(hours: Option<usize>, tz: i64, local: bool) -> Result<String, String> {
689 let fmt = report_render::Fmt::Table;
690 let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
691 let rows = if local {
692 reports::load_local_hourly(hours.unwrap_or(24), tz)
693 } else {
694 let key = api::api_key().map_err(|e| e.to_string())?;
695 reports::load_account_hourly(hours.unwrap_or(24), &key, tz).map_err(|e| e.to_string())?
696 };
697 let subtitle = if local {
698 if tz != 0 {
699 format!("local, CLI sessions, UTC{}", tz_suffix(tz))
700 } else {
701 "local, CLI sessions".to_string()
702 }
703 } else if tz != 0 {
704 format!("all harnesses, UTC{}", tz_suffix(tz))
705 } else {
706 "all harnesses, UTC".to_string()
707 };
708 Ok(report_render::hourly_table(&rows, fmt, &subtitle, false))
709}
710
711fn output_fmt(args: &cli::Args) -> report_render::Fmt {
713 if args.json {
714 report_render::Fmt::Json
715 } else if args.csv {
716 report_render::Fmt::Csv
717 } else {
718 report_render::Fmt::Table
719 }
720}
721
722fn statusline_cmd(args: &cli::Args) {
723 let cfg = config::load();
724 let s = snapshot::snapshot(false);
725 let plan = render::plan_name(&s.sub.plan_id);
726 let cap = render::plan_monthly_cap(&s.sub.plan_id).unwrap_or(0.0);
727 if let Some(e) = &s.err {
728 println!("cmduse: {e}");
729 std::process::exit(1);
730 }
731 if args.json {
732 let w = |o: &Option<api::Window>| -> (f64, f64) {
733 (
734 o.as_ref().map(|w| w.used).unwrap_or(0.0),
735 o.as_ref().map(|w| w.cap).unwrap_or(0.0),
736 )
737 };
738 let (h5u, h5c) = w(&s.credits.window_limits.five_hour);
739 let (wku, wkc) = w(&s.credits.window_limits.weekly);
740 let out = serde_json::json!({
741 "plan": plan,
742 "monthlyRemaining": s.credits.credits.monthly_credits,
743 "monthlyCap": cap,
744 "fiveHourUsed": h5u,
745 "fiveHourCap": h5c,
746 "weeklyUsed": wku,
747 "weeklyCap": wkc,
748 });
749 print!("{out}");
750 return;
751 }
752 let h5 = s
753 .credits
754 .window_limits
755 .five_hour
756 .as_ref()
757 .map(|w| (w.used, w.cap));
758 let wk = s
759 .credits
760 .window_limits
761 .weekly
762 .as_ref()
763 .map(|w| (w.used, w.cap));
764 let now = s.now;
766 let eta_of = |w: &Option<api::Window>, dur: u64| -> Option<String> {
767 let w = w.as_ref()?;
768 let secs = cmduse_core::pace_eta(w.reset_at, dur, w.used, w.cap, now)?;
769 Some(format!(
770 "on pace to hit cap in {}",
771 cmduse_core::duration(secs as u64)
772 ))
773 };
774 let h5_eta = eta_of(
775 &s.credits.window_limits.five_hour,
776 cmduse_core::FIVE_HOUR_SECS,
777 );
778 let wk_eta = eta_of(&s.credits.window_limits.weekly, cmduse_core::WEEKLY_SECS);
779 let d = report_render::StatusData {
780 plan,
781 monthly_remaining: s.credits.credits.monthly_credits,
782 monthly_cap: cap,
783 five_hour: &h5,
784 weekly: &wk,
785 five_hour_eta: h5_eta,
786 weekly_eta: wk_eta,
787 bar_width: cfg.bar_width.min(30),
788 colors: cfg.statusline_colors,
789 ascii: cfg.statusline_ascii,
790 };
791 print!(
792 "{}",
793 report_render::render_statusline(&cfg.statusline_template, &d)
794 );
795}