pi-ark-usage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * `arkcli` process wrapper: locate the binary, invoke `arkcli usage plan`,
3
+ * parse and validate its JSON output.
4
+ */
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { promisify } from "node:util";
8
+ import type { ArkPlanOutput, ArkPlanItem, ArkPeriod } from "./types.js";
9
+
10
+ const execFileAsync = promisify(execFile);
11
+
12
+ const DEFAULT_TIMEOUT_MS = 10_000;
13
+ const MAX_JSON_BYTES = 1024 * 1024;
14
+
15
+ const PRODUCT_IDS = new Set([
16
+ "coding-plan",
17
+ "agent-plan",
18
+ "coding-plan-team",
19
+ "agent-plan-team",
20
+ ]);
21
+
22
+ /** Locate arkcli: explicit env override, else plain name (resolved via PATH). */
23
+ export function arkcliBin(): string {
24
+ return process.env.PI_ARK_USAGE_ARKCLI || "arkcli";
25
+ }
26
+
27
+ export async function checkArkcliAvailable(): Promise<boolean> {
28
+ try {
29
+ await execFileAsync(arkcliBin(), ["--help"], { timeout: 5_000 });
30
+ return true;
31
+ } catch {
32
+ // --help may exit non-zero in odd wrappers; fall back to version check.
33
+ try {
34
+ await execFileAsync(arkcliBin(), ["version"], { timeout: 5_000 });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ }
41
+
42
+ function isFiniteNumber(v: unknown): v is number {
43
+ return typeof v === "number" && Number.isFinite(v);
44
+ }
45
+
46
+ function validatePeriod(raw: unknown): ArkPeriod | null {
47
+ if (!raw || typeof raw !== "object") return null;
48
+ const p = raw as Record<string, unknown>;
49
+ if (typeof p.label !== "string" || p.label.length === 0) return null;
50
+ if (!isFiniteNumber(p.percent) || p.percent < 0) return null;
51
+ const out: ArkPeriod = {
52
+ label: p.label,
53
+ percent: Math.min(p.percent, 100),
54
+ };
55
+ if (typeof p.reset_at === "string" && p.reset_at.length > 0) {
56
+ out.reset_at = p.reset_at;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function validateItem(raw: unknown): ArkPlanItem | null {
62
+ if (!raw || typeof raw !== "object") return null;
63
+ const it = raw as Record<string, unknown>;
64
+ if (typeof it.product !== "string" || !PRODUCT_IDS.has(it.product)) return null;
65
+ if (typeof it.edition !== "string") return null;
66
+ const out: ArkPlanItem = {
67
+ product: it.product,
68
+ edition: it.edition,
69
+ subscribed: it.subscribed === true,
70
+ };
71
+ if (typeof it.error === "string") out.error = it.error;
72
+ if (isFiniteNumber(it.updated_at)) out.updated_at = it.updated_at;
73
+ if (Array.isArray(it.periods)) {
74
+ const periods = it.periods
75
+ .map(validatePeriod)
76
+ .filter((p): p is ArkPeriod => p !== null);
77
+ if (periods.length > 0) out.periods = periods;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ /** Parse and validate raw arkcli JSON. Throws on malformed payload. */
83
+ export function parsePlanOutput(raw: string): ArkPlanOutput {
84
+ if (raw.length > MAX_JSON_BYTES) throw new Error("arkcli output too large");
85
+ const json: unknown = JSON.parse(raw);
86
+ if (!json || typeof json !== "object") throw new Error("invalid arkcli output");
87
+ const root = json as Record<string, unknown>;
88
+ if (!Array.isArray(root.items)) throw new Error("invalid arkcli output: missing items");
89
+ const items = root.items
90
+ .map(validateItem)
91
+ .filter((it): it is ArkPlanItem => it !== null);
92
+ if (items.length === 0) throw new Error("arkcli returned no usable items");
93
+ const out: ArkPlanOutput = { items };
94
+ if (root.viewer && typeof root.viewer === "object") {
95
+ out.viewer = root.viewer as ArkPlanOutput["viewer"];
96
+ }
97
+ return out;
98
+ }
99
+
100
+ export type FetchPlanOptions = {
101
+ signal?: AbortSignal;
102
+ timeoutMs?: number;
103
+ /** specific product id; undefined = auto-discover all subscriptions */
104
+ product?: string;
105
+ seat?: string;
106
+ };
107
+
108
+ /**
109
+ * Run `arkcli usage plan --format json`.
110
+ * Authentication is fully handled by arkcli itself (SSO profiles etc.).
111
+ */
112
+ export async function fetchPlan(options: FetchPlanOptions = {}): Promise<ArkPlanOutput> {
113
+ const args = ["usage", "plan", "--format", "json"];
114
+ if (options.product) {
115
+ args.push("--product", options.product);
116
+ }
117
+ if (options.seat) {
118
+ args.push("--seat", options.seat);
119
+ }
120
+
121
+ const { stdout } = await execFileAsync(arkcliBin(), args, {
122
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
123
+ maxBuffer: MAX_JSON_BYTES,
124
+ signal: options.signal,
125
+ // arkcli may print warnings on stderr; they don't fail the call unless exit code != 0.
126
+ windowsHide: true,
127
+ });
128
+ return parsePlanOutput(stdout);
129
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Durable cache: persists settings and latest snapshots across restarts.
3
+ *
4
+ * File: ~/.pi/agent/pi-ark-usage/cache.json (0600), written via temp-file
5
+ * rename to survive crashes, mirroring pi-check-agent-quota's safe-write
6
+ * approach but simplified.
7
+ */
8
+
9
+ import {
10
+ closeSync,
11
+ constants as fsConstants,
12
+ fchmodSync,
13
+ fstatSync,
14
+ openSync,
15
+ readFileSync,
16
+ } from "node:fs";
17
+ import { chmod, lstat, mkdir, open, rename, unlink } from "node:fs/promises";
18
+ import { randomUUID } from "node:crypto";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+ import { DEFAULT_SETTINGS, getSettings, patchSettings } from "./widget.js";
22
+ import type {
23
+ ArkSettings,
24
+ DiskCache,
25
+ Language,
26
+ PlanSnapshot,
27
+ } from "./types.js";
28
+
29
+ export const CACHE_DIR = join(homedir(), ".pi", "agent", "pi-ark-usage");
30
+ export const CACHE_FILE = join(CACHE_DIR, "cache.json");
31
+
32
+ const FILE_MODE = 0o600;
33
+ const DIR_MODE = 0o700;
34
+ const MAX_BYTES = 256 * 1024;
35
+
36
+ function isFiniteNumber(v: unknown): v is number {
37
+ return typeof v === "number" && Number.isFinite(v);
38
+ }
39
+
40
+ function validateSnapshot(raw: unknown): PlanSnapshot | null {
41
+ if (!raw || typeof raw !== "object") return null;
42
+ const s = raw as Record<string, unknown>;
43
+ if (typeof s.product !== "string" || typeof s.edition !== "string") return null;
44
+ if (!isFiniteNumber(s.fetchedAt)) return null;
45
+ if (!Array.isArray(s.periods) || s.periods.length === 0) return null;
46
+ const periods = s.periods
47
+ .filter((p): p is Record<string, unknown> => !!p && typeof p === "object")
48
+ .filter((p) => typeof p.label === "string" && isFiniteNumber(p.percent))
49
+ .map((p) => ({
50
+ label: p.label as string,
51
+ percent: p.percent as number,
52
+ reset_at: typeof p.reset_at === "string" ? p.reset_at : undefined,
53
+ }));
54
+ if (periods.length === 0) return null;
55
+ return {
56
+ product: s.product,
57
+ edition: s.edition,
58
+ fetchedAt: s.fetchedAt,
59
+ periods,
60
+ updatedAt: isFiniteNumber(s.updatedAt) ? s.updatedAt : undefined,
61
+ };
62
+ }
63
+
64
+ function validateSettings(raw: unknown): Partial<ArkSettings> {
65
+ if (!raw || typeof raw !== "object") return {};
66
+ const r = raw as Record<string, unknown>;
67
+ const out: Partial<ArkSettings> = {};
68
+ if (isFiniteNumber(r.pctYellow) && r.pctYellow > 0 && r.pctYellow < 100) {
69
+ out.pctYellow = r.pctYellow;
70
+ }
71
+ if (isFiniteNumber(r.pctRed) && r.pctRed > 0 && r.pctRed < 100) {
72
+ out.pctRed = r.pctRed;
73
+ }
74
+ if (isFiniteNumber(r.autoRefreshMinutes) && r.autoRefreshMinutes >= 0) {
75
+ out.autoRefreshMinutes = Math.min(30, Math.round(r.autoRefreshMinutes));
76
+ }
77
+ if (
78
+ r.product === "auto" ||
79
+ r.product === "coding-plan" ||
80
+ r.product === "agent-plan" ||
81
+ r.product === "coding-plan-team" ||
82
+ r.product === "agent-plan-team"
83
+ ) {
84
+ out.product = r.product;
85
+ }
86
+ if (typeof r.seat === "string" && r.seat.length <= 128) {
87
+ out.seat = r.seat;
88
+ }
89
+ return out;
90
+ }
91
+
92
+ export type LoadedCache = {
93
+ language: Language;
94
+ snapshots: Record<string, PlanSnapshot>;
95
+ };
96
+
97
+ /** Read & validate cache; applies settings on success. Never throws. */
98
+ export function loadCache(): LoadedCache | null {
99
+ let fd: number | undefined;
100
+ try {
101
+ fd = openSync(
102
+ CACHE_FILE,
103
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0),
104
+ );
105
+ const info = fstatSync(fd);
106
+ if (!info.isFile() || info.nlink !== 1 || info.size > MAX_BYTES) return null;
107
+ fchmodSync(fd, FILE_MODE);
108
+ const disk = JSON.parse(readFileSync(fd, "utf8")) as DiskCache;
109
+ if (!disk || disk.version !== 1) return null;
110
+
111
+ const validated = validateSettings(disk.settings);
112
+ patchSettings({ ...DEFAULT_SETTINGS, ...validated });
113
+
114
+ const snapshots: Record<string, PlanSnapshot> = {};
115
+ if (disk.snapshots && typeof disk.snapshots === "object") {
116
+ for (const [k, v] of Object.entries(disk.snapshots)) {
117
+ const s = validateSnapshot(v);
118
+ if (s) snapshots[k] = s;
119
+ }
120
+ }
121
+ return {
122
+ language: disk.language === "en" ? "en" : "zh",
123
+ snapshots,
124
+ };
125
+ } catch {
126
+ return null;
127
+ } finally {
128
+ if (fd !== undefined) {
129
+ try {
130
+ closeSync(fd);
131
+ } catch {}
132
+ }
133
+ }
134
+ }
135
+
136
+ let writeQueue: Promise<void> = Promise.resolve();
137
+ let pending: string | null = null;
138
+ let scheduled = false;
139
+ let shuttingDown = false;
140
+
141
+ /** Schedule an async cache write; multiple rapid calls coalesce into one. */
142
+ export function saveCache(
143
+ language: Language,
144
+ snapshots: Record<string, PlanSnapshot>,
145
+ ): void {
146
+ if (shuttingDown) return;
147
+ pending = JSON.stringify({
148
+ version: 1,
149
+ language,
150
+ settings: getSettings(),
151
+ snapshots,
152
+ } satisfies DiskCache);
153
+ if (scheduled) return;
154
+ scheduled = true;
155
+ writeQueue = writeQueue
156
+ .then(async () => {
157
+ scheduled = false;
158
+ while (pending !== null) {
159
+ const data = pending;
160
+ pending = null;
161
+ await writeFile(data);
162
+ }
163
+ })
164
+ .catch(() => {});
165
+ }
166
+
167
+ export async function flushWrites(): Promise<void> {
168
+ await writeQueue;
169
+ }
170
+
171
+ export function markShuttingDown(): void {
172
+ shuttingDown = true;
173
+ }
174
+
175
+ async function writeFile(data: string): Promise<void> {
176
+ await mkdir(CACHE_DIR, { recursive: true, mode: DIR_MODE });
177
+ const dir = await lstat(CACHE_DIR);
178
+ if (!dir.isDirectory() || dir.isSymbolicLink()) {
179
+ throw new Error("unsafe cache directory");
180
+ }
181
+ await chmod(CACHE_DIR, DIR_MODE);
182
+
183
+ const tmp = `${CACHE_FILE}.${randomUUID()}.tmp`;
184
+ try {
185
+ const handle = await open(tmp, "wx", FILE_MODE);
186
+ try {
187
+ await handle.writeFile(data, { encoding: "utf8" });
188
+ } finally {
189
+ await handle.close();
190
+ }
191
+ await rename(tmp, CACHE_FILE);
192
+ await chmod(CACHE_FILE, FILE_MODE);
193
+ } finally {
194
+ await unlink(tmp).catch(() => {});
195
+ }
196
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Shared types for pi-ark-usage.
3
+ */
4
+
5
+ export type Language = "zh" | "en";
6
+
7
+ /** Render model, modeled after pi-check-agent-quota. */
8
+ export type RenderItem =
9
+ | { kind: "text"; text: string }
10
+ | { kind: "pct"; pct: number; metric: string }
11
+ | { kind: "annotation"; text: string }
12
+ | { kind: "age"; text: string };
13
+
14
+ /** User-tunable settings (persisted in the disk cache). */
15
+ export type ArkSettings = {
16
+ /** yellow threshold, percent */
17
+ pctYellow: number;
18
+ /** red threshold, percent */
19
+ pctRed: number;
20
+ /** auto refresh interval in minutes; 0 = off */
21
+ autoRefreshMinutes: number;
22
+ /** forced product; "auto" = map from active provider */
23
+ product: "auto" | "coding-plan" | "agent-plan" | "coding-plan-team" | "agent-plan-team";
24
+ /** seat id for team products */
25
+ seat: string;
26
+ };
27
+
28
+ export type PeriodLabel = "session" | "5h" | "weekly" | "monthly";
29
+
30
+ /** One billing period as returned by `arkcli usage plan`. */
31
+ export type ArkPeriod = {
32
+ label: PeriodLabel | string;
33
+ percent: number;
34
+ reset_at?: string;
35
+ };
36
+
37
+ /** One product entry as returned by `arkcli usage plan`. */
38
+ export type ArkPlanItem = {
39
+ product: string;
40
+ edition: "personal" | "team" | string;
41
+ subscribed: boolean;
42
+ periods?: ArkPeriod[];
43
+ error?: string;
44
+ updated_at?: number;
45
+ };
46
+
47
+ /** Shape of `arkcli usage plan --format json` output. */
48
+ export type ArkPlanOutput = {
49
+ viewer?: {
50
+ auth_method?: string;
51
+ user_name?: string;
52
+ region?: string;
53
+ profile?: string;
54
+ [k: string]: unknown;
55
+ };
56
+ items: ArkPlanItem[];
57
+ };
58
+
59
+ /** Normalized per-product snapshot kept at runtime. */
60
+ export type PlanSnapshot = {
61
+ product: string;
62
+ edition: string;
63
+ fetchedAt: number;
64
+ periods: ArkPeriod[];
65
+ updatedAt?: number;
66
+ };
67
+
68
+ export type DiskCache = {
69
+ version: 1;
70
+ language: Language;
71
+ settings: Partial<ArkSettings>;
72
+ snapshots?: Record<string, PlanSnapshot>;
73
+ };