@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.
- package/README.md +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +9 -21
- package/src/api/routes/webhooks/opencode.ts +6 -1
- package/src/api/routes.ts +1 -1
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +170 -26
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +11 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode/collector.ts +4 -2
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +109 -14
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/index.ts +2 -1
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +679 -69
- package/src/usage/resolvers/claude-auth.ts +150 -41
- package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- 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 {
|
|
3
|
+
import { UsageError, classifyUsageError } from "../errors";
|
|
4
4
|
|
|
5
|
-
export
|
|
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
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
64
|
+
throw new UsageError("transient", `Codex usage server error: ${res.status}`);
|
|
45
65
|
}
|
|
46
66
|
|
|
47
|
-
|
|
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 =
|
|
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:
|
|
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
|
+
}
|