codex-team 0.0.1

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/dist/main.js ADDED
@@ -0,0 +1,1227 @@
1
+ import { stderr, stdin, stdout as external_node_process_stdout } from "node:process";
2
+ import dayjs from "dayjs";
3
+ import timezone from "dayjs/plugin/timezone.js";
4
+ import utc from "dayjs/plugin/utc.js";
5
+ import { chmod, copyFile, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ import { basename, dirname, join } from "node:path";
8
+ import { execFile } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+ function isRecord(value) {
11
+ return "object" == typeof value && null !== value && !Array.isArray(value);
12
+ }
13
+ function asNonEmptyString(value, fieldName) {
14
+ if ("string" != typeof value || "" === value.trim()) throw new Error(`Field "${fieldName}" must be a non-empty string.`);
15
+ return value;
16
+ }
17
+ function asOptionalString(value, fieldName) {
18
+ if (null == value) return;
19
+ return asNonEmptyString(value, fieldName);
20
+ }
21
+ function asOptionalBoolean(value, fieldName) {
22
+ if (null == value) return;
23
+ if ("boolean" != typeof value) throw new Error(`Field "${fieldName}" must be a boolean.`);
24
+ return value;
25
+ }
26
+ function asOptionalNumber(value, fieldName) {
27
+ if (null == value) return;
28
+ if ("number" != typeof value || Number.isNaN(value)) throw new Error(`Field "${fieldName}" must be a number.`);
29
+ return value;
30
+ }
31
+ function defaultQuotaSnapshot() {
32
+ return {
33
+ status: "stale"
34
+ };
35
+ }
36
+ function parseQuotaSnapshot(raw) {
37
+ if (null == raw) return defaultQuotaSnapshot();
38
+ if (!isRecord(raw)) throw new Error('Field "quota" must be an object.');
39
+ const status = raw.status;
40
+ if ("ok" !== status && "stale" !== status && "error" !== status && "unsupported" !== status) throw new Error('Field "quota.status" must be one of ok/stale/error/unsupported.');
41
+ return {
42
+ status,
43
+ plan_type: asOptionalString(raw.plan_type, "quota.plan_type"),
44
+ credits_balance: asOptionalNumber(raw.credits_balance, "quota.credits_balance"),
45
+ fetched_at: asOptionalString(raw.fetched_at, "quota.fetched_at"),
46
+ error_message: asOptionalString(raw.error_message, "quota.error_message"),
47
+ unlimited: asOptionalBoolean(raw.unlimited, "quota.unlimited"),
48
+ five_hour: parseQuotaWindowSnapshot(raw.five_hour, "quota.five_hour"),
49
+ one_week: parseQuotaWindowSnapshot(raw.one_week, "quota.one_week")
50
+ };
51
+ }
52
+ function parseQuotaWindowSnapshot(raw, fieldName) {
53
+ if (null == raw) return;
54
+ if (!isRecord(raw)) throw new Error(`Field "${fieldName}" must be an object.`);
55
+ return {
56
+ used_percent: asNonEmptyNumber(raw.used_percent, `${fieldName}.used_percent`),
57
+ window_seconds: asNonEmptyNumber(raw.window_seconds, `${fieldName}.window_seconds`),
58
+ reset_after_seconds: asOptionalNumber(raw.reset_after_seconds, `${fieldName}.reset_after_seconds`),
59
+ reset_at: asOptionalString(raw.reset_at, `${fieldName}.reset_at`)
60
+ };
61
+ }
62
+ function asNonEmptyNumber(value, fieldName) {
63
+ if ("number" != typeof value || Number.isNaN(value)) throw new Error(`Field "${fieldName}" must be a number.`);
64
+ return value;
65
+ }
66
+ function parseAuthSnapshot(raw) {
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(raw);
70
+ } catch (error) {
71
+ throw new Error(`Failed to parse auth snapshot JSON: ${error.message}`);
72
+ }
73
+ if (!isRecord(parsed)) throw new Error("Auth snapshot must be a JSON object.");
74
+ const authMode = asNonEmptyString(parsed.auth_mode, "auth_mode");
75
+ if (!isRecord(parsed.tokens)) throw new Error('Field "tokens" must be an object.');
76
+ const accountId = asNonEmptyString(parsed.tokens.account_id, "tokens.account_id");
77
+ return {
78
+ ...parsed,
79
+ auth_mode: authMode,
80
+ tokens: {
81
+ ...parsed.tokens,
82
+ account_id: accountId
83
+ }
84
+ };
85
+ }
86
+ async function readAuthSnapshotFile(filePath) {
87
+ const raw = await readFile(filePath, "utf8");
88
+ return parseAuthSnapshot(raw);
89
+ }
90
+ function createSnapshotMeta(name, snapshot, now, existingCreatedAt) {
91
+ const timestamp = now.toISOString();
92
+ return {
93
+ name,
94
+ auth_mode: snapshot.auth_mode,
95
+ account_id: snapshot.tokens.account_id,
96
+ created_at: existingCreatedAt ?? timestamp,
97
+ updated_at: timestamp,
98
+ last_switched_at: null,
99
+ quota: defaultQuotaSnapshot()
100
+ };
101
+ }
102
+ function parseSnapshotMeta(raw) {
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(raw);
106
+ } catch (error) {
107
+ throw new Error(`Failed to parse account metadata JSON: ${error.message}`);
108
+ }
109
+ if (!isRecord(parsed)) throw new Error("Account metadata must be a JSON object.");
110
+ const lastSwitchedAt = parsed.last_switched_at;
111
+ if (null !== lastSwitchedAt && "string" != typeof lastSwitchedAt) throw new Error('Field "last_switched_at" must be a string or null.');
112
+ return {
113
+ name: asNonEmptyString(parsed.name, "name"),
114
+ auth_mode: asNonEmptyString(parsed.auth_mode, "auth_mode"),
115
+ account_id: asNonEmptyString(parsed.account_id, "account_id"),
116
+ created_at: asNonEmptyString(parsed.created_at, "created_at"),
117
+ updated_at: asNonEmptyString(parsed.updated_at, "updated_at"),
118
+ last_switched_at: lastSwitchedAt,
119
+ quota: parseQuotaSnapshot(parsed.quota)
120
+ };
121
+ }
122
+ function maskAccountId(accountId) {
123
+ if (accountId.length <= 10) return accountId;
124
+ return `${accountId.slice(0, 6)}...${accountId.slice(-4)}`;
125
+ }
126
+ function decodeJwtPayload(token) {
127
+ const payload = token.split(".")[1];
128
+ if (!payload) throw new Error("Token payload is missing.");
129
+ const padded = payload.padEnd(payload.length + (4 - payload.length % 4) % 4, "=");
130
+ const decoded = Buffer.from(padded, "base64url").toString("utf8");
131
+ const parsed = JSON.parse(decoded);
132
+ if (!isRecord(parsed)) throw new Error("Token payload must be a JSON object.");
133
+ return parsed;
134
+ }
135
+ const DEFAULT_CHATGPT_BASE_URL = "https://chatgpt.com";
136
+ const USER_AGENT = "codexm/0.1";
137
+ function quota_client_isRecord(value) {
138
+ return "object" == typeof value && null !== value && !Array.isArray(value);
139
+ }
140
+ function extractAuthClaim(payload) {
141
+ const value = payload["https://api.openai.com/auth"];
142
+ return quota_client_isRecord(value) ? value : void 0;
143
+ }
144
+ function extractStringClaim(payload, key) {
145
+ const value = payload[key];
146
+ return "string" == typeof value && "" !== value.trim() ? value : void 0;
147
+ }
148
+ function isSupportedChatGPTMode(authMode) {
149
+ const normalized = authMode.trim().toLowerCase();
150
+ return "chatgpt" === normalized || "chatgpt_auth_tokens" === normalized;
151
+ }
152
+ function parsePlanType(snapshot) {
153
+ for (const tokenName of [
154
+ "id_token",
155
+ "access_token"
156
+ ]){
157
+ const token = snapshot.tokens[tokenName];
158
+ if ("string" == typeof token && "" !== token.trim()) try {
159
+ const payload = decodeJwtPayload(token);
160
+ const authClaim = extractAuthClaim(payload);
161
+ const planType = authClaim?.chatgpt_plan_type;
162
+ if ("string" == typeof planType && "" !== planType.trim()) return planType;
163
+ } catch {}
164
+ }
165
+ }
166
+ function extractChatGPTAuth(snapshot) {
167
+ const authMode = snapshot.auth_mode ?? "";
168
+ const supported = isSupportedChatGPTMode(authMode);
169
+ const accessTokenValue = snapshot.tokens.access_token;
170
+ const refreshTokenValue = snapshot.tokens.refresh_token;
171
+ const directAccountId = snapshot.tokens.account_id;
172
+ let accountId = "string" == typeof directAccountId && "" !== directAccountId.trim() ? directAccountId : void 0;
173
+ let planType;
174
+ let issuer;
175
+ let clientId;
176
+ for (const tokenName of [
177
+ "id_token",
178
+ "access_token"
179
+ ]){
180
+ const token = snapshot.tokens[tokenName];
181
+ if ("string" == typeof token && "" !== token.trim()) try {
182
+ const payload = decodeJwtPayload(token);
183
+ const authClaim = extractAuthClaim(payload);
184
+ if (!accountId) {
185
+ const maybeAccountId = authClaim?.chatgpt_account_id;
186
+ if ("string" == typeof maybeAccountId && "" !== maybeAccountId.trim()) accountId = maybeAccountId;
187
+ }
188
+ if (!planType) {
189
+ const maybePlanType = authClaim?.chatgpt_plan_type;
190
+ if ("string" == typeof maybePlanType && "" !== maybePlanType.trim()) planType = maybePlanType;
191
+ }
192
+ issuer ??= extractStringClaim(payload, "iss");
193
+ clientId ??= extractStringClaim(payload, "client_id") ?? extractStringClaim(payload, "azp") ?? ("string" == typeof payload.aud ? payload.aud : void 0);
194
+ } catch {}
195
+ }
196
+ if (!supported) return {
197
+ accessToken: "string" == typeof accessTokenValue ? accessTokenValue : "",
198
+ accountId: accountId ?? "",
199
+ refreshToken: "string" == typeof refreshTokenValue && "" !== refreshTokenValue.trim() ? refreshTokenValue : void 0,
200
+ planType,
201
+ issuer,
202
+ clientId,
203
+ supported: false
204
+ };
205
+ if ("string" != typeof accessTokenValue || "" === accessTokenValue.trim()) throw new Error("auth.json is missing access_token.");
206
+ if (!accountId) throw new Error("auth.json is missing ChatGPT account_id.");
207
+ return {
208
+ accessToken: accessTokenValue,
209
+ accountId,
210
+ refreshToken: "string" == typeof refreshTokenValue && "" !== refreshTokenValue.trim() ? refreshTokenValue : void 0,
211
+ planType,
212
+ issuer,
213
+ clientId,
214
+ supported: true
215
+ };
216
+ }
217
+ async function readChatGPTBaseUrl(homeDir) {
218
+ if (!homeDir) return DEFAULT_CHATGPT_BASE_URL;
219
+ try {
220
+ const config = await readFile(join(homeDir, ".codex", "config.toml"), "utf8");
221
+ for (const line of config.split(/\r?\n/u)){
222
+ const trimmed = line.trim();
223
+ if (!trimmed.startsWith("chatgpt_base_url")) continue;
224
+ const [, rawValue] = trimmed.split("=", 2);
225
+ const value = rawValue?.trim().replace(/^['"]|['"]$/gu, "");
226
+ if (value) return value.replace(/\/+$/u, "");
227
+ }
228
+ } catch {}
229
+ return DEFAULT_CHATGPT_BASE_URL;
230
+ }
231
+ async function resolveUsageUrls(homeDir) {
232
+ const baseUrl = await readChatGPTBaseUrl(homeDir);
233
+ const normalizedBaseUrl = baseUrl.replace(/\/+$/u, "");
234
+ const candidates = [
235
+ `${normalizedBaseUrl}/backend-api/wham/usage`,
236
+ `${normalizedBaseUrl}/wham/usage`,
237
+ `${normalizedBaseUrl}/api/codex/usage`,
238
+ "https://chatgpt.com/backend-api/wham/usage"
239
+ ];
240
+ return [
241
+ ...new Set(candidates)
242
+ ];
243
+ }
244
+ function normalizeFetchError(error) {
245
+ return error instanceof Error ? error.message : String(error);
246
+ }
247
+ function shouldRetryWithTokenRefresh(message) {
248
+ const normalized = message.toLowerCase();
249
+ return normalized.includes("401") || normalized.includes("403") || normalized.includes("unauthorized") || normalized.includes("invalid_token") || normalized.includes("deactivated_workspace");
250
+ }
251
+ function parseCreditsBalance(balance) {
252
+ if (null == balance) return;
253
+ const normalized = balance.trim().toLowerCase();
254
+ if ("" === normalized || "null" === normalized || "none" === normalized || "nan" === normalized) return;
255
+ const numeric = Number.parseFloat(balance);
256
+ if (Number.isFinite(numeric)) return numeric;
257
+ throw new Error(`Invalid credits balance "${balance}".`);
258
+ }
259
+ function mapUsagePayload(payload, fallbackPlanType, fetchedAt) {
260
+ if (!payload.credits) throw new Error('Usage response is missing the "credits" field.');
261
+ const windows = collectUsageWindows(payload);
262
+ return {
263
+ status: "ok",
264
+ plan_type: payload.plan_type ?? fallbackPlanType,
265
+ credits_balance: parseCreditsBalance(payload.credits.balance),
266
+ fetched_at: fetchedAt,
267
+ unlimited: true === payload.credits.unlimited,
268
+ five_hour: pickNearestWindow(windows, 18000),
269
+ one_week: pickNearestWindow(windows, 604800)
270
+ };
271
+ }
272
+ function collectUsageWindows(payload) {
273
+ const windows = [];
274
+ const pushRateLimit = (rateLimit)=>{
275
+ if (!rateLimit) return;
276
+ if (rateLimit.primary_window) windows.push(rateLimit.primary_window);
277
+ if (rateLimit.secondary_window) windows.push(rateLimit.secondary_window);
278
+ };
279
+ pushRateLimit(payload.rate_limit);
280
+ for (const additional of payload.additional_rate_limits ?? [])pushRateLimit(additional.rate_limit);
281
+ return windows;
282
+ }
283
+ function pickNearestWindow(windows, targetSeconds) {
284
+ const nearest = windows.reduce((best, current)=>{
285
+ if (!best) return current;
286
+ return Math.abs(current.limit_window_seconds - targetSeconds) < Math.abs(best.limit_window_seconds - targetSeconds) ? current : best;
287
+ }, void 0);
288
+ if (!nearest) return;
289
+ return {
290
+ used_percent: nearest.used_percent,
291
+ window_seconds: nearest.limit_window_seconds,
292
+ reset_after_seconds: nearest.reset_after_seconds,
293
+ reset_at: "number" == typeof nearest.reset_at ? new Date(1000 * nearest.reset_at).toISOString() : void 0
294
+ };
295
+ }
296
+ async function requestUsage(snapshot, options) {
297
+ const fetchImpl = options.fetchImpl ?? fetch;
298
+ const extracted = extractChatGPTAuth(snapshot);
299
+ const urls = await resolveUsageUrls(options.homeDir);
300
+ const now = (options.now ?? new Date()).toISOString();
301
+ const errors = [];
302
+ for (const url of urls){
303
+ let response;
304
+ try {
305
+ response = await fetchImpl(url, {
306
+ method: "GET",
307
+ headers: {
308
+ Authorization: `Bearer ${extracted.accessToken}`,
309
+ "ChatGPT-Account-Id": extracted.accountId,
310
+ Accept: "application/json",
311
+ "User-Agent": USER_AGENT
312
+ }
313
+ });
314
+ } catch (error) {
315
+ errors.push(`${url} -> ${normalizeFetchError(error)}`);
316
+ continue;
317
+ }
318
+ if (!response.ok) {
319
+ const body = await response.text();
320
+ errors.push(`${url} -> ${response.status}: ${body.slice(0, 140).replace(/\s+/gu, " ").trim()}`);
321
+ continue;
322
+ }
323
+ let payload;
324
+ try {
325
+ payload = await response.json();
326
+ } catch (error) {
327
+ errors.push(`${url} -> failed to parse JSON: ${normalizeFetchError(error)}`);
328
+ continue;
329
+ }
330
+ return mapUsagePayload(payload, extracted.planType, now);
331
+ }
332
+ throw new Error(0 === errors.length ? "Usage request failed: no candidate URL was attempted." : `Usage request failed: ${errors.join(" | ")}`);
333
+ }
334
+ async function refreshChatGPTAuthTokens(snapshot, options) {
335
+ const fetchImpl = options.fetchImpl ?? fetch;
336
+ const extracted = extractChatGPTAuth(snapshot);
337
+ if (!extracted.refreshToken) throw new Error("auth.json is missing refresh_token.");
338
+ const tokenUrl = `${(extracted.issuer ?? "https://auth.openai.com").replace(/\/+$/u, "")}/oauth/token`;
339
+ const body = new URLSearchParams({
340
+ grant_type: "refresh_token",
341
+ refresh_token: extracted.refreshToken
342
+ });
343
+ if (extracted.clientId) body.set("client_id", extracted.clientId);
344
+ const response = await fetchImpl(tokenUrl, {
345
+ method: "POST",
346
+ headers: {
347
+ "Content-Type": "application/x-www-form-urlencoded",
348
+ Accept: "application/json",
349
+ "User-Agent": USER_AGENT
350
+ },
351
+ body
352
+ });
353
+ if (!response.ok) {
354
+ const errorText = await response.text();
355
+ throw new Error(`Token refresh failed: ${response.status} ${errorText.slice(0, 140).replace(/\s+/gu, " ").trim()}`);
356
+ }
357
+ const payload = await response.json();
358
+ const nextSnapshot = {
359
+ ...snapshot,
360
+ last_refresh: (options.now ?? new Date()).toISOString(),
361
+ tokens: {
362
+ ...snapshot.tokens,
363
+ access_token: payload.access_token,
364
+ id_token: payload.id_token,
365
+ refresh_token: payload.refresh_token ?? extracted.refreshToken,
366
+ account_id: extracted.accountId
367
+ }
368
+ };
369
+ return nextSnapshot;
370
+ }
371
+ async function fetchQuotaSnapshot(snapshot, options = {}) {
372
+ const fetchedAt = (options.now ?? new Date()).toISOString();
373
+ const extracted = extractChatGPTAuth(snapshot);
374
+ if (!extracted.supported) return {
375
+ quota: {
376
+ status: "unsupported",
377
+ plan_type: extracted.planType ?? parsePlanType(snapshot),
378
+ fetched_at: fetchedAt
379
+ },
380
+ authSnapshot: snapshot
381
+ };
382
+ try {
383
+ return {
384
+ quota: await requestUsage(snapshot, options),
385
+ authSnapshot: snapshot
386
+ };
387
+ } catch (error) {
388
+ const message = normalizeFetchError(error);
389
+ if (!extracted.refreshToken || !shouldRetryWithTokenRefresh(message)) throw error;
390
+ const refreshedSnapshot = await refreshChatGPTAuthTokens(snapshot, options);
391
+ return {
392
+ quota: await requestUsage(refreshedSnapshot, options),
393
+ authSnapshot: refreshedSnapshot
394
+ };
395
+ }
396
+ }
397
+ const account_store_execFile = promisify(execFile);
398
+ const DIRECTORY_MODE = 448;
399
+ const FILE_MODE = 384;
400
+ const SCHEMA_VERSION = 1;
401
+ const ACCOUNT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
402
+ function defaultPaths(homeDir = homedir()) {
403
+ const codexDir = join(homeDir, ".codex");
404
+ const codexTeamDir = join(homeDir, ".codex-team");
405
+ return {
406
+ homeDir,
407
+ codexDir,
408
+ codexTeamDir,
409
+ currentAuthPath: join(codexDir, "auth.json"),
410
+ accountsDir: join(codexTeamDir, "accounts"),
411
+ backupsDir: join(codexTeamDir, "backups"),
412
+ statePath: join(codexTeamDir, "state.json")
413
+ };
414
+ }
415
+ async function chmodIfPossible(path, mode) {
416
+ try {
417
+ await chmod(path, mode);
418
+ } catch (error) {
419
+ const nodeError = error;
420
+ if ("ENOENT" !== nodeError.code) throw error;
421
+ }
422
+ }
423
+ async function ensureDirectory(path, mode) {
424
+ await mkdir(path, {
425
+ recursive: true,
426
+ mode
427
+ });
428
+ await chmodIfPossible(path, mode);
429
+ }
430
+ async function atomicWriteFile(path, content, mode = FILE_MODE) {
431
+ const directory = dirname(path);
432
+ const tempPath = join(directory, `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
433
+ await ensureDirectory(directory, DIRECTORY_MODE);
434
+ await writeFile(tempPath, content, {
435
+ encoding: "utf8",
436
+ mode
437
+ });
438
+ await chmodIfPossible(tempPath, mode);
439
+ await rename(tempPath, path);
440
+ await chmodIfPossible(path, mode);
441
+ }
442
+ function stringifyJson(value) {
443
+ return `${JSON.stringify(value, null, 2)}\n`;
444
+ }
445
+ function ensureAccountName(name) {
446
+ if (!ACCOUNT_NAME_PATTERN.test(name)) throw new Error('Account name must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ and cannot contain path separators.');
447
+ }
448
+ async function pathExists(path) {
449
+ try {
450
+ await stat(path);
451
+ return true;
452
+ } catch (error) {
453
+ const nodeError = error;
454
+ if ("ENOENT" === nodeError.code) return false;
455
+ throw error;
456
+ }
457
+ }
458
+ async function readJsonFile(path) {
459
+ return readFile(path, "utf8");
460
+ }
461
+ async function detectRunningCodexProcesses() {
462
+ try {
463
+ const { stdout } = await account_store_execFile("ps", [
464
+ "-Ao",
465
+ "pid=,command="
466
+ ]);
467
+ const pids = [];
468
+ for (const line of stdout.split("\n")){
469
+ const match = line.trim().match(/^(\d+)\s+(.+)$/);
470
+ if (!match) continue;
471
+ const pid = Number(match[1]);
472
+ const command = match[2];
473
+ if (pid !== process.pid && /(^|\s|\/)codex(\s|$)/.test(command) && !command.includes("codex-team")) pids.push(pid);
474
+ }
475
+ return pids;
476
+ } catch {
477
+ return [];
478
+ }
479
+ }
480
+ class AccountStore {
481
+ paths;
482
+ fetchImpl;
483
+ constructor(paths){
484
+ const resolved = defaultPaths(paths?.homeDir);
485
+ this.paths = {
486
+ ...resolved,
487
+ ...paths,
488
+ homeDir: paths?.homeDir ?? resolved.homeDir
489
+ };
490
+ this.fetchImpl = paths?.fetchImpl;
491
+ }
492
+ accountDirectory(name) {
493
+ ensureAccountName(name);
494
+ return join(this.paths.accountsDir, name);
495
+ }
496
+ accountAuthPath(name) {
497
+ return join(this.accountDirectory(name), "auth.json");
498
+ }
499
+ accountMetaPath(name) {
500
+ return join(this.accountDirectory(name), "meta.json");
501
+ }
502
+ async writeAccountAuthSnapshot(name, snapshot) {
503
+ await atomicWriteFile(this.accountAuthPath(name), stringifyJson(snapshot));
504
+ }
505
+ async writeAccountMeta(name, meta) {
506
+ await atomicWriteFile(this.accountMetaPath(name), stringifyJson(meta));
507
+ }
508
+ async syncCurrentAuthIfMatching(snapshot) {
509
+ if (!await pathExists(this.paths.currentAuthPath)) return;
510
+ try {
511
+ const currentSnapshot = await readAuthSnapshotFile(this.paths.currentAuthPath);
512
+ if (currentSnapshot.tokens.account_id !== snapshot.tokens.account_id) return;
513
+ await atomicWriteFile(this.paths.currentAuthPath, stringifyJson(snapshot));
514
+ } catch {}
515
+ }
516
+ async quotaSummaryForAccount(account) {
517
+ let planType = account.quota.plan_type ?? null;
518
+ try {
519
+ const snapshot = await readAuthSnapshotFile(account.authPath);
520
+ const extracted = extractChatGPTAuth(snapshot);
521
+ planType ??= extracted.planType ?? null;
522
+ } catch {}
523
+ return {
524
+ name: account.name,
525
+ account_id: account.account_id,
526
+ plan_type: planType,
527
+ credits_balance: account.quota.credits_balance ?? null,
528
+ status: account.quota.status,
529
+ fetched_at: account.quota.fetched_at ?? null,
530
+ error_message: account.quota.error_message ?? null,
531
+ unlimited: true === account.quota.unlimited,
532
+ five_hour: account.quota.five_hour ?? null,
533
+ one_week: account.quota.one_week ?? null
534
+ };
535
+ }
536
+ async ensureLayout() {
537
+ await ensureDirectory(this.paths.codexTeamDir, DIRECTORY_MODE);
538
+ await ensureDirectory(this.paths.accountsDir, DIRECTORY_MODE);
539
+ await ensureDirectory(this.paths.backupsDir, DIRECTORY_MODE);
540
+ }
541
+ async readState() {
542
+ if (!await pathExists(this.paths.statePath)) return {
543
+ schema_version: SCHEMA_VERSION,
544
+ last_switched_account: null,
545
+ last_backup_path: null
546
+ };
547
+ const raw = await readJsonFile(this.paths.statePath);
548
+ const parsed = JSON.parse(raw);
549
+ return {
550
+ schema_version: parsed.schema_version ?? SCHEMA_VERSION,
551
+ last_switched_account: parsed.last_switched_account ?? null,
552
+ last_backup_path: parsed.last_backup_path ?? null
553
+ };
554
+ }
555
+ async writeState(state) {
556
+ await this.ensureLayout();
557
+ await atomicWriteFile(this.paths.statePath, stringifyJson(state));
558
+ }
559
+ async readManagedAccount(name) {
560
+ const metaPath = this.accountMetaPath(name);
561
+ const authPath = this.accountAuthPath(name);
562
+ const [rawMeta, snapshot] = await Promise.all([
563
+ readJsonFile(metaPath),
564
+ readAuthSnapshotFile(authPath)
565
+ ]);
566
+ const meta = parseSnapshotMeta(rawMeta);
567
+ if (meta.name !== name) throw new Error(`Account metadata name mismatch for "${name}".`);
568
+ if (meta.account_id !== snapshot.tokens.account_id) throw new Error(`Account metadata account_id mismatch for "${name}".`);
569
+ return {
570
+ ...meta,
571
+ authPath,
572
+ metaPath,
573
+ duplicateAccountId: false
574
+ };
575
+ }
576
+ async listAccounts() {
577
+ await this.ensureLayout();
578
+ const entries = await readdir(this.paths.accountsDir, {
579
+ withFileTypes: true
580
+ });
581
+ const accounts = [];
582
+ const warnings = [];
583
+ for (const entry of entries)if (entry.isDirectory()) try {
584
+ accounts.push(await this.readManagedAccount(entry.name));
585
+ } catch (error) {
586
+ warnings.push(`Account "${entry.name}" is invalid: ${error.message}`);
587
+ }
588
+ const counts = new Map();
589
+ for (const account of accounts)counts.set(account.account_id, (counts.get(account.account_id) ?? 0) + 1);
590
+ accounts.sort((left, right)=>left.name.localeCompare(right.name));
591
+ return {
592
+ accounts: accounts.map((account)=>({
593
+ ...account,
594
+ duplicateAccountId: (counts.get(account.account_id) ?? 0) > 1
595
+ })),
596
+ warnings
597
+ };
598
+ }
599
+ async getCurrentStatus() {
600
+ const { accounts, warnings } = await this.listAccounts();
601
+ if (!await pathExists(this.paths.currentAuthPath)) return {
602
+ exists: false,
603
+ auth_mode: null,
604
+ account_id: null,
605
+ matched_accounts: [],
606
+ managed: false,
607
+ duplicate_match: false,
608
+ warnings
609
+ };
610
+ const snapshot = await readAuthSnapshotFile(this.paths.currentAuthPath);
611
+ const matchedAccounts = accounts.filter((account)=>account.account_id === snapshot.tokens.account_id).map((account)=>account.name);
612
+ return {
613
+ exists: true,
614
+ auth_mode: snapshot.auth_mode,
615
+ account_id: snapshot.tokens.account_id,
616
+ matched_accounts: matchedAccounts,
617
+ managed: matchedAccounts.length > 0,
618
+ duplicate_match: matchedAccounts.length > 1,
619
+ warnings
620
+ };
621
+ }
622
+ async saveCurrentAccount(name, force = false) {
623
+ ensureAccountName(name);
624
+ await this.ensureLayout();
625
+ if (!await pathExists(this.paths.currentAuthPath)) throw new Error("Current ~/.codex/auth.json does not exist.");
626
+ const rawSnapshot = await readJsonFile(this.paths.currentAuthPath);
627
+ const snapshot = parseAuthSnapshot(rawSnapshot);
628
+ const accountDir = this.accountDirectory(name);
629
+ const authPath = this.accountAuthPath(name);
630
+ const metaPath = this.accountMetaPath(name);
631
+ const accountExists = await pathExists(accountDir);
632
+ const existingMeta = accountExists && await pathExists(metaPath) ? parseSnapshotMeta(await readJsonFile(metaPath)) : void 0;
633
+ if (accountExists && !force) throw new Error(`Account "${name}" already exists. Use --force to overwrite it.`);
634
+ await ensureDirectory(accountDir, DIRECTORY_MODE);
635
+ await atomicWriteFile(authPath, `${rawSnapshot.trimEnd()}\n`);
636
+ const meta = createSnapshotMeta(name, snapshot, new Date(), existingMeta?.created_at);
637
+ meta.last_switched_at = existingMeta?.last_switched_at ?? null;
638
+ meta.quota = existingMeta?.quota ?? meta.quota;
639
+ await atomicWriteFile(metaPath, stringifyJson(meta));
640
+ return this.readManagedAccount(name);
641
+ }
642
+ async updateCurrentManagedAccount() {
643
+ await this.ensureLayout();
644
+ if (!await pathExists(this.paths.currentAuthPath)) throw new Error("Current ~/.codex/auth.json does not exist.");
645
+ const current = await this.getCurrentStatus();
646
+ if (!current.managed) throw new Error("Current account is not managed.");
647
+ if (current.duplicate_match || 1 !== current.matched_accounts.length) throw new Error(`Current account matches multiple managed accounts: ${current.matched_accounts.join(", ")}.`);
648
+ const name = current.matched_accounts[0];
649
+ const currentRawSnapshot = await readJsonFile(this.paths.currentAuthPath);
650
+ const currentSnapshot = parseAuthSnapshot(currentRawSnapshot);
651
+ const metaPath = this.accountMetaPath(name);
652
+ const existingMeta = parseSnapshotMeta(await readJsonFile(metaPath));
653
+ await atomicWriteFile(this.accountAuthPath(name), `${currentRawSnapshot.trimEnd()}\n`);
654
+ await atomicWriteFile(metaPath, stringifyJson({
655
+ ...createSnapshotMeta(name, currentSnapshot, new Date(), existingMeta.created_at),
656
+ last_switched_at: existingMeta.last_switched_at,
657
+ quota: existingMeta.quota
658
+ }));
659
+ return {
660
+ account: await this.readManagedAccount(name)
661
+ };
662
+ }
663
+ async switchAccount(name) {
664
+ ensureAccountName(name);
665
+ await this.ensureLayout();
666
+ const account = await this.readManagedAccount(name);
667
+ const warnings = [];
668
+ let backupPath = null;
669
+ await ensureDirectory(this.paths.codexDir, DIRECTORY_MODE);
670
+ if (await pathExists(this.paths.currentAuthPath)) {
671
+ backupPath = join(this.paths.backupsDir, "last-active-auth.json");
672
+ await copyFile(this.paths.currentAuthPath, backupPath);
673
+ await chmodIfPossible(backupPath, FILE_MODE);
674
+ }
675
+ const rawAuth = await readJsonFile(account.authPath);
676
+ await atomicWriteFile(this.paths.currentAuthPath, `${rawAuth.trimEnd()}\n`);
677
+ const writtenSnapshot = await readAuthSnapshotFile(this.paths.currentAuthPath);
678
+ if (writtenSnapshot.tokens.account_id !== account.account_id) throw new Error(`Switch verification failed for account "${name}".`);
679
+ const meta = parseSnapshotMeta(await readJsonFile(account.metaPath));
680
+ meta.last_switched_at = new Date().toISOString();
681
+ meta.updated_at = meta.last_switched_at;
682
+ await atomicWriteFile(account.metaPath, stringifyJson(meta));
683
+ await this.writeState({
684
+ schema_version: SCHEMA_VERSION,
685
+ last_switched_account: name,
686
+ last_backup_path: backupPath
687
+ });
688
+ const runningCodexPids = await detectRunningCodexProcesses();
689
+ if (runningCodexPids.length > 0) warnings.push(`Detected running codex processes (${runningCodexPids.join(", ")}). Existing sessions may still hold the previous login state.`);
690
+ return {
691
+ account: await this.readManagedAccount(name),
692
+ warnings,
693
+ backup_path: backupPath
694
+ };
695
+ }
696
+ async listQuotaSummaries() {
697
+ const { accounts, warnings } = await this.listAccounts();
698
+ const summaries = await Promise.all(accounts.map((account)=>this.quotaSummaryForAccount(account)));
699
+ return {
700
+ accounts: summaries,
701
+ warnings
702
+ };
703
+ }
704
+ async refreshQuotaForAccount(name) {
705
+ ensureAccountName(name);
706
+ await this.ensureLayout();
707
+ const account = await this.readManagedAccount(name);
708
+ const meta = parseSnapshotMeta(await readJsonFile(account.metaPath));
709
+ const snapshot = await readAuthSnapshotFile(account.authPath);
710
+ const now = new Date();
711
+ try {
712
+ const result = await fetchQuotaSnapshot(snapshot, {
713
+ homeDir: this.paths.homeDir,
714
+ fetchImpl: this.fetchImpl,
715
+ now
716
+ });
717
+ if (JSON.stringify(result.authSnapshot) !== JSON.stringify(snapshot)) {
718
+ await this.writeAccountAuthSnapshot(name, result.authSnapshot);
719
+ await this.syncCurrentAuthIfMatching(result.authSnapshot);
720
+ }
721
+ meta.auth_mode = result.authSnapshot.auth_mode;
722
+ meta.account_id = result.authSnapshot.tokens.account_id;
723
+ meta.updated_at = now.toISOString();
724
+ meta.quota = result.quota;
725
+ await this.writeAccountMeta(name, meta);
726
+ return {
727
+ account: await this.readManagedAccount(name),
728
+ quota: meta.quota
729
+ };
730
+ } catch (error) {
731
+ let planType = meta.quota.plan_type;
732
+ try {
733
+ const extracted = extractChatGPTAuth(snapshot);
734
+ planType ??= extracted.planType;
735
+ } catch {}
736
+ meta.updated_at = now.toISOString();
737
+ meta.quota = {
738
+ ...meta.quota,
739
+ status: "error",
740
+ plan_type: planType,
741
+ fetched_at: now.toISOString(),
742
+ error_message: error.message
743
+ };
744
+ await this.writeAccountMeta(name, meta);
745
+ throw new Error(`Failed to refresh quota for "${name}": ${error.message}`);
746
+ }
747
+ }
748
+ async refreshAllQuotas(targetName) {
749
+ const { accounts } = await this.listAccounts();
750
+ const targets = targetName ? accounts.filter((account)=>account.name === targetName) : accounts;
751
+ if (targetName && 0 === targets.length) throw new Error(`Account "${targetName}" does not exist.`);
752
+ const successes = [];
753
+ const failures = [];
754
+ for (const account of targets)try {
755
+ const refreshed = await this.refreshQuotaForAccount(account.name);
756
+ successes.push(await this.quotaSummaryForAccount(refreshed.account));
757
+ } catch (error) {
758
+ failures.push({
759
+ name: account.name,
760
+ error: error.message
761
+ });
762
+ }
763
+ return {
764
+ successes,
765
+ failures
766
+ };
767
+ }
768
+ async removeAccount(name) {
769
+ ensureAccountName(name);
770
+ const accountDir = this.accountDirectory(name);
771
+ if (!await pathExists(accountDir)) throw new Error(`Account "${name}" does not exist.`);
772
+ await rm(accountDir, {
773
+ recursive: true,
774
+ force: false
775
+ });
776
+ }
777
+ async renameAccount(oldName, newName) {
778
+ ensureAccountName(oldName);
779
+ ensureAccountName(newName);
780
+ const oldDir = this.accountDirectory(oldName);
781
+ const newDir = this.accountDirectory(newName);
782
+ if (!await pathExists(oldDir)) throw new Error(`Account "${oldName}" does not exist.`);
783
+ if (await pathExists(newDir)) throw new Error(`Account "${newName}" already exists.`);
784
+ await rename(oldDir, newDir);
785
+ const metaPath = this.accountMetaPath(newName);
786
+ const meta = parseSnapshotMeta(await readJsonFile(metaPath));
787
+ meta.name = newName;
788
+ meta.updated_at = new Date().toISOString();
789
+ await atomicWriteFile(metaPath, stringifyJson(meta));
790
+ return this.readManagedAccount(newName);
791
+ }
792
+ async doctor() {
793
+ await this.ensureLayout();
794
+ const issues = [];
795
+ const warnings = [];
796
+ const invalidAccounts = [];
797
+ const rootStat = await stat(this.paths.codexTeamDir);
798
+ if ((511 & rootStat.mode) !== DIRECTORY_MODE) issues.push(`Store directory permissions are ${(511 & rootStat.mode).toString(8)}, expected 700.`);
799
+ if (await pathExists(this.paths.statePath)) {
800
+ const stateStat = await stat(this.paths.statePath);
801
+ if ((511 & stateStat.mode) !== FILE_MODE) issues.push(`State file permissions are ${(511 & stateStat.mode).toString(8)}, expected 600.`);
802
+ await this.readState();
803
+ }
804
+ const { accounts, warnings: accountWarnings } = await this.listAccounts();
805
+ for (const warning of accountWarnings){
806
+ warnings.push(warning);
807
+ const match = warning.match(/^Account "(.+)" is invalid:/);
808
+ if (match) invalidAccounts.push(match[1]);
809
+ }
810
+ for (const account of accounts){
811
+ const authStat = await stat(account.authPath);
812
+ const metaStat = await stat(account.metaPath);
813
+ if ((511 & authStat.mode) !== FILE_MODE) issues.push(`Account "${account.name}" auth permissions must be 600.`);
814
+ if ((511 & metaStat.mode) !== FILE_MODE) issues.push(`Account "${account.name}" metadata permissions must be 600.`);
815
+ if (account.duplicateAccountId) warnings.push(`Account "${account.name}" shares account_id ${account.account_id} with another saved account.`);
816
+ }
817
+ let currentAuthPresent = false;
818
+ if (await pathExists(this.paths.currentAuthPath)) {
819
+ currentAuthPresent = true;
820
+ try {
821
+ await readAuthSnapshotFile(this.paths.currentAuthPath);
822
+ } catch (error) {
823
+ issues.push(`Current auth.json is invalid: ${error.message}`);
824
+ }
825
+ } else warnings.push("Current ~/.codex/auth.json is missing.");
826
+ return {
827
+ healthy: 0 === issues.length,
828
+ warnings,
829
+ issues,
830
+ account_count: accounts.length,
831
+ invalid_accounts: invalidAccounts,
832
+ current_auth_present: currentAuthPresent
833
+ };
834
+ }
835
+ }
836
+ function createAccountStore(homeDir, options) {
837
+ return new AccountStore({
838
+ homeDir,
839
+ fetchImpl: options?.fetchImpl
840
+ });
841
+ }
842
+ dayjs.extend(utc);
843
+ dayjs.extend(timezone);
844
+ function parseArgs(argv) {
845
+ const flags = new Set();
846
+ const positionals = [];
847
+ for (const arg of argv)if (arg.startsWith("--")) flags.add(arg);
848
+ else positionals.push(arg);
849
+ return {
850
+ command: positionals[0] ?? null,
851
+ positionals: positionals.slice(1),
852
+ flags
853
+ };
854
+ }
855
+ function writeJson(stream, value) {
856
+ stream.write(`${JSON.stringify(value, null, 2)}\n`);
857
+ }
858
+ function formatTable(rows, columns) {
859
+ if (0 === rows.length) return "";
860
+ const widths = columns.map(({ key, label })=>Math.max(label.length, ...rows.map((row)=>row[key].length)));
861
+ const renderRow = (row)=>columns.map(({ key }, index)=>row[key].padEnd(widths[index])).join(" ").trimEnd();
862
+ const header = renderRow(Object.fromEntries(columns.map(({ key, label })=>[
863
+ key,
864
+ label
865
+ ])));
866
+ const separator = widths.map((width)=>"-".repeat(width)).join(" ");
867
+ return [
868
+ header,
869
+ separator,
870
+ ...rows.map(renderRow)
871
+ ].join("\n");
872
+ }
873
+ function printHelp(stream) {
874
+ stream.write(`codexm - manage multiple Codex ChatGPT auth snapshots
875
+
876
+ Usage:
877
+ codexm current [--json]
878
+ codexm list [--json]
879
+ codexm save <name> [--force] [--json]
880
+ codexm update [--json]
881
+ codexm quota refresh [name] [--json]
882
+ codexm quota list [--json]
883
+ codexm switch <name> [--json]
884
+ codexm remove <name> [--yes] [--json]
885
+ codexm rename <old> <new> [--json]
886
+ codexm doctor [--json]
887
+
888
+ Account names must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.
889
+ `);
890
+ }
891
+ function describeCurrentStatus(status) {
892
+ const lines = [];
893
+ if (status.exists) {
894
+ lines.push("Current auth: present");
895
+ lines.push(`Auth mode: ${status.auth_mode}`);
896
+ lines.push(`Account ID: ${maskAccountId(status.account_id ?? "")}`);
897
+ if (0 === status.matched_accounts.length) lines.push("Managed account: no (unmanaged)");
898
+ else if (1 === status.matched_accounts.length) lines.push(`Managed account: ${status.matched_accounts[0]}`);
899
+ else lines.push(`Managed account: multiple (${status.matched_accounts.join(", ")})`);
900
+ } else lines.push("Current auth: missing");
901
+ for (const warning of status.warnings)lines.push(`Warning: ${warning}`);
902
+ return lines.join("\n");
903
+ }
904
+ function describeAccounts(accounts, warnings) {
905
+ if (0 === accounts.length) return 0 === warnings.length ? "No saved accounts." : warnings.map((warning)=>`Warning: ${warning}`).join("\n");
906
+ const table = formatTable(accounts.map((account)=>({
907
+ name: account.name,
908
+ account_id: maskAccountId(account.account_id),
909
+ auth_mode: account.auth_mode,
910
+ saved: account.created_at,
911
+ switched: account.last_switched_at ?? "-",
912
+ flags: account.duplicateAccountId ? "duplicate-account-id" : "-"
913
+ })), [
914
+ {
915
+ key: "name",
916
+ label: "NAME"
917
+ },
918
+ {
919
+ key: "account_id",
920
+ label: "ACCOUNT ID"
921
+ },
922
+ {
923
+ key: "auth_mode",
924
+ label: "AUTH MODE"
925
+ },
926
+ {
927
+ key: "saved",
928
+ label: "SAVED AT"
929
+ },
930
+ {
931
+ key: "switched",
932
+ label: "LAST SWITCHED"
933
+ },
934
+ {
935
+ key: "flags",
936
+ label: "FLAGS"
937
+ }
938
+ ]);
939
+ const lines = [
940
+ table
941
+ ];
942
+ for (const warning of warnings)lines.push(`Warning: ${warning}`);
943
+ return lines.join("\n");
944
+ }
945
+ function describeDoctor(report) {
946
+ const lines = [
947
+ report.healthy ? "Doctor checks passed." : "Doctor checks found issues.",
948
+ `Saved accounts: ${report.account_count}`,
949
+ `Current auth present: ${report.current_auth_present ? "yes" : "no"}`
950
+ ];
951
+ for (const issue of report.issues)lines.push(`Issue: ${issue}`);
952
+ for (const warning of report.warnings)lines.push(`Warning: ${warning}`);
953
+ return lines.join("\n");
954
+ }
955
+ function formatUsagePercent(window) {
956
+ if (!window) return "-";
957
+ return `${window.used_percent}%`;
958
+ }
959
+ function formatResetAt(window) {
960
+ if (!window?.reset_at) return "-";
961
+ return dayjs.utc(window.reset_at).tz(dayjs.tz.guess()).format("MM-DD HH:mm");
962
+ }
963
+ function describeQuotaAccounts(accounts, warnings) {
964
+ if (0 === accounts.length) return 0 === warnings.length ? "No saved accounts." : warnings.map((warning)=>`Warning: ${warning}`).join("\n");
965
+ const table = formatTable(accounts.map((account)=>({
966
+ name: account.name,
967
+ account_id: maskAccountId(account.account_id),
968
+ plan_type: account.plan_type ?? "-",
969
+ five_hour: formatUsagePercent(account.five_hour),
970
+ five_hour_reset: formatResetAt(account.five_hour),
971
+ one_week: formatUsagePercent(account.one_week),
972
+ one_week_reset: formatResetAt(account.one_week),
973
+ status: account.status
974
+ })), [
975
+ {
976
+ key: "name",
977
+ label: "NAME"
978
+ },
979
+ {
980
+ key: "account_id",
981
+ label: "ACCOUNT ID"
982
+ },
983
+ {
984
+ key: "plan_type",
985
+ label: "PLAN TYPE"
986
+ },
987
+ {
988
+ key: "five_hour",
989
+ label: "5H USED"
990
+ },
991
+ {
992
+ key: "five_hour_reset",
993
+ label: "5H RESET AT"
994
+ },
995
+ {
996
+ key: "one_week",
997
+ label: "1W USED"
998
+ },
999
+ {
1000
+ key: "one_week_reset",
1001
+ label: "1W RESET AT"
1002
+ },
1003
+ {
1004
+ key: "status",
1005
+ label: "STATUS"
1006
+ }
1007
+ ]);
1008
+ const lines = [
1009
+ table
1010
+ ];
1011
+ for (const warning of warnings)lines.push(`Warning: ${warning}`);
1012
+ return lines.join("\n");
1013
+ }
1014
+ function describeQuotaRefresh(result) {
1015
+ const lines = [];
1016
+ if (result.successes.length > 0) {
1017
+ lines.push("Refreshed quotas:");
1018
+ lines.push(describeQuotaAccounts(result.successes, []));
1019
+ }
1020
+ for (const failure of result.failures)lines.push(`Failure: ${failure.name}: ${failure.error}`);
1021
+ if (0 === lines.length) lines.push("No accounts were refreshed.");
1022
+ return lines.join("\n");
1023
+ }
1024
+ async function confirmRemoval(name, streams) {
1025
+ if (!streams.stdin.isTTY) throw new Error(`Refusing to remove "${name}" without --yes in a non-interactive terminal.`);
1026
+ streams.stdout.write(`Remove saved account "${name}"? [y/N] `);
1027
+ return await new Promise((resolve)=>{
1028
+ const onData = (buffer)=>{
1029
+ const answer = buffer.toString("utf8").trim().toLowerCase();
1030
+ streams.stdin.off("data", onData);
1031
+ streams.stdout.write("\n");
1032
+ resolve("y" === answer || "yes" === answer);
1033
+ };
1034
+ streams.stdin.on("data", onData);
1035
+ });
1036
+ }
1037
+ async function runCli(argv, options = {}) {
1038
+ const streams = {
1039
+ stdin: options.stdin ?? stdin,
1040
+ stdout: options.stdout ?? external_node_process_stdout,
1041
+ stderr: options.stderr ?? stderr
1042
+ };
1043
+ const store = options.store ?? createAccountStore();
1044
+ const parsed = parseArgs(argv);
1045
+ const json = parsed.flags.has("--json");
1046
+ try {
1047
+ if (!parsed.command || parsed.flags.has("--help")) {
1048
+ printHelp(streams.stdout);
1049
+ return 0;
1050
+ }
1051
+ switch(parsed.command){
1052
+ case "current":
1053
+ {
1054
+ const result = await store.getCurrentStatus();
1055
+ if (json) writeJson(streams.stdout, result);
1056
+ else streams.stdout.write(`${describeCurrentStatus(result)}\n`);
1057
+ return 0;
1058
+ }
1059
+ case "list":
1060
+ {
1061
+ const result = await store.listAccounts();
1062
+ if (json) writeJson(streams.stdout, result);
1063
+ else streams.stdout.write(`${describeAccounts(result.accounts, result.warnings)}\n`);
1064
+ return 0;
1065
+ }
1066
+ case "save":
1067
+ {
1068
+ const name = parsed.positionals[0];
1069
+ if (!name) throw new Error("Usage: codexm save <name> [--force]");
1070
+ const account = await store.saveCurrentAccount(name, parsed.flags.has("--force"));
1071
+ const payload = {
1072
+ ok: true,
1073
+ action: "save",
1074
+ account: {
1075
+ name: account.name,
1076
+ account_id: account.account_id,
1077
+ auth_mode: account.auth_mode
1078
+ }
1079
+ };
1080
+ if (json) writeJson(streams.stdout, payload);
1081
+ else streams.stdout.write(`Saved account "${account.name}" (${maskAccountId(account.account_id)}).\n`);
1082
+ return 0;
1083
+ }
1084
+ case "update":
1085
+ {
1086
+ const result = await store.updateCurrentManagedAccount();
1087
+ const warnings = [];
1088
+ let quota = null;
1089
+ try {
1090
+ const quotaResult = await store.refreshQuotaForAccount(result.account.name);
1091
+ const quotaList = await store.listQuotaSummaries();
1092
+ quota = quotaList.accounts.find((account)=>account.name === quotaResult.account.name) ?? null;
1093
+ } catch (error) {
1094
+ warnings.push(error.message);
1095
+ }
1096
+ const payload = {
1097
+ ok: true,
1098
+ action: "update",
1099
+ account: {
1100
+ name: result.account.name,
1101
+ account_id: result.account.account_id,
1102
+ auth_mode: result.account.auth_mode
1103
+ },
1104
+ quota,
1105
+ warnings
1106
+ };
1107
+ if (json) writeJson(streams.stdout, payload);
1108
+ else {
1109
+ streams.stdout.write(`Updated managed account "${result.account.name}" (${maskAccountId(result.account.account_id)}).\n`);
1110
+ for (const warning of warnings)streams.stdout.write(`Warning: ${warning}\n`);
1111
+ }
1112
+ return 0;
1113
+ }
1114
+ case "quota":
1115
+ {
1116
+ const quotaCommand = parsed.positionals[0];
1117
+ if ("list" === quotaCommand) {
1118
+ const result = await store.listQuotaSummaries();
1119
+ if (json) writeJson(streams.stdout, result);
1120
+ else streams.stdout.write(`${describeQuotaAccounts(result.accounts, result.warnings)}\n`);
1121
+ return 0;
1122
+ }
1123
+ if ("refresh" === quotaCommand) {
1124
+ const targetName = parsed.positionals[1];
1125
+ const result = await store.refreshAllQuotas(targetName);
1126
+ if (json) writeJson(streams.stdout, result);
1127
+ else streams.stdout.write(`${describeQuotaRefresh(result)}\n`);
1128
+ return 0 === result.failures.length ? 0 : 1;
1129
+ }
1130
+ throw new Error("Usage: codexm quota <refresh [name] | list> [--json]");
1131
+ }
1132
+ case "switch":
1133
+ {
1134
+ const name = parsed.positionals[0];
1135
+ if (!name) throw new Error("Usage: codexm switch <name>");
1136
+ const result = await store.switchAccount(name);
1137
+ let quota = null;
1138
+ try {
1139
+ await store.refreshQuotaForAccount(result.account.name);
1140
+ const quotaList = await store.listQuotaSummaries();
1141
+ quota = quotaList.accounts.find((account)=>account.name === result.account.name) ?? null;
1142
+ } catch (error) {
1143
+ result.warnings.push(error.message);
1144
+ }
1145
+ const payload = {
1146
+ ok: true,
1147
+ action: "switch",
1148
+ account: {
1149
+ name: result.account.name,
1150
+ account_id: result.account.account_id,
1151
+ auth_mode: result.account.auth_mode
1152
+ },
1153
+ quota,
1154
+ backup_path: result.backup_path,
1155
+ warnings: result.warnings
1156
+ };
1157
+ if (json) writeJson(streams.stdout, payload);
1158
+ else {
1159
+ streams.stdout.write(`Switched to "${result.account.name}" (${maskAccountId(result.account.account_id)}).\n`);
1160
+ if (result.backup_path) streams.stdout.write(`Backup: ${result.backup_path}\n`);
1161
+ for (const warning of result.warnings)streams.stdout.write(`Warning: ${warning}\n`);
1162
+ }
1163
+ return 0;
1164
+ }
1165
+ case "remove":
1166
+ {
1167
+ const name = parsed.positionals[0];
1168
+ if (!name) throw new Error("Usage: codexm remove <name> [--yes]");
1169
+ const confirmed = parsed.flags.has("--yes") || await confirmRemoval(name, streams);
1170
+ if (!confirmed) {
1171
+ if (json) writeJson(streams.stdout, {
1172
+ ok: false,
1173
+ action: "remove",
1174
+ account: name,
1175
+ cancelled: true
1176
+ });
1177
+ else streams.stdout.write("Cancelled.\n");
1178
+ return 1;
1179
+ }
1180
+ await store.removeAccount(name);
1181
+ if (json) writeJson(streams.stdout, {
1182
+ ok: true,
1183
+ action: "remove",
1184
+ account: name
1185
+ });
1186
+ else streams.stdout.write(`Removed account "${name}".\n`);
1187
+ return 0;
1188
+ }
1189
+ case "rename":
1190
+ {
1191
+ const oldName = parsed.positionals[0];
1192
+ const newName = parsed.positionals[1];
1193
+ if (!oldName || !newName) throw new Error("Usage: codexm rename <old> <new>");
1194
+ const account = await store.renameAccount(oldName, newName);
1195
+ if (json) writeJson(streams.stdout, {
1196
+ ok: true,
1197
+ action: "rename",
1198
+ account: {
1199
+ name: account.name,
1200
+ account_id: account.account_id,
1201
+ auth_mode: account.auth_mode
1202
+ }
1203
+ });
1204
+ else streams.stdout.write(`Renamed "${oldName}" to "${newName}".\n`);
1205
+ return 0;
1206
+ }
1207
+ case "doctor":
1208
+ {
1209
+ const report = await store.doctor();
1210
+ if (json) writeJson(streams.stdout, report);
1211
+ else streams.stdout.write(`${describeDoctor(report)}\n`);
1212
+ return report.healthy ? 0 : 1;
1213
+ }
1214
+ default:
1215
+ throw new Error(`Unknown command "${parsed.command}".`);
1216
+ }
1217
+ } catch (error) {
1218
+ const message = error.message;
1219
+ if (json) writeJson(streams.stderr, {
1220
+ ok: false,
1221
+ error: message
1222
+ });
1223
+ else streams.stderr.write(`Error: ${message}\n`);
1224
+ return 1;
1225
+ }
1226
+ }
1227
+ export { runCli };