@scrylog/cli 0.1.4 → 0.3.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 (97) hide show
  1. package/README.md +46 -7
  2. package/package.json +13 -10
  3. package/src/api/docs/sessions.ts +3 -2
  4. package/src/api/docs/system.ts +3 -0
  5. package/src/api/docs/usage.ts +2 -2
  6. package/src/api/routes/sessions.ts +6 -2
  7. package/src/api/routes/system.ts +4 -1
  8. package/src/api/routes/webhooks/claude.ts +9 -21
  9. package/src/api/routes/webhooks/opencode.ts +6 -1
  10. package/src/api/routes.ts +1 -1
  11. package/src/api/schemas/common.ts +8 -0
  12. package/src/api/schemas/sessions.ts +15 -1
  13. package/src/api/schemas/usage.ts +1 -0
  14. package/src/cli/commands.ts +170 -26
  15. package/src/cli/index.ts +18 -0
  16. package/src/cli/program.ts +175 -0
  17. package/src/daemon/collectors/claude/collector.ts +11 -1
  18. package/src/daemon/collectors/claude/discovery.ts +2 -23
  19. package/src/daemon/collectors/codex/collector.ts +3 -3
  20. package/src/daemon/collectors/git.ts +60 -2
  21. package/src/daemon/collectors/herdr/collector.ts +128 -0
  22. package/src/daemon/collectors/herdr/index.ts +2 -0
  23. package/src/daemon/collectors/herdr/normalization.ts +111 -0
  24. package/src/daemon/collectors/opencode/collector.ts +4 -2
  25. package/src/daemon/collectors/opencode-db.ts +17 -5
  26. package/src/daemon/collectors/tmux/collector.ts +21 -1
  27. package/src/daemon/collectors/tmux/enrichment.ts +1 -0
  28. package/src/daemon/collectors/usage.ts +115 -49
  29. package/src/daemon/index.ts +2 -0
  30. package/src/daemon/store.ts +109 -14
  31. package/src/postinstall.cjs +11 -0
  32. package/src/shared/config.ts +33 -0
  33. package/src/shared/session-exclusions.ts +5 -0
  34. package/src/shared/session-view.ts +67 -6
  35. package/src/shared/terminal-capabilities.ts +130 -0
  36. package/src/shared/types.ts +33 -2
  37. package/src/shared/version.ts +44 -0
  38. package/src/tui/App.tsx +240 -0
  39. package/src/tui/assets/providers/SOURCES.md +21 -0
  40. package/src/tui/assets/providers/claude-terminal.png +0 -0
  41. package/src/tui/assets/providers/claude-terminal.svg +62 -0
  42. package/src/tui/assets/providers/claude.png +0 -0
  43. package/src/tui/assets/providers/copilot.png +0 -0
  44. package/src/tui/assets/providers/copilot.svg +44 -0
  45. package/src/tui/assets/providers/openai-terminal.png +0 -0
  46. package/src/tui/assets/providers/openai-terminal.svg +62 -0
  47. package/src/tui/assets/providers/openai.png +0 -0
  48. package/src/tui/assets/providers/opencode-terminal.png +0 -0
  49. package/src/tui/assets/providers/opencode-terminal.svg +62 -0
  50. package/src/tui/assets/providers/opencode.png +0 -0
  51. package/src/tui/assets/providers/shell.png +0 -0
  52. package/src/tui/assets/providers/shell.svg +23 -0
  53. package/src/tui/assets/providers/unknown.png +0 -0
  54. package/src/tui/assets/providers/unknown.svg +23 -0
  55. package/src/tui/client.ts +22 -8
  56. package/src/tui/components/BindingsModal.tsx +82 -0
  57. package/src/tui/components/DashboardHeader.tsx +256 -0
  58. package/src/tui/components/KeyBar.tsx +67 -0
  59. package/src/tui/components/SessionDetailModal.tsx +169 -0
  60. package/src/tui/components/SessionsView.tsx +184 -0
  61. package/src/tui/components/TabBar.tsx +40 -0
  62. package/src/tui/components/UsageSummaryPane.tsx +272 -0
  63. package/src/tui/components/UsageView.tsx +242 -0
  64. package/src/tui/components/tool-icons.ts +24 -0
  65. package/src/tui/index.tsx +106 -0
  66. package/src/tui/keybindings.ts +49 -0
  67. package/src/tui/model.ts +295 -0
  68. package/src/tui/state.ts +77 -0
  69. package/src/tui/theme.ts +110 -0
  70. package/src/usage/README.md +3 -3
  71. package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
  72. package/src/usage/clients/anthropic-web-usage.ts +133 -49
  73. package/src/usage/clients/claude-cli-probe.ts +458 -132
  74. package/src/usage/clients/codex-app-server.ts +354 -0
  75. package/src/usage/clients/codex-oauth-usage.ts +143 -16
  76. package/src/usage/clients/opencode-go-api.ts +100 -0
  77. package/src/usage/clients/opencode-go-usage.ts +163 -27
  78. package/src/usage/errors.ts +33 -0
  79. package/src/usage/index.ts +2 -1
  80. package/src/usage/local/opencode-go-local.ts +194 -0
  81. package/src/usage/mappers.ts +24 -15
  82. package/src/usage/planner.ts +89 -25
  83. package/src/usage/providers/claude.ts +203 -83
  84. package/src/usage/providers/codex.ts +92 -11
  85. package/src/usage/providers/opencode-go.ts +94 -16
  86. package/src/usage/resolvers/browser-cookies.ts +679 -69
  87. package/src/usage/resolvers/claude-auth.ts +150 -41
  88. package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
  89. package/src/usage/resolvers/claude-web.ts +72 -6
  90. package/src/usage/resolvers/codex-auth.ts +74 -17
  91. package/src/usage/resolvers/keychain-coordinator.ts +397 -0
  92. package/src/usage/resolvers/opencode-go-web.ts +47 -6
  93. package/src/usage/types.ts +37 -2
  94. package/src/usage/web-session-cache.ts +37 -0
  95. package/src/neo-blessed.d.ts +0 -4
  96. package/src/tui/app.ts +0 -920
  97. package/src/tui/index.ts +0 -193
