@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,198 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { createDecipheriv, pbkdf2Sync } from "node:crypto";
5
+ import { join } from "node:path";
6
+ import { Database } from "bun:sqlite";
7
+ import { IS_MAC, IS_WINDOWS } from "../../shared/platform";
8
+
9
+ export interface BrowserCookie {
10
+ name: string;
11
+ value: string;
12
+ }
13
+
14
+ const CHROME_COOKIE_PATHS = ((): string[] => {
15
+ const home = homedir();
16
+ if (IS_MAC) {
17
+ return [
18
+ join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Cookies"),
19
+ join(home, "Library", "Application Support", "Google", "Chrome", "Profile 1", "Cookies"),
20
+ ];
21
+ }
22
+ if (IS_WINDOWS) {
23
+ return [
24
+ join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Cookies"),
25
+ ];
26
+ }
27
+ // Linux / WSL
28
+ return [
29
+ join(home, ".config", "google-chrome", "Default", "Cookies"),
30
+ join(home, ".config", "chromium", "Default", "Cookies"),
31
+ ];
32
+ })();
33
+
34
+ const SAFARI_COOKIE_PATH = join(homedir(), "Library", "Cookies", "Cookies.binarycookies");
35
+
36
+ function getChromeSafeStorageKey(): Buffer | null {
37
+ try {
38
+ if (IS_MAC) {
39
+ const password = execSync('security find-generic-password -s "Chrome Safe Storage" -w', {
40
+ encoding: "utf8",
41
+ stdio: ["pipe", "pipe", "pipe"],
42
+ }).trim();
43
+ if (!password) return null;
44
+ return pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1");
45
+ }
46
+
47
+ if (IS_WINDOWS) {
48
+ return null;
49
+ }
50
+ return pbkdf2Sync("peanuts", "saltysalt", 1003, 16, "sha1");
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function decryptChromeCookie(encrypted: Buffer, key: Buffer, dbVersion = 0): string | null {
57
+ if (encrypted.length < 3) return null;
58
+ // v10 or v11 prefix
59
+ const prefix = encrypted.slice(0, 3).toString("ascii");
60
+ if (prefix !== "v10" && prefix !== "v11") {
61
+ // Possibly plain text (older Chrome versions)
62
+ return encrypted.toString("utf8");
63
+ }
64
+ const iv = Buffer.alloc(16, 32); // space character (0x20) repeated 16 times
65
+ try {
66
+ const decipher = createDecipheriv("aes-128-cbc", key, iv);
67
+ let decrypted = Buffer.concat([decipher.update(encrypted.slice(3)), decipher.final()]);
68
+ // Remove PKCS#7 padding
69
+ const padLen = decrypted[decrypted.length - 1];
70
+ if (padLen > 0 && padLen <= 16) {
71
+ decrypted = decrypted.slice(0, decrypted.length - padLen);
72
+ }
73
+ // Chrome v24+ prepends a SHA256 hash of the domain (32 bytes) to encrypted values
74
+ if (dbVersion >= 24 && decrypted.length >= 32) {
75
+ decrypted = decrypted.slice(32);
76
+ }
77
+ return decrypted.toString("utf8");
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ function readChromeCookies(domain: string): BrowserCookie[] {
84
+ const cookies: BrowserCookie[] = [];
85
+ const key = getChromeSafeStorageKey();
86
+ if (!key) return cookies;
87
+
88
+ for (const dbPath of CHROME_COOKIE_PATHS) {
89
+ if (!existsSync(dbPath)) continue;
90
+ let db: Database | undefined;
91
+ try {
92
+ db = new Database(dbPath, { readonly: true });
93
+
94
+ // Chrome v24+ prepends a SHA256 hash (32 bytes) to encrypted cookie values
95
+ let dbVersion = 0;
96
+ try {
97
+ const versionStmt = db.query("SELECT value FROM meta WHERE key = 'version'");
98
+ const versionRow = versionStmt.get() as { value: number } | null;
99
+ if (versionRow) dbVersion = Number(versionRow.value);
100
+ } catch {
101
+ // meta table may not exist in older DBs
102
+ }
103
+
104
+ const stmt = db.query(
105
+ "SELECT name, encrypted_value, host_key FROM cookies WHERE host_key LIKE ? AND expires_utc > ?"
106
+ );
107
+ const now = (Date.now() + 11644473600000) * 1000; // Chrome epoch offset
108
+ const rows = stmt.all(`%${domain}%`, now) as Array<{
109
+ name: string;
110
+ encrypted_value: Uint8Array;
111
+ host_key: string;
112
+ }>;
113
+ for (const row of rows) {
114
+ const value = decryptChromeCookie(Buffer.from(row.encrypted_value), key, dbVersion);
115
+ if (value !== null) {
116
+ cookies.push({ name: row.name, value });
117
+ }
118
+ }
119
+ } catch {
120
+ // Ignore permission errors or locked DB
121
+ } finally {
122
+ db?.close();
123
+ }
124
+ }
125
+
126
+ return cookies;
127
+ }
128
+
129
+ function readSafariCookies(domain: string): BrowserCookie[] {
130
+ const cookies: BrowserCookie[] = [];
131
+ if (process.platform !== "darwin") return cookies;
132
+ if (!existsSync(SAFARI_COOKIE_PATH)) return cookies;
133
+
134
+ try {
135
+ const data = readFileSync(SAFARI_COOKIE_PATH);
136
+ // Simple binarycookies parser
137
+ // Header: 4 bytes magic "cook", 4 bytes page size, then pages
138
+ if (data.length < 8 || data.toString("ascii", 0, 4) !== "cook") return cookies;
139
+
140
+ const numPages = data.readUInt32BE(4);
141
+ let offset = 8;
142
+ const pageSizes: number[] = [];
143
+ for (let i = 0; i < numPages; i++) {
144
+ pageSizes.push(data.readUInt32BE(offset));
145
+ offset += 4;
146
+ }
147
+
148
+ for (const pageSize of pageSizes) {
149
+ const pageEnd = offset + pageSize;
150
+ if (pageEnd > data.length) break;
151
+ // Page header: "page" + 4 bytes count
152
+ if (data.toString("ascii", offset, offset + 4) !== "page") {
153
+ offset += pageSize;
154
+ continue;
155
+ }
156
+ const numCookies = data.readUInt32BE(offset + 4);
157
+ let cookieOffset = offset + 8;
158
+ for (let i = 0; i < numCookies; i++) {
159
+ // Each cookie record: size (4) + unknown (4) + flags (4) + hasPort (4)
160
+ // + port (4) + urlOffset (4) + nameOffset (4) + pathOffset (4) + valueOffset (4)
161
+ // + tail (8) = 40 bytes header, then strings
162
+ const size = data.readUInt32BE(cookieOffset);
163
+ const urlOffset = data.readUInt32BE(cookieOffset + 20);
164
+ const nameOffset = data.readUInt32BE(cookieOffset + 24);
165
+ const pathOffset = data.readUInt32BE(cookieOffset + 28);
166
+ const valueOffset = data.readUInt32BE(cookieOffset + 32);
167
+
168
+ const url = data.toString("utf8", cookieOffset + urlOffset, cookieOffset + nameOffset - 1);
169
+ const name = data.toString("utf8", cookieOffset + nameOffset, cookieOffset + pathOffset - 1);
170
+ const value = data.toString("utf8", cookieOffset + valueOffset, cookieOffset + size);
171
+
172
+ if (url.includes(domain)) {
173
+ cookies.push({ name, value });
174
+ }
175
+
176
+ cookieOffset += size;
177
+ if (cookieOffset > pageEnd) break;
178
+ }
179
+ offset += pageSize;
180
+ }
181
+ } catch {
182
+ // Ignore errors
183
+ }
184
+
185
+ return cookies;
186
+ }
187
+
188
+ export function extractCookies(domain: string): BrowserCookie[] {
189
+ const chrome = readChromeCookies(domain);
190
+ if (chrome.length > 0) return chrome;
191
+ // Safari is only available on macOS.
192
+ if (IS_MAC) return readSafariCookies(domain);
193
+ return [];
194
+ }
195
+
196
+ export function buildCookieHeader(cookies: BrowserCookie[]): string {
197
+ return cookies.map((c) => `${c.name}=${c.value}`).join("; ");
198
+ }
@@ -0,0 +1,99 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { TokenResolver } from "../resolver";
6
+ import type { ClaudeOAuthCredentials } from "../types";
7
+ import { IS_MAC } from "../../shared/platform";
8
+
9
+ export class ClaudeAuthResolver implements TokenResolver<ClaudeOAuthCredentials> {
10
+ private readonly debug = (message: string, meta?: Record<string, unknown>) => {
11
+ // Avoid coupling to logger; stderr debug line is acceptable for resolver diagnostics.
12
+ if (process.env.SCRYLOG_DEBUG_AUTH) {
13
+ process.stderr.write(`[scrylog:claude-auth] ${message}${meta ? ` ${JSON.stringify(meta)}` : ""}\n`);
14
+ }
15
+ };
16
+
17
+ async resolve(): Promise<ClaudeOAuthCredentials | null> {
18
+ // 1. macOS Keychain is the primary store on macOS.
19
+ if (IS_MAC) {
20
+ const services = ["claude-api-oauth", "Claude Code-credentials"];
21
+ for (const service of services) {
22
+ const creds = this.tryService(service);
23
+ if (creds) return creds;
24
+ }
25
+ } else {
26
+ this.debug("Keychain not available on this platform", { platform: process.platform });
27
+ }
28
+
29
+ // 2. Linux/WSL/Windows: Claude Code stores credentials in ~/.claude/.credentials.json
30
+ const fileCreds = this.tryCredentialsFile();
31
+ if (fileCreds) return fileCreds;
32
+
33
+ // 3. Universal env-var fallback
34
+ const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
35
+ if (envToken) {
36
+ return { accessToken: envToken };
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ async isAvailable(): Promise<boolean> {
43
+ return (await this.resolve()) !== null;
44
+ }
45
+
46
+ private tryService(service: string): ClaudeOAuthCredentials | null {
47
+ try {
48
+ const output = execSync(
49
+ `security find-generic-password -s "${service}" -w`,
50
+ { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
51
+ ).trim();
52
+ if (!output) return null;
53
+ return this.parseCredentialsJson(output);
54
+ } catch (error) {
55
+ this.debug("Keychain lookup failed", { service, error: error instanceof Error ? error.message : String(error) });
56
+ return null;
57
+ }
58
+ }
59
+
60
+ private tryCredentialsFile(): ClaudeOAuthCredentials | null {
61
+ // On macOS, Keychain is authoritative; skip the file unless explicitly requested.
62
+ if (IS_MAC && !process.env.CLAUDE_CODE_USE_CREDENTIALS_FILE) return null;
63
+
64
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
65
+ const path = join(claudeDir, ".credentials.json");
66
+ if (!existsSync(path)) return null;
67
+
68
+ try {
69
+ const raw = readFileSync(path, "utf8");
70
+ return this.parseCredentialsJson(raw);
71
+ } catch (error) {
72
+ this.debug("Failed to read credentials file", { path, error: error instanceof Error ? error.message : String(error) });
73
+ return null;
74
+ }
75
+ }
76
+
77
+ private parseCredentialsJson(input: string): ClaudeOAuthCredentials | null {
78
+ try {
79
+ const parsed = JSON.parse(input) as Record<string, unknown>;
80
+ // Handle nested structure: { claudeAiOauth: { accessToken, ... } }
81
+ const inner = parsed.claudeAiOauth && typeof parsed.claudeAiOauth === "object"
82
+ ? parsed.claudeAiOauth as Record<string, unknown>
83
+ : parsed;
84
+ const accessToken = inner.accessToken ?? inner.access_token;
85
+ if (!accessToken) return null;
86
+ return {
87
+ accessToken: String(accessToken),
88
+ refreshToken: inner.refreshToken ? String(inner.refreshToken) : undefined,
89
+ expiresAt: inner.expiresAt ? String(inner.expiresAt) : undefined,
90
+ scopes: Array.isArray(inner.scopes) ? inner.scopes.map(String) : undefined,
91
+ rateLimitTier: inner.rateLimitTier ? String(inner.rateLimitTier) : undefined,
92
+ subscriptionType: inner.subscriptionType ? String(inner.subscriptionType) : undefined,
93
+ };
94
+ } catch {
95
+ // Not JSON — treat the raw string as an access token.
96
+ return { accessToken: input.trim() };
97
+ }
98
+ }
99
+ }
@@ -0,0 +1,24 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { whichCommand } from "../../shared/platform";
4
+ import type { TokenResolver } from "../resolver";
5
+
6
+ export class ClaudeCliResolver implements TokenResolver<string> {
7
+ async resolve(): Promise<string | null> {
8
+ const paths = [
9
+ process.env.CLAUDE_BIN_OVERRIDE,
10
+ `${homedir()}/.claude/local/claude`,
11
+ "/usr/local/bin/claude",
12
+ ];
13
+ for (const p of paths) {
14
+ if (p && existsSync(p)) return p;
15
+ }
16
+ const which = whichCommand("claude");
17
+ if (which) return which;
18
+ return null;
19
+ }
20
+
21
+ async isAvailable(): Promise<boolean> {
22
+ return (await this.resolve()) !== null;
23
+ }
24
+ }
@@ -0,0 +1,16 @@
1
+ import type { TokenResolver } from "../resolver";
2
+ import type { ClaudeWebSession } from "../types";
3
+ import { extractCookies, buildCookieHeader } from "./browser-cookies";
4
+
5
+ export class ClaudeWebResolver implements TokenResolver<ClaudeWebSession> {
6
+ async resolve(): Promise<ClaudeWebSession | null> {
7
+ const cookies = extractCookies("claude.ai");
8
+ const sessionKey = cookies.find((c) => c.name === "sessionKey");
9
+ if (!sessionKey) return null;
10
+ return { cookieHeader: buildCookieHeader(cookies) };
11
+ }
12
+
13
+ async isAvailable(): Promise<boolean> {
14
+ return (await this.resolve()) !== null;
15
+ }
16
+ }
@@ -0,0 +1,117 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { TokenResolver } from "../resolver";
5
+ import type { CodexOAuthCredentials } from "../types";
6
+
7
+ function getCodexDir(): string {
8
+ if (process.env.CODEX_HOME) return process.env.CODEX_HOME;
9
+ return join(homedir(), ".codex");
10
+ }
11
+
12
+ function readConfigToml(codexDir: string): string | undefined {
13
+ const path = join(codexDir, "config.toml");
14
+ if (!existsSync(path)) return undefined;
15
+ try {
16
+ const content = readFileSync(path, "utf8");
17
+ for (const line of content.split(/\r?\n/)) {
18
+ const match = line.match(/^chatgpt_base_url\s*=\s*"(.+)"\s*$/);
19
+ if (match) return match[1];
20
+ }
21
+ } catch {
22
+ // ignore
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ function decodeJwtClaims(token: string): { email?: string; sub?: string; org_id?: string } {
28
+ try {
29
+ const payload = token.split(".")[1];
30
+ if (!payload) return {};
31
+ const json = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as Record<string, unknown>;
32
+ return {
33
+ email: typeof json["email"] === "string" ? json["email"] : undefined,
34
+ sub: typeof json["sub"] === "string" ? json["sub"] : undefined,
35
+ org_id: typeof json["org_id"] === "string" ? json["org_id"] : undefined,
36
+ };
37
+ } catch {
38
+ return {};
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Extract access_token and related fields from Codex auth.json.
44
+ * Codex CLI writes credentials in a nested "tokens" object, e.g.:
45
+ * { "tokens": { "access_token": "...", "refresh_token": "...", "account_id": "..." } }
46
+ * Some versions may write flat keys. This helper supports both formats.
47
+ */
48
+ function extractTokens(raw: Record<string, unknown>): {
49
+ accessToken: string | undefined;
50
+ accountId: string | undefined;
51
+ refreshToken: string | undefined;
52
+ } {
53
+ const tokens =
54
+ typeof raw["tokens"] === "object" && raw["tokens"] !== null
55
+ ? (raw["tokens"] as Record<string, unknown>)
56
+ : undefined;
57
+
58
+ const accessToken =
59
+ typeof raw["access_token"] === "string"
60
+ ? raw["access_token"]
61
+ : typeof tokens?.["access_token"] === "string"
62
+ ? tokens["access_token"]
63
+ : undefined;
64
+
65
+ const accountId =
66
+ typeof tokens?.["account_id"] === "string" ? tokens["account_id"] : undefined;
67
+
68
+ const refreshToken =
69
+ typeof raw["refresh_token"] === "string"
70
+ ? raw["refresh_token"]
71
+ : typeof tokens?.["refresh_token"] === "string"
72
+ ? tokens["refresh_token"]
73
+ : undefined;
74
+
75
+ return { accessToken, accountId, refreshToken };
76
+ }
77
+
78
+ export class CodexAuthResolver implements TokenResolver<CodexOAuthCredentials> {
79
+ async resolve(): Promise<CodexOAuthCredentials | null> {
80
+ const codexDir = getCodexDir();
81
+ const authPath = join(codexDir, "auth.json");
82
+ if (!existsSync(authPath)) return null;
83
+
84
+ try {
85
+ const raw = JSON.parse(readFileSync(authPath, "utf8")) as Record<string, unknown>;
86
+ const tokens = extractTokens(raw);
87
+ if (!tokens.accessToken) return null;
88
+
89
+ const claims = decodeJwtClaims(tokens.accessToken);
90
+ const chatgptBaseUrl = readConfigToml(codexDir);
91
+
92
+ return {
93
+ accessToken: tokens.accessToken,
94
+ refreshToken: tokens.refreshToken,
95
+ expiresAt: typeof raw["expires_at"] === "string" ? raw["expires_at"] : undefined,
96
+ accountEmail: claims.email ?? null,
97
+ accountId: tokens.accountId ?? claims.sub ?? claims.org_id ?? null,
98
+ ...(chatgptBaseUrl ? { chatgptBaseUrl } : {}),
99
+ };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ async isAvailable(): Promise<boolean> {
106
+ const codexDir = getCodexDir();
107
+ const authPath = join(codexDir, "auth.json");
108
+ if (!existsSync(authPath)) return false;
109
+ try {
110
+ const raw = JSON.parse(readFileSync(authPath, "utf8")) as Record<string, unknown>;
111
+ const { accessToken } = extractTokens(raw);
112
+ return typeof accessToken === "string" && accessToken.length > 0;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,14 @@
1
+ import type { TokenResolver } from "../resolver";
2
+ import { extractCookies, buildCookieHeader } from "./browser-cookies";
3
+
4
+ export class OpenCodeGoWebResolver implements TokenResolver<{ cookies: string }> {
5
+ async resolve(): Promise<{ cookies: string } | null> {
6
+ const cookies = extractCookies("opencode.ai");
7
+ if (cookies.length === 0) return null;
8
+ return { cookies: buildCookieHeader(cookies) };
9
+ }
10
+
11
+ async isAvailable(): Promise<boolean> {
12
+ return (await this.resolve()) !== null;
13
+ }
14
+ }
@@ -0,0 +1,190 @@
1
+ // Standardized unified types for usage limits — all providers share the same structure
2
+
3
+ export interface UsageWindow {
4
+ /** Machine-readable identifier, e.g. "session", "weekly", "monthly", "designs" */
5
+ id: string;
6
+ /** Human-readable label, e.g. "Current Session", "Weekly", "Monthly" */
7
+ title: string;
8
+ /** Percentage used (0-100) */
9
+ usedPercent: number;
10
+ /** Absolute usage count (requests, tokens, credits) — optional */
11
+ used?: number;
12
+ /** Absolute limit — optional */
13
+ limit?: number;
14
+ /** Window duration in minutes — optional */
15
+ windowMinutes?: number;
16
+ /** Reset timestamp (ISO 8601) — preferred */
17
+ resetsAt?: string | null;
18
+ /** Seconds until reset — computed if not provided by provider */
19
+ resetInSec?: number;
20
+ }
21
+
22
+ export interface ProviderCostSnapshot {
23
+ /** Amount used */
24
+ used: number;
25
+ /** Limit amount */
26
+ limit: number;
27
+ /** Currency code, e.g. "USD" */
28
+ currency: string;
29
+ /** Billing period label, e.g. "Monthly" */
30
+ period: string;
31
+ /** Reset timestamp (ISO 8601) */
32
+ resetsAt?: string | null;
33
+ /** Last update timestamp (ISO 8601) */
34
+ updatedAt: string;
35
+ }
36
+
37
+ export interface UsageProviderEntry {
38
+ /** Provider identifier, e.g. "claude", "opencode-go" */
39
+ provider: string;
40
+
41
+ /** Account identifier — stable across hosts for grouping. null only when provider is unavailable or identity could not be determined. */
42
+ accountId: string | null;
43
+
44
+ /** Whether this provider is available on this machine (credentials detected) */
45
+ available: boolean;
46
+
47
+ /** Status of this provider */
48
+ status: "ok" | "error" | "unavailable" | "pending";
49
+
50
+ /** Metadata about the data source and account */
51
+ metadata?: {
52
+ /** How the data was obtained, e.g. "oauth", "web", "cli" */
53
+ dataSource?: string;
54
+ /** Account email if known */
55
+ accountEmail?: string | null;
56
+ /** Organization/account name if known */
57
+ accountOrganization?: string | null;
58
+ /** Subscription type, e.g. "pro", "free" */
59
+ subscriptionType?: string | null;
60
+ /** Rate limit tier */
61
+ rateLimitTier?: string | null;
62
+ /** Workspace ID (OpenCode Go) */
63
+ workspaceId?: string | null;
64
+ /** Workspace name (OpenCode Go) */
65
+ workspaceName?: string | null;
66
+ };
67
+
68
+ /** Errors specific to this provider (only present when status === "error") */
69
+ errors?: string[];
70
+
71
+ /** Usage windows — standardized for all providers */
72
+ windows?: UsageWindow[];
73
+
74
+ /** Cost/overage snapshot if available */
75
+ cost?: ProviderCostSnapshot | null;
76
+
77
+ /** Last update timestamp (ISO 8601) */
78
+ updatedAt?: string;
79
+ }
80
+
81
+ export interface UsageState {
82
+ /** All configured providers, including unavailable ones */
83
+ providers: UsageProviderEntry[];
84
+ /** Global refresh timestamp */
85
+ lastRefreshedAt: number;
86
+ }
87
+
88
+ // Legacy provider-specific snapshots (internal use only, mapped to UsageProviderEntry)
89
+
90
+ export interface ClaudeUsageSnapshot {
91
+ source: "oauth" | "web" | "cli";
92
+ primary: RateWindowLegacy;
93
+ secondary: RateWindowLegacy | null;
94
+ opus: RateWindowLegacy | null;
95
+ extraRateWindows: NamedRateWindowLegacy[];
96
+ providerCost: ProviderCostSnapshotLegacy | null;
97
+ updatedAt: Date;
98
+ accountEmail: string | null;
99
+ accountOrganization: string | null;
100
+ loginMethod: string | null;
101
+ subscriptionType: string | null;
102
+ rateLimitTier: string | null;
103
+ }
104
+
105
+ export interface OpenCodeGoUsageSnapshot {
106
+ source: "web";
107
+ hasMonthlyUsage: boolean;
108
+ rollingUsagePercent: number;
109
+ weeklyUsagePercent: number;
110
+ monthlyUsagePercent: number;
111
+ rollingResetInSec: number;
112
+ weeklyResetInSec: number;
113
+ monthlyResetInSec: number;
114
+ updatedAt: Date;
115
+ workspaceId: string | null;
116
+ workspaceName: string | null;
117
+ }
118
+
119
+ // Legacy internal types (used by providers before mapping to unified model)
120
+
121
+ export interface RateWindowLegacy {
122
+ usedPercent: number;
123
+ windowMinutes: number;
124
+ resetsAt: Date | null;
125
+ resetDescription: string | null;
126
+ }
127
+
128
+ export interface NamedRateWindowLegacy {
129
+ id: string;
130
+ title: string;
131
+ window: RateWindowLegacy;
132
+ }
133
+
134
+ export interface ProviderCostSnapshotLegacy {
135
+ used: number;
136
+ limit: number;
137
+ currencyCode: string;
138
+ period: string;
139
+ resetsAt: Date | null;
140
+ updatedAt: Date;
141
+ }
142
+
143
+ // Credential types
144
+
145
+ export interface ClaudeOAuthCredentials {
146
+ accessToken: string;
147
+ refreshToken?: string;
148
+ expiresAt?: string;
149
+ scopes?: string[];
150
+ rateLimitTier?: string;
151
+ subscriptionType?: string;
152
+ }
153
+
154
+ export interface CodexOAuthCredentials {
155
+ accessToken: string;
156
+ refreshToken?: string;
157
+ expiresAt?: string;
158
+ accountEmail?: string | null;
159
+ accountId?: string | null;
160
+ chatgptBaseUrl?: string;
161
+ }
162
+
163
+ export interface ClaudeWebSession {
164
+ cookieHeader: string;
165
+ }
166
+
167
+ export type ClaudeDataSource = "oauth" | "web" | "cli" | "auto";
168
+
169
+ export interface ClaudePlannerDecision {
170
+ primary: ClaudeDataSource;
171
+ fallback: ClaudeDataSource[];
172
+ webExtras: boolean;
173
+ }
174
+
175
+ export type CodexDataSource = "oauth" | "auto";
176
+
177
+ export interface CodexUsageSnapshot {
178
+ source: "oauth";
179
+ planType: string | null;
180
+ primaryWindow: RateWindowLegacy | null;
181
+ secondaryWindow: RateWindowLegacy | null;
182
+ credits: {
183
+ hasCredits: boolean;
184
+ unlimited: boolean;
185
+ balance: number | null;
186
+ } | null;
187
+ updatedAt: Date;
188
+ accountEmail: string | null;
189
+ accountId: string | null;
190
+ }