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.
- package/README.md +249 -68
- package/bin/lib.js +433 -29
- package/bin/oc-go-usage-display-init.js +42 -13
- package/bin/oc-go-usage-display-remove.js +38 -0
- package/bin/oc-go-usage-display-show.js +47 -39
- package/bin/oc-go-usage-display-status.js +33 -42
- package/bin/oc-go-usage-display-update.js +27 -14
- package/dist/helpers.d.ts +116 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +568 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +258 -278
- package/dist/index.js.map +1 -1
- package/dist/plugins/oc-go-usage-display.kilo.ts +678 -0
- package/dist/plugins/oc-go-usage-display.kilo.tsx +1272 -0
- package/dist/plugins/oc-go-usage-display.ts +678 -0
- package/dist/plugins/oc-go-usage-display.tsx +1018 -0
- package/dist/shared.d.ts +96 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +575 -0
- package/dist/shared.js.map +1 -0
- package/dist/tui-shared.d.ts +109 -0
- package/dist/tui-shared.d.ts.map +1 -0
- package/dist/tui-shared.js +372 -0
- package/dist/tui-shared.js.map +1 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +226 -377
- package/dist/tui.js.map +1 -1
- package/dist/tui.kilo.d.ts +8 -0
- package/dist/tui.kilo.d.ts.map +1 -0
- package/dist/tui.kilo.js +502 -0
- package/dist/tui.kilo.js.map +1 -0
- package/package.json +47 -13
- package/src/helpers.ts +733 -0
- package/src/index.ts +331 -304
- package/src/shared.ts +696 -0
- package/src/tui-shared.tsx +705 -0
- package/src/tui.kilo.tsx +816 -0
- 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/
|
|
21
|
-
// ~/.local/share/opencode/auth.json),
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
//
|
|
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
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
289
|
+
const window = DOM_ORDER[idx];
|
|
290
|
+
const start = itemStarts[idx];
|
|
291
|
+
if (window === undefined || start === undefined)
|
|
401
292
|
break;
|
|
402
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
301
|
+
const resetText = resetMatch?.[1];
|
|
302
|
+
result[window] = {
|
|
303
|
+
percent: Number.parseInt(percent, 10),
|
|
411
304
|
status: null,
|
|
412
|
-
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if (
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
397
|
+
const fileConfig = readFileConfig(HOST);
|
|
398
|
+
const credentials = resolveCredentials(fileConfig);
|
|
488
399
|
if (credentials.kind === "mock") {
|
|
489
|
-
|
|
490
|
-
memoryCache = { at: now, snapshot };
|
|
491
|
-
return snapshot;
|
|
400
|
+
return mockSnapshot();
|
|
492
401
|
}
|
|
493
402
|
if (credentials.kind === "none") {
|
|
494
|
-
|
|
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
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|