@scrylog/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +836 -0
  3. package/package.json +75 -0
  4. package/src/api/docs/sessions.keys.ts +6 -0
  5. package/src/api/docs/sessions.ts +81 -0
  6. package/src/api/docs/system.keys.ts +5 -0
  7. package/src/api/docs/system.ts +66 -0
  8. package/src/api/docs/usage.keys.ts +3 -0
  9. package/src/api/docs/usage.ts +24 -0
  10. package/src/api/docs/webhooks.keys.ts +5 -0
  11. package/src/api/docs/webhooks.ts +78 -0
  12. package/src/api/index.ts +18 -0
  13. package/src/api/lib/auth.ts +43 -0
  14. package/src/api/lib/errors.ts +28 -0
  15. package/src/api/lib/route.ts +18 -0
  16. package/src/api/models/claude.ts +4 -0
  17. package/src/api/models/codex.ts +4 -0
  18. package/src/api/models/opencode.ts +11 -0
  19. package/src/api/routes/sessions.ts +47 -0
  20. package/src/api/routes/system.ts +103 -0
  21. package/src/api/routes/usage.ts +15 -0
  22. package/src/api/routes/webhooks/claude.ts +161 -0
  23. package/src/api/routes/webhooks/codex.ts +175 -0
  24. package/src/api/routes/webhooks/opencode.ts +107 -0
  25. package/src/api/routes.ts +93 -0
  26. package/src/api/schemas/common.ts +21 -0
  27. package/src/api/schemas/sessions.ts +72 -0
  28. package/src/api/schemas/usage.ts +50 -0
  29. package/src/api/schemas/webhooks/claude.ts +32 -0
  30. package/src/api/schemas/webhooks/codex.ts +34 -0
  31. package/src/api/schemas/webhooks/opencode.ts +29 -0
  32. package/src/api/services/claude-session.ts +9 -0
  33. package/src/api/services/codex-session.ts +8 -0
  34. package/src/api/services/opencode-session.ts +73 -0
  35. package/src/classifier.ts +206 -0
  36. package/src/cli/commands.ts +498 -0
  37. package/src/cli/prompt.ts +44 -0
  38. package/src/daemon/cli.ts +27 -0
  39. package/src/daemon/collectors/claude/README.md +108 -0
  40. package/src/daemon/collectors/claude/collector.ts +413 -0
  41. package/src/daemon/collectors/claude/discovery.ts +148 -0
  42. package/src/daemon/collectors/claude/hooks.ts +181 -0
  43. package/src/daemon/collectors/claude/index.ts +8 -0
  44. package/src/daemon/collectors/claude/jsonl.ts +85 -0
  45. package/src/daemon/collectors/claude/transcript.ts +246 -0
  46. package/src/daemon/collectors/codex/README.md +147 -0
  47. package/src/daemon/collectors/codex/collector.ts +661 -0
  48. package/src/daemon/collectors/codex/discovery.ts +104 -0
  49. package/src/daemon/collectors/codex/hooks.ts +179 -0
  50. package/src/daemon/collectors/codex/index.ts +7 -0
  51. package/src/daemon/collectors/codex/jsonl.ts +108 -0
  52. package/src/daemon/collectors/codex/transcript.ts +299 -0
  53. package/src/daemon/collectors/git.ts +295 -0
  54. package/src/daemon/collectors/opencode/README.md +106 -0
  55. package/src/daemon/collectors/opencode/collector.ts +543 -0
  56. package/src/daemon/collectors/opencode/discovery.ts +257 -0
  57. package/src/daemon/collectors/opencode/index.ts +5 -0
  58. package/src/daemon/collectors/opencode/session-builder.ts +59 -0
  59. package/src/daemon/collectors/opencode-db.ts +282 -0
  60. package/src/daemon/collectors/process.ts +201 -0
  61. package/src/daemon/collectors/tmux/README.md +128 -0
  62. package/src/daemon/collectors/tmux/collector.ts +472 -0
  63. package/src/daemon/collectors/tmux/enrichment.ts +241 -0
  64. package/src/daemon/collectors/tmux/index.ts +4 -0
  65. package/src/daemon/collectors/tmux/parse.ts +128 -0
  66. package/src/daemon/collectors/usage.ts +193 -0
  67. package/src/daemon/index.ts +207 -0
  68. package/src/daemon/scheduler.ts +86 -0
  69. package/src/daemon/store.ts +535 -0
  70. package/src/detect.ts +143 -0
  71. package/src/hooks/claude.ts +596 -0
  72. package/src/hooks/codex.ts +517 -0
  73. package/src/legacy/classifier.ts +215 -0
  74. package/src/legacy/detect.ts +143 -0
  75. package/src/mcp/client-server.ts +21 -0
  76. package/src/mcp/http-handler.ts +126 -0
  77. package/src/mcp/index.ts +61 -0
  78. package/src/mcp/resources/config-current.ts +19 -0
  79. package/src/mcp/resources/index.ts +11 -0
  80. package/src/mcp/resources/sessions-active.ts +19 -0
  81. package/src/mcp/resources/sessions-all.ts +19 -0
  82. package/src/mcp/schemas/sessions.ts +3 -0
  83. package/src/mcp/server.ts +17 -0
  84. package/src/mcp/services/api-adapter.ts +48 -0
  85. package/src/mcp/services/data-source.ts +22 -0
  86. package/src/mcp/services/store-adapter.ts +40 -0
  87. package/src/mcp/tools/get-active-sessions.ts +16 -0
  88. package/src/mcp/tools/get-session-children.ts +24 -0
  89. package/src/mcp/tools/get-session-messages.ts +24 -0
  90. package/src/mcp/tools/get-session.ts +23 -0
  91. package/src/mcp/tools/index.ts +15 -0
  92. package/src/mcp/tools/list-sessions.ts +16 -0
  93. package/src/neo-blessed.d.ts +4 -0
  94. package/src/oc/index.ts +302 -0
  95. package/src/service/manager.ts +242 -0
  96. package/src/shared/config.ts +478 -0
  97. package/src/shared/ipc.ts +80 -0
  98. package/src/shared/logger.ts +248 -0
  99. package/src/shared/paths.ts +73 -0
  100. package/src/shared/platform.ts +110 -0
  101. package/src/shared/session-view.ts +241 -0
  102. package/src/shared/types.ts +299 -0
  103. package/src/shared/utils.ts +339 -0
  104. package/src/tui/app.ts +920 -0
  105. package/src/tui/client.ts +149 -0
  106. package/src/tui/index.ts +181 -0
  107. package/src/types.ts +113 -0
  108. package/src/usage/README.md +323 -0
  109. package/src/usage/api.ts +99 -0
  110. package/src/usage/client.ts +3 -0
  111. package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
  112. package/src/usage/clients/anthropic-web-usage.ts +245 -0
  113. package/src/usage/clients/claude-cli-probe.ts +187 -0
  114. package/src/usage/clients/codex-oauth-usage.ts +113 -0
  115. package/src/usage/clients/opencode-go-usage.ts +441 -0
  116. package/src/usage/index.ts +19 -0
  117. package/src/usage/mappers.ts +273 -0
  118. package/src/usage/planner.ts +54 -0
  119. package/src/usage/providers/claude.ts +113 -0
  120. package/src/usage/providers/codex.ts +34 -0
  121. package/src/usage/providers/opencode-go.ts +37 -0
  122. package/src/usage/resolver.ts +4 -0
  123. package/src/usage/resolvers/browser-cookies.ts +198 -0
  124. package/src/usage/resolvers/claude-auth.ts +99 -0
  125. package/src/usage/resolvers/claude-cli.ts +24 -0
  126. package/src/usage/resolvers/claude-web.ts +16 -0
  127. package/src/usage/resolvers/codex-auth.ts +117 -0
  128. package/src/usage/resolvers/opencode-go-web.ts +14 -0
  129. package/src/usage/types.ts +190 -0
