oc-go-usage-display 1.0.2 → 2.0.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.
Files changed (41) hide show
  1. package/README.md +249 -68
  2. package/bin/lib.js +433 -29
  3. package/bin/oc-go-usage-display-init.js +42 -13
  4. package/bin/oc-go-usage-display-remove.js +38 -0
  5. package/bin/oc-go-usage-display-show.js +47 -39
  6. package/bin/oc-go-usage-display-status.js +33 -42
  7. package/bin/oc-go-usage-display-update.js +27 -14
  8. package/dist/helpers.d.ts +116 -0
  9. package/dist/helpers.d.ts.map +1 -0
  10. package/dist/helpers.js +568 -0
  11. package/dist/helpers.js.map +1 -0
  12. package/dist/index.d.ts +5 -9
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +258 -278
  15. package/dist/index.js.map +1 -1
  16. package/dist/plugins/oc-go-usage-display.kilo.ts +678 -0
  17. package/dist/plugins/oc-go-usage-display.kilo.tsx +1272 -0
  18. package/dist/plugins/oc-go-usage-display.ts +678 -0
  19. package/dist/plugins/oc-go-usage-display.tsx +1018 -0
  20. package/dist/shared.d.ts +96 -0
  21. package/dist/shared.d.ts.map +1 -0
  22. package/dist/shared.js +575 -0
  23. package/dist/shared.js.map +1 -0
  24. package/dist/tui-shared.d.ts +109 -0
  25. package/dist/tui-shared.d.ts.map +1 -0
  26. package/dist/tui-shared.js +372 -0
  27. package/dist/tui-shared.js.map +1 -0
  28. package/dist/tui.d.ts.map +1 -1
  29. package/dist/tui.js +226 -377
  30. package/dist/tui.js.map +1 -1
  31. package/dist/tui.kilo.d.ts +8 -0
  32. package/dist/tui.kilo.d.ts.map +1 -0
  33. package/dist/tui.kilo.js +502 -0
  34. package/dist/tui.kilo.js.map +1 -0
  35. package/package.json +47 -13
  36. package/src/helpers.ts +733 -0
  37. package/src/index.ts +331 -304
  38. package/src/shared.ts +696 -0
  39. package/src/tui-shared.tsx +705 -0
  40. package/src/tui.kilo.tsx +816 -0
  41. package/src/tui.tsx +356 -465
package/dist/index.js CHANGED
@@ -12,159 +12,66 @@
12
12
  // only (one tool). No transcript injection, no TUI
13
13
  // footer slot (unstable API).
14
14
  //
15
- // Auth (first match wins, secrets are never logged):
15
+ // Auth (first match wins, secrets are never logged), host-specific stores:
16
16
  // 1. OPENCODE_GO_MOCK=1 -> deterministic mock snapshot (for testing)
17
17
  // 2. OPENCODE_GO_API_KEY -> GET https://opencode.ai/zen/go/v1/usage
18
18
  // (Authorization: Bearer <key>)
19
- // 3. Provider auth.json key -> same Bearer path as (2), no paste needed:
20
- // $XDG_DATA_HOME/opencode/auth.json (or
21
- // ~/.local/share/opencode/auth.json), fallback
22
- // ~/.config/opencode/auth.json (legacy).
23
- // Uses `opencode-go` key, else `opencode` key.
19
+ // 3. Provider auth.json key -> same Bearer path as (2), no paste needed.
20
+ // opencode bundle: $XDG_DATA_HOME/opencode/
21
+ // auth.json (~/.local/share/opencode/auth.json),
22
+ // fallback $XDG_CONFIG_HOME/opencode/auth.json
23
+ // (~/.config/opencode/auth.json); uses the
24
+ // `opencode-go` key, else `opencode`.
25
+ // Kilo bundle (oc-go-usage-display.kilo.ts):
26
+ // the same files under the `kilo` roots, so
27
+ // a Kilo-only login works.
24
28
  // 4. OPENCODE_GO_WORKSPACE_ID + OPENCODE_GO_AUTH_COOKIE (env), or
25
- // ~/.config/opencode/oc-go-usage-display.json
29
+ // <host config dir>/oc-go-usage-display.json
26
30
  // ({ "workspaceId": "...", "authCookie": "..." })
27
31
  // -> GET https://opencode.ai/workspace/{id}/go
