1mod api;
2pub mod cli;
3mod config;
4mod mcp;
5mod notify;
6pub mod ocuse;
9pub 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 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(
106 args.since.as_deref(),
107 args.last,
108 tz,
109 fmt,
110 colors,
111 rows_cap(&args, usize::MAX)
112 )
113 );
114 return;
115 }
116 Some(cli::SubCmd::Session) => {
117 print!(
118 "{}",
119 session_output(
120 args.since.as_deref(),
121 args.last,
122 tz,
123 fmt,
124 colors,
125 rows_cap(&args, 20)
126 )
127 );
128 return;
129 }
130 Some(cli::SubCmd::Statusline) => {
131 statusline_cmd(&args);
132 return;
133 }
134 Some(cli::SubCmd::Models) => {
135 models_cmd(&args);
136 return;
137 }
138 Some(cli::SubCmd::Plans) => {
139 plans_cmd(&args);
140 return;
141 }
142 Some(cli::SubCmd::Mcp) => {}
144 None => {}
145 }
146
147 let cfg = config::load(cli::Binary::Cmduse);
148 let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
149 let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
150 let burst_on = args.bursts.is_some() || cfg.burst_enabled;
153 let burst_cap = if burst_on {
154 args.bursts
155 .or(Some(cfg.burst_samples))
156 .unwrap_or(40)
157 .clamp(5, 240)
158 } else {
159 0
160 };
161 let notify_on_cap = cfg.notify_on_cap;
162
163 let update = update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
168
169 if args.once {
170 if let Some(msg) = &update {
171 eprintln!("{msg}");
172 }
173 let s = snapshot::snapshot(false);
174 if args.json {
175 println!("{}", render::render_json(&s));
176 } else if args.plain || !render::color_enabled() {
177 print!("{}", render::render_plain(&s));
178 } else {
179 print!("{}", render::render(&s, bar_width));
180 }
181 return;
182 }
183
184 let stdout = std::io::stdout();
193 let mut out = stdout.lock();
194 let mut prev_lines = 0usize;
195 let use_color = render::color_enabled();
198 let mut history: Vec<f64> = Vec::new();
201 let mut history_used: Vec<f64> = Vec::new(); let mut was_exceeded_5h = false;
204 let mut was_exceeded_wk = false;
205 let spinner = !args.plain && std::io::stdout().is_terminal();
208 loop {
209 let s = snapshot::snapshot(spinner);
210 let (rows, cols) = term_size().unwrap_or((0, 0));
211 let compact = cols > 0 && (rows < 16 || cols < 40);
214 let used = s
217 .credits
218 .window_limits
219 .five_hour
220 .as_ref()
221 .map(|w| w.used)
222 .unwrap_or(0.0);
223 let delta = history_used
224 .last()
225 .map(|prev| (used - prev).max(0.0))
226 .unwrap_or(0.0);
227 history_used.push(used);
228 if history_used.len() > 60 {
229 history_used.remove(0);
230 }
231 if burst_on {
232 history.push(delta);
233 if history.len() > burst_cap {
234 history.remove(0);
235 }
236 }
237 if notify_on_cap {
238 let ex5 = s
239 .credits
240 .window_limits
241 .five_hour
242 .as_ref()
243 .map(|w| w.exceeded)
244 .unwrap_or(false);
245 let exwk = s
246 .credits
247 .window_limits
248 .weekly
249 .as_ref()
250 .map(|w| w.exceeded)
251 .unwrap_or(false);
252 if ex5 && !was_exceeded_5h {
253 notify::cap(
254 "cmduse: limit exceeded",
255 "Command Code 5-hour window is over its cap",
256 );
257 }
258 if exwk && !was_exceeded_wk {
259 notify::cap(
260 "cmduse: limit exceeded",
261 "Command Code weekly window is over its cap",
262 );
263 }
264 was_exceeded_5h = ex5;
265 was_exceeded_wk = exwk;
266 }
267 let text = if compact {
268 compact_dashboard(&s, if burst_on { &history } else { &[] })
269 } else if args.plain || !use_color {
270 render::render_plain(&s)
271 } else {
272 render::render(&s, bar_width)
273 };
274 let spark = if compact || !burst_on {
275 String::new()
276 } else if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
277 format!(
278 "{DIM}spend bursts ({}s samples){RESET} {}\n",
279 interval,
280 render::sparkline(&history)
281 )
282 } else {
283 String::new() };
285 let update_box = update
286 .as_ref()
287 .map(|m| render::update_box(m, (cols > 0).then_some(cols), use_color && !args.plain));
288 let status_line = format!("{DIM}refreshing every {interval}s · ctrl-c to quit{RESET}");
289 let frame = match &update_box {
290 Some(u) => format!("{u}{text}{spark}{status_line}"),
291 None => format!("{text}{spark}{status_line}"),
292 };
293 prev_lines = redraw_frame(&mut out, &frame, prev_lines, (cols > 0).then_some(cols));
294 out.flush().ok();
295 for remaining in (1..interval).rev() {
297 std::thread::sleep(std::time::Duration::from_secs(1));
298 let msg = format!(
299 "\r\x1b[2K{DIM}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{RESET}"
300 );
301 write!(out, "{}", clip_to_width(&msg, (cols > 0).then_some(cols))).ok();
302 out.flush().ok();
303 }
304 }
305}
306
307fn compact_dashboard(s: &crate::render::Snapshot, history: &[f64]) -> String {
313 use crate::render::plan_name;
314 if let Some(e) = &s.err {
315 return format!("{BOLD}error{RESET} {e}\n");
316 }
317 let cap = cmduse_core::plan_monthly_cap(&s.sub.plan_id);
318 let cap_txt = cap.map(cmduse_core::money).unwrap_or_else(|| "-".into());
319 let rem_txt = cmduse_core::money(s.credits.credits.monthly_credits);
320 let h5 = s
321 .credits
322 .window_limits
323 .five_hour
324 .as_ref()
325 .map(|w| cmduse_core::money(w.used))
326 .unwrap_or_else(|| "-".into());
327 let status = if s.sub.status.is_empty() {
328 String::new()
329 } else {
330 format!(" · {}", s.sub.status)
331 };
332 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
333 format!(" {DIM}{}{RESET}", crate::render::sparkline(history))
334 } else {
335 String::new()
336 };
337 format!(
338 "{BOLD}{}{RESET} {rem_txt}/{cap_txt} · 5h {h5}{status}{spark}\n",
339 plan_name(&s.sub.plan_id)
340 )
341}
342
343pub fn term_size() -> Option<(usize, usize)> {
348 let out = std::process::Command::new("sh")
349 .arg("-c")
350 .arg("stty size < /dev/tty")
351 .output()
352 .ok()?;
353 if !out.status.success() {
354 return None;
355 }
356 let text = String::from_utf8_lossy(&out.stdout);
357 let mut it = text.split_whitespace();
358 let rows = it.next()?.parse().ok()?;
359 let cols = it.next()?.parse().ok()?;
360 Some((rows, cols))
361}
362
363pub fn clip_to_width(line: &str, cols: Option<usize>) -> String {
367 let Some(cols) = cols else { return line.into() };
368 if cols == 0 {
369 return String::new();
370 }
371 let mut vis = 0usize;
372 let mut cs = line.chars().peekable();
373 while let Some(&c) = cs.peek() {
374 match c {
375 '\x1b' => {
376 cs.next();
377 for e in cs.by_ref() {
378 if e.is_ascii_alphabetic() {
379 break;
380 }
381 }
382 }
383 c if (c as u32) < 0x20 => {
384 cs.next(); }
386 _ => {
387 vis += 1;
388 cs.next();
389 }
390 }
391 }
392 if vis <= cols {
393 return line.into();
394 }
395 let mut out = String::new();
396 let mut kept = 0usize;
397 let mut cs = line.chars();
398 while let Some(c) = cs.next() {
399 match c {
400 '\x1b' => {
401 out.push(c);
402 for e in cs.by_ref() {
403 out.push(e);
404 if e.is_ascii_alphabetic() {
405 break;
406 }
407 }
408 }
409 '\r' => out.push(c), c if (c as u32) < 0x20 => {}
411 _ => {
412 if kept < cols {
413 out.push(c);
414 kept += 1;
415 }
416 }
417 }
418 }
419 if out.ends_with("\x1b[0m") {
420 out
421 } else {
422 out + "\x1b[0m"
423 }
424}
425
426pub fn redraw_frame(
430 out: &mut impl Write,
431 frame: &str,
432 prev_lines: usize,
433 cols: Option<usize>,
434) -> usize {
435 if prev_lines > 0 {
436 write!(out, "\x1b[{}F", prev_lines - 1).ok();
438 }
439 let lines: Vec<&str> = frame.lines().collect();
440 let n = lines.len();
441 for (i, line) in lines.iter().enumerate() {
442 let line = clip_to_width(line, cols);
443 if i + 1 < n {
444 write!(out, "\x1b[2K\r{line}\n").ok();
445 } else {
446 write!(out, "\x1b[2K\r{line}").ok();
448 }
449 }
450 if n < prev_lines {
451 write!(out, "\x1b[1B").ok();
459 for _ in (n + 1)..prev_lines {
460 write!(out, "\x1b[2K\x1b[1B").ok();
461 }
462 write!(out, "\x1b[2K").ok();
463 write!(out, "\x1b[{}F", prev_lines - n).ok();
464 }
465 n
466}
467
468fn models_cmd(args: &cli::Args) {
469 if args.gated {
470 if let Some(days) = cmduse_core::gate_age_days(cmduse_core::dates::now_secs()) {
471 if days > 30 {
472 eprintln!(
473 "warning: gating snapshot is {days}d old (CLI {}) — run `bun run extract` from the repo root",
474 cmduse_core::GATE_CLI_VERSION
475 );
476 }
477 }
478 }
479 let key = match api::api_key() {
480 Ok(k) => k,
481 Err(e) => {
482 eprintln!("error: {e}");
483 std::process::exit(1);
484 }
485 };
486 let (plan_id, unlocked) = if args.gated {
488 match (api::subscriptions(&key), api::credits(&key)) {
489 (Ok(s), Ok(c)) => (
490 s.plan_id,
491 c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
492 ),
493 _ => (String::new(), true), }
495 } else {
496 (String::new(), true)
497 };
498 match api::models(&key) {
499 Ok(list) => {
500 if args.json {
501 for m in &list {
502 if args.gated {
503 let (allowed, reason) = cmduse_core::gate(&m.id, &plan_id, unlocked);
505 let line = serde_json::json!({
506 "id": m.id,
507 "name": m.name,
508 "context_length": m.context_length,
509 "owned_by": m.owned_by,
510 "allowed": allowed,
511 "reason": reason,
512 });
513 println!("{line}");
514 } else {
515 let line = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
516 println!("{line}");
517 }
518 }
519 return;
520 }
521 let list: Vec<_> = if args.gated {
522 list.into_iter()
523 .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
524 .collect()
525 } else {
526 list
527 };
528 if list.is_empty() {
529 println!("no models returned");
530 return;
531 }
532 let mut rows: Vec<_> = list
533 .iter()
534 .map(|m| {
535 let ctx = m
536 .context_length
537 .map(|n| format!("{} ctx", render::compact(n)))
538 .unwrap_or_else(|| "-".into());
539 let name = m.name.as_deref().unwrap_or(&m.id);
540 (m.id.as_str(), name, ctx)
541 })
542 .collect();
543 rows.sort_by(|a, b| a.1.cmp(b.1));
544 let more = rows.len().saturating_sub(rows_cap(args, usize::MAX));
545 for (id, name, ctx) in rows.into_iter().take(rows_cap(args, usize::MAX)) {
546 println!("{name:<36} {ctx:<10} {id}");
547 }
548 if more > 0 {
549 println!("… {more} more (--rows N to widen)");
550 }
551 }
552 Err(e) => {
553 eprintln!("error: {e}");
554 std::process::exit(1);
555 }
556 }
557}
558
559fn plans_cmd(args: &cli::Args) {
560 let current = api::api_key()
562 .ok()
563 .and_then(|k| api::subscriptions(&k).ok())
564 .map(|s| s.plan_id)
565 .unwrap_or_default();
566 if args.json {
567 print!("{}", render::plans_json(¤t));
568 } else {
569 print!("{}", render::plans_table(¤t, render::color_enabled()));
570 }
571}
572
573pub(crate) fn usage_output() -> String {
578 render::render_plain(&snapshot::snapshot(false))
579}
580
581pub(crate) fn plans_output(colors: bool) -> String {
584 let current = api::api_key()
585 .ok()
586 .and_then(|k| api::subscriptions(&k).ok())
587 .map(|s| s.plan_id)
588 .unwrap_or_default();
589 render::plans_table(¤t, colors)
590}
591
592pub(crate) fn models_output(gated: bool) -> Result<String, String> {
594 let key = api::api_key().map_err(|e| e.to_string())?;
595 let (plan_id, unlocked) = if gated {
596 match (api::subscriptions(&key), api::credits(&key)) {
597 (Ok(s), Ok(c)) => (
598 s.plan_id,
599 c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
600 ),
601 _ => (String::new(), true), }
603 } else {
604 (String::new(), true)
605 };
606 let list = api::models(&key).map_err(|e| e.to_string())?;
607 let list: Vec<_> = if gated {
608 list.into_iter()
609 .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
610 .collect()
611 } else {
612 list
613 };
614 if list.is_empty() {
615 return Ok("no models returned\n".into());
616 }
617 let mut rows: Vec<_> = list
618 .iter()
619 .map(|m| {
620 let ctx = m
621 .context_length
622 .map(|n| format!("{} ctx", render::compact(n)))
623 .unwrap_or_else(|| "-".into());
624 let name = m.name.as_deref().unwrap_or(&m.id);
625 (m.id.as_str(), name, ctx)
626 })
627 .collect();
628 rows.sort_by(|a, b| a.1.cmp(b.1));
629 let mut out = String::new();
630 for (id, name, ctx) in rows {
631 out.push_str(&format!("{name:<36} {ctx:<10} {id}\n"));
632 }
633 Ok(out)
634}
635
636pub(crate) fn daily_output(
638 days: Option<usize>,
639 tz: i64,
640 local: bool,
641 fmt: report_render::Fmt,
642) -> Result<String, String> {
643 let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
644 let data_source = if local {
646 None
647 } else {
648 api::api_key()
649 .ok()
650 .map(|k| reports::load_account_daily(days.unwrap_or(7), &k, tz))
651 };
652 match data_source {
653 Some(Ok(by_day)) => {
654 let total = reports::sum_days(&by_day);
655 Ok(report_render::table(
656 "account",
657 "all harnesses",
658 &by_day,
659 &total,
660 None,
661 fmt,
662 false,
663 ))
664 }
665 Some(Err(e)) => Err(e.to_string()),
666 None => {
667 let d = reports::load_local(tz);
668 let subtitle = if tz != 0 {
669 format!("offline, ~/.commandcode/projects, UTC{}", tz_suffix(tz))
670 } else {
671 "offline, ~/.commandcode/projects".to_string()
672 };
673 Ok(report_render::table(
674 "local", &subtitle, &d.by_day, &d.total, days, fmt, false,
675 ))
676 }
677 }
678}
679
680pub(crate) fn hourly_output(
682 hours: Option<usize>,
683 tz: i64,
684 local: bool,
685 fmt: report_render::Fmt,
686) -> Result<String, String> {
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 local_since(since_iso: Option<&str>, days: Option<usize>) -> Option<f64> {
714 match since_iso {
715 Some(iso) => cmduse_core::dates::parse_iso_utc(iso),
716 None => {
717 days.map(|d| (cmduse_core::dates::now_secs() as f64 - (d as f64) * 86_400.0) * 1000.0)
718 }
719 }
720}
721
722pub(crate) fn model_output(
725 since_iso: Option<&str>,
726 days: Option<usize>,
727 tz: i64,
728 fmt: report_render::Fmt,
729 colors: bool,
730 rows: usize,
731) -> String {
732 let d = reports::load_local_since(tz, local_since(since_iso, days));
733 report_render::model_table(&d.by_model, fmt, colors, since_iso, rows)
734}
735
736pub(crate) fn session_output(
738 since_iso: Option<&str>,
739 days: Option<usize>,
740 tz: i64,
741 fmt: report_render::Fmt,
742 colors: bool,
743 rows: usize,
744) -> String {
745 let d = reports::load_local_since(tz, local_since(since_iso, days));
746 report_render::project_table(&d.by_project, fmt, colors, rows)
747}
748
749fn rows_cap(args: &cli::Args, default: usize) -> usize {
753 args.rows_cap(default)
754}
755
756fn output_fmt(args: &cli::Args) -> report_render::Fmt {
758 if args.json {
759 report_render::Fmt::Json
760 } else if args.csv {
761 report_render::Fmt::Csv
762 } else {
763 report_render::Fmt::Table
764 }
765}
766
767fn statusline_cmd(args: &cli::Args) {
768 let cfg = config::load(cli::Binary::Cmduse);
769 let s = snapshot::snapshot(false);
770 let plan = render::plan_name(&s.sub.plan_id);
771 let cap = render::plan_monthly_cap(&s.sub.plan_id).unwrap_or(0.0);
772 if let Some(e) = &s.err {
773 println!("cmduse: {e}");
774 std::process::exit(1);
775 }
776 if args.json {
777 let w = |o: &Option<api::Window>| -> (f64, f64) {
778 (
779 o.as_ref().map(|w| w.used).unwrap_or(0.0),
780 o.as_ref().map(|w| w.cap).unwrap_or(0.0),
781 )
782 };
783 let (h5u, h5c) = w(&s.credits.window_limits.five_hour);
784 let (wku, wkc) = w(&s.credits.window_limits.weekly);
785 let out = serde_json::json!({
786 "plan": plan,
787 "monthlyRemaining": s.credits.credits.monthly_credits,
788 "monthlyCap": cap,
789 "fiveHourUsed": h5u,
790 "fiveHourCap": h5c,
791 "weeklyUsed": wku,
792 "weeklyCap": wkc,
793 });
794 print!("{out}");
795 return;
796 }
797 let h5 = s
798 .credits
799 .window_limits
800 .five_hour
801 .as_ref()
802 .map(|w| (w.used, w.cap));
803 let wk = s
804 .credits
805 .window_limits
806 .weekly
807 .as_ref()
808 .map(|w| (w.used, w.cap));
809 let now = s.now;
811 let eta_of = |w: &Option<api::Window>, dur: u64| -> Option<String> {
812 let w = w.as_ref()?;
813 let secs = cmduse_core::pace_eta(w.reset_at, dur, w.used, w.cap, now)?;
814 Some(format!(
815 "on pace to hit cap in {}",
816 cmduse_core::duration(secs as u64)
817 ))
818 };
819 let h5_eta = eta_of(
820 &s.credits.window_limits.five_hour,
821 cmduse_core::FIVE_HOUR_SECS,
822 );
823 let wk_eta = eta_of(&s.credits.window_limits.weekly, cmduse_core::WEEKLY_SECS);
824 let d = report_render::StatusData {
825 plan,
826 monthly_remaining: s.credits.credits.monthly_credits,
827 monthly_cap: cap,
828 five_hour: &h5,
829 weekly: &wk,
830 five_hour_eta: h5_eta,
831 weekly_eta: wk_eta,
832 bar_width: cfg.bar_width.min(30),
833 colors: cfg.statusline_colors,
834 ascii: cfg.statusline_ascii,
835 };
836 print!(
837 "{}",
838 report_render::render_statusline(&cfg.statusline_template, &d)
839 );
840}