@@ -0,0 +1,245 @@
1
+ import type { UsageClient } from "../client";
2
+ import type { ClaudeUsageSnapshot, NamedRateWindowLegacy, ProviderCostSnapshotLegacy, RateWindowLegacy, ClaudeWebSession } from "../types";
3
+
4
+ export class InvalidCredentialsError extends Error {
5
+ constructor(message: string) {
6
+ super(message);
7
+ this.name = "InvalidCredentialsError";
8
+ }
9
+ }
10
+
11
+ interface Org {
12
+ uuid: string;
13
+ name?: string;
14
+ capabilities?: string[];
15
+ }
16
+
17
+ interface UsageResponse {
18
+ five_hour?: { utilization?: number | null; resets_at?: string | null } | null;
19
+ seven_day?: { utilization?: number | null; resets_at?: string | null } | null;
20
+ seven_day_sonnet?: { utilization?: number | null; resets_at?: string | null } | null;
21
+ seven_day_opus?: { utilization?: number | null; resets_at?: string | null } | null;
22
+ seven_day_design?: { utilization?: number | null; resets_at?: string | null } | null;
23
+ seven_day_routines?: { utilization?: number | null; resets_at?: string | null } | null;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface OverageResponse {
28
+ monthly_credit_limit?: number;
29
+ currency?: string;
30
+ used_credits?: number;
31
+ is_enabled?: boolean;
32
+ }
33
+
34
+ interface AccountResponse {
35
+ email_address?: string;
36
+ memberships?: Array<{
37
+ organization?: {
38
+ uuid: string;
39
+ name?: string;
40
+ rate_limit_tier?: string;
41
+ billing_type?: string;
42
+ };
43
+ }>;
44
+ }
45
+
46
+ const DESIGN_ALTERNATES = [
47
+ "seven_day_design",
48
+ "seven_day_claude_design",
49
+ "claude_design",
50
+ "design",
51
+ "seven_day_omelette",
52
+ "omelette",
53
+ "omelette_promotional",
54
+ ];
55
+
56
+ const ROUTINES_ALTERNATES = [
57
+ "seven_day_routines",
58
+ "seven_day_claude_routines",
59
+ "claude_routines",
60
+ "routines",
61
+ "routine",
62
+ "seven_day_cowork",
63
+ "cowork",
64
+ ];
65
+
66
+ function pickWindow(data: UsageResponse, keys: string[]): { utilization?: number | null; resets_at?: string | null } | null {
67
+ for (const key of keys) {
68
+ const val = data[key];
69
+ if (val && typeof val === "object" && "utilization" in val) {
70
+ return val as { utilization?: number | null; resets_at?: string | null };
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function toRateWindow(
77
+ utilization: number | null | undefined,
78
+ resetsAt: string | null | undefined,
79
+ windowMinutes: number,
80
+ ): RateWindowLegacy {
81
+ const usedPercent = typeof utilization === "number" ? Math.max(0, Math.min(100, utilization)) : 0;
82
+ const resetsAtDate = resetsAt ? new Date(resetsAt) : null;
83
+ const resetDescription = resetsAtDate && !Number.isNaN(resetsAtDate.getTime())
84
+ ? resetsAtDate.toLocaleString()
85
+ : null;
86
+ return {
87
+ usedPercent,
88
+ windowMinutes,
89
+ resetsAt: resetsAtDate && !Number.isNaN(resetsAtDate.getTime()) ? resetsAtDate : null,
90
+ resetDescription,
91
+ };
92
+ }
93
+
94
+ export class AnthropicWebUsageClient implements UsageClient<ClaudeWebSession, ClaudeUsageSnapshot> {
95
+ async fetch(creds: ClaudeWebSession, options?: { signal?: AbortSignal }): Promise<ClaudeUsageSnapshot> {
96
+ // 1. Fetch organizations
97
+ const orgRes = await fetch("https://claude.ai/api/organizations", {
98
+ signal: options?.signal,
99
+ headers: {
100
+ Cookie: creds.cookieHeader,
101
+ Accept: "application/json",
102
+ },
103
+ });
104
+
105
+ if (orgRes.status === 401 || orgRes.status === 403) {
106
+ throw new InvalidCredentialsError("Session cookie invalid or expired");
107
+ }
108
+ if (!orgRes.ok) {
109
+ throw new Error(`Organizations fetch failed: ${orgRes.status}`);
110
+ }
111
+
112
+ const orgs = (await orgRes.json()) as Org[];
113
+ if (!Array.isArray(orgs) || orgs.length === 0) {
114
+ throw new Error("No organizations found");
115
+ }
116
+
117
+ // Select org
118
+ let org = orgs.find((o) => o.capabilities?.includes("chat"));
119
+ if (!org) {
120
+ org = orgs.find((o) => JSON.stringify(o.capabilities) !== '["api"]');
121
+ }
122
+ if (!org) {
123
+ org = orgs[0];
124
+ }
125
+
126
+ // 2. Fetch usage
127
+ const usageRes = await fetch(`https://claude.ai/api/organizations/${org.uuid}/usage`, {
128
+ signal: options?.signal,
129
+ headers: {
130
+ Cookie: creds.cookieHeader,
131
+ Accept: "application/json",
132
+ },
133
+ });
134
+
135
+ if (usageRes.status === 401 || usageRes.status === 403) {
136
+ throw new InvalidCredentialsError("Session cookie invalid or expired");
137
+ }
138
+ if (!usageRes.ok) {
139
+ throw new Error(`Usage fetch failed: ${usageRes.status}`);
140
+ }
141
+
142
+ const data = (await usageRes.json()) as UsageResponse;
143
+
144
+ if (!data.five_hour || typeof data.five_hour.utilization !== "number") {
145
+ throw new Error("Missing five_hour.utilization");
146
+ }
147
+
148
+ const primary = toRateWindow(data.five_hour.utilization, data.five_hour.resets_at, 300);
149
+ const secondary = data.seven_day
150
+ ? toRateWindow(data.seven_day.utilization, data.seven_day.resets_at, 10080)
151
+ : null;
152
+
153
+ const opusWindow = pickWindow(data, ["seven_day_sonnet", "seven_day_opus"]);
154
+ const opus = opusWindow ? toRateWindow(opusWindow.utilization, opusWindow.resets_at, 10080) : null;
155
+
156
+ const extraRateWindows: NamedRateWindowLegacy[] = [];
157
+ const designWindow = pickWindow(data, DESIGN_ALTERNATES);
158
+ if (designWindow && typeof designWindow.utilization === "number") {
159
+ extraRateWindows.push({
160
+ id: "claude-design",
161
+ title: "Designs",
162
+ window: toRateWindow(designWindow.utilization, designWindow.resets_at, 10080),
163
+ });
164
+ }
165
+ const routinesWindow = pickWindow(data, ROUTINES_ALTERNATES);
166
+ if (routinesWindow && typeof routinesWindow.utilization === "number") {
167
+ extraRateWindows.push({
168
+ id: "claude-routines",
169
+ title: "Daily Routines",
170
+ window: toRateWindow(routinesWindow.utilization, routinesWindow.resets_at, 10080),
171
+ });
172
+ }
173
+
174
+ // 3. Best-effort overage
175
+ let providerCost: ProviderCostSnapshotLegacy | null = null;
176
+ try {
177
+ const overageRes = await fetch(`https://claude.ai/api/organizations/${org.uuid}/overage_spend_limit`, {
178
+ signal: options?.signal,
179
+ headers: {
180
+ Cookie: creds.cookieHeader,
181
+ Accept: "application/json",
182
+ },
183
+ });
184
+ if (overageRes.ok) {
185
+ const overage = (await overageRes.json()) as OverageResponse;
186
+ if (overage.is_enabled) {
187
+ providerCost = {
188
+ used: (overage.used_credits ?? 0) / 100,
189
+ limit: (overage.monthly_credit_limit ?? 0) / 100,
190
+ currencyCode: (overage.currency || "USD").trim() || "USD",
191
+ period: "Monthly",
192
+ resetsAt: null,
193
+ updatedAt: new Date(),
194
+ };
195
+ }
196
+ }
197
+ } catch {
198
+ // Ignore best-effort failure
199
+ }
200
+
201
+ // 4. Best-effort account info
202
+ let accountEmail: string | null = null;
203
+ let accountOrganization: string | null = null;
204
+ let loginMethod: string | null = null;
205
+ try {
206
+ const accountRes = await fetch("https://claude.ai/api/account", {
207
+ signal: options?.signal,
208
+ headers: {
209
+ Cookie: creds.cookieHeader,
210
+ Accept: "application/json",
211
+ },
212
+ });
213
+ if (accountRes.ok) {
214
+ const account = (await accountRes.json()) as AccountResponse;
215
+ if (account.email_address) {
216
+ accountEmail = account.email_address.trim();
217
+ }
218
+ const membership = account.memberships?.find((m) => m.organization?.uuid === org.uuid);
219
+ if (membership?.organization?.name) {
220
+ accountOrganization = membership.organization.name;
221
+ if (membership.organization.rate_limit_tier) {
222
+ loginMethod = membership.organization.rate_limit_tier;
223
+ }
224
+ }
225
+ }
226
+ } catch {
227
+ // Ignore best-effort failure
228
+ }
229
+
230
+ return {
231
+ source: "web",
232
+ primary,
233
+ secondary,
234
+ opus,
235
+ extraRateWindows,
236
+ providerCost,
237
+ updatedAt: new Date(),
238
+ accountEmail,
239
+ accountOrganization,
240
+ loginMethod,
241
+ subscriptionType: null,
242
+ rateLimitTier: null,
243
+ };
244
+ }
245
+ }
@@ -0,0 +1,187 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { UsageClient } from "../client";
3
+ import type { ClaudeUsageSnapshot, RateWindowLegacy } from "../types";
4
+
5
+ export class TimeoutError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = "TimeoutError";
9
+ }
10
+ }
11
+
12
+ function parseResetDate(text: string): Date | null {
13
+ // Patterns:
14
+ // "Resets at 8:00PM PDT"
15
+ // "Resets at 8:00 PM PDT"
16
+ // "Resets at Apr 28 at 8:00PM PDT"
17
+ // "Resets at Apr 28 at 8:00 PM PDT"
18
+ const match = text.match(/Resets at (.+?)(?:\s*\(([^)]+)\))?$/i);
19
+ if (!match) return null;
20
+ const datePart = match[1]!.trim();
21
+ const tz = match[2] || undefined;
22
+ try {
23
+ const d = new Date(datePart);
24
+ if (!Number.isNaN(d.getTime())) return d;
25
+ // Fallback: try appending current year if no year present
26
+ const withYear = `${datePart} ${new Date().getFullYear()}`;
27
+ const d2 = new Date(withYear);
28
+ if (!Number.isNaN(d2.getTime())) return d2;
29
+ } catch {
30
+ // ignore
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function parsePercent(line: string): number | null {
36
+ const match = line.match(/(\d{1,3}(?:\.\d+)?)\s*%/);
37
+ if (!match) return null;
38
+ const val = Number.parseFloat(match[1]!);
39
+ return Number.isNaN(val) ? null : val;
40
+ }
41
+
42
+ function toRateWindow(usedPercent: number, resetText: string | null, windowMinutes: number): RateWindowLegacy {
43
+ const resetsAt = resetText ? parseResetDate(resetText) : null;
44
+ return {
45
+ usedPercent: Math.max(0, Math.min(100, usedPercent)),
46
+ windowMinutes,
47
+ resetsAt,
48
+ resetDescription: resetText,
49
+ };
50
+ }
51
+
52
+ function probeCli(binaryPath: string, timeoutMs: number): Promise<string> {
53
+ return new Promise((resolve, reject) => {
54
+ let output = "";
55
+ let killed = false;
56
+
57
+ // Use `script` for PTY on macOS; fallback to direct spawn
58
+ const useScript = process.platform === "darwin";
59
+ const child = useScript
60
+ ? spawn("script", ["-q", "/dev/null", binaryPath], {
61
+ env: process.env,
62
+ stdio: ["pipe", "pipe", "pipe"],
63
+ })
64
+ : spawn(binaryPath, [], {
65
+ env: process.env,
66
+ stdio: ["pipe", "pipe", "pipe"],
67
+ });
68
+
69
+ const timer = setTimeout(() => {
70
+ killed = true;
71
+ child.kill("SIGTERM");
72
+ reject(new TimeoutError(`CLI probe timed out after ${timeoutMs}ms`));
73
+ }, timeoutMs);
74
+
75
+ child.stdout.on("data", (data: Buffer) => {
76
+ output += data.toString("utf8");
77
+ });
78
+
79
+ child.stderr.on("data", (data: Buffer) => {
80
+ output += data.toString("utf8");
81
+ });
82
+
83
+ child.on("error", (err) => {
84
+ clearTimeout(timer);
85
+ if (!killed) reject(err);
86
+ });
87
+
88
+ child.on("close", () => {
89
+ clearTimeout(timer);
90
+ if (!killed) resolve(output);
91
+ });
92
+
93
+ // Send commands
94
+ child.stdin.write("/usage\n");
95
+ setTimeout(() => {
96
+ child.stdin.write("/status\n");
97
+ setTimeout(() => {
98
+ child.stdin.end();
99
+ }, 500);
100
+ }, 500);
101
+ });
102
+ }
103
+
104
+ export class ClaudeCliProbeClient implements UsageClient<string, ClaudeUsageSnapshot> {
105
+ async fetch(binaryPath: string): Promise<ClaudeUsageSnapshot> {
106
+ let output: string;
107
+ try {
108
+ output = await probeCli(binaryPath, 10_000);
109
+ } catch (err) {
110
+ if (err instanceof TimeoutError) {
111
+ // Retry with longer timeout
112
+ output = await probeCli(binaryPath, 24_000);
113
+ } else {
114
+ throw err;
115
+ }
116
+ }
117
+
118
+ const lines = output.split(/\r?\n/);
119
+
120
+ let primary: RateWindowLegacy | null = null;
121
+ let secondary: RateWindowLegacy | null = null;
122
+ let opus: RateWindowLegacy | null = null;
123
+ let accountEmail: string | null = null;
124
+ let accountOrganization: string | null = null;
125
+ let loginMethod: string | null = null;
126
+
127
+ for (let i = 0; i < lines.length; i++) {
128
+ const line = lines[i]!;
129
+ const lower = line.toLowerCase();
130
+
131
+ if (lower.includes("account_email")) {
132
+ const m = line.match(/account_email[:=]\s*(.+)/i);
133
+ if (m) accountEmail = m[1]!.trim();
134
+ }
135
+ if (lower.includes("account_org")) {
136
+ const m = line.match(/account_org[:=]\s*(.+)/i);
137
+ if (m) accountOrganization = m[1]!.trim();
138
+ }
139
+ if (lower.includes("login_method")) {
140
+ const m = line.match(/login_method[:=]\s*(.+)/i);
141
+ if (m) loginMethod = m[1]!.trim();
142
+ }
143
+
144
+ if (lower.includes("current session")) {
145
+ const percent = parsePercent(line);
146
+ if (percent !== null) {
147
+ const used = Math.max(0, Math.min(100, 100 - percent));
148
+ const resetLine = lines[i + 1] || "";
149
+ primary = toRateWindow(used, resetLine.includes("Resets") ? resetLine.trim() : null, 300);
150
+ }
151
+ }
152
+
153
+ if (lower.includes("current week")) {
154
+ const percent = parsePercent(line);
155
+ if (percent !== null) {
156
+ const used = Math.max(0, Math.min(100, 100 - percent));
157
+ const resetLine = lines[i + 1] || "";
158
+ const resetText = resetLine.includes("Resets") ? resetLine.trim() : null;
159
+ if (lower.includes("(all models)") || lower.includes("all models")) {
160
+ secondary = toRateWindow(used, resetText, 10080);
161
+ } else if (lower.includes("opus") || lower.includes("sonnet only")) {
162
+ opus = toRateWindow(used, resetText, 10080);
163
+ }
164
+ }
165
+ }
166
+ }
167
+
168
+ if (!primary) {
169
+ throw new Error("Could not parse primary usage window from CLI output");
170
+ }
171
+
172
+ return {
173
+ source: "cli",
174
+ primary,
175
+ secondary,
176
+ opus,
177
+ extraRateWindows: [],
178
+ providerCost: null,
179
+ updatedAt: new Date(),
180
+ accountEmail,
181
+ accountOrganization,
182
+ loginMethod,
183
+ subscriptionType: null,
184
+ rateLimitTier: null,
185
+ };
186
+ }
187
+ }
@@ -0,0 +1,113 @@
1
+ import type { UsageClient } from "../client";
2
+ import type { CodexOAuthCredentials, CodexUsageSnapshot, RateWindowLegacy } from "../types";
3
+ import { AuthenticationError, ScopeError } from "./anthropic-oauth-usage";
4
+
5
+ export { AuthenticationError, ScopeError };
6
+
7
+ export class ParseError extends Error {
8
+ constructor(message: string) {
9
+ super(message);
10
+ this.name = "ParseError";
11
+ }
12
+ }
13
+
14
+ export class CodexOAuthUsageClient implements UsageClient<CodexOAuthCredentials, CodexUsageSnapshot> {
15
+ 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`;
18
+
19
+ const headers: Record<string, string> = {
20
+ Authorization: `Bearer ${creds.accessToken}`,
21
+ "Content-Type": "application/json",
22
+ };
23
+ if (creds.accountId) {
24
+ headers["ChatGPT-Account-Id"] = creds.accountId;
25
+ }
26
+
27
+ const res = await fetch(url, {
28
+ method: "GET",
29
+ headers,
30
+ signal: options?.signal,
31
+ });
32
+
33
+ if (res.status === 401) {
34
+ throw new AuthenticationError("Codex OAuth token expired or invalid");
35
+ }
36
+ if (res.status === 403) {
37
+ const body = await res.text();
38
+ if (/scope|permission|access denied/i.test(body)) {
39
+ throw new ScopeError("Codex OAuth access forbidden: missing scope");
40
+ }
41
+ throw new ScopeError("Codex OAuth access forbidden");
42
+ }
43
+ if (!res.ok) {
44
+ throw new Error(`Codex usage server error: ${res.status}`);
45
+ }
46
+
47
+ const body = (await res.json()) as Record<string, unknown>;
48
+
49
+ const planType = typeof body["plan_type"] === "string" ? body["plan_type"] : null;
50
+
51
+ // Handle both rate_limit and rate_limits key names
52
+ const rateLimitKey = typeof body["rate_limit"] === "object" && body["rate_limit"] !== null
53
+ ? "rate_limit"
54
+ : typeof body["rate_limits"] === "object" && body["rate_limits"] !== null
55
+ ? "rate_limits"
56
+ : null;
57
+ const rateLimit = rateLimitKey ? (body[rateLimitKey] as Record<string, unknown> | null) : null;
58
+
59
+ if (!rateLimit) {
60
+ throw new ParseError("Codex usage response missing rate_limit or rate_limits");
61
+ }
62
+
63
+ const primaryWindow = parseWindow(rateLimit?.["primary_window"] as Record<string, unknown> | undefined);
64
+ const secondaryWindow = parseWindow(rateLimit?.["secondary_window"] as Record<string, unknown> | undefined);
65
+
66
+ let credits: CodexUsageSnapshot["credits"] = null;
67
+ if (typeof body["credits"] === "object" && body["credits"] !== null) {
68
+ const c = body["credits"] as Record<string, unknown>;
69
+ credits = {
70
+ hasCredits: c["has_credits"] === true,
71
+ unlimited: c["unlimited"] === true,
72
+ balance: typeof c["balance"] === "number" ? c["balance"] : null,
73
+ };
74
+ }
75
+
76
+ return {
77
+ source: "oauth",
78
+ planType,
79
+ primaryWindow,
80
+ secondaryWindow,
81
+ credits,
82
+ updatedAt: new Date(),
83
+ accountEmail: creds.accountEmail ?? null,
84
+ accountId: creds.accountId ?? null,
85
+ };
86
+ }
87
+ }
88
+
89
+ function parseWindow(raw: Record<string, unknown> | undefined): RateWindowLegacy | null {
90
+ if (!raw) return null;
91
+ const usedPercent = typeof raw["used_percent"] === "number" ? raw["used_percent"] : 0;
92
+ const limitWindowSeconds = typeof raw["limit_window_seconds"] === "number" ? raw["limit_window_seconds"] : undefined;
93
+ const resetAtRaw = typeof raw["reset_at"] === "number" ? raw["reset_at"] : typeof raw["resets_at"] === "number" ? raw["resets_at"] : undefined;
94
+ const resetsAt = resetAtRaw ? new Date(resetAtRaw * 1000) : null;
95
+ const windowMinutes = limitWindowSeconds ? Math.round(limitWindowSeconds / 60) : 300;
96
+ const resetDescription = resetsAt ? formatDuration(Math.max(0, resetsAt.getTime() - Date.now())) : null;
97
+
98
+ return {
99
+ usedPercent,
100
+ windowMinutes,
101
+ resetsAt,
102
+ resetDescription,
103
+ };
104
+ }
105
+
106
+ function formatDuration(ms: number): string {
107
+ const seconds = Math.floor(ms / 1000);
108
+ const minutes = Math.floor(seconds / 60);
109
+ const hours = Math.floor(minutes / 60);
110
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
111
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
112
+ return `${seconds}s`;
113
+ }