28
32
  // (scraped rolling/weekly/monthly)
29
33
  // 5. none -> unavailable snapshot (literal-only error)
30
- // Snapshots are cached 60s in memory + on disk.
34
+ // Snapshots are cached 60s in memory + on disk (host config dir).
31
35
  import { tool } from "@opencode-ai/plugin";
32
36
  import * as fs from "node:fs";
33
- import * as os from "node:os";
34
37
  import * as path from "node:path";
38
+ import { formatServerLine, hasMalformedAuthCookie, isMalformedAuthCookie, readFileConfig, resolveAllowedRedirect, } from "./helpers.js";
39
+ import { errorMessage, extractSnapshotFromApiPayload, extractWindow, hostEnv, hostEnvName, isLimitedStatus, isRecord, mockSnapshot, readAuthJsonApiKey, resolveHostRoots, resolveUsageHost, safeJoinPath, toFiniteNumber, toNonEmptyString, unavailableSnapshot, } from "./shared.js";
35
40
  // ---------------------------------------------------------------------------
36
41
  // Constants
37
42
  // ---------------------------------------------------------------------------
38
43
  const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
39
44
  const CACHE_TTL_MS = 60_000;
40
45
  const FETCH_TIMEOUT_MS = 10_000;
41
- const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode");
42
- const FILE_CONFIG_PATH = path.join(CONFIG_DIR, "oc-go-usage-display.json");
43
- const DISK_CACHE_PATH = path.join(CONFIG_DIR, "oc-go-usage-display-cache.json");
44
- function dataShareAuthPath() {
45
- const xdgDataHome = toNonEmptyString(process.env.XDG_DATA_HOME);
46
- if (xdgDataHome)
47
- return path.join(xdgDataHome, "opencode", "auth.json");
48
- return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
49
- }
50
- function authJsonPaths() {
51
- return [dataShareAuthPath(), path.join(CONFIG_DIR, "auth.json")];
52
- }
53
- // ---------------------------------------------------------------------------
54
- // Small pure helpers
55
- // ---------------------------------------------------------------------------
56
- function isRecord(value) {
57
- return typeof value === "object" && value !== null && !Array.isArray(value);
58
- }
59
- function toFiniteNumber(value) {
60
- if (typeof value !== "number" || !Number.isFinite(value))
61
- return null;
62
- return value;
63
- }
64
- function toNonEmptyString(value) {
65
- if (typeof value !== "string")
66
- return null;
67
- const trimmed = value.trim();
68
- return trimmed.length > 0 ? trimmed : null;
69
- }
70
- function formatResetDuration(totalSec) {
71
- if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0)
72
- return null;
73
- const sec = Math.floor(totalSec);
74
- const hours = Math.floor(sec / 3600);
75
- const minutes = Math.floor((sec % 3600) / 60);
76
- if (hours > 0)
77
- return `${hours}h${minutes}m`;
78
- if (minutes > 0)
79
- return `${minutes}m`;
80
- return `${sec}s`;
81
- }
82
- function formatWindow(window) {
83
- if (!window)
84
- return "n/a";
85
- return `${window.percent}%`;
86
- }
87
- function formatCompactLine(snapshot) {
88
- if (snapshot.apiUnavailable || (!snapshot.rolling && !snapshot.weekly && !snapshot.monthly)) {
89
- const reason = snapshot.apiError ?? "unknown error";
90
- return `Go n/a (${reason})`;
91
- }
92
- const rollingReset = formatResetDuration(snapshot.rolling?.resetInSec ?? null) ??
93
- snapshot.rolling?.resetText ??
94
- null;
95
- const rollingText = snapshot.rolling === null
96
- ? "5h n/a"
97
- : `5h ${snapshot.rolling.percent}%${rollingReset ? ` (reset ${rollingReset})` : ""}`;
98
- return `Go ${rollingText} | 7d ${formatWindow(snapshot.weekly)} | 30d ${formatWindow(snapshot.monthly)}`;
99
- }
46
+ const WINDOW_KEYS = ["rolling", "weekly", "monthly"];
47
+ // Fetch redirect statuses (the cookie path handles them manually).
48
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
49
+ // Host this bundle serves: "kilo" only when baked in by the build (see
50
+ // scripts/build-plugins.mjs); every other case is opencode. Credentials, file
51
+ // config and the disk cache always resolve from this host's own roots.
52
+ const HOST = resolveUsageHost();
53
+ const DISK_CACHE_PATH = safeJoinPath(resolveHostRoots(HOST).configDir, "oc-go-usage-display-cache.json");
100
54
  // ---------------------------------------------------------------------------
