Skip to main content

cmd_usage/arkuse/
cli.rs

1// arkuse — Volcengine ARK Agent Plan usage, same CLI shape as cmduse and ocuse.
2//
3// Data: `arkcli`'s control-plane meters (the console's own 5-hour / weekly /
4// monthly figures, via `arkuse::api`), opencode's store read for `agent-plan`
5// rows only (per-model attribution, `arkuse::db`), and the hand-maintained
6// catalogue in `core/ark.json` (`arkuse::ark`).
7//
8// The unit is AFP, the plan's own currency: 1 AFP = ¥0.002 at every tier. This
9// binary never prints dollars, and never uses opencode's recorded `cost` — ARK
10// is subscription-billed, so the store holds tokens and no money that applies to
11// this plan.
12//
13// The front-end is shared: `cmduse/src/cli.rs` parses argv for all three
14// binaries, `config.rs` owns the config files, `notify.rs` the desktop notice,
15// `update_check.rs` the crates.io line. Lives in the library so `arkuse` and its
16// `arkusedev` twin share one entry point.
17use 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
24/// `YYYY-MM-DD` -> epoch ms at 00:00 UTC.
25fn 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
35/// Flags whose value is the next argv token (mirrors the shared parser).
36const 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
57/// `arkuse model <id>` takes a positional model id the shared parser (written
58/// for cmduse, whose `model` takes none) rejects, so pull it out first.
59fn 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
94/// The live watch loop is the default, but `-1`/`--once` and `--json` want a
95/// single frame and an exit.
96fn wants_watch(args: &cli::Args) -> bool {
97    !args.once && !args.json
98}
99
100/// cmduse's small-terminal rule: one clipped line instead of the full frame.
101fn compact_frame(rows: usize, cols: usize) -> bool {
102    cols > 0 && (rows < 16 || cols < 40)
103}
104
105/// Spend-burst sparkline derivation, identical to cmduse's and ocuse's.
106fn 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
116/// Report output format, mirroring cmduse's (`--json` / `--csv` / table).
117fn 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
127/// The period's end (epoch ms), resolved from the report's own start so an
128/// inferred anniversary gets one too. `--window all` has no boundary.
129fn 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    // `plans` needs neither the store nor a profile: the catalogue is static.
194    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    // The profile names both the control-plane account and its tier. `--no-api`
204    // is a promise not to shell out at all, so the tier then comes only from
205    // `--plan` (else the catalogue default) and no profile is resolved.
206    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    // `models` is the catalogue joined with one tier's grants: no store read.
235    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    // `--since` (ISO-8601) pins the report window for model/session, as in the
245    // other two binaries; `parse_iso_utc` yields epoch milliseconds.
246    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    // The period: explicit start > window mode > calendar month. Inference from
253    // the first `agent-plan` request is opt-in because a sparse history guesses
254    // the wrong anniversary.
255    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    // The control plane's meters. A missing profile, a missing `arkcli`, or a
293    // refused call leaves `None` and a named reason; every window row then falls
294    // back to the local estimate, labelled as such.
295    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    // The meter knows the billing anniversary; the calendar month is a guess, and
309    // slicing by it would disagree with the monthly meter printed beside it. So
310    // when the endpoint answered and nothing pinned the period, take the
311    // account's own month: ARK resets at 23:59:59 of the anniversary day, in the
312    // server's offset, so the start is 00:00 on that day-of-month one month back.
313    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    // The report's own read window: an explicit --since pins it, else --days.
344    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(&read_since(report_window), &catalog, 20, colours, fmt)
421            );
422            return;
423        }
424        Some(SubCmd::Mcp) => {
425            // stdio JSON-RPC; stdout carries protocol only, so nothing else may print.
426            mcp::run(mcp::Settings {
427                db_path: &db_path,
428                days,
429                tz_secs,
430                colour: false,
431                tier: &tier,
432                period_start,
433                account_month,
434                account: account.as_ref(),
435                account_error: account_error.as_deref(),
436            });
437            return;
438        }
439        Some(SubCmd::Model) => {
440            let report = build_at(now, account.as_ref(), account_error.as_deref());
441            if fmt == Fmt::Table {
442                match &model_id {
443                    Some(id) => print!("{}", render::model_text(&report, id, colours)),
444                    None => print!(
445                        "{}",
446                        render::models_period_text(
447                            &report,
448                            args.rows.unwrap_or(usize::MAX),
449                            colours
450                        )
451                    ),
452                }
453            } else {
454                print!(
455                    "{}",
456                    render::model_report(&report, model_id.as_deref(), fmt)
457                );
458            }
459            return;
460        }
461        // served above: plans and models need no store read
462        Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
463        None => {}
464    }
465
466    // Dashboard (the default command): one-shot or live watch.
467    let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
468    let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
469    let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
470    let notify_on_cap = cfg.notify_on_cap;
471
472    // Update check before any output. Watch mode draws the notice inside the
473    // redrawn frame; one-shot has no frame, so it goes to stderr.
474    let update =
475        crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
476
477    if !wants_watch(&args) {
478        if let Some(msg) = &update {
479            eprintln!("{msg}");
480        }
481        let report = build_at(now, account.as_ref(), account_error.as_deref());
482        if args.json {
483            println!("{}", render::render_json(&report));
484        } else {
485            print!(
486                "{}",
487                render::render_text(&report, &db_path, rows, colours, bar_width)
488            );
489        }
490        return;
491    }
492
493    // Live frame, cmduse's redraw contract: the frame's last line carries no
494    // newline, so the cursor parks there and the countdown rewrites that one line
495    // each second instead of repainting the whole frame.
496    let ink = render::Ink::new(colours);
497    let mut out = std::io::stdout();
498    let mut prev_lines = 0usize;
499    let mut history: Vec<f64> = Vec::new();
500    let mut last_five: Option<f64> = None;
501    // Window cap-hit notifications: fire once on the rising edge into "over".
502    // ARK limits the plan's windows, not any one model, so the meters are the
503    // only source with a verdict.
504    let mut over: HashSet<String> = HashSet::new();
505    let use_color = crate::render::color_enabled();
506    loop {
507        // Re-ask each tick, keeping the last answer through a blip so the frame
508        // cannot flip between the meters and the local estimate. A blip's reason
509        // is dropped with it: the rows on screen are still the account's.
510        match fetch_account() {
511            Ok(fresh) => {
512                account = Some(fresh);
513                account_error = None;
514            }
515            Err(why) => {
516                if account.is_none() {
517                    account_error = Some(why.reason());
518                }
519            }
520        }
521        let report = build_at(window::now_ms(), account.as_ref(), account_error.as_deref());
522        let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
523        let cols_opt = (cols > 0).then_some(cols);
524        let compact = compact_frame(term_rows, cols);
525        // The 5-hour figure only rises between refreshes, so the delta is the
526        // burst; a sparkline of the cumulative figure would just stay full.
527        let delta = last_five
528            .map(|prev| (report.five_hour.afp - prev).max(0.0))
529            .unwrap_or(0.0);
530        last_five = Some(report.five_hour.afp);
531        if burst_on {
532            history.push(delta);
533            if history.len() > burst_cap {
534                history.remove(0);
535            }
536        }
537        if notify_on_cap {
538            let mut over_now: HashSet<String> = HashSet::new();
539            if let Some(a) = &account {
540                for (key, label, meter) in [
541                    ("window:5h", "5-hour window", &a.five_hour),
542                    ("window:wk", "weekly window", &a.weekly),
543                    ("window:mo", "monthly quota", &a.monthly),
544                ] {
545                    if meter.exceeded() {
546                        over_now.insert(key.to_string());
547                        if !over.contains(key) {
548                            crate::notify::cap(
549                                "arkuse: limit exceeded",
550                                &format!("ARK Agent Plan {label} is over its limit"),
551                            );
552                        }
553                    }
554                }
555            }
556            over = over_now;
557        }
558        let burst_slice: &[f64] = if burst_on { &history } else { &[] };
559        let text = if compact {
560            render::compact_line(&report, burst_slice, colours)
561        } else {
562            render::watch_frame(
563                &report,
564                &db_path,
565                rows,
566                colours,
567                burst_slice,
568                interval,
569                bar_width,
570            )
571        };
572        let update_box = update
573            .as_ref()
574            .map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
575        let status = format!(
576            "{}refreshing every {interval}s · ctrl-c to quit{}",
577            ink.dim, ink.reset
578        );
579        let frame = match &update_box {
580            Some(u) => format!("{u}{text}\n{status}"),
581            None => format!("{text}\n{status}"),
582        };
583        prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
584        let _ = out.flush();
585        for remaining in (1..interval).rev() {
586            std::thread::sleep(std::time::Duration::from_secs(1));
587            let msg = format!(
588                "\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
589                ink.dim, ink.reset
590            );
591            let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
592            let _ = out.flush();
593        }
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600
601    fn argv(args: &[&str]) -> Vec<OsString> {
602        std::iter::once(OsString::from("arkuse"))
603            .chain(args.iter().map(OsString::from))
604            .collect()
605    }
606
607    fn parse(args: &[&str]) -> cli::Args {
608        cli::parse_args_from(argv(args), Binary::Arkuse).expect("parses")
609    }
610
611    #[test]
612    fn json_and_one_shot_leave_the_watch_loop() {
613        assert!(wants_watch(&parse(&[])));
614        assert!(wants_watch(&parse(&["-W"])));
615        assert!(!wants_watch(&parse(&["-1"])));
616        assert!(!wants_watch(&parse(&["--once"])));
617        assert!(!wants_watch(&parse(&["--json"])));
618    }
619
620    #[test]
621    fn compact_frame_switches_on_small_terminals() {
622        assert!(!compact_frame(40, 120));
623        assert!(compact_frame(10, 120));
624        assert!(compact_frame(40, 30));
625        assert!(!compact_frame(0, 0));
626    }
627
628    #[test]
629    fn burst_state_derives_from_args_and_config() {
630        assert_eq!(burst_config(None, false, 40), (false, 0));
631        assert_eq!(burst_config(Some(30), false, 40), (true, 30));
632        assert_eq!(burst_config(None, true, 25), (true, 25));
633        assert_eq!(burst_config(Some(60), true, 25), (true, 60));
634        assert_eq!(burst_config(None, true, 3), (true, 5));
635    }
636
637    #[test]
638    fn the_ark_flags_are_shared_and_the_profile_is_ark_only() {
639        let a = parse(&["models", "--gated", "--plan", "large", "--profile", "p"]);
640        assert!(a.gated);
641        assert_eq!(a.subcmd, Some(SubCmd::Models));
642        assert_eq!(a.plan.as_deref(), Some("large"));
643        assert_eq!(a.profile.as_deref(), Some("p"));
644        // The tier flag exists for ocuse too; the profile does not — ocuse's
645        // credential is the opencode store.
646        assert!(cli::parse_args_from(argv(&["--profile", "p"]), Binary::Ocuse).is_err());
647    }
648
649    #[test]
650    fn no_api_is_available_to_arkuse_too() {
651        assert!(parse(&["-1", "--no-api"]).no_api);
652        assert!(!parse(&["-1"]).no_api);
653    }
654
655    #[test]
656    fn output_fmt_maps_the_flags() {
657        let of = |args: &[&str]| output_fmt(&parse(args));
658        assert!(matches!(of(&[]), Fmt::Table));
659        assert!(matches!(of(&["--json"]), Fmt::Json));
660        assert!(matches!(of(&["--csv"]), Fmt::Csv));
661    }
662
663    #[test]
664    fn period_end_resolves_from_the_reports_own_start() {
665        let since = window::days_from_civil(2026, 9, 15) * 86_400_000;
666        let report = window::Report {
667            period_label: "calendar month".into(),
668            now_ms: since,
669            since_ms: since,
670            period_end_ms: None,
671            month_inferred: false,
672            account: None,
673            account_error: None,
674            tier: "medium".into(),
675            profile: None,
676            offset_secs: 0,
677            five_hour: Default::default(),
678            weekly: Default::default(),
679            month: Default::default(),
680            daily: None,
681            models: Vec::new(),
682        };
683        assert_eq!(
684            period_end(&report, "month", false),
685            Some(window::next_month(since))
686        );
687        assert_eq!(
688            period_end(&report, "30d", false),
689            Some(since + 30 * 86_400_000)
690        );
691        assert_eq!(period_end(&report, "all", false), None);
692        assert_eq!(
693            period_end(&report, "30d", true),
694            Some(window::next_month(since))
695        );
696    }
697
698    #[test]
699    fn split_model_id_lifts_only_the_positional_after_model() {
700        let (rest, id) = split_model_id(argv(&["model", "deepseek-v4.1-flash", "-1"]));
701        assert_eq!(id.as_deref(), Some("deepseek-v4.1-flash"));
702        assert_eq!(rest.len(), 3, "the id is gone, the flags and argv[0] stay");
703        assert_eq!(rest[2], OsString::from("-1"));
704        // A flag value that looks like an id is not one.
705        let (_, id) = split_model_id(argv(&["--plan", "medium", "model"]));
706        assert_eq!(id, None);
707    }
708}