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 match args.model_id.as_deref() {
106 Some(id) =>
107 model_detail_output(id, args.since.as_deref(), args.last, tz, fmt, colors),
108 None => model_output(
109 args.since.as_deref(),
110 args.last,
111 tz,
112 fmt,
113 colors,
114 rows_cap(&args, usize::MAX)
115 ),
116 }
117 );
118 return;
119 }
120 Some(cli::SubCmd::Session) => {
121 print!(
122 "{}",
123 session_output(
124 args.since.as_deref(),
125 args.last,
126 tz,
127 fmt,
128 colors,
129 rows_cap(&args, 20)
130 )
131 );
132 return;
133 }
134 Some(cli::SubCmd::Statusline) => {
135 statusline_cmd(&args);
136 return;
137 }
138 Some(cli::SubCmd::Models) => {
139 models_cmd(&args);
140 return;
141 }
142 Some(cli::SubCmd::Plans) => {
143 plans_cmd(&args);
144 return;
145 }
146 Some(cli::SubCmd::Mcp) => {}
148 None => {}
149 }
150
151 let cfg = config::load(cli::Binary::Cmduse);
152 let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
153 let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
154 let burst_on = args.bursts.is_some() || cfg.burst_enabled;
157 let burst_cap = if burst_on {
158 args.bursts
159 .or(Some(cfg.burst_samples))
160 .unwrap_or(40)
161 .clamp(5, 240)
162 } else {
163 0
164 };
165 let notify_on_cap = cfg.notify_on_cap;
166
167 let update = update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
172
173 if args.once {
174 if let Some(msg) = &update {
175 eprintln!("{msg}");
176 }
177 let s = snapshot::snapshot(false);
178 if args.json {
179 println!("{}", render::render_json(&s));
180 } else if args.plain || !render::color_enabled() {
181 print!("{}", render::render_plain(&s));
182 } else {
183 print!("{}", render::render(&s, bar_width));
184 }
185 return;
186 }
187
188 let stdout = std::io::stdout();
197 let mut out = stdout.lock();
198 let mut prev_lines = 0usize;
199 let use_color = render::color_enabled();
202 let mut history: Vec<f64> = Vec::new();
205 let mut history_used: Vec<f64> = Vec::new(); let mut was_exceeded_5h = false;
208 let mut was_exceeded_wk = false;
209 let spinner = !args.plain && std::io::stdout().is_terminal();
212 loop {
213 let s = snapshot::snapshot(spinner);
214 let (rows, cols) = term_size().unwrap_or((0, 0));
215 let compact = cols > 0 && (rows < 16 || cols < 40);
218 let used = s
221 .credits
222 .window_limits
223 .five_hour
224 .as_ref()
225 .map(|w| w.used)
226 .unwrap_or(0.0);
227 let delta = history_used
228 .last()
229 .map(|prev| (used - prev).max(0.0))
230 .unwrap_or(0.0);
231 history_used.push(used);
232 if history_used.len() > 60 {
233 history_used.remove(0);
234 }
235 if burst_on {
236 history.push(delta);
237 if history.len() > burst_cap {
238 history.remove(0);
239 }
240 }
241 if notify_on_cap {
242 let ex5 = s
243 .credits
244 .window_limits
245 .five_hour
246 .as_ref()
247 .map(|w| w.exceeded)
248 .unwrap_or(false);
249 let exwk = s
250 .credits
251 .window_limits
252 .weekly
253 .as_ref()
254 .map(|w| w.exceeded)
255 .unwrap_or(false);
256 if ex5 && !was_exceeded_5h {
257 notify::cap(
258 "cmduse: limit exceeded",
259 "Command Code 5-hour window is over its cap",
260 );
261 }
262 if exwk && !was_exceeded_wk {
263 notify::cap(
264 "cmduse: limit exceeded",
265 "Command Code weekly window is over its cap",
266 );
267 }
268 was_exceeded_5h = ex5;
269 was_exceeded_wk = exwk;
270 }
271 let text = if compact {
272 compact_dashboard(&s, if burst_on { &history } else { &[] })
273 } else if args.plain || !use_color {
274 render::render_plain(&s)
275 } else {
276 render::render(&s, bar_width)
277 };
278 let spark = if compact || !burst_on {
279 String::new()
280 } else if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
281 format!(
282 "{DIM}spend bursts ({}s samples){RESET} {}\n",
283 interval,
284 render::sparkline(&history)
285 )
286 } else {
287 String::new() };
289 let update_box = update
290 .as_ref()
291 .map(|m| render::update_box(m, (cols > 0).then_some(cols), use_color && !args.plain));
292 let status_line = format!("{DIM}refreshing every {interval}s · ctrl-c to quit{RESET}");
293 let frame = match &update_box {
294 Some(u) => format!("{u}{text}{spark}{status_line}"),
295 None => format!("{text}{spark}{status_line}"),
296 };
297 prev_lines = redraw_frame(&mut out, &frame, prev_lines, (cols > 0).then_some(cols));
298 out.flush().ok();
299 for remaining in (1..interval).rev() {
301 std::thread::sleep(std::time::Duration::from_secs(1));
302 let msg = format!(
303 "\r\x1b[2K{DIM}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{RESET}"
304 );
305 write!(out, "{}", clip_to_width(&msg, (cols > 0).then_some(cols))).ok();
306 out.flush().ok();
307 }
308 }
309}
310
311fn compact_dashboard(s: &crate::render::Snapshot, history: &[f64]) -> String {
317 use crate::render::plan_name;
318 if let Some(e) = &s.err {
319 return format!("{BOLD}error{RESET} {e}\n");
320 }
321 let cap = cmduse_core::plan_monthly_cap(&s.sub.plan_id);
322 let cap_txt = cap.map(cmduse_core::money).unwrap_or_else(|| "-".into());
323 let rem_txt = cmduse_core::money(s.credits.credits.monthly_credits);
324 let h5 = s
325 .credits
326 .window_limits
327 .five_hour
328 .as_ref()
329 .map(|w| cmduse_core::money(w.used))
330 .unwrap_or_else(|| "-".into());
331 let status = if s.sub.status.is_empty() {
332 String::new()
333 } else {
334 format!(" · {}", s.sub.status)
335 };
336 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
337 format!(" {DIM}{}{RESET}", crate::render::sparkline(history))
338 } else {
339 String::new()
340 };
341 format!(
342 "{BOLD}{}{RESET} {rem_txt}/{cap_txt} · 5h {h5}{status}{spark}\n",
343 plan_name(&s.sub.plan_id)
344 )
345}
346
347pub fn term_size() -> Option<(usize, usize)> {
352 let out = std::process::Command::new("sh")
353 .arg("-c")
354 .arg("stty size < /dev/tty")
355 .output()
356 .ok()?;
357 if !out.status.success() {
358 return None;
359 }
360 let text = String::from_utf8_lossy(&out.stdout);
361 let mut it = text.split_whitespace();
362 let rows = it.next()?.parse().ok()?;
363 let cols = it.next()?.parse().ok()?;
364 Some((rows, cols))
365}
366
367pub fn clip_to_width(line: &str, cols: Option<usize>) -> String {
371 let Some(cols) = cols else { return line.into() };
372 if cols == 0 {
373 return String::new();
374 }
375 let mut vis = 0usize;
376 let mut cs = line.chars().peekable();
377 while let Some(&c) = cs.peek() {
378 match c {
379 '\x1b' => {
380 cs.next();
381 for e in cs.by_ref() {
382 if e.is_ascii_alphabetic() {
383 break;
384 }
385 }
386 }
387 c if (c as u32) < 0x20 => {
388 cs.next(); }
390 _ => {
391 vis += 1;
392 cs.next();
393 }
394 }
395 }
396 if vis <= cols {
397 return line.into();
398 }
399 let mut out = String::new();
400 let mut kept = 0usize;
401 let mut cs = line.chars();
402 while let Some(c) = cs.next() {
403 match c {
404 '\x1b' => {
405 out.push(c);
406 for e in cs.by_ref() {
407 out.push(e);
408 if e.is_ascii_alphabetic() {
409 break;
410 }
411 }
412 }
413 '\r' => out.push(c), c if (c as u32) < 0x20 => {}
415 _ => {
416 if kept < cols {
417 out.push(c);
418 kept += 1;
419 }
420 }
421 }
422 }
423 if out.ends_with("\x1b[0m") {
424 out
425 } else {
426 out + "\x1b[0m"
427 }
428}
429
430pub fn redraw_frame(
434 out: &mut impl Write,
435 frame: &str,
436 prev_lines: usize,
437 cols: Option<usize>,
438) -> usize {
439 if prev_lines > 0 {
440 write!(out, "\x1b[{}F", prev_lines - 1).ok();
442 }
443 let lines: Vec<&str> = frame.lines().collect();
444 let n = lines.len();
445 for (i, line) in lines.iter().enumerate() {
446 let line = clip_to_width(line, cols);
447 if i + 1 < n {
448 write!(out, "\x1b[2K\r{line}\n").ok();
449 } else {
450 write!(out, "\x1b[2K\r{line}").ok();
452 }
453 }
454 if n < prev_lines {
455 write!(out, "\x1b[1B").ok();
463 for _ in (n + 1)..prev_lines {
464 write!(out, "\x1b[2K\x1b[1B").ok();
465 }
466 write!(out, "\x1b[2K").ok();
467 write!(out, "\x1b[{}F", prev_lines - n).ok();
468 }
469 n
470}
471
472fn models_cmd(args: &cli::Args) {
473 if args.gated {
474 if let Some(days) = cmduse_core::gate_age_days(cmduse_core::dates::now_secs()) {
475 if days > 30 {
476 eprintln!(
477 "warning: gating snapshot is {days}d old (CLI {}) — run `bun run extract` from the repo root",
478 cmduse_core::GATE_CLI_VERSION
479 );
480 }
481 }
482 }
483 let key = match api::api_key() {
484 Ok(k) => k,
485 Err(e) => {
486 eprintln!("error: {e}");
487 std::process::exit(1);
488 }
489 };
490 let (plan_id, unlocked) = if args.gated {
492 match (api::subscriptions(&key), api::credits(&key)) {
493 (Ok(s), Ok(c)) => (
494 s.plan_id,
495 c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
496 ),
497 _ => (String::new(), true), }
499 } else {
500 (String::new(), true)
501 };
502 match api::models(&key) {
503 Ok(list) => {
504 if args.json {
505 for m in &list {
506 if args.gated {
507 let (allowed, reason) = cmduse_core::gate(&m.id, &plan_id, unlocked);
509 let line = serde_json::json!({
510 "id": m.id,
511 "name": m.name,
512 "context_length": m.context_length,
513 "owned_by": m.owned_by,
514 "allowed": allowed,
515 "reason": reason,
516 });
517 println!("{line}");
518 } else {
519 let line = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
520 println!("{line}");
521 }
522 }
523 return;
524 }
525 let list: Vec<_> = if args.gated {
526 list.into_iter()
527 .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
528 .collect()
529 } else {
530 list
531 };
532 if list.is_empty() {
533 println!("no models returned");
534 return;
535 }
536 let mut rows: Vec<_> = list
537 .iter()
538 .map(|m| {
539 let ctx = m
540 .context_length
541 .map(|n| format!("{} ctx", render::compact(n)))
542 .unwrap_or_else(|| "-".into());
543 let name = m.name.as_deref().unwrap_or(&m.id);
544 (m.id.as_str(), name, ctx)
545 })
546 .collect();
547 rows.sort_by(|a, b| a.1.cmp(b.1));
548 let more = rows.len().saturating_sub(rows_cap(args, usize::MAX));
549 for (id, name, ctx) in rows.into_iter().take(rows_cap(args, usize::MAX)) {
550 println!("{name:<36} {ctx:<10} {id}");
551 }
552 if more > 0 {
553 println!("… {more} more (--rows N to widen)");
554 }
555 }
556 Err(e) => {
557 eprintln!("error: {e}");
558 std::process::exit(1);
559 }
560 }
561}
562
563fn plans_cmd(args: &cli::Args) {
564 let current = api::api_key()
566 .ok()
567 .and_then(|k| api::subscriptions(&k).ok())
568 .map(|s| s.plan_id)
569 .unwrap_or_default();
570 if args.json {
571 print!("{}", render::plans_json(¤t));
572 } else {
573 print!("{}", render::plans_table(¤t, render::color_enabled()));
574 }
575}
576
577pub(crate) fn usage_output() -> String {
582 render::render_plain(&snapshot::snapshot(false))
583}
584
585pub(crate) fn plans_output(colors: bool) -> String {
588 let current = api::api_key()
589 .ok()
590 .and_then(|k| api::subscriptions(&k).ok())
591 .map(|s| s.plan_id)
592 .unwrap_or_default();
593 render::plans_table(¤t, colors)
594}
595
596pub(crate) fn models_output(gated: bool) -> Result<String, String> {
598 let key = api::api_key().map_err(|e| e.to_string())?;
599 let (plan_id, unlocked) = if gated {
600 match (api::subscriptions(&key), api::credits(&key)) {
601 (Ok(s), Ok(c)) => (
602 s.plan_id,
603 c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
604 ),
605 _ => (String::new(), true), }
607 } else {
608 (String::new(), true)
609 };
610 let list = api::models(&key).map_err(|e| e.to_string())?;
611 let list: Vec<_> = if gated {
612 list.into_iter()
613 .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
614 .collect()
615 } else {
616 list
617 };
618 if list.is_empty() {
619 return Ok("no models returned\n".into());
620 }
621 let mut rows: Vec<_> = list
622 .iter()
623 .map(|m| {
624 let ctx = m
625 .context_length
626 .map(|n| format!("{} ctx", render::compact(n)))
627 .unwrap_or_else(|| "-".into());
628 let name = m.name.as_deref().unwrap_or(&m.id);
629 (m.id.as_str(), name, ctx)
630 })
631 .collect();
632 rows.sort_by(|a, b| a.1.cmp(b.1));
633 let mut out = String::new();
634 for (id, name, ctx) in rows {
635 out.push_str(&format!("{name:<36} {ctx:<10} {id}\n"));
636 }
637 Ok(out)
638}
639
640pub(crate) fn daily_output(
642 days: Option<usize>,
643 tz: i64,
644 local: bool,
645 fmt: report_render::Fmt,
646) -> Result<String, String> {
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(
686 hours: Option<usize>,
687 tz: i64,
688 local: bool,
689 fmt: report_render::Fmt,
690) -> Result<String, String> {
691 let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
692 let rows = if local {
693 reports::load_local_hourly(hours.unwrap_or(24), tz)
694 } else {
695 let key = api::api_key().map_err(|e| e.to_string())?;
696 reports::load_account_hourly(hours.unwrap_or(24), &key, tz).map_err(|e| e.to_string())?
697 };
698 let subtitle = if local {
699 if tz != 0 {
700 format!("local, CLI sessions, UTC{}", tz_suffix(tz))
701 } else {
702 "local, CLI sessions".to_string()
703 }
704 } else if tz != 0 {
705 format!("all harnesses, UTC{}", tz_suffix(tz))
706 } else {
707 "all harnesses, UTC".to_string()
708 };
709 Ok(report_render::hourly_table(&rows, fmt, &subtitle, false))
710}
711
712fn local_since(since_iso: Option<&str>, days: Option<usize>) -> Option<f64> {
718 match since_iso {
719 Some(iso) => cmduse_core::dates::parse_iso_utc(iso),
720 None => {
721 days.map(|d| (cmduse_core::dates::now_secs() as f64 - (d as f64) * 86_400.0) * 1000.0)
722 }
723 }
724}
725
726pub(crate) fn model_output(
729 since_iso: Option<&str>,
730 days: Option<usize>,
731 tz: i64,
732 fmt: report_render::Fmt,
733 colors: bool,
734 rows: usize,
735) -> String {
736 let d = reports::load_local_since(tz, local_since(since_iso, days));
737 report_render::model_table(&d.by_model, fmt, colors, since_iso, rows)
738}
739
740pub(crate) fn model_detail_output(
743 id: &str,
744 since_iso: Option<&str>,
745 days: Option<usize>,
746 tz: i64,
747 fmt: report_render::Fmt,
748 colors: bool,
749) -> String {
750 let d = reports::load_local_since(tz, local_since(since_iso, days));
751 report_render::model_detail(&d.by_model, id, fmt, colors, since_iso)
752}
753
754pub(crate) fn session_output(
756 since_iso: Option<&str>,
757 days: Option<usize>,
758 tz: i64,
759 fmt: report_render::Fmt,
760 colors: bool,
761 rows: usize,
762) -> String {
763 let d = reports::load_local_since(tz, local_since(since_iso, days));
764 report_render::project_table(&d.by_project, fmt, colors, rows)
765}
766
767fn rows_cap(args: &cli::Args, default: usize) -> usize {
771 args.rows_cap(default)
772}
773
774fn output_fmt(args: &cli::Args) -> report_render::Fmt {
776 if args.json {
777 report_render::Fmt::Json
778 } else if args.csv {
779 report_render::Fmt::Csv
780 } else {
781 report_render::Fmt::Table
782 }
783}
784
785fn statusline_json(
791 plan: &str,
792 month_cap: f64,
793 month_remaining: f64,
794 five_hour: (f64, f64),
795 weekly: (f64, f64),
796) -> serde_json::Value {
797 let (h5u, h5c) = five_hour;
798 let (wku, wkc) = weekly;
799 serde_json::json!({
800 "plan": plan,
801 "monthSpent": (month_cap - month_remaining).max(0.0),
802 "monthCap": month_cap,
803 "fiveHourSpent": h5u,
804 "fiveHourCap": h5c,
805 "weeklySpent": wku,
806 "weeklyCap": wkc,
807 "monthlyRemaining": month_remaining,
808 "monthlyCap": month_cap,
809 "fiveHourUsed": h5u,
810 "weeklyUsed": wku,
811 })
812}
813
814fn statusline_cmd(args: &cli::Args) {
815 let cfg = config::load(cli::Binary::Cmduse);
816 let s = snapshot::snapshot(false);
817 let plan = render::plan_name(&s.sub.plan_id);
818 let cap = render::plan_monthly_cap(&s.sub.plan_id).unwrap_or(0.0);
819 if let Some(e) = &s.err {
820 println!("cmduse: {e}");
821 std::process::exit(1);
822 }
823 if args.json {
824 let w = |o: &Option<api::Window>| -> (f64, f64) {
825 (
826 o.as_ref().map(|w| w.used).unwrap_or(0.0),
827 o.as_ref().map(|w| w.cap).unwrap_or(0.0),
828 )
829 };
830 let out = statusline_json(
831 plan,
832 cap,
833 s.credits.credits.monthly_credits,
834 w(&s.credits.window_limits.five_hour),
835 w(&s.credits.window_limits.weekly),
836 );
837 print!("{out}");
838 return;
839 }
840 let h5 = s
841 .credits
842 .window_limits
843 .five_hour
844 .as_ref()
845 .map(|w| (w.used, w.cap));
846 let wk = s
847 .credits
848 .window_limits
849 .weekly
850 .as_ref()
851 .map(|w| (w.used, w.cap));
852 let now = s.now;
854 let eta_of = |w: &Option<api::Window>, dur: u64| -> Option<String> {
855 let w = w.as_ref()?;
856 let secs = cmduse_core::pace_eta(w.reset_at, dur, w.used, w.cap, now)?;
857 Some(format!(
858 "on pace to hit cap in {}",
859 cmduse_core::duration(secs as u64)
860 ))
861 };
862 let h5_eta = eta_of(
863 &s.credits.window_limits.five_hour,
864 cmduse_core::FIVE_HOUR_SECS,
865 );
866 let wk_eta = eta_of(&s.credits.window_limits.weekly, cmduse_core::WEEKLY_SECS);
867 let d = report_render::StatusData {
868 plan,
869 monthly_remaining: s.credits.credits.monthly_credits,
870 monthly_cap: cap,
871 five_hour: &h5,
872 weekly: &wk,
873 five_hour_eta: h5_eta,
874 weekly_eta: wk_eta,
875 bar_width: cfg.bar_width.min(30),
876 colors: cfg.statusline_colors,
877 ascii: cfg.statusline_ascii,
878 };
879 print!(
880 "{}",
881 report_render::render_statusline(&cfg.statusline_template, &d)
882 );
883}