oc-codex-multi-auth 6.22.0 → 6.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +729 -729
  3. package/assets/icon.svg +7 -7
  4. package/assets/opencode-logo-ornate-dark.svg +18 -18
  5. package/assets/readme-hero.svg +31 -31
  6. package/config/README.md +197 -177
  7. package/config/minimal-opencode.json +14 -14
  8. package/config/opencode-legacy.json +1346 -1496
  9. package/config/opencode-modern.json +466 -555
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/accounts/warm-request.d.ts.map +1 -1
  14. package/dist/lib/accounts/warm-request.js +9 -11
  15. package/dist/lib/accounts/warm-request.js.map +1 -1
  16. package/dist/lib/constants.d.ts +13 -0
  17. package/dist/lib/constants.d.ts.map +1 -1
  18. package/dist/lib/constants.js +13 -0
  19. package/dist/lib/constants.js.map +1 -1
  20. package/dist/lib/context-overflow.js +10 -10
  21. package/dist/lib/desktop-notifications.js +6 -6
  22. package/dist/lib/oauth-success.js +202 -202
  23. package/dist/lib/prompts/codex-opencode-bridge.js +67 -67
  24. package/dist/lib/prompts/codex.d.ts +12 -2
  25. package/dist/lib/prompts/codex.d.ts.map +1 -1
  26. package/dist/lib/prompts/codex.js +122 -91
  27. package/dist/lib/prompts/codex.js.map +1 -1
  28. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  29. package/dist/lib/request/fetch-helpers.js +84 -50
  30. package/dist/lib/request/fetch-helpers.js.map +1 -1
  31. package/dist/lib/request/helpers/model-map.d.ts +10 -0
  32. package/dist/lib/request/helpers/model-map.d.ts.map +1 -1
  33. package/dist/lib/request/helpers/model-map.js +39 -11
  34. package/dist/lib/request/helpers/model-map.js.map +1 -1
  35. package/dist/lib/request/helpers/responses-lite.d.ts.map +1 -1
  36. package/dist/lib/request/helpers/responses-lite.js +4 -1
  37. package/dist/lib/request/helpers/responses-lite.js.map +1 -1
  38. package/dist/lib/request/request-transformer.d.ts.map +1 -1
  39. package/dist/lib/request/request-transformer.js +37 -15
  40. package/dist/lib/request/request-transformer.js.map +1 -1
  41. package/dist/lib/schemas.d.ts +6 -0
  42. package/dist/lib/schemas.d.ts.map +1 -1
  43. package/package.json +155 -155
  44. package/scripts/audit-dev-allowlist.js +114 -114
  45. package/scripts/clean-dist.js +27 -27
  46. package/scripts/copy-oauth-success.js +47 -47
  47. package/scripts/install-oc-codex-multi-auth-core.js +2016 -2003
  48. package/scripts/install-oc-codex-multi-auth.js +37 -37
  49. package/scripts/test-all-models.sh +260 -260
  50. package/scripts/validate-model-map.sh +97 -97