@@ -0,0 +1,354 @@
1
+ import { spawn } from "node:child_process";
2
+ import { whichCommand } from "../../shared/platform";
3
+ import { UsageError } from "../errors";
4
+ import type { CodexOAuthCredentials, CodexUsageSnapshot, RateWindowLegacy } from "../types";
5
+
6
+ export interface CodexAppServerProcess {
7
+ write(line: string): void;
8
+ closeStdin(): void;
9
+ onStdout(listener: (chunk: Uint8Array) => void): void;
10
+ onStderr(listener: (chunk: Uint8Array) => void): void;
11
+ onExit(listener: (code: number | null) => void): void;
12
+ onError(listener: (error: Error) => void): void;
13
+ kill(signal: "SIGTERM" | "SIGKILL"): void;
14
+ }
15
+
16
+ export type CodexAppServerTransport = (
17
+ executable: string,
18
+ args: string[],
19
+ ) => CodexAppServerProcess;
20
+
21
+ export interface CodexAppServerOptions {
22
+ timeoutMs?: number;
23
+ killGraceMs?: number;
24
+ maxLineBytes?: number;
25
+ maxOutputBytes?: number;
26
+ resolveExecutable?: () => string | null;
27
+ transport?: CodexAppServerTransport;
28
+ }
29
+
30
+ const defaultTransport: CodexAppServerTransport = (executable, args) => {
31
+ const child = spawn(executable, args, {
32
+ env: process.env,
33
+ stdio: ["pipe", "pipe", "pipe"],
34
+ windowsHide: true,
35
+ });
36
+ return {
37
+ write: (line) => child.stdin.write(line),
38
+ closeStdin: () => child.stdin.end(),
39
+ onStdout: (listener) => child.stdout.on("data", listener),
40
+ onStderr: (listener) => child.stderr.on("data", listener),
41
+ onExit: (listener) => child.on("close", listener),
42
+ onError: (listener) => child.on("error", listener),
43
+ kill: (signal) => { child.kill(signal); },
44
+ };
45
+ };
46
+
47
+ const INITIALIZE_ID = 1;
48
+ const RATE_LIMITS_ID = 2;
49
+
50
+ export class CodexAppServerClient {
51
+ private readonly timeoutMs: number;
52
+ private readonly killGraceMs: number;
53
+ private readonly maxLineBytes: number;
54
+ private readonly maxOutputBytes: number;
55
+ private readonly resolveExecutable: () => string | null;
56
+ private readonly transport: CodexAppServerTransport;
57
+ private readonly inFlight = new Map<string, Promise<CodexUsageSnapshot>>();
58
+
59
+ constructor(options: CodexAppServerOptions = {}) {
60
+ this.timeoutMs = options.timeoutMs ?? 10_000;
61
+ this.killGraceMs = options.killGraceMs ?? 250;
62
+ this.maxLineBytes = options.maxLineBytes ?? 256 * 1_024;
63
+ this.maxOutputBytes = options.maxOutputBytes ?? 1_024 * 1_024;
64
+ this.resolveExecutable = options.resolveExecutable ?? (() => whichCommand("codex"));
65
+ this.transport = options.transport ?? defaultTransport;
66
+ }
67
+
68
+ async fetch(
69
+ identity?: Pick<CodexOAuthCredentials, "accountEmail" | "accountId">,
70
+ options?: { signal?: AbortSignal },
71
+ ): Promise<CodexUsageSnapshot> {
72
+ const key = identity?.accountId ?? identity?.accountEmail ?? "default";
73
+ const existing = this.inFlight.get(key);
74
+ if (existing) return this.waitForShared(existing, options?.signal);
75
+
76
+ const attempt = this.fetchOnce(identity, options).finally(() => this.inFlight.delete(key));
77
+ this.inFlight.set(key, attempt);
78
+ return attempt;
79
+ }
80
+
81
+ private async fetchOnce(
82
+ identity?: Pick<CodexOAuthCredentials, "accountEmail" | "accountId">,
83
+ options?: { signal?: AbortSignal },
84
+ ): Promise<CodexUsageSnapshot> {
85
+ if (options?.signal?.aborted) {
86
+ throw new UsageError("cancelled", "Codex app-server request cancelled");
87
+ }
88
+
89
+ const executable = this.resolveExecutable();
90
+ if (!executable) {
91
+ throw new UsageError("transient", "Codex CLI executable was not found");
92
+ }
93
+
94
+ let processHandle: CodexAppServerProcess;
95
+ try {
96
+ processHandle = this.transport(executable, ["-s", "read-only", "-a", "never", "app-server"]);
97
+ } catch (error) {
98
+ throw new UsageError(
99
+ "transient",
100
+ `Codex app-server failed to start: ${error instanceof Error ? error.message : String(error)}`,
101
+ );
102
+ }
103
+
104
+ return new Promise<CodexUsageSnapshot>((resolve, reject) => {
105
+ let buffer = "";
106
+ let outputBytes = 0;
107
+ let cleaningUp = false;
108
+ let completed = false;
109
+ let result: CodexUsageSnapshot | undefined;
110
+ let failure: UsageError | undefined;
111
+
112
+ const complete = () => {
113
+ if (completed) return;
114
+ completed = true;
115
+ clearTimeout(deadline);
116
+ clearTimeout(killTimer);
117
+ options?.signal?.removeEventListener("abort", onAbort);
118
+ if (failure) reject(failure);
119
+ else resolve(result!);
120
+ };
121
+
122
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
123
+ const settle = (nextResult?: CodexUsageSnapshot, nextFailure?: UsageError) => {
124
+ if (cleaningUp || completed) return;
125
+ cleaningUp = true;
126
+ result = nextResult;
127
+ failure = nextFailure;
128
+ try { processHandle.closeStdin(); } catch {}
129
+ try { processHandle.kill("SIGTERM"); } catch {}
130
+ killTimer = setTimeout(() => {
131
+ try { processHandle.kill("SIGKILL"); } catch {}
132
+ complete();
133
+ }, this.killGraceMs);
134
+ };
135
+
136
+ const onAbort = () => settle(undefined, new UsageError("cancelled", "Codex app-server request cancelled"));
137
+ const deadline = setTimeout(
138
+ () => settle(undefined, new UsageError("transient", `Codex app-server timed out after ${this.timeoutMs}ms`)),
139
+ this.timeoutMs,
140
+ );
141
+
142
+ const send = (message: Record<string, unknown>) => {
143
+ processHandle.write(`${JSON.stringify(message)}\n`);
144
+ };
145
+
146
+ const handleLine = (line: string) => {
147
+ if (!line.trim() || cleaningUp) return;
148
+ let message: Record<string, unknown>;
149
+ try {
150
+ message = JSON.parse(line) as Record<string, unknown>;
151
+ } catch {
152
+ settle(undefined, new UsageError("invalid-response", "Codex app-server emitted invalid JSON"));
153
+ return;
154
+ }
155
+
156
+ if (message["id"] === INITIALIZE_ID) {
157
+ if (message["error"] !== undefined) {
158
+ settle(undefined, new UsageError("invalid-response", "Codex app-server initialization failed"));
159
+ return;
160
+ }
161
+ send({ jsonrpc: "2.0", method: "initialized", params: {} });
162
+ send({ jsonrpc: "2.0", id: RATE_LIMITS_ID, method: "account/rateLimits/read", params: {} });
163
+ return;
164
+ }
165
+
166
+ if (message["id"] === RATE_LIMITS_ID) {
167
+ if (message["error"] !== undefined) {
168
+ settle(undefined, new UsageError("invalid-response", "Codex app-server rate-limit request failed"));
169
+ return;
170
+ }
171
+ try {
172
+ settle(parseRateLimits(message["result"], identity));
173
+ } catch (error) {
174
+ settle(undefined, error instanceof UsageError ? error : new UsageError("invalid-response", "Codex app-server returned invalid rate limits"));
175
+ }
176
+ }
177
+ };
178
+
179
+ processHandle.onStdout((chunk) => {
180
+ if (cleaningUp) return;
181
+ outputBytes += chunk.byteLength;
182
+ if (outputBytes > this.maxOutputBytes) {
183
+ settle(undefined, new UsageError("invalid-response", "Codex app-server output exceeded the configured limit"));
184
+ return;
185
+ }
186
+ buffer += Buffer.from(chunk).toString("utf8");
187
+ let newline = buffer.indexOf("\n");
188
+ while (newline >= 0) {
189
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
190
+ buffer = buffer.slice(newline + 1);
191
+ if (Buffer.byteLength(line) > this.maxLineBytes) {
192
+ settle(undefined, new UsageError("invalid-response", "Codex app-server line exceeded the configured limit"));
193
+ return;
194
+ }
195
+ handleLine(line);
196
+ newline = buffer.indexOf("\n");
197
+ }
198
+ if (Buffer.byteLength(buffer) > this.maxLineBytes) {
199
+ settle(undefined, new UsageError("invalid-response", "Codex app-server line exceeded the configured limit"));
200
+ }
201
+ });
202
+ processHandle.onStderr((chunk) => {
203
+ if (cleaningUp) return;
204
+ outputBytes += chunk.byteLength;
205
+ if (outputBytes > this.maxOutputBytes) {
206
+ settle(undefined, new UsageError("invalid-response", "Codex app-server output exceeded the configured limit"));
207
+ }
208
+ });
209
+ processHandle.onError((error) => {
210
+ settle(undefined, new UsageError("transient", `Codex app-server failed to start: ${error.message}`));
211
+ });
212
+ processHandle.onExit((code) => {
213
+ if (cleaningUp) complete();
214
+ else settle(undefined, new UsageError("transient", `Codex app-server exited before responding${code === null ? "" : ` (${code})`}`));
215
+ });
216
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
217
+
218
+ send({
219
+ jsonrpc: "2.0",
220
+ id: INITIALIZE_ID,
221
+ method: "initialize",
222
+ params: {
223
+ clientInfo: { name: "scrylog", version: "0.1.0" },
224
+ capabilities: {},
225
+ },
226
+ });
227
+ });
228
+ }
229
+
230
+ private waitForShared(promise: Promise<CodexUsageSnapshot>, signal?: AbortSignal): Promise<CodexUsageSnapshot> {
231
+ if (!signal) return promise;
232
+ if (signal.aborted) return Promise.reject(new UsageError("cancelled", "Codex app-server request cancelled"));
233
+ return new Promise((resolve, reject) => {
234
+ const abort = () => reject(new UsageError("cancelled", "Codex app-server request cancelled"));
235
+ signal.addEventListener("abort", abort, { once: true });
236
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
237
+ });
238
+ }
239
+ }
240
+
241
+ function parseRateLimits(
242
+ raw: unknown,
243
+ identity?: Pick<CodexOAuthCredentials, "accountEmail" | "accountId">,
244
+ ): CodexUsageSnapshot {
245
+ if (!isRecord(raw)) throw new UsageError("invalid-response", "Codex app-server response is missing a result");
246
+ const limits = pickRecord(raw, "rateLimits", "rate_limits");
247
+ if (!limits) throw new UsageError("invalid-response", "Codex app-server response is missing rate limits");
248
+
249
+ const primaryWindow = parseWindow(pickRecord(limits, "primary", "primaryWindow", "primary_window"));
250
+ const secondaryWindow = parseWindow(pickRecord(limits, "secondary", "secondaryWindow", "secondary_window"));
251
+ const creditsRaw = pickRecord(limits, "credits");
252
+ const creditBalance = creditsRaw
253
+ ? pickNumber(creditsRaw, "balance") ?? pickNumericString(creditsRaw, "balance")
254
+ : undefined;
255
+ const individualLimit = parseIndividualLimit(pickRecord(limits, "individualLimit", "individual_limit"));
256
+
257
+ return {
258
+ source: "cli",
259
+ planType: pickString(limits, "planType", "plan_type") ?? null,
260
+ primaryWindow,
261
+ secondaryWindow,
262
+ credits: creditsRaw ? {
263
+ hasCredits: pickBoolean(creditsRaw, "hasCredits", "has_credits") ?? false,
264
+ unlimited: pickBoolean(creditsRaw, "unlimited") ?? false,
265
+ balance: creditBalance !== undefined && creditBalance >= 0 ? creditBalance : null,
266
+ } : null,
267
+ individualLimit,
268
+ spendControlPresent: individualLimit !== null,
269
+ updatedAt: new Date(),
270
+ accountEmail: pickString(raw, "accountEmail", "account_email") ?? identity?.accountEmail ?? null,
271
+ accountId: pickString(raw, "accountId", "account_id") ?? identity?.accountId ?? null,
272
+ };
273
+ }
274
+
275
+ function parseIndividualLimit(
276
+ raw: Record<string, unknown> | undefined,
277
+ ): NonNullable<CodexUsageSnapshot["individualLimit"]> | null {
278
+ if (!raw) return null;
279
+ const used = pickNumber(raw, "used") ?? pickNumericString(raw, "used");
280
+ const limit = pickNumber(raw, "limit") ?? pickNumericString(raw, "limit");
281
+ if (used === undefined || limit === undefined || limit <= 0) return null;
282
+ const resetValue = raw["resetsAt"] ?? raw["resets_at"];
283
+ const resetsAt = typeof resetValue === "number"
284
+ ? new Date(resetValue * 1_000)
285
+ : typeof resetValue === "string" && /^\d+(?:\.\d+)?$/.test(resetValue.trim())
286
+ ? new Date(Number(resetValue) * 1_000)
287
+ : typeof resetValue === "string"
288
+ ? new Date(resetValue)
289
+ : null;
290
+ return {
291
+ used: Math.max(0, used),
292
+ limit,
293
+ resetsAt: resetsAt && !Number.isNaN(resetsAt.getTime()) ? resetsAt : null,
294
+ };
295
+ }
296
+
297
+ function parseWindow(raw: Record<string, unknown> | undefined): RateWindowLegacy | null {
298
+ if (!raw) return null;
299
+ const usedPercent = pickNumber(raw, "usedPercent", "used_percent");
300
+ if (usedPercent === undefined) return null;
301
+ const windowMinutes = pickNumber(raw, "windowDurationMins", "windowMinutes", "window_minutes") ?? 300;
302
+ const resetValue = raw["resetsAt"] ?? raw["resets_at"];
303
+ const resetsAt = typeof resetValue === "number"
304
+ ? new Date(resetValue * 1_000)
305
+ : typeof resetValue === "string"
306
+ ? new Date(resetValue)
307
+ : null;
308
+ const validReset = resetsAt && !Number.isNaN(resetsAt.getTime()) ? resetsAt : null;
309
+ return {
310
+ usedPercent: Math.max(0, Math.min(100, usedPercent)),
311
+ windowMinutes,
312
+ resetsAt: validReset,
313
+ resetDescription: validReset ? formatDuration(Math.max(0, validReset.getTime() - Date.now())) : null,
314
+ };
315
+ }
316
+
317
+ function formatDuration(ms: number): string {
318
+ const minutes = Math.floor(ms / 60_000);
319
+ const hours = Math.floor(minutes / 60);
320
+ return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`;
321
+ }
322
+
323
+ function isRecord(value: unknown): value is Record<string, unknown> {
324
+ return typeof value === "object" && value !== null && !Array.isArray(value);
325
+ }
326
+
327
+ function pickRecord(raw: Record<string, unknown>, ...keys: string[]): Record<string, unknown> | undefined {
328
+ for (const key of keys) if (isRecord(raw[key])) return raw[key];
329
+ return undefined;
330
+ }
331
+
332
+ function pickString(raw: Record<string, unknown>, ...keys: string[]): string | undefined {
333
+ for (const key of keys) if (typeof raw[key] === "string") return raw[key];
334
+ return undefined;
335
+ }
336
+
337
+ function pickNumber(raw: Record<string, unknown>, ...keys: string[]): number | undefined {
338
+ for (const key of keys) if (typeof raw[key] === "number" && Number.isFinite(raw[key])) return raw[key];
339
+ return undefined;
340
+ }
341
+
342
+ function pickNumericString(raw: Record<string, unknown>, ...keys: string[]): number | undefined {
343
+ for (const key of keys) {
344
+ if (typeof raw[key] !== "string") continue;
345
+ const value = Number(raw[key]);
346
+ if (Number.isFinite(value)) return value;
347
+ }
348
+ return undefined;
349
+ }
350
+
351
+ function pickBoolean(raw: Record<string, unknown>, ...keys: string[]): boolean | undefined {
352
+ for (const key of keys) if (typeof raw[key] === "boolean") return raw[key];
353
+ return undefined;
354
+ }
@@ -1,20 +1,31 @@
1
1
  import type { UsageClient } from "../client";
2
2
  import type { CodexOAuthCredentials, CodexUsageSnapshot, RateWindowLegacy } from "../types";
3
- import { AuthenticationError, ScopeError } from "./anthropic-oauth-usage";
3
+ import { UsageError, classifyUsageError } from "../errors";
4
4
 
5
- export { AuthenticationError, ScopeError };
5
+ export class AuthenticationError extends UsageError {
6
+ constructor(message: string) {
7
+ super("invalid-credentials", message);
8
+ this.name = "AuthenticationError";
9
+ }
10
+ }
6
11
 
7
- export class ParseError extends Error {
12
+ export class ScopeError extends UsageError {
8
13
  constructor(message: string) {
9
- super(message);
14
+ super("forbidden", message);
15
+ this.name = "ScopeError";
16
+ }
17
+ }
18
+
19
+ export class ParseError extends UsageError {
20
+ constructor(message: string) {
21
+ super("invalid-response", message);
10
22
  this.name = "ParseError";
11
23
  }
12
24
  }
13
25
 
14
26
  export class CodexOAuthUsageClient implements UsageClient<CodexOAuthCredentials, CodexUsageSnapshot> {
15
27
  async fetch(creds: CodexOAuthCredentials, options?: { signal?: AbortSignal }): Promise<CodexUsageSnapshot> {
16
- const baseUrl = creds.chatgptBaseUrl ?? "https://chatgpt.com";
17
- const url = `${baseUrl}/backend-api/wham/usage`;
28
+ const url = `${backendApiBaseUrl(creds)}/wham/usage`;
18
29
 
19
30
  const headers: Record<string, string> = {
20
31
  Authorization: `Bearer ${creds.accessToken}`,
@@ -24,11 +35,16 @@ export class CodexOAuthUsageClient implements UsageClient<CodexOAuthCredentials,
24
35
  headers["ChatGPT-Account-Id"] = creds.accountId;
25
36
  }
26
37
 
27
- const res = await fetch(url, {
28
- method: "GET",
29
- headers,
30
- signal: options?.signal,
31
- });
38
+ let res: Response;
39
+ try {
40
+ res = await fetch(url, {
41
+ method: "GET",
42
+ headers,
43
+ signal: options?.signal,
44
+ });
45
+ } catch (error) {
46
+ throw classifyUsageError(error);
47
+ }
32
48
 
33
49
  if (res.status === 401) {
34
50
  throw new AuthenticationError("Codex OAuth token expired or invalid");
@@ -40,13 +56,23 @@ export class CodexOAuthUsageClient implements UsageClient<CodexOAuthCredentials,
40
56
  }
41
57
  throw new ScopeError("Codex OAuth access forbidden");
42
58
  }
59
+ if (res.status === 429) {
60
+ const retryAfter = Number.parseFloat(res.headers.get("retry-after") ?? "");
61
+ throw new UsageError("rate-limited", "Codex usage request was rate limited", Number.isFinite(retryAfter) ? retryAfter * 1_000 : undefined);
62
+ }
43
63
  if (!res.ok) {
44
- throw new Error(`Codex usage server error: ${res.status}`);
64
+ throw new UsageError("transient", `Codex usage server error: ${res.status}`);
45
65
  }
46
66
 
47
- const body = (await res.json()) as Record<string, unknown>;
67
+ let body: Record<string, unknown>;
68
+ try {
69
+ body = (await res.json()) as Record<string, unknown>;
70
+ } catch {
71
+ throw new ParseError("Codex usage response was not valid JSON");
72
+ }
48
73
 
49
- const planType = typeof body["plan_type"] === "string" ? body["plan_type"] : null;
74
+ const planType = pickString(body, "plan_type", "planType") ?? null;
75
+ const responseAccountId = pickString(body, "account_id", "accountId") ?? null;
50
76
 
51
77
  // Handle both rate_limit and rate_limits key names
52
78
  const rateLimitKey = typeof body["rate_limit"] === "object" && body["rate_limit"] !== null
@@ -62,14 +88,21 @@ export class CodexOAuthUsageClient implements UsageClient<CodexOAuthCredentials,
62
88
 
63
89
  const primaryWindow = parseWindow(rateLimit?.["primary_window"] as Record<string, unknown> | undefined);
64
90
  const secondaryWindow = parseWindow(rateLimit?.["secondary_window"] as Record<string, unknown> | undefined);
91
+ const spendControl = pickRecord(body, "spend_control", "spendControl");
92
+ const individualLimit = parseIndividualLimit(
93
+ pickRecord(body, "individual_limit", "individualLimit")
94
+ ?? pickRecord(rateLimit, "individual_limit", "individualLimit")
95
+ ?? (spendControl ? pickRecord(spendControl, "individual_limit", "individualLimit") : undefined),
96
+ );
65
97
 
66
98
  let credits: CodexUsageSnapshot["credits"] = null;
67
99
  if (typeof body["credits"] === "object" && body["credits"] !== null) {
68
100
  const c = body["credits"] as Record<string, unknown>;
101
+ const balance = pickFiniteNumber(c, "balance");
69
102
  credits = {
70
103
  hasCredits: c["has_credits"] === true,
71
104
  unlimited: c["unlimited"] === true,
72
- balance: typeof c["balance"] === "number" ? c["balance"] : null,
105
+ balance: balance !== undefined && balance >= 0 ? balance : null,
73
106
  };
74
107
  }
75
108
 
@@ -79,13 +112,61 @@ export class CodexOAuthUsageClient implements UsageClient<CodexOAuthCredentials,
79
112
  primaryWindow,
80
113
  secondaryWindow,
81
114
  credits,
115
+ individualLimit,
116
+ spendControlPresent: Object.hasOwn(body, "spend_control") || Object.hasOwn(body, "spendControl"),
82
117
  updatedAt: new Date(),
83
118
  accountEmail: creds.accountEmail ?? null,
84
- accountId: creds.accountId ?? null,
119
+ accountId: responseAccountId ?? creds.accountId ?? null,
85
120
  };
86
121
  }
87
122
  }
88
123
 
124
+ export class CodexMonthlySpendClient {
125
+ async fetch(
126
+ creds: CodexOAuthCredentials,
127
+ accountId: string,
128
+ options?: { signal?: AbortSignal },
129
+ ): Promise<NonNullable<CodexUsageSnapshot["individualLimit"]> | null> {
130
+ const encodedAccountId = encodeURIComponent(accountId);
131
+ const url = `${backendApiBaseUrl(creds)}/accounts/${encodedAccountId}/spend-controls/current-user/monthly-usage`;
132
+ let res: Response;
133
+ try {
134
+ res = await fetch(url, {
135
+ method: "GET",
136
+ headers: {
137
+ Authorization: `Bearer ${creds.accessToken}`,
138
+ Accept: "application/json",
139
+ "ChatGPT-Account-Id": accountId,
140
+ },
141
+ signal: options?.signal,
142
+ });
143
+ } catch (error) {
144
+ throw classifyUsageError(error);
145
+ }
146
+ if (!res.ok) {
147
+ throw new UsageError(
148
+ res.status === 401 ? "invalid-credentials" : res.status === 403 ? "forbidden" : "transient",
149
+ `Codex monthly spend request failed: ${res.status}`,
150
+ );
151
+ }
152
+
153
+ let body: Record<string, unknown>;
154
+ try {
155
+ body = (await res.json()) as Record<string, unknown>;
156
+ } catch {
157
+ throw new ParseError("Codex monthly spend response was not valid JSON");
158
+ }
159
+ const effectiveLimit = pickRecord(body, "effective_monthly_limit", "effectiveMonthlyLimit");
160
+ if (!effectiveLimit) return null;
161
+ const enforcement = pickString(effectiveLimit, "enforcement_mode", "enforcementMode")?.toLowerCase();
162
+ if (enforcement && ["none", "off", "disabled", "no_limit"].includes(enforcement)) return null;
163
+ const used = pickFiniteNumber(body, "current_month_usage", "currentMonthUsage");
164
+ const limit = pickFiniteNumber(effectiveLimit, "limit");
165
+ if (used === undefined || limit === undefined || limit <= 0) return null;
166
+ return { used: Math.max(0, used), limit, resetsAt: null };
167
+ }
168
+ }
169
+
89
170
  function parseWindow(raw: Record<string, unknown> | undefined): RateWindowLegacy | null {
90
171
  if (!raw) return null;
91
172
  const usedPercent = typeof raw["used_percent"] === "number" ? raw["used_percent"] : 0;
@@ -103,6 +184,52 @@ function parseWindow(raw: Record<string, unknown> | undefined): RateWindowLegacy
103
184
  };
104
185
  }
105
186
 
187
+ function parseIndividualLimit(
188
+ raw: Record<string, unknown> | undefined,
189
+ ): NonNullable<CodexUsageSnapshot["individualLimit"]> | null {
190
+ if (!raw) return null;
191
+ const used = pickFiniteNumber(raw, "used");
192
+ const limit = pickFiniteNumber(raw, "limit");
193
+ if (used === undefined || limit === undefined || limit <= 0) return null;
194
+ const reset = pickFiniteNumber(raw, "resets_at", "resetsAt", "reset_at");
195
+ return {
196
+ used: Math.max(0, used),
197
+ limit,
198
+ resetsAt: reset === undefined ? null : new Date(reset * 1_000),
199
+ };
200
+ }
201
+
202
+ function backendApiBaseUrl(creds: CodexOAuthCredentials): string {
203
+ const base = (creds.chatgptBaseUrl ?? "https://chatgpt.com").replace(/\/+$/, "");
204
+ return base.endsWith("/backend-api") ? base : `${base}/backend-api`;
205
+ }
206
+
207
+ function pickRecord(raw: Record<string, unknown>, ...keys: string[]): Record<string, unknown> | undefined {
208
+ for (const key of keys) {
209
+ const value = raw[key];
210
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
211
+ return value as Record<string, unknown>;
212
+ }
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ function pickString(raw: Record<string, unknown>, ...keys: string[]): string | undefined {
218
+ for (const key of keys) if (typeof raw[key] === "string") return raw[key];
219
+ return undefined;
220
+ }
221
+
222
+ function pickFiniteNumber(raw: Record<string, unknown>, ...keys: string[]): number | undefined {
223
+ for (const key of keys) {
224
+ const value = raw[key];
225
+ const normalized = typeof value === "string" ? value.trim() : value;
226
+ if (normalized === "") continue;
227
+ const number = typeof normalized === "number" ? normalized : typeof normalized === "string" ? Number(normalized) : Number.NaN;
228
+ if (Number.isFinite(number)) return number;
229
+ }
230
+ return undefined;
231
+ }
232
+
106
233
  function formatDuration(ms: number): string {
107
234
  const seconds = Math.floor(ms / 1000);
108
235
  const minutes = Math.floor(seconds / 60);
@@ -0,0 +1,100 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ import type { OpenCodeGoUsageSnapshot } from "../types";
5
+
6
+ export interface OpenCodeGoUsageApiResponse {
7
+ usage?: {
8
+ rolling?: UsageApiWindow;
9
+ weekly?: UsageApiWindow;
10
+ monthly?: UsageApiWindow;
11
+ };
12
+ }
13
+
14
+ interface UsageApiWindow {
15
+ status?: string;
16
+ percent?: number;
17
+ resetsAt?: string;
18
+ }
19
+
20
+ export const OPENCODE_API_KEY_ENV = "OPENCODE_API_KEY";
21
+
22
+ export class OpenCodeGoApiKeyUsageClient {
23
+ constructor(
24
+ private readonly usageUrl: string = "https://opencode.ai/zen/go/v1/usage",
25
+ private readonly authJsonPath: string = join(homedir(), ".local", "share", "opencode", "auth.json"),
26
+ private readonly env: Record<string, string | undefined> = process.env,
27
+ ) {}
28
+
29
+ async resolveApiKey(): Promise<string | null> {
30
+ const fromEnv = this.env[OPENCODE_API_KEY_ENV]?.trim();
31
+ if (fromEnv) return fromEnv;
32
+ try {
33
+ const auth = (JSON.parse(await Bun.file(this.authJsonPath).text()) as Record<string, unknown>)["opencode-go"];
34
+ if (auth && typeof auth === "object") {
35
+ const apiKey = (auth as Record<string, unknown>)["key"];
36
+ if (typeof apiKey === "string" && apiKey.trim()) return apiKey.trim();
37
+ }
38
+ } catch {
39
+ // Missing or malformed auth.json simply means no API key available.
40
+ }
41
+ return null;
42
+ }
43
+
44
+ async fetch(): Promise<OpenCodeGoUsageSnapshot | null> {
45
+ const apiKey = await this.resolveApiKey();
46
+ if (!apiKey) return null;
47
+
48
+ let response: Response;
49
+ try {
50
+ response = await fetch(this.usageUrl, {
51
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json", "User-Agent": "scrylog" },
52
+ redirect: "manual",
53
+ });
54
+ } catch {
55
+ return null;
56
+ }
57
+ if (!response.ok) return null;
58
+
59
+ let body: OpenCodeGoUsageApiResponse;
60
+ try {
61
+ body = (await response.json()) as OpenCodeGoUsageApiResponse;
62
+ } catch {
63
+ return null;
64
+ }
65
+ const usage = body.usage;
66
+ if (!usage || typeof usage !== "object") return null;
67
+
68
+ const now = new Date();
69
+ const rolling = parseUsageWindow(usage.rolling, now);
70
+ const weekly = parseUsageWindow(usage.weekly, now);
71
+ if (!rolling || !weekly) return null;
72
+ const monthly = parseUsageWindow(usage.monthly, now);
73
+
74
+ return {
75
+ source: "api",
76
+ hasMonthlyUsage: monthly !== null,
77
+ rollingUsagePercent: rolling.percent,
78
+ weeklyUsagePercent: weekly.percent,
79
+ monthlyUsagePercent: monthly?.percent ?? 0,
80
+ rollingResetInSec: rolling.resetInSec,
81
+ weeklyResetInSec: weekly.resetInSec,
82
+ monthlyResetInSec: monthly?.resetInSec ?? 0,
83
+ updatedAt: now,
84
+ workspaceId: null,
85
+ workspaceName: null,
86
+ };
87
+ }
88
+ }
89
+
90
+ function parseUsageWindow(raw: UsageApiWindow | undefined, now: Date): { percent: number; resetInSec: number } | null {
91
+ if (!raw || typeof raw !== "object" || raw.status === "exceeded") return null;
92
+ if (typeof raw.percent !== "number" || !Number.isFinite(raw.percent)) return null;
93
+ const percent = Math.max(0, Math.min(100, raw.percent));
94
+ let resetInSec = 0;
95
+ if (typeof raw.resetsAt === "string") {
96
+ const at = Date.parse(raw.resetsAt);
97
+ if (!Number.isNaN(at)) resetInSec = Math.max(0, Math.round((at - now.getTime()) / 1000));
98
+ }
99
+ return { percent, resetInSec };
100
+ }