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 stores;
20mod update_check;
21
22#[cfg(test)]
23mod cli_tests;
24
25#[cfg(test)]
26mod config_tests;
27
28#[cfg(test)]
29mod mcp_tests;
30
31#[cfg(test)]
32mod render_tests;
33
34#[cfg(test)]
35mod main_tests;
36
37use std::io::{IsTerminal, Write};
38
39use crate::render::{BOLD, DIM, RESET};
40
41pub fn run() {
42 let args = cli::parse_args(cli::Binary::Cmduse);
43 if args.help {
44 cli::usage(cli::Binary::Cmduse);
45 return;
46 }
47
48 if let Some(cs) = args.config_set {
49 if let Err(e) = config::set(cli::Binary::Cmduse, &cs) {
50 eprintln!("error: {e}");
51 std::process::exit(1);
52 }
53 return;
54 }
55
56 if args.dismiss_update {
57 match update_check::latest_newer() {
58 Ok(Some(v)) => match config::set_dismissed(cli::Binary::Cmduse, &v) {
59 Ok(()) => println!("dismissed update notice for {v}"),
60 Err(e) => {
61 eprintln!("error: {e}");
62 std::process::exit(1);
63 }
64 },
65 Ok(None) => println!("cmduse is up to date ({})", env!("CARGO_PKG_VERSION")),
66 Err(e) => {
67 eprintln!("error: {e}");
68 std::process::exit(1);
69 }
70 }
71 return;
72 }
73
74 if let Some(cli::SubCmd::Mcp) = args.subcmd {
75 mcp::run();
76 return;
77 }
78
79 let fmt = output_fmt(&args);
80 let colors = !args.plain && render::color_enabled();
81 let tz = args.tz.unwrap_or(0);
82
83 match args.subcmd {
85 Some(cli::SubCmd::Daily) => {
86 match daily_output(args.last, tz, args.local, fmt) {
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::Hours) => {
96 match hourly_output(args.hours, tz, args.local, fmt) {
97 Ok(text) => print!("{text}"),
98 Err(e) => {
99 eprintln!("error: {e}");
100 std::process::exit(1);
101 }
102 }
103 return;
104 }
105 Some(cli::SubCmd::Model) => {
106 print!(
107 "{}",
108 match args.model_id.as_deref() {
109 Some(id) =>
110 model_detail_output(id, args.since.as_deref(), args.last, tz, fmt, colors),
111 None => model_output(
112 args.since.as_deref(),
113 args.last,
114 tz,
115 fmt,
116 colors,
117 rows_cap(&args, usize::MAX)
118 ),
119 }
120 );
121 return;
122 }
123 Some(cli::SubCmd::Session) => {
124 print!(
125 "{}",
126 session_output(
127 args.since.as_deref(),
128 args.last,
129 tz,
130 fmt,
131 colors,
132 rows_cap(&args, 20)
133 )
134 );
135 return;
136 }
137 Some(cli::SubCmd::Statusline) => {
138 statusline_cmd(&args);
139 return;
140 }
141 Some(cli::SubCmd::Models) => {
142 models_cmd(&args);
143 return;
144 }
145 Some(cli::SubCmd::Plans) => {
146 plans_cmd(&args);
147 return;
148 }
149 Some(cli::SubCmd::Mcp) => {}
151 None => {}
152 }
153
154 let cfg = config::load(cli::Binary::Cmduse);
155 let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
156 let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
157 let burst_on = args.bursts.is_some() || cfg.burst_enabled;
160 let burst_cap = if burst_on {
161 args.bursts
162 .or(Some(cfg.burst_samples))
163 .unwrap_or(40)
164 .clamp(5, 240)
165 } else {
166 0
167 };
168 let notify_on_cap = cfg.notify_on_cap;
169
170 let update = update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
175
176 if args.once {
177 if let Some(msg) = &update {
178 eprintln!("{msg}");
179 }
180 let s = snapshot::snapshot(false);
181 if args.json {
182 println!("{}", render::render_json(&s));
183 } else if args.plain || !render::color_enabled() {
184 print!("{}", render::render_plain(&s));
185 } else {
186 print!("{}", render::render(&s, bar_width));
187 }
188 return;
189 }
190
191 let stdout = std::io::stdout();
200 let mut out = stdout.lock();
201 let mut prev_lines = 0usize;
202 let use_color = render::color_enabled();
205 let mut history: Vec<f64> = Vec::new();
208 let mut history_used: Vec<f64> = Vec::new(); let mut was_exceeded_5h = false;
211 let mut was_exceeded_wk = false;
212 let spinner = !args.plain && std::io::stdout().is_terminal();
215 loop {
216 let s = snapshot::snapshot(spinner);
217 let (rows, cols) = term_size().unwrap_or((0, 0));
218 let compact = cols > 0 && (rows < 16 || cols < 40);
221 let used = s
224 .credits
225 .window_limits
226 .five_hour
227 .as_ref()
228 .map(|w| w.used)
229 .unwrap_or(0.0);
230 let delta = history_used
231 .last()
232 .map(|prev| (used - prev).max(0.0))
233 .unwrap_or(0.0);
234 history_used.push(used);
235 if history_used.len() > 60 {
236 history_used.remove(0);
237 }
238 if burst_on {
239 history.push(delta);
240 if history.len() > burst_cap {
241 history.remove(0);
242 }
243 }
244 if notify_on_cap {
245 let ex5 = s
246 .credits
247 .window_limits
248 .five_hour
249 .as_ref()
250 .map(|w| w.exceeded)
251 .unwrap_or(false);
252 let exwk = s
253 .credits
254 .window_limits
255 .weekly
256 .as_ref()
257 .map(|w| w.exceeded)
258 .unwrap_or(false);
259 if ex5 && !was_exceeded_5h {
260 notify::cap(
261 "cmduse: limit exceeded",
262 "Command Code 5-hour window is over its cap",
263 );
264 }
265 if exwk && !was_exceeded_wk {
266 notify::cap(
267 "cmduse: limit exceeded",
268 "Command Code weekly window is over its cap",
269 );
270 }
271 was_exceeded_5h = ex5;
272 was_exceeded_wk = exwk;
273 }
274 let text = if compact {
275 compact_dashboard(&s, if burst_on { &history } else { &[] })
276 } else if args.plain || !use_color {
277 render::render_plain(&s)
278 } else {
279 render::render(&s, bar_width)
280 };
281 let spark = if compact || !burst_on {
282 String::new()
283 } else if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
284 format!(
285 "{DIM}spend bursts ({}s samples){RESET} {}\n",
286 interval,
287 render::sparkline(&history)
288 )
289 } else {
290 String::new() };
292 let update_box = update
293 .as_ref()
294 .map(|m| render::update_box(m, (cols > 0).then_some(cols), use_color && !args.plain));
295 let status_line = format!("{DIM}refreshing every {interval}s · ctrl-c to quit{RESET}");
296 let frame = match &update_box {
297 Some(u) => format!("{u}{text}{spark}{status_line}"),
298 None => format!("{text}{spark}{status_line}"),
299 };
300 prev_lines = redraw_frame(&mut out, &frame, prev_lines, (cols > 0).then_some(cols));
301 out.flush().ok();
302 for remaining in (1..interval).rev() {
304 std::thread::sleep(std::time::Duration::from_secs(1));
305 let msg = format!(
306 "\r\x1b[2K{DIM}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{RESET}"
307 );
308 write!(out, "{}", clip_to_width(&msg, (cols > 0).then_some(cols))).ok();
309 out.flush().ok();
310 }
311 }
312}
313
314fn compact_dashboard(s: &crate::render::Snapshot, history: &[f64]) -> String {
320 use crate::render::plan_name;
321 if let Some(e) = &s.err {
322 return format!("{BOLD}error{RESET} {e}\n");
323 }
324 let cap = cmduse_core::plan_monthly_cap(&s.sub.plan_id);
325 let cap_txt = cap.map(cmduse_core::money).unwrap_or_else(|| "-".into());
326 let rem_txt = cmduse_core::money(s.credits.credits.monthly_credits);
327 let h5 = s
328 .credits
329 .window_limits
330 .five_hour
331 .as_ref()
332 .map(|w| cmduse_core::money(w.used))
333 .unwrap_or_else(|| "-".into());
334 let status = if s.sub.status.is_empty() {
335 String::new()
336 } else {
337 format!(" · {}", s.sub.status)
338 };
339 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
340 format!(" {DIM}{}{RESET}", crate::render::sparkline(history))
341 } else {
342 String::new()
343 };
344 format!(
345 "{BOLD}{}{RESET} {rem_txt}/{cap_txt} · 5h {h5}{status}{spark}\n",
346 plan_name(&s.sub.plan_id)
347 )
348}
349
350pub fn term_size() -> Option<(usize, usize)> {
355 let out = std::process::Command::new("sh")
356 .arg("-c")
357 .arg("stty size < /dev/tty")
358 .output()
359 .ok()?;
360 if !out.status.success() {
361 return None;
362 }
363 let text = String::from_utf8_lossy(&out.stdout);
364 let mut it = text.split_whitespace();
365 let rows = it.next()?.parse().ok()?;
366 let cols = it.next()?.parse().ok()?;
367 Some((rows, cols))
368}
369
370pub fn clip_to_width(line: &str, cols: Option<usize>) -> String {
374 let Some(cols) = cols else { return line.into() };
375 if cols == 0 {
376 return String::new();
377 }
378 let mut vis = 0usize;
379 let mut cs = line.chars().peekable();
380 while let Some(&c) = cs.peek() {
381 match c {
382 '\x1b' => {
383 cs.next();
384 for e in cs.by_ref() {
385 if e.is_ascii_alphabetic() {
386 break;
387 }
388 }
389 }
390 c if (c as u32) < 0x20 => {
391 cs.next(); }
393 _ => {
394 vis += 1;
395 cs.next();
396 }
397 }
398 }
399 if vis <= cols {
400 return line.into();
401 }
402 let mut out = String::new();
403 let mut kept = 0usize;
404 let mut cs = line.chars();
405 while let Some(c) = cs.next() {
406 match c {
407 '\x1b' => {
408 out.push(c);
409 for e in cs.by_ref() {
410 out.push(e);
411 if e.is_ascii_alphabetic() {
412 break;
413 }
414 }
415 }
416 '\r' => out.push(c), c if (c as u32) < 0x20 => {}
418 _ => {
419 if kept < cols {
420 out.push(c);
421 kept += 1;
422 }
423 }
424 }
425 }
426 if out.ends_with("\x1b[0m") {
427 out
428 } else {
429 out + "\x1b[0m"
430 }
431}
432
433pub fn redraw_frame(
437 out: &mut impl Write,
438 frame: &str,
439 prev_lines: usize,
440 cols: Option<usize>,
441) -> usize {
442 if prev_lines > 0 {
443 write!(out, "\x1b[{}F", prev_lines - 1).ok();
445 }
446 let lines: Vec<&str> = frame.lines().collect();
447 let n = lines.len();
448 for (i, line) in lines.iter().enumerate() {
449 let line = clip_to_width(line, cols);
450 if i + 1 < n {
451 write!(out, "\x1b[2K\r{line}\n").ok();
452 } else {
453 write!(out, "\x1b[2K\r{line}").ok();
455 }
456 }
457 if n < prev_lines {
458 write!(out, "\x1b[1B").ok();
466 for _ in (n + 1)..prev_lines {
467 write!(out, "\x1b[2K\x1b[1B").ok();
468 }
469 write!(out, "\x1b[2K").ok();
470 write!(out, "\x1b[{}F", prev_lines - n).ok();
471 }
472 n
473}
474
475fn models_cmd(args: &cli::Args) {
476 if args.gated {
477 if let Some(days) = cmduse_core::gate_age_days(cmduse_core::dates::now_secs()) {
478 if days > 30 {
479 eprintln!(
480 "warning: gating snapshot is {days}d old (CLI {}) — run `bun run extract` from the repo root",
481 cmduse_core::GATE_CLI_VERSION
482 );
483 }
484 }
485 }
486 let key = match api::api_key() {
487 Ok(k) => k,
488 Err(e) => {
489 eprintln!("error: {e}");
490 std::process::exit(1);
491 }
492 };
493 let (plan_id, unlocked) = if args.gated {
495 match (api::subscriptions(&key), api::credits(&key)) {
496 (Ok(s), Ok(c)) => (
497 s.plan_id,
498 c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
499 ),
500 _ => (String::new(), true), }
502 } else {
503 (String::new(), true)
504 };
505 match api::models(&key) {
506 Ok(list) => {
507 if args.json {
508 for m in &list {
509 if args.gated {
510 let (allowed, reason) = cmduse_core::gate(&m.id, &plan_id, unlocked);
512 let line = serde_json::json!({
513 "id": m.id,
514 "name": m.name,
515 "context_length": m.context_length,
516 "owned_by": m.owned_by,
517 "allowed": allowed,
518 "reason": reason,
519 });
520 println!("{line}");
521 } else {
522 let line = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
523 println!("{line}");
524 }
525 }
526 return;
527 }
528 let list: Vec<_> = if args.gated {
529 list.into_iter()
530 .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
531 .collect()
532 } else {
533 list
534 };
535 if list.is_empty() {
536 println!("no models returned");
537 return;
538 }
539 let mut rows: Vec<_> = list
540 .iter()
541 .map(|m| {
542 let ctx = m
543 .context_length
544 .map(|n| format!("{} ctx", render::compact(n)))
545 .unwrap_or_else(|| "-".into());
546 let name = m.name.as_deref().unwrap_or(&m.id);
547 (m.id.as_str(), name, ctx)
548 })
549 .collect();
550 rows.sort_by(|a, b| a.1.cmp(b.1));
551 let more = rows.len().saturating_sub(rows_cap(args, usize::MAX));
552 for (id, name, ctx) in rows.into_iter().take(rows_cap(args, usize::MAX)) {
553 println!("{name:<36} {ctx:<10} {id}");
554 }
555 if more > 0 {
556 println!("… {more} more (--rows N to widen)");
557 }
558 }
559 Err(e) => {
560 eprintln!("error: {e}");
561 std::process::exit(1);
562 }
563 }
564}
565
566fn plans_cmd(args: &cli::Args) {
567 let current = api::api_key()
569 .ok()
570 .and_then(|k| api::subscriptions(&k).ok())
571 .map(|s| s.plan_id)
572 .unwrap_or_default();
573 if args.json {
574 print!("{}", render::plans_json(¤t));
575 } else {
576 print!("{}", render::plans_table(¤t, render::color_enabled()));
577 }
578}
579
580pub(crate) fn usage_output() -> String {
585 render::render_plain(&snapshot::snapshot(false))
586}
587
588pub(crate) fn plans_output(colors: bool) -> String {
591 let current = api::api_key()
592 .ok()
593 .and_then(|k| api::subscriptions(&k).ok())
594 .map(|s| s.plan_id)
595 .unwrap_or_default();
596 render::plans_table(¤t, colors)
597}
598
599pub(crate) fn models_output(gated: bool) -> Result<String, String> {
601 let key = api::api_key().map_err(|e| e.to_string())?;
602 let (plan_id, unlocked) = if gated {
603 match (api::subscriptions(&key), api::credits(&key)) {
604 (Ok(s), Ok(c)) => (
605 s.plan_id,
606 c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
607 ),
608 _ => (String::new(), true), }
610 } else {
611 (String::new(), true)
612 };
613 let list = api::models(&key).map_err(|e| e.to_string())?;
614 let list: Vec<_> = if gated {
615 list.into_iter()
616 .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
617 .collect()
618 } else {
619 list
620 };
621 if list.is_empty() {
622 return Ok("no models returned\n".into());
623 }
624 let mut rows: Vec<_> = list
625 .iter()
626 .map(|m| {
627 let ctx = m
628 .context_length
629 .map(|n| format!("{} ctx", render::compact(n)))
630 .unwrap_or_else(|| "-".into());
631 let name = m.name.as_deref().unwrap_or(&m.id);
632 (m.id.as_str(), name, ctx)
633 })
634 .collect();
635 rows.sort_by(|a, b| a.1.cmp(b.1));
636 let mut out = String::new();
637 for (id, name, ctx) in rows {
638 out.push_str(&format!("{name:<36} {ctx:<10} {id}\n"));
639 }
640 Ok(out)
641}
642
643pub(crate) fn daily_output(
645 days: Option<usize>,
646 tz: i64,
647 local: bool,
648 fmt: report_render::Fmt,
649) -> Result<String, String> {
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 {
678 format!(
679 "offline, ~/.commandcode/projects + pi, dsh, UTC{}",
680 tz_suffix(tz)
681 )
682 } else {
683 "offline, ~/.commandcode/projects + pi, dsh".to_string()
684 };
685 Ok(report_render::table(
686 "local", &subtitle, &d.by_day, &d.total, days, fmt, false,
687 ))
688 }
689 }
690}
691
692pub(crate) fn hourly_output(
694 hours: Option<usize>,
695 tz: i64,
696 local: bool,
697 fmt: report_render::Fmt,
698) -> Result<String, String> {
699 let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
700 let rows = if local {
701 reports::load_local_hourly(hours.unwrap_or(24), tz)
702 } else {
703 let key = api::api_key().map_err(|e| e.to_string())?;
704 reports::load_account_hourly(hours.unwrap_or(24), &key, tz).map_err(|e| e.to_string())?
705 };
706 let subtitle = if local {
707 if tz != 0 {
708 format!("local, CLI sessions, UTC{}", tz_suffix(tz))
709 } else {
710 "local, CLI sessions".to_string()
711 }
712 } else if tz != 0 {
713 format!("all harnesses, UTC{}", tz_suffix(tz))
714 } else {
715 "all harnesses, UTC".to_string()
716 };
717 Ok(report_render::hourly_table(&rows, fmt, &subtitle, false))
718}
719
720fn local_since(since_iso: Option<&str>, days: Option<usize>) -> Option<f64> {
726 match since_iso {
727 Some(iso) => cmduse_core::dates::parse_iso_utc(iso),
728 None => {
729 days.map(|d| (cmduse_core::dates::now_secs() as f64 - (d as f64) * 86_400.0) * 1000.0)
730 }
731 }
732}
733
734pub(crate) fn model_output(
737 since_iso: Option<&str>,
738 days: Option<usize>,
739 tz: i64,
740 fmt: report_render::Fmt,
741 colors: bool,
742 rows: usize,
743) -> String {
744 let d = reports::load_local_since(tz, local_since(since_iso, days));
745 report_render::model_table(&d.by_model, fmt, colors, since_iso, rows)
746}
747
748pub(crate) fn model_detail_output(
751 id: &str,
752 since_iso: Option<&str>,
753 days: Option<usize>,
754 tz: i64,
755 fmt: report_render::Fmt,
756 colors: bool,
757) -> String {
758 let d = reports::load_local_since(tz, local_since(since_iso, days));
759 report_render::model_detail(&d.by_model, id, fmt, colors, since_iso)
760}
761
762pub(crate) fn session_output(
764 since_iso: Option<&str>,
765 days: Option<usize>,
766 tz: i64,
767 fmt: report_render::Fmt,
768 colors: bool,
769 rows: usize,
770) -> String {
771 let d = reports::load_local_since(tz, local_since(since_iso, days));
772 report_render::project_table(&d.by_project, fmt, colors, rows)
773}
774
775fn rows_cap(args: &cli::Args, default: usize) -> usize {
779 args.rows_cap(default)
780}
781
782fn output_fmt(args: &cli::Args) -> report_render::Fmt {
784 if args.json {
785 report_render::Fmt::Json
786 } else if args.csv {
787 report_render::Fmt::Csv
788 } else {
789 report_render::Fmt::Table
790 }
791}
792
793fn statusline_json(
799 plan: &str,
800 month_cap: f64,
801 month_remaining: f64,
802 five_hour: (f64, f64),
803 weekly: (f64, f64),
804) -> serde_json::Value {
805 let (h5u, h5c) = five_hour;
806 let (wku, wkc) = weekly;
807 serde_json::json!({
808 "plan": plan,
809 "monthSpent": (month_cap - month_remaining).max(0.0),
810 "monthCap": month_cap,
811 "fiveHourSpent": h5u,
812 "fiveHourCap": h5c,
813 "weeklySpent": wku,
814 "weeklyCap": wkc,
815 "monthlyRemaining": month_remaining,
816 "monthlyCap": month_cap,
817 "fiveHourUsed": h5u,
818 "weeklyUsed": wku,
819 })
820}
821
822fn statusline_cmd(args: &cli::Args) {
823 let cfg = config::load(cli::Binary::Cmduse);
824 let s = snapshot::snapshot(false);
825 let plan = render::plan_name(&s.sub.plan_id);
826 let cap = render::plan_monthly_cap(&s.sub.plan_id).unwrap_or(0.0);
827 if let Some(e) = &s.err {
828 println!("cmduse: {e}");
829 std::process::exit(1);
830 }
831 if args.json {
832 let w = |o: &Option<api::Window>| -> (f64, f64) {
833 (
834 o.as_ref().map(|w| w.used).unwrap_or(0.0),
835 o.as_ref().map(|w| w.cap).unwrap_or(0.0),
836 )
837 };
838 let out = statusline_json(
839 plan,
840 cap,
841 s.credits.credits.monthly_credits,
842 w(&s.credits.window_limits.five_hour),
843 w(&s.credits.window_limits.weekly),
844 );
845 print!("{out}");
846 return;
847 }
848 let h5 = s
849 .credits
850 .window_limits
851 .five_hour
852 .as_ref()
853 .map(|w| (w.used, w.cap));
854 let wk = s
855 .credits
856 .window_limits
857 .weekly
858 .as_ref()
859 .map(|w| (w.used, w.cap));
860 let now = s.now;
862 let eta_of = |w: &Option<api::Window>, dur: u64| -> Option<String> {
863 let w = w.as_ref()?;
864 let secs = cmduse_core::pace_eta(w.reset_at, dur, w.used, w.cap, now)?;
865 Some(format!(
866 "on pace to hit cap in {}",
867 cmduse_core::duration(secs as u64)
868 ))
869 };
870 let h5_eta = eta_of(
871 &s.credits.window_limits.five_hour,
872 cmduse_core::FIVE_HOUR_SECS,
873 );
874 let wk_eta = eta_of(&s.credits.window_limits.weekly, cmduse_core::WEEKLY_SECS);
875 let d = report_render::StatusData {
876 plan,
877 monthly_remaining: s.credits.credits.monthly_credits,
878 monthly_cap: cap,
879 five_hour: &h5,
880 weekly: &wk,
881 five_hour_eta: h5_eta,
882 weekly_eta: wk_eta,
883 bar_width: cfg.bar_width.min(30),
884 colors: cfg.statusline_colors,
885 ascii: cfg.statusline_ascii,
886 };
887 print!(
888 "{}",
889 report_render::render_statusline(&cfg.statusline_template, &d)
890 );
891}