@@ -1,2003 +1,2016 @@
1
- import { createHash } from "node:crypto";
2
- import { existsSync, readFileSync, realpathSync } from "node:fs";
3
- import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
- import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
- import { fileURLToPath, pathToFileURL } from "node:url";
7
-
8
- const PACKAGE_NAME = "oc-codex-multi-auth";
9
- const LEGACY_PACKAGE_NAMES = ["oc-chatgpt-multi-auth"];
10
- const ORIGIN_HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json";
11
- const WINDOWS_RENAME_RETRY_ATTEMPTS = 5;
12
- const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10;
13
- const STALE_MANAGED_MODEL_KEYS = new Set([
14
- "gpt-5.2",
15
- "gpt-5.3-codex",
16
- "gpt-5.4",
17
- ]);
18
- const STANDALONE_COMMANDS = new Set(["doctor", "status", "list", "limits", "dashboard", "health", "diag", "warm"]);
19
- const INSTALLER_COMMANDS = new Set(["install"]);
20
- const UPDATE_COMMANDS = new Set(["update"]);
21
-
22
- function splitCommandArgv(argv) {
23
- const [first, ...rest] = argv;
24
- if (!first) return { kind: "install", argv };
25
- if (INSTALLER_COMMANDS.has(first)) return { kind: "install", argv: rest };
26
- if (UPDATE_COMMANDS.has(first)) return { kind: "update", argv: rest };
27
- if (STANDALONE_COMMANDS.has(first)) return { kind: "standalone", command: first, argv: rest };
28
- if (first.startsWith("-")) return { kind: "install", argv };
29
- return { kind: "unknown", command: first, argv: rest };
30
- }
31
-
32
- function parseStandaloneArgs(argv) {
33
- const options = {
34
- json: false,
35
- includeSensitive: false,
36
- deep: false,
37
- fix: false,
38
- tag: undefined,
39
- configPath: undefined,
40
- help: false,
41
- };
42
- for (let index = 0; index < argv.length; index += 1) {
43
- const arg = argv[index];
44
- if (arg === "--json") options.json = true;
45
- else if (arg === "--include-sensitive") options.includeSensitive = true;
46
- else if (arg === "--deep") options.deep = true;
47
- else if (arg === "--fix") options.fix = true;
48
- else if (arg === "--tag") options.tag = argv[++index];
49
- else if (arg.startsWith("--tag=")) options.tag = arg.slice("--tag=".length);
50
- else if (arg === "--config-path") options.configPath = argv[++index];
51
- else if (arg.startsWith("--config-path=")) options.configPath = arg.slice("--config-path=".length);
52
- else if (arg === "--help" || arg === "-h") options.help = true;
53
- else throw new Error(`Unknown option for standalone command: ${arg}`);
54
- }
55
- return options;
56
- }
57
-
58
- function getManagedPackageNames() {
59
- return [PACKAGE_NAME, ...LEGACY_PACKAGE_NAMES];
60
- }
61
-
62
- export function normalizePathForCompare(path, resolveRealPath = realpathSync) {
63
- const resolved = resolve(path);
64
- try {
65
- const realPath = resolveRealPath(resolved);
66
- return process.platform === "win32" ? realPath.toLowerCase() : realPath;
67
- } catch {
68
- return process.platform === "win32" ? resolved.toLowerCase() : resolved;
69
- }
70
- }
71
-
72
- export function isDirectRunPath(argvPath, modulePath, resolveRealPath = realpathSync) {
73
- if (!argvPath || !modulePath) return false;
74
- return (
75
- normalizePathForCompare(argvPath, resolveRealPath) ===
76
- normalizePathForCompare(modulePath, resolveRealPath)
77
- );
78
- }
79
-
80
- function printHelp() {
81
- console.log(`Usage: ${PACKAGE_NAME} [command] [options]\n\n` +
82
- "Commands:\n" +
83
- " install Register plugin entries (default with no command)\n" +
84
- " update Refresh the cached package without changing OpenCode config\n" +
85
- " doctor Run local account/config diagnostics\n" +
86
- " status Show account/config status\n" +
87
- " list List configured accounts\n" +
88
- " limits Show live 5-hour and weekly usage for each account\n" +
89
- " dashboard Print dashboard guidance\n" +
90
- " health Check local token/account health\n" +
91
- " diag Alias for doctor --deep\n" +
92
- " warm Open every enabled account's usage window now (one request each)\n\n" +
93
- `Installer usage: ${PACKAGE_NAME} install [--plugin-only|--modern|--full|--legacy] [--dry-run] [--no-cache-clear]\n` +
94
- `Updater usage: ${PACKAGE_NAME} update [--dry-run]\n\n` +
95
- "Default behavior:\n" +
96
- " - Registers plugin entries without changing provider.openai\n" +
97
- " - Enables the prompt status bar TUI plugin at ~/.config/opencode/tui.json\n" +
98
- " - Installs model catalogs only with --modern, --full, or --legacy\n" +
99
- " - Ensures plugin is unpinned (latest)\n" +
100
- " - Clears OpenCode plugin cache\n\n" +
101
- "Options:\n" +
102
- " --plugin-only Register plugins without changing provider.openai\n" +
103
- " --modern Force compact modern config (13 base OAuth models + --variant presets)\n" +
104
- " --full Install compact base models plus 59 explicit selector entries\n" +
105
- " --legacy Force explicit legacy config (59 preset model entries)\n" +
106
- " --dry-run Show actions without writing\n" +
107
- " --no-cache-clear Skip clearing OpenCode cache\n"
108
- );
109
- }
110
-
111
- const scriptDir = dirname(fileURLToPath(import.meta.url));
112
- const repoRoot = resolve(scriptDir, "..");
113
- const modernTemplatePath = join(repoRoot, "config", "opencode-modern.json");
114
- const legacyTemplatePath = join(repoRoot, "config", "opencode-legacy.json");
115
-
116
- function log(message) {
117
- console.log(message);
118
- }
119
-
120
- function delay(ms) {
121
- return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
122
- }
123
-
124
- function isWindowsLockError(error) {
125
- const code = error?.code;
126
- return code === "EPERM" || code === "EBUSY";
127
- }
128
-
129
- function formatErrorForLog(error) {
130
- if (error instanceof Error) {
131
- return error.message;
132
- }
133
- return String(error);
134
- }
135
-
136
- function resolveHomeDirectory(env = process.env) {
137
- return env.HOME || env.USERPROFILE || homedir();
138
- }
139
-
140
- function buildPaths(homeDir) {
141
- const configDir = join(homeDir, ".config", "opencode");
142
- const cacheDir = join(homeDir, ".cache", "opencode");
143
- return {
144
- configDir,
145
- configPath: join(configDir, "opencode.json"),
146
- tuiConfigPath: join(configDir, "tui.json"),
147
- cacheDir,
148
- cacheNodeModulesPaths: getManagedPackageNames().map((name) => join(cacheDir, "node_modules", name)),
149
- cachePackagePaths: getManagedPackageNames().flatMap((name) => [
150
- join(cacheDir, "packages", name),
151
- join(cacheDir, "packages", `${name}@latest`),
152
- ]),
153
- cacheBunLock: join(cacheDir, "bun.lock"),
154
- cachePackageJson: join(cacheDir, "package.json"),
155
- originHistoryPath: join(homeDir, ".opencode", ORIGIN_HISTORY_FILE_NAME),
156
- modernTemplatePath,
157
- legacyTemplatePath,
158
- };
159
- }
160
-
161
- function parseCliArgs(argv = process.argv.slice(2)) {
162
- const args = new Set(argv);
163
- if (args.has("--help") || args.has("-h")) {
164
- return {
165
- wantsHelp: true,
166
- };
167
- }
168
-
169
- const requestedModern = args.has("--modern");
170
- const requestedFull = args.has("--full");
171
- const requestedLegacy = args.has("--legacy");
172
- const explicitPluginOnly = args.has("--plugin-only");
173
-
174
- const requestedModes = [requestedModern, requestedFull, requestedLegacy]
175
- .filter(Boolean).length;
176
- if (requestedModes > 1) {
177
- throw new Error("Choose only one of --modern, --full, or --legacy.");
178
- }
179
- if (explicitPluginOnly && requestedModes > 0) {
180
- throw new Error("--plugin-only cannot be combined with --modern, --full, or --legacy.");
181
- }
182
- const pluginOnly = explicitPluginOnly || requestedModes === 0;
183
-
184
- return {
185
- wantsHelp: false,
186
- dryRun: args.has("--dry-run"),
187
- skipCacheClear: args.has("--no-cache-clear"),
188
- pluginOnly,
189
- configMode: requestedFull ? "full" : requestedLegacy ? "legacy" : "modern",
190
- };
191
- }
192
-
193
- function parseUpdateArgs(argv) {
194
- const args = new Set(argv);
195
- const supported = new Set(["--dry-run", "--help", "-h"]);
196
- const unknown = argv.find((arg) => !supported.has(arg));
197
- if (unknown) {
198
- throw new Error(`Unknown option for update command: ${unknown}`);
199
- }
200
- return {
201
- wantsHelp: args.has("--help") || args.has("-h"),
202
- dryRun: args.has("--dry-run"),
203
- };
204
- }
205
-
206
- const MANAGED_PACKAGE_ENTRY = "managed-package";
207
- const LOCAL_CHECKOUT_ENTRY = "local-checkout";
208
- const UNRELATED_ENTRY = "unrelated";
209
- const DECLARED_NAME_LOOKUP_DEPTH = 3;
210
-
211
- function pluginEntrySpecifier(entry) {
212
- if (typeof entry === "string") return entry;
213
- // `[specifier, options]` configures a plugin without changing where it loads from.
214
- if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
215
- return null;
216
- }
217
-
218
- /**
219
- * Spellings that can only mean a location on disk: absolute paths, `~`,
220
- * `./`/`../`, Windows drive letters, and UNC shares. Anything else that merely
221
- * contains a separator (`@scope/name`, git URLs, `npm:` aliases) is ambiguous
222
- * and counts as a path only when it resolves on this machine.
223
- */
224
- function isExplicitPathSpecifier(specifier) {
225
- return (
226
- /^[a-zA-Z]:[\\/]/.test(specifier) ||
227
- /^[\\/]/.test(specifier) ||
228
- /^~[\\/]/.test(specifier) ||
229
- /^\.\.?[\\/]/.test(specifier)
230
- );
231
- }
232
-
233
- /**
234
- * The path an entry names, exactly as the config spells it - and only when the
235
- * specifier actually is a path. A `/` alone does not make one: registry and
236
- * URL spellings can end in `oc-codex-multi-auth` without naming this package's
237
- * checkout, so an ambiguous specifier counts only when it resolves on disk.
238
- */
239
- function pluginEntryPath(specifier, baseDirectory) {
240
- const trimmed = specifier.trim();
241
- if (!trimmed) return null;
242
- if (/^file:\/\//i.test(trimmed)) {
243
- try {
244
- return fileURLToPath(trimmed);
245
- } catch {
246
- return null;
247
- }
248
- }
249
- if (isExplicitPathSpecifier(trimmed)) return trimmed;
250
- if (!trimmed.includes("/") && !trimmed.includes("\\")) return null;
251
- const inspectionPath = resolveInspectionPath(trimmed, baseDirectory);
252
- return inspectionPath && existsSync(inspectionPath) ? trimmed : null;
253
- }
254
-
255
- function pluginPathSegments(entryPath) {
256
- return entryPath.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean);
257
- }
258
-
259
- /**
260
- * Compared as written, without resolving symlinks: this asks whether an entry
261
- * names something `clearCache` removes, and `clearCache` removes the paths
262
- * exactly as it spells them - `rm` unlinks a symlink rather than descending
263
- * into it. Cache eviction resolves symlinks because it decides the opposite
264
- * question, whether a recursive delete is safe.
265
- */
266
- function isInsideDirectory(candidate, directory, platform) {
267
- const fold = (value) => (platform === "win32" ? value.toLowerCase() : value);
268
- const relativePath = relative(fold(resolve(directory)), fold(resolve(candidate)));
269
- return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
270
- }
271
-
272
- function isPackageManagerPath(entryPath, options = {}) {
273
- const { platform = process.platform, cacheDirectory, inspectionPath } = options;
274
- // Windows reaches one directory under many spellings, so `NODE_MODULES`
275
- // there is the same package-manager output as `node_modules`. Elsewhere the
276
- // two are different directories and must stay so.
277
- const segments = pluginPathSegments(entryPath).map((segment) =>
278
- platform === "win32" ? segment.toLowerCase() : segment,
279
- );
280
- if (
281
- segments.some(
282
- (segment, index) =>
283
- segment === "node_modules" ||
284
- // OpenCode's plugin cache spells the version into the directory name.
285
- // A `packages/` directory without one is an ordinary monorepo.
286
- (segments[index - 1] === "packages" && segment.includes("@")),
287
- )
288
- ) {
289
- return true;
290
- }
291
- // The cache is where this installer puts its own copies, and `clearCache`
292
- // empties it on the same run. Reading spelling alone leaves the cache's
293
- // unversioned `packages/<name>` looking like somebody's monorepo, so the
294
- // entry is kept while the directory under it is deleted - a config left
295
- // pointing at nothing. Whose directory it is settles that; the spelling
296
- // cannot.
297
- return Boolean(
298
- cacheDirectory &&
299
- inspectionPath &&
300
- isInsideDirectory(inspectionPath, cacheDirectory, platform),
301
- );
302
- }
303
-
304
- /**
305
- * Where an entry points, for reading metadata about it only. OpenCode resolves
306
- * a relative entry against the config file that declares it, so that directory
307
- * is what makes such a path mean anything; the installer's working directory
308
- * would name somewhere else entirely. Null when a relative entry arrives with
309
- * no declaring directory to resolve it against.
310
- */
311
- function resolveInspectionPath(entryPath, baseDirectory) {
312
- if (isAbsolute(entryPath)) return entryPath;
313
- return baseDirectory ? resolve(baseDirectory, entryPath) : null;
314
- }
315
-
316
- /**
317
- * Last-resort identification for a path that is not present on this machine.
318
- * Spelling alone never authorizes deleting an entry; it only names the package a
319
- * missing path was probably meant to point at.
320
- */
321
- function managedNameFromPathSpelling(entryPath) {
322
- const segments = pluginPathSegments(entryPath);
323
- const last = segments.at(-1) === "dist" ? segments.at(-2) : segments.at(-1);
324
- if (!last) return null;
325
- let candidate = last.toLowerCase();
326
- try {
327
- candidate = decodeURIComponent(candidate);
328
- } catch {
329
- // Keep the raw segment when it carries a malformed escape.
330
- }
331
- const versionSuffix = candidate.indexOf("@");
332
- if (versionSuffix > 0) candidate = candidate.slice(0, versionSuffix);
333
- return getManagedPackageNames().find((name) => name.toLowerCase() === candidate) ?? null;
334
- }
335
-
336
- function readDeclaredPackageName(directoryPath) {
337
- try {
338
- const parsed = JSON.parse(readFileSync(join(directoryPath, "package.json"), "utf8"));
339
- const name = parsed?.name;
340
- return typeof name === "string" && name.trim() ? name.trim() : null;
341
- } catch {
342
- return null;
343
- }
344
- }
345
-
346
- /** An entry may point at a build output inside the package, so walk upwards. */
347
- function resolveDeclaredPackageName(entryPath) {
348
- if (!isAbsolute(entryPath)) return null;
349
- let current = resolve(entryPath);
350
- for (let depth = 0; depth <= DECLARED_NAME_LOOKUP_DEPTH; depth += 1) {
351
- const name = readDeclaredPackageName(current);
352
- if (name) return name;
353
- const parent = dirname(current);
354
- if (parent === current) return null;
355
- current = parent;
356
- }
357
- return null;
358
- }
359
-
360
- /**
361
- * Decides what a plugin entry is, by identity rather than by spelling.
362
- *
363
- * The distinction that matters is not which package an entry names but who
364
- * chose the location. A bare specifier or a path inside `node_modules` is a
365
- * reference the installer itself produced and may retire. Any other path is
366
- * somewhere a human deliberately pointed OpenCode - a checkout of this package
367
- * being developed on, most often - and is never the installer's to remove.
368
- */
369
- function classifyPluginEntry(entry, options = {}) {
370
- const {
371
- resolveDeclaredName = resolveDeclaredPackageName,
372
- baseDirectory,
373
- cacheDirectory,
374
- platform = process.platform,
375
- } = options;
376
- const specifier = pluginEntrySpecifier(entry);
377
- if (specifier === null) return { kind: UNRELATED_ENTRY, name: null };
378
-
379
- const entryPath = pluginEntryPath(specifier, baseDirectory);
380
- if (entryPath === null) {
381
- const bare = specifier.trim().toLowerCase();
382
- const name = getManagedPackageNames().find(
383
- (managed) =>
384
- bare === managed.toLowerCase() || bare.startsWith(`${managed.toLowerCase()}@`),
385
- );
386
- return name
387
- ? { kind: MANAGED_PACKAGE_ENTRY, name }
388
- : { kind: UNRELATED_ENTRY, name: null };
389
- }
390
-
391
- const inspectionPath = resolveInspectionPath(entryPath, baseDirectory);
392
- const declaredName = inspectionPath ? resolveDeclaredName(inspectionPath) : null;
393
- const managedName = declaredName
394
- ? getManagedPackageNames().find(
395
- (managed) => managed.toLowerCase() === declaredName.toLowerCase(),
396
- ) ?? null
397
- : managedNameFromPathSpelling(entryPath);
398
-
399
- if (!managedName) return { kind: UNRELATED_ENTRY, name: null };
400
-
401
- return isPackageManagerPath(entryPath, { platform, cacheDirectory, inspectionPath })
402
- ? { kind: MANAGED_PACKAGE_ENTRY, name: managedName }
403
- : {
404
- kind: LOCAL_CHECKOUT_ENTRY,
405
- name: managedName,
406
- path: inspectionPath ?? entryPath,
407
- resolvesOnDisk: Boolean(inspectionPath && existsSync(inspectionPath)),
408
- };
409
- }
410
-
411
- /**
412
- * Ensures this plugin is registered exactly once, without changing how an
413
- * existing registration is spelled. Appending the published package name is the
414
- * fallback for a config that does not reference the plugin at all, not the
415
- * canonical form every config is rewritten into.
416
- */
417
- function normalizePluginList(list, onNotice, options = {}) {
418
- const entries = Array.isArray(list)
419
- ? list.filter((entry) => entry !== null && entry !== undefined && entry !== "")
420
- : [];
421
- const classifications = entries.map((entry) => classifyPluginEntry(entry, options));
422
- // A checkout of this package already IS the registration, so a published
423
- // entry beside it is a second copy of the same plugin for OpenCode to load.
424
- // `options.checkoutRegistered` carries the same fact across config files: a
425
- // checkout registered only in opencode.json still suppresses the published
426
- // name in tui.json, and vice versa. Only a checkout of the CURRENT package
427
- // counts: the former name is valid for cleanup, never as the registration
428
- // the installer exists to ensure.
429
- const checkoutRegistered = options.checkoutRegistered === true || classifications.some(
430
- (classification) =>
431
- classification.kind === LOCAL_CHECKOUT_ENTRY && classification.name === PACKAGE_NAME,
432
- );
433
- const kept = [];
434
- let keptPublishedName = false;
435
-
436
- entries.forEach((entry, index) => {
437
- const classification = classifications[index];
438
-
439
- if (classification.kind === LOCAL_CHECKOUT_ENTRY) {
440
- kept.push(entry);
441
- if (classification.resolvesOnDisk === false) {
442
- onNotice?.(
443
- `Warning: keeping ${classification.path} registered, but it does not resolve on disk; ` +
444
- "the plugin may not load until the path exists again.",
445
- );
446
- } else {
447
- onNotice?.(
448
- `Keeping the local ${classification.name} checkout registered at ${classification.path}`,
449
- );
450
- }
451
- return;
452
- }
453
-
454
- if (classification.kind === MANAGED_PACKAGE_ENTRY) {
455
- // Retire stale duplicates, version pins, renamed packages, and paths
456
- // into package-manager output; keep one published-name entry in place
457
- // unless a checkout already covers it.
458
- const isPublishedName = pluginEntrySpecifier(entry) === PACKAGE_NAME;
459
- if (isPublishedName && !checkoutRegistered && !keptPublishedName) {
460
- keptPublishedName = true;
461
- kept.push(entry);
462
- }
463
- return;
464
- }
465
-
466
- kept.push(entry);
467
- });
468
-
469
- return checkoutRegistered || keptPublishedName ? kept : [...kept, PACKAGE_NAME];
470
- }
471
-
472
- function readLocalCheckoutSightings(historyPath) {
473
- try {
474
- const parsed = JSON.parse(readFileSync(historyPath, "utf8"));
475
- const sightings = parsed?.sightings;
476
- if (!Array.isArray(sightings)) return [];
477
- return sightings.filter(
478
- (sighting) =>
479
- sighting &&
480
- typeof sighting === "object" &&
481
- sighting.isLocalCheckout === true &&
482
- typeof sighting.root === "string" &&
483
- typeof sighting.lastSeen === "string" &&
484
- getManagedPackageNames().includes(sighting.name),
485
- );
486
- } catch {
487
- return [];
488
- }
489
- }
490
-
491
- /**
492
- * A checkout the plugin has run from that the finished config does not
493
- * register. Reported rather than restored: config history is evidence of what
494
- * happened, not authority over what the user wants registered now.
495
- */
496
- function findUnregisteredLocalCheckout(pluginList, historyPath, options = {}) {
497
- const entries = Array.isArray(pluginList) ? pluginList : [];
498
- if (entries.some((entry) => classifyPluginEntry(entry, options).kind === LOCAL_CHECKOUT_ENTRY)) {
499
- return null;
500
- }
501
- const latest = readLocalCheckoutSightings(historyPath)
502
- .sort((left, right) => (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0))
503
- .at(-1);
504
- if (!latest) return null;
505
- // The directory has to still hold the package that was recorded there. A
506
- // path gets reused - a checkout deleted and something else cloned into its
507
- // place - and a recorded path that now declares another project would
508
- // otherwise be offered as somewhere to point OpenCode back at.
509
- const declaredName = resolveDeclaredPackageName(latest.root);
510
- if (!declaredName || declaredName.toLowerCase() !== String(latest.name).toLowerCase()) {
511
- return null;
512
- }
513
- return latest;
514
- }
515
-
516
- function mergeTuiConfig(existingConfig, onNotice, options = {}) {
517
- const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {};
518
- const next = { ...existing };
519
- if (typeof next.$schema !== "string" || !next.$schema.trim()) {
520
- next.$schema = "https://opencode.ai/tui.json";
521
- }
522
- next.plugin = normalizePluginList(existing.plugin, onNotice, options);
523
- return next;
524
- }
525
-
526
- function formatJson(obj) {
527
- return `${JSON.stringify(obj, null, 2)}\n`;
528
- }
529
-
530
- function getStandaloneStoragePath(options, env = process.env) {
531
- if (options.configPath) return resolve(options.configPath);
532
- return join(resolveHomeDirectory(env), ".opencode", "oc-codex-multi-auth-accounts.json");
533
- }
534
-
535
- async function readStandaloneStorage(path) {
536
- try {
537
- const raw = await readFile(path, "utf-8");
538
- const parsed = JSON.parse(raw);
539
- // Shape validation, not just parse validation: a JSON array, scalar, or
540
- // object without an `accounts` array is unreadable by the plugin runtime
541
- // too (normalizeAccountStorage rejects it), so reporting it as a healthy
542
- // empty pool (exit 0, "No accounts configured") hides the corruption
543
- // from scripted callers that key on exit codes.
544
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
545
- return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
546
- }
547
- // Forward-compat mirror of the runtime guard: a newer schema version
548
- // must not be shown as readable accounts by this build.
549
- const version = parsed.version;
550
- if (typeof version === "number" && Number.isFinite(version) && version > 3) {
551
- return {
552
- storage: null,
553
- error: `Unsupported account storage schema version ${version}; this build supports up to version 3.`,
554
- };
555
- }
556
- if (!Array.isArray(parsed.accounts)) {
557
- return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
558
- }
559
- return {
560
- storage: normalizeStandaloneStorage(parsed),
561
- error: null,
562
- };
563
- } catch (error) {
564
- if (error?.code === "ENOENT") return { storage: null, error: null };
565
- return { storage: null, error: formatErrorForLog(error) };
566
- }
567
- }
568
-
569
- function normalizeStandaloneIdentityPart(value) {
570
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
571
- }
572
-
573
- function sameStandaloneIdentity(left, right) {
574
- const normalizedLeft = normalizeStandaloneIdentityPart(left);
575
- const normalizedRight = normalizeStandaloneIdentityPart(right);
576
- return !!normalizedLeft && !!normalizedRight && normalizedLeft === normalizedRight;
577
- }
578
-
579
- function isStandaloneOrgTokenDuplicate(left, right) {
580
- const leftOrganizationId = normalizeStandaloneIdentityPart(left?.organizationId);
581
- const rightOrganizationId = normalizeStandaloneIdentityPart(right?.organizationId);
582
- if (leftOrganizationId && rightOrganizationId && leftOrganizationId !== rightOrganizationId) return false;
583
- const leftOrgLike = !!leftOrganizationId || left?.accountIdSource === "org";
584
- const rightOrgLike = !!rightOrganizationId || right?.accountIdSource === "org";
585
- const leftTokenLike = !leftOrganizationId && left?.accountIdSource === "token";
586
- const rightTokenLike = !rightOrganizationId && right?.accountIdSource === "token";
587
- if (!((leftOrgLike && rightTokenLike) || (rightOrgLike && leftTokenLike))) return false;
588
- return sameStandaloneIdentity(left?.email, right?.email) ||
589
- sameStandaloneIdentity(left?.refreshToken, right?.refreshToken);
590
- }
591
-
592
- function mergeStandaloneAccounts(target, source) {
593
- const targetOrgLike = !!normalizeStandaloneIdentityPart(target?.organizationId) || target?.accountIdSource === "org";
594
- const sourceOrgLike = !!normalizeStandaloneIdentityPart(source?.organizationId) || source?.accountIdSource === "org";
595
- if (targetOrgLike || !sourceOrgLike) {
596
- return {
597
- ...source,
598
- ...target,
599
- organizationId: target.organizationId ?? source.organizationId,
600
- accountId: target.accountId ?? source.accountId,
601
- accountIdSource: target.accountIdSource ?? source.accountIdSource,
602
- accountLabel: target.accountLabel ?? source.accountLabel,
603
- email: target.email ?? source.email,
604
- };
605
- }
606
- return mergeStandaloneAccounts(source, target);
607
- }
608
-
609
- // Mirror of `isStaleGeneratedAccountLabel` / `dropStaleGeneratedLabel` in
610
- // lib/auth/token-utils.ts and lib/storage/normalize.ts. The standalone CLI
611
- // reads the pool through this normalizer and never through the compiled
612
- // `normalizeAccountStorage`, so without the mirror `status`, `list`, `health`,
613
- // `doctor` and `dashboard` keep printing the org-derived label the plugin
614
- // itself now drops - next to the account id, which is the identity the label
615
- // was misnaming. The marker must hold this account's own id suffix so a name
616
- // set with `codex-label` survives.
617
- const GENERATED_LABEL_PATTERN = /\s\[id:[^\]]*\]$/;
618
-
619
- function dropStaleStandaloneLabel(account) {
620
- const label = typeof account?.accountLabel === "string" ? account.accountLabel.trim() : "";
621
- const accountId = typeof account?.accountId === "string" ? account.accountId.trim() : "";
622
- if (!label || !accountId) return account;
623
- const marker = label.match(GENERATED_LABEL_PATTERN)?.[0];
624
- if (!marker) return account;
625
- const suffix = accountId.length > 6 ? accountId.slice(-6) : accountId;
626
- if (marker !== ` [id:${suffix}]`) return account;
627
- const next = { ...account };
628
- delete next.accountLabel;
629
- return next;
630
- }
631
-
632
- function normalizeStandaloneStorage(storage) {
633
- if (!Array.isArray(storage.accounts)) return storage;
634
- const accounts = [...storage.accounts];
635
- const removed = new Set();
636
- for (let i = 0; i < accounts.length; i += 1) {
637
- if (removed.has(i)) continue;
638
- for (let j = i + 1; j < accounts.length; j += 1) {
639
- if (removed.has(j) || !isStandaloneOrgTokenDuplicate(accounts[i], accounts[j])) continue;
640
- const leftOrgLike = !!normalizeStandaloneIdentityPart(accounts[i]?.organizationId) ||
641
- accounts[i]?.accountIdSource === "org";
642
- const targetIndex = leftOrgLike ? i : j;
643
- const sourceIndex = targetIndex === i ? j : i;
644
- accounts[targetIndex] = mergeStandaloneAccounts(accounts[targetIndex], accounts[sourceIndex]);
645
- removed.add(sourceIndex);
646
- if (sourceIndex === i) break;
647
- }
648
- }
649
- const normalizedAccounts = accounts
650
- .filter((_, index) => !removed.has(index))
651
- .map(dropStaleStandaloneLabel);
652
- return {
653
- ...storage,
654
- accounts: normalizedAccounts,
655
- activeIndex: Math.max(0, Math.min(storage.activeIndex ?? 0, Math.max(0, normalizedAccounts.length - 1))),
656
- };
657
- }
658
-
659
- const MASKED_VALUE = "*****";
660
- // The head/tail mask keeps eight characters, so it conceals nothing worth
661
- // concealing below thirteen: `me@x.io` would print in full and `me@x.io12`
662
- // all but its middle character. `doctor` output is what users paste into
663
- // issues, so anything shorter is replaced outright instead. Input is trimmed
664
- // first, or `" me@x.io "` clears the cutoff on padding alone and drops back
665
- // into the partial mask.
666
- const MASK_MIN_LENGTH = 13;
667
-
668
- function maskValue(value, includeSensitive) {
669
- if (includeSensitive || typeof value !== "string") return value;
670
- const trimmed = value.trim();
671
- if (!trimmed) return trimmed;
672
- if (trimmed.length < MASK_MIN_LENGTH) return MASKED_VALUE;
673
- return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
674
- }
675
-
676
- // Six characters when the id is shown in full, matching what the
677
- // in-conversation surfaces print as `id:`. Four when it is head/tail masked,
678
- // which is the tail `maskValue` already discloses as `accountId` in the same
679
- // payload, so the printed identity never reveals more of an id than the field
680
- // beside it. Nothing at all when the id was too short for that mask: the
681
- // `accountId` next to it is then `*****`, and four raw characters of a short
682
- // id can be the whole id.
683
- function accountIdSuffix(accountId, includeSensitive) {
684
- if (!accountId) return undefined;
685
- if (includeSensitive) {
686
- return accountId.length > 6 ? accountId.slice(-6) : accountId;
687
- }
688
- if (accountId.length < MASK_MIN_LENGTH) return undefined;
689
- return accountId.slice(-4);
690
- }
691
-
692
- // A member id is what tells two seats of one Business workspace apart, and no
693
- // fixed-length tail always does it: member ids sharing a six-character tail
694
- // were observed, and in a real nine-seat pool the ids are 67 characters with
695
- // no shared tail at all, so growing a tail until it separates them prints most
696
- // of the id in every row. The renderer below mirrors `resolveSeatRenderer` in
697
- // lib/account-display.ts - a tail, else one window anchored where the ids first
698
- // diverge, else short windows at each position where a pair first differs
699
- // joined by `..`, else a hash prefix, each capped - and the id whole only if
700
- // none of those separate them, which needs a 128-bit SHA-256 collision. The
701
- // hash outcome is reachable, and it prints a value that cannot be matched
702
- // against the id by eye.
703
- const STANDALONE_SEAT_MAX_LENGTH = 12;
704
- const STANDALONE_SEAT_HASH_LENGTHS = [8, 12, 16, 24, 32];
705
- const STANDALONE_SEAT_WINDOW_SEPARATOR = "..";
706
-
707
- function seatIsDisclosable(accountUserId, includeSensitive) {
708
- if (!accountUserId) return false;
709
- return includeSensitive || accountUserId.length >= MASK_MIN_LENGTH;
710
- }
711
-
712
- function seatTail(accountUserId, length) {
713
- return accountUserId.length > length ? accountUserId.slice(-length) : accountUserId;
714
- }
715
-
716
- function seatWindow(accountUserId, start, length) {
717
- if (accountUserId.length <= length) return accountUserId;
718
- const begin = Math.max(0, Math.min(start, accountUserId.length - length));
719
- return accountUserId.slice(begin, begin + length);
720
- }
721
-
722
- function seatCommonPrefixLength(values) {
723
- const [first] = values;
724
- if (first === undefined) return 0;
725
- let shared = first.length;
726
- for (const value of values) {
727
- let index = 0;
728
- while (index < shared && index < value.length && first[index] === value[index]) {
729
- index += 1;
730
- }
731
- shared = index;
732
- if (shared === 0) break;
733
- }
734
- return shared;
735
- }
736
-
737
- function seatFirstDivergence(left, right) {
738
- const limit = Math.min(left.length, right.length);
739
- let index = 0;
740
- while (index < limit && left[index] === right[index]) index += 1;
741
- return index;
742
- }
743
-
744
- // For every pair, the first index at which that pair differs - not every index
745
- // where the ids disagree, which across a handful of random-looking ids is
746
- // nearly all of them and localizes nothing.
747
- function seatDivergenceAnchors(values) {
748
- const anchors = new Set();
749
- for (let left = 0; left < values.length; left += 1) {
750
- for (let right = left + 1; right < values.length; right += 1) {
751
- anchors.add(seatFirstDivergence(values[left], values[right]));
752
- }
753
- }
754
- return [...anchors].sort((left, right) => left - right);
755
- }
756
-
757
- function seatAnchorWindowStarts(anchors, width) {
758
- const starts = [];
759
- for (const anchor of anchors) {
760
- const last = starts[starts.length - 1];
761
- if (last !== undefined && anchor < last + width) continue;
762
- starts.push(anchor);
763
- }
764
- return starts;
765
- }
766
-
767
- function resolveStandaloneSeatRenderer(accountUserIds, includeSensitive) {
768
- // Starts at the length the mask above allows, so masked output widens only
769
- // when leaving it short would print a lie.
770
- const base = includeSensitive ? 6 : 4;
771
- const distinct = [];
772
- const seen = new Set();
773
- for (const accountUserId of accountUserIds) {
774
- if (!seatIsDisclosable(accountUserId, includeSensitive)) continue;
775
- if (seen.has(accountUserId)) continue;
776
- seen.add(accountUserId);
777
- distinct.push(accountUserId);
778
- }
779
- const atBase = (accountUserId) => seatTail(accountUserId, base);
780
- if (distinct.length <= 1) return atBase;
781
-
782
- const separates = (render) => new Set(distinct.map(render)).size === distinct.length;
783
-
784
- for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
785
- const render = (accountUserId) => seatTail(accountUserId, length);
786
- if (separates(render)) return render;
787
- }
788
- const start = seatCommonPrefixLength(distinct);
789
- for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
790
- const render = (accountUserId) => seatWindow(accountUserId, start, length);
791
- if (separates(render)) return render;
792
- }
793
- const anchors = seatDivergenceAnchors(distinct);
794
- for (let width = 2; width <= STANDALONE_SEAT_MAX_LENGTH; width += 1) {
795
- const starts = seatAnchorWindowStarts(anchors, width);
796
- const rendered =
797
- starts.length * width + (starts.length - 1) * STANDALONE_SEAT_WINDOW_SEPARATOR.length;
798
- // Skipped, not abandoned: a wider window can span two nearby anchors
799
- // that needed one window each, so the cost falls as the window count
800
- // does. Mirrors `resolveSeatRenderer` in lib/account-display.ts, where
801
- // the measured counter-example is written out.
802
- if (rendered > STANDALONE_SEAT_MAX_LENGTH) continue;
803
- const render = (accountUserId) =>
804
- starts
805
- .map((windowStart) => accountUserId.slice(windowStart, windowStart + width))
806
- .join(STANDALONE_SEAT_WINDOW_SEPARATOR);
807
- if (separates(render)) return render;
808
- }
809
- for (const length of STANDALONE_SEAT_HASH_LENGTHS) {
810
- const render = (accountUserId) => createHash("sha256").update(accountUserId).digest("hex").slice(0, length);
811
- if (separates(render)) return render;
812
- }
813
- return (accountUserId) => accountUserId;
814
- }
815
-
816
- function summarizeStandaloneAccounts(storage, includeSensitive, tag) {
817
- const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
818
- const normalizedTag = typeof tag === "string" ? tag.trim().toLowerCase() : "";
819
- const entries = accounts
820
- .map((account, index) => ({ account, index }))
821
- .filter(({ account }) => !normalizedTag ||
822
- (Array.isArray(account?.accountTags) &&
823
- account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)));
824
- const renderSeat = resolveStandaloneSeatRenderer(
825
- entries.map(({ account }) =>
826
- (typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "") || undefined,
827
- ),
828
- includeSensitive,
829
- );
830
- return entries
831
- .map(({ account, index }) => {
832
- const trimmedId =
833
- typeof account?.accountId === "string" ? account.accountId.trim() : "";
834
- const accountId = trimmedId || undefined;
835
- // Members of one Business workspace share `accountId`, so the seat is
836
- // what tells them apart. It is carried masked next to its suffix for
837
- // the same reason `accountId` is: so the printed `seat:` discloses no
838
- // more of an id than the field beside it unless telling two seats
839
- // apart requires it.
840
- const trimmedUserId =
841
- typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "";
842
- const accountUserId = trimmedUserId || undefined;
843
- return {
844
- index,
845
- label: account?.accountLabel ?? `Account ${index + 1}`,
846
- email: maskValue(account?.email, includeSensitive),
847
- accountId: maskValue(accountId, includeSensitive),
848
- idSuffix: accountIdSuffix(accountId, includeSensitive),
849
- accountUserId: maskValue(accountUserId, includeSensitive),
850
- seatSuffix: seatIsDisclosable(accountUserId, includeSensitive)
851
- ? renderSeat(accountUserId)
852
- : undefined,
853
- accountIdSource: account?.accountIdSource,
854
- enabled: account?.enabled !== false,
855
- hasRefreshToken: typeof account?.refreshToken === "string" && account.refreshToken.length > 0,
856
- hasAccessToken: typeof account?.accessToken === "string" && account.accessToken.length > 0,
857
- expiresAt: account?.expiresAt,
858
- expired: typeof account?.expiresAt === "number" ? account.expiresAt <= Date.now() : undefined,
859
- tags: Array.isArray(account?.accountTags) ? account.accountTags : [],
860
- note: account?.accountNote,
861
- rateLimitResetTimes: account?.rateLimitResetTimes ?? {},
862
- quotaExhaustedUntil: account?.quotaExhaustedUntil,
863
- };
864
- });
865
- }
866
-
867
- function printStandaloneResult(command, payload, json) {
868
- if (json) {
869
- console.log(JSON.stringify(payload, null, 2));
870
- return;
871
- }
872
- console.log(`oc-codex-multi-auth ${command}`);
873
- if (payload.message) console.log(payload.message);
874
- console.log(`Storage: ${payload.storagePath}`);
875
- console.log(`Accounts: ${payload.totalAccounts}`);
876
- if (Array.isArray(payload.accounts)) {
877
- for (const account of payload.accounts) {
878
- const identity = [
879
- account.email,
880
- account.idSuffix ? `id:${account.idSuffix}` : undefined,
881
- account.seatSuffix ? `seat:${account.seatSuffix}` : undefined,
882
- ]
883
- .filter(Boolean)
884
- .join(", ");
885
- const name = identity ? `${account.label} (${identity})` : account.label;
886
- console.log(`- [${account.index}] ${name} enabled=${account.enabled} refresh=${account.hasRefreshToken} access=${account.hasAccessToken}`);
887
- }
888
- }
889
- if (payload.error) console.log(`Error: ${payload.error}`);
890
- for (const fix of payload.appliedFixes ?? []) console.log(`Fixed: ${fix}`);
891
- for (const error of payload.fixErrors ?? []) console.log(`Repair failed: ${error}`);
892
- if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
893
- }
894
-
895
- /**
896
- * Import compiled modules from dist/ so the standalone CLI behaves identically
897
- * to the in-conversation tools. dist/ ships in the npm package (files
898
- * allowlist) and none of these modules import the OpenCode plugin runtime, so
899
- * they load cleanly in plain Node.
900
- */
901
- async function loadDistModules(relativePaths, label) {
902
- const distRoot = join(repoRoot, "dist", "lib");
903
- const toUrl = (rel) => pathToFileURL(join(distRoot, rel)).href;
904
- try {
905
- return await Promise.all(relativePaths.map((rel) => import(toUrl(rel))));
906
- } catch (error) {
907
- throw new Error(
908
- `Could not load ${label} runtime from dist/. Build the package first (npm run build). Cause: ${formatErrorForLog(error)}`,
909
- );
910
- }
911
- }
912
-
913
- async function loadWarmRuntime(env) {
914
- const [storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod] = await loadDistModules(
915
- [
916
- "storage.js",
917
- "codex-usage.js",
918
- "accounts/warm-request.js",
919
- "accounts/warm.js",
920
- "shutdown.js",
921
- "accounts/warm-recovery.js",
922
- ],
923
- "warm",
924
- );
925
- // Unlike the plugin, this CLI *is* the process, so it owns termination:
926
- // Ctrl+C must abort the warm run rather than wait for it to drain.
927
- // Refreshing a token here persists credentials, which registers the
928
- // shutdown handler via the storage lock.
929
- shutdownMod.setShutdownOwnsProcess(true);
930
- return { storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod };
931
- }
932
-
933
- async function loadLimitsRuntime(env) {
934
- const [storageMod, usageMod, shutdownMod, loggerMod, configMod] =
935
- await loadDistModules(
936
- ["storage.js", "codex-usage.js", "shutdown.js", "logger.js", "config.js"],
937
- "limits",
938
- );
939
- // Fetching usage can refresh (and therefore persist) a token, so the same
940
- // process-owns-termination rule as `warm` applies.
941
- shutdownMod.setShutdownOwnsProcess(true);
942
- return { storageMod, usageMod, shutdownMod, loggerMod, configMod };
943
- }
944
-
945
- export async function runWarmCommand(parsed, options = {}) {
946
- const { env = process.env } = options;
947
- const storagePath = getStandaloneStoragePath(parsed, env);
948
-
949
- let runtime;
950
- try {
951
- runtime = await (options.loadWarmRuntime ?? loadWarmRuntime)(env);
952
- } catch (error) {
953
- const payload = { command: "warm", storagePath, error: formatErrorForLog(error) };
954
- printWarmResult(payload, parsed.json);
955
- return { exitCode: 1, action: "warm", storagePath };
956
- }
957
-
958
- const { storageMod, usageMod, warmReqMod, warmMod, recoveryMod } = runtime;
959
- // Point dist storage at the resolved accounts file so a refreshed token is
960
- // persisted to the SAME file the rest of the toolchain reads.
961
- storageMod.setStoragePathDirect(storagePath);
962
-
963
- let storage = null;
964
- try {
965
- storage = await storageMod.loadAccounts();
966
- } catch (error) {
967
- // Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
968
- // upgrade hint; surface them rather than crashing the CLI.
969
- const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
970
- const payload = { command: "warm", storagePath, error: `${formatErrorForLog(error)}${hint}` };
971
- printWarmResult(payload, parsed.json);
972
- return { exitCode: 1, action: "warm", storagePath };
973
- }
974
- const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
975
- if (accounts.length === 0) {
976
- // `loadAccounts` swallows parse/IO errors and returns null. Probe the
977
- // file so a corrupt storage fails like `status`/`doctor` do (exit 1)
978
- // instead of reporting a healthy empty pool. ENOENT stays a silent
979
- // empty pool: a missing file legitimately means no accounts yet.
980
- const probe = await readStandaloneStorage(storagePath);
981
- if (probe.error) {
982
- const payload = { command: "warm", storagePath, error: probe.error };
983
- printWarmResult(payload, parsed.json);
984
- return { exitCode: 1, action: "warm", storagePath };
985
- }
986
- const payload = {
987
- command: "warm",
988
- storagePath,
989
- totalAccounts: 0,
990
- warmed: 0,
991
- blocksCleared: 0,
992
- failed: 0,
993
- skipped: 0,
994
- results: [],
995
- message: "No accounts configured.",
996
- nextAction: "Run opencode auth login.",
997
- };
998
- printWarmResult(payload, parsed.json);
999
- return { exitCode: 0, action: "warm", storagePath };
1000
- }
1001
-
1002
- // Same adapter as lib/tools/codex-warm.ts createWarmOne: refresh → resolve
1003
- // account id → open the usage window; map an exhausted (quota-429) account
1004
- // to a failure so it is not reported as warmed.
1005
- const succeeded = [];
1006
- const warmOne = async (account) => {
1007
- const snapshot = { ...account, rateLimitResetTimes: { ...account.rateLimitResetTimes } };
1008
- const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
1009
- const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
1010
- if (!accountId) {
1011
- return { status: "failed", detail: "could not resolve account id (re-login may be required)" };
1012
- }
1013
- const result = await warmReqMod.warmAccountWindow({
1014
- accountId,
1015
- accessToken,
1016
- organizationId: account.organizationId,
1017
- });
1018
- if (result.status === "exhausted") {
1019
- return { status: "failed", detail: result.detail ?? "quota/usage limit reached" };
1020
- }
1021
- if (!result.rateLimited && result.model) succeeded.push({
1022
- account: { ...snapshot, refreshToken: account.refreshToken }, model: result.model, accessToken,
1023
- });
1024
- return { status: "warmed" };
1025
- };
1026
-
1027
- const summary = await warmMod.warmAccounts(accounts, warmOne);
1028
- let blocksCleared = 0;
1029
- let blockClearError;
1030
- for (const observation of succeeded) {
1031
- let changed = false;
1032
- try {
1033
- const completed = await recoveryMod.recoverWarmedAccount(observation, () => { changed = true; });
1034
- changed = completed || changed;
1035
- } catch {
1036
- blockClearError = "Failed to clear local blocks; warm results are unchanged.";
1037
- }
1038
- if (changed) blocksCleared++;
1039
- }
1040
- const payload = {
1041
- command: "warm",
1042
- blocksCleared,
1043
- blockClearError,
1044
- storagePath,
1045
- totalAccounts: summary.total,
1046
- warmed: summary.warmedCount,
1047
- failed: summary.failedCount,
1048
- skipped: summary.skippedCount,
1049
- results: summary.results.map((r) => ({
1050
- index: r.index,
1051
- email: maskValue(accounts[r.index]?.email, parsed.includeSensitive),
1052
- status: r.status,
1053
- detail: r.detail,
1054
- })),
1055
- };
1056
- printWarmResult(payload, parsed.json);
1057
- return { exitCode: summary.failedCount > 0 ? 1 : 0, action: "warm", storagePath };
1058
- }
1059
-
1060
- function printWarmResult(payload, json) {
1061
- if (json) {
1062
- console.log(JSON.stringify(payload, null, 2));
1063
- return;
1064
- }
1065
- console.log(`oc-codex-multi-auth warm`);
1066
- if (payload.message) console.log(payload.message);
1067
- console.log(`Storage: ${payload.storagePath}`);
1068
- if (payload.error) {
1069
- console.log(`Error: ${payload.error}`);
1070
- return;
1071
- }
1072
- console.log(`Accounts: ${payload.totalAccounts}`);
1073
- for (const r of payload.results ?? []) {
1074
- const label = r.email ? `[${r.index}] ${r.email}` : `[${r.index}]`;
1075
- const detail = r.detail ? ` — ${r.detail}` : "";
1076
- console.log(`- ${label}: ${r.status}${detail}`);
1077
- }
1078
- console.log(`Summary: ${payload.warmed} warmed, ${payload.failed} failed, ${payload.skipped} skipped`);
1079
- console.log(`Blocks cleared: ${payload.blocksCleared ?? 0}`);
1080
- if (payload.blockClearError) console.log(payload.blockClearError);
1081
- if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
1082
- }
1083
-
1084
- /**
1085
- * `limits` — show live 5-hour and weekly Codex usage per account (#209).
1086
- *
1087
- * Reuses the compiled `codex-usage` runtime so the CLI reports the same windows
1088
- * as the in-conversation `codex-limits` tool. The locally persisted
1089
- * `rateLimitResetTimes` is carried in the payload as well: it is the only
1090
- * rate-limit state available for an account whose live fetch fails, and
1091
- * `--json` consumers of the previous behavior still find the field.
1092
- */
1093
- export async function runLimitsCommand(parsed, options = {}) {
1094
- const { env = process.env } = options;
1095
- const storagePath = getStandaloneStoragePath(parsed, env);
1096
-
1097
- let runtime;
1098
- try {
1099
- runtime = await (options.loadLimitsRuntime ?? loadLimitsRuntime)(env);
1100
- } catch (error) {
1101
- const payload = { command: "limits", storagePath, error: formatErrorForLog(error) };
1102
- printLimitsResult(payload, parsed.json);
1103
- return { exitCode: 1, action: "limits", storagePath };
1104
- }
1105
-
1106
- const { storageMod, usageMod, loggerMod, configMod } = runtime;
1107
- const quotaDisplay = configMod.getQuotaDisplay(configMod.loadPluginConfig());
1108
- // Point dist storage at the resolved accounts file so a refreshed token is
1109
- // persisted to the SAME file the rest of the toolchain reads.
1110
- storageMod.setStoragePathDirect(storagePath);
1111
-
1112
- let storage = null;
1113
- try {
1114
- storage = await storageMod.loadAccounts();
1115
- } catch (error) {
1116
- // Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
1117
- // upgrade hint; surface them rather than crashing the CLI.
1118
- const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
1119
- const payload = { command: "limits", storagePath, error: `${formatErrorForLog(error)}${hint}` };
1120
- printLimitsResult(payload, parsed.json);
1121
- return { exitCode: 1, action: "limits", storagePath };
1122
- }
1123
- const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
1124
- if (accounts.length === 0) {
1125
- // Same probe contract as `warm`: a corrupt file exits 1 like
1126
- // `status`/`doctor`; a missing file stays a silent empty pool.
1127
- const probe = await readStandaloneStorage(storagePath);
1128
- if (probe.error) {
1129
- const payload = { command: "limits", storagePath, error: probe.error };
1130
- printLimitsResult(payload, parsed.json);
1131
- return { exitCode: 1, action: "limits", storagePath };
1132
- }
1133
- const payload = {
1134
- command: "limits",
1135
- storagePath,
1136
- totalAccounts: 0,
1137
- accounts: [],
1138
- message: "No accounts configured.",
1139
- nextAction: "Run opencode auth login.",
1140
- };
1141
- printLimitsResult(payload, parsed.json);
1142
- return { exitCode: 0, action: "limits", storagePath };
1143
- }
1144
-
1145
- // Same workspace dedupe the codex-limits tool applies, so two entries for one
1146
- // workspace are not billed and printed twice.
1147
- const indices = usageMod.deduplicateUsageAccountIndices(storage);
1148
- // `--tag` must gate which accounts are contacted at all, not just which are
1149
- // printed: an untagged account would otherwise be billed a usage fetch and
1150
- // could have its refreshed credentials persisted.
1151
- const normalizedTag =
1152
- typeof parsed.tag === "string" ? parsed.tag.trim().toLowerCase() : "";
1153
- const results = [];
1154
- let failedCount = 0;
1155
-
1156
- for (const index of indices) {
1157
- const account = accounts[index];
1158
- if (!account) continue;
1159
- if (
1160
- normalizedTag &&
1161
- !(
1162
- Array.isArray(account.accountTags) &&
1163
- account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)
1164
- )
1165
- ) {
1166
- continue;
1167
- }
1168
- const entry = {
1169
- index,
1170
- label: account.accountLabel ?? `Account ${index + 1}`,
1171
- email: maskValue(account.email, parsed.includeSensitive),
1172
- rateLimitResetTimes: account.rateLimitResetTimes ?? {},
1173
- quotaExhaustedUntil: account.quotaExhaustedUntil,
1174
- };
1175
- try {
1176
- const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
1177
- const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
1178
- if (!accountId) {
1179
- throw new Error("could not resolve account id (re-login may be required)");
1180
- }
1181
- const usage = usageMod.parseCodexUsagePayload(
1182
- await usageMod.fetchCodexUsage({
1183
- accountId,
1184
- accessToken,
1185
- organizationId: account.organizationId,
1186
- }),
1187
- quotaDisplay,
1188
- );
1189
- const quotaExhaustedResetAtMs = usageMod.getUsageQuotaExhaustedResetAtMs([
1190
- usage.primary,
1191
- usage.secondary,
1192
- ]);
1193
- if (quotaExhaustedResetAtMs !== undefined) {
1194
- try {
1195
- await usageMod.persistUsageQuotaExhaustion(account, quotaExhaustedResetAtMs);
1196
- } catch (error) {
1197
- loggerMod.logWarn(
1198
- `[${PACKAGE_NAME}] Failed to persist exhausted usage quota: ${formatErrorForLog(error)}`,
1199
- );
1200
- }
1201
- }
1202
- if (usageMod.isUsageQuotaRecovered([usage.primary, usage.secondary])) {
1203
- try {
1204
- await usageMod.persistUsageQuotaRecovery(account);
1205
- } catch {
1206
- loggerMod.logWarn("Failed to persist recovered usage quota");
1207
- }
1208
- }
1209
- entry.planType = usage.planType;
1210
- entry.credits = usage.credits;
1211
- // Raw counts stay in `resetCredits` and the rendered line lives in
1212
- // its own field: embedding the English summary inside the counts
1213
- // object would make `--json` consumers parse presentation text to
1214
- // reach a number that is already beside it.
1215
- entry.resetCredits = usage.resetCredits;
1216
- entry.resetCreditsSummary = usage.resetCredits
1217
- ? usageMod.formatResetCredits(usage.resetCredits)
1218
- : null;
1219
- entry.limits = usage.limits;
1220
- } catch (error) {
1221
- // `ensureCodexUsageAccessToken` can surface a raw OAuth refresh
1222
- // response, so the message is redacted through the logger's token
1223
- // patterns before it reaches stdout, JSON output, or CI logs.
1224
- // Truncation alone does not protect bearer/JWT/refresh-token material.
1225
- entry.error = loggerMod.maskString(formatErrorForLog(error)).slice(0, 160);
1226
- failedCount += 1;
1227
- }
1228
- results.push(entry);
1229
- }
1230
-
1231
- const payload = {
1232
- command: "limits",
1233
- storagePath,
1234
- totalAccounts: accounts.length,
1235
- shownAccounts: results.length,
1236
- accounts: results,
1237
- };
1238
- printLimitsResult(payload, parsed.json);
1239
- return { exitCode: failedCount > 0 ? 1 : 0, action: "limits", storagePath };
1240
- }
1241
-
1242
- function printLimitsResult(payload, json) {
1243
- if (json) {
1244
- console.log(JSON.stringify(payload, null, 2));
1245
- return;
1246
- }
1247
- console.log(`oc-codex-multi-auth limits`);
1248
- if (payload.message) console.log(payload.message);
1249
- console.log(`Storage: ${payload.storagePath}`);
1250
- if (payload.error) {
1251
- console.log(`Error: ${payload.error}`);
1252
- return;
1253
- }
1254
- console.log(`Accounts: ${payload.totalAccounts}`);
1255
- for (const account of payload.accounts ?? []) {
1256
- const label = account.email ? `${account.label} (${account.email})` : account.label;
1257
- console.log(`- [${account.index}] ${label}`);
1258
- if (account.error) {
1259
- console.log(` Error: ${account.error}`);
1260
- continue;
1261
- }
1262
- for (const limit of account.limits ?? []) {
1263
- console.log(` ${limit.name}: ${limit.summary}`);
1264
- }
1265
- if ((account.limits ?? []).length === 0) {
1266
- console.log(" No usage windows reported yet.");
1267
- }
1268
- if (account.planType) console.log(` Plan: ${account.planType}`);
1269
- if (account.credits) console.log(` Credits: ${account.credits}`);
1270
- if (account.resetCredits && account.resetCredits.available > 0) {
1271
- console.log(` Resets: ${account.resetCreditsSummary}`);
1272
- }
1273
- }
1274
- if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
1275
- }
1276
-
1277
- export async function runStandaloneCommand(command, argv = [], options = {}) {
1278
- const parsed = parseStandaloneArgs(argv);
1279
- if (command === "diag") {
1280
- command = "doctor";
1281
- parsed.deep = true;
1282
- }
1283
- if (parsed.help) {
1284
- printHelp();
1285
- return { exitCode: 0, action: "help" };
1286
- }
1287
- if (command === "warm") {
1288
- return runWarmCommand(parsed, options);
1289
- }
1290
- if (command === "limits") {
1291
- return runLimitsCommand(parsed, options);
1292
- }
1293
- const { env = process.env } = options;
1294
- const storagePath = getStandaloneStoragePath(parsed, env);
1295
- const repairRequested = command === "doctor" && parsed.fix;
1296
- let storage = null;
1297
- let error = null;
1298
- if (parsed.configPath || !repairRequested) {
1299
- ({ storage, error } = await readStandaloneStorage(storagePath));
1300
- }
1301
- const appliedFixes = [];
1302
- const fixErrors = [];
1303
- if (repairRequested && !error) {
1304
- const previousKeychain = process.env.CODEX_KEYCHAIN;
1305
- try {
1306
- const loadDoctorRuntime = options.loadDoctorRuntime ?? (() => loadDistModules(
1307
- ["storage.js", "tools/doctor-repair.js", "shutdown.js"], "doctor",
1308
- ));
1309
- const [storageMod, repairMod, shutdownMod] = await loadDoctorRuntime();
1310
- // A CLI file selection must not read or replace the global keychain pool.
1311
- if (parsed.configPath) process.env.CODEX_KEYCHAIN = "0";
1312
- storageMod.setStoragePathDirect(storagePath);
1313
- shutdownMod.setShutdownOwnsProcess(true);
1314
- try {
1315
- storage = await storageMod.loadAccounts();
1316
- } catch (loadError) {
1317
- // Typed storage errors (UNSUPPORTED_SCHEMA_VERSION, unknown V2)
1318
- // carry exact in-tree copy plus an upgrade/recovery hint, and the
1319
- // load never got far enough to attempt a repair. Surface them on
1320
- // the error channel (exit 1) instead of the generic catch below,
1321
- // which is reserved for unknown throws so upstream failure text
1322
- // never reaches output unredacted.
1323
- if (loadError && typeof loadError.code === "string") {
1324
- const hint = typeof loadError.hint === "string" ? ` ${loadError.hint}` : "";
1325
- error = `${formatErrorForLog(loadError)}${hint}`;
1326
- } else {
1327
- throw loadError;
1328
- }
1329
- }
1330
- if (!storage && !error) {
1331
- // `loadAccounts` swallows JSON parse/IO errors and returns null. In
1332
- // default-path mode the pre-read above was skipped (keychain routing
1333
- // may own the pool), so probe the JSON file here: a corrupt file must
1334
- // surface as a parse error (exit 1) instead of "No accounts
1335
- // configured" (exit 0). ENOENT stays silent - a missing file with an
1336
- // empty keychain legitimately means no accounts yet. Skipped when a
1337
- // typed load error already set `error`; the probe would only
1338
- // overwrite the precise schema message with its own paraphrase.
1339
- const probe = await readStandaloneStorage(storagePath);
1340
- if (probe.error) error = probe.error;
1341
- }
1342
- if (!error) {
1343
- const repair = await repairMod.repairDoctorAccounts(storage?.accounts ?? []);
1344
- appliedFixes.push(...repair.appliedFixes);
1345
- fixErrors.push(...repair.fixErrors);
1346
- storage = (await storageMod.loadAccounts()) ?? storage;
1347
- }
1348
- } catch {
1349
- fixErrors.push("Doctor repair could not complete. Check the selected storage file and installed runtime.");
1350
- } finally {
1351
- if (parsed.configPath) {
1352
- if (previousKeychain === undefined) delete process.env.CODEX_KEYCHAIN;
1353
- else process.env.CODEX_KEYCHAIN = previousKeychain;
1354
- }
1355
- }
1356
- }
1357
- const accounts = summarizeStandaloneAccounts(storage, parsed.includeSensitive, parsed.tag);
1358
- const totalAccounts = Array.isArray(storage?.accounts) ? storage.accounts.length : 0;
1359
- const payload = {
1360
- command,
1361
- storagePath,
1362
- totalAccounts,
1363
- shownAccounts: accounts.length,
1364
- activeIndex: typeof storage?.activeIndex === "number" ? storage.activeIndex : 0,
1365
- activeIndexByFamily: storage?.activeIndexByFamily ?? {},
1366
- accounts,
1367
- error,
1368
- };
1369
- if (command === "dashboard") {
1370
- payload.message = "Standalone dashboard server is not launched by this safe CLI; use status/list/limits/health or OpenCode codex-dashboard.";
1371
- payload.nextAction = "Run oc-codex-multi-auth status or open OpenCode and call codex-dashboard.";
1372
- } else if (command === "doctor") {
1373
- payload.message = error ? "Storage could not be parsed." : totalAccounts > 0 ? "Local diagnostics completed." : "No accounts configured.";
1374
- payload.deep = parsed.deep;
1375
- payload.fixApplied = parsed.fix ? appliedFixes.length > 0 : undefined;
1376
- if (parsed.fix) {
1377
- payload.appliedFixes = appliedFixes;
1378
- payload.fixErrors = fixErrors;
1379
- }
1380
- payload.nextAction = totalAccounts > 0 ? "Run oc-codex-multi-auth health --json for scriptable checks." : "Run opencode auth login.";
1381
- } else if (command === "health") {
1382
- payload.healthyCount = accounts.filter((account) => account.enabled && account.hasRefreshToken).length;
1383
- payload.unhealthyCount = accounts.filter((account) => !account.enabled || !account.hasRefreshToken).length;
1384
- } else if (command === "status") {
1385
- payload.message = totalAccounts > 0 ? "Account storage loaded." : "No accounts configured.";
1386
- }
1387
- printStandaloneResult(command, payload, parsed.json);
1388
- return { exitCode: error || fixErrors.length > 0 ? 1 : 0, action: command, storagePath };
1389
- }
1390
-
1391
- // Top-level keys inside `provider.openai` that the installer owns absolutely.
1392
- // These are always sourced from the template (overwritten or removed) so the
1393
- // plugin's required runtime shape is authoritative. Any OTHER key the user has
1394
- // placed under `provider.openai` is preserved as-is. `models` is handled
1395
- // separately because it's a map where user-added model ids must survive while
1396
- // template-shipped ids win on collision.
1397
- const MANAGED_OPENAI_KEYS = new Set(["baseURL", "apiKey", "options"]);
1398
-
1399
- function isPlainObject(value) {
1400
- return value !== null && typeof value === "object" && !Array.isArray(value);
1401
- }
1402
-
1403
- // Deep-merge `provider.openai` preserving unknown user keys while letting the
1404
- // installer overwrite the managed shape it ships. This replaces the earlier
1405
- // wholesale overwrite which clobbered custom user-added keys (see audit top-20
1406
- // #6).
1407
- function mergeOpenaiProvider(existingOpenai, templateOpenai, options = {}) {
1408
- const existingSafe = isPlainObject(existingOpenai) ? existingOpenai : {};
1409
- const templateSafe = isPlainObject(templateOpenai) ? templateOpenai : {};
1410
- const modelKeysToRemove = options.modelKeysToRemove instanceof Set
1411
- ? options.modelKeysToRemove
1412
- : new Set();
1413
-
1414
- const result = {};
1415
-
1416
- // 1. Start with the user's non-managed keys (unknown-to-installer settings).
1417
- for (const [key, value] of Object.entries(existingSafe)) {
1418
- if (MANAGED_OPENAI_KEYS.has(key)) continue;
1419
- if (key === "models") continue; // handled explicitly below
1420
- result[key] = value;
1421
- }
1422
-
1423
- // 2. Apply template-managed keys. Installer is source of truth for these.
1424
- for (const [key, value] of Object.entries(templateSafe)) {
1425
- if (key === "models") continue; // handled explicitly below
1426
- result[key] = value;
1427
- }
1428
-
1429
- // 3. Merge `models` by id: template wins on collision, user-added ids survive.
1430
- const existingModels = isPlainObject(existingSafe.models) ? existingSafe.models : {};
1431
- const templateModels = isPlainObject(templateSafe.models) ? templateSafe.models : {};
1432
- const prunedExistingModels = Object.fromEntries(
1433
- Object.entries(existingModels).filter(([key]) => !modelKeysToRemove.has(key)),
1434
- );
1435
- const mergedModels = { ...prunedExistingModels, ...templateModels };
1436
- if (Object.keys(mergedModels).length > 0) {
1437
- result.models = mergedModels;
1438
- }
1439
-
1440
- return result;
1441
- }
1442
-
1443
- // Naive line-by-line diff for displaying config changes in dry-run. Good enough
1444
- // for eyeballing; not intended to be parsed or round-tripped.
1445
- function formatConfigDiff(existingConfig, nextConfig) {
1446
- const oldText = existingConfig === undefined ? "" : formatJson(existingConfig);
1447
- const newText = formatJson(nextConfig);
1448
- if (oldText === newText) {
1449
- return "(no changes)";
1450
- }
1451
- const lines = [];
1452
- lines.push("--- existing");
1453
- lines.push("+++ proposed");
1454
- if (existingConfig === undefined) {
1455
- lines.push("- (no existing config)");
1456
- } else {
1457
- for (const line of oldText.split("\n")) {
1458
- lines.push(`- ${line}`);
1459
- }
1460
- }
1461
- for (const line of newText.split("\n")) {
1462
- lines.push(`+ ${line}`);
1463
- }
1464
- return lines.join("\n");
1465
- }
1466
-
1467
- function formatRedactedConfigDiff(existingConfig, nextConfig) {
1468
- const missing = Symbol("missing");
1469
- const changes = [];
1470
- const visit = (existing, next, path) => {
1471
- if (existing === missing) {
1472
- changes.push(`+ ${path}`);
1473
- return;
1474
- }
1475
- if (next === missing) {
1476
- changes.push(`- ${path}`);
1477
- return;
1478
- }
1479
- if (Object.is(existing, next)) return;
1480
-
1481
- if (Array.isArray(existing) && Array.isArray(next)) {
1482
- const length = Math.max(existing.length, next.length);
1483
- for (let index = 0; index < length; index += 1) {
1484
- visit(
1485
- index < existing.length ? existing[index] : missing,
1486
- index < next.length ? next[index] : missing,
1487
- `${path}[${index}]`,
1488
- );
1489
- }
1490
- return;
1491
- }
1492
-
1493
- if (isPlainObject(existing) && isPlainObject(next)) {
1494
- const keys = new Set([...Object.keys(existing), ...Object.keys(next)]);
1495
- for (const key of keys) {
1496
- visit(
1497
- Object.hasOwn(existing, key) ? existing[key] : missing,
1498
- Object.hasOwn(next, key) ? next[key] : missing,
1499
- `${path}.${key}`,
1500
- );
1501
- }
1502
- return;
1503
- }
1504
-
1505
- changes.push(`~ ${path}`);
1506
- };
1507
-
1508
- visit(existingConfig === undefined ? missing : existingConfig, nextConfig, "$");
1509
- return changes.length > 0 ? changes.join("\n") : "(no changes)";
1510
- }
1511
-
1512
- function mergeFullTemplate(modernTemplate, legacyTemplate) {
1513
- const modernModels = modernTemplate.provider?.openai?.models ?? {};
1514
- const legacyModels = legacyTemplate.provider?.openai?.models ?? {};
1515
- const overlappingKeys = Object.keys(modernModels).filter((key) => Object.hasOwn(legacyModels, key));
1516
-
1517
- if (overlappingKeys.length > 0) {
1518
- throw new Error(`Full config template collision for model keys: ${overlappingKeys.join(", ")}`);
1519
- }
1520
-
1521
- return {
1522
- ...modernTemplate,
1523
- provider: {
1524
- ...(modernTemplate.provider ?? {}),
1525
- openai: {
1526
- ...(modernTemplate.provider?.openai ?? {}),
1527
- models: {
1528
- ...modernModels,
1529
- ...legacyModels,
1530
- },
1531
- },
1532
- },
1533
- };
1534
- }
1535
-
1536
- function getTemplateModelKeys(template) {
1537
- return new Set(Object.keys(template.provider?.openai?.models ?? {}));
1538
- }
1539
-
1540
- async function readJson(filePath) {
1541
- const content = await readFile(filePath, "utf-8");
1542
- return JSON.parse(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content);
1543
- }
1544
-
1545
- async function renameWithWindowsRetry(sourcePath, destinationPath) {
1546
- let lastError = null;
1547
-
1548
- for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
1549
- try {
1550
- await rename(sourcePath, destinationPath);
1551
- return;
1552
- } catch (error) {
1553
- if (isWindowsLockError(error)) {
1554
- lastError = error;
1555
- await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
1556
- continue;
1557
- }
1558
- throw error;
1559
- }
1560
- }
1561
-
1562
- if (lastError) {
1563
- throw lastError;
1564
- }
1565
- }
1566
-
1567
- async function removeWithWindowsRetry(path, options) {
1568
- let lastError = null;
1569
-
1570
- for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
1571
- try {
1572
- await rm(path, options);
1573
- return;
1574
- } catch (error) {
1575
- if (isWindowsLockError(error)) {
1576
- lastError = error;
1577
- await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
1578
- continue;
1579
- }
1580
- throw error;
1581
- }
1582
- }
1583
-
1584
- if (lastError) {
1585
- throw lastError;
1586
- }
1587
- }
1588
-
1589
- async function writeFileAtomic(filePath, content) {
1590
- const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
1591
- const tempPath = `${filePath}.${uniqueSuffix}.tmp`;
1592
-
1593
- try {
1594
- await mkdir(dirname(filePath), { recursive: true });
1595
- await writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
1596
- await renameWithWindowsRetry(tempPath, filePath);
1597
- } catch (error) {
1598
- await rm(tempPath, { force: true }).catch(() => {});
1599
- throw error;
1600
- }
1601
- }
1602
-
1603
- async function loadTemplate(mode, paths) {
1604
- if (mode === "modern") {
1605
- return readJson(paths.modernTemplatePath);
1606
- }
1607
- if (mode === "legacy") {
1608
- return readJson(paths.legacyTemplatePath);
1609
- }
1610
-
1611
- const [modernTemplate, legacyTemplate] = await Promise.all([
1612
- readJson(paths.modernTemplatePath),
1613
- readJson(paths.legacyTemplatePath),
1614
- ]);
1615
-
1616
- return mergeFullTemplate(modernTemplate, legacyTemplate);
1617
- }
1618
-
1619
- async function copyFileWithWindowsRetry(sourcePath, destinationPath) {
1620
- let lastError = null;
1621
-
1622
- for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
1623
- try {
1624
- await copyFile(sourcePath, destinationPath);
1625
- return;
1626
- } catch (error) {
1627
- if (isWindowsLockError(error)) {
1628
- lastError = error;
1629
- await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
1630
- continue;
1631
- }
1632
- throw error;
1633
- }
1634
- }
1635
-
1636
- if (lastError) {
1637
- throw lastError;
1638
- }
1639
- }
1640
-
1641
- async function backupConfig(sourcePath, dryRun) {
1642
- const timestamp = new Date()
1643
- .toISOString()
1644
- .replace(/[:.]/g, "-")
1645
- .replace("T", "_")
1646
- .replace("Z", "");
1647
- const backupPath = `${sourcePath}.bak-${timestamp}`;
1648
- if (!dryRun) {
1649
- await copyFileWithWindowsRetry(sourcePath, backupPath);
1650
- }
1651
- return backupPath;
1652
- }
1653
-
1654
- async function removePluginFromCachePackage(paths, dryRun) {
1655
- if (!existsSync(paths.cachePackageJson)) {
1656
- return;
1657
- }
1658
- if (!isEvictableCachePath(paths.cachePackageJson, paths.cacheDir)) {
1659
- log(`Warning: refusing to update ${paths.cachePackageJson}: it does not resolve inside the OpenCode cache.`);
1660
- return;
1661
- }
1662
-
1663
- let cacheData;
1664
- try {
1665
- cacheData = await readJson(paths.cachePackageJson);
1666
- } catch (error) {
1667
- log(`Warning: Could not parse ${paths.cachePackageJson} (${formatErrorForLog(error)}). Skipping.`);
1668
- return;
1669
- }
1670
-
1671
- const sections = [
1672
- "dependencies",
1673
- "devDependencies",
1674
- "peerDependencies",
1675
- "optionalDependencies",
1676
- ];
1677
-
1678
- let changed = false;
1679
- for (const section of sections) {
1680
- const deps = cacheData?.[section];
1681
- if (deps && typeof deps === "object") {
1682
- for (const name of getManagedPackageNames()) {
1683
- if (name in deps) {
1684
- delete deps[name];
1685
- changed = true;
1686
- }
1687
- }
1688
- }
1689
- }
1690
-
1691
- if (!changed) {
1692
- return;
1693
- }
1694
-
1695
- if (dryRun) {
1696
- log(`[dry-run] Would update ${paths.cachePackageJson} to remove ${getManagedPackageNames().join(", ")}`);
1697
- return;
1698
- }
1699
-
1700
- await writeFileAtomic(paths.cachePackageJson, formatJson(cacheData));
1701
- }
1702
-
1703
- /**
1704
- * Mirror of `isEvictableCachePath` in lib/auto-update-checker.ts. A recursive
1705
- * delete must never act on a path that only spells like cache: the cache root
1706
- * itself must not resolve through a symlink (`~/.cache/opencode -> ~` would
1707
- * otherwise call the whole home directory "inside the cache"), and the
1708
- * resolved target must stay inside the resolved root.
1709
- */
1710
- function isEvictableCachePath(cachePath, cacheRoot) {
1711
- const absolutePath = resolve(cachePath);
1712
- const absoluteRoot = resolve(cacheRoot);
1713
- if (!isInsideDirectory(absolutePath, absoluteRoot, process.platform)) return false;
1714
- try {
1715
- const realRoot = realpathSync(absoluteRoot);
1716
- const rootIsSymlinked = process.platform === "win32"
1717
- ? realRoot.toLowerCase() !== absoluteRoot.toLowerCase()
1718
- : realRoot !== absoluteRoot;
1719
- if (rootIsSymlinked) return false;
1720
- return isInsideDirectory(realpathSync(absolutePath), realRoot, process.platform);
1721
- } catch {
1722
- return false;
1723
- }
1724
- }
1725
-
1726
- async function clearCache(paths, dryRun, skipCacheClear) {
1727
- if (skipCacheClear) {
1728
- log("Skipping cache clear (--no-cache-clear).");
1729
- await removePluginFromCachePackage(paths, dryRun);
1730
- return;
1731
- }
1732
-
1733
- const cacheTargets = [
1734
- ...paths.cacheNodeModulesPaths,
1735
- ...paths.cachePackagePaths,
1736
- paths.cacheBunLock,
1737
- ];
1738
-
1739
- if (dryRun) {
1740
- for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) {
1741
- log(`[dry-run] Would remove ${cacheNodeModulesPath}`);
1742
- }
1743
- for (const cachePackagePath of paths.cachePackagePaths) {
1744
- log(`[dry-run] Would remove ${cachePackagePath}`);
1745
- }
1746
- log(`[dry-run] Would remove ${paths.cacheBunLock}`);
1747
- } else {
1748
- for (const cacheTarget of cacheTargets) {
1749
- if (!existsSync(cacheTarget)) continue;
1750
- if (!isEvictableCachePath(cacheTarget, paths.cacheDir)) {
1751
- log(`Warning: refusing to remove ${cacheTarget}: it does not resolve inside the OpenCode cache.`);
1752
- continue;
1753
- }
1754
- await removeWithWindowsRetry(cacheTarget, {
1755
- recursive: cacheTarget !== paths.cacheBunLock,
1756
- force: true,
1757
- });
1758
- }
1759
- }
1760
-
1761
- await removePluginFromCachePackage(paths, dryRun);
1762
- }
1763
-
1764
- export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1765
- const split = splitCommandArgv(argv);
1766
- if (split.kind === "standalone") {
1767
- return runStandaloneCommand(split.command, split.argv, options);
1768
- }
1769
- if (split.kind === "unknown") {
1770
- printHelp();
1771
- throw new Error(`Unknown command: ${split.command}`);
1772
- }
1773
- const { env = process.env } = options;
1774
- const paths = buildPaths(resolveHomeDirectory(env));
1775
- if (split.kind === "update") {
1776
- const parsedUpdate = parseUpdateArgs(split.argv);
1777
- if (parsedUpdate.wantsHelp) {
1778
- printHelp();
1779
- return { exitCode: 0, action: "help" };
1780
- }
1781
- await clearCache(paths, parsedUpdate.dryRun, false);
1782
- log(`\n${parsedUpdate.dryRun ? "Dry run complete." : "Cache cleared."} Restart OpenCode to install the latest plugin.`);
1783
- return {
1784
- exitCode: 0,
1785
- action: "update",
1786
- dryRun: Boolean(parsedUpdate.dryRun),
1787
- };
1788
- }
1789
- const parsed = parseCliArgs(split.argv);
1790
- if (parsed.wantsHelp) {
1791
- printHelp();
1792
- return { exitCode: 0, action: "help" };
1793
- }
1794
-
1795
- const { configMode, dryRun, skipCacheClear, pluginOnly } = parsed;
1796
- const effectiveConfigMode = pluginOnly ? "plugin-only" : configMode;
1797
- const requiredTemplatePaths = pluginOnly
1798
- ? []
1799
- : configMode === "modern"
1800
- ? [paths.modernTemplatePath]
1801
- : configMode === "legacy"
1802
- ? [paths.legacyTemplatePath]
1803
- : [paths.modernTemplatePath, paths.legacyTemplatePath];
1804
-
1805
- for (const templatePath of requiredTemplatePaths) {
1806
- if (!existsSync(templatePath)) {
1807
- throw new Error(`Config template not found at ${templatePath}`);
1808
- }
1809
- }
1810
-
1811
- const template = pluginOnly
1812
- ? { $schema: "https://opencode.ai/config.json", plugin: [PACKAGE_NAME] }
1813
- : await loadTemplate(configMode, paths);
1814
- template.plugin = [PACKAGE_NAME];
1815
- const modelKeysToRemove = new Set(STALE_MANAGED_MODEL_KEYS);
1816
- if (!pluginOnly && configMode === "modern") {
1817
- for (const key of getTemplateModelKeys(await readJson(paths.legacyTemplatePath))) {
1818
- modelKeysToRemove.add(key);
1819
- }
1820
- }
1821
- if (!pluginOnly && configMode === "legacy") {
1822
- for (const key of getTemplateModelKeys(await readJson(paths.modernTemplatePath))) {
1823
- modelKeysToRemove.add(key);
1824
- }
1825
- }
1826
-
1827
- let existingConfig;
1828
- if (existsSync(paths.configPath)) {
1829
- try {
1830
- const existing = await readJson(paths.configPath);
1831
- if (!isPlainObject(existing)) {
1832
- throw new Error("config root must be a JSON object");
1833
- }
1834
- existingConfig = existing;
1835
- } catch (error) {
1836
- if (pluginOnly) {
1837
- throw new Error(
1838
- `Could not parse existing config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
1839
- );
1840
- }
1841
- log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`);
1842
- existingConfig = undefined;
1843
- }
1844
- } else {
1845
- log("No existing config found. Creating new global config.");
1846
- }
1847
-
1848
- let existingTuiConfig;
1849
- if (existsSync(paths.tuiConfigPath)) {
1850
- try {
1851
- const existing = await readJson(paths.tuiConfigPath);
1852
- if (!isPlainObject(existing)) {
1853
- throw new Error("TUI config root must be a JSON object");
1854
- }
1855
- existingTuiConfig = existing;
1856
- } catch (error) {
1857
- if (pluginOnly) {
1858
- throw new Error(
1859
- `Could not parse existing TUI config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
1860
- );
1861
- }
1862
- log(`Warning: Could not parse existing TUI config (${formatErrorForLog(error)}). Replacing with minimal TUI config.`);
1863
- existingTuiConfig = undefined;
1864
- }
1865
- } else {
1866
- log("No existing TUI config found. Creating new global TUI config.");
1867
- }
1868
-
1869
- // A checkout of this package registered in either file already loads the
1870
- // plugin, so the published name must not be written beside it anywhere -
1871
- // otherwise the checkout in opencode.json and the published package in
1872
- // tui.json both load.
1873
- const pluginListOptions = {
1874
- baseDirectory: paths.configDir,
1875
- cacheDirectory: paths.cacheDir,
1876
- };
1877
- const checkoutRegistered = [existingConfig?.plugin, existingTuiConfig?.plugin]
1878
- .flatMap((list) => (Array.isArray(list) ? list : []))
1879
- .some((entry) => {
1880
- const classification = classifyPluginEntry(entry, pluginListOptions);
1881
- return (
1882
- classification.kind === LOCAL_CHECKOUT_ENTRY &&
1883
- classification.name === PACKAGE_NAME
1884
- );
1885
- });
1886
- const normalizeOptions = { ...pluginListOptions, checkoutRegistered };
1887
-
1888
- let nextConfig;
1889
- if (existingConfig !== undefined) {
1890
- const merged = { ...existingConfig };
1891
- merged.plugin = normalizePluginList(existingConfig.plugin, log, normalizeOptions);
1892
- if (!pluginOnly) {
1893
- const provider = (existingConfig.provider && typeof existingConfig.provider === "object")
1894
- ? { ...existingConfig.provider }
1895
- : {};
1896
- provider.openai = mergeOpenaiProvider(existingConfig.provider?.openai, template.provider?.openai, {
1897
- modelKeysToRemove,
1898
- });
1899
- merged.provider = provider;
1900
- }
1901
- nextConfig = merged;
1902
- } else {
1903
- nextConfig = pluginOnly
1904
- ? { $schema: template.$schema, plugin: [PACKAGE_NAME] }
1905
- : template;
1906
- nextConfig.plugin = normalizePluginList(nextConfig.plugin, log, normalizeOptions);
1907
- }
1908
-
1909
- const nextTuiConfig = mergeTuiConfig(existingTuiConfig, log, normalizeOptions);
1910
-
1911
- const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath, {
1912
- baseDirectory: paths.configDir,
1913
- cacheDirectory: paths.cacheDir,
1914
- });
1915
- if (unregisteredCheckout) {
1916
- log(
1917
- `Note: this plugin last loaded from a checkout at ${unregisteredCheckout.root} on ${unregisteredCheckout.lastSeen}, ` +
1918
- `which ${paths.configPath} does not register. Point the plugin entry back at that path if OpenCode should keep loading your own build.`,
1919
- );
1920
- }
1921
-
1922
- const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig);
1923
- const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig);
1924
- let wrote = false;
1925
- if (dryRun) {
1926
- log(`[dry-run] ${configChanged ? "Would write" : "Would leave unchanged"} ${paths.configPath} using ${effectiveConfigMode} config`);
1927
- log(`[dry-run] Diff for ${paths.configPath}:`);
1928
- log(formatRedactedConfigDiff(existingConfig, nextConfig));
1929
- log(`[dry-run] ${tuiConfigChanged ? "Would write" : "Would leave unchanged"} ${paths.tuiConfigPath} with the TUI status plugin`);
1930
- log(`[dry-run] Diff for ${paths.tuiConfigPath}:`);
1931
- log(formatRedactedConfigDiff(existingTuiConfig, nextTuiConfig));
1932
- } else {
1933
- if (configChanged) {
1934
- if (existsSync(paths.configPath)) {
1935
- const backupPath = await backupConfig(paths.configPath, false);
1936
- log(`Backup created: ${backupPath}`);
1937
- }
1938
- await writeFileAtomic(paths.configPath, formatJson(nextConfig));
1939
- wrote = true;
1940
- log(`Wrote ${paths.configPath} (${effectiveConfigMode} config)`);
1941
- } else {
1942
- log(`Left ${paths.configPath} unchanged`);
1943
- }
1944
- if (tuiConfigChanged) {
1945
- if (existsSync(paths.tuiConfigPath)) {
1946
- const backupPath = await backupConfig(paths.tuiConfigPath, false);
1947
- log(`Backup created: ${backupPath}`);
1948
- }
1949
- await writeFileAtomic(paths.tuiConfigPath, formatJson(nextTuiConfig));
1950
- wrote = true;
1951
- log(`Wrote ${paths.tuiConfigPath} (TUI status plugin)`);
1952
- } else {
1953
- log(`Left ${paths.tuiConfigPath} unchanged`);
1954
- }
1955
- }
1956
-
1957
- await clearCache(paths, dryRun, skipCacheClear);
1958
-
1959
- log("\nDone. Restart OpenCode to (re)install the plugin.");
1960
- log("Example: opencode");
1961
- if (!pluginOnly && configMode === "modern") {
1962
- log("Note: Modern config intentionally shows 13 base OAuth model entries; use the variant picker for reasoning presets.");
1963
- }
1964
- if (!pluginOnly && configMode === "legacy") {
1965
- log("Note: Legacy config writes 59 explicit preset entries and is also safe for older OpenCode versions.");
1966
- }
1967
- if (!pluginOnly && configMode === "full") {
1968
- log("Note: Full config installs both compact base models and explicit preset entries for direct selector IDs.");
1969
- }
1970
-
1971
- return {
1972
- exitCode: 0,
1973
- action: "install",
1974
- configMode: effectiveConfigMode,
1975
- pluginOnly,
1976
- configPath: paths.configPath,
1977
- tuiConfigPath: paths.tuiConfigPath,
1978
- dryRun: Boolean(dryRun),
1979
- wrote,
1980
- };
1981
- }
1982
-
1983
- export const __test = {
1984
- ORIGIN_HISTORY_FILE_NAME,
1985
- buildPaths,
1986
- backupConfig,
1987
- classifyPluginEntry,
1988
- copyFileWithWindowsRetry,
1989
- findUnregisteredLocalCheckout,
1990
- formatConfigDiff,
1991
- formatRedactedConfigDiff,
1992
- mergeFullTemplate,
1993
- mergeOpenaiProvider,
1994
- mergeTuiConfig,
1995
- normalizePluginList,
1996
- parseCliArgs,
1997
- removeWithWindowsRetry,
1998
- runStandaloneCommand,
1999
- splitCommandArgv,
2000
- writeFileAtomic,
2001
- renameWithWindowsRetry,
2002
- resolveHomeDirectory,
2003
- };
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+
8
+ const PACKAGE_NAME = "oc-codex-multi-auth";
9
+ const LEGACY_PACKAGE_NAMES = ["oc-chatgpt-multi-auth"];
10
+ const ORIGIN_HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json";
11
+ const WINDOWS_RENAME_RETRY_ATTEMPTS = 5;
12
+ const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10;
13
+ const STALE_MANAGED_MODEL_KEYS = new Set([
14
+ "gpt-5.2",
15
+ "gpt-5.3-codex",
16
+ "gpt-5.4",
17
+ // Retired per OpenAI's docs and dropped from the templates: gpt-5.4-mini left
18
+ // Codex (ChatGPT sign-in) on 2026-08-31; gpt-5-codex and the gpt-5.1-codex
19
+ // family were shut down on 2026-07-23 (developers.openai.com/api/docs/deprecations).
20
+ "gpt-5.4-mini",
21
+ "gpt-5-codex",
22
+ "gpt-5.1-codex",
23
+ "gpt-5.1-codex-max",
24
+ "gpt-5.1-codex-mini",
25
+ ...["none", "low", "medium", "high", "xhigh"].map((e) => `gpt-5.4-mini-${e}`),
26
+ ...["low", "medium", "high"].map((e) => `gpt-5-codex-${e}`),
27
+ ...["low", "medium", "high"].map((e) => `gpt-5.1-codex-${e}`),
28
+ ...["low", "medium", "high", "xhigh"].map((e) => `gpt-5.1-codex-max-${e}`),
29
+ ...["medium", "high"].map((e) => `gpt-5.1-codex-mini-${e}`),
30
+ ]);
31
+ const STANDALONE_COMMANDS = new Set(["doctor", "status", "list", "limits", "dashboard", "health", "diag", "warm"]);
32
+ const INSTALLER_COMMANDS = new Set(["install"]);
33
+ const UPDATE_COMMANDS = new Set(["update"]);
34
+
35
+ function splitCommandArgv(argv) {
36
+ const [first, ...rest] = argv;
37
+ if (!first) return { kind: "install", argv };
38
+ if (INSTALLER_COMMANDS.has(first)) return { kind: "install", argv: rest };
39
+ if (UPDATE_COMMANDS.has(first)) return { kind: "update", argv: rest };
40
+ if (STANDALONE_COMMANDS.has(first)) return { kind: "standalone", command: first, argv: rest };
41
+ if (first.startsWith("-")) return { kind: "install", argv };
42
+ return { kind: "unknown", command: first, argv: rest };
43
+ }
44
+
45
+ function parseStandaloneArgs(argv) {
46
+ const options = {
47
+ json: false,
48
+ includeSensitive: false,
49
+ deep: false,
50
+ fix: false,
51
+ tag: undefined,
52
+ configPath: undefined,
53
+ help: false,
54
+ };
55
+ for (let index = 0; index < argv.length; index += 1) {
56
+ const arg = argv[index];
57
+ if (arg === "--json") options.json = true;
58
+ else if (arg === "--include-sensitive") options.includeSensitive = true;
59
+ else if (arg === "--deep") options.deep = true;
60
+ else if (arg === "--fix") options.fix = true;
61
+ else if (arg === "--tag") options.tag = argv[++index];
62
+ else if (arg.startsWith("--tag=")) options.tag = arg.slice("--tag=".length);
63
+ else if (arg === "--config-path") options.configPath = argv[++index];
64
+ else if (arg.startsWith("--config-path=")) options.configPath = arg.slice("--config-path=".length);
65
+ else if (arg === "--help" || arg === "-h") options.help = true;
66
+ else throw new Error(`Unknown option for standalone command: ${arg}`);
67
+ }
68
+ return options;
69
+ }
70
+
71
+ function getManagedPackageNames() {
72
+ return [PACKAGE_NAME, ...LEGACY_PACKAGE_NAMES];
73
+ }
74
+
75
+ export function normalizePathForCompare(path, resolveRealPath = realpathSync) {
76
+ const resolved = resolve(path);
77
+ try {
78
+ const realPath = resolveRealPath(resolved);
79
+ return process.platform === "win32" ? realPath.toLowerCase() : realPath;
80
+ } catch {
81
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
82
+ }
83
+ }
84
+
85
+ export function isDirectRunPath(argvPath, modulePath, resolveRealPath = realpathSync) {
86
+ if (!argvPath || !modulePath) return false;
87
+ return (
88
+ normalizePathForCompare(argvPath, resolveRealPath) ===
89
+ normalizePathForCompare(modulePath, resolveRealPath)
90
+ );
91
+ }
92
+
93
+ function printHelp() {
94
+ console.log(`Usage: ${PACKAGE_NAME} [command] [options]\n\n` +
95
+ "Commands:\n" +
96
+ " install Register plugin entries (default with no command)\n" +
97
+ " update Refresh the cached package without changing OpenCode config\n" +
98
+ " doctor Run local account/config diagnostics\n" +
99
+ " status Show account/config status\n" +
100
+ " list List configured accounts\n" +
101
+ " limits Show live 5-hour and weekly usage for each account\n" +
102
+ " dashboard Print dashboard guidance\n" +
103
+ " health Check local token/account health\n" +
104
+ " diag Alias for doctor --deep\n" +
105
+ " warm Open every enabled account's usage window now (one request each)\n\n" +
106
+ `Installer usage: ${PACKAGE_NAME} install [--plugin-only|--modern|--full|--legacy] [--dry-run] [--no-cache-clear]\n` +
107
+ `Updater usage: ${PACKAGE_NAME} update [--dry-run]\n\n` +
108
+ "Default behavior:\n" +
109
+ " - Registers plugin entries without changing provider.openai\n" +
110
+ " - Enables the prompt status bar TUI plugin at ~/.config/opencode/tui.json\n" +
111
+ " - Installs model catalogs only with --modern, --full, or --legacy\n" +
112
+ " - Ensures plugin is unpinned (latest)\n" +
113
+ " - Clears OpenCode plugin cache\n\n" +
114
+ "Options:\n" +
115
+ " --plugin-only Register plugins without changing provider.openai\n" +
116
+ " --modern Force compact modern config (10 base OAuth models + --variant presets)\n" +
117
+ " --full Install compact base models plus 53 explicit selector entries\n" +
118
+ " --legacy Force explicit legacy config (53 preset model entries)\n" +
119
+ " --dry-run Show actions without writing\n" +
120
+ " --no-cache-clear Skip clearing OpenCode cache\n"
121
+ );
122
+ }
123
+
124
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
125
+ const repoRoot = resolve(scriptDir, "..");
126
+ const modernTemplatePath = join(repoRoot, "config", "opencode-modern.json");
127
+ const legacyTemplatePath = join(repoRoot, "config", "opencode-legacy.json");
128
+
129
+ function log(message) {
130
+ console.log(message);
131
+ }
132
+
133
+ function delay(ms) {
134
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
135
+ }
136
+
137
+ function isWindowsLockError(error) {
138
+ const code = error?.code;
139
+ return code === "EPERM" || code === "EBUSY";
140
+ }
141
+
142
+ function formatErrorForLog(error) {
143
+ if (error instanceof Error) {
144
+ return error.message;
145
+ }
146
+ return String(error);
147
+ }
148
+
149
+ function resolveHomeDirectory(env = process.env) {
150
+ return env.HOME || env.USERPROFILE || homedir();
151
+ }
152
+
153
+ function buildPaths(homeDir) {
154
+ const configDir = join(homeDir, ".config", "opencode");
155
+ const cacheDir = join(homeDir, ".cache", "opencode");
156
+ return {
157
+ configDir,
158
+ configPath: join(configDir, "opencode.json"),
159
+ tuiConfigPath: join(configDir, "tui.json"),
160
+ cacheDir,
161
+ cacheNodeModulesPaths: getManagedPackageNames().map((name) => join(cacheDir, "node_modules", name)),
162
+ cachePackagePaths: getManagedPackageNames().flatMap((name) => [
163
+ join(cacheDir, "packages", name),
164
+ join(cacheDir, "packages", `${name}@latest`),
165
+ ]),
166
+ cacheBunLock: join(cacheDir, "bun.lock"),
167
+ cachePackageJson: join(cacheDir, "package.json"),
168
+ originHistoryPath: join(homeDir, ".opencode", ORIGIN_HISTORY_FILE_NAME),
169
+ modernTemplatePath,
170
+ legacyTemplatePath,
171
+ };
172
+ }
173
+
174
+ function parseCliArgs(argv = process.argv.slice(2)) {
175
+ const args = new Set(argv);
176
+ if (args.has("--help") || args.has("-h")) {
177
+ return {
178
+ wantsHelp: true,
179
+ };
180
+ }
181
+
182
+ const requestedModern = args.has("--modern");
183
+ const requestedFull = args.has("--full");
184
+ const requestedLegacy = args.has("--legacy");
185
+ const explicitPluginOnly = args.has("--plugin-only");
186
+
187
+ const requestedModes = [requestedModern, requestedFull, requestedLegacy]
188
+ .filter(Boolean).length;
189
+ if (requestedModes > 1) {
190
+ throw new Error("Choose only one of --modern, --full, or --legacy.");
191
+ }
192
+ if (explicitPluginOnly && requestedModes > 0) {
193
+ throw new Error("--plugin-only cannot be combined with --modern, --full, or --legacy.");
194
+ }
195
+ const pluginOnly = explicitPluginOnly || requestedModes === 0;
196
+
197
+ return {
198
+ wantsHelp: false,
199
+ dryRun: args.has("--dry-run"),
200
+ skipCacheClear: args.has("--no-cache-clear"),
201
+ pluginOnly,
202
+ configMode: requestedFull ? "full" : requestedLegacy ? "legacy" : "modern",
203
+ };
204
+ }
205
+
206
+ function parseUpdateArgs(argv) {
207
+ const args = new Set(argv);
208
+ const supported = new Set(["--dry-run", "--help", "-h"]);
209
+ const unknown = argv.find((arg) => !supported.has(arg));
210
+ if (unknown) {
211
+ throw new Error(`Unknown option for update command: ${unknown}`);
212
+ }
213
+ return {
214
+ wantsHelp: args.has("--help") || args.has("-h"),
215
+ dryRun: args.has("--dry-run"),
216
+ };
217
+ }
218
+
219
+ const MANAGED_PACKAGE_ENTRY = "managed-package";
220
+ const LOCAL_CHECKOUT_ENTRY = "local-checkout";
221
+ const UNRELATED_ENTRY = "unrelated";
222
+ const DECLARED_NAME_LOOKUP_DEPTH = 3;
223
+
224
+ function pluginEntrySpecifier(entry) {
225
+ if (typeof entry === "string") return entry;
226
+ // `[specifier, options]` configures a plugin without changing where it loads from.
227
+ if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Spellings that can only mean a location on disk: absolute paths, `~`,
233
+ * `./`/`../`, Windows drive letters, and UNC shares. Anything else that merely
234
+ * contains a separator (`@scope/name`, git URLs, `npm:` aliases) is ambiguous
235
+ * and counts as a path only when it resolves on this machine.
236
+ */
237
+ function isExplicitPathSpecifier(specifier) {
238
+ return (
239
+ /^[a-zA-Z]:[\\/]/.test(specifier) ||
240
+ /^[\\/]/.test(specifier) ||
241
+ /^~[\\/]/.test(specifier) ||
242
+ /^\.\.?[\\/]/.test(specifier)
243
+ );
244
+ }
245
+
246
+ /**
247
+ * The path an entry names, exactly as the config spells it - and only when the
248
+ * specifier actually is a path. A `/` alone does not make one: registry and
249
+ * URL spellings can end in `oc-codex-multi-auth` without naming this package's
250
+ * checkout, so an ambiguous specifier counts only when it resolves on disk.
251
+ */
252
+ function pluginEntryPath(specifier, baseDirectory) {
253
+ const trimmed = specifier.trim();
254
+ if (!trimmed) return null;
255
+ if (/^file:\/\//i.test(trimmed)) {
256
+ try {
257
+ return fileURLToPath(trimmed);
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+ if (isExplicitPathSpecifier(trimmed)) return trimmed;
263
+ if (!trimmed.includes("/") && !trimmed.includes("\\")) return null;
264
+ const inspectionPath = resolveInspectionPath(trimmed, baseDirectory);
265
+ return inspectionPath && existsSync(inspectionPath) ? trimmed : null;
266
+ }
267
+
268
+ function pluginPathSegments(entryPath) {
269
+ return entryPath.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean);
270
+ }
271
+
272
+ /**
273
+ * Compared as written, without resolving symlinks: this asks whether an entry
274
+ * names something `clearCache` removes, and `clearCache` removes the paths
275
+ * exactly as it spells them - `rm` unlinks a symlink rather than descending
276
+ * into it. Cache eviction resolves symlinks because it decides the opposite
277
+ * question, whether a recursive delete is safe.
278
+ */
279
+ function isInsideDirectory(candidate, directory, platform) {
280
+ const fold = (value) => (platform === "win32" ? value.toLowerCase() : value);
281
+ const relativePath = relative(fold(resolve(directory)), fold(resolve(candidate)));
282
+ return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
283
+ }
284
+
285
+ function isPackageManagerPath(entryPath, options = {}) {
286
+ const { platform = process.platform, cacheDirectory, inspectionPath } = options;
287
+ // Windows reaches one directory under many spellings, so `NODE_MODULES`
288
+ // there is the same package-manager output as `node_modules`. Elsewhere the
289
+ // two are different directories and must stay so.
290
+ const segments = pluginPathSegments(entryPath).map((segment) =>
291
+ platform === "win32" ? segment.toLowerCase() : segment,
292
+ );
293
+ if (
294
+ segments.some(
295
+ (segment, index) =>
296
+ segment === "node_modules" ||
297
+ // OpenCode's plugin cache spells the version into the directory name.
298
+ // A `packages/` directory without one is an ordinary monorepo.
299
+ (segments[index - 1] === "packages" && segment.includes("@")),
300
+ )
301
+ ) {
302
+ return true;
303
+ }
304
+ // The cache is where this installer puts its own copies, and `clearCache`
305
+ // empties it on the same run. Reading spelling alone leaves the cache's
306
+ // unversioned `packages/<name>` looking like somebody's monorepo, so the
307
+ // entry is kept while the directory under it is deleted - a config left
308
+ // pointing at nothing. Whose directory it is settles that; the spelling
309
+ // cannot.
310
+ return Boolean(
311
+ cacheDirectory &&
312
+ inspectionPath &&
313
+ isInsideDirectory(inspectionPath, cacheDirectory, platform),
314
+ );
315
+ }
316
+
317
+ /**
318
+ * Where an entry points, for reading metadata about it only. OpenCode resolves
319
+ * a relative entry against the config file that declares it, so that directory
320
+ * is what makes such a path mean anything; the installer's working directory
321
+ * would name somewhere else entirely. Null when a relative entry arrives with
322
+ * no declaring directory to resolve it against.
323
+ */
324
+ function resolveInspectionPath(entryPath, baseDirectory) {
325
+ if (isAbsolute(entryPath)) return entryPath;
326
+ return baseDirectory ? resolve(baseDirectory, entryPath) : null;
327
+ }
328
+
329
+ /**
330
+ * Last-resort identification for a path that is not present on this machine.
331
+ * Spelling alone never authorizes deleting an entry; it only names the package a
332
+ * missing path was probably meant to point at.
333
+ */
334
+ function managedNameFromPathSpelling(entryPath) {
335
+ const segments = pluginPathSegments(entryPath);
336
+ const last = segments.at(-1) === "dist" ? segments.at(-2) : segments.at(-1);
337
+ if (!last) return null;
338
+ let candidate = last.toLowerCase();
339
+ try {
340
+ candidate = decodeURIComponent(candidate);
341
+ } catch {
342
+ // Keep the raw segment when it carries a malformed escape.
343
+ }
344
+ const versionSuffix = candidate.indexOf("@");
345
+ if (versionSuffix > 0) candidate = candidate.slice(0, versionSuffix);
346
+ return getManagedPackageNames().find((name) => name.toLowerCase() === candidate) ?? null;
347
+ }
348
+
349
+ function readDeclaredPackageName(directoryPath) {
350
+ try {
351
+ const parsed = JSON.parse(readFileSync(join(directoryPath, "package.json"), "utf8"));
352
+ const name = parsed?.name;
353
+ return typeof name === "string" && name.trim() ? name.trim() : null;
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+
359
+ /** An entry may point at a build output inside the package, so walk upwards. */
360
+ function resolveDeclaredPackageName(entryPath) {
361
+ if (!isAbsolute(entryPath)) return null;
362
+ let current = resolve(entryPath);
363
+ for (let depth = 0; depth <= DECLARED_NAME_LOOKUP_DEPTH; depth += 1) {
364
+ const name = readDeclaredPackageName(current);
365
+ if (name) return name;
366
+ const parent = dirname(current);
367
+ if (parent === current) return null;
368
+ current = parent;
369
+ }
370
+ return null;
371
+ }
372
+
373
+ /**
374
+ * Decides what a plugin entry is, by identity rather than by spelling.
375
+ *
376
+ * The distinction that matters is not which package an entry names but who
377
+ * chose the location. A bare specifier or a path inside `node_modules` is a
378
+ * reference the installer itself produced and may retire. Any other path is
379
+ * somewhere a human deliberately pointed OpenCode - a checkout of this package
380
+ * being developed on, most often - and is never the installer's to remove.
381
+ */
382
+ function classifyPluginEntry(entry, options = {}) {
383
+ const {
384
+ resolveDeclaredName = resolveDeclaredPackageName,
385
+ baseDirectory,
386
+ cacheDirectory,
387
+ platform = process.platform,
388
+ } = options;
389
+ const specifier = pluginEntrySpecifier(entry);
390
+ if (specifier === null) return { kind: UNRELATED_ENTRY, name: null };
391
+
392
+ const entryPath = pluginEntryPath(specifier, baseDirectory);
393
+ if (entryPath === null) {
394
+ const bare = specifier.trim().toLowerCase();
395
+ const name = getManagedPackageNames().find(
396
+ (managed) =>
397
+ bare === managed.toLowerCase() || bare.startsWith(`${managed.toLowerCase()}@`),
398
+ );
399
+ return name
400
+ ? { kind: MANAGED_PACKAGE_ENTRY, name }
401
+ : { kind: UNRELATED_ENTRY, name: null };
402
+ }
403
+
404
+ const inspectionPath = resolveInspectionPath(entryPath, baseDirectory);
405
+ const declaredName = inspectionPath ? resolveDeclaredName(inspectionPath) : null;
406
+ const managedName = declaredName
407
+ ? getManagedPackageNames().find(
408
+ (managed) => managed.toLowerCase() === declaredName.toLowerCase(),
409
+ ) ?? null
410
+ : managedNameFromPathSpelling(entryPath);
411
+
412
+ if (!managedName) return { kind: UNRELATED_ENTRY, name: null };
413
+
414
+ return isPackageManagerPath(entryPath, { platform, cacheDirectory, inspectionPath })
415
+ ? { kind: MANAGED_PACKAGE_ENTRY, name: managedName }
416
+ : {
417
+ kind: LOCAL_CHECKOUT_ENTRY,
418
+ name: managedName,
419
+ path: inspectionPath ?? entryPath,
420
+ resolvesOnDisk: Boolean(inspectionPath && existsSync(inspectionPath)),
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Ensures this plugin is registered exactly once, without changing how an
426
+ * existing registration is spelled. Appending the published package name is the
427
+ * fallback for a config that does not reference the plugin at all, not the
428
+ * canonical form every config is rewritten into.
429
+ */
430
+ function normalizePluginList(list, onNotice, options = {}) {
431
+ const entries = Array.isArray(list)
432
+ ? list.filter((entry) => entry !== null && entry !== undefined && entry !== "")
433
+ : [];
434
+ const classifications = entries.map((entry) => classifyPluginEntry(entry, options));
435
+ // A checkout of this package already IS the registration, so a published
436
+ // entry beside it is a second copy of the same plugin for OpenCode to load.
437
+ // `options.checkoutRegistered` carries the same fact across config files: a
438
+ // checkout registered only in opencode.json still suppresses the published
439
+ // name in tui.json, and vice versa. Only a checkout of the CURRENT package
440
+ // counts: the former name is valid for cleanup, never as the registration
441
+ // the installer exists to ensure.
442
+ const checkoutRegistered = options.checkoutRegistered === true || classifications.some(
443
+ (classification) =>
444
+ classification.kind === LOCAL_CHECKOUT_ENTRY && classification.name === PACKAGE_NAME,
445
+ );
446
+ const kept = [];
447
+ let keptPublishedName = false;
448
+
449
+ entries.forEach((entry, index) => {
450
+ const classification = classifications[index];
451
+
452
+ if (classification.kind === LOCAL_CHECKOUT_ENTRY) {
453
+ kept.push(entry);
454
+ if (classification.resolvesOnDisk === false) {
455
+ onNotice?.(
456
+ `Warning: keeping ${classification.path} registered, but it does not resolve on disk; ` +
457
+ "the plugin may not load until the path exists again.",
458
+ );
459
+ } else {
460
+ onNotice?.(
461
+ `Keeping the local ${classification.name} checkout registered at ${classification.path}`,
462
+ );
463
+ }
464
+ return;
465
+ }
466
+
467
+ if (classification.kind === MANAGED_PACKAGE_ENTRY) {
468
+ // Retire stale duplicates, version pins, renamed packages, and paths
469
+ // into package-manager output; keep one published-name entry in place
470
+ // unless a checkout already covers it.
471
+ const isPublishedName = pluginEntrySpecifier(entry) === PACKAGE_NAME;
472
+ if (isPublishedName && !checkoutRegistered && !keptPublishedName) {
473
+ keptPublishedName = true;
474
+ kept.push(entry);
475
+ }
476
+ return;
477
+ }
478
+
479
+ kept.push(entry);
480
+ });
481
+
482
+ return checkoutRegistered || keptPublishedName ? kept : [...kept, PACKAGE_NAME];
483
+ }
484
+
485
+ function readLocalCheckoutSightings(historyPath) {
486
+ try {
487
+ const parsed = JSON.parse(readFileSync(historyPath, "utf8"));
488
+ const sightings = parsed?.sightings;
489
+ if (!Array.isArray(sightings)) return [];
490
+ return sightings.filter(
491
+ (sighting) =>
492
+ sighting &&
493
+ typeof sighting === "object" &&
494
+ sighting.isLocalCheckout === true &&
495
+ typeof sighting.root === "string" &&
496
+ typeof sighting.lastSeen === "string" &&
497
+ getManagedPackageNames().includes(sighting.name),
498
+ );
499
+ } catch {
500
+ return [];
501
+ }
502
+ }
503
+
504
+ /**
505
+ * A checkout the plugin has run from that the finished config does not
506
+ * register. Reported rather than restored: config history is evidence of what
507
+ * happened, not authority over what the user wants registered now.
508
+ */
509
+ function findUnregisteredLocalCheckout(pluginList, historyPath, options = {}) {
510
+ const entries = Array.isArray(pluginList) ? pluginList : [];
511
+ if (entries.some((entry) => classifyPluginEntry(entry, options).kind === LOCAL_CHECKOUT_ENTRY)) {
512
+ return null;
513
+ }
514
+ const latest = readLocalCheckoutSightings(historyPath)
515
+ .sort((left, right) => (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0))
516
+ .at(-1);
517
+ if (!latest) return null;
518
+ // The directory has to still hold the package that was recorded there. A
519
+ // path gets reused - a checkout deleted and something else cloned into its
520
+ // place - and a recorded path that now declares another project would
521
+ // otherwise be offered as somewhere to point OpenCode back at.
522
+ const declaredName = resolveDeclaredPackageName(latest.root);
523
+ if (!declaredName || declaredName.toLowerCase() !== String(latest.name).toLowerCase()) {
524
+ return null;
525
+ }
526
+ return latest;
527
+ }
528
+
529
+ function mergeTuiConfig(existingConfig, onNotice, options = {}) {
530
+ const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {};
531
+ const next = { ...existing };
532
+ if (typeof next.$schema !== "string" || !next.$schema.trim()) {
533
+ next.$schema = "https://opencode.ai/tui.json";
534
+ }
535
+ next.plugin = normalizePluginList(existing.plugin, onNotice, options);
536
+ return next;
537
+ }
538
+
539
+ function formatJson(obj) {
540
+ return `${JSON.stringify(obj, null, 2)}\n`;
541
+ }
542
+
543
+ function getStandaloneStoragePath(options, env = process.env) {
544
+ if (options.configPath) return resolve(options.configPath);
545
+ return join(resolveHomeDirectory(env), ".opencode", "oc-codex-multi-auth-accounts.json");
546
+ }
547
+
548
+ async function readStandaloneStorage(path) {
549
+ try {
550
+ const raw = await readFile(path, "utf-8");
551
+ const parsed = JSON.parse(raw);
552
+ // Shape validation, not just parse validation: a JSON array, scalar, or
553
+ // object without an `accounts` array is unreadable by the plugin runtime
554
+ // too (normalizeAccountStorage rejects it), so reporting it as a healthy
555
+ // empty pool (exit 0, "No accounts configured") hides the corruption
556
+ // from scripted callers that key on exit codes.
557
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
558
+ return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
559
+ }
560
+ // Forward-compat mirror of the runtime guard: a newer schema version
561
+ // must not be shown as readable accounts by this build.
562
+ const version = parsed.version;
563
+ if (typeof version === "number" && Number.isFinite(version) && version > 3) {
564
+ return {
565
+ storage: null,
566
+ error: `Unsupported account storage schema version ${version}; this build supports up to version 3.`,
567
+ };
568
+ }
569
+ if (!Array.isArray(parsed.accounts)) {
570
+ return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
571
+ }
572
+ return {
573
+ storage: normalizeStandaloneStorage(parsed),
574
+ error: null,
575
+ };
576
+ } catch (error) {
577
+ if (error?.code === "ENOENT") return { storage: null, error: null };
578
+ return { storage: null, error: formatErrorForLog(error) };
579
+ }
580
+ }
581
+
582
+ function normalizeStandaloneIdentityPart(value) {
583
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
584
+ }
585
+
586
+ function sameStandaloneIdentity(left, right) {
587
+ const normalizedLeft = normalizeStandaloneIdentityPart(left);
588
+ const normalizedRight = normalizeStandaloneIdentityPart(right);
589
+ return !!normalizedLeft && !!normalizedRight && normalizedLeft === normalizedRight;
590
+ }
591
+
592
+ function isStandaloneOrgTokenDuplicate(left, right) {
593
+ const leftOrganizationId = normalizeStandaloneIdentityPart(left?.organizationId);
594
+ const rightOrganizationId = normalizeStandaloneIdentityPart(right?.organizationId);
595
+ if (leftOrganizationId && rightOrganizationId && leftOrganizationId !== rightOrganizationId) return false;
596
+ const leftOrgLike = !!leftOrganizationId || left?.accountIdSource === "org";
597
+ const rightOrgLike = !!rightOrganizationId || right?.accountIdSource === "org";
598
+ const leftTokenLike = !leftOrganizationId && left?.accountIdSource === "token";
599
+ const rightTokenLike = !rightOrganizationId && right?.accountIdSource === "token";
600
+ if (!((leftOrgLike && rightTokenLike) || (rightOrgLike && leftTokenLike))) return false;
601
+ return sameStandaloneIdentity(left?.email, right?.email) ||
602
+ sameStandaloneIdentity(left?.refreshToken, right?.refreshToken);
603
+ }
604
+
605
+ function mergeStandaloneAccounts(target, source) {
606
+ const targetOrgLike = !!normalizeStandaloneIdentityPart(target?.organizationId) || target?.accountIdSource === "org";
607
+ const sourceOrgLike = !!normalizeStandaloneIdentityPart(source?.organizationId) || source?.accountIdSource === "org";
608
+ if (targetOrgLike || !sourceOrgLike) {
609
+ return {
610
+ ...source,
611
+ ...target,
612
+ organizationId: target.organizationId ?? source.organizationId,
613
+ accountId: target.accountId ?? source.accountId,
614
+ accountIdSource: target.accountIdSource ?? source.accountIdSource,
615
+ accountLabel: target.accountLabel ?? source.accountLabel,
616
+ email: target.email ?? source.email,
617
+ };
618
+ }
619
+ return mergeStandaloneAccounts(source, target);
620
+ }
621
+
622
+ // Mirror of `isStaleGeneratedAccountLabel` / `dropStaleGeneratedLabel` in
623
+ // lib/auth/token-utils.ts and lib/storage/normalize.ts. The standalone CLI
624
+ // reads the pool through this normalizer and never through the compiled
625
+ // `normalizeAccountStorage`, so without the mirror `status`, `list`, `health`,
626
+ // `doctor` and `dashboard` keep printing the org-derived label the plugin
627
+ // itself now drops - next to the account id, which is the identity the label
628
+ // was misnaming. The marker must hold this account's own id suffix so a name
629
+ // set with `codex-label` survives.
630
+ const GENERATED_LABEL_PATTERN = /\s\[id:[^\]]*\]$/;
631
+
632
+ function dropStaleStandaloneLabel(account) {
633
+ const label = typeof account?.accountLabel === "string" ? account.accountLabel.trim() : "";
634
+ const accountId = typeof account?.accountId === "string" ? account.accountId.trim() : "";
635
+ if (!label || !accountId) return account;
636
+ const marker = label.match(GENERATED_LABEL_PATTERN)?.[0];
637
+ if (!marker) return account;
638
+ const suffix = accountId.length > 6 ? accountId.slice(-6) : accountId;
639
+ if (marker !== ` [id:${suffix}]`) return account;
640
+ const next = { ...account };
641
+ delete next.accountLabel;
642
+ return next;
643
+ }
644
+
645
+ function normalizeStandaloneStorage(storage) {
646
+ if (!Array.isArray(storage.accounts)) return storage;
647
+ const accounts = [...storage.accounts];
648
+ const removed = new Set();
649
+ for (let i = 0; i < accounts.length; i += 1) {
650
+ if (removed.has(i)) continue;
651
+ for (let j = i + 1; j < accounts.length; j += 1) {
652
+ if (removed.has(j) || !isStandaloneOrgTokenDuplicate(accounts[i], accounts[j])) continue;
653
+ const leftOrgLike = !!normalizeStandaloneIdentityPart(accounts[i]?.organizationId) ||
654
+ accounts[i]?.accountIdSource === "org";
655
+ const targetIndex = leftOrgLike ? i : j;
656
+ const sourceIndex = targetIndex === i ? j : i;
657
+ accounts[targetIndex] = mergeStandaloneAccounts(accounts[targetIndex], accounts[sourceIndex]);
658
+ removed.add(sourceIndex);
659
+ if (sourceIndex === i) break;
660
+ }
661
+ }
662
+ const normalizedAccounts = accounts
663
+ .filter((_, index) => !removed.has(index))
664
+ .map(dropStaleStandaloneLabel);
665
+ return {
666
+ ...storage,
667
+ accounts: normalizedAccounts,
668
+ activeIndex: Math.max(0, Math.min(storage.activeIndex ?? 0, Math.max(0, normalizedAccounts.length - 1))),
669
+ };
670
+ }
671
+
672
+ const MASKED_VALUE = "*****";
673
+ // The head/tail mask keeps eight characters, so it conceals nothing worth
674
+ // concealing below thirteen: `me@x.io` would print in full and `me@x.io12`
675
+ // all but its middle character. `doctor` output is what users paste into
676
+ // issues, so anything shorter is replaced outright instead. Input is trimmed
677
+ // first, or `" me@x.io "` clears the cutoff on padding alone and drops back
678
+ // into the partial mask.
679
+ const MASK_MIN_LENGTH = 13;
680
+
681
+ function maskValue(value, includeSensitive) {
682
+ if (includeSensitive || typeof value !== "string") return value;
683
+ const trimmed = value.trim();
684
+ if (!trimmed) return trimmed;
685
+ if (trimmed.length < MASK_MIN_LENGTH) return MASKED_VALUE;
686
+ return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
687
+ }
688
+
689
+ // Six characters when the id is shown in full, matching what the
690
+ // in-conversation surfaces print as `id:`. Four when it is head/tail masked,
691
+ // which is the tail `maskValue` already discloses as `accountId` in the same
692
+ // payload, so the printed identity never reveals more of an id than the field
693
+ // beside it. Nothing at all when the id was too short for that mask: the
694
+ // `accountId` next to it is then `*****`, and four raw characters of a short
695
+ // id can be the whole id.
696
+ function accountIdSuffix(accountId, includeSensitive) {
697
+ if (!accountId) return undefined;
698
+ if (includeSensitive) {
699
+ return accountId.length > 6 ? accountId.slice(-6) : accountId;
700
+ }
701
+ if (accountId.length < MASK_MIN_LENGTH) return undefined;
702
+ return accountId.slice(-4);
703
+ }
704
+
705
+ // A member id is what tells two seats of one Business workspace apart, and no
706
+ // fixed-length tail always does it: member ids sharing a six-character tail
707
+ // were observed, and in a real nine-seat pool the ids are 67 characters with
708
+ // no shared tail at all, so growing a tail until it separates them prints most
709
+ // of the id in every row. The renderer below mirrors `resolveSeatRenderer` in
710
+ // lib/account-display.ts - a tail, else one window anchored where the ids first
711
+ // diverge, else short windows at each position where a pair first differs
712
+ // joined by `..`, else a hash prefix, each capped - and the id whole only if
713
+ // none of those separate them, which needs a 128-bit SHA-256 collision. The
714
+ // hash outcome is reachable, and it prints a value that cannot be matched
715
+ // against the id by eye.
716
+ const STANDALONE_SEAT_MAX_LENGTH = 12;
717
+ const STANDALONE_SEAT_HASH_LENGTHS = [8, 12, 16, 24, 32];
718
+ const STANDALONE_SEAT_WINDOW_SEPARATOR = "..";
719
+
720
+ function seatIsDisclosable(accountUserId, includeSensitive) {
721
+ if (!accountUserId) return false;
722
+ return includeSensitive || accountUserId.length >= MASK_MIN_LENGTH;
723
+ }
724
+
725
+ function seatTail(accountUserId, length) {
726
+ return accountUserId.length > length ? accountUserId.slice(-length) : accountUserId;
727
+ }
728
+
729
+ function seatWindow(accountUserId, start, length) {
730
+ if (accountUserId.length <= length) return accountUserId;
731
+ const begin = Math.max(0, Math.min(start, accountUserId.length - length));
732
+ return accountUserId.slice(begin, begin + length);
733
+ }
734
+
735
+ function seatCommonPrefixLength(values) {
736
+ const [first] = values;
737
+ if (first === undefined) return 0;
738
+ let shared = first.length;
739
+ for (const value of values) {
740
+ let index = 0;
741
+ while (index < shared && index < value.length && first[index] === value[index]) {
742
+ index += 1;
743
+ }
744
+ shared = index;
745
+ if (shared === 0) break;
746
+ }
747
+ return shared;
748
+ }
749
+
750
+ function seatFirstDivergence(left, right) {
751
+ const limit = Math.min(left.length, right.length);
752
+ let index = 0;
753
+ while (index < limit && left[index] === right[index]) index += 1;
754
+ return index;
755
+ }
756
+
757
+ // For every pair, the first index at which that pair differs - not every index
758
+ // where the ids disagree, which across a handful of random-looking ids is
759
+ // nearly all of them and localizes nothing.
760
+ function seatDivergenceAnchors(values) {
761
+ const anchors = new Set();
762
+ for (let left = 0; left < values.length; left += 1) {
763
+ for (let right = left + 1; right < values.length; right += 1) {
764
+ anchors.add(seatFirstDivergence(values[left], values[right]));
765
+ }
766
+ }
767
+ return [...anchors].sort((left, right) => left - right);
768
+ }
769
+
770
+ function seatAnchorWindowStarts(anchors, width) {
771
+ const starts = [];
772
+ for (const anchor of anchors) {
773
+ const last = starts[starts.length - 1];
774
+ if (last !== undefined && anchor < last + width) continue;
775
+ starts.push(anchor);
776
+ }
777
+ return starts;
778
+ }
779
+
780
+ function resolveStandaloneSeatRenderer(accountUserIds, includeSensitive) {
781
+ // Starts at the length the mask above allows, so masked output widens only
782
+ // when leaving it short would print a lie.
783
+ const base = includeSensitive ? 6 : 4;
784
+ const distinct = [];
785
+ const seen = new Set();
786
+ for (const accountUserId of accountUserIds) {
787
+ if (!seatIsDisclosable(accountUserId, includeSensitive)) continue;
788
+ if (seen.has(accountUserId)) continue;
789
+ seen.add(accountUserId);
790
+ distinct.push(accountUserId);
791
+ }
792
+ const atBase = (accountUserId) => seatTail(accountUserId, base);
793
+ if (distinct.length <= 1) return atBase;
794
+
795
+ const separates = (render) => new Set(distinct.map(render)).size === distinct.length;
796
+
797
+ for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
798
+ const render = (accountUserId) => seatTail(accountUserId, length);
799
+ if (separates(render)) return render;
800
+ }
801
+ const start = seatCommonPrefixLength(distinct);
802
+ for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
803
+ const render = (accountUserId) => seatWindow(accountUserId, start, length);
804
+ if (separates(render)) return render;
805
+ }
806
+ const anchors = seatDivergenceAnchors(distinct);
807
+ for (let width = 2; width <= STANDALONE_SEAT_MAX_LENGTH; width += 1) {
808
+ const starts = seatAnchorWindowStarts(anchors, width);
809
+ const rendered =
810
+ starts.length * width + (starts.length - 1) * STANDALONE_SEAT_WINDOW_SEPARATOR.length;
811
+ // Skipped, not abandoned: a wider window can span two nearby anchors
812
+ // that needed one window each, so the cost falls as the window count
813
+ // does. Mirrors `resolveSeatRenderer` in lib/account-display.ts, where
814
+ // the measured counter-example is written out.
815
+ if (rendered > STANDALONE_SEAT_MAX_LENGTH) continue;
816
+ const render = (accountUserId) =>
817
+ starts
818
+ .map((windowStart) => accountUserId.slice(windowStart, windowStart + width))
819
+ .join(STANDALONE_SEAT_WINDOW_SEPARATOR);
820
+ if (separates(render)) return render;
821
+ }
822
+ for (const length of STANDALONE_SEAT_HASH_LENGTHS) {
823
+ const render = (accountUserId) => createHash("sha256").update(accountUserId).digest("hex").slice(0, length);
824
+ if (separates(render)) return render;
825
+ }
826
+ return (accountUserId) => accountUserId;
827
+ }
828
+
829
+ function summarizeStandaloneAccounts(storage, includeSensitive, tag) {
830
+ const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
831
+ const normalizedTag = typeof tag === "string" ? tag.trim().toLowerCase() : "";
832
+ const entries = accounts
833
+ .map((account, index) => ({ account, index }))
834
+ .filter(({ account }) => !normalizedTag ||
835
+ (Array.isArray(account?.accountTags) &&
836
+ account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)));
837
+ const renderSeat = resolveStandaloneSeatRenderer(
838
+ entries.map(({ account }) =>
839
+ (typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "") || undefined,
840
+ ),
841
+ includeSensitive,
842
+ );
843
+ return entries
844
+ .map(({ account, index }) => {
845
+ const trimmedId =
846
+ typeof account?.accountId === "string" ? account.accountId.trim() : "";
847
+ const accountId = trimmedId || undefined;
848
+ // Members of one Business workspace share `accountId`, so the seat is
849
+ // what tells them apart. It is carried masked next to its suffix for
850
+ // the same reason `accountId` is: so the printed `seat:` discloses no
851
+ // more of an id than the field beside it unless telling two seats
852
+ // apart requires it.
853
+ const trimmedUserId =
854
+ typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "";
855
+ const accountUserId = trimmedUserId || undefined;
856
+ return {
857
+ index,
858
+ label: account?.accountLabel ?? `Account ${index + 1}`,
859
+ email: maskValue(account?.email, includeSensitive),
860
+ accountId: maskValue(accountId, includeSensitive),
861
+ idSuffix: accountIdSuffix(accountId, includeSensitive),
862
+ accountUserId: maskValue(accountUserId, includeSensitive),
863
+ seatSuffix: seatIsDisclosable(accountUserId, includeSensitive)
864
+ ? renderSeat(accountUserId)
865
+ : undefined,
866
+ accountIdSource: account?.accountIdSource,
867
+ enabled: account?.enabled !== false,
868
+ hasRefreshToken: typeof account?.refreshToken === "string" && account.refreshToken.length > 0,
869
+ hasAccessToken: typeof account?.accessToken === "string" && account.accessToken.length > 0,
870
+ expiresAt: account?.expiresAt,
871
+ expired: typeof account?.expiresAt === "number" ? account.expiresAt <= Date.now() : undefined,
872
+ tags: Array.isArray(account?.accountTags) ? account.accountTags : [],
873
+ note: account?.accountNote,
874
+ rateLimitResetTimes: account?.rateLimitResetTimes ?? {},
875
+ quotaExhaustedUntil: account?.quotaExhaustedUntil,
876
+ };
877
+ });
878
+ }
879
+
880
+ function printStandaloneResult(command, payload, json) {
881
+ if (json) {
882
+ console.log(JSON.stringify(payload, null, 2));
883
+ return;
884
+ }
885
+ console.log(`oc-codex-multi-auth ${command}`);
886
+ if (payload.message) console.log(payload.message);
887
+ console.log(`Storage: ${payload.storagePath}`);
888
+ console.log(`Accounts: ${payload.totalAccounts}`);
889
+ if (Array.isArray(payload.accounts)) {
890
+ for (const account of payload.accounts) {
891
+ const identity = [
892
+ account.email,
893
+ account.idSuffix ? `id:${account.idSuffix}` : undefined,
894
+ account.seatSuffix ? `seat:${account.seatSuffix}` : undefined,
895
+ ]
896
+ .filter(Boolean)
897
+ .join(", ");
898
+ const name = identity ? `${account.label} (${identity})` : account.label;
899
+ console.log(`- [${account.index}] ${name} enabled=${account.enabled} refresh=${account.hasRefreshToken} access=${account.hasAccessToken}`);
900
+ }
901
+ }
902
+ if (payload.error) console.log(`Error: ${payload.error}`);
903
+ for (const fix of payload.appliedFixes ?? []) console.log(`Fixed: ${fix}`);
904
+ for (const error of payload.fixErrors ?? []) console.log(`Repair failed: ${error}`);
905
+ if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
906
+ }
907
+
908
+ /**
909
+ * Import compiled modules from dist/ so the standalone CLI behaves identically
910
+ * to the in-conversation tools. dist/ ships in the npm package (files
911
+ * allowlist) and none of these modules import the OpenCode plugin runtime, so
912
+ * they load cleanly in plain Node.
913
+ */
914
+ async function loadDistModules(relativePaths, label) {
915
+ const distRoot = join(repoRoot, "dist", "lib");
916
+ const toUrl = (rel) => pathToFileURL(join(distRoot, rel)).href;
917
+ try {
918
+ return await Promise.all(relativePaths.map((rel) => import(toUrl(rel))));
919
+ } catch (error) {
920
+ throw new Error(
921
+ `Could not load ${label} runtime from dist/. Build the package first (npm run build). Cause: ${formatErrorForLog(error)}`,
922
+ );
923
+ }
924
+ }
925
+
926
+ async function loadWarmRuntime(env) {
927
+ const [storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod] = await loadDistModules(
928
+ [
929
+ "storage.js",
930
+ "codex-usage.js",
931
+ "accounts/warm-request.js",
932
+ "accounts/warm.js",
933
+ "shutdown.js",
934
+ "accounts/warm-recovery.js",
935
+ ],
936
+ "warm",
937
+ );
938
+ // Unlike the plugin, this CLI *is* the process, so it owns termination:
939
+ // Ctrl+C must abort the warm run rather than wait for it to drain.
940
+ // Refreshing a token here persists credentials, which registers the
941
+ // shutdown handler via the storage lock.
942
+ shutdownMod.setShutdownOwnsProcess(true);
943
+ return { storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod };
944
+ }
945
+
946
+ async function loadLimitsRuntime(env) {
947
+ const [storageMod, usageMod, shutdownMod, loggerMod, configMod] =
948
+ await loadDistModules(
949
+ ["storage.js", "codex-usage.js", "shutdown.js", "logger.js", "config.js"],
950
+ "limits",
951
+ );
952
+ // Fetching usage can refresh (and therefore persist) a token, so the same
953
+ // process-owns-termination rule as `warm` applies.
954
+ shutdownMod.setShutdownOwnsProcess(true);
955
+ return { storageMod, usageMod, shutdownMod, loggerMod, configMod };
956
+ }
957
+
958
+ export async function runWarmCommand(parsed, options = {}) {
959
+ const { env = process.env } = options;
960
+ const storagePath = getStandaloneStoragePath(parsed, env);
961
+
962
+ let runtime;
963
+ try {
964
+ runtime = await (options.loadWarmRuntime ?? loadWarmRuntime)(env);
965
+ } catch (error) {
966
+ const payload = { command: "warm", storagePath, error: formatErrorForLog(error) };
967
+ printWarmResult(payload, parsed.json);
968
+ return { exitCode: 1, action: "warm", storagePath };
969
+ }
970
+
971
+ const { storageMod, usageMod, warmReqMod, warmMod, recoveryMod } = runtime;
972
+ // Point dist storage at the resolved accounts file so a refreshed token is
973
+ // persisted to the SAME file the rest of the toolchain reads.
974
+ storageMod.setStoragePathDirect(storagePath);
975
+
976
+ let storage = null;
977
+ try {
978
+ storage = await storageMod.loadAccounts();
979
+ } catch (error) {
980
+ // Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
981
+ // upgrade hint; surface them rather than crashing the CLI.
982
+ const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
983
+ const payload = { command: "warm", storagePath, error: `${formatErrorForLog(error)}${hint}` };
984
+ printWarmResult(payload, parsed.json);
985
+ return { exitCode: 1, action: "warm", storagePath };
986
+ }
987
+ const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
988
+ if (accounts.length === 0) {
989
+ // `loadAccounts` swallows parse/IO errors and returns null. Probe the
990
+ // file so a corrupt storage fails like `status`/`doctor` do (exit 1)
991
+ // instead of reporting a healthy empty pool. ENOENT stays a silent
992
+ // empty pool: a missing file legitimately means no accounts yet.
993
+ const probe = await readStandaloneStorage(storagePath);
994
+ if (probe.error) {
995
+ const payload = { command: "warm", storagePath, error: probe.error };
996
+ printWarmResult(payload, parsed.json);
997
+ return { exitCode: 1, action: "warm", storagePath };
998
+ }
999
+ const payload = {
1000
+ command: "warm",
1001
+ storagePath,
1002
+ totalAccounts: 0,
1003
+ warmed: 0,
1004
+ blocksCleared: 0,
1005
+ failed: 0,
1006
+ skipped: 0,
1007
+ results: [],
1008
+ message: "No accounts configured.",
1009
+ nextAction: "Run opencode auth login.",
1010
+ };
1011
+ printWarmResult(payload, parsed.json);
1012
+ return { exitCode: 0, action: "warm", storagePath };
1013
+ }
1014
+
1015
+ // Same adapter as lib/tools/codex-warm.ts createWarmOne: refresh → resolve
1016
+ // account id → open the usage window; map an exhausted (quota-429) account
1017
+ // to a failure so it is not reported as warmed.
1018
+ const succeeded = [];
1019
+ const warmOne = async (account) => {
1020
+ const snapshot = { ...account, rateLimitResetTimes: { ...account.rateLimitResetTimes } };
1021
+ const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
1022
+ const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
1023
+ if (!accountId) {
1024
+ return { status: "failed", detail: "could not resolve account id (re-login may be required)" };
1025
+ }
1026
+ const result = await warmReqMod.warmAccountWindow({
1027
+ accountId,
1028
+ accessToken,
1029
+ organizationId: account.organizationId,
1030
+ });
1031
+ if (result.status === "exhausted") {
1032
+ return { status: "failed", detail: result.detail ?? "quota/usage limit reached" };
1033
+ }
1034
+ if (!result.rateLimited && result.model) succeeded.push({
1035
+ account: { ...snapshot, refreshToken: account.refreshToken }, model: result.model, accessToken,
1036
+ });
1037
+ return { status: "warmed" };
1038
+ };
1039
+
1040
+ const summary = await warmMod.warmAccounts(accounts, warmOne);
1041
+ let blocksCleared = 0;
1042
+ let blockClearError;
1043
+ for (const observation of succeeded) {
1044
+ let changed = false;
1045
+ try {
1046
+ const completed = await recoveryMod.recoverWarmedAccount(observation, () => { changed = true; });
1047
+ changed = completed || changed;
1048
+ } catch {
1049
+ blockClearError = "Failed to clear local blocks; warm results are unchanged.";
1050
+ }
1051
+ if (changed) blocksCleared++;
1052
+ }
1053
+ const payload = {
1054
+ command: "warm",
1055
+ blocksCleared,
1056
+ blockClearError,
1057
+ storagePath,
1058
+ totalAccounts: summary.total,
1059
+ warmed: summary.warmedCount,
1060
+ failed: summary.failedCount,
1061
+ skipped: summary.skippedCount,
1062
+ results: summary.results.map((r) => ({
1063
+ index: r.index,
1064
+ email: maskValue(accounts[r.index]?.email, parsed.includeSensitive),
1065
+ status: r.status,
1066
+ detail: r.detail,
1067
+ })),
1068
+ };
1069
+ printWarmResult(payload, parsed.json);
1070
+ return { exitCode: summary.failedCount > 0 ? 1 : 0, action: "warm", storagePath };
1071
+ }
1072
+
1073
+ function printWarmResult(payload, json) {
1074
+ if (json) {
1075
+ console.log(JSON.stringify(payload, null, 2));
1076
+ return;
1077
+ }
1078
+ console.log(`oc-codex-multi-auth warm`);
1079
+ if (payload.message) console.log(payload.message);
1080
+ console.log(`Storage: ${payload.storagePath}`);
1081
+ if (payload.error) {
1082
+ console.log(`Error: ${payload.error}`);
1083
+ return;
1084
+ }
1085
+ console.log(`Accounts: ${payload.totalAccounts}`);
1086
+ for (const r of payload.results ?? []) {
1087
+ const label = r.email ? `[${r.index}] ${r.email}` : `[${r.index}]`;
1088
+ const detail = r.detail ? ` — ${r.detail}` : "";
1089
+ console.log(`- ${label}: ${r.status}${detail}`);
1090
+ }
1091
+ console.log(`Summary: ${payload.warmed} warmed, ${payload.failed} failed, ${payload.skipped} skipped`);
1092
+ console.log(`Blocks cleared: ${payload.blocksCleared ?? 0}`);
1093
+ if (payload.blockClearError) console.log(payload.blockClearError);
1094
+ if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
1095
+ }
1096
+
1097
+ /**
1098
+ * `limits` — show live 5-hour and weekly Codex usage per account (#209).
1099
+ *
1100
+ * Reuses the compiled `codex-usage` runtime so the CLI reports the same windows
1101
+ * as the in-conversation `codex-limits` tool. The locally persisted
1102
+ * `rateLimitResetTimes` is carried in the payload as well: it is the only
1103
+ * rate-limit state available for an account whose live fetch fails, and
1104
+ * `--json` consumers of the previous behavior still find the field.
1105
+ */
1106
+ export async function runLimitsCommand(parsed, options = {}) {
1107
+ const { env = process.env } = options;
1108
+ const storagePath = getStandaloneStoragePath(parsed, env);
1109
+
1110
+ let runtime;
1111
+ try {
1112
+ runtime = await (options.loadLimitsRuntime ?? loadLimitsRuntime)(env);
1113
+ } catch (error) {
1114
+ const payload = { command: "limits", storagePath, error: formatErrorForLog(error) };
1115
+ printLimitsResult(payload, parsed.json);
1116
+ return { exitCode: 1, action: "limits", storagePath };
1117
+ }
1118
+
1119
+ const { storageMod, usageMod, loggerMod, configMod } = runtime;
1120
+ const quotaDisplay = configMod.getQuotaDisplay(configMod.loadPluginConfig());
1121
+ // Point dist storage at the resolved accounts file so a refreshed token is
1122
+ // persisted to the SAME file the rest of the toolchain reads.
1123
+ storageMod.setStoragePathDirect(storagePath);
1124
+
1125
+ let storage = null;
1126
+ try {
1127
+ storage = await storageMod.loadAccounts();
1128
+ } catch (error) {
1129
+ // Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
1130
+ // upgrade hint; surface them rather than crashing the CLI.
1131
+ const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
1132
+ const payload = { command: "limits", storagePath, error: `${formatErrorForLog(error)}${hint}` };
1133
+ printLimitsResult(payload, parsed.json);
1134
+ return { exitCode: 1, action: "limits", storagePath };
1135
+ }
1136
+ const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
1137
+ if (accounts.length === 0) {
1138
+ // Same probe contract as `warm`: a corrupt file exits 1 like
1139
+ // `status`/`doctor`; a missing file stays a silent empty pool.
1140
+ const probe = await readStandaloneStorage(storagePath);
1141
+ if (probe.error) {
1142
+ const payload = { command: "limits", storagePath, error: probe.error };
1143
+ printLimitsResult(payload, parsed.json);
1144
+ return { exitCode: 1, action: "limits", storagePath };
1145
+ }
1146
+ const payload = {
1147
+ command: "limits",
1148
+ storagePath,
1149
+ totalAccounts: 0,
1150
+ accounts: [],
1151
+ message: "No accounts configured.",
1152
+ nextAction: "Run opencode auth login.",
1153
+ };
1154
+ printLimitsResult(payload, parsed.json);
1155
+ return { exitCode: 0, action: "limits", storagePath };
1156
+ }
1157
+
1158
+ // Same workspace dedupe the codex-limits tool applies, so two entries for one
1159
+ // workspace are not billed and printed twice.
1160
+ const indices = usageMod.deduplicateUsageAccountIndices(storage);
1161
+ // `--tag` must gate which accounts are contacted at all, not just which are
1162
+ // printed: an untagged account would otherwise be billed a usage fetch and
1163
+ // could have its refreshed credentials persisted.
1164
+ const normalizedTag =
1165
+ typeof parsed.tag === "string" ? parsed.tag.trim().toLowerCase() : "";
1166
+ const results = [];
1167
+ let failedCount = 0;
1168
+
1169
+ for (const index of indices) {
1170
+ const account = accounts[index];
1171
+ if (!account) continue;
1172
+ if (
1173
+ normalizedTag &&
1174
+ !(
1175
+ Array.isArray(account.accountTags) &&
1176
+ account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)
1177
+ )
1178
+ ) {
1179
+ continue;
1180
+ }
1181
+ const entry = {
1182
+ index,
1183
+ label: account.accountLabel ?? `Account ${index + 1}`,
1184
+ email: maskValue(account.email, parsed.includeSensitive),
1185
+ rateLimitResetTimes: account.rateLimitResetTimes ?? {},
1186
+ quotaExhaustedUntil: account.quotaExhaustedUntil,
1187
+ };
1188
+ try {
1189
+ const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
1190
+ const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
1191
+ if (!accountId) {
1192
+ throw new Error("could not resolve account id (re-login may be required)");
1193
+ }
1194
+ const usage = usageMod.parseCodexUsagePayload(
1195
+ await usageMod.fetchCodexUsage({
1196
+ accountId,
1197
+ accessToken,
1198
+ organizationId: account.organizationId,
1199
+ }),
1200
+ quotaDisplay,
1201
+ );
1202
+ const quotaExhaustedResetAtMs = usageMod.getUsageQuotaExhaustedResetAtMs([
1203
+ usage.primary,
1204
+ usage.secondary,
1205
+ ]);
1206
+ if (quotaExhaustedResetAtMs !== undefined) {
1207
+ try {
1208
+ await usageMod.persistUsageQuotaExhaustion(account, quotaExhaustedResetAtMs);
1209
+ } catch (error) {
1210
+ loggerMod.logWarn(
1211
+ `[${PACKAGE_NAME}] Failed to persist exhausted usage quota: ${formatErrorForLog(error)}`,
1212
+ );
1213
+ }
1214
+ }
1215
+ if (usageMod.isUsageQuotaRecovered([usage.primary, usage.secondary])) {
1216
+ try {
1217
+ await usageMod.persistUsageQuotaRecovery(account);
1218
+ } catch {
1219
+ loggerMod.logWarn("Failed to persist recovered usage quota");
1220
+ }
1221
+ }
1222
+ entry.planType = usage.planType;
1223
+ entry.credits = usage.credits;
1224
+ // Raw counts stay in `resetCredits` and the rendered line lives in
1225
+ // its own field: embedding the English summary inside the counts
1226
+ // object would make `--json` consumers parse presentation text to
1227
+ // reach a number that is already beside it.
1228
+ entry.resetCredits = usage.resetCredits;
1229
+ entry.resetCreditsSummary = usage.resetCredits
1230
+ ? usageMod.formatResetCredits(usage.resetCredits)
1231
+ : null;
1232
+ entry.limits = usage.limits;
1233
+ } catch (error) {
1234
+ // `ensureCodexUsageAccessToken` can surface a raw OAuth refresh
1235
+ // response, so the message is redacted through the logger's token
1236
+ // patterns before it reaches stdout, JSON output, or CI logs.
1237
+ // Truncation alone does not protect bearer/JWT/refresh-token material.
1238
+ entry.error = loggerMod.maskString(formatErrorForLog(error)).slice(0, 160);
1239
+ failedCount += 1;
1240
+ }
1241
+ results.push(entry);
1242
+ }
1243
+
1244
+ const payload = {
1245
+ command: "limits",
1246
+ storagePath,
1247
+ totalAccounts: accounts.length,
1248
+ shownAccounts: results.length,
1249
+ accounts: results,
1250
+ };
1251
+ printLimitsResult(payload, parsed.json);
1252
+ return { exitCode: failedCount > 0 ? 1 : 0, action: "limits", storagePath };
1253
+ }
1254
+
1255
+ function printLimitsResult(payload, json) {
1256
+ if (json) {
1257
+ console.log(JSON.stringify(payload, null, 2));
1258
+ return;
1259
+ }
1260
+ console.log(`oc-codex-multi-auth limits`);
1261
+ if (payload.message) console.log(payload.message);
1262
+ console.log(`Storage: ${payload.storagePath}`);
1263
+ if (payload.error) {
1264
+ console.log(`Error: ${payload.error}`);
1265
+ return;
1266
+ }
1267
+ console.log(`Accounts: ${payload.totalAccounts}`);
1268
+ for (const account of payload.accounts ?? []) {
1269
+ const label = account.email ? `${account.label} (${account.email})` : account.label;
1270
+ console.log(`- [${account.index}] ${label}`);
1271
+ if (account.error) {
1272
+ console.log(` Error: ${account.error}`);
1273
+ continue;
1274
+ }
1275
+ for (const limit of account.limits ?? []) {
1276
+ console.log(` ${limit.name}: ${limit.summary}`);
1277
+ }
1278
+ if ((account.limits ?? []).length === 0) {
1279
+ console.log(" No usage windows reported yet.");
1280
+ }
1281
+ if (account.planType) console.log(` Plan: ${account.planType}`);
1282
+ if (account.credits) console.log(` Credits: ${account.credits}`);
1283
+ if (account.resetCredits && account.resetCredits.available > 0) {
1284
+ console.log(` Resets: ${account.resetCreditsSummary}`);
1285
+ }
1286
+ }
1287
+ if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
1288
+ }
1289
+
1290
+ export async function runStandaloneCommand(command, argv = [], options = {}) {
1291
+ const parsed = parseStandaloneArgs(argv);
1292
+ if (command === "diag") {
1293
+ command = "doctor";
1294
+ parsed.deep = true;
1295
+ }
1296
+ if (parsed.help) {
1297
+ printHelp();
1298
+ return { exitCode: 0, action: "help" };
1299
+ }
1300
+ if (command === "warm") {
1301
+ return runWarmCommand(parsed, options);
1302
+ }
1303
+ if (command === "limits") {
1304
+ return runLimitsCommand(parsed, options);
1305
+ }
1306
+ const { env = process.env } = options;
1307
+ const storagePath = getStandaloneStoragePath(parsed, env);
1308
+ const repairRequested = command === "doctor" && parsed.fix;
1309
+ let storage = null;
1310
+ let error = null;
1311
+ if (parsed.configPath || !repairRequested) {
1312
+ ({ storage, error } = await readStandaloneStorage(storagePath));
1313
+ }
1314
+ const appliedFixes = [];
1315
+ const fixErrors = [];
1316
+ if (repairRequested && !error) {
1317
+ const previousKeychain = process.env.CODEX_KEYCHAIN;
1318
+ try {
1319
+ const loadDoctorRuntime = options.loadDoctorRuntime ?? (() => loadDistModules(
1320
+ ["storage.js", "tools/doctor-repair.js", "shutdown.js"], "doctor",
1321
+ ));
1322
+ const [storageMod, repairMod, shutdownMod] = await loadDoctorRuntime();
1323
+ // A CLI file selection must not read or replace the global keychain pool.
1324
+ if (parsed.configPath) process.env.CODEX_KEYCHAIN = "0";
1325
+ storageMod.setStoragePathDirect(storagePath);
1326
+ shutdownMod.setShutdownOwnsProcess(true);
1327
+ try {
1328
+ storage = await storageMod.loadAccounts();
1329
+ } catch (loadError) {
1330
+ // Typed storage errors (UNSUPPORTED_SCHEMA_VERSION, unknown V2)
1331
+ // carry exact in-tree copy plus an upgrade/recovery hint, and the
1332
+ // load never got far enough to attempt a repair. Surface them on
1333
+ // the error channel (exit 1) instead of the generic catch below,
1334
+ // which is reserved for unknown throws so upstream failure text
1335
+ // never reaches output unredacted.
1336
+ if (loadError && typeof loadError.code === "string") {
1337
+ const hint = typeof loadError.hint === "string" ? ` ${loadError.hint}` : "";
1338
+ error = `${formatErrorForLog(loadError)}${hint}`;
1339
+ } else {
1340
+ throw loadError;
1341
+ }
1342
+ }
1343
+ if (!storage && !error) {
1344
+ // `loadAccounts` swallows JSON parse/IO errors and returns null. In
1345
+ // default-path mode the pre-read above was skipped (keychain routing
1346
+ // may own the pool), so probe the JSON file here: a corrupt file must
1347
+ // surface as a parse error (exit 1) instead of "No accounts
1348
+ // configured" (exit 0). ENOENT stays silent - a missing file with an
1349
+ // empty keychain legitimately means no accounts yet. Skipped when a
1350
+ // typed load error already set `error`; the probe would only
1351
+ // overwrite the precise schema message with its own paraphrase.
1352
+ const probe = await readStandaloneStorage(storagePath);
1353
+ if (probe.error) error = probe.error;
1354
+ }
1355
+ if (!error) {
1356
+ const repair = await repairMod.repairDoctorAccounts(storage?.accounts ?? []);
1357
+ appliedFixes.push(...repair.appliedFixes);
1358
+ fixErrors.push(...repair.fixErrors);
1359
+ storage = (await storageMod.loadAccounts()) ?? storage;
1360
+ }
1361
+ } catch {
1362
+ fixErrors.push("Doctor repair could not complete. Check the selected storage file and installed runtime.");
1363
+ } finally {
1364
+ if (parsed.configPath) {
1365
+ if (previousKeychain === undefined) delete process.env.CODEX_KEYCHAIN;
1366
+ else process.env.CODEX_KEYCHAIN = previousKeychain;
1367
+ }
1368
+ }
1369
+ }
1370
+ const accounts = summarizeStandaloneAccounts(storage, parsed.includeSensitive, parsed.tag);
1371
+ const totalAccounts = Array.isArray(storage?.accounts) ? storage.accounts.length : 0;
1372
+ const payload = {
1373
+ command,
1374
+ storagePath,
1375
+ totalAccounts,
1376
+ shownAccounts: accounts.length,
1377
+ activeIndex: typeof storage?.activeIndex === "number" ? storage.activeIndex : 0,
1378
+ activeIndexByFamily: storage?.activeIndexByFamily ?? {},
1379
+ accounts,
1380
+ error,
1381
+ };
1382
+ if (command === "dashboard") {
1383
+ payload.message = "Standalone dashboard server is not launched by this safe CLI; use status/list/limits/health or OpenCode codex-dashboard.";
1384
+ payload.nextAction = "Run oc-codex-multi-auth status or open OpenCode and call codex-dashboard.";
1385
+ } else if (command === "doctor") {
1386
+ payload.message = error ? "Storage could not be parsed." : totalAccounts > 0 ? "Local diagnostics completed." : "No accounts configured.";
1387
+ payload.deep = parsed.deep;
1388
+ payload.fixApplied = parsed.fix ? appliedFixes.length > 0 : undefined;
1389
+ if (parsed.fix) {
1390
+ payload.appliedFixes = appliedFixes;
1391
+ payload.fixErrors = fixErrors;
1392
+ }
1393
+ payload.nextAction = totalAccounts > 0 ? "Run oc-codex-multi-auth health --json for scriptable checks." : "Run opencode auth login.";
1394
+ } else if (command === "health") {
1395
+ payload.healthyCount = accounts.filter((account) => account.enabled && account.hasRefreshToken).length;
1396
+ payload.unhealthyCount = accounts.filter((account) => !account.enabled || !account.hasRefreshToken).length;
1397
+ } else if (command === "status") {
1398
+ payload.message = totalAccounts > 0 ? "Account storage loaded." : "No accounts configured.";
1399
+ }
1400
+ printStandaloneResult(command, payload, parsed.json);
1401
+ return { exitCode: error || fixErrors.length > 0 ? 1 : 0, action: command, storagePath };
1402
+ }
1403
+
1404
+ // Top-level keys inside `provider.openai` that the installer owns absolutely.
1405
+ // These are always sourced from the template (overwritten or removed) so the
1406
+ // plugin's required runtime shape is authoritative. Any OTHER key the user has
1407
+ // placed under `provider.openai` is preserved as-is. `models` is handled
1408
+ // separately because it's a map where user-added model ids must survive while
1409
+ // template-shipped ids win on collision.
1410
+ const MANAGED_OPENAI_KEYS = new Set(["baseURL", "apiKey", "options"]);
1411
+
1412
+ function isPlainObject(value) {
1413
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1414
+ }
1415
+
1416
+ // Deep-merge `provider.openai` preserving unknown user keys while letting the
1417
+ // installer overwrite the managed shape it ships. This replaces the earlier
1418
+ // wholesale overwrite which clobbered custom user-added keys (see audit top-20
1419
+ // #6).
1420
+ function mergeOpenaiProvider(existingOpenai, templateOpenai, options = {}) {
1421
+ const existingSafe = isPlainObject(existingOpenai) ? existingOpenai : {};
1422
+ const templateSafe = isPlainObject(templateOpenai) ? templateOpenai : {};
1423
+ const modelKeysToRemove = options.modelKeysToRemove instanceof Set
1424
+ ? options.modelKeysToRemove
1425
+ : new Set();
1426
+
1427
+ const result = {};
1428
+
1429
+ // 1. Start with the user's non-managed keys (unknown-to-installer settings).
1430
+ for (const [key, value] of Object.entries(existingSafe)) {
1431
+ if (MANAGED_OPENAI_KEYS.has(key)) continue;
1432
+ if (key === "models") continue; // handled explicitly below
1433
+ result[key] = value;
1434
+ }
1435
+
1436
+ // 2. Apply template-managed keys. Installer is source of truth for these.
1437
+ for (const [key, value] of Object.entries(templateSafe)) {
1438
+ if (key === "models") continue; // handled explicitly below
1439
+ result[key] = value;
1440
+ }
1441
+
1442
+ // 3. Merge `models` by id: template wins on collision, user-added ids survive.
1443
+ const existingModels = isPlainObject(existingSafe.models) ? existingSafe.models : {};
1444
+ const templateModels = isPlainObject(templateSafe.models) ? templateSafe.models : {};
1445
+ const prunedExistingModels = Object.fromEntries(
1446
+ Object.entries(existingModels).filter(([key]) => !modelKeysToRemove.has(key)),
1447
+ );
1448
+ const mergedModels = { ...prunedExistingModels, ...templateModels };
1449
+ if (Object.keys(mergedModels).length > 0) {
1450
+ result.models = mergedModels;
1451
+ }
1452
+
1453
+ return result;
1454
+ }
1455
+
1456
+ // Naive line-by-line diff for displaying config changes in dry-run. Good enough
1457
+ // for eyeballing; not intended to be parsed or round-tripped.
1458
+ function formatConfigDiff(existingConfig, nextConfig) {
1459
+ const oldText = existingConfig === undefined ? "" : formatJson(existingConfig);
1460
+ const newText = formatJson(nextConfig);
1461
+ if (oldText === newText) {
1462
+ return "(no changes)";
1463
+ }
1464
+ const lines = [];
1465
+ lines.push("--- existing");
1466
+ lines.push("+++ proposed");
1467
+ if (existingConfig === undefined) {
1468
+ lines.push("- (no existing config)");
1469
+ } else {
1470
+ for (const line of oldText.split("\n")) {
1471
+ lines.push(`- ${line}`);
1472
+ }
1473
+ }
1474
+ for (const line of newText.split("\n")) {
1475
+ lines.push(`+ ${line}`);
1476
+ }
1477
+ return lines.join("\n");
1478
+ }
1479
+
1480
+ function formatRedactedConfigDiff(existingConfig, nextConfig) {
1481
+ const missing = Symbol("missing");
1482
+ const changes = [];
1483
+ const visit = (existing, next, path) => {
1484
+ if (existing === missing) {
1485
+ changes.push(`+ ${path}`);
1486
+ return;
1487
+ }
1488
+ if (next === missing) {
1489
+ changes.push(`- ${path}`);
1490
+ return;
1491
+ }
1492
+ if (Object.is(existing, next)) return;
1493
+
1494
+ if (Array.isArray(existing) && Array.isArray(next)) {
1495
+ const length = Math.max(existing.length, next.length);
1496
+ for (let index = 0; index < length; index += 1) {
1497
+ visit(
1498
+ index < existing.length ? existing[index] : missing,
1499
+ index < next.length ? next[index] : missing,
1500
+ `${path}[${index}]`,
1501
+ );
1502
+ }
1503
+ return;
1504
+ }
1505
+
1506
+ if (isPlainObject(existing) && isPlainObject(next)) {
1507
+ const keys = new Set([...Object.keys(existing), ...Object.keys(next)]);
1508
+ for (const key of keys) {
1509
+ visit(
1510
+ Object.hasOwn(existing, key) ? existing[key] : missing,
1511
+ Object.hasOwn(next, key) ? next[key] : missing,
1512
+ `${path}.${key}`,
1513
+ );
1514
+ }
1515
+ return;
1516
+ }
1517
+
1518
+ changes.push(`~ ${path}`);
1519
+ };
1520
+
1521
+ visit(existingConfig === undefined ? missing : existingConfig, nextConfig, "$");
1522
+ return changes.length > 0 ? changes.join("\n") : "(no changes)";
1523
+ }
1524
+
1525
+ function mergeFullTemplate(modernTemplate, legacyTemplate) {
1526
+ const modernModels = modernTemplate.provider?.openai?.models ?? {};
1527
+ const legacyModels = legacyTemplate.provider?.openai?.models ?? {};
1528
+ const overlappingKeys = Object.keys(modernModels).filter((key) => Object.hasOwn(legacyModels, key));
1529
+
1530
+ if (overlappingKeys.length > 0) {
1531
+ throw new Error(`Full config template collision for model keys: ${overlappingKeys.join(", ")}`);
1532
+ }
1533
+
1534
+ return {
1535
+ ...modernTemplate,
1536
+ provider: {
1537
+ ...(modernTemplate.provider ?? {}),
1538
+ openai: {
1539
+ ...(modernTemplate.provider?.openai ?? {}),
1540
+ models: {
1541
+ ...modernModels,
1542
+ ...legacyModels,
1543
+ },
1544
+ },
1545
+ },
1546
+ };
1547
+ }
1548
+
1549
+ function getTemplateModelKeys(template) {
1550
+ return new Set(Object.keys(template.provider?.openai?.models ?? {}));
1551
+ }
1552
+
1553
+ async function readJson(filePath) {
1554
+ const content = await readFile(filePath, "utf-8");
1555
+ return JSON.parse(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content);
1556
+ }
1557
+
1558
+ async function renameWithWindowsRetry(sourcePath, destinationPath) {
1559
+ let lastError = null;
1560
+
1561
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
1562
+ try {
1563
+ await rename(sourcePath, destinationPath);
1564
+ return;
1565
+ } catch (error) {
1566
+ if (isWindowsLockError(error)) {
1567
+ lastError = error;
1568
+ await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
1569
+ continue;
1570
+ }
1571
+ throw error;
1572
+ }
1573
+ }
1574
+
1575
+ if (lastError) {
1576
+ throw lastError;
1577
+ }
1578
+ }
1579
+
1580
+ async function removeWithWindowsRetry(path, options) {
1581
+ let lastError = null;
1582
+
1583
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
1584
+ try {
1585
+ await rm(path, options);
1586
+ return;
1587
+ } catch (error) {
1588
+ if (isWindowsLockError(error)) {
1589
+ lastError = error;
1590
+ await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
1591
+ continue;
1592
+ }
1593
+ throw error;
1594
+ }
1595
+ }
1596
+
1597
+ if (lastError) {
1598
+ throw lastError;
1599
+ }
1600
+ }
1601
+
1602
+ async function writeFileAtomic(filePath, content) {
1603
+ const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
1604
+ const tempPath = `${filePath}.${uniqueSuffix}.tmp`;
1605
+
1606
+ try {
1607
+ await mkdir(dirname(filePath), { recursive: true });
1608
+ await writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
1609
+ await renameWithWindowsRetry(tempPath, filePath);
1610
+ } catch (error) {
1611
+ await rm(tempPath, { force: true }).catch(() => {});
1612
+ throw error;
1613
+ }
1614
+ }
1615
+
1616
+ async function loadTemplate(mode, paths) {
1617
+ if (mode === "modern") {
1618
+ return readJson(paths.modernTemplatePath);
1619
+ }
1620
+ if (mode === "legacy") {
1621
+ return readJson(paths.legacyTemplatePath);
1622
+ }
1623
+
1624
+ const [modernTemplate, legacyTemplate] = await Promise.all([
1625
+ readJson(paths.modernTemplatePath),
1626
+ readJson(paths.legacyTemplatePath),
1627
+ ]);
1628
+
1629
+ return mergeFullTemplate(modernTemplate, legacyTemplate);
1630
+ }
1631
+
1632
+ async function copyFileWithWindowsRetry(sourcePath, destinationPath) {
1633
+ let lastError = null;
1634
+
1635
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
1636
+ try {
1637
+ await copyFile(sourcePath, destinationPath);
1638
+ return;
1639
+ } catch (error) {
1640
+ if (isWindowsLockError(error)) {
1641
+ lastError = error;
1642
+ await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
1643
+ continue;
1644
+ }
1645
+ throw error;
1646
+ }
1647
+ }
1648
+
1649
+ if (lastError) {
1650
+ throw lastError;
1651
+ }
1652
+ }
1653
+
1654
+ async function backupConfig(sourcePath, dryRun) {
1655
+ const timestamp = new Date()
1656
+ .toISOString()
1657
+ .replace(/[:.]/g, "-")
1658
+ .replace("T", "_")
1659
+ .replace("Z", "");
1660
+ const backupPath = `${sourcePath}.bak-${timestamp}`;
1661
+ if (!dryRun) {
1662
+ await copyFileWithWindowsRetry(sourcePath, backupPath);
1663
+ }
1664
+ return backupPath;
1665
+ }
1666
+
1667
+ async function removePluginFromCachePackage(paths, dryRun) {
1668
+ if (!existsSync(paths.cachePackageJson)) {
1669
+ return;
1670
+ }
1671
+ if (!isEvictableCachePath(paths.cachePackageJson, paths.cacheDir)) {
1672
+ log(`Warning: refusing to update ${paths.cachePackageJson}: it does not resolve inside the OpenCode cache.`);
1673
+ return;
1674
+ }
1675
+
1676
+ let cacheData;
1677
+ try {
1678
+ cacheData = await readJson(paths.cachePackageJson);
1679
+ } catch (error) {
1680
+ log(`Warning: Could not parse ${paths.cachePackageJson} (${formatErrorForLog(error)}). Skipping.`);
1681
+ return;
1682
+ }
1683
+
1684
+ const sections = [
1685
+ "dependencies",
1686
+ "devDependencies",
1687
+ "peerDependencies",
1688
+ "optionalDependencies",
1689
+ ];
1690
+
1691
+ let changed = false;
1692
+ for (const section of sections) {
1693
+ const deps = cacheData?.[section];
1694
+ if (deps && typeof deps === "object") {
1695
+ for (const name of getManagedPackageNames()) {
1696
+ if (name in deps) {
1697
+ delete deps[name];
1698
+ changed = true;
1699
+ }
1700
+ }
1701
+ }
1702
+ }
1703
+
1704
+ if (!changed) {
1705
+ return;
1706
+ }
1707
+
1708
+ if (dryRun) {
1709
+ log(`[dry-run] Would update ${paths.cachePackageJson} to remove ${getManagedPackageNames().join(", ")}`);
1710
+ return;
1711
+ }
1712
+
1713
+ await writeFileAtomic(paths.cachePackageJson, formatJson(cacheData));
1714
+ }
1715
+
1716
+ /**
1717
+ * Mirror of `isEvictableCachePath` in lib/auto-update-checker.ts. A recursive
1718
+ * delete must never act on a path that only spells like cache: the cache root
1719
+ * itself must not resolve through a symlink (`~/.cache/opencode -> ~` would
1720
+ * otherwise call the whole home directory "inside the cache"), and the
1721
+ * resolved target must stay inside the resolved root.
1722
+ */
1723
+ function isEvictableCachePath(cachePath, cacheRoot) {
1724
+ const absolutePath = resolve(cachePath);
1725
+ const absoluteRoot = resolve(cacheRoot);
1726
+ if (!isInsideDirectory(absolutePath, absoluteRoot, process.platform)) return false;
1727
+ try {
1728
+ const realRoot = realpathSync(absoluteRoot);
1729
+ const rootIsSymlinked = process.platform === "win32"
1730
+ ? realRoot.toLowerCase() !== absoluteRoot.toLowerCase()
1731
+ : realRoot !== absoluteRoot;
1732
+ if (rootIsSymlinked) return false;
1733
+ return isInsideDirectory(realpathSync(absolutePath), realRoot, process.platform);
1734
+ } catch {
1735
+ return false;
1736
+ }
1737
+ }
1738
+
1739
+ async function clearCache(paths, dryRun, skipCacheClear) {
1740
+ if (skipCacheClear) {
1741
+ log("Skipping cache clear (--no-cache-clear).");
1742
+ await removePluginFromCachePackage(paths, dryRun);
1743
+ return;
1744
+ }
1745
+
1746
+ const cacheTargets = [
1747
+ ...paths.cacheNodeModulesPaths,
1748
+ ...paths.cachePackagePaths,
1749
+ paths.cacheBunLock,
1750
+ ];
1751
+
1752
+ if (dryRun) {
1753
+ for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) {
1754
+ log(`[dry-run] Would remove ${cacheNodeModulesPath}`);
1755
+ }
1756
+ for (const cachePackagePath of paths.cachePackagePaths) {
1757
+ log(`[dry-run] Would remove ${cachePackagePath}`);
1758
+ }
1759
+ log(`[dry-run] Would remove ${paths.cacheBunLock}`);
1760
+ } else {
1761
+ for (const cacheTarget of cacheTargets) {
1762
+ if (!existsSync(cacheTarget)) continue;
1763
+ if (!isEvictableCachePath(cacheTarget, paths.cacheDir)) {
1764
+ log(`Warning: refusing to remove ${cacheTarget}: it does not resolve inside the OpenCode cache.`);
1765
+ continue;
1766
+ }
1767
+ await removeWithWindowsRetry(cacheTarget, {
1768
+ recursive: cacheTarget !== paths.cacheBunLock,
1769
+ force: true,
1770
+ });
1771
+ }
1772
+ }
1773
+
1774
+ await removePluginFromCachePackage(paths, dryRun);
1775
+ }
1776
+
1777
+ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1778
+ const split = splitCommandArgv(argv);
1779
+ if (split.kind === "standalone") {
1780
+ return runStandaloneCommand(split.command, split.argv, options);
1781
+ }
1782
+ if (split.kind === "unknown") {
1783
+ printHelp();
1784
+ throw new Error(`Unknown command: ${split.command}`);
1785
+ }
1786
+ const { env = process.env } = options;
1787
+ const paths = buildPaths(resolveHomeDirectory(env));
1788
+ if (split.kind === "update") {
1789
+ const parsedUpdate = parseUpdateArgs(split.argv);
1790
+ if (parsedUpdate.wantsHelp) {
1791
+ printHelp();
1792
+ return { exitCode: 0, action: "help" };
1793
+ }
1794
+ await clearCache(paths, parsedUpdate.dryRun, false);
1795
+ log(`\n${parsedUpdate.dryRun ? "Dry run complete." : "Cache cleared."} Restart OpenCode to install the latest plugin.`);
1796
+ return {
1797
+ exitCode: 0,
1798
+ action: "update",
1799
+ dryRun: Boolean(parsedUpdate.dryRun),
1800
+ };
1801
+ }
1802
+ const parsed = parseCliArgs(split.argv);
1803
+ if (parsed.wantsHelp) {
1804
+ printHelp();
1805
+ return { exitCode: 0, action: "help" };
1806
+ }
1807
+
1808
+ const { configMode, dryRun, skipCacheClear, pluginOnly } = parsed;
1809
+ const effectiveConfigMode = pluginOnly ? "plugin-only" : configMode;
1810
+ const requiredTemplatePaths = pluginOnly
1811
+ ? []
1812
+ : configMode === "modern"
1813
+ ? [paths.modernTemplatePath]
1814
+ : configMode === "legacy"
1815
+ ? [paths.legacyTemplatePath]
1816
+ : [paths.modernTemplatePath, paths.legacyTemplatePath];
1817
+
1818
+ for (const templatePath of requiredTemplatePaths) {
1819
+ if (!existsSync(templatePath)) {
1820
+ throw new Error(`Config template not found at ${templatePath}`);
1821
+ }
1822
+ }
1823
+
1824
+ const template = pluginOnly
1825
+ ? { $schema: "https://opencode.ai/config.json", plugin: [PACKAGE_NAME] }
1826
+ : await loadTemplate(configMode, paths);
1827
+ template.plugin = [PACKAGE_NAME];
1828
+ const modelKeysToRemove = new Set(STALE_MANAGED_MODEL_KEYS);
1829
+ if (!pluginOnly && configMode === "modern") {
1830
+ for (const key of getTemplateModelKeys(await readJson(paths.legacyTemplatePath))) {
1831
+ modelKeysToRemove.add(key);
1832
+ }
1833
+ }
1834
+ if (!pluginOnly && configMode === "legacy") {
1835
+ for (const key of getTemplateModelKeys(await readJson(paths.modernTemplatePath))) {
1836
+ modelKeysToRemove.add(key);
1837
+ }
1838
+ }
1839
+
1840
+ let existingConfig;
1841
+ if (existsSync(paths.configPath)) {
1842
+ try {
1843
+ const existing = await readJson(paths.configPath);
1844
+ if (!isPlainObject(existing)) {
1845
+ throw new Error("config root must be a JSON object");
1846
+ }
1847
+ existingConfig = existing;
1848
+ } catch (error) {
1849
+ if (pluginOnly) {
1850
+ throw new Error(
1851
+ `Could not parse existing config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
1852
+ );
1853
+ }
1854
+ log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`);
1855
+ existingConfig = undefined;
1856
+ }
1857
+ } else {
1858
+ log("No existing config found. Creating new global config.");
1859
+ }
1860
+
1861
+ let existingTuiConfig;
1862
+ if (existsSync(paths.tuiConfigPath)) {
1863
+ try {
1864
+ const existing = await readJson(paths.tuiConfigPath);
1865
+ if (!isPlainObject(existing)) {
1866
+ throw new Error("TUI config root must be a JSON object");
1867
+ }
1868
+ existingTuiConfig = existing;
1869
+ } catch (error) {
1870
+ if (pluginOnly) {
1871
+ throw new Error(
1872
+ `Could not parse existing TUI config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
1873
+ );
1874
+ }
1875
+ log(`Warning: Could not parse existing TUI config (${formatErrorForLog(error)}). Replacing with minimal TUI config.`);
1876
+ existingTuiConfig = undefined;
1877
+ }
1878
+ } else {
1879
+ log("No existing TUI config found. Creating new global TUI config.");
1880
+ }
1881
+
1882
+ // A checkout of this package registered in either file already loads the
1883
+ // plugin, so the published name must not be written beside it anywhere -
1884
+ // otherwise the checkout in opencode.json and the published package in
1885
+ // tui.json both load.
1886
+ const pluginListOptions = {
1887
+ baseDirectory: paths.configDir,
1888
+ cacheDirectory: paths.cacheDir,
1889
+ };
1890
+ const checkoutRegistered = [existingConfig?.plugin, existingTuiConfig?.plugin]
1891
+ .flatMap((list) => (Array.isArray(list) ? list : []))
1892
+ .some((entry) => {
1893
+ const classification = classifyPluginEntry(entry, pluginListOptions);
1894
+ return (
1895
+ classification.kind === LOCAL_CHECKOUT_ENTRY &&
1896
+ classification.name === PACKAGE_NAME
1897
+ );
1898
+ });
1899
+ const normalizeOptions = { ...pluginListOptions, checkoutRegistered };
1900
+
1901
+ let nextConfig;
1902
+ if (existingConfig !== undefined) {
1903
+ const merged = { ...existingConfig };
1904
+ merged.plugin = normalizePluginList(existingConfig.plugin, log, normalizeOptions);
1905
+ if (!pluginOnly) {
1906
+ const provider = (existingConfig.provider && typeof existingConfig.provider === "object")
1907
+ ? { ...existingConfig.provider }
1908
+ : {};
1909
+ provider.openai = mergeOpenaiProvider(existingConfig.provider?.openai, template.provider?.openai, {
1910
+ modelKeysToRemove,
1911
+ });
1912
+ merged.provider = provider;
1913
+ }
1914
+ nextConfig = merged;
1915
+ } else {
1916
+ nextConfig = pluginOnly
1917
+ ? { $schema: template.$schema, plugin: [PACKAGE_NAME] }
1918
+ : template;
1919
+ nextConfig.plugin = normalizePluginList(nextConfig.plugin, log, normalizeOptions);
1920
+ }
1921
+
1922
+ const nextTuiConfig = mergeTuiConfig(existingTuiConfig, log, normalizeOptions);
1923
+
1924
+ const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath, {
1925
+ baseDirectory: paths.configDir,
1926
+ cacheDirectory: paths.cacheDir,
1927
+ });
1928
+ if (unregisteredCheckout) {
1929
+ log(
1930
+ `Note: this plugin last loaded from a checkout at ${unregisteredCheckout.root} on ${unregisteredCheckout.lastSeen}, ` +
1931
+ `which ${paths.configPath} does not register. Point the plugin entry back at that path if OpenCode should keep loading your own build.`,
1932
+ );
1933
+ }
1934
+
1935
+ const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig);
1936
+ const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig);
1937
+ let wrote = false;
1938
+ if (dryRun) {
1939
+ log(`[dry-run] ${configChanged ? "Would write" : "Would leave unchanged"} ${paths.configPath} using ${effectiveConfigMode} config`);
1940
+ log(`[dry-run] Diff for ${paths.configPath}:`);
1941
+ log(formatRedactedConfigDiff(existingConfig, nextConfig));
1942
+ log(`[dry-run] ${tuiConfigChanged ? "Would write" : "Would leave unchanged"} ${paths.tuiConfigPath} with the TUI status plugin`);
1943
+ log(`[dry-run] Diff for ${paths.tuiConfigPath}:`);
1944
+ log(formatRedactedConfigDiff(existingTuiConfig, nextTuiConfig));
1945
+ } else {
1946
+ if (configChanged) {
1947
+ if (existsSync(paths.configPath)) {
1948
+ const backupPath = await backupConfig(paths.configPath, false);
1949
+ log(`Backup created: ${backupPath}`);
1950
+ }
1951
+ await writeFileAtomic(paths.configPath, formatJson(nextConfig));
1952
+ wrote = true;
1953
+ log(`Wrote ${paths.configPath} (${effectiveConfigMode} config)`);
1954
+ } else {
1955
+ log(`Left ${paths.configPath} unchanged`);
1956
+ }
1957
+ if (tuiConfigChanged) {
1958
+ if (existsSync(paths.tuiConfigPath)) {
1959
+ const backupPath = await backupConfig(paths.tuiConfigPath, false);
1960
+ log(`Backup created: ${backupPath}`);
1961
+ }
1962
+ await writeFileAtomic(paths.tuiConfigPath, formatJson(nextTuiConfig));
1963
+ wrote = true;
1964
+ log(`Wrote ${paths.tuiConfigPath} (TUI status plugin)`);
1965
+ } else {
1966
+ log(`Left ${paths.tuiConfigPath} unchanged`);
1967
+ }
1968
+ }
1969
+
1970
+ await clearCache(paths, dryRun, skipCacheClear);
1971
+
1972
+ log("\nDone. Restart OpenCode to (re)install the plugin.");
1973
+ log("Example: opencode");
1974
+ if (!pluginOnly && configMode === "modern") {
1975
+ log("Note: Modern config intentionally shows 10 base OAuth model entries; use the variant picker for reasoning presets.");
1976
+ }
1977
+ if (!pluginOnly && configMode === "legacy") {
1978
+ log("Note: Legacy config writes 53 explicit preset entries and is also safe for older OpenCode versions.");
1979
+ }
1980
+ if (!pluginOnly && configMode === "full") {
1981
+ log("Note: Full config installs both compact base models and explicit preset entries for direct selector IDs.");
1982
+ }
1983
+
1984
+ return {
1985
+ exitCode: 0,
1986
+ action: "install",
1987
+ configMode: effectiveConfigMode,
1988
+ pluginOnly,
1989
+ configPath: paths.configPath,
1990
+ tuiConfigPath: paths.tuiConfigPath,
1991
+ dryRun: Boolean(dryRun),
1992
+ wrote,
1993
+ };
1994
+ }
1995
+
1996
+ export const __test = {
1997
+ ORIGIN_HISTORY_FILE_NAME,
1998
+ buildPaths,
1999
+ backupConfig,
2000
+ classifyPluginEntry,
2001
+ copyFileWithWindowsRetry,
2002
+ findUnregisteredLocalCheckout,
2003
+ formatConfigDiff,
2004
+ formatRedactedConfigDiff,
2005
+ mergeFullTemplate,
2006
+ mergeOpenaiProvider,
2007
+ mergeTuiConfig,
2008
+ normalizePluginList,
2009
+ parseCliArgs,
2010
+ removeWithWindowsRetry,
2011
+ runStandaloneCommand,
2012
+ splitCommandArgv,
2013
+ writeFileAtomic,
2014
+ renameWithWindowsRetry,
2015
+ resolveHomeDirectory,
2016
+ };