101
55
  // Credentials (boundary: env + optional JSON file; never logged)
102
56
  // ---------------------------------------------------------------------------
103
- function readFileConfig() {
104
- let raw;
105
- try {
106
- raw = fs.readFileSync(FILE_CONFIG_PATH, "utf8");
107
- }
108
- catch {
109
- return { workspaceId: null, authCookie: null };
110
- }
111
- let parsed;
112
- try {
113
- parsed = JSON.parse(raw);
114
- }
115
- catch {
116
- return { workspaceId: null, authCookie: null };
117
- }
118
- if (!isRecord(parsed))
119
- return { workspaceId: null, authCookie: null };
120
- return {
121
- workspaceId: toNonEmptyString(parsed.workspaceId),
122
- authCookie: toNonEmptyString(parsed.authCookie),
123
- };
124
- }
125
- function readAuthJsonApiKey() {
126
- for (const authPath of authJsonPaths()) {
127
- let raw;
128
- try {
129
- raw = fs.readFileSync(authPath, "utf8");
130
- }
131
- catch {
132
- continue;
133
- }
134
- let parsed;
135
- try {
136
- parsed = JSON.parse(raw);
137
- }
138
- catch {
139
- continue;
140
- }
141
- if (!isRecord(parsed))
142
- continue;
143
- const goEntry = parsed["opencode-go"];
144
- const fallbackEntry = parsed["opencode"];
145
- const goKey = isRecord(goEntry) ? toNonEmptyString(goEntry.key) : null;
146
- if (goKey)
147
- return goKey;
148
- const fallbackKey = isRecord(fallbackEntry) ? toNonEmptyString(fallbackEntry.key) : null;
149
- if (fallbackKey)
150
- return fallbackKey;
151
- }
152
- return null;
153
- }
154
- function resolveCredentials() {
155
- if (process.env.OPENCODE_GO_MOCK === "1")
57
+ function resolveCredentials(fileConfig = readFileConfig(HOST)) {
58
+ if (hostEnv(HOST, "MOCK") === "1")
156
59
  return { kind: "mock" };
157
- const apiKey = toNonEmptyString(process.env.OPENCODE_GO_API_KEY);
60
+ const apiKey = toNonEmptyString(hostEnv(HOST, "API_KEY"));
158
61
  if (apiKey)
159
62
  return { kind: "apiKey", apiKey };
160
- const authJsonKey = readAuthJsonApiKey();
63
+ const authJsonKey = readAuthJsonApiKey(HOST);
161
64
  if (authJsonKey)
162
65
  return { kind: "apiKey", apiKey: authJsonKey };
163
- const fileConfig = readFileConfig();
164
- const workspaceId = toNonEmptyString(process.env.OPENCODE_GO_WORKSPACE_ID) ?? fileConfig.workspaceId;
165
- const authCookie = toNonEmptyString(process.env.OPENCODE_GO_AUTH_COOKIE) ?? fileConfig.authCookie;
166
- if (workspaceId && authCookie)
66
+ const workspaceId = toNonEmptyString(hostEnv(HOST, "WORKSPACE_ID")) ?? fileConfig.workspaceId;
67
+ const authCookie = toNonEmptyString(hostEnv(HOST, "AUTH_COOKIE")) ?? fileConfig.authCookie;
68
+ if (workspaceId && authCookie) {
69
+ // Reject header-injection / cookie-jar confusion payloads. The cookie
70
+ // value is never logged; malformed values fall through to "none".
71
+ if (isMalformedAuthCookie(authCookie))
72
+ return { kind: "none" };
167
73
  return { kind: "cookie", workspaceId, authCookie };
74
+ }
168
75
  return { kind: "none" };
169
76
  }
170
77
  // ---------------------------------------------------------------------------
@@ -172,7 +79,7 @@ function resolveCredentials() {
172
79
  // ---------------------------------------------------------------------------
173
80
  let memoryCache = null;
174
81
  function isFresh(at, now) {
175
- return now - at < CACHE_TTL_MS;
82
+ return at <= now && now - at < CACHE_TTL_MS;
176
83
  }
177
84
  function readDiskCache(now) {
178
85
  let raw;
@@ -199,7 +106,25 @@ function readDiskCache(now) {
199
106
  const snapshot = parsed.snapshot;
200
107
  if (!("rolling" in snapshot && "weekly" in snapshot && "monthly" in snapshot))
201
108
  return null;
202
- return snapshot;
109
+ // Re-parse the stored windows instead of casting the raw entry: a cache file
110
+ // written by an older build predates newer `UsageWindow` fields, so the entry
111
+ // can be missing them. A window that no longer parses drops the whole entry
112
+ // so the next fetch repopulates the cache.
113
+ const windows = {
114
+ rolling: null,
115
+ weekly: null,
116
+ monthly: null,
117
+ };
118
+ for (const key of WINDOW_KEYS) {
119
+ const cachedWindow = snapshot[key];
120
+ if (cachedWindow === null)
121
+ continue;
122
+ const window = extractWindow(cachedWindow);
123
+ if (window === null)
124
+ return null;
125
+ windows[key] = window;
126
+ }
127
+ return { ...snapshot, ...windows };
203
128
  }
204
129
  function writeDiskCache(snapshot) {
205
130
  try {
@@ -213,96 +138,54 @@ function writeDiskCache(snapshot) {
213
138
  // ---------------------------------------------------------------------------
214
139
  // Fetch helpers
215
140
  // ---------------------------------------------------------------------------
216
- function unavailableSnapshot(error, source = "unavailable") {
217
- return {
218
- rolling: null,
219
- weekly: null,
220
- monthly: null,
221
- source,
222
- fetchedAt: Date.now(),
223
- apiUnavailable: true,
224
- apiError: error,
225
- };
226
- }
227
- async function fetchWithTimeout(url, init) {
141
+ // Run the whole exchange (headers and body) under one abort timer: clearing
142
+ // it as soon as the response headers arrive would leave a server that stalls
143
+ // mid-body hanging past FETCH_TIMEOUT_MS. `read` consumes the body while the
144
+ // timer is armed; the signal lets callers tell an aborted read from a payload
145
+ // problem. The timer is cleared on every path (body read, redirect handling,
146
+ // fetch failure, abort).
147
+ async function fetchWithTimeout(url, init, read) {
228
148
  const controller = new AbortController();
229
149
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
230
150
  try {
231
- return await fetch(url, { ...init, signal: controller.signal });
151
+ const response = await fetch(url, { ...init, signal: controller.signal });
152
+ return await read(response, controller.signal);
232
153
  }
233
154
  finally {
234
155
  clearTimeout(timer);
235
156
  }
236
157
  }
237
- // --- API-key path: tolerant JSON parsing (shape may evolve) ---
238
- function extractWindow(candidate) {
239
- if (!isRecord(candidate))
240
- return null;
241
- const percent = toFiniteNumber(candidate.percent ??
242
- candidate.usagePercent ??
243
- candidate.usedPercent ??
244
- candidate.value ??
245
- candidate.usage);
246
- if (percent === null)
247
- return null;
248
- const resetInSec = toFiniteNumber(candidate.resetInSec ?? candidate.resetInSeconds ?? null);
249
- const status = toNonEmptyString(candidate.status);
250
- const resetText = toNonEmptyString(candidate.resetText ?? candidate.reset ?? null);
251
- return { percent: Math.round(percent), resetInSec, status, resetText };
252
- }
253
- function extractSnapshotFromApiPayload(payload) {
254
- if (!isRecord(payload))
255
- return null;
256
- const containers = [payload];
257
- for (const key of ["usage", "data", "go"]) {
258
- if (isRecord(payload[key]))
259
- containers.push(payload[key]);
260
- }
261
- for (const container of containers) {
262
- if (!isRecord(container))
263
- continue;
264
- const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"]);
265
- const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"]);
266
- const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"]);
267
- if (rolling || weekly || monthly) {
268
- return {
269
- rolling,
270
- weekly,
271
- monthly,
272
- source: "api",
273
- fetchedAt: Date.now(),
274
- };
275
- }
276
- }
277
- return null;
278
- }
158
+ // --- API-key path: tolerant JSON parsing lives in `./shared.js` (shape may
159
+ // evolve); scrape helpers below stay server-local. ---
279
160
  async function fetchViaApiKey(apiKey) {
280
- let response;
281
161
  try {
282
- response = await fetchWithTimeout(API_USAGE_URL, {
162
+ return await fetchWithTimeout(API_USAGE_URL, {
283
163
  headers: {
284
164
  Authorization: `Bearer ${apiKey}`,
285
165
  Accept: "application/json",
286
166
  },
167
+ }, async (response, signal) => {
168
+ if (response.status === 401 || response.status === 403) {
169
+ return unavailableSnapshot("API key rejected (401/403)");
170
+ }
171
+ if (!response.ok) {
172
+ return unavailableSnapshot(`upstream returned HTTP ${response.status}`);
173
+ }
174
+ let payload;
175
+ try {
176
+ payload = await response.json();
177
+ }
178
+ catch {
179
+ // A body read cut off by the timeout is a request failure; anything
180
+ // else is a payload the API-shape parser cannot use.
181
+ return unavailableSnapshot(signal.aborted ? "request failed" : "unexpected API response shape");
182
+ }
183
+ return extractSnapshotFromApiPayload(payload) ?? unavailableSnapshot("unexpected API response shape");
287
184
  });
288
185
  }
289
186
  catch {
290
187
  return unavailableSnapshot("request failed");
291
188
  }
292
- if (response.status === 401 || response.status === 403) {
293
- return unavailableSnapshot("API key rejected (401/403)");
294
- }
295
- if (!response.ok) {
296
- return unavailableSnapshot(`upstream returned HTTP ${response.status}`);
297
- }
298
- let payload;
299
- try {
300
- payload = await response.json();
301
- }
302
- catch {
303
- return unavailableSnapshot("unexpected API response shape");
304
- }
305
- return extractSnapshotFromApiPayload(payload) ?? unavailableSnapshot("unexpected API response shape");
306
189
  }
307
190
  // --- Cookie path: workspace page scrape (ported from opencode-go-hud) ---
308
191
  const USAGE_KEYS = {
@@ -310,6 +193,9 @@ const USAGE_KEYS = {
310
193
  weekly: "weeklyUsage",
311
194
  monthly: "monthlyUsage",
312
195
  };
196
+ // Markup order of the `usage-item` slots. Spelled out rather than derived from
197
+ // `WINDOW_KEYS`: it is a property of the scraped page, not of the display, and
198
+ // must not follow a display-order change.
313
199
  const DOM_ORDER = ["rolling", "weekly", "monthly"];
314
200
  const LOGIN_TITLE_MARKER = "<title>OpenAuth</title>";
315
201
  function isLoginPage(finalUrl, html) {
@@ -357,11 +243,12 @@ function extractUsageBlock(html, key) {
357
243
  }
358
244
  function parseIntField(block, field) {
359
245
  const match = new RegExp(`${escapeRegex(field)}\\s*:\\s*(-?\\d+)`).exec(block);
360
- return match ? Number.parseInt(match[1], 10) : null;
246
+ const digits = match?.[1];
247
+ return digits === undefined ? null : Number.parseInt(digits, 10);
361
248
  }
362
249
  function parseStrField(block, field) {
363
250
  const match = new RegExp(`${escapeRegex(field)}\\s*:\\s*"([^"]*)"`).exec(block);
364
- return match ? match[1] : null;
251
+ return match?.[1] ?? null;
365
252
  }
366
253
  function parseInlineUsage(html) {
367
254
  const result = {};
@@ -373,10 +260,12 @@ function parseInlineUsage(html) {
373
260
  const percent = parseIntField(block, "usagePercent");
374
261
  if (percent === null)
375
262
  continue;
263
+ const status = parseStrField(block, "status");
376
264
  result[window] = {
377
265
  percent,
266
+ status,
267
+ limited: isLimitedStatus(status),
378
268
  resetInSec: parseIntField(block, "resetInSec"),
379
- status: parseStrField(block, "status"),
380
269
  resetText: null,
381
270
  };
382
271
  }
@@ -397,19 +286,25 @@ function parseDomUsage(html) {
397
286
  itemStarts.push(match.index);
398
287
  }
399
288
  for (let idx = 0; idx < DOM_ORDER.length; idx++) {
400
- if (idx >= itemStarts.length)
289
+ const window = DOM_ORDER[idx];
290
+ const start = itemStarts[idx];
291
+ if (window === undefined || start === undefined)
401
292
  break;
402
- const segment = html.slice(itemStarts[idx], idx + 1 < itemStarts.length ? itemStarts[idx + 1] : itemStarts[idx] + 800);
293
+ const nextStart = itemStarts[idx + 1];
294
+ const segment = html.slice(start, nextStart !== undefined ? nextStart : start + 800);
403
295
  const valueMatch = /data-slot="usage-value">\s*(?:<!--[\s\S]*?-->)?\s*(\d+)/s.exec(segment) ??
404
296
  /width:\s*(\d+)%/.exec(segment);
405
- if (!valueMatch)
297
+ const percent = valueMatch?.[1];
298
+ if (percent === undefined)
406
299
  continue;
407
300
  const resetMatch = /data-slot="reset-time">\s*([\s\S]*?)<\/span>/.exec(segment);
408
- result[DOM_ORDER[idx]] = {
409
- percent: Number.parseInt(valueMatch[1], 10),
410
- resetInSec: null,
301
+ const resetText = resetMatch?.[1];
302
+ result[window] = {
303
+ percent: Number.parseInt(percent, 10),
411
304
  status: null,
412
- resetText: resetMatch ? cleanResetText(resetMatch[1]) : null,
305
+ limited: false,
306
+ resetInSec: null,
307
+ resetText: resetText !== undefined ? cleanResetText(resetText) : null,
413
308
  };
414
309
  }
415
310
  return result;
@@ -422,61 +317,76 @@ function parseScrapedUsage(html) {
422
317
  }
423
318
  async function fetchViaCookie(workspaceId, authCookie) {
424
319
  const workspaceUrl = `https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`;
425
- let response;
426
- try {
427
- response = await fetchWithTimeout(workspaceUrl, {
428
- redirect: "follow",
429
- headers: {
430
- Cookie: `auth=${authCookie}`,
431
- "User-Agent": "oc-go-usage-display-plugin/1.0",
432
- Accept: "text/html,application/xhtml+xml",
433
- },
434
- });
435
- }
436
- catch {
437
- return unavailableSnapshot("request failed");
438
- }
439
- let html = "";
440
- try {
441
- html = await response.text();
442
- }
443
- catch {
444
- return unavailableSnapshot("request failed");
445
- }
446
- if (isLoginPage(response.url, html)) {
447
- return unavailableSnapshot("login expired (refresh auth cookie)");
448
- }
449
- if (response.status !== 200) {
450
- return unavailableSnapshot(`upstream returned HTTP ${response.status}`);
451
- }
452
- const usages = parseScrapedUsage(html);
453
- if (Object.keys(usages).length === 0) {
454
- if (isNoSubscription(html))
455
- return unavailableSnapshot("no OpenCode Go subscription");
456
- return unavailableSnapshot("usage markup not recognized");
320
+ let currentUrl = workspaceUrl;
321
+ let hopsFollowed = 0;
322
+ // `redirect: "manual"`: automatic redirect following may re-send caller
323
+ // headers (including the auth cookie) to a cross-origin Location, and runtime
324
+ // header stripping cannot be relied on. Each hop is re-requested only after
325
+ // `resolveAllowedRedirect` approves the Location (allowlisted canonical HTTPS
326
+ // hosts, at most MAX_REDIRECT_HOPS hops).
327
+ for (;;) {
328
+ let hop;
329
+ try {
330
+ hop = await fetchWithTimeout(currentUrl, {
331
+ redirect: "manual",
332
+ headers: {
333
+ Cookie: `auth=${authCookie}`,
334
+ "User-Agent": "oc-go-usage-display-plugin",
335
+ Accept: "text/html,application/xhtml+xml",
336
+ },
337
+ }, async (response) => {
338
+ if (REDIRECT_STATUSES.has(response.status)) {
339
+ // The redirect body is never read: Location alone decides the next
340
+ // hop, so this resolves (and clears the abort timer) immediately.
341
+ return { kind: "redirect", location: response.headers.get("location") };
342
+ }
343
+ return { kind: "body", status: response.status, html: await response.text() };
344
+ });
345
+ }
346
+ catch {
347
+ return unavailableSnapshot("request failed");
348
+ }
349
+ if (hop.kind === "redirect") {
350
+ const decision = resolveAllowedRedirect(currentUrl, hop.location, hopsFollowed);
351
+ if (!decision.follow)
352
+ return unavailableSnapshot(decision.reason);
353
+ currentUrl = decision.url;
354
+ hopsFollowed += 1;
355
+ continue;
356
+ }
357
+ const { status, html } = hop;
358
+ if (isLoginPage(currentUrl, html)) {
359
+ return unavailableSnapshot("login expired (refresh auth cookie)");
360
+ }
361
+ if (status !== 200) {
362
+ return unavailableSnapshot(`upstream returned HTTP ${status}`);
363
+ }
364
+ const usages = parseScrapedUsage(html);
365
+ if (Object.keys(usages).length === 0) {
366
+ if (isNoSubscription(html))
367
+ return unavailableSnapshot("no OpenCode Go subscription");
368
+ return unavailableSnapshot("usage markup not recognized");
369
+ }
370
+ return {
371
+ rolling: usages.rolling ?? null,
372
+ weekly: usages.weekly ?? null,
373
+ monthly: usages.monthly ?? null,
374
+ source: "scrape",
375
+ fetchedAt: Date.now(),
376
+ };
457
377
  }
458
- return {
459
- rolling: usages.rolling ?? null,
460
- weekly: usages.weekly ?? null,
461
- monthly: usages.monthly ?? null,
462
- source: "scrape",
463
- fetchedAt: Date.now(),
464
- };
465
- }
466
- function mockSnapshot() {
467
- return {
468
- rolling: { percent: 42, resetInSec: 7543, status: "active", resetText: null },
469
- weekly: { percent: 15, resetInSec: null, status: "active", resetText: null },
470
- monthly: { percent: 61, resetInSec: null, status: "active", resetText: null },
471
- source: "mock",
472
- fetchedAt: Date.now(),
473
- };
474
378
  }
475
379
  // ---------------------------------------------------------------------------
476
380
  // Snapshot entry point (cached, never throws, never logs secrets)
477
381
  // ---------------------------------------------------------------------------
478
382
  async function getUsageSnapshot() {
479
383
  const now = Date.now();
384
+ // Mock bypasses cache for determinism: a stale disk/memory entry must
385
+ // never shadow the deterministic mock snapshot during tests. The mock
386
+ // never reads or writes the cache.
387
+ if (hostEnv(HOST, "MOCK") === "1") {
388
+ return mockSnapshot();
389
+ }
480
390
  if (memoryCache && isFresh(memoryCache.at, now))
481
391
  return memoryCache.snapshot;
482
392
  const diskCached = readDiskCache(now);
@@ -484,14 +394,20 @@ async function getUsageSnapshot() {
484
394
  memoryCache = { at: now, snapshot: diskCached };
485
395
  return diskCached;
486
396
  }
487
- const credentials = resolveCredentials();
397
+ const fileConfig = readFileConfig(HOST);
398
+ const credentials = resolveCredentials(fileConfig);
488
399
  if (credentials.kind === "mock") {
489
- const snapshot = mockSnapshot();
490
- memoryCache = { at: now, snapshot };
491
- return snapshot;
400
+ return mockSnapshot();
492
401
  }
493
402
  if (credentials.kind === "none") {
494
- return unavailableSnapshot("not configured (set OPENCODE_GO_API_KEY)");
403
+ // A malformed-cookie reason is only meaningful when a workspaceId is
404
+ // present (the user actually attempted cookie auth); a fully
405
+ // unconfigured setup reports the generic reason.
406
+ const workspaceId = toNonEmptyString(hostEnv(HOST, "WORKSPACE_ID")) ?? fileConfig.workspaceId;
407
+ if (workspaceId && hasMalformedAuthCookie(fileConfig)) {
408
+ return unavailableSnapshot("not configured (malformed auth cookie)");
409
+ }
410
+ return unavailableSnapshot(`not configured (set ${hostEnvName(HOST, "API_KEY")})`);
495
411
  }
496
412
  const snapshot = credentials.kind === "apiKey"
497
413
  ? await fetchViaApiKey(credentials.apiKey)
@@ -505,19 +421,83 @@ async function getUsageSnapshot() {
505
421
  // ---------------------------------------------------------------------------
506
422
  // Plugin
507
423
  // ---------------------------------------------------------------------------
508
- export default (async () => {
509
- return {
510
- tool: {
511
- go_usage: tool({
512
- description: "Show OpenCode Go subscription usage: rolling 5h, weekly, and monthly windows. Takes no arguments.",
513
- args: {},
514
- execute: async () => {
515
- const snapshot = await getUsageSnapshot().catch(() => unavailableSnapshot("request failed"));
516
- const line = formatCompactLine(snapshot);
517
- return `${line}\n${JSON.stringify(snapshot, null, 2)}`;
518
- },
519
- }),
520
- },
521
- };
522
- });
424
+ // The ONLY export must be the default module: OpenCode's loader enumerates
425
+ // every export and invokes each as a plugin factory when the default is not a
426
+ // `{ id, server }` module. Helpers live in `./helpers.js` for exactly that
427
+ // reason. `server` returns the existing `{ tool: { go_usage } }` hook surface.
428
+ // A Hooks value is always a plain object. Drop nullish hook entries (and
429
+ // nullish tool definitions) so the loader can never dereference
430
+ // `hook.config` / `hook.provider` on a null value.
431
+ function sanitizeHooks(hooks) {
432
+ if (!isRecord(hooks))
433
+ return {};
434
+ const clean = {};
435
+ for (const [key, value] of Object.entries(hooks)) {
436
+ if (value === null || value === undefined)
437
+ continue;
438
+ if (key === "tool" && isRecord(value)) {
439
+ const tools = {};
440
+ for (const [name, definition] of Object.entries(value)) {
441
+ if (definition === null || definition === undefined)
442
+ continue;
443
+ tools[name] = definition;
444
+ }
445
+ clean[key] = tools;
446
+ continue;
447
+ }
448
+ clean[key] = value;
449
+ }
450
+ return clean;
451
+ }
452
+ // Best-effort error log. The client (or its `log` method) may be absent or
453
+ // throw on a malformed input, and logging must never rethrow into a caller or
454
+ // delay plugin resolution: callers use `void logServerError(...)`.
455
+ async function logServerError(input, message) {
456
+ try {
457
+ const client = input?.client;
458
+ await client?.app?.log?.({
459
+ service: "oc-go-usage-display",
460
+ level: "error",
461
+ message,
462
+ });
463
+ }
464
+ catch {
465
+ // Logging is best-effort; callers must never fail because of it.
466
+ }
467
+ }
468
+ // Fail-safe contract: OpenCode always starts, even if this plugin cannot. The
469
+ // factory resolves to a valid Hooks object (never undefined/null) for any
470
+ // input; on initialization failure it logs best-effort and resolves to `{}`.
471
+ const server = async (input, _options) => {
472
+ try {
473
+ return sanitizeHooks({
474
+ tool: {
475
+ go_usage: tool({
476
+ description: "Show OpenCode Go subscription usage: rolling 5h, weekly, and monthly windows. Takes no arguments.",
477
+ args: {},
478
+ execute: async () => {
479
+ try {
480
+ const snapshot = await getUsageSnapshot().catch(() => unavailableSnapshot("request failed"));
481
+ const line = formatServerLine(snapshot);
482
+ return `${line}\n${JSON.stringify(snapshot, null, 2)}`;
483
+ }
484
+ catch (error) {
485
+ // Same output shape as the success path (line + JSON tail); the
486
+ // failure is logged best-effort and the invocation still
487
+ // resolves so it can never reject into the host.
488
+ const snapshot = unavailableSnapshot("request failed");
489
+ void logServerError(input, `go_usage tool execution failed: ${errorMessage(error)}`);
490
+ return `${formatServerLine(snapshot)}\n${JSON.stringify(snapshot, null, 2)}`;
491
+ }
492
+ },
493
+ }),
494
+ },
495
+ });
496
+ }
497
+ catch (error) {
498
+ void logServerError(input, `Go usage plugin failed to initialize: ${errorMessage(error)}; continuing without hooks`);
499
+ return {};
500
+ }
501
+ };
502
+ export default { id: "oc-go-usage-display", server };
523
503
  //# sourceMappingURL=index.js.map