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