@indigoai-us/hq-cli 5.345.92 → 5.345.93
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/CHANGELOG.md +14 -0
- package/dist/command-catalog.generated.d.ts +252 -0
- package/dist/command-catalog.generated.js +306 -0
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/capacity.d.ts +71 -0
- package/dist/commands/capacity.js +666 -0
- package/dist/commands/integrations-connect.js +64 -5
- package/dist/commands/lanes-accounts.d.ts +1 -0
- package/dist/commands/lanes-accounts.js +6 -0
- package/dist/lib/capacity/accounts.d.ts +36 -0
- package/dist/lib/capacity/accounts.js +90 -0
- package/dist/lib/capacity/collect.d.ts +33 -0
- package/dist/lib/capacity/collect.js +164 -0
- package/dist/lib/capacity/collectors/claude-oauth.d.ts +145 -0
- package/dist/lib/capacity/collectors/claude-oauth.js +1211 -0
- package/dist/lib/capacity/collectors/claude-statusline.d.ts +72 -0
- package/dist/lib/capacity/collectors/claude-statusline.js +436 -0
- package/dist/lib/capacity/collectors/codex.d.ts +120 -0
- package/dist/lib/capacity/collectors/codex.js +1134 -0
- package/dist/lib/capacity/collectors/grok.d.ts +86 -0
- package/dist/lib/capacity/collectors/grok.js +588 -0
- package/dist/lib/capacity/lane-login.d.ts +29 -0
- package/dist/lib/capacity/lane-login.js +91 -0
- package/dist/lib/capacity/ledger.d.ts +399 -0
- package/dist/lib/capacity/ledger.js +1441 -0
- package/dist/lib/capacity/project.d.ts +12 -0
- package/dist/lib/capacity/project.js +264 -0
- package/dist/lib/capacity/read.d.ts +62 -0
- package/dist/lib/capacity/read.js +264 -0
- package/dist/lib/capacity/refusal.d.ts +30 -0
- package/dist/lib/capacity/refusal.js +183 -0
- package/dist/lib/capacity/scheduler.d.ts +76 -0
- package/dist/lib/capacity/scheduler.js +800 -0
- package/dist/lib/capacity/sign-in.d.ts +103 -0
- package/dist/lib/capacity/sign-in.js +517 -0
- package/dist/lib/capacity/widget.d.ts +22 -0
- package/dist/lib/capacity/widget.js +418 -0
- package/dist/lib/lanes/spawn.js +12 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/run.js +29 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude OAuth usage collector (US-005).
|
|
3
|
+
*
|
|
4
|
+
* Reads the Claude Code credential read-only (macOS Keychain item
|
|
5
|
+
* `Claude Code-credentials`, plus `Claude Code-credentials-<hash>` seats,
|
|
6
|
+
* or the credentials JSON under ~/.claude/ on Linux). GETs
|
|
7
|
+
* https://api.anthropic.com/api/oauth/usage with the oauth-2025-04-20 beta
|
|
8
|
+
* header. This module does not refresh the token, write the Keychain or
|
|
9
|
+
* ~/.claude, call an OAuth token endpoint, or invoke the claude CLI except
|
|
10
|
+
* `claude --version` when the caller does not pass cliVersion.
|
|
11
|
+
*
|
|
12
|
+
* The legacy shape (seven_day_opus populated, no limits[]) is drift.
|
|
13
|
+
* observedAt is the response time. One collect appends every account's
|
|
14
|
+
* readings and failures in one rebuild.
|
|
15
|
+
*/
|
|
16
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
17
|
+
import * as crypto from "node:crypto";
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { DIR_MODE, FILE_MODE, appendReadings, capacityRoot, EXPIRED_REASON, readCollectorMark, } from "../ledger.js";
|
|
22
|
+
import { hashClaudeAccountId } from "./claude-statusline.js";
|
|
23
|
+
export const CLAUDE_OAUTH_PARSER_VERSION = "claude-oauth-usage.1";
|
|
24
|
+
export const CLAUDE_OAUTH_PROVIDER = "claude";
|
|
25
|
+
export const CLAUDE_OAUTH_SOURCE = "oauth-usage";
|
|
26
|
+
export const CLAUDE_OAUTH_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
27
|
+
export const CLAUDE_OAUTH_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
28
|
+
export const CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
|
|
29
|
+
export const CLAUDE_ORG_CACHE_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
export const CLAUDE_ORG_CACHE_FILE = "claude-org-cache.json";
|
|
31
|
+
export const CLAUDE_OAUTH_POLL_MS = 5 * 60 * 1000;
|
|
32
|
+
/** Freshness is shorter than the 5 minute tick so the next tick is not skipped. */
|
|
33
|
+
export const CLAUDE_OAUTH_FRESH_MS = CLAUDE_OAUTH_POLL_MS - 15_000;
|
|
34
|
+
export const CLAUDE_OAUTH_KEYCHAIN_BACKOFF_MS = 60 * 60 * 1000;
|
|
35
|
+
export const CLAUDE_OAUTH_FETCH_TIMEOUT_MS = 10_000;
|
|
36
|
+
export const CLAUDE_OAUTH_SECURITY_TIMEOUT_MS = 5_000;
|
|
37
|
+
export const CLAUDE_OAUTH_POLL_FILE = "claude-oauth-last-call.json";
|
|
38
|
+
export const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
39
|
+
const SESSION_MINUTES = 5 * 60;
|
|
40
|
+
const WEEKLY_MINUTES = 7 * 24 * 60;
|
|
41
|
+
const ACCOUNT_ID_KEYS = [
|
|
42
|
+
"organizationUuid",
|
|
43
|
+
"organizationId",
|
|
44
|
+
"organization_id",
|
|
45
|
+
"orgUuid",
|
|
46
|
+
"orgId",
|
|
47
|
+
"accountUuid",
|
|
48
|
+
"accountId",
|
|
49
|
+
"account_id",
|
|
50
|
+
"uuid",
|
|
51
|
+
];
|
|
52
|
+
export function claudeOAuthPollPath(root) {
|
|
53
|
+
return path.join(root, CLAUDE_OAUTH_POLL_FILE);
|
|
54
|
+
}
|
|
55
|
+
export function defaultClaudeCredentialsPath(home = os.homedir()) {
|
|
56
|
+
return path.join(home, ".claude", ".credentials.json");
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Claude Code's keychain service for a config dir.
|
|
60
|
+
* The default home uses `Claude Code-credentials`. Any other resolved
|
|
61
|
+
* path uses that name plus the first 8 hex chars of SHA-256(utf8 path).
|
|
62
|
+
*/
|
|
63
|
+
export function claudeKeychainServiceName(configDir, defaultConfigDir = path.join(os.homedir(), ".claude")) {
|
|
64
|
+
const resolved = path.resolve(configDir);
|
|
65
|
+
if (resolved === path.resolve(defaultConfigDir))
|
|
66
|
+
return CLAUDE_KEYCHAIN_SERVICE;
|
|
67
|
+
const suffix = crypto.createHash("sha256").update(resolved, "utf8").digest("hex").slice(0, 8);
|
|
68
|
+
return `${CLAUDE_KEYCHAIN_SERVICE}-${suffix}`;
|
|
69
|
+
}
|
|
70
|
+
export function parseClaudeCliVersion(text) {
|
|
71
|
+
const match = /\b(\d+\.\d+\.\d+)\b/.exec(text);
|
|
72
|
+
return match?.[1] ?? null;
|
|
73
|
+
}
|
|
74
|
+
/** Map a usage JSON body. Does not read credentials or call the network. */
|
|
75
|
+
export function mapClaudeOAuthUsage(body, options) {
|
|
76
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
77
|
+
return { readings: [], failures: [failure(options, "claude oauth usage field drift: limits")] };
|
|
78
|
+
}
|
|
79
|
+
const root = body;
|
|
80
|
+
if (!Array.isArray(root.limits)) {
|
|
81
|
+
return { readings: [], failures: [failure(options, "claude oauth usage field drift: limits")] };
|
|
82
|
+
}
|
|
83
|
+
const readings = [];
|
|
84
|
+
const failures = [];
|
|
85
|
+
const indexed = [];
|
|
86
|
+
for (let index = 0; index < root.limits.length; index += 1) {
|
|
87
|
+
const mapped = mapLimit(root.limits[index], options);
|
|
88
|
+
if ("error" in mapped) {
|
|
89
|
+
failures.push(failure(options, mapped.error));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
readings.push(mapped.reading);
|
|
93
|
+
indexed.push({ index, reading: mapped.reading });
|
|
94
|
+
}
|
|
95
|
+
attachShares(root.weekly_scoped_shares, indexed, failures, options);
|
|
96
|
+
const extra = mapExtraUsage(root.extra_usage, options);
|
|
97
|
+
if (extra) {
|
|
98
|
+
if ("error" in extra)
|
|
99
|
+
failures.push(failure(options, extra.error));
|
|
100
|
+
else
|
|
101
|
+
readings.push(extra.reading);
|
|
102
|
+
}
|
|
103
|
+
const spend = mapSpend(root.spend, options);
|
|
104
|
+
if (spend) {
|
|
105
|
+
if ("error" in spend)
|
|
106
|
+
failures.push(failure(options, spend.error));
|
|
107
|
+
else
|
|
108
|
+
readings.push(spend.reading);
|
|
109
|
+
}
|
|
110
|
+
return { readings, failures };
|
|
111
|
+
}
|
|
112
|
+
export async function collectClaudeOAuth(options = {}) {
|
|
113
|
+
const ledger = ledgerOptions(options);
|
|
114
|
+
const root = ledger.root ?? capacityRoot(ledger.env, ledger.home);
|
|
115
|
+
const scoped = { ...ledger, root };
|
|
116
|
+
const clock = options.now ?? ledger.now ?? (() => new Date());
|
|
117
|
+
const empty = (extra = {}) => ({
|
|
118
|
+
readings: [],
|
|
119
|
+
failures: [],
|
|
120
|
+
view: null,
|
|
121
|
+
keychain: "no-prompt",
|
|
122
|
+
httpStatus: null,
|
|
123
|
+
accountIds: [],
|
|
124
|
+
...extra,
|
|
125
|
+
});
|
|
126
|
+
if (!readCollectorMark(scoped)?.enabled && !options.allowDisabled) {
|
|
127
|
+
return empty({ skipped: "disabled" });
|
|
128
|
+
}
|
|
129
|
+
const cliVersion = options.cliVersion ?? readInstalledClaudeVersion(options.readVersion);
|
|
130
|
+
if (!options.credentialsPath && !options.readCredentials) {
|
|
131
|
+
const accounts = options.accounts ?? [{
|
|
132
|
+
label: "default",
|
|
133
|
+
home: path.join(options.ledger?.home ?? os.homedir(), ".claude"),
|
|
134
|
+
}];
|
|
135
|
+
return collectEnumeratedClaude({ ...options, accounts }, scoped, root, clock, cliVersion);
|
|
136
|
+
}
|
|
137
|
+
const nowMs = clock().getTime();
|
|
138
|
+
const loaded = loadAccounts(options, nowMs);
|
|
139
|
+
const keychainFailed = loaded.keychain === "timeout"
|
|
140
|
+
|| loaded.keychain === "prompt"
|
|
141
|
+
|| (loaded.keychain === "error" && loaded.accounts.length === 0 && loaded.store === "keychain");
|
|
142
|
+
if (keychainFailed) {
|
|
143
|
+
const observedAt = clock().toISOString();
|
|
144
|
+
const accountId = hashClaudeAccountId("claude-oauth-keychain");
|
|
145
|
+
const failures = [failure({
|
|
146
|
+
observedAt,
|
|
147
|
+
cliVersion,
|
|
148
|
+
accountId,
|
|
149
|
+
planType: null,
|
|
150
|
+
}, "unavailable: keychain")];
|
|
151
|
+
writeKeychainBlock(root, new Date(nowMs + CLAUDE_OAUTH_KEYCHAIN_BACKOFF_MS).toISOString());
|
|
152
|
+
const view = appendReadings([], failures, null, scoped);
|
|
153
|
+
return {
|
|
154
|
+
readings: [],
|
|
155
|
+
failures,
|
|
156
|
+
view,
|
|
157
|
+
keychain: loaded.keychain === "error" ? "error" : loaded.keychain,
|
|
158
|
+
httpStatus: null,
|
|
159
|
+
accountIds: [accountId],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
if (loaded.accounts.length === 0 && loaded.failures.length === 0 && loaded.keychain === "error") {
|
|
163
|
+
const observedAt = clock().toISOString();
|
|
164
|
+
const accountId = hashClaudeAccountId(loaded.seat || "claude-oauth-credentials");
|
|
165
|
+
const failures = [failure({
|
|
166
|
+
observedAt,
|
|
167
|
+
cliVersion,
|
|
168
|
+
accountId,
|
|
169
|
+
planType: null,
|
|
170
|
+
}, "unavailable: keychain")];
|
|
171
|
+
const view = appendReadings([], failures, null, scoped);
|
|
172
|
+
return { readings: [], failures, view, keychain: "error", httpStatus: null, accountIds: [accountId] };
|
|
173
|
+
}
|
|
174
|
+
const endpoint = options.endpoint ?? CLAUDE_OAUTH_URL;
|
|
175
|
+
const fetchImpl = options.fetchImpl ?? defaultClaudeFetch;
|
|
176
|
+
const readings = [];
|
|
177
|
+
const failures = [...loaded.failures];
|
|
178
|
+
const accountIds = [];
|
|
179
|
+
let httpStatus = null;
|
|
180
|
+
let fetched = false;
|
|
181
|
+
for (const account of loaded.accounts) {
|
|
182
|
+
accountIds.push(account.accountId);
|
|
183
|
+
const observedAt = clock().toISOString();
|
|
184
|
+
const mapContext = {
|
|
185
|
+
observedAt,
|
|
186
|
+
cliVersion,
|
|
187
|
+
accountId: account.accountId,
|
|
188
|
+
planType: account.planType,
|
|
189
|
+
};
|
|
190
|
+
if (pollIsFresh(root, account.accountId, nowMs))
|
|
191
|
+
continue;
|
|
192
|
+
writeAccountPoll(root, account.accountId, new Date(nowMs).toISOString());
|
|
193
|
+
if (account.blocked) {
|
|
194
|
+
failures.push(failure(mapContext, credentialBlockReason(account.blocked)));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const token = account.token;
|
|
198
|
+
if (!token)
|
|
199
|
+
continue;
|
|
200
|
+
fetched = true;
|
|
201
|
+
let status;
|
|
202
|
+
let text;
|
|
203
|
+
let retryAfter;
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetchImpl(endpoint, usageInit(token));
|
|
206
|
+
status = response.status;
|
|
207
|
+
retryAfter = response.headers?.get("retry-after") ?? null;
|
|
208
|
+
text = status === 200 ? await response.text() : "";
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
failures.push(failure(mapContext, "claude oauth usage request failed"));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (httpStatus === null)
|
|
215
|
+
httpStatus = status;
|
|
216
|
+
if (status === 401 || status === 403) {
|
|
217
|
+
failures.push(failure(mapContext, "signed out: auth rejected"));
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (status === 429 || status >= 500) {
|
|
221
|
+
failures.push(failure(mapContext, `unavailable: http ${status}`));
|
|
222
|
+
noteHttpBackoff(root, account.accountId, nowMs, retryAfter);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (status !== 200) {
|
|
226
|
+
failures.push(failure(mapContext, `unavailable: http ${status}`));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
let parsed;
|
|
230
|
+
try {
|
|
231
|
+
parsed = JSON.parse(text);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
failures.push(failure(mapContext, "claude oauth usage response is not JSON"));
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const mapped = mapClaudeOAuthUsage(parsed, mapContext);
|
|
238
|
+
readings.push(...mapped.readings);
|
|
239
|
+
failures.push(...mapped.failures);
|
|
240
|
+
}
|
|
241
|
+
if (!fetched && failures.length === 0) {
|
|
242
|
+
return empty({
|
|
243
|
+
skipped: "rate-limit",
|
|
244
|
+
keychain: loaded.keychain,
|
|
245
|
+
accountIds,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (readings.length === 0 && failures.length === 0) {
|
|
249
|
+
return {
|
|
250
|
+
readings,
|
|
251
|
+
failures,
|
|
252
|
+
view: null,
|
|
253
|
+
keychain: loaded.keychain,
|
|
254
|
+
httpStatus,
|
|
255
|
+
accountIds,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
const view = appendReadings(readings, failures, null, scoped);
|
|
259
|
+
return { readings, failures, view, keychain: loaded.keychain, httpStatus, accountIds };
|
|
260
|
+
}
|
|
261
|
+
async function collectEnumeratedClaude(options, scoped, root, clock, cliVersion) {
|
|
262
|
+
const nowMs = clock().getTime();
|
|
263
|
+
const groups = groupHomes(options.accounts ?? []);
|
|
264
|
+
if (groups.length > 0 && groups.every((group) => homeKeychainBlocked(root, group.home, nowMs))) {
|
|
265
|
+
return {
|
|
266
|
+
readings: [],
|
|
267
|
+
failures: [],
|
|
268
|
+
view: null,
|
|
269
|
+
skipped: "keychain-backoff",
|
|
270
|
+
keychain: "timeout",
|
|
271
|
+
httpStatus: null,
|
|
272
|
+
accountIds: [],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
const loaded = [];
|
|
276
|
+
for (const group of groups)
|
|
277
|
+
loaded.push(await readEnumeratedHome(group, options, nowMs, root));
|
|
278
|
+
const merged = mergeEnumerated(loaded);
|
|
279
|
+
const endpoint = options.endpoint ?? CLAUDE_OAUTH_URL;
|
|
280
|
+
const fetchImpl = options.fetchImpl ?? defaultClaudeFetch;
|
|
281
|
+
const readings = [];
|
|
282
|
+
const failures = [];
|
|
283
|
+
const accountIds = [];
|
|
284
|
+
let httpStatus = null;
|
|
285
|
+
let fetched = false;
|
|
286
|
+
let keychain = "no-prompt";
|
|
287
|
+
for (const account of merged) {
|
|
288
|
+
accountIds.push(account.accountId);
|
|
289
|
+
if (account.keychain === "prompt" || account.keychain === "timeout")
|
|
290
|
+
keychain = account.keychain;
|
|
291
|
+
else if (account.keychain === "error" && keychain === "no-prompt")
|
|
292
|
+
keychain = "error";
|
|
293
|
+
const label = primaryLabel(account.labels);
|
|
294
|
+
const home = account.homes[0];
|
|
295
|
+
const observedAt = clock().toISOString();
|
|
296
|
+
const mapContext = {
|
|
297
|
+
observedAt,
|
|
298
|
+
cliVersion,
|
|
299
|
+
accountId: account.accountId,
|
|
300
|
+
planType: account.planType,
|
|
301
|
+
accountLabel: label,
|
|
302
|
+
accountLabels: account.labels,
|
|
303
|
+
accountHome: home,
|
|
304
|
+
organization: null,
|
|
305
|
+
};
|
|
306
|
+
if (account.blocked) {
|
|
307
|
+
failures.push(failure(mapContext, account.blocked));
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (pollIsFresh(root, account.accountId, nowMs))
|
|
311
|
+
continue;
|
|
312
|
+
writeAccountPoll(root, account.accountId, new Date(nowMs).toISOString());
|
|
313
|
+
const token = account.token;
|
|
314
|
+
if (!token)
|
|
315
|
+
continue;
|
|
316
|
+
mapContext.organization = await resolveOrganization(root, account.accountId, token, fetchImpl, nowMs);
|
|
317
|
+
fetched = true;
|
|
318
|
+
let status;
|
|
319
|
+
let text;
|
|
320
|
+
let retryAfter;
|
|
321
|
+
try {
|
|
322
|
+
const response = await fetchImpl(endpoint, usageInit(token));
|
|
323
|
+
status = response.status;
|
|
324
|
+
retryAfter = response.headers?.get("retry-after") ?? null;
|
|
325
|
+
text = status === 200 ? await response.text() : "";
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
failures.push(failure(mapContext, "claude oauth usage request failed"));
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (httpStatus === null)
|
|
332
|
+
httpStatus = status;
|
|
333
|
+
if (status === 401 || status === 403) {
|
|
334
|
+
failures.push(failure(mapContext, "signed out: auth rejected"));
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
if (status === 429 || status >= 500) {
|
|
338
|
+
failures.push(failure(mapContext, `unavailable: http ${status}`));
|
|
339
|
+
noteHttpBackoff(root, account.accountId, nowMs, retryAfter);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (status !== 200) {
|
|
343
|
+
failures.push(failure(mapContext, `unavailable: http ${status}`));
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
let parsed;
|
|
347
|
+
try {
|
|
348
|
+
parsed = JSON.parse(text);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
failures.push(failure(mapContext, "claude oauth usage response is not JSON"));
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const mapped = mapClaudeOAuthUsage(parsed, mapContext);
|
|
355
|
+
readings.push(...mapped.readings);
|
|
356
|
+
failures.push(...mapped.failures);
|
|
357
|
+
}
|
|
358
|
+
if (!fetched && failures.length === 0) {
|
|
359
|
+
return {
|
|
360
|
+
readings: [],
|
|
361
|
+
failures: [],
|
|
362
|
+
view: null,
|
|
363
|
+
skipped: "rate-limit",
|
|
364
|
+
keychain,
|
|
365
|
+
httpStatus: null,
|
|
366
|
+
accountIds,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (readings.length === 0 && failures.length === 0) {
|
|
370
|
+
return { readings, failures, view: null, keychain, httpStatus, accountIds };
|
|
371
|
+
}
|
|
372
|
+
const view = appendReadings(readings, failures, null, scoped);
|
|
373
|
+
return { readings, failures, view, keychain, httpStatus, accountIds };
|
|
374
|
+
}
|
|
375
|
+
function groupHomes(accounts) {
|
|
376
|
+
const groups = new Map();
|
|
377
|
+
for (const account of accounts) {
|
|
378
|
+
const key = path.resolve(account.home);
|
|
379
|
+
const group = groups.get(key) ?? { home: account.home, labels: [] };
|
|
380
|
+
if (!group.labels.includes(account.label))
|
|
381
|
+
group.labels.push(account.label);
|
|
382
|
+
groups.set(key, group);
|
|
383
|
+
}
|
|
384
|
+
return [...groups.values()];
|
|
385
|
+
}
|
|
386
|
+
function primaryLabel(labels) {
|
|
387
|
+
return labels.find((label) => label !== "default") ?? labels[0] ?? "default";
|
|
388
|
+
}
|
|
389
|
+
async function readEnumeratedHome(group, options, nowMs, root) {
|
|
390
|
+
const labels = [...group.labels];
|
|
391
|
+
const platform = options.platform ?? process.platform;
|
|
392
|
+
const seat = platform === "darwin"
|
|
393
|
+
? `keychain:${claudeKeychainServiceName(group.home)}`
|
|
394
|
+
: `file:${path.resolve(group.home)}`;
|
|
395
|
+
const accountId = hashClaudeAccountId(seat);
|
|
396
|
+
if (homeKeychainBlocked(root, group.home, nowMs)) {
|
|
397
|
+
return {
|
|
398
|
+
accountId,
|
|
399
|
+
labels,
|
|
400
|
+
homes: [group.home],
|
|
401
|
+
token: null,
|
|
402
|
+
planType: null,
|
|
403
|
+
blocked: "unavailable: keychain",
|
|
404
|
+
keychain: "timeout",
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (pollIsFresh(root, accountId, nowMs)) {
|
|
408
|
+
return {
|
|
409
|
+
accountId,
|
|
410
|
+
labels,
|
|
411
|
+
homes: [group.home],
|
|
412
|
+
token: null,
|
|
413
|
+
planType: null,
|
|
414
|
+
blocked: null,
|
|
415
|
+
keychain: "no-prompt",
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
if (platform === "darwin")
|
|
419
|
+
return readEnumeratedKeychain(group, labels, options, nowMs, root);
|
|
420
|
+
return readEnumeratedFile(group, labels, nowMs);
|
|
421
|
+
}
|
|
422
|
+
function readEnumeratedFile(group, labels, nowMs) {
|
|
423
|
+
const file = path.join(group.home, ".credentials.json");
|
|
424
|
+
let text;
|
|
425
|
+
try {
|
|
426
|
+
text = fs.readFileSync(file, "utf8");
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return blockedCredential(group, labels, "signed out: credential missing", "error");
|
|
430
|
+
}
|
|
431
|
+
const parsed = parseCredentialDocument(text, `file:${path.resolve(group.home)}`, nowMs, "underlying");
|
|
432
|
+
if (!parsed || parsed.length === 0) {
|
|
433
|
+
return blockedCredential(group, labels, "signed out: credential unreadable", "error");
|
|
434
|
+
}
|
|
435
|
+
return outcomeCredential(group, labels, parsed[0], "no-prompt");
|
|
436
|
+
}
|
|
437
|
+
async function readEnumeratedKeychain(group, labels, options, nowMs, root) {
|
|
438
|
+
const service = claudeKeychainServiceName(group.home);
|
|
439
|
+
const username = os.userInfo().username;
|
|
440
|
+
let read = await callSecurity(options, ["find-generic-password", "-s", service, "-a", username, "-w"], CLAUDE_OAUTH_SECURITY_TIMEOUT_MS);
|
|
441
|
+
let status = classifySecurity(read);
|
|
442
|
+
if (status === "prompt" || status === "timeout") {
|
|
443
|
+
noteKeychainBackoff(root, group.home, nowMs);
|
|
444
|
+
return blockedCredential(group, labels, "unavailable: keychain", status);
|
|
445
|
+
}
|
|
446
|
+
if (isKeychainMiss(read)) {
|
|
447
|
+
read = await callSecurity(options, ["find-generic-password", "-s", service, "-w"], CLAUDE_OAUTH_SECURITY_TIMEOUT_MS);
|
|
448
|
+
status = classifySecurity(read);
|
|
449
|
+
if (status === "prompt" || status === "timeout") {
|
|
450
|
+
noteKeychainBackoff(root, group.home, nowMs);
|
|
451
|
+
return blockedCredential(group, labels, "unavailable: keychain", status);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (status !== "no-prompt") {
|
|
455
|
+
const reason = isKeychainMiss(read) ? "signed out: credential missing" : "signed out: credential unreadable";
|
|
456
|
+
return blockedCredential(group, labels, reason, "error");
|
|
457
|
+
}
|
|
458
|
+
const parsed = parseCredentialDocument(read.stdout.replace(/\n$/, ""), `keychain:${service}`, nowMs, "underlying");
|
|
459
|
+
if (!parsed || parsed.length === 0) {
|
|
460
|
+
return blockedCredential(group, labels, "signed out: credential unreadable", "error");
|
|
461
|
+
}
|
|
462
|
+
return outcomeCredential(group, labels, parsed[0], "no-prompt");
|
|
463
|
+
}
|
|
464
|
+
function blockedCredential(group, labels, reason, keychain) {
|
|
465
|
+
return {
|
|
466
|
+
accountId: hashClaudeAccountId(`missing:${path.resolve(group.home)}`),
|
|
467
|
+
labels,
|
|
468
|
+
homes: [group.home],
|
|
469
|
+
token: null,
|
|
470
|
+
planType: null,
|
|
471
|
+
blocked: reason,
|
|
472
|
+
keychain,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function outcomeCredential(group, labels, outcome, keychain) {
|
|
476
|
+
if (!outcome)
|
|
477
|
+
return blockedCredential(group, labels, "signed out: credential unreadable", "error");
|
|
478
|
+
const blocked = outcome.blocked ? credentialBlockReason(outcome.blocked) : null;
|
|
479
|
+
return {
|
|
480
|
+
accountId: outcome.accountId,
|
|
481
|
+
labels,
|
|
482
|
+
homes: [group.home],
|
|
483
|
+
token: outcome.token ?? null,
|
|
484
|
+
planType: outcome.planType,
|
|
485
|
+
blocked,
|
|
486
|
+
keychain,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
function credentialBlockReason(blocked) {
|
|
490
|
+
if (blocked.startsWith("signed out:") || blocked.startsWith("expired:"))
|
|
491
|
+
return blocked;
|
|
492
|
+
if (blocked === "unavailable: token_expired")
|
|
493
|
+
return EXPIRED_REASON;
|
|
494
|
+
if (blocked === "unavailable: missing_scope")
|
|
495
|
+
return "signed out: missing profile scope";
|
|
496
|
+
if (blocked === "unavailable: auth")
|
|
497
|
+
return "signed out: auth rejected";
|
|
498
|
+
return `signed out: ${blocked}`;
|
|
499
|
+
}
|
|
500
|
+
function mergeEnumerated(loaded) {
|
|
501
|
+
const merged = [];
|
|
502
|
+
for (const account of loaded) {
|
|
503
|
+
const existing = merged.find((row) => row.accountId === account.accountId);
|
|
504
|
+
if (!existing) {
|
|
505
|
+
merged.push({ ...account, labels: [...account.labels], homes: [...account.homes] });
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
for (const label of account.labels) {
|
|
509
|
+
if (!existing.labels.includes(label))
|
|
510
|
+
existing.labels.push(label);
|
|
511
|
+
}
|
|
512
|
+
for (const home of account.homes) {
|
|
513
|
+
if (!existing.homes.includes(home))
|
|
514
|
+
existing.homes.push(home);
|
|
515
|
+
}
|
|
516
|
+
if (!existing.token && account.token) {
|
|
517
|
+
existing.token = account.token;
|
|
518
|
+
existing.blocked = account.blocked;
|
|
519
|
+
existing.planType = account.planType;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return merged;
|
|
523
|
+
}
|
|
524
|
+
function isKeychainMiss(result) {
|
|
525
|
+
if (result.status === 44)
|
|
526
|
+
return true;
|
|
527
|
+
return /could not be found in the keychain/i.test(result.stderr);
|
|
528
|
+
}
|
|
529
|
+
/** sha256 hex, first 16 characters. The only account id the cache may store. */
|
|
530
|
+
const IDENTITY_KEY_RE = /^[0-9a-f]{16}$/;
|
|
531
|
+
/**
|
|
532
|
+
* Ledger account id → hashed profile account id. Rows with no captured
|
|
533
|
+
* identity are omitted. The raw uuid is never returned.
|
|
534
|
+
*/
|
|
535
|
+
export function claudeIdentityKeys(root) {
|
|
536
|
+
const keys = new Map();
|
|
537
|
+
for (const [accountId, entry] of Object.entries(readOrgCache(root).accounts)) {
|
|
538
|
+
if (entry.identityKey)
|
|
539
|
+
keys.set(accountId, entry.identityKey);
|
|
540
|
+
}
|
|
541
|
+
return keys;
|
|
542
|
+
}
|
|
543
|
+
async function resolveOrganization(root, accountId, token, fetchImpl, nowMs) {
|
|
544
|
+
const fingerprint = crypto.createHash("sha256").update(token, "utf8").digest("hex").slice(0, 16);
|
|
545
|
+
const cache = readOrgCache(root);
|
|
546
|
+
const cached = cache.accounts[accountId];
|
|
547
|
+
if (cached && cached.tokenFingerprint === fingerprint && orgCacheFresh(cached, nowMs))
|
|
548
|
+
return cached.organization;
|
|
549
|
+
let organization = cached?.organization ?? null;
|
|
550
|
+
try {
|
|
551
|
+
const response = await fetchImpl(CLAUDE_OAUTH_PROFILE_URL, usageInit(token));
|
|
552
|
+
if (response.status === 200) {
|
|
553
|
+
const text = await response.text();
|
|
554
|
+
const body = JSON.parse(text);
|
|
555
|
+
organization = organizationName(body);
|
|
556
|
+
cache.accounts[accountId] = {
|
|
557
|
+
organization,
|
|
558
|
+
fetchedAt: new Date(nowMs).toISOString(),
|
|
559
|
+
identityKey: accountIdentityKey(body),
|
|
560
|
+
identityCaptured: true,
|
|
561
|
+
tokenFingerprint: fingerprint,
|
|
562
|
+
};
|
|
563
|
+
writeOrgCache(root, cache);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return organization;
|
|
568
|
+
}
|
|
569
|
+
return organization;
|
|
570
|
+
}
|
|
571
|
+
function orgCacheFresh(entry, nowMs) {
|
|
572
|
+
if (!entry.identityCaptured)
|
|
573
|
+
return false;
|
|
574
|
+
const at = Date.parse(entry.fetchedAt);
|
|
575
|
+
return !Number.isNaN(at) && nowMs - at >= 0 && nowMs - at < CLAUDE_ORG_CACHE_MS;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Identity is the profile account id, hashed. Organization ids are not
|
|
579
|
+
* account identity: the desktop app can rewrite `.claude.json` for a
|
|
580
|
+
* different account than the token that was read.
|
|
581
|
+
*/
|
|
582
|
+
function accountIdentityKey(body) {
|
|
583
|
+
const raw = accountIdentityRaw(body);
|
|
584
|
+
if (!raw)
|
|
585
|
+
return null;
|
|
586
|
+
return crypto.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, 16);
|
|
587
|
+
}
|
|
588
|
+
function accountIdentityRaw(body) {
|
|
589
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
590
|
+
return null;
|
|
591
|
+
const account = body.account;
|
|
592
|
+
if (!account || typeof account !== "object" || Array.isArray(account))
|
|
593
|
+
return null;
|
|
594
|
+
const record = account;
|
|
595
|
+
for (const key of ["uuid", "account_uuid", "accountUuid", "id"]) {
|
|
596
|
+
const value = record[key];
|
|
597
|
+
if (typeof value !== "string")
|
|
598
|
+
continue;
|
|
599
|
+
const trimmed = value.trim();
|
|
600
|
+
if (!trimmed || trimmed.includes("@") || looksSecret(trimmed))
|
|
601
|
+
continue;
|
|
602
|
+
return trimmed;
|
|
603
|
+
}
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
function organizationName(body) {
|
|
607
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
608
|
+
return null;
|
|
609
|
+
const organization = body.organization;
|
|
610
|
+
if (!organization || typeof organization !== "object" || Array.isArray(organization))
|
|
611
|
+
return null;
|
|
612
|
+
const name = organization.name;
|
|
613
|
+
if (typeof name !== "string")
|
|
614
|
+
return null;
|
|
615
|
+
return displayOrganization(name);
|
|
616
|
+
}
|
|
617
|
+
/** Mail providers whose domain says nothing about the account, shown as "Personal". */
|
|
618
|
+
const CONSUMER_MAIL_DOMAINS = new Set([
|
|
619
|
+
"gmail.com", "googlemail.com", "icloud.com", "me.com", "mac.com", "outlook.com",
|
|
620
|
+
"hotmail.com", "live.com", "msn.com", "yahoo.com", "aol.com", "proton.me",
|
|
621
|
+
"protonmail.com", "pm.me", "fastmail.com", "hey.com",
|
|
622
|
+
]);
|
|
623
|
+
/**
|
|
624
|
+
* Claude names an individual plan's organization "<email>'s Organization".
|
|
625
|
+
* The ledger never stores an email, so such a name becomes the email's
|
|
626
|
+
* domain (for example "getindigo.ai"), or "Personal" for a consumer mail
|
|
627
|
+
* domain. Any other name containing "@" is dropped.
|
|
628
|
+
*/
|
|
629
|
+
export function displayOrganization(name) {
|
|
630
|
+
const trimmed = name.trim();
|
|
631
|
+
if (!trimmed)
|
|
632
|
+
return null;
|
|
633
|
+
const personal = /^[^@\s]+@([a-z0-9.-]+\.[a-z]{2,})'s organization$/i.exec(trimmed);
|
|
634
|
+
if (personal) {
|
|
635
|
+
const domain = personal[1].toLowerCase();
|
|
636
|
+
return CONSUMER_MAIL_DOMAINS.has(domain) ? "Personal" : domain;
|
|
637
|
+
}
|
|
638
|
+
if (trimmed.includes("@"))
|
|
639
|
+
return null;
|
|
640
|
+
return trimmed;
|
|
641
|
+
}
|
|
642
|
+
/** null = profile had no account id. undefined = missing or not a hash, so refetch. */
|
|
643
|
+
function storedIdentityKey(value) {
|
|
644
|
+
if (value === null)
|
|
645
|
+
return null;
|
|
646
|
+
if (typeof value === "string" && IDENTITY_KEY_RE.test(value))
|
|
647
|
+
return value;
|
|
648
|
+
return undefined;
|
|
649
|
+
}
|
|
650
|
+
function readOrgCache(root) {
|
|
651
|
+
try {
|
|
652
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, CLAUDE_ORG_CACHE_FILE), "utf8"));
|
|
653
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
654
|
+
return { accounts: {} };
|
|
655
|
+
const accounts = parsed.accounts;
|
|
656
|
+
if (!accounts || typeof accounts !== "object" || Array.isArray(accounts))
|
|
657
|
+
return { accounts: {} };
|
|
658
|
+
const out = {};
|
|
659
|
+
for (const [key, value] of Object.entries(accounts)) {
|
|
660
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
661
|
+
continue;
|
|
662
|
+
const row = value;
|
|
663
|
+
if (typeof row.fetchedAt !== "string")
|
|
664
|
+
continue;
|
|
665
|
+
const organization = typeof row.organization === "string" && !row.organization.includes("@")
|
|
666
|
+
? row.organization
|
|
667
|
+
: null;
|
|
668
|
+
const identity = Object.prototype.hasOwnProperty.call(row, "identityKey")
|
|
669
|
+
? storedIdentityKey(row.identityKey)
|
|
670
|
+
: undefined;
|
|
671
|
+
const fingerprint = typeof row.tokenFingerprint === "string" && IDENTITY_KEY_RE.test(row.tokenFingerprint)
|
|
672
|
+
? row.tokenFingerprint
|
|
673
|
+
: undefined;
|
|
674
|
+
out[key] = {
|
|
675
|
+
organization,
|
|
676
|
+
fetchedAt: row.fetchedAt,
|
|
677
|
+
identityKey: identity ?? null,
|
|
678
|
+
identityCaptured: identity !== undefined,
|
|
679
|
+
tokenFingerprint: fingerprint,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return { accounts: out };
|
|
683
|
+
}
|
|
684
|
+
catch {
|
|
685
|
+
return { accounts: {} };
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function writeOrgCache(root, cache) {
|
|
689
|
+
fs.mkdirSync(root, { recursive: true, mode: DIR_MODE });
|
|
690
|
+
try {
|
|
691
|
+
fs.chmodSync(root, DIR_MODE);
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
// The directory mode is best-effort when the root already exists.
|
|
695
|
+
}
|
|
696
|
+
const dest = path.join(root, CLAUDE_ORG_CACHE_FILE);
|
|
697
|
+
const stored = {
|
|
698
|
+
accounts: Object.fromEntries(Object.entries(cache.accounts).map(([id, entry]) => {
|
|
699
|
+
const row = {
|
|
700
|
+
organization: entry.organization,
|
|
701
|
+
fetchedAt: entry.fetchedAt,
|
|
702
|
+
};
|
|
703
|
+
if (entry.identityCaptured)
|
|
704
|
+
row.identityKey = entry.identityKey;
|
|
705
|
+
if (entry.tokenFingerprint && IDENTITY_KEY_RE.test(entry.tokenFingerprint)) {
|
|
706
|
+
row.tokenFingerprint = entry.tokenFingerprint;
|
|
707
|
+
}
|
|
708
|
+
return [id, row];
|
|
709
|
+
})),
|
|
710
|
+
};
|
|
711
|
+
const body = `${JSON.stringify(stored)}\n`;
|
|
712
|
+
const tmp = path.join(root, `.${CLAUDE_ORG_CACHE_FILE}.${process.pid}.tmp`);
|
|
713
|
+
fs.writeFileSync(tmp, body, { mode: FILE_MODE });
|
|
714
|
+
fs.renameSync(tmp, dest);
|
|
715
|
+
fs.chmodSync(dest, FILE_MODE);
|
|
716
|
+
}
|
|
717
|
+
export function claudeSeatAccountId(seat, orgId) {
|
|
718
|
+
const material = orgId ? `${orgId}\0${seat}` : seat;
|
|
719
|
+
return hashClaudeAccountId(material);
|
|
720
|
+
}
|
|
721
|
+
function loadAccounts(options, nowMs) {
|
|
722
|
+
if (options.readCredentials) {
|
|
723
|
+
const read = options.readCredentials();
|
|
724
|
+
return {
|
|
725
|
+
keychain: read.keychain,
|
|
726
|
+
store: "injected",
|
|
727
|
+
seat: "injected",
|
|
728
|
+
failures: [],
|
|
729
|
+
accounts: read.accounts.map((account) => classifyAccount(account, nowMs)),
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
if (options.credentialsPath || (options.platform ?? process.platform) !== "darwin") {
|
|
733
|
+
const file = options.credentialsPath ?? defaultClaudeCredentialsPath(options.ledger?.home);
|
|
734
|
+
const seat = `file:${path.basename(file)}`;
|
|
735
|
+
const read = readCredentialFile(file, seat, nowMs);
|
|
736
|
+
if ("error" in read) {
|
|
737
|
+
return { keychain: "error", accounts: [], failures: [], store: "file", seat };
|
|
738
|
+
}
|
|
739
|
+
return { keychain: "no-prompt", accounts: read.accounts, failures: [], store: "file", seat };
|
|
740
|
+
}
|
|
741
|
+
const file = defaultClaudeCredentialsPath(options.ledger?.home);
|
|
742
|
+
const seat = `file:${path.basename(file)}`;
|
|
743
|
+
const read = readCredentialFile(file, seat, nowMs);
|
|
744
|
+
if ("error" in read) {
|
|
745
|
+
return { keychain: "error", accounts: [], failures: [], store: "file", seat };
|
|
746
|
+
}
|
|
747
|
+
return { keychain: "no-prompt", accounts: read.accounts, failures: [], store: "file", seat };
|
|
748
|
+
}
|
|
749
|
+
function classifyAccount(account, nowMs) {
|
|
750
|
+
const planType = account.planType ?? null;
|
|
751
|
+
if (isExpired(account.expiresAt ?? null, nowMs)) {
|
|
752
|
+
return { blocked: "unavailable: token_expired", accountId: account.accountId, planType };
|
|
753
|
+
}
|
|
754
|
+
if (!hasProfileScope(account.scopes)) {
|
|
755
|
+
return { blocked: "unavailable: missing_scope", accountId: account.accountId, planType };
|
|
756
|
+
}
|
|
757
|
+
return { token: account.token, accountId: account.accountId, planType };
|
|
758
|
+
}
|
|
759
|
+
function readCredentialFile(file, seat, nowMs) {
|
|
760
|
+
let text;
|
|
761
|
+
try {
|
|
762
|
+
text = fs.readFileSync(file, "utf8");
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
return { error: "missing" };
|
|
766
|
+
}
|
|
767
|
+
const accounts = parseCredentialDocument(text, seat, nowMs);
|
|
768
|
+
if (!accounts || accounts.length === 0)
|
|
769
|
+
return { error: "invalid" };
|
|
770
|
+
return { accounts };
|
|
771
|
+
}
|
|
772
|
+
function parseCredentialDocument(text, seat, nowMs, identity = "seat") {
|
|
773
|
+
let parsed;
|
|
774
|
+
try {
|
|
775
|
+
parsed = JSON.parse(text);
|
|
776
|
+
}
|
|
777
|
+
catch {
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
781
|
+
return null;
|
|
782
|
+
const root = parsed;
|
|
783
|
+
const oauth = root.claudeAiOauth && typeof root.claudeAiOauth === "object" && !Array.isArray(root.claudeAiOauth)
|
|
784
|
+
? root.claudeAiOauth
|
|
785
|
+
: null;
|
|
786
|
+
if (!oauth)
|
|
787
|
+
return [];
|
|
788
|
+
const token = typeof oauth.accessToken === "string" && oauth.accessToken.trim()
|
|
789
|
+
? oauth.accessToken
|
|
790
|
+
: (typeof oauth.access_token === "string" ? oauth.access_token : "");
|
|
791
|
+
if (!token)
|
|
792
|
+
return [];
|
|
793
|
+
const orgId = findAccountId(oauth);
|
|
794
|
+
// A profile or credential account id is the seat. An organization id is
|
|
795
|
+
// not: two people in one org must stay two accounts. With no account id,
|
|
796
|
+
// the home (keychain service or credentials path) keeps them apart.
|
|
797
|
+
const accountId = identity === "underlying"
|
|
798
|
+
? hashClaudeAccountId(underlyingAccountRaw(oauth) ?? seat)
|
|
799
|
+
: claudeSeatAccountId(seat, orgId);
|
|
800
|
+
const scopes = Array.isArray(oauth.scopes)
|
|
801
|
+
? oauth.scopes.filter((scope) => typeof scope === "string")
|
|
802
|
+
: null;
|
|
803
|
+
const planType = typeof oauth.subscriptionType === "string" && oauth.subscriptionType.trim()
|
|
804
|
+
? oauth.subscriptionType.trim()
|
|
805
|
+
: (typeof oauth.rateLimitTier === "string" && oauth.rateLimitTier.trim() ? oauth.rateLimitTier.trim() : null);
|
|
806
|
+
const expiresAt = typeof oauth.expiresAt === "number" && Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : null;
|
|
807
|
+
return [classifyAccount({ token, accountId, expiresAt, scopes, planType }, nowMs)];
|
|
808
|
+
}
|
|
809
|
+
function underlyingAccountRaw(oauth) {
|
|
810
|
+
for (const key of ["accountUuid", "accountId", "account_id", "uuid"]) {
|
|
811
|
+
const value = oauth[key];
|
|
812
|
+
if (typeof value === "string" && value.trim() && !value.includes("@") && !looksSecret(value)) {
|
|
813
|
+
return value.trim();
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
function findAccountId(oauth) {
|
|
819
|
+
for (const key of ACCOUNT_ID_KEYS) {
|
|
820
|
+
const value = oauth[key];
|
|
821
|
+
if (typeof value === "string" && value.trim() && !value.includes("@") && !looksSecret(value))
|
|
822
|
+
return value.trim();
|
|
823
|
+
}
|
|
824
|
+
const organization = oauth.organization;
|
|
825
|
+
if (organization && typeof organization === "object" && !Array.isArray(organization)) {
|
|
826
|
+
const nested = findAccountId(organization);
|
|
827
|
+
if (nested)
|
|
828
|
+
return nested;
|
|
829
|
+
}
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
function looksSecret(value) {
|
|
833
|
+
return value.startsWith("sk-ant-") || value.startsWith("eyJ");
|
|
834
|
+
}
|
|
835
|
+
function hasProfileScope(scopes) {
|
|
836
|
+
return Array.isArray(scopes) && scopes.includes("user:profile");
|
|
837
|
+
}
|
|
838
|
+
function isExpired(expiresAt, nowMs) {
|
|
839
|
+
if (expiresAt === null)
|
|
840
|
+
return false;
|
|
841
|
+
const ms = expiresAt < 1e12 ? expiresAt * 1000 : expiresAt;
|
|
842
|
+
return ms <= nowMs;
|
|
843
|
+
}
|
|
844
|
+
function classifySecurity(result) {
|
|
845
|
+
if (result.timedOut)
|
|
846
|
+
return "timeout";
|
|
847
|
+
const text = `${result.stderr}\n${result.stdout}`;
|
|
848
|
+
if (/interaction is not allowed|User canceled|errSecUserCanceled|errSecInteractionNotAllowed|-25308/i.test(text) && result.status !== 0) {
|
|
849
|
+
return "prompt";
|
|
850
|
+
}
|
|
851
|
+
if (result.status !== 0)
|
|
852
|
+
return "error";
|
|
853
|
+
return "no-prompt";
|
|
854
|
+
}
|
|
855
|
+
function usageInit(token) {
|
|
856
|
+
return {
|
|
857
|
+
method: "GET",
|
|
858
|
+
headers: {
|
|
859
|
+
Authorization: `Bearer ${token}`,
|
|
860
|
+
"anthropic-beta": CLAUDE_OAUTH_BETA,
|
|
861
|
+
Accept: "application/json",
|
|
862
|
+
},
|
|
863
|
+
redirect: "manual",
|
|
864
|
+
signal: AbortSignal.timeout(CLAUDE_OAUTH_FETCH_TIMEOUT_MS),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const defaultClaudeFetch = async (url, init) => {
|
|
868
|
+
const response = await fetch(url, init);
|
|
869
|
+
return {
|
|
870
|
+
status: response.status,
|
|
871
|
+
text: () => response.text(),
|
|
872
|
+
headers: { get: (name) => response.headers.get(name) },
|
|
873
|
+
};
|
|
874
|
+
};
|
|
875
|
+
export function readInstalledClaudeVersion(readVersion = defaultClaudeVersionText) {
|
|
876
|
+
try {
|
|
877
|
+
return parseClaudeCliVersion(readVersion()) ?? "unknown";
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
return "unknown";
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/** Async `claude --version` for the daemon. The sync reader stays for one-shot callers. */
|
|
884
|
+
export function readInstalledClaudeVersionAsync() {
|
|
885
|
+
return execFileText("claude", ["--version"]).then((text) => parseClaudeCliVersion(text) ?? "unknown", () => "unknown");
|
|
886
|
+
}
|
|
887
|
+
function execFileText(command, args) {
|
|
888
|
+
return new Promise((resolve, reject) => {
|
|
889
|
+
execFile(command, [...args], { timeout: 5_000, encoding: "utf8", windowsHide: true }, (error, stdout, stderr) => {
|
|
890
|
+
if (error && !stdout && !stderr) {
|
|
891
|
+
reject(error);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
resolve(`${stdout ?? ""}\n${stderr ?? ""}`);
|
|
895
|
+
});
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
function defaultClaudeVersionText() {
|
|
899
|
+
const result = spawnSync("claude", ["--version"], {
|
|
900
|
+
encoding: "utf8",
|
|
901
|
+
timeout: 5_000,
|
|
902
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
903
|
+
windowsHide: true,
|
|
904
|
+
});
|
|
905
|
+
if (result.error)
|
|
906
|
+
throw result.error;
|
|
907
|
+
return `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
908
|
+
}
|
|
909
|
+
function mapLimit(value, options) {
|
|
910
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
911
|
+
return { error: "claude oauth usage field drift: limits" };
|
|
912
|
+
}
|
|
913
|
+
const record = value;
|
|
914
|
+
const kind = record.kind;
|
|
915
|
+
if (kind !== "session" && kind !== "weekly_all" && kind !== "weekly_scoped") {
|
|
916
|
+
return { error: "claude oauth usage field drift: limits" };
|
|
917
|
+
}
|
|
918
|
+
if (!isFiniteNumber(record.percent))
|
|
919
|
+
return { error: "claude oauth usage field drift: limits" };
|
|
920
|
+
const resetsAt = normalizeReset(record.resets_at);
|
|
921
|
+
if (typeof record.resets_at === "string" && record.resets_at.trim() && !resetsAt) {
|
|
922
|
+
return { error: "claude oauth usage field drift: limits" };
|
|
923
|
+
}
|
|
924
|
+
const scope = kind === "weekly_scoped" ? readScope(record.scope) : null;
|
|
925
|
+
const windowMinutes = kind === "session" ? SESSION_MINUTES : WEEKLY_MINUTES;
|
|
926
|
+
return {
|
|
927
|
+
reading: reading(options, {
|
|
928
|
+
limitId: scope ? `${kind}:${scope}` : kind,
|
|
929
|
+
kind,
|
|
930
|
+
scope,
|
|
931
|
+
usedPercent: record.percent,
|
|
932
|
+
windowMinutes,
|
|
933
|
+
resetsAt,
|
|
934
|
+
balance: null,
|
|
935
|
+
planType: options.planType,
|
|
936
|
+
}),
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
function attachShares(value, indexed, failures, options) {
|
|
940
|
+
if (value === undefined || value === null)
|
|
941
|
+
return;
|
|
942
|
+
if (!Array.isArray(value)) {
|
|
943
|
+
failures.push(failure(options, "claude oauth usage field drift: weekly_scoped_shares"));
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
for (const share of value) {
|
|
947
|
+
if (!share || typeof share !== "object" || Array.isArray(share))
|
|
948
|
+
continue;
|
|
949
|
+
const record = share;
|
|
950
|
+
if (typeof record.limit_index !== "number")
|
|
951
|
+
continue;
|
|
952
|
+
if (!isFiniteNumber(record.allowance_percent_of_weekly))
|
|
953
|
+
continue;
|
|
954
|
+
const hit = indexed.find((row) => row.index === record.limit_index && row.reading.limit.kind === "weekly_scoped");
|
|
955
|
+
if (!hit)
|
|
956
|
+
continue;
|
|
957
|
+
hit.reading.limit.allowancePercentOfWeekly = record.allowance_percent_of_weekly;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function mapExtraUsage(value, options) {
|
|
961
|
+
if (value === undefined || value === null)
|
|
962
|
+
return null;
|
|
963
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
964
|
+
return { error: "claude oauth usage field drift: extra_usage" };
|
|
965
|
+
}
|
|
966
|
+
const record = value;
|
|
967
|
+
if (typeof record.is_enabled !== "boolean")
|
|
968
|
+
return { error: "claude oauth usage field drift: extra_usage" };
|
|
969
|
+
if ("utilization" in record && record.utilization !== null && !isFiniteNumber(record.utilization)) {
|
|
970
|
+
return { error: "claude oauth usage field drift: extra_usage" };
|
|
971
|
+
}
|
|
972
|
+
const currency = typeof record.currency === "string" && record.currency.trim() ? record.currency.trim() : null;
|
|
973
|
+
return {
|
|
974
|
+
reading: reading(options, {
|
|
975
|
+
limitId: "extra_usage",
|
|
976
|
+
kind: "credits",
|
|
977
|
+
scope: currency,
|
|
978
|
+
usedPercent: isFiniteNumber(record.utilization) ? record.utilization : null,
|
|
979
|
+
windowMinutes: null,
|
|
980
|
+
resetsAt: null,
|
|
981
|
+
balance: creditsBalance(record.used_credits, record.monthly_limit),
|
|
982
|
+
planType: options.planType,
|
|
983
|
+
hasCredits: record.is_enabled,
|
|
984
|
+
}),
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
function mapSpend(value, options) {
|
|
988
|
+
if (value === undefined || value === null)
|
|
989
|
+
return null;
|
|
990
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
991
|
+
return { error: "claude oauth usage field drift: spend" };
|
|
992
|
+
}
|
|
993
|
+
const record = value;
|
|
994
|
+
if (!isFiniteNumber(record.percent))
|
|
995
|
+
return { error: "claude oauth usage field drift: spend" };
|
|
996
|
+
const used = record.used && typeof record.used === "object" && !Array.isArray(record.used)
|
|
997
|
+
? record.used
|
|
998
|
+
: null;
|
|
999
|
+
const currency = used && typeof used.currency === "string" && used.currency.trim() ? used.currency.trim() : null;
|
|
1000
|
+
const amount = used && isFiniteNumber(used.amount_minor) ? used.amount_minor : null;
|
|
1001
|
+
return {
|
|
1002
|
+
reading: reading(options, {
|
|
1003
|
+
limitId: "spend",
|
|
1004
|
+
kind: "credits",
|
|
1005
|
+
scope: currency,
|
|
1006
|
+
usedPercent: record.percent,
|
|
1007
|
+
windowMinutes: null,
|
|
1008
|
+
resetsAt: null,
|
|
1009
|
+
balance: amount,
|
|
1010
|
+
planType: options.planType,
|
|
1011
|
+
hasCredits: typeof record.enabled === "boolean" ? record.enabled : null,
|
|
1012
|
+
}),
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
function creditsBalance(used, limit) {
|
|
1016
|
+
const usedOk = isFiniteNumber(used);
|
|
1017
|
+
const limitOk = isFiniteNumber(limit);
|
|
1018
|
+
if (usedOk && limitOk)
|
|
1019
|
+
return `${used}/${limit}`;
|
|
1020
|
+
if (usedOk)
|
|
1021
|
+
return used;
|
|
1022
|
+
if (limitOk)
|
|
1023
|
+
return limit;
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
function readScope(value) {
|
|
1027
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1028
|
+
return null;
|
|
1029
|
+
const model = value.model;
|
|
1030
|
+
if (!model || typeof model !== "object" || Array.isArray(model))
|
|
1031
|
+
return null;
|
|
1032
|
+
const name = model.display_name;
|
|
1033
|
+
return typeof name === "string" && name.trim() ? name.trim() : null;
|
|
1034
|
+
}
|
|
1035
|
+
function normalizeReset(value) {
|
|
1036
|
+
if (typeof value !== "string" || !value.trim())
|
|
1037
|
+
return null;
|
|
1038
|
+
const parsed = Date.parse(value);
|
|
1039
|
+
if (Number.isNaN(parsed))
|
|
1040
|
+
return null;
|
|
1041
|
+
return new Date(parsed).toISOString();
|
|
1042
|
+
}
|
|
1043
|
+
function reading(options, limit) {
|
|
1044
|
+
const row = {
|
|
1045
|
+
readingId: makeReadingId([
|
|
1046
|
+
CLAUDE_OAUTH_SOURCE,
|
|
1047
|
+
options.accountId,
|
|
1048
|
+
limit.limitId,
|
|
1049
|
+
options.observedAt,
|
|
1050
|
+
String(limit.usedPercent),
|
|
1051
|
+
String(limit.balance),
|
|
1052
|
+
]),
|
|
1053
|
+
observedAt: options.observedAt,
|
|
1054
|
+
provider: CLAUDE_OAUTH_PROVIDER,
|
|
1055
|
+
accountId: options.accountId,
|
|
1056
|
+
source: CLAUDE_OAUTH_SOURCE,
|
|
1057
|
+
cliVersion: options.cliVersion,
|
|
1058
|
+
confidence: "fetched",
|
|
1059
|
+
parserVersion: CLAUDE_OAUTH_PARSER_VERSION,
|
|
1060
|
+
limit,
|
|
1061
|
+
};
|
|
1062
|
+
return stampAccount(row, options);
|
|
1063
|
+
}
|
|
1064
|
+
function failure(options, error) {
|
|
1065
|
+
const event = {
|
|
1066
|
+
event: "parser_failure",
|
|
1067
|
+
observedAt: options.observedAt,
|
|
1068
|
+
provider: CLAUDE_OAUTH_PROVIDER,
|
|
1069
|
+
source: CLAUDE_OAUTH_SOURCE,
|
|
1070
|
+
error,
|
|
1071
|
+
accountId: options.accountId,
|
|
1072
|
+
cliVersion: options.cliVersion,
|
|
1073
|
+
parserVersion: CLAUDE_OAUTH_PARSER_VERSION,
|
|
1074
|
+
};
|
|
1075
|
+
return stampAccount(event, options);
|
|
1076
|
+
}
|
|
1077
|
+
function stampAccount(row, options) {
|
|
1078
|
+
if (options.accountLabel)
|
|
1079
|
+
row.accountLabel = options.accountLabel;
|
|
1080
|
+
if (options.accountLabels)
|
|
1081
|
+
row.accountLabels = [...options.accountLabels];
|
|
1082
|
+
if (options.accountHome)
|
|
1083
|
+
row.accountHome = options.accountHome;
|
|
1084
|
+
if (options.organization !== undefined)
|
|
1085
|
+
row.organization = options.organization;
|
|
1086
|
+
return row;
|
|
1087
|
+
}
|
|
1088
|
+
function makeReadingId(parts) {
|
|
1089
|
+
const digest = crypto.createHash("sha256").update(parts.join("\0"), "utf8").digest("hex").slice(0, 20);
|
|
1090
|
+
return `rdg_${digest}`;
|
|
1091
|
+
}
|
|
1092
|
+
function ledgerOptions(options) {
|
|
1093
|
+
return { ...(options.ledger ?? {}) };
|
|
1094
|
+
}
|
|
1095
|
+
function pollIsFresh(root, accountId, nowMs) {
|
|
1096
|
+
const state = readPoll(claudeOAuthPollPath(root));
|
|
1097
|
+
const last = state.accounts[accountId];
|
|
1098
|
+
if (!last)
|
|
1099
|
+
return false;
|
|
1100
|
+
const at = Date.parse(last);
|
|
1101
|
+
if (Number.isNaN(at))
|
|
1102
|
+
return false;
|
|
1103
|
+
const delta = nowMs - at;
|
|
1104
|
+
return delta >= 0 && delta < CLAUDE_OAUTH_FRESH_MS;
|
|
1105
|
+
}
|
|
1106
|
+
function homeBlockKey(home) {
|
|
1107
|
+
return `block:${path.resolve(home)}`;
|
|
1108
|
+
}
|
|
1109
|
+
function homeKeychainBlocked(root, home, nowMs) {
|
|
1110
|
+
if (keychainBlocked(root, nowMs))
|
|
1111
|
+
return true;
|
|
1112
|
+
const until = readPoll(claudeOAuthPollPath(root)).accounts[homeBlockKey(home)];
|
|
1113
|
+
if (!until)
|
|
1114
|
+
return false;
|
|
1115
|
+
const at = Date.parse(until);
|
|
1116
|
+
return !Number.isNaN(at) && nowMs < at;
|
|
1117
|
+
}
|
|
1118
|
+
function noteKeychainBackoff(root, home, nowMs) {
|
|
1119
|
+
const until = new Date(nowMs + CLAUDE_OAUTH_KEYCHAIN_BACKOFF_MS).toISOString();
|
|
1120
|
+
writeKeychainBlock(root, until);
|
|
1121
|
+
const state = readPoll(claudeOAuthPollPath(root));
|
|
1122
|
+
state.accounts[homeBlockKey(home)] = until;
|
|
1123
|
+
writePoll(root, state);
|
|
1124
|
+
}
|
|
1125
|
+
function noteHttpBackoff(root, accountId, nowMs, retryAfter) {
|
|
1126
|
+
let retryMs = 0;
|
|
1127
|
+
if (retryAfter) {
|
|
1128
|
+
const seconds = Number(retryAfter);
|
|
1129
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
1130
|
+
retryMs = seconds * 1000;
|
|
1131
|
+
else {
|
|
1132
|
+
const at = Date.parse(retryAfter);
|
|
1133
|
+
if (!Number.isNaN(at))
|
|
1134
|
+
retryMs = Math.max(0, at - nowMs);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
const backoffMs = Math.max(retryMs, CLAUDE_OAUTH_POLL_MS);
|
|
1138
|
+
writeAccountPoll(root, accountId, new Date(nowMs + backoffMs - CLAUDE_OAUTH_FRESH_MS).toISOString());
|
|
1139
|
+
}
|
|
1140
|
+
function callSecurity(options, args, timeoutMs) {
|
|
1141
|
+
if (options.securityExec)
|
|
1142
|
+
return Promise.resolve(options.securityExec(args, timeoutMs));
|
|
1143
|
+
return new Promise((resolve) => {
|
|
1144
|
+
execFile("security", [...args], { timeout: timeoutMs, encoding: "utf8", maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
1145
|
+
const err = error;
|
|
1146
|
+
const timedOut = err?.code === "ETIMEDOUT";
|
|
1147
|
+
const status = typeof err?.code === "number" ? err.code : (err ? 1 : 0);
|
|
1148
|
+
resolve({
|
|
1149
|
+
status,
|
|
1150
|
+
stdout: typeof stdout === "string" ? stdout : "",
|
|
1151
|
+
stderr: typeof stderr === "string" ? stderr : "",
|
|
1152
|
+
timedOut,
|
|
1153
|
+
});
|
|
1154
|
+
});
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
function keychainBlocked(root, nowMs) {
|
|
1158
|
+
const until = readPoll(claudeOAuthPollPath(root)).keychainBlockedUntil;
|
|
1159
|
+
if (!until)
|
|
1160
|
+
return false;
|
|
1161
|
+
const at = Date.parse(until);
|
|
1162
|
+
return !Number.isNaN(at) && nowMs < at;
|
|
1163
|
+
}
|
|
1164
|
+
function readPoll(file) {
|
|
1165
|
+
const empty = { accounts: {}, keychainBlockedUntil: null };
|
|
1166
|
+
try {
|
|
1167
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1168
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1169
|
+
return empty;
|
|
1170
|
+
const record = parsed;
|
|
1171
|
+
const accounts = {};
|
|
1172
|
+
if (record.accounts && typeof record.accounts === "object" && !Array.isArray(record.accounts)) {
|
|
1173
|
+
for (const [key, value] of Object.entries(record.accounts)) {
|
|
1174
|
+
if (typeof value === "string")
|
|
1175
|
+
accounts[key] = value;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const blocked = typeof record.keychainBlockedUntil === "string" ? record.keychainBlockedUntil : null;
|
|
1179
|
+
return { accounts, keychainBlockedUntil: blocked };
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
return empty;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
function writeAccountPoll(root, accountId, at) {
|
|
1186
|
+
const state = readPoll(claudeOAuthPollPath(root));
|
|
1187
|
+
state.accounts[accountId] = at;
|
|
1188
|
+
writePoll(root, state);
|
|
1189
|
+
}
|
|
1190
|
+
function writeKeychainBlock(root, until) {
|
|
1191
|
+
const state = readPoll(claudeOAuthPollPath(root));
|
|
1192
|
+
state.keychainBlockedUntil = until;
|
|
1193
|
+
writePoll(root, state);
|
|
1194
|
+
}
|
|
1195
|
+
function writePoll(root, state) {
|
|
1196
|
+
fs.mkdirSync(root, { recursive: true, mode: DIR_MODE });
|
|
1197
|
+
fs.chmodSync(root, DIR_MODE);
|
|
1198
|
+
const dest = claudeOAuthPollPath(root);
|
|
1199
|
+
const body = `${JSON.stringify({
|
|
1200
|
+
accounts: state.accounts,
|
|
1201
|
+
keychainBlockedUntil: state.keychainBlockedUntil,
|
|
1202
|
+
}, null, 2)}\n`;
|
|
1203
|
+
const tmp = path.join(root, `.${CLAUDE_OAUTH_POLL_FILE}.${process.pid}.tmp`);
|
|
1204
|
+
fs.writeFileSync(tmp, body, { mode: FILE_MODE });
|
|
1205
|
+
fs.renameSync(tmp, dest);
|
|
1206
|
+
fs.chmodSync(dest, FILE_MODE);
|
|
1207
|
+
}
|
|
1208
|
+
function isFiniteNumber(value) {
|
|
1209
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
1210
|
+
}
|
|
1211
|
+
//# sourceMappingURL=claude-oauth.js.map
|