pi-provider-status 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cyzlmh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # pi-provider-status
2
+
3
+ `/status` — a live panel in [pi](https://github.com/earendil-works/pi) showing **remaining quota / balance** for every provider you have configured, with usage bars and reset timelines.
4
+
5
+ ```
6
+ ● Kimi Coding (kimi-coding) (0.8s)
7
+ Week ██████░░░░░░░░░░ 43% used resets 8-25 00:00
8
+ ─────────●───── 3d 4h to reset (8-25 00:00)
9
+ 5h window ██░░░░░░░░░░░░░░ 12% used
10
+
11
+ ● Deepseek (0.3s)
12
+ Balance CNY 128.40
13
+ ```
14
+
15
+ The panel opens immediately; each section fills in as its request settles. Press `r` to refresh, `Esc`/`q` to close. In non-interactive mode the report is printed as a plain notification instead.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ pi install npm:pi-provider-status
21
+ ```
22
+
23
+ No configuration needed — a provider is reported **only when you already have an API key configured for it** in pi. Anything else never appears.
24
+
25
+ ## Supported sources
26
+
27
+ **API-key providers** (key resolved from pi's model registry):
28
+
29
+ | Provider id | What's shown |
30
+ |---|---|
31
+ | `kimi-coding` | Membership, weekly quota, 5h window |
32
+ | `zai-coding-cn` (Zhipu ZAI Coding Plan) | Membership, weekly quota, 5h window, MCP monthly |
33
+ | `minimax-cn` | Per-model interval + weekly windows |
34
+ | `deepseek` | Balance per currency |
35
+ | `openrouter-free` | Credit balance + monthly usage |
36
+
37
+ **OAuth accounts** (discovered automatically):
38
+
39
+ - **Claude account** — reads Claude Code's own credentials (macOS Keychain, or `~/.claude/.credentials.json`). **Strictly read-only**: an expired token is reported as an error, never refreshed — re-login with the `claude` CLI to renew.
40
+ - **Codex account** — uses pi's own ChatGPT login (`/login` → ChatGPT Codex), so pi handles token refresh. Shows plan type, weekly/5h windows, credits, spend control.
41
+ - **Kimi cli account** — reads the official kimi-code CLI's OAuth credentials (`~/.kimi-code`). When the token is close to expiry it is refreshed through the CLI's own OAuth flow and the rotated pair is written back (exactly what the CLI itself does).
42
+
43
+ **Multiple API keys for one provider** (e.g. several Kimi accounts): point `KIMI_SWITCH_STORE` at a directory of `<name>.auth.json` files (`{"key": "sk-..."}`) and each becomes its own "Kimi slot" section; the slot matching the live registry key is marked `(active)`.
44
+
45
+ ## Privacy & safety
46
+
47
+ - All requests go only to the respective provider's official status endpoint, authenticated with your own key/token.
48
+ - No telemetry, no third-party calls, no data leaves your machine except the status queries themselves.
49
+ - Writes happen in exactly one place: rotating the kimi-code CLI's OAuth token (as documented above). Claude tokens are never written.
50
+
51
+ ## Compatibility
52
+
53
+ Tested with pi `0.84.x` on macOS and Linux. Provider status endpoints are undocumented and can change without notice — if a section starts erroring after a provider update, please open an issue.
54
+
55
+ ## License
56
+
57
+ MIT — © cyzlmh. See [LICENSE](LICENSE).
@@ -0,0 +1,914 @@
1
+ /**
2
+ * Provider Status Extension
3
+ *
4
+ * /status Query remaining quota / balance for providers that expose a
5
+ * Bearer-token status endpoint, and print a combined report.
6
+ *
7
+ * The FAMILIES table below is a fixed list of provider ids. A
8
+ * provider is reported only when an API key is configured for
9
+ * it (resolved via ctx.modelRegistry.getApiKeyForProvider());
10
+ * anything else — including built-in registry providers without
11
+ * a key — never appears. To support a new provider, add one
12
+ * FAMILIES entry.
13
+ *
14
+ * Multiple API keys for one provider (e.g. several Kimi
15
+ * accounts) can be reported as one section per key: point
16
+ * $KIMI_SWITCH_STORE at a directory of <name>.auth.json files
17
+ * ({"key": "sk-..."}) and each becomes a "Kimi slot" section;
18
+ * the slot matching the live registry key is marked (active).
19
+ *
20
+ * The official kimi-code CLI's OAuth account (~/.kimi-code) is
21
+ * reported as "Kimi cli account"; its token is refreshed through
22
+ * the CLI's own OAuth flow when close to expiry (the rotated
23
+ * pair is written back, like the CLI itself would).
24
+ *
25
+ * Claude Code accounts saved by the CLI are reported as
26
+ * "Claude account" when its credentials exist
27
+ * (~/.claude/.credentials.json, or the macOS Keychain). Claude
28
+ * tokens are used strictly read-only — an expired access token
29
+ * is reported as an error, never refreshed (re-login with the
30
+ * claude CLI to renew). The "Codex account" section uses pi's
31
+ * own ChatGPT OAuth login (/login → ChatGPT Codex) via the
32
+ * model registry, so pi refreshes the token.
33
+ *
34
+ * Sections render in a fixed order — pay-as-you-go balances,
35
+ * then subscription plans, then kimi accounts — via the
36
+ * order field on each FAMILIES entry / section.
37
+ *
38
+ * The panel opens immediately; each section fills in as its
39
+ * request settles. Press r to refresh, Esc (or q) to close.
40
+ */
41
+
42
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
43
+ import { Key, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
44
+ import { execFileSync } from "node:child_process";
45
+ import { existsSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
46
+ import { homedir, platform } from "node:os";
47
+ import { join } from "node:path";
48
+
49
+ // ─── Rendering model ────────────────────────────────────────────────────────
50
+
51
+ type Tone = "ok" | "warn" | "error";
52
+ type StyleColor = Tone | "muted" | "accent";
53
+
54
+ /** One rendered line of a section: label + optional bar/value + dim hint. */
55
+ interface Row {
56
+ label: string;
57
+ text?: string;
58
+ /** 0–100 remaining; renders a bar (aligned with the timeline) colored by usageTone(). */
59
+ percentLeft?: number;
60
+ /** Suppress the bar for plain balances where a quota bar makes no sense. */
61
+ hideBar?: boolean;
62
+ /** Explicit row color (probe results, errors); default is plain text. */
63
+ tone?: Tone;
64
+ /** Secondary dim text, e.g. "resets Fri 00:00". */
65
+ hint?: string;
66
+ /** Quota window bounds; renders a timeline line under the row. */
67
+ timeline?: { startMs: number; endMs: number };
68
+ }
69
+
70
+ /** Minimal styling surface so TUI (theme) and pipe mode (ANSI) share rendering. */
71
+ interface Styler {
72
+ bold(s: string): string;
73
+ dim(s: string): string;
74
+ tone(color: StyleColor, s: string): string;
75
+ }
76
+
77
+ const ANSI_COLORS: Record<StyleColor, string> = { ok: "32", warn: "33", error: "31", muted: "2", accent: "36" };
78
+
79
+ const ansiStyler: Styler = {
80
+ bold: (s) => `\x1b[1m${s}\x1b[22m`,
81
+ dim: (s) => `\x1b[2m${s}\x1b[22m`,
82
+ tone: (c, s) => `\x1b[${ANSI_COLORS[c]}m${s}\x1b[0m`,
83
+ };
84
+
85
+ /** Percent-left color: red ≤ 5, yellow ≤ 20, green < 90, accent ≥ 90. */
86
+ function percentTone(pctLeft: number): StyleColor {
87
+ return pctLeft <= 5 ? "error" : pctLeft <= 20 ? "warn" : pctLeft < 90 ? "ok" : "accent";
88
+ }
89
+
90
+ /** Accepts both numbers and numeric strings (kimi serializes quotas as strings). */
91
+ function toNum(v: unknown): number | undefined {
92
+ if (typeof v === "number" && Number.isFinite(v)) return v;
93
+ if (typeof v === "string" && v.trim() !== "") {
94
+ const n = Number(v);
95
+ return Number.isFinite(n) ? n : undefined;
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /** "3d 2h" / "5h 12m" / "45m" for timeline annotations. */
101
+ function humanDuration(ms: number): string {
102
+ const m = Math.max(0, Math.round(ms / 60000));
103
+ const d = Math.floor(m / 1440);
104
+ const h = Math.floor((m % 1440) / 60);
105
+ return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
106
+ }
107
+
108
+ /** Window length from a kimi window spec, e.g. {duration: 300, timeUnit: "TIME_UNIT_MINUTE"}. */
109
+ function kimiWindowMs(w: any): number | undefined {
110
+ const n = toNum(w?.duration);
111
+ const u = String(w?.timeUnit ?? "");
112
+ if (n === undefined) return undefined;
113
+ if (u.endsWith("MINUTE")) return n * 60e3;
114
+ if (u.endsWith("HOUR")) return n * 3600e3;
115
+ if (u.endsWith("DAY")) return n * 86400e3;
116
+ if (u.endsWith("SECOND")) return n * 1e3;
117
+ return undefined;
118
+ }
119
+
120
+ /**
121
+ * Canonical quota row: percentage + "used/limit" + dim reset hint, so
122
+ * every provider's quota lines look the same. usedPct is for APIs (zai)
123
+ * that report only "% used + absolute remaining" — the limit is derived.
124
+ * pctLeft is an API-reported remaining percent used when the absolute
125
+ * counts are missing or zero (minimax). windowMs + reset add a timeline.
126
+ */
127
+ function quotaRow(
128
+ label: string,
129
+ o: {
130
+ remaining?: unknown;
131
+ limit?: unknown;
132
+ usedPct?: unknown;
133
+ pctLeft?: unknown;
134
+ reset?: unknown;
135
+ windowMs?: number;
136
+ hideBar?: boolean;
137
+ format?: (n: number) => string;
138
+ },
139
+ ): Row {
140
+ const f = o.format ?? ((n: number) => String(n));
141
+ const remaining = toNum(o.remaining);
142
+ let limit = toNum(o.limit);
143
+ const usedPct = toNum(o.usedPct);
144
+ if (limit === undefined && remaining !== undefined && usedPct !== undefined && usedPct < 100) {
145
+ limit = Math.round(remaining / (1 - usedPct / 100));
146
+ }
147
+ const hasCounts = remaining !== undefined && limit !== undefined && limit > 0;
148
+ const resetMs =
149
+ typeof o.reset === "string" || typeof o.reset === "number" ? new Date(o.reset).getTime() : NaN;
150
+ // With a timeline the reset time moves to the timeline line; no hint needed.
151
+ const timeline =
152
+ o.windowMs !== undefined && Number.isFinite(resetMs)
153
+ ? { startMs: resetMs - o.windowMs, endMs: resetMs }
154
+ : undefined;
155
+ return {
156
+ label,
157
+ text: hasCounts ? `${f(limit! - remaining!)}/${f(limit!)}`
158
+ : remaining !== undefined && limit === undefined ? `${f(remaining)} left`
159
+ : undefined,
160
+ percentLeft: hasCounts ? (remaining! / limit!) * 100 : toNum(o.pctLeft),
161
+ hideBar: o.hideBar || undefined,
162
+ hint: timeline ? undefined : formatReset(o.reset) || undefined,
163
+ timeline,
164
+ };
165
+ }
166
+
167
+ async function fetchJson(url: string, apiKey: string, timeoutMs = 8000): Promise<unknown> {
168
+ const controller = new AbortController();
169
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
170
+ try {
171
+ const resp = await fetch(url, {
172
+ headers: { Authorization: `Bearer ${apiKey}` },
173
+ signal: controller.signal,
174
+ });
175
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
176
+ return await resp.json();
177
+ } finally {
178
+ clearTimeout(timer);
179
+ }
180
+ }
181
+
182
+ /** "8-13 14:52" — compact timestamp for reset annotations. */
183
+ function compactTime(d: Date): string {
184
+ const pad = (n: number) => String(n).padStart(2, "0");
185
+ return `${d.getMonth() + 1}-${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
186
+ }
187
+
188
+ function formatReset(iso: unknown): string {
189
+ if (typeof iso !== "string" && typeof iso !== "number") return "";
190
+ const d = new Date(iso);
191
+ if (Number.isNaN(d.getTime())) return "";
192
+ return `resets ${compactTime(d)}`;
193
+ }
194
+
195
+ // ─── Provider families (the only place that knows about specific vendors) ──
196
+
197
+ interface ProviderFamily {
198
+ /** Exact provider id, e.g. "deepseek" or "qwen-token-plan-cn". */
199
+ provider: string;
200
+ /** Fixed display order: pay-as-you-go 1x–2x, subscription plans 3x–5x, kimi 6x. */
201
+ order: number;
202
+ /** Fetch status with the provider's API key and format report rows. */
203
+ query: (apiKey: string) => Promise<Row[]>;
204
+ }
205
+
206
+ /** Standard family: GET one Bearer-token endpoint, then format the JSON. */
207
+ function simpleFamily(provider: string, order: number, url: string, format: (data: any) => Row[]): ProviderFamily {
208
+ return { provider, order, query: async (apiKey) => format(await fetchJson(url, apiKey)) };
209
+ }
210
+
211
+ const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
212
+
213
+ /** Format the kimi /usages payload into report rows. */
214
+ function formatKimiUsage(d: any): Row[] {
215
+ const rows: Row[] = [];
216
+ const level = d?.user?.membership?.level;
217
+ if (level) rows.push({ label: "Membership", text: String(level) });
218
+ const u = d?.usage;
219
+ if (u?.limit)
220
+ // The weekly quota window is not in the payload; it resets every 7 days.
221
+ // The API dropped `remaining` from `usage` (only limit/used/resetTime
222
+ // remain), so derive it as limit - used when missing.
223
+ rows.push(quotaRow("Week", {
224
+ remaining: u.remaining ?? (toNum(u.limit) !== undefined && toNum(u.used) !== undefined ? toNum(u.limit)! - toNum(u.used)! : undefined),
225
+ limit: u.limit,
226
+ reset: u.resetTime,
227
+ windowMs: 7 * 86400e3,
228
+ }));
229
+ const win = d?.limits?.[0]?.detail;
230
+ if (win?.limit)
231
+ // `remaining` is omitted by the API when the window is exhausted,
232
+ // so derive it as limit - used when missing (same as Week above).
233
+ rows.push(quotaRow("5h window", {
234
+ remaining: win.remaining ?? (toNum(win.limit) !== undefined && toNum(win.used) !== undefined ? toNum(win.limit)! - toNum(win.used)! : undefined),
235
+ limit: win.limit,
236
+ reset: win.resetTime,
237
+ windowMs: kimiWindowMs(d?.limits?.[0]?.window),
238
+ }));
239
+ return rows.length ? rows : [{ label: "usage", text: "no usage data", tone: "warn" }];
240
+ }
241
+
242
+ const FAMILIES: ProviderFamily[] = [
243
+ simpleFamily("kimi-coding", 69, KIMI_USAGE_URL, formatKimiUsage),
244
+ {
245
+ // ZAI Coding Plan (China) accepts the raw API key in the Authorization
246
+ // header with no "Bearer" prefix (Bearer is rejected on some accounts),
247
+ // so it can't use simpleFamily().
248
+ provider: "zai-coding-cn",
249
+ order: 50,
250
+ query: async (apiKey) => {
251
+ const resp = await fetch("https://open.bigmodel.cn/api/monitor/usage/quota/limit", {
252
+ headers: { Authorization: apiKey, "Accept-Language": "en-US,en" },
253
+ signal: AbortSignal.timeout(8000),
254
+ });
255
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
256
+ const d = (await resp.json()) as Record<string, any>;
257
+ const limits = d?.data?.limits;
258
+ if (!Array.isArray(limits) || limits.length === 0) return [{ label: "quota", text: "no quota data", tone: "warn" as const }];
259
+ const rows: Row[] = limits.map((l: any): Row => {
260
+ // Labels and order mirror the kimi sections: Week, then 5h window.
261
+ const label =
262
+ l.unit === 6 && l.number === 1 ? "Week" :
263
+ l.unit === 3 && l.number === 5 ? "5h window" :
264
+ l.type === "TIME_LIMIT" ? "MCP monthly" : String(l.type ?? "limit");
265
+ // unit 3 = hours, unit 6 = weeks; other limit types have no known window.
266
+ const windowMs =
267
+ l.unit === 3 ? toNum(l.number) !== undefined ? toNum(l.number)! * 3600e3 : undefined
268
+ : l.unit === 6 ? toNum(l.number) !== undefined ? toNum(l.number)! * 7 * 86400e3 : undefined
269
+ : undefined;
270
+ return quotaRow(label, { remaining: l.remaining, limit: l.usage, usedPct: l.percentage, reset: l.nextResetTime, windowMs });
271
+ });
272
+ const rank = (label: string): number => (label === "Week" ? 0 : label === "5h window" ? 1 : 2);
273
+ rows.sort((a, b) => rank(a.label) - rank(b.label));
274
+ if (d?.data?.level) rows.unshift({ label: "Membership", text: String(d.data.level) });
275
+ return rows;
276
+ },
277
+ },
278
+ simpleFamily("minimax-cn", 40, "https://api.minimaxi.com/v1/token_plan/remains", (d) => {
279
+ const models = d?.model_remains;
280
+ if (!Array.isArray(models) || models.length === 0) return [{ label: "quota", text: "no quota data", tone: "warn" as const }];
281
+ // All models share the same interval/weekly windows. One row per
282
+ // (window × model) so every quota gets its own bar; the shared
283
+ // timeline hangs under the last model row of each window.
284
+ const windows = [
285
+ { key: "interval", start: "start_time", end: "end_time", total: "current_interval_total_count", used: "current_interval_usage_count", pct: "current_interval_remaining_percent" },
286
+ { key: "weekly", start: "weekly_start_time", end: "weekly_end_time", total: "current_weekly_total_count", used: "current_weekly_usage_count", pct: "current_weekly_remaining_percent" },
287
+ ];
288
+ const rows: Row[] = [];
289
+ for (const wdef of windows) {
290
+ const start = toNum(models[0]?.[wdef.start]);
291
+ const end = toNum(models[0]?.[wdef.end]);
292
+ const hasWindow = start !== undefined && end !== undefined;
293
+ models.forEach((m: any, i: number) => {
294
+ const total = toNum(m[wdef.total]);
295
+ const used = toNum(m[wdef.used]);
296
+ const hasCounts = total !== undefined && used !== undefined && total > 0;
297
+ const last = i === models.length - 1;
298
+ rows.push({
299
+ label: `${wdef.key} ${String(m.model_name ?? "?")}`,
300
+ text: hasCounts ? `${used}/${total}` : undefined,
301
+ percentLeft: toNum(m[wdef.pct]) ?? (hasCounts ? ((total - used) / total) * 100 : undefined),
302
+ // With a timeline the reset time moves to the timeline line.
303
+ hint: last && !hasWindow ? formatReset(end) || undefined : undefined,
304
+ timeline: last && hasWindow ? { startMs: start, endMs: end } : undefined,
305
+ });
306
+ });
307
+ }
308
+ return rows;
309
+ }),
310
+ simpleFamily("deepseek", 20, "https://api.deepseek.com/user/balance", (d) => {
311
+ const infos = d?.balance_infos;
312
+ if (!Array.isArray(infos) || infos.length === 0) return [{ label: "balance", text: "no balance data", tone: "warn" as const }];
313
+ return infos.map((b: any): Row => ({
314
+ label: `Balance ${b.currency ?? ""}`.trim(),
315
+ text: String(b.total_balance ?? "?"),
316
+ }));
317
+ }),
318
+ {
319
+ // OpenRouter needs two endpoints, so it uses a custom query.
320
+ // Only the free-tier provider is reported; plain "openrouter" is skipped.
321
+ provider: "openrouter-free",
322
+ order: 10,
323
+ query: async (apiKey) => {
324
+ const [credits, keyInfo] = await Promise.all([
325
+ fetchJson("https://openrouter.ai/api/v1/credits", apiKey) as Promise<Record<string, any>>,
326
+ fetchJson("https://openrouter.ai/api/v1/auth/key", apiKey) as Promise<Record<string, any>>,
327
+ ]);
328
+ const c = credits?.data ?? {};
329
+ const total = Number(c.total_credits ?? 0);
330
+ const used = Number(c.total_usage ?? 0);
331
+ const monthly = Number(keyInfo?.data?.usage_monthly ?? 0);
332
+ return [
333
+ quotaRow("Balance", { remaining: total - used, limit: total, hideBar: true, format: (n) => `$${n.toFixed(2)}` }),
334
+ { label: "Usage", text: `$${used.toFixed(2)} total · $${monthly.toFixed(2)} this month` },
335
+ ];
336
+ },
337
+ },
338
+ ];
339
+
340
+ // ─── Section builders ───────────────────────────────────────────────────────
341
+
342
+ type Query = (ctx: ExtensionCommandContext) => Promise<Row[]>;
343
+
344
+ interface Section {
345
+ label: string;
346
+ /** Fixed display order; sections sort ascending before rendering. */
347
+ order: number;
348
+ query: Query;
349
+ /** Provider id this section was discovered from (used for dedup). */
350
+ provider?: string;
351
+ }
352
+
353
+ /** One section per FAMILIES entry whose provider has an API key configured. */
354
+ async function discoverSections(ctx: ExtensionCommandContext): Promise<Section[]> {
355
+ const sections: Section[] = [];
356
+ for (const family of FAMILIES) {
357
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(family.provider).catch(() => undefined);
358
+ if (!apiKey) continue;
359
+ const display = ctx.modelRegistry.getProviderDisplayName(family.provider) || family.provider;
360
+ sections.push({
361
+ label: display === family.provider ? family.provider : `${display} (${family.provider})`,
362
+ order: family.order,
363
+ provider: family.provider,
364
+ query: () => family.query(apiKey),
365
+ });
366
+ }
367
+ return sections;
368
+ }
369
+
370
+ // ─── Kimi account slots ───────────────────────────────────────────────────
371
+
372
+ /** Directory holding extra kimi API-key files (<name>.auth.json). */
373
+ function kimiStoreDir(): string | undefined {
374
+ // Only the env var: set KIMI_SWITCH_STORE to a directory of
375
+ // <name>.auth.json ({"key": "sk-..."}) files to report every saved
376
+ // kimi account as its own section. No default — feature is off otherwise.
377
+ return process.env.KIMI_SWITCH_STORE;
378
+ }
379
+
380
+ interface KimiSlot {
381
+ name: string;
382
+ key: string;
383
+ }
384
+
385
+ /** Read every saved kimi account slot; [] if the store is absent or empty. */
386
+ function kimiSlots(): KimiSlot[] {
387
+ try {
388
+ const dir = kimiStoreDir();
389
+ if (!dir) return [];
390
+ return readdirSync(dir)
391
+ .filter((f) => f.endsWith(".auth.json"))
392
+ .sort()
393
+ .map((f) => ({
394
+ name: f.replace(/\.auth\.json$/, ""),
395
+ key: (JSON.parse(readFileSync(join(dir, f), "utf8")) as { key?: string }).key ?? "",
396
+ }))
397
+ .filter((s) => s.key.length > 0);
398
+ } catch {
399
+ return [];
400
+ }
401
+ }
402
+
403
+ // ─── Official kimi-code CLI account (OAuth login in ~/.kimi-code) ───────────
404
+
405
+ const KIMI_CLI_CRED_FILE = join(homedir(), ".kimi-code", "credentials", "kimi-code.json");
406
+ const KIMI_CLI_OAUTH_URL = "https://auth.kimi.com/api/oauth/token";
407
+ const KIMI_CLI_CLIENT_ID = "17e5f671-d194-4dfb-9706-5516cb48c098";
408
+
409
+ interface KimiCliCreds {
410
+ access_token: string;
411
+ refresh_token: string;
412
+ expires_at: number;
413
+ scope?: string;
414
+ token_type?: string;
415
+ }
416
+
417
+ /** Read the kimi-code CLI OAuth credentials; undefined if absent/corrupt. */
418
+ function kimiCliCreds(): KimiCliCreds | undefined {
419
+ try {
420
+ const d = JSON.parse(readFileSync(KIMI_CLI_CRED_FILE, "utf8")) as Partial<KimiCliCreds>;
421
+ if (typeof d.access_token !== "string" || typeof d.refresh_token !== "string") return undefined;
422
+ return {
423
+ access_token: d.access_token,
424
+ refresh_token: d.refresh_token,
425
+ expires_at: typeof d.expires_at === "number" ? d.expires_at : 0,
426
+ scope: d.scope,
427
+ token_type: d.token_type,
428
+ };
429
+ } catch {
430
+ return undefined;
431
+ }
432
+ }
433
+
434
+ /** Persist credentials atomically in the CLI's wire format (0600). */
435
+ function saveKimiCliCreds(creds: KimiCliCreds): void {
436
+ const tmp = `${KIMI_CLI_CRED_FILE}.${process.pid}.${Date.now()}.tmp`;
437
+ writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 });
438
+ renameSync(tmp, KIMI_CLI_CRED_FILE);
439
+ }
440
+
441
+ /** Refresh the CLI's OAuth access token and persist the rotated pair. */
442
+ async function refreshKimiCliCreds(creds: KimiCliCreds): Promise<KimiCliCreds> {
443
+ const resp = await fetch(KIMI_CLI_OAUTH_URL, {
444
+ method: "POST",
445
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
446
+ body: new URLSearchParams({
447
+ client_id: KIMI_CLI_CLIENT_ID,
448
+ grant_type: "refresh_token",
449
+ refresh_token: creds.refresh_token,
450
+ }).toString(),
451
+ });
452
+ if (!resp.ok) throw new Error(`token refresh failed (HTTP ${resp.status})`);
453
+ const d = (await resp.json()) as Record<string, any>;
454
+ const expiresIn = Number(d.expires_in ?? 3600);
455
+ const refreshed: KimiCliCreds = {
456
+ access_token: String(d.access_token),
457
+ refresh_token: String(d.refresh_token ?? creds.refresh_token),
458
+ expires_at: Date.now() / 1000 + expiresIn,
459
+ scope: typeof d.scope === "string" ? d.scope : creds.scope,
460
+ token_type: typeof d.token_type === "string" ? d.token_type : creds.token_type,
461
+ };
462
+ saveKimiCliCreds(refreshed);
463
+ return refreshed;
464
+ }
465
+
466
+ /** Access token for the kimi-code CLI account, refreshing when close to expiry. */
467
+ async function kimiCliAccessToken(): Promise<string> {
468
+ const creds = kimiCliCreds();
469
+ if (!creds) throw new Error("no kimi-code CLI credentials");
470
+ if (creds.expires_at > Date.now() / 1000 + 60) return creds.access_token;
471
+ return (await refreshKimiCliCreds(creds)).access_token;
472
+ }
473
+
474
+ // ─── Claude Code account (OAuth login in ~/.claude/.credentials.json) ───────
475
+
476
+ const CLAUDE_CREDS_FILE = join(homedir(), ".claude", ".credentials.json");
477
+ const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
478
+ const CLAUDE_BETA_HEADER = "oauth-2025-04-20";
479
+ // The usage endpoint is shared with the CLI and can 429 when polled hard; a
480
+ // short TTL cache keeps /status refreshes polite and still mostly fresh.
481
+ const CLAUDE_USAGE_CACHE_TTL_MS = 60_000;
482
+
483
+ interface ClaudeOauth {
484
+ accessToken: string;
485
+ refreshToken: string;
486
+ expiresAt: number;
487
+ refreshTokenExpiresAt?: number;
488
+ scopes?: string[];
489
+ subscriptionType?: string;
490
+ rateLimitTier?: string;
491
+ }
492
+
493
+ const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
494
+ const isMac = platform() === "darwin";
495
+
496
+ /**
497
+ * Read the raw claude-code credentials JSON. On macOS the CLI stores its
498
+ * OAuth tokens in the Keychain (service "Claude Code-credentials"), not the
499
+ * ~/.claude/.credentials.json file — that file is a stale fallback whose
500
+ * tokens have usually been rotated server-side, so the Keychain is
501
+ * authoritative on macOS.
502
+ */
503
+ function readClaudeCredsRaw(): string | undefined {
504
+ if (isMac) {
505
+ try {
506
+ const out = execFileSync("security", ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"], {
507
+ stdio: ["ignore", "pipe", "ignore"],
508
+ }).toString().trim();
509
+ if (out) return out;
510
+ } catch {
511
+ // fall through to the file
512
+ }
513
+ }
514
+ try {
515
+ return readFileSync(CLAUDE_CREDS_FILE, "utf8");
516
+ } catch {
517
+ return undefined;
518
+ }
519
+ }
520
+
521
+ /** Read the claude-code CLI OAuth credentials; undefined if absent/corrupt. */
522
+ function claudeOauth(): ClaudeOauth | undefined {
523
+ const raw = readClaudeCredsRaw();
524
+ if (!raw) return undefined;
525
+ try {
526
+ const o = (JSON.parse(raw) as { claudeAiOauth?: Partial<ClaudeOauth> }).claudeAiOauth;
527
+ if (!o || typeof o.accessToken !== "string" || typeof o.refreshToken !== "string") return undefined;
528
+ return {
529
+ accessToken: o.accessToken,
530
+ refreshToken: o.refreshToken,
531
+ expiresAt: typeof o.expiresAt === "number" ? o.expiresAt : 0,
532
+ refreshTokenExpiresAt: o.refreshTokenExpiresAt,
533
+ scopes: o.scopes,
534
+ subscriptionType: o.subscriptionType,
535
+ rateLimitTier: o.rateLimitTier,
536
+ };
537
+ } catch {
538
+ return undefined;
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Access token for the claude-code CLI account. Strictly read-only: an
544
+ * expired token is an error, never refreshed or written back — re-login
545
+ * with the claude CLI to renew.
546
+ */
547
+ function claudeAccessToken(): string {
548
+ const oauth = claudeOauth();
549
+ if (!oauth) throw new Error("no claude-code OAuth credentials");
550
+ if (oauth.expiresAt <= Date.now() + 60_000)
551
+ throw new Error(`claude OAuth token expired ${compactTime(new Date(oauth.expiresAt))} — re-login with the claude CLI`);
552
+ return oauth.accessToken;
553
+ }
554
+
555
+ let claudeUsageCache: { rows: Row[]; at: number } | undefined;
556
+
557
+ /** Fetch subscription usage for the claude-code CLI account (cached briefly). */
558
+ async function fetchClaudeUsage(): Promise<Row[]> {
559
+ const accessToken = claudeAccessToken();
560
+ try {
561
+ const resp = await fetch(CLAUDE_USAGE_URL, {
562
+ headers: {
563
+ Authorization: `Bearer ${accessToken}`,
564
+ "anthropic-beta": CLAUDE_BETA_HEADER,
565
+ "Content-Type": "application/json",
566
+ },
567
+ signal: AbortSignal.timeout(8000),
568
+ });
569
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
570
+ const rows = formatClaudeUsage(await resp.json());
571
+ claudeUsageCache = { rows, at: Date.now() };
572
+ return rows;
573
+ } catch (err) {
574
+ // The endpoint 429s when polled aggressively; serve the last good
575
+ // read within the TTL instead of failing the section.
576
+ if (claudeUsageCache && Date.now() - claudeUsageCache.at < CLAUDE_USAGE_CACHE_TTL_MS) return claudeUsageCache.rows;
577
+ throw err;
578
+ }
579
+ }
580
+
581
+ /** Format the claude /api/oauth/usage payload into report rows. */
582
+ function formatClaudeUsage(d: any): Row[] {
583
+ const rows: Row[] = [];
584
+ const tier = claudeOauth()?.subscriptionType;
585
+ if (tier) rows.push({ label: "Membership", text: String(tier) });
586
+ const fiveHour = d?.five_hour;
587
+ const sevenDay = d?.seven_day;
588
+ if (sevenDay && toNum(sevenDay.utilization) !== undefined)
589
+ rows.push(quotaRow("Week", { pctLeft: 100 - toNum(sevenDay.utilization)!, reset: sevenDay.resets_at, windowMs: 7 * 86400e3 }));
590
+ if (fiveHour && toNum(fiveHour.utilization) !== undefined)
591
+ rows.push(quotaRow("5h window", { pctLeft: 100 - toNum(fiveHour.utilization)!, reset: fiveHour.resets_at, windowMs: 5 * 3600e3 }));
592
+ // Fallback when the window objects are absent: the limits[] buckets.
593
+ if (rows.length <= 1 && Array.isArray(d?.limits))
594
+ for (const l of d.limits) {
595
+ const pct = toNum(l?.percent);
596
+ if (pct === undefined) continue;
597
+ const label = l?.kind === "session" ? "Session" : l?.kind === "weekly_all" ? "Week" : String(l?.kind ?? "limit");
598
+ rows.push(quotaRow(label, { pctLeft: 100 - pct, reset: l?.resets_at }));
599
+ }
600
+ const extra = d?.extra_usage;
601
+ const extraUsed = toNum(extra?.used_credits);
602
+ const extraLimit = toNum(extra?.monthly_limit);
603
+ if (extra?.is_enabled && extraLimit !== undefined)
604
+ rows.push(quotaRow("Extra usage", {
605
+ // API reports used credits; quotaRow wants remaining.
606
+ remaining: extraUsed !== undefined ? extraLimit - extraUsed : undefined,
607
+ limit: extraLimit,
608
+ pctLeft: toNum(extra.utilization) !== undefined ? 100 - toNum(extra.utilization)! : undefined,
609
+ }));
610
+ return rows.length ? rows : [{ label: "usage", text: "no usage data", tone: "warn" }];
611
+ }
612
+
613
+ // ─── Codex account (pi's ChatGPT OAuth login: /login → ChatGPT Codex) ────────
614
+
615
+ // The access token comes from pi's model registry (/login → ChatGPT Codex),
616
+ // which refreshes, locks, and persists it. Rate limits live on the ChatGPT
617
+ // backend; the CLI uses /wham/… paths (chatgpt.com/backend-api base).
618
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
619
+
620
+ /** chatgpt_account_id claim from a ChatGPT access JWT (pi's codex login). */
621
+ function jwtAccountId(token: string): string | undefined {
622
+ try {
623
+ const payload = JSON.parse(
624
+ Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"),
625
+ ) as { "https://api.openai.com/auth"?: { chatgpt_account_id?: string } };
626
+ const id = payload?.["https://api.openai.com/auth"]?.chatgpt_account_id;
627
+ return typeof id === "string" ? id : undefined;
628
+ } catch {
629
+ return undefined;
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Fetch codex rate limits from the ChatGPT backend using pi's own codex
635
+ * login: the access token is resolved via the model registry, so refresh,
636
+ * locking, and persistence are all handled by pi.
637
+ */
638
+ async function fetchCodexUsage(ctx: ExtensionCommandContext): Promise<Row[]> {
639
+ const accessToken = await ctx.modelRegistry.getApiKeyForProvider("openai-codex");
640
+ if (!accessToken) throw new Error("no pi codex login — run /login and pick ChatGPT Codex");
641
+ const headers: Record<string, string> = {
642
+ Authorization: `Bearer ${accessToken}`,
643
+ "Content-Type": "application/json",
644
+ // chatgpt.com rejects the request without these (403/404).
645
+ "User-Agent": "codex-cli",
646
+ originator: "codex_cli_rs",
647
+ "OAI-Product-Sku": "codex",
648
+ };
649
+ const accountId = jwtAccountId(accessToken);
650
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId;
651
+ const resp = await fetch(CODEX_USAGE_URL, { headers, signal: AbortSignal.timeout(8000) });
652
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
653
+ return formatCodexUsage(await resp.json());
654
+ }
655
+
656
+ /** Unix seconds → ms; codex reports epoch seconds. */
657
+ function secToMs(v: unknown): number | undefined {
658
+ const n = toNum(v);
659
+ return n !== undefined ? (n < 1e12 ? n * 1000 : n) : undefined;
660
+ }
661
+
662
+ /** Format the codex /wham/usage payload into report rows. */
663
+ function formatCodexUsage(d: any): Row[] {
664
+ const rows: Row[] = [];
665
+ if (d?.plan_type) rows.push({ label: "Plan", text: String(d.plan_type) });
666
+ const rl = d?.rate_limit;
667
+ const pw = rl?.primary_window;
668
+ if (pw && toNum(pw.used_percent) !== undefined) {
669
+ const windowSecs = toNum(pw.limit_window_seconds);
670
+ const row = quotaRow(windowSecs === 604800 ? "Week" : windowSecs === 18000 ? "5h window" : "Quota", {
671
+ pctLeft: 100 - toNum(pw.used_percent)!,
672
+ reset: secToMs(pw.reset_at),
673
+ windowMs: windowSecs !== undefined ? windowSecs * 1000 : 7 * 86400e3,
674
+ });
675
+ if (rl?.limit_reached) row.hint = "limit reached";
676
+ rows.push(row);
677
+ }
678
+ const sw = rl?.secondary_window;
679
+ if (sw && toNum(sw.used_percent) !== undefined) {
680
+ const windowSecs = toNum(sw.limit_window_seconds);
681
+ rows.push(quotaRow(windowSecs === 604800 ? "Week" : windowSecs === 18000 ? "5h window" : "Quota", {
682
+ pctLeft: 100 - toNum(sw.used_percent)!,
683
+ reset: secToMs(sw.reset_at),
684
+ windowMs: windowSecs !== undefined ? windowSecs * 1000 : undefined,
685
+ }));
686
+ }
687
+ const credits = d?.credits;
688
+ if (credits?.has_credits)
689
+ rows.push({ label: "Credits", text: `$${toNum(credits.balance) ?? "0"} left`, tone: credits.overage_limit_reached ? "warn" : undefined });
690
+ const sc = d?.spend_control;
691
+ if (sc?.individual_limit != null)
692
+ rows.push({ label: "Spend control", text: sc.reached ? "limit reached" : `limit $${toNum(sc.individual_limit) ?? "?"}`, tone: sc.reached ? "warn" : undefined });
693
+ if (rl && !rl.allowed)
694
+ rows.push({ label: "Status", text: "rate limited", tone: "error", hint: rl?.rate_limit_reached_type != null ? String(rl.rate_limit_reached_type) : undefined });
695
+ return rows.length ? rows : [{ label: "usage", text: "no usage data", tone: "warn" }];
696
+ }
697
+
698
+ // ─── Command ────────────────────────────────────────────────────────────────
699
+
700
+ export default function (pi: ExtensionAPI) {
701
+ pi.registerCommand("status", {
702
+ description: "Show remaining quota / balance for all configured providers",
703
+ handler: async (_args, ctx) => {
704
+ const sections = await discoverSections(ctx);
705
+
706
+ // Multi-key slots (KIMI_SWITCH_STORE): report every saved account, not
707
+ // just the currently active key, so usage is visible without switching.
708
+ const slots = kimiSlots();
709
+ if (slots.length > 0) {
710
+ const liveKey = await ctx.modelRegistry
711
+ .getApiKeyForProvider("kimi-coding")
712
+ .catch(() => undefined);
713
+ const active = new Set(slots.filter((s) => s.key === liveKey).map((s) => s.name));
714
+ if (active.size > 0) {
715
+ // The active slot already reports the live key's usage; drop
716
+ // the registry-based kimi-coding section as a duplicate.
717
+ for (let i = sections.length - 1; i >= 0; i--) {
718
+ if (sections[i].provider === "kimi-coding") sections.splice(i, 1);
719
+ }
720
+ }
721
+ // Active slot first, then the rest alphabetically.
722
+ const orderedSlots = [...slots].sort(
723
+ (a, b) => Number(active.has(b.name)) - Number(active.has(a.name)) || a.name.localeCompare(b.name),
724
+ );
725
+ orderedSlots.forEach((slot, i) => {
726
+ sections.push({
727
+ label: `Kimi slot: ${slot.name}${active.has(slot.name) ? " (active)" : ""}`,
728
+ order: 61 + i,
729
+ query: async () => formatKimiUsage(await fetchJson(KIMI_USAGE_URL, slot.key)),
730
+ });
731
+ });
732
+ }
733
+
734
+ // Official kimi-code CLI account: OAuth login in ~/.kimi-code, often
735
+ // a subscription separate from the API-key slots above.
736
+ if (existsSync(KIMI_CLI_CRED_FILE)) {
737
+ sections.push({
738
+ label: "Kimi cli account",
739
+ order: 60,
740
+ query: async () => formatKimiUsage(await fetchJson(KIMI_USAGE_URL, await kimiCliAccessToken())),
741
+ });
742
+ }
743
+
744
+ // Claude Code account: subscription login stored in the Keychain
745
+ // (macOS) or ~/.claude/.credentials.json (other platforms).
746
+ if (claudeOauth()) {
747
+ sections.push({
748
+ label: "Claude account",
749
+ order: 55,
750
+ query: async () => fetchClaudeUsage(),
751
+ });
752
+ }
753
+
754
+ // Codex account: pi's own ChatGPT OAuth login (/login → ChatGPT
755
+ // Codex); the token resolves and refreshes via the registry.
756
+ if (await ctx.modelRegistry.getApiKeyForProvider("openai-codex").catch(() => undefined)) {
757
+ sections.push({
758
+ label: "Codex account",
759
+ order: 56,
760
+ query: (qctx) => fetchCodexUsage(qctx),
761
+ });
762
+ }
763
+
764
+ if (sections.length === 0) {
765
+ ctx.ui.notify("No configured providers match a known status endpoint.", "info");
766
+ return;
767
+ }
768
+
769
+ sections.sort((a, b) => a.order - b.order);
770
+
771
+ type SectionState =
772
+ | { status: "pending" }
773
+ | { status: "done"; rows: Row[]; tone: Tone; elapsedMs: number };
774
+
775
+ let states: SectionState[] = sections.map(() => ({ status: "pending" }));
776
+ let runGen = 0;
777
+ let lastUpdated = 0;
778
+ let onUpdate: () => void = () => {};
779
+
780
+ const rowTone = (r: Row): StyleColor => {
781
+ if (r.tone) return r.tone;
782
+ if (r.percentLeft === undefined) return "ok";
783
+ const t = percentTone(r.percentLeft);
784
+ return t === "error" || t === "warn" ? t : "ok";
785
+ };
786
+ // Section dot: red ✗ only for real failures (explicit error rows);
787
+ // a low-quota percentage alone caps the section at yellow.
788
+ const worstTone = (rows: Row[]): Tone => {
789
+ if (rows.some((r) => r.tone === "error")) return "error";
790
+ return rows.some((r) => rowTone(r) === "error" || rowTone(r) === "warn") ? "warn" : "ok";
791
+ };
792
+
793
+ /** Start a fresh round of queries; stale rounds are dropped via runGen. */
794
+ const runAll = (): Promise<void>[] => {
795
+ const gen = ++runGen;
796
+ states = sections.map(() => ({ status: "pending" }));
797
+ return sections.map((s, i) => {
798
+ const started = Date.now();
799
+ const settle = (rows: Row[]): void => {
800
+ if (gen !== runGen) return;
801
+ states[i] = { status: "done", rows, tone: worstTone(rows), elapsedMs: Date.now() - started };
802
+ lastUpdated = Date.now();
803
+ onUpdate();
804
+ };
805
+ return s.query(ctx).then(settle, (err) => {
806
+ const msg = err instanceof Error ? err.message : String(err);
807
+ settle([{ label: "error", text: msg, tone: "error" }]);
808
+ });
809
+ });
810
+ };
811
+
812
+ /** Track width shared by the usage bar and the timeline so they line up. */
813
+ const TRACK_W = 20;
814
+
815
+ /** Usage bar: filled = consumed (usage tone), dim = remaining headroom. */
816
+ const usageBar = (st: Styler, pctLeft: number): string => {
817
+ const pct = Math.max(0, Math.min(100, Math.round(pctLeft)));
818
+ const filled = Math.round(((100 - pct) / 100) * TRACK_W);
819
+ return st.tone(percentTone(pct), "█".repeat(filled)) + st.dim("░".repeat(TRACK_W - filled));
820
+ };
821
+
822
+ /** One-line window timeline: elapsed in accent, ● = now, dim = still ahead. */
823
+ const timelineLine = (st: Styler, { startMs, endMs }: { startMs: number; endMs: number }): string => {
824
+ const now = Date.now();
825
+ const frac = endMs > startMs ? Math.max(0, Math.min(1, (now - startMs) / (endMs - startMs))) : 1;
826
+ const knob = Math.round(frac * (TRACK_W - 1));
827
+ const line = st.tone("accent", "─".repeat(knob) + "●") + st.dim("─".repeat(TRACK_W - 1 - knob));
828
+ const note = `${humanDuration(endMs - now)} to reset (${compactTime(new Date(endMs))})`;
829
+ return `${line} ${st.dim(note)}`;
830
+ };
831
+
832
+ const renderRows = (st: Styler, rows: Row[]): string => {
833
+ const w = Math.max(...rows.map((r) => r.label.length));
834
+ const lines: string[] = [];
835
+ for (const r of rows) {
836
+ const padded = r.label.padEnd(w);
837
+ const bar = r.percentLeft !== undefined && !r.hideBar ? `${usageBar(st, r.percentLeft)} ` : "";
838
+ const parts: string[] = [];
839
+ if (r.percentLeft !== undefined) {
840
+ const pct = Math.max(0, Math.min(100, Math.round(r.percentLeft)));
841
+ parts.push(st.tone(percentTone(pct), `${100 - pct}% used`));
842
+ }
843
+ if (r.text) parts.push(r.tone ? st.tone(r.tone, r.text) : r.text);
844
+ let line = ` ${r.tone && r.percentLeft === undefined ? st.tone(r.tone, padded) : padded} ${bar}${parts.join(" ")}`;
845
+ if (r.hint) line += ` ${st.dim(r.hint)}`;
846
+ lines.push(line);
847
+ if (r.timeline) lines.push(` ${" ".repeat(w)} ${timelineLine(st, r.timeline)}`);
848
+ }
849
+ return lines.join("\n");
850
+ };
851
+
852
+ const renderReport = (st: Styler, interactive: boolean): string => {
853
+ const body = sections
854
+ .map(({ label }, i) => {
855
+ const s = states[i];
856
+ const dot =
857
+ s.status === "pending" ? st.tone("muted", "○")
858
+ : s.tone === "error" ? st.tone("error", "✗")
859
+ : st.tone(s.tone, "●");
860
+ const elapsed = s.status === "done" ? st.dim(` (${(s.elapsedMs / 1000).toFixed(1)}s)`) : "";
861
+ const lines = s.status === "pending" ? ` ${st.dim("querying…")}` : renderRows(st, s.rows);
862
+ return `${dot} ${st.bold(label)}${elapsed}\n${lines}`;
863
+ })
864
+ .join("\n\n");
865
+ const updated = lastUpdated ? `updated ${new Date(lastUpdated).toLocaleTimeString()}` : "";
866
+ const footer = st.dim(interactive ? `r refresh · Esc/q close · ${updated}` : updated);
867
+ return `${body}\n\n${footer}`;
868
+ };
869
+
870
+ if (ctx.mode !== "tui") {
871
+ await Promise.all(runAll());
872
+ ctx.ui.notify(renderReport(ansiStyler, false), "info");
873
+ return;
874
+ }
875
+
876
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => {
877
+ const st: Styler = {
878
+ bold: (s) => theme.bold(s),
879
+ dim: (s) => theme.fg("dim", s),
880
+ tone: (c, s) =>
881
+ theme.fg(c === "ok" ? "success" : c === "warn" ? "warning" : c === "error" ? "error" : c, s),
882
+ };
883
+ let cachedWidth = 0;
884
+ let cachedLines: string[] = [];
885
+ const refresh = (): void => {
886
+ cachedWidth = 0;
887
+ tui.requestRender();
888
+ };
889
+ onUpdate = refresh;
890
+ // Show the panel immediately; each section fills in as its query settles.
891
+ runAll();
892
+ return {
893
+ render(width: number): string[] {
894
+ if (width !== cachedWidth) {
895
+ cachedWidth = width;
896
+ cachedLines = wrapTextWithAnsi(renderReport(st, true), width);
897
+ }
898
+ return cachedLines;
899
+ },
900
+ handleInput(data: string): void {
901
+ if (matchesKey(data, Key.escape) || data === "q") done();
902
+ else if (data === "r") {
903
+ runAll();
904
+ refresh();
905
+ }
906
+ },
907
+ invalidate(): void {
908
+ cachedWidth = 0;
909
+ },
910
+ };
911
+ });
912
+ },
913
+ });
914
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "pi-provider-status",
3
+ "version": "0.1.0",
4
+ "description": "Show remaining quota and balance for LLM providers in pi — /status panel for Kimi Coding, Zhipu ZAI, MiniMax, DeepSeek, OpenRouter, Claude and Codex subscription windows",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "pi-coding-agent",
10
+ "quota",
11
+ "usage",
12
+ "rate-limit",
13
+ "kimi",
14
+ "zhipu",
15
+ "minimax",
16
+ "deepseek"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "cyzlmh",
20
+ "pi": {
21
+ "extensions": ["./extensions"]
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/cyzlmh/pi-extensions.git",
26
+ "directory": "pi-provider-status"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/cyzlmh/pi-extensions/issues"
30
+ },
31
+ "homepage": "https://github.com/cyzlmh/pi-extensions/tree/main/pi-provider-status",
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-coding-agent": "*",
34
+ "@earendil-works/pi-tui": "*"
35
+ }
36
+ }