@cad0p/pi-usage 0.0.0-stage → 0.11.2
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/LICENSE +21 -0
- package/README.md +509 -2
- package/bin/piquota.js +199 -0
- package/extensions/quota-panel.ts +963 -0
- package/package.json +59 -4
- package/src/auth/claude-code-auth.js +490 -0
- package/src/auth/jwt.js +84 -0
- package/src/auth/pi-auth.js +424 -0
- package/src/browser/cookies.js +557 -0
- package/src/browser/history.js +173 -0
- package/src/cache.js +224 -0
- package/src/cli/args.js +154 -0
- package/src/cli/commands/auth.js +216 -0
- package/src/cli/commands/gentle-pi.js +44 -0
- package/src/cli/commands/omarchy.js +75 -0
- package/src/cli/explain.js +99 -0
- package/src/cli/output.js +34 -0
- package/src/engine.js +404 -0
- package/src/exec.js +31 -0
- package/src/gentle-pi/rail-patch.js +335 -0
- package/src/http.js +182 -0
- package/src/model.js +332 -0
- package/src/omarchy/assets/antigravity.svg +3 -0
- package/src/omarchy/assets/claude.svg +1 -0
- package/src/omarchy/assets/codex.svg +1 -0
- package/src/omarchy/assets/github-actions.svg +3 -0
- package/src/omarchy/assets/opencode-go.svg +3 -0
- package/src/omarchy/publish.js +152 -0
- package/src/omarchy/record.js +462 -0
- package/src/opencode/dashboard.js +337 -0
- package/src/opencode/session.js +485 -0
- package/src/providers/antigravity-oauth.js +173 -0
- package/src/providers/antigravity.js +225 -0
- package/src/providers/backoff.js +146 -0
- package/src/providers/claude.js +194 -0
- package/src/providers/codex.js +151 -0
- package/src/providers/error-kind.js +128 -0
- package/src/providers/github-actions.js +379 -0
- package/src/providers/opencode-go.js +274 -0
- package/src/refresh.js +292 -0
- package/src/render/panel.js +411 -0
- package/src/render/theme.js +102 -0
- package/src/sticky.js +319 -0
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cad0p/pi-usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
}
|
|
3
|
+
"version": "0.11.2",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"description": "Read-only usage/quota for the AI subscriptions Pi already owns (Claude, Codex, multi-account OpenCode Go, Antigravity): /usage in Pi and the Omarchy Agents panel. Fork of @j3pf/piquota.",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22.19.0"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"piquota": "./bin/piquota.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"src",
|
|
18
|
+
"extensions",
|
|
19
|
+
"README.md",
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"pi-package",
|
|
25
|
+
"pi",
|
|
26
|
+
"quota",
|
|
27
|
+
"ai-agents",
|
|
28
|
+
"rate-limits",
|
|
29
|
+
"claude",
|
|
30
|
+
"codex",
|
|
31
|
+
"antigravity",
|
|
32
|
+
"opencode",
|
|
33
|
+
"usage",
|
|
34
|
+
"omarchy",
|
|
35
|
+
"opencode-go",
|
|
36
|
+
"multi-account"
|
|
37
|
+
],
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/cad0p/pi-usage.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/cad0p/pi-usage/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/cad0p/pi-usage#readme",
|
|
46
|
+
"pi": {
|
|
47
|
+
"extensions": [
|
|
48
|
+
"./extensions/quota-panel.ts"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"test": "node --test tests/*.test.mjs",
|
|
53
|
+
"quota": "node bin/piquota.js",
|
|
54
|
+
"quota:json": "node bin/piquota.js --json"
|
|
55
|
+
},
|
|
56
|
+
"license": "MIT",
|
|
57
|
+
"contributors": [
|
|
58
|
+
"J3pf (https://github.com/J3pf)",
|
|
59
|
+
"Pier Carlo Cadoppi (https://github.com/cad0p)"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code CLI credentials — the second Claude source.
|
|
3
|
+
*
|
|
4
|
+
* `pi-claude-code-provider` creates a Pi provider that drives the installed
|
|
5
|
+
* `claude` executable. It deliberately stores nothing in Pi's auth store: the
|
|
6
|
+
* subscription token lives in Claude Code's own file, which Claude Code owns and
|
|
7
|
+
* refreshes itself.
|
|
8
|
+
*
|
|
9
|
+
* This module therefore has a stricter contract than `pi-auth.js`:
|
|
10
|
+
*
|
|
11
|
+
* - the file is opened read-only and is never written, moved or truncated;
|
|
12
|
+
* - the refresh token is deliberately *not* carried into the credential, so no
|
|
13
|
+
* later code path can even attempt to rotate it behind Claude Code's back
|
|
14
|
+
* (Anthropic rotates refresh tokens, and a stolen rotation would sign the
|
|
15
|
+
* installed CLI out);
|
|
16
|
+
* - reading never throws: an absent or corrupt store degrades with a message
|
|
17
|
+
* that names the file it looked for.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, rmdirSync, rmSync, statSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { runCommand } from "../exec.js";
|
|
26
|
+
|
|
27
|
+
/** Windows-side profiles reachable from WSL, discovered without spawning cmd.exe. */
|
|
28
|
+
const WINDOWS_USERS_ROOT = "/mnt/c/Users";
|
|
29
|
+
const MAX_WINDOWS_PROFILES = 40;
|
|
30
|
+
|
|
31
|
+
const CREDENTIALS_NAME = ".credentials.json";
|
|
32
|
+
const PROFILE_NAME = ".claude.json";
|
|
33
|
+
|
|
34
|
+
/** macOS: Claude Code keeps the same JSON blob in the login Keychain instead of a file. */
|
|
35
|
+
export const KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_LABEL = "Claude (Pi)";
|
|
38
|
+
export const OAUTH_REFRESH_LOCK_NAME = ".oauth_refresh.lock";
|
|
39
|
+
export const DEFAULT_STALE_LOCK_MS = 120_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} value
|
|
43
|
+
* @returns {string | null}
|
|
44
|
+
*/
|
|
45
|
+
function text(value) {
|
|
46
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {unknown} value
|
|
51
|
+
* @returns {number | null}
|
|
52
|
+
*/
|
|
53
|
+
function epochMs(value) {
|
|
54
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
55
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
56
|
+
const parsed = Number(value);
|
|
57
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
58
|
+
const fromDate = Date.parse(value);
|
|
59
|
+
if (!Number.isNaN(fromDate)) return fromDate;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Non-reversible identity fingerprint. Never a token, never reversible.
|
|
66
|
+
*
|
|
67
|
+
* @param {string[]} parts
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function fingerprint(parts) {
|
|
71
|
+
return createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Config directory Claude Code itself would use.
|
|
76
|
+
*
|
|
77
|
+
* `CLAUDE_CONFIG_DIR` is Claude Code's own documented relocation variable, so a
|
|
78
|
+
* user who moved the CLI's state must be followed rather than second-guessed.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string }} [options]
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
export function resolveClaudeConfigDir(options = {}) {
|
|
84
|
+
const env = options.env ?? process.env;
|
|
85
|
+
return text(env.CLAUDE_CONFIG_DIR) ?? join(options.home ?? homedir(), ".claude");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Every candidate credential path, in priority order, whether or not it exists.
|
|
90
|
+
*
|
|
91
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, platform?: string, usersRoot?: string }} [options]
|
|
92
|
+
* @returns {string[]}
|
|
93
|
+
*/
|
|
94
|
+
export function claudeCodeCandidatePaths(options = {}) {
|
|
95
|
+
const env = options.env ?? process.env;
|
|
96
|
+
const explicit = text(env.PI_QUOTA_CLAUDE_CODE_CREDENTIALS);
|
|
97
|
+
if (explicit) return [explicit];
|
|
98
|
+
|
|
99
|
+
const candidates = [join(resolveClaudeConfigDir(options), CREDENTIALS_NAME)];
|
|
100
|
+
// A relocated config directory is authoritative: do not also probe the default one.
|
|
101
|
+
if (text(env.CLAUDE_CONFIG_DIR)) return candidates;
|
|
102
|
+
|
|
103
|
+
candidates.push(...discoverWindowsClaudeCodePaths({ platform: options.platform, usersRoot: options.usersRoot }));
|
|
104
|
+
return candidates;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Windows-side Claude Code stores reachable from WSL.
|
|
109
|
+
*
|
|
110
|
+
* @param {{ platform?: string, usersRoot?: string }} [options]
|
|
111
|
+
* @returns {string[]}
|
|
112
|
+
*/
|
|
113
|
+
export function discoverWindowsClaudeCodePaths(options = {}) {
|
|
114
|
+
const platform = options.platform ?? process.platform;
|
|
115
|
+
const usersRoot = options.usersRoot ?? WINDOWS_USERS_ROOT;
|
|
116
|
+
if (platform === "win32") return [];
|
|
117
|
+
if (!existsSync(usersRoot)) return [];
|
|
118
|
+
|
|
119
|
+
/** @type {string[]} */
|
|
120
|
+
const found = [];
|
|
121
|
+
let entries = [];
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(usersRoot, { withFileTypes: true });
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let scanned = 0;
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
if (scanned >= MAX_WINDOWS_PROFILES) break;
|
|
131
|
+
if (!entry.isDirectory()) continue;
|
|
132
|
+
const name = entry.name;
|
|
133
|
+
if (name === "Default" || name === "Default User" || name === "All Users" || name === "Public") continue;
|
|
134
|
+
scanned += 1;
|
|
135
|
+
const candidate = join(usersRoot, name, ".claude", CREDENTIALS_NAME);
|
|
136
|
+
if (existsSync(candidate)) found.push(candidate);
|
|
137
|
+
}
|
|
138
|
+
return found;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Candidate paths that actually exist, de-duplicated by real path.
|
|
143
|
+
*
|
|
144
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, platform?: string, usersRoot?: string }} [options]
|
|
145
|
+
* @returns {string[]}
|
|
146
|
+
*/
|
|
147
|
+
export function resolveClaudeCodeCredentialPaths(options = {}) {
|
|
148
|
+
const seen = new Set();
|
|
149
|
+
const out = [];
|
|
150
|
+
for (const path of claudeCodeCandidatePaths(options)) {
|
|
151
|
+
if (!existsSync(path)) continue;
|
|
152
|
+
let key = path;
|
|
153
|
+
try {
|
|
154
|
+
key = realpathSync(path);
|
|
155
|
+
} catch {
|
|
156
|
+
// Keep the unresolved path; existsSync already ran.
|
|
157
|
+
}
|
|
158
|
+
if (seen.has(key)) continue;
|
|
159
|
+
seen.add(key);
|
|
160
|
+
out.push(path);
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Claude Code's own profile file, which carries the account e-mail and the
|
|
167
|
+
* display name. It is a large file (project history lives in it), so only the
|
|
168
|
+
* account fields are ever returned from it.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, profilePath?: string }} [options]
|
|
171
|
+
* @returns {string[]}
|
|
172
|
+
*/
|
|
173
|
+
export function claudeCodeProfilePaths(options = {}) {
|
|
174
|
+
if (options.profilePath) return [options.profilePath];
|
|
175
|
+
const first = join(resolveClaudeConfigDir(options), PROFILE_NAME);
|
|
176
|
+
const fallback = join(options.home ?? homedir(), PROFILE_NAME);
|
|
177
|
+
return fallback === first ? [first] : [first, fallback];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, profilePath?: string }} [options]
|
|
182
|
+
* @returns {{ email?: string, displayName?: string, organizationType?: string }}
|
|
183
|
+
*/
|
|
184
|
+
export function readClaudeCodeProfile(options = {}) {
|
|
185
|
+
for (const path of claudeCodeProfilePaths(options)) {
|
|
186
|
+
if (!existsSync(path)) continue;
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = JSON.parse(readFileSync(path, { encoding: "utf-8", flag: "r" }));
|
|
190
|
+
} catch {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const account = parsed?.oauthAccount;
|
|
194
|
+
if (!account || typeof account !== "object") continue;
|
|
195
|
+
|
|
196
|
+
/** @type {{ email?: string, displayName?: string, organizationType?: string }} */
|
|
197
|
+
const profile = {};
|
|
198
|
+
const email = text(account.emailAddress);
|
|
199
|
+
const displayName = text(account.displayName);
|
|
200
|
+
const organizationType = text(account.organizationType);
|
|
201
|
+
if (email) profile.email = email;
|
|
202
|
+
if (displayName) profile.displayName = displayName;
|
|
203
|
+
if (organizationType) profile.organizationType = organizationType;
|
|
204
|
+
if (Object.keys(profile).length > 0) return profile;
|
|
205
|
+
}
|
|
206
|
+
return {};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Read one Claude Code credential file. Never writes, never throws.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} path
|
|
213
|
+
* @returns {{ ok: true, oauth: Record<string, unknown> } | { ok: false, error: string }}
|
|
214
|
+
*/
|
|
215
|
+
export function readClaudeCodeCredentialFile(path) {
|
|
216
|
+
let parsed;
|
|
217
|
+
try {
|
|
218
|
+
parsed = JSON.parse(readFileSync(path, { encoding: "utf-8", flag: "r" }));
|
|
219
|
+
} catch (error) {
|
|
220
|
+
const code = /** @type {{ code?: string }} */ (error)?.code;
|
|
221
|
+
if (code === "ENOENT") return { ok: false, error: missingMessage(path) };
|
|
222
|
+
const message = /** @type {{ message?: string }} */ (error)?.message ?? String(error);
|
|
223
|
+
return { ok: false, error: `cannot parse ${path}: ${message}` };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const oauth = parsed?.claudeAiOauth;
|
|
227
|
+
if (!oauth || typeof oauth !== "object") {
|
|
228
|
+
return { ok: false, error: `${path} has no claudeAiOauth block; run \`claude\` once to sign in` };
|
|
229
|
+
}
|
|
230
|
+
return { ok: true, oauth: /** @type {Record<string, unknown>} */ (oauth) };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Whether the macOS Keychain is a legitimate Claude Code source for these options.
|
|
235
|
+
*
|
|
236
|
+
* An explicit credentials path or a relocated config directory means the user
|
|
237
|
+
* chose a file; Claude Code also derives a different Keychain service name for a
|
|
238
|
+
* relocated directory, so the default entry would be the wrong account.
|
|
239
|
+
*
|
|
240
|
+
* The login Keychain belongs to the real user: with a substituted `home` (tests,
|
|
241
|
+
* sandboxes) it is skipped unless a reader is injected.
|
|
242
|
+
*
|
|
243
|
+
* @param {{ env?: Record<string, string | undefined>, platform?: string, paths?: string[] | null, home?: string, keychainReader?: () => string | null }} options
|
|
244
|
+
* @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
function keychainApplies(options) {
|
|
247
|
+
const env = options.env ?? process.env;
|
|
248
|
+
if ((options.platform ?? process.platform) !== "darwin") return false;
|
|
249
|
+
if (options.paths) return false;
|
|
250
|
+
if (options.home && options.home !== homedir() && typeof options.keychainReader !== "function") return false;
|
|
251
|
+
return !text(env.PI_QUOTA_CLAUDE_CODE_CREDENTIALS) && !text(env.CLAUDE_CONFIG_DIR);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Read Claude Code's login Keychain entry. Read-only (`find-generic-password`),
|
|
256
|
+
* never throws; the first read may raise macOS's own access prompt.
|
|
257
|
+
*
|
|
258
|
+
* @param {{ keychainReader?: () => string | null }} [options]
|
|
259
|
+
* @returns {{ ok: true, oauth: Record<string, unknown> } | { ok: false, error: string }}
|
|
260
|
+
*/
|
|
261
|
+
export function readClaudeCodeKeychain(options = {}) {
|
|
262
|
+
let raw = null;
|
|
263
|
+
if (typeof options.keychainReader === "function") {
|
|
264
|
+
raw = options.keychainReader();
|
|
265
|
+
} else {
|
|
266
|
+
const result = runCommand("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-w"], { timeoutMs: 10_000 });
|
|
267
|
+
if (result.status === 0) raw = result.stdout.trim();
|
|
268
|
+
}
|
|
269
|
+
if (!raw) return { ok: false, error: `no "${KEYCHAIN_SERVICE}" item in the macOS Keychain (or access was denied)` };
|
|
270
|
+
|
|
271
|
+
let oauth;
|
|
272
|
+
try {
|
|
273
|
+
oauth = JSON.parse(raw)?.claudeAiOauth;
|
|
274
|
+
} catch {
|
|
275
|
+
return { ok: false, error: `the "${KEYCHAIN_SERVICE}" Keychain item is not valid JSON` };
|
|
276
|
+
}
|
|
277
|
+
if (!oauth || typeof oauth !== "object") {
|
|
278
|
+
return { ok: false, error: `the "${KEYCHAIN_SERVICE}" Keychain item has no claudeAiOauth block; run \`claude\` once to sign in` };
|
|
279
|
+
}
|
|
280
|
+
return { ok: true, oauth };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* @param {string | undefined} path
|
|
285
|
+
* @returns {string}
|
|
286
|
+
*/
|
|
287
|
+
function missingMessage(path) {
|
|
288
|
+
return `Claude Code CLI has no readable credentials; looked for ${path ?? "~/.claude/.credentials.json"}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Detect and recover from an abandoned Claude Code OAuth refresh lock.
|
|
293
|
+
*
|
|
294
|
+
* Claude Code creates `.oauth_refresh.lock` only for the few seconds it takes to
|
|
295
|
+
* rotate its tokens with Anthropic. If the CLI is interrupted or killed mid-refresh,
|
|
296
|
+
* that directory remains and causes all future refreshes to fail with:
|
|
297
|
+
* "Failed to refresh OAuth token: another Claude Code process is refreshing it or exited mid-refresh."
|
|
298
|
+
*
|
|
299
|
+
* Any lock older than `staleMs` (default 2 minutes) is safely treated as abandoned,
|
|
300
|
+
* as Claude Code's own internal lock expiration threshold is 60 seconds.
|
|
301
|
+
*
|
|
302
|
+
* @param {string} configDir
|
|
303
|
+
* @param {{ now?: number, staleMs?: number }} [options]
|
|
304
|
+
* @returns {{ recovered: boolean, lockPath: string, ageMs?: number, error?: string }}
|
|
305
|
+
*/
|
|
306
|
+
export function recoverStaleClaudeLock(configDir, options = {}) {
|
|
307
|
+
const lockPath = join(configDir, OAUTH_REFRESH_LOCK_NAME);
|
|
308
|
+
if (!existsSync(lockPath)) return { recovered: false, lockPath };
|
|
309
|
+
|
|
310
|
+
const now = options.now ?? Date.now();
|
|
311
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
const stat = statSync(lockPath);
|
|
315
|
+
const ageMs = Math.max(0, now - stat.mtimeMs);
|
|
316
|
+
if (ageMs < staleMs) {
|
|
317
|
+
return { recovered: false, lockPath, ageMs };
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
rmdirSync(lockPath);
|
|
321
|
+
} catch {
|
|
322
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
323
|
+
}
|
|
324
|
+
return { recovered: true, lockPath, ageMs };
|
|
325
|
+
} catch (error) {
|
|
326
|
+
return {
|
|
327
|
+
recovered: false,
|
|
328
|
+
lockPath,
|
|
329
|
+
error: /** @type {{ message?: string }} */ (error)?.message ?? String(error),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Normalize the Claude Code store into the same credential shape `pi-auth.js`
|
|
336
|
+
* produces, so every downstream surface treats both Claude sources identically.
|
|
337
|
+
*
|
|
338
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, platform?: string, usersRoot?: string, paths?: string[] | null }} [options]
|
|
339
|
+
* @returns {{ ok: true, credential: import("./pi-auth.js").PiCredential & { sourceKind: string, rateLimitTier: string | null }, paths: string[] }
|
|
340
|
+
* | { ok: false, credential: null, error: string, paths: string[] }}
|
|
341
|
+
*/
|
|
342
|
+
export function loadClaudeCodeCredential(options = {}) {
|
|
343
|
+
const paths = options.paths ?? resolveClaudeCodeCredentialPaths(options);
|
|
344
|
+
const useKeychain = keychainApplies(options);
|
|
345
|
+
if (paths.length === 0 && !useKeychain) {
|
|
346
|
+
return { ok: false, credential: null, error: missingMessage(claudeCodeCandidatePaths(options)[0]), paths: [] };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* @param {Record<string, unknown>} oauth
|
|
351
|
+
* @param {string} source
|
|
352
|
+
*/
|
|
353
|
+
const toCredential = (oauth, source) => {
|
|
354
|
+
const access = text(oauth.accessToken);
|
|
355
|
+
if (!access) return null;
|
|
356
|
+
const email = readClaudeCodeProfile(options).email ?? null;
|
|
357
|
+
return {
|
|
358
|
+
provider: "claude-code",
|
|
359
|
+
family: "claude",
|
|
360
|
+
label: DEFAULT_LABEL,
|
|
361
|
+
sourceKind: "claude-code",
|
|
362
|
+
source,
|
|
363
|
+
identity: email ?? fingerprint(["claude-code", access]),
|
|
364
|
+
access,
|
|
365
|
+
// Only the access token's own expiry is exposed. The refresh token and its
|
|
366
|
+
// expiry are never read or carried, because nothing here may act on them.
|
|
367
|
+
expiresAtMs: epochMs(oauth.expiresAt),
|
|
368
|
+
accountId: null,
|
|
369
|
+
projectId: null,
|
|
370
|
+
email,
|
|
371
|
+
planType: text(oauth.subscriptionType),
|
|
372
|
+
rateLimitTier: text(oauth.rateLimitTier),
|
|
373
|
+
kind: "oauth",
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
let lastError = null;
|
|
378
|
+
let recoveredLock = null;
|
|
379
|
+
for (const path of paths) {
|
|
380
|
+
if (options.recoverStaleLock !== false) {
|
|
381
|
+
const recovery = recoverStaleClaudeLock(dirname(path), { now: options.now, staleMs: options.staleMs });
|
|
382
|
+
if (recovery.recovered) recoveredLock = recovery;
|
|
383
|
+
}
|
|
384
|
+
const read = readClaudeCodeCredentialFile(path);
|
|
385
|
+
if (!read.ok) {
|
|
386
|
+
lastError = read.error;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const credential = toCredential(read.oauth, path);
|
|
391
|
+
if (!credential) {
|
|
392
|
+
return { ok: false, credential: null, error: `no Claude Code access token in ${path}; run \`claude\` once to sign in`, paths, recoveredLock };
|
|
393
|
+
}
|
|
394
|
+
return { ok: true, paths, recoveredLock, credential };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (useKeychain) {
|
|
398
|
+
const read = readClaudeCodeKeychain(options);
|
|
399
|
+
if (read.ok) {
|
|
400
|
+
const credential = toCredential(read.oauth, `macOS Keychain (${KEYCHAIN_SERVICE})`);
|
|
401
|
+
if (credential) return { ok: true, paths, recoveredLock, credential };
|
|
402
|
+
lastError = `no Claude Code access token in the macOS Keychain item "${KEYCHAIN_SERVICE}"; run \`claude\` once to sign in`;
|
|
403
|
+
} else {
|
|
404
|
+
lastError = lastError ?? read.error;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { ok: false, credential: null, error: lastError ?? missingMessage(claudeCodeCandidatePaths(options)[0]), paths, recoveredLock };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Diagnostics for `piquota --explain`: names only, never token material.
|
|
413
|
+
*
|
|
414
|
+
* @param {{ env?: Record<string, string | undefined>, home?: string, platform?: string, usersRoot?: string, paths?: string[] | null }} [options]
|
|
415
|
+
* @returns {{ ok: boolean, paths: string[], path: string | null, hasAccessToken: boolean, hasRefreshToken: boolean, expiresInMin: number | null, plan: string | null, error: string | null }}
|
|
416
|
+
*/
|
|
417
|
+
export function describeClaudeCodeSource(options = {}) {
|
|
418
|
+
const paths = options.paths ?? resolveClaudeCodeCredentialPaths(options);
|
|
419
|
+
const path = paths[0] ?? null;
|
|
420
|
+
const configDir = path ? dirname(path) : resolveClaudeConfigDir(options);
|
|
421
|
+
const lockPath = join(configDir, OAUTH_REFRESH_LOCK_NAME);
|
|
422
|
+
let hasStaleLock = false;
|
|
423
|
+
let lockAgeSec = null;
|
|
424
|
+
if (existsSync(lockPath)) {
|
|
425
|
+
try {
|
|
426
|
+
const stat = statSync(lockPath);
|
|
427
|
+
const ageMs = (options.now ?? Date.now()) - stat.mtimeMs;
|
|
428
|
+
lockAgeSec = Math.max(0, Math.round(ageMs / 1000));
|
|
429
|
+
hasStaleLock = ageMs >= (options.staleMs ?? DEFAULT_STALE_LOCK_MS);
|
|
430
|
+
} catch {
|
|
431
|
+
// Degrade gracefully if stat fails
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** @type {{ ok: boolean, paths: string[], path: string | null, hasAccessToken: boolean, hasRefreshToken: boolean, hasStaleLock: boolean, lockAgeSec: number | null, expiresInMin: number | null, plan: string | null, error: string | null }} */
|
|
436
|
+
const described = {
|
|
437
|
+
ok: path !== null,
|
|
438
|
+
paths,
|
|
439
|
+
path,
|
|
440
|
+
hasAccessToken: false,
|
|
441
|
+
hasRefreshToken: false,
|
|
442
|
+
hasStaleLock,
|
|
443
|
+
lockAgeSec,
|
|
444
|
+
expiresInMin: null,
|
|
445
|
+
plan: null,
|
|
446
|
+
error: path === null ? missingMessage(claudeCodeCandidatePaths(options)[0]) : null,
|
|
447
|
+
};
|
|
448
|
+
if (path === null && keychainApplies(options)) {
|
|
449
|
+
const keychain = readClaudeCodeKeychain(options);
|
|
450
|
+
if (keychain.ok) {
|
|
451
|
+
described.ok = true;
|
|
452
|
+
described.path = `macOS Keychain (${KEYCHAIN_SERVICE})`;
|
|
453
|
+
described.error = null;
|
|
454
|
+
described.hasAccessToken = text(keychain.oauth.accessToken) !== null;
|
|
455
|
+
described.hasRefreshToken = text(keychain.oauth.refreshToken) !== null;
|
|
456
|
+
described.plan = text(keychain.oauth.subscriptionType);
|
|
457
|
+
const expiresAtMs = epochMs(keychain.oauth.expiresAt);
|
|
458
|
+
described.expiresInMin = expiresAtMs === null ? null : Math.round((expiresAtMs - Date.now()) / 60000);
|
|
459
|
+
if (!described.hasAccessToken) {
|
|
460
|
+
described.ok = false;
|
|
461
|
+
described.error = `no Claude Code access token in the macOS Keychain item "${KEYCHAIN_SERVICE}"`;
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
described.error = keychain.error;
|
|
465
|
+
}
|
|
466
|
+
return described;
|
|
467
|
+
}
|
|
468
|
+
if (path === null) return described;
|
|
469
|
+
|
|
470
|
+
const read = readClaudeCodeCredentialFile(path);
|
|
471
|
+
if (!read.ok) {
|
|
472
|
+
described.ok = false;
|
|
473
|
+
described.error = read.error;
|
|
474
|
+
return described;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const hasAccessToken = text(read.oauth.accessToken) !== null;
|
|
478
|
+
described.hasAccessToken = hasAccessToken;
|
|
479
|
+
// A boolean only: whether Claude Code holds a refresh token is useful to know,
|
|
480
|
+
// its value never leaves the file.
|
|
481
|
+
described.hasRefreshToken = text(read.oauth.refreshToken) !== null;
|
|
482
|
+
described.plan = text(read.oauth.subscriptionType);
|
|
483
|
+
const expiresAtMs = epochMs(read.oauth.expiresAt);
|
|
484
|
+
described.expiresInMin = expiresAtMs === null ? null : Math.round((expiresAtMs - Date.now()) / 60000);
|
|
485
|
+
if (!hasAccessToken) {
|
|
486
|
+
described.ok = false;
|
|
487
|
+
described.error = `no Claude Code access token in ${path}; run \`claude\` once to sign in`;
|
|
488
|
+
}
|
|
489
|
+
return described;
|
|
490
|
+
}
|
package/src/auth/jwt.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only JWT payload decoding.
|
|
3
|
+
*
|
|
4
|
+
* Pi stores the OpenAI Codex access token as a signed JWT whose payload already
|
|
5
|
+
* carries the account id, plan type and email. We decode the payload for
|
|
6
|
+
* identification only; the signature is never verified and the token is never
|
|
7
|
+
* rewritten.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} token
|
|
12
|
+
* @returns {Record<string, unknown> | null}
|
|
13
|
+
*/
|
|
14
|
+
export function decodeJwtPayload(token) {
|
|
15
|
+
if (typeof token !== "string") return null;
|
|
16
|
+
const parts = token.split(".");
|
|
17
|
+
if (parts.length < 2) return null;
|
|
18
|
+
try {
|
|
19
|
+
const padded = parts[1] + "=".repeat((4 - (parts[1].length % 4)) % 4);
|
|
20
|
+
const json = Buffer.from(padded, "base64url").toString("utf8");
|
|
21
|
+
const parsed = JSON.parse(json);
|
|
22
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read a nested field from the payload. Keys are passed as a list on purpose:
|
|
30
|
+
* OAuth claim names contain dots (`https://api.openai.com/auth`), so splitting a
|
|
31
|
+
* dotted path would break on the very keys we need.
|
|
32
|
+
*
|
|
33
|
+
* @param {Record<string, unknown> | null} source
|
|
34
|
+
* @param {string[]} keys
|
|
35
|
+
* @returns {unknown}
|
|
36
|
+
*/
|
|
37
|
+
function readPath(source, keys) {
|
|
38
|
+
if (!source) return undefined;
|
|
39
|
+
return keys.reduce((current, key) => {
|
|
40
|
+
if (current && typeof current === "object") {
|
|
41
|
+
return /** @type {Record<string, unknown>} */ (current)[key];
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}, /** @type {unknown} */ (source));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
48
|
+
const OPENAI_PROFILE_CLAIM = "https://api.openai.com/profile";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} token
|
|
52
|
+
* @returns {string | null}
|
|
53
|
+
*/
|
|
54
|
+
export function extractChatGptAccountId(token) {
|
|
55
|
+
const value = readPath(decodeJwtPayload(token), [OPENAI_AUTH_CLAIM, "chatgpt_account_id"]);
|
|
56
|
+
return typeof value === "string" && value ? value : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string} token
|
|
61
|
+
* @returns {string | null}
|
|
62
|
+
*/
|
|
63
|
+
export function extractChatGptPlanType(token) {
|
|
64
|
+
const value = readPath(decodeJwtPayload(token), [OPENAI_AUTH_CLAIM, "chatgpt_plan_type"]);
|
|
65
|
+
return typeof value === "string" && value ? value : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {string} token
|
|
70
|
+
* @returns {string | null}
|
|
71
|
+
*/
|
|
72
|
+
export function extractEmail(token) {
|
|
73
|
+
const value = readPath(decodeJwtPayload(token), [OPENAI_PROFILE_CLAIM, "email"]);
|
|
74
|
+
return typeof value === "string" && value ? value : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} token
|
|
79
|
+
* @returns {number | null} Expiry as epoch milliseconds.
|
|
80
|
+
*/
|
|
81
|
+
export function extractExpiresAtMs(token) {
|
|
82
|
+
const value = readPath(decodeJwtPayload(token), ["exp"]);
|
|
83
|
+
return typeof value === "number" && Number.isFinite(value) ? value * 1000 : null;
|
|
84
|
+
}
|