1use crate::arkuse::{api, ark, build, db, mcp, render, window};
18use crate::cli::{self, Binary, SubCmd};
19use crate::report_render::Fmt;
20use std::collections::HashSet;
21use std::ffi::OsString;
22use std::io::Write;
23
24fn parse_day(value: &str) -> Option<i64> {
26 let mut parts = value.split('-');
27 let y: i64 = parts.next()?.parse().ok()?;
28 let m: i64 = parts.next()?.parse().ok()?;
29 let d: i64 = parts.next()?.parse().ok()?;
30 (1..=12).contains(&m).then_some(())?;
31 (1..=31).contains(&d).then_some(())?;
32 Some(window::days_from_civil(y, m, d) * 86_400_000)
33}
34
35const VALUE_FLAGS: &[&str] = &[
37 "-i",
38 "--interval",
39 "-w",
40 "--bar-width",
41 "-b",
42 "--bursts",
43 "--days",
44 "--last",
45 "-l",
46 "--since",
47 "--hours",
48 "--tz",
49 "--db",
50 "--plan",
51 "--profile",
52 "--window",
53 "--period-start",
54 "--rows",
55];
56
57fn split_model_id(argv: Vec<OsString>) -> (Vec<OsString>, Option<String>) {
60 let strs: Vec<String> = argv
61 .iter()
62 .map(|a| a.to_string_lossy().into_owned())
63 .collect();
64 let mut drop_at = None;
65 let mut skip_value = false;
66 let mut after_model = false;
67 for (i, tok) in strs.iter().enumerate() {
68 if skip_value {
69 skip_value = false;
70 continue;
71 }
72 if tok.starts_with('-') {
73 if VALUE_FLAGS.contains(&tok.as_str()) {
74 skip_value = true;
75 }
76 continue;
77 }
78 if after_model {
79 drop_at = Some(i);
80 break;
81 }
82 after_model = tok == "model";
83 }
84 let id = drop_at.map(|i| strs[i].clone());
85 let argv = argv
86 .into_iter()
87 .enumerate()
88 .filter(|(i, _)| Some(*i) != drop_at)
89 .map(|(_, a)| a)
90 .collect();
91 (argv, id)
92}
93
94fn wants_watch(args: &cli::Args) -> bool {
97 !args.once && !args.json
98}
99
100fn compact_frame(rows: usize, cols: usize) -> bool {
102 cols > 0 && (rows < 16 || cols < 40)
103}
104
105fn burst_config(bursts: Option<usize>, enabled: bool, samples: usize) -> (bool, usize) {
107 let on = bursts.is_some() || enabled;
108 let cap = if on {
109 bursts.or(Some(samples)).unwrap_or(40).clamp(5, 240)
110 } else {
111 0
112 };
113 (on, cap)
114}
115
116fn output_fmt(args: &cli::Args) -> Fmt {
118 if args.json {
119 Fmt::Json
120 } else if args.csv {
121 Fmt::Csv
122 } else {
123 Fmt::Table
124 }
125}
126
127fn period_end(report: &window::Report, window_mode: &str, inferred: bool) -> Option<i64> {
130 if inferred {
131 return Some(window::next_month(report.since_ms));
132 }
133 if window_mode == "all" {
134 None
135 } else if let Some(d) = window_mode
136 .strip_suffix('d')
137 .and_then(|v| v.parse::<i64>().ok())
138 {
139 Some(report.since_ms + d * 86_400_000)
140 } else {
141 Some(window::next_month(report.since_ms))
142 }
143}
144
145pub fn run() {
146 let (argv, model_id) = split_model_id(std::env::args_os().collect());
147 let args = match cli::parse_args_from(argv, Binary::Arkuse) {
148 Ok(a) => a,
149 Err(e) => {
150 eprintln!("{e}");
151 std::process::exit(2);
152 }
153 };
154 if args.help {
155 cli::usage(Binary::Arkuse);
156 return;
157 }
158 if let Some(cs) = args.config_set {
159 if let Err(e) = crate::config::set(Binary::Arkuse, &cs) {
160 eprintln!("error: {e}");
161 std::process::exit(1);
162 }
163 return;
164 }
165 if args.dismiss_update {
166 match crate::update_check::latest_newer() {
167 Ok(Some(v)) => match crate::config::set_dismissed(Binary::Arkuse, &v) {
168 Ok(()) => println!("dismissed update notice for {v}"),
169 Err(e) => {
170 eprintln!("error: {e}");
171 std::process::exit(1);
172 }
173 },
174 Ok(None) => println!("arkuse is up to date ({})", env!("CARGO_PKG_VERSION")),
175 Err(e) => {
176 eprintln!("error: {e}");
177 std::process::exit(1);
178 }
179 }
180 return;
181 }
182
183 let cfg = crate::config::load(Binary::Arkuse);
184 let colours = !args.plain && crate::render::color_enabled();
185 let fmt = output_fmt(&args);
186 let db_path = args.db.clone().unwrap_or_else(db::default_path);
187 let days: i64 = args.last.unwrap_or(120) as i64;
188 let hours: i64 = args.hours.unwrap_or(24) as i64;
189 let rows: usize = args.rows.unwrap_or(15);
190 let now = window::now_ms();
191 let tz_secs = args.tz.unwrap_or(0);
192
193 if let Some(SubCmd::Plans) = args.subcmd {
195 if args.json {
196 print!("{}", render::plans_json());
197 } else {
198 print!("{}", render::plans_text(colours));
199 }
200 return;
201 }
202
203 let resolved = if args.no_api {
207 None
208 } else {
209 api::resolve(args.profile.as_deref())
210 };
211 let profile = args
212 .profile
213 .clone()
214 .or_else(|| {
215 std::env::var("ARKUSE_PROFILE")
216 .ok()
217 .filter(|v| !v.is_empty())
218 })
219 .or_else(|| resolved.as_ref().map(|(name, _)| name.clone()));
220 let tier = args
221 .plan
222 .clone()
223 .or_else(|| resolved.as_ref().and_then(|(_, tier)| tier.clone()))
224 .unwrap_or_else(|| ark::DEFAULT_PLAN.to_string());
225 let catalog = ark::catalog();
226 if catalog.plan(&tier).is_none() {
227 eprintln!(
228 "arkuse: --plan expects one of {} (got {tier})",
229 catalog.plan_ids().join(", ")
230 );
231 std::process::exit(2);
232 }
233
234 if let Some(SubCmd::Models) = args.subcmd {
236 if args.json {
237 print!("{}", render::models_json(&tier, args.gated));
238 } else {
239 print!("{}", render::models_text(&tier, args.gated, colours));
240 }
241 return;
242 }
243
244 let since_override = args
247 .since
248 .as_deref()
249 .and_then(cmduse_core::dates::parse_iso_utc)
250 .map(|ms| ms as i64);
251
252 let window_mode = args.window.clone().unwrap_or_else(|| "month".into());
256 let period_start = if let Some(ms) = since_override {
257 Some(ms)
258 } else if let Some(day) = args.period_start.as_deref().and_then(parse_day) {
259 Some(day)
260 } else if window_mode == "all" {
261 Some(0)
262 } else if window_mode == "month" {
263 Some(window::calendar_month_start(now))
264 } else if let Some(d) = window_mode
265 .strip_suffix('d')
266 .and_then(|v| v.parse::<i64>().ok())
267 {
268 Some(now - d * 86_400_000)
269 } else {
270 eprintln!("arkuse: --window expects month, all, or <n>d (got {window_mode})");
271 std::process::exit(2);
272 };
273 let period_label = if let Some(iso) = &args.since {
274 format!("since {iso}")
275 } else if args.period_start.is_some() {
276 "period start (pinned)".to_string()
277 } else {
278 match window_mode.as_str() {
279 "all" => "all time".to_string(),
280 "month" => "calendar month".to_string(),
281 other => other.to_string(),
282 }
283 };
284 let period_start =
285 if args.infer_anniversary && args.period_start.is_none() && args.since.is_none() {
286 None
287 } else {
288 period_start
289 };
290 let period_inferred = period_start.is_none();
291
292 let fetch_account = || -> Result<api::Usage, api::Unavailable> {
296 if args.no_api {
297 return Err(api::Unavailable::Disabled);
298 }
299 match &profile {
300 Some(name) => api::fetch(name),
301 None => Err(api::Unavailable::NoProfile),
302 }
303 };
304 let (mut account, mut account_error) = match fetch_account() {
305 Ok(usage) => (Some(usage), None),
306 Err(why) => (None, Some(why.reason())),
307 };
308 let account_month = account.as_ref().and_then(|a| {
314 let reset = a.monthly.reset_ms()?;
315 Some((
316 window::month_start_before(reset, a.monthly.offset_secs()),
317 reset,
318 ))
319 });
320 let account_month = if window_mode == "month"
321 && !period_inferred
322 && since_override.is_none()
323 && args.period_start.is_none()
324 {
325 account_month
326 } else {
327 None
328 };
329 let period_start = account_month.map(|(start, _)| start).or(period_start);
330 let period_label = match account_month {
331 Some(_) => "Agent Plan billing month".to_string(),
332 None => period_label,
333 };
334
335 let read_since = |since_ms: i64| match db::read_rows(&db_path, since_ms) {
336 Ok(rows) => rows,
337 Err(error) => {
338 eprintln!("arkuse: {error}");
339 eprintln!("hint: --db <path>, or OPENCODE_DB; the store is created by opencode itself");
340 std::process::exit(1);
341 }
342 };
343 let report_window = since_override.unwrap_or(now - days * 86_400_000);
345 let build_at = |t: i64, account: Option<&api::Usage>, account_error: Option<&str>| {
346 let mut report = build(
347 &read_since(report_window),
348 &catalog,
349 &tier,
350 t,
351 period_start,
352 account,
353 );
354 report.period_label = period_label.clone();
355 report.account = account.cloned();
356 report.account_error = account_error.map(str::to_string);
357 report.tier = tier.clone();
358 report.profile = profile.clone();
359 report.period_end_ms = account_month
360 .map(|(_, reset)| reset)
361 .or_else(|| period_end(&report, &window_mode, period_inferred));
362 report
363 };
364
365 match args.subcmd {
366 Some(SubCmd::Statusline) => {
367 let report = build_at(now, account.as_ref(), account_error.as_deref());
368 if args.json {
369 println!("{}", render::statusline_json(&report, &tier));
370 } else {
371 print!(
372 "{}",
373 render::statusline_template(
374 &report,
375 &tier,
376 &cfg.statusline_template,
377 cfg.statusline_colors,
378 cfg.statusline_ascii,
379 cfg.bar_width.min(30),
380 )
381 );
382 }
383 return;
384 }
385 Some(SubCmd::Daily) => {
386 print!(
387 "{}",
388 render::bucket_text(
389 &read_since(now - days * 86_400_000),
390 &catalog,
391 now,
392 days * 86_400_000,
393 86_400_000,
394 tz_secs,
395 colours,
396 fmt,
397 )
398 );
399 return;
400 }
401 Some(SubCmd::Hours) => {
402 print!(
403 "{}",
404 render::bucket_text(
405 &read_since(now - hours * 3_600_000),
406 &catalog,
407 now,
408 hours * 3_600_000,
409 3_600_000,
410 tz_secs,
411 colours,
412 fmt,
413 )
414 );
415 return;
416 }
417 Some(SubCmd::Session) => {
418 print!(
419 "{}",
420 render::session_text(
421 &read_since(report_window),
422 &catalog,
423 args.rows_cap(20),
424 colours,
425 fmt
426 )
427 );
428 return;
429 }
430 Some(SubCmd::Mcp) => {
431 mcp::run(mcp::Settings {
433 db_path: &db_path,
434 days,
435 tz_secs,
436 colour: false,
437 tier: &tier,
438 period_start,
439 account_month,
440 account: account.as_ref(),
441 account_error: account_error.as_deref(),
442 });
443 return;
444 }
445 Some(SubCmd::Model) => {
446 let report = build_at(now, account.as_ref(), account_error.as_deref());
447 if fmt == Fmt::Table {
448 match &model_id {
449 Some(id) => print!("{}", render::model_text(&report, id, colours)),
450 None => print!(
451 "{}",
452 render::models_period_text(&report, args.rows_cap(usize::MAX), colours)
453 ),
454 }
455 } else {
456 print!(
457 "{}",
458 render::model_report(&report, model_id.as_deref(), fmt)
459 );
460 }
461 return;
462 }
463 Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
465 None => {}
466 }
467
468 let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
470 let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
471 let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
472 let notify_on_cap = cfg.notify_on_cap;
473
474 let update =
477 crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
478
479 if !wants_watch(&args) {
480 if let Some(msg) = &update {
481 eprintln!("{msg}");
482 }
483 let report = build_at(now, account.as_ref(), account_error.as_deref());
484 if args.json {
485 println!("{}", render::render_json(&report));
486 } else {
487 print!(
488 "{}",
489 render::render_text(&report, &db_path, rows, colours, bar_width)
490 );
491 }
492 return;
493 }
494
495 let ink = render::Ink::new(colours);
499 let mut out = std::io::stdout();
500 let mut prev_lines = 0usize;
501 let mut history: Vec<f64> = Vec::new();
502 let mut last_five: Option<f64> = None;
503 let mut over: HashSet<String> = HashSet::new();
507 let use_color = crate::render::color_enabled();
508 loop {
509 match fetch_account() {
513 Ok(fresh) => {
514 account = Some(fresh);
515 account_error = None;
516 }
517 Err(why) => {
518 if account.is_none() {
519 account_error = Some(why.reason());
520 }
521 }
522 }
523 let report = build_at(window::now_ms(), account.as_ref(), account_error.as_deref());
524 let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
525 let cols_opt = (cols > 0).then_some(cols);
526 let compact = compact_frame(term_rows, cols);
527 let delta = last_five
530 .map(|prev| (report.five_hour.afp - prev).max(0.0))
531 .unwrap_or(0.0);
532 last_five = Some(report.five_hour.afp);
533 if burst_on {
534 history.push(delta);
535 if history.len() > burst_cap {
536 history.remove(0);
537 }
538 }
539 if notify_on_cap {
540 let mut over_now: HashSet<String> = HashSet::new();
541 if let Some(a) = &account {
542 for (key, label, meter) in [
543 ("window:5h", "5-hour window", &a.five_hour),
544 ("window:wk", "weekly window", &a.weekly),
545 ("window:mo", "monthly quota", &a.monthly),
546 ] {
547 if meter.exceeded() {
548 over_now.insert(key.to_string());
549 if !over.contains(key) {
550 crate::notify::cap(
551 "arkuse: limit exceeded",
552 &format!("ARK Agent Plan {label} is over its limit"),
553 );
554 }
555 }
556 }
557 }
558 over = over_now;
559 }
560 let burst_slice: &[f64] = if burst_on { &history } else { &[] };
561 let text = if compact {
562 render::compact_line(&report, burst_slice, colours)
563 } else {
564 render::watch_frame(
565 &report,
566 &db_path,
567 rows,
568 colours,
569 burst_slice,
570 interval,
571 bar_width,
572 )
573 };
574 let update_box = update
575 .as_ref()
576 .map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
577 let status = format!(
578 "{}refreshing every {interval}s · ctrl-c to quit{}",
579 ink.dim, ink.reset
580 );
581 let frame = match &update_box {
582 Some(u) => format!("{u}{text}\n{status}"),
583 None => format!("{text}\n{status}"),
584 };
585 prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
586 let _ = out.flush();
587 for remaining in (1..interval).rev() {
588 std::thread::sleep(std::time::Duration::from_secs(1));
589 let msg = format!(
590 "\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
591 ink.dim, ink.reset
592 );
593 let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
594 let _ = out.flush();
595 }
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 fn argv(args: &[&str]) -> Vec<OsString> {
604 std::iter::once(OsString::from("arkuse"))
605 .chain(args.iter().map(OsString::from))
606 .collect()
607 }
608
609 fn parse(args: &[&str]) -> cli::Args {
610 cli::parse_args_from(argv(args), Binary::Arkuse).expect("parses")
611 }
612
613 #[test]
614 fn json_and_one_shot_leave_the_watch_loop() {
615 assert!(wants_watch(&parse(&[])));
616 assert!(wants_watch(&parse(&["-W"])));
617 assert!(!wants_watch(&parse(&["-1"])));
618 assert!(!wants_watch(&parse(&["--once"])));
619 assert!(!wants_watch(&parse(&["--json"])));
620 }
621
622 #[test]
623 fn compact_frame_switches_on_small_terminals() {
624 assert!(!compact_frame(40, 120));
625 assert!(compact_frame(10, 120));
626 assert!(compact_frame(40, 30));
627 assert!(!compact_frame(0, 0));
628 }
629
630 #[test]
631 fn burst_state_derives_from_args_and_config() {
632 assert_eq!(burst_config(None, false, 40), (false, 0));
633 assert_eq!(burst_config(Some(30), false, 40), (true, 30));
634 assert_eq!(burst_config(None, true, 25), (true, 25));
635 assert_eq!(burst_config(Some(60), true, 25), (true, 60));
636 assert_eq!(burst_config(None, true, 3), (true, 5));
637 }
638
639 #[test]
640 fn the_ark_flags_are_shared_and_the_profile_is_ark_only() {
641 let a = parse(&["models", "--gated", "--plan", "large", "--profile", "p"]);
642 assert!(a.gated);
643 assert_eq!(a.subcmd, Some(SubCmd::Models));
644 assert_eq!(a.plan.as_deref(), Some("large"));
645 assert_eq!(a.profile.as_deref(), Some("p"));
646 assert!(cli::parse_args_from(argv(&["--profile", "p"]), Binary::Ocuse).is_err());
649 }
650
651 #[test]
652 fn no_api_is_available_to_arkuse_too() {
653 assert!(parse(&["-1", "--no-api"]).no_api);
654 assert!(!parse(&["-1"]).no_api);
655 }
656
657 #[test]
658 fn output_fmt_maps_the_flags() {
659 let of = |args: &[&str]| output_fmt(&parse(args));
660 assert!(matches!(of(&[]), Fmt::Table));
661 assert!(matches!(of(&["--json"]), Fmt::Json));
662 assert!(matches!(of(&["--csv"]), Fmt::Csv));
663 }
664
665 #[test]
666 fn period_end_resolves_from_the_reports_own_start() {
667 let since = window::days_from_civil(2026, 9, 15) * 86_400_000;
668 let report = window::Report {
669 period_label: "calendar month".into(),
670 now_ms: since,
671 since_ms: since,
672 period_end_ms: None,
673 month_inferred: false,
674 account: None,
675 account_error: None,
676 tier: "medium".into(),
677 profile: None,
678 offset_secs: 0,
679 five_hour: Default::default(),
680 weekly: Default::default(),
681 month: Default::default(),
682 daily: None,
683 models: Vec::new(),
684 };
685 assert_eq!(
686 period_end(&report, "month", false),
687 Some(window::next_month(since))
688 );
689 assert_eq!(
690 period_end(&report, "30d", false),
691 Some(since + 30 * 86_400_000)
692 );
693 assert_eq!(period_end(&report, "all", false), None);
694 assert_eq!(
695 period_end(&report, "30d", true),
696 Some(window::next_month(since))
697 );
698 }
699
700 #[test]
701 fn split_model_id_lifts_only_the_positional_after_model() {
702 let (rest, id) = split_model_id(argv(&["model", "deepseek-v4.1-flash", "-1"]));
703 assert_eq!(id.as_deref(), Some("deepseek-v4.1-flash"));
704 assert_eq!(rest.len(), 3, "the id is gone, the flags and argv[0] stay");
705 assert_eq!(rest[2], OsString::from("-1"));
706 let (_, id) = split_model_id(argv(&["--plan", "medium", "model"]));
708 assert_eq!(id, None);
709 }
710}