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_all(&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(
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            // stdio JSON-RPC; stdout carries protocol only, so nothing else may print.
432            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        // served above: plans and models need no store read
464        Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
465        None => {}
466    }
467
468    // Dashboard (the default command): one-shot or live watch.
469    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    // Update check before any output. Watch mode draws the notice inside the
475    // redrawn frame; one-shot has no frame, so it goes to stderr.
476    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    // Live frame, cmduse's redraw contract: the frame's last line carries no
496    // newline, so the cursor parks there and the countdown rewrites that one line
497    // each second instead of repainting the whole frame.
498    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    // Window cap-hit notifications: fire once on the rising edge into "over".
504    // ARK limits the plan's windows, not any one model, so the meters are the
505    // only source with a verdict.
506    let mut over: HashSet<String> = HashSet::new();
507    let use_color = crate::render::color_enabled();
508    loop {
509        // Re-ask each tick, keeping the last answer through a blip so the frame
510        // cannot flip between the meters and the local estimate. A blip's reason
511        // is dropped with it: the rows on screen are still the account's.
512        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        // The 5-hour figure only rises between refreshes, so the delta is the
528        // burst; a sparkline of the cumulative figure would just stay full.
529        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        // The tier flag exists for ocuse too; the profile does not — ocuse's
647        // credential is the opencode store.
648        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            clients: Vec::new(),
677            tier: "medium".into(),
678            profile: None,
679            offset_secs: 0,
680            five_hour: Default::default(),
681            weekly: Default::default(),
682            month: Default::default(),
683            daily: None,
684            models: Vec::new(),
685        };
686        assert_eq!(
687            period_end(&report, "month", false),
688            Some(window::next_month(since))
689        );
690        assert_eq!(
691            period_end(&report, "30d", false),
692            Some(since + 30 * 86_400_000)
693        );
694        assert_eq!(period_end(&report, "all", false), None);
695        assert_eq!(
696            period_end(&report, "30d", true),
697            Some(window::next_month(since))
698        );
699    }
700
701    #[test]
702    fn split_model_id_lifts_only_the_positional_after_model() {
703        let (rest, id) = split_model_id(argv(&["model", "deepseek-v4.1-flash", "-1"]));
704        assert_eq!(id.as_deref(), Some("deepseek-v4.1-flash"));
705        assert_eq!(rest.len(), 3, "the id is gone, the flags and argv[0] stay");
706        assert_eq!(rest[2], OsString::from("-1"));
707        // A flag value that looks like an id is not one.
708        let (_, id) = split_model_id(argv(&["--plan", "medium", "model"]));
709        assert_eq!(id, None);
710    }
711}