oc-codex-multi-auth 6.24.0 → 6.25.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 (80) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +795 -793
  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 -197
  7. package/config/minimal-opencode.json +14 -14
  8. package/config/opencode-legacy.json +1346 -1346
  9. package/config/opencode-modern.json +466 -466
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +26 -42
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/accounts/persistence.d.ts +6 -6
  14. package/dist/lib/accounts/persistence.d.ts.map +1 -1
  15. package/dist/lib/accounts/persistence.js +60 -6
  16. package/dist/lib/accounts/persistence.js.map +1 -1
  17. package/dist/lib/accounts/recovery.d.ts +5 -1
  18. package/dist/lib/accounts/recovery.d.ts.map +1 -1
  19. package/dist/lib/accounts/recovery.js +26 -1
  20. package/dist/lib/accounts/recovery.js.map +1 -1
  21. package/dist/lib/accounts/state.d.ts +18 -0
  22. package/dist/lib/accounts/state.d.ts.map +1 -1
  23. package/dist/lib/accounts/state.js +26 -2
  24. package/dist/lib/accounts/state.js.map +1 -1
  25. package/dist/lib/accounts.d.ts +2 -0
  26. package/dist/lib/accounts.d.ts.map +1 -1
  27. package/dist/lib/accounts.js +7 -1
  28. package/dist/lib/accounts.js.map +1 -1
  29. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  30. package/dist/lib/auth/login-runner.js +8 -0
  31. package/dist/lib/auth/login-runner.js.map +1 -1
  32. package/dist/lib/auth/plan-tier.d.ts.map +1 -1
  33. package/dist/lib/auth/plan-tier.js +2 -1
  34. package/dist/lib/auth/plan-tier.js.map +1 -1
  35. package/dist/lib/codex-usage.d.ts +49 -0
  36. package/dist/lib/codex-usage.d.ts.map +1 -1
  37. package/dist/lib/codex-usage.js +135 -5
  38. package/dist/lib/codex-usage.js.map +1 -1
  39. package/dist/lib/config.d.ts +11 -0
  40. package/dist/lib/config.d.ts.map +1 -1
  41. package/dist/lib/config.js +12 -0
  42. package/dist/lib/config.js.map +1 -1
  43. package/dist/lib/context-overflow.js +10 -10
  44. package/dist/lib/desktop-notifications.js +6 -6
  45. package/dist/lib/oauth-success.js +202 -202
  46. package/dist/lib/opencode-v2-rpc.d.ts +4 -0
  47. package/dist/lib/opencode-v2-rpc.d.ts.map +1 -1
  48. package/dist/lib/opencode-v2-rpc.js +1 -0
  49. package/dist/lib/opencode-v2-rpc.js.map +1 -1
  50. package/dist/lib/opencode-v2-status.d.ts +1 -0
  51. package/dist/lib/opencode-v2-status.d.ts.map +1 -1
  52. package/dist/lib/opencode-v2-status.js +8 -1
  53. package/dist/lib/opencode-v2-status.js.map +1 -1
  54. package/dist/lib/opencode-v2-tui.d.ts.map +1 -1
  55. package/dist/lib/opencode-v2-tui.js +8 -1
  56. package/dist/lib/opencode-v2-tui.js.map +1 -1
  57. package/dist/lib/prompts/codex-opencode-bridge.js +67 -67
  58. package/dist/lib/prompts/codex.js +75 -75
  59. package/dist/lib/schemas.d.ts +11 -0
  60. package/dist/lib/schemas.d.ts.map +1 -1
  61. package/dist/lib/schemas.js +4 -0
  62. package/dist/lib/schemas.js.map +1 -1
  63. package/dist/lib/storage/load-save.d.ts.map +1 -1
  64. package/dist/lib/storage/load-save.js +22 -20
  65. package/dist/lib/storage/load-save.js.map +1 -1
  66. package/dist/lib/tui-quota-cache.d.ts +7 -0
  67. package/dist/lib/tui-quota-cache.d.ts.map +1 -1
  68. package/dist/lib/tui-quota-cache.js +2 -1
  69. package/dist/lib/tui-quota-cache.js.map +1 -1
  70. package/dist/lib/tui-quota-overview.d.ts.map +1 -1
  71. package/dist/lib/tui-quota-overview.js +11 -2
  72. package/dist/lib/tui-quota-overview.js.map +1 -1
  73. package/package.json +157 -157
  74. package/scripts/audit-dev-allowlist.js +114 -114
  75. package/scripts/clean-dist.js +27 -27
  76. package/scripts/copy-oauth-success.js +47 -47
  77. package/scripts/install-oc-codex-multi-auth-core.js +2661 -2092
  78. package/scripts/install-oc-codex-multi-auth.js +37 -37
  79. package/scripts/test-all-models.sh +260 -260
  80. package/scripts/validate-model-map.sh +97 -97
@@ -1,2092 +1,2661 @@
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
- " --v2 Register for OpenCode V2 (includes automatic quota UI loading)\n" +
117
- " --modern Force compact modern config (10 base OAuth models + --variant presets)\n" +
118
- " --full Install compact base models plus 53 explicit selector entries\n" +
119
- " --legacy Force explicit legacy config (53 preset model entries)\n" +
120
- " --dry-run Show actions without writing\n" +
121
- " --no-cache-clear Skip clearing OpenCode cache\n"
122
- );
123
- }
124
-
125
- const scriptDir = dirname(fileURLToPath(import.meta.url));
126
- const repoRoot = resolve(scriptDir, "..");
127
- const modernTemplatePath = join(repoRoot, "config", "opencode-modern.json");
128
- const legacyTemplatePath = join(repoRoot, "config", "opencode-legacy.json");
129
-
130
- function log(message) {
131
- console.log(message);
132
- }
133
-
134
- function delay(ms) {
135
- return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
136
- }
137
-
138
- function isWindowsLockError(error) {
139
- const code = error?.code;
140
- return code === "EPERM" || code === "EBUSY";
141
- }
142
-
143
- function formatErrorForLog(error) {
144
- if (error instanceof Error) {
145
- return error.message;
146
- }
147
- return String(error);
148
- }
149
-
150
- function resolveHomeDirectory(env = process.env) {
151
- return env.HOME || env.USERPROFILE || homedir();
152
- }
153
-
154
- /** Resolve both JSON and JSONC config locations before changing V2 registration. */
155
- function buildPaths(homeDir) {
156
- const configDir = join(homeDir, ".config", "opencode");
157
- const cacheDir = join(homeDir, ".cache", "opencode");
158
- return {
159
- configDir,
160
- configPath: join(configDir, "opencode.json"),
161
- jsoncConfigPath: join(configDir, "opencode.jsonc"),
162
- tuiConfigPath: join(configDir, "tui.json"),
163
- cacheDir,
164
- cacheNodeModulesPaths: getManagedPackageNames().map((name) => join(cacheDir, "node_modules", name)),
165
- cachePackagePaths: getManagedPackageNames().flatMap((name) => [
166
- join(cacheDir, "packages", name),
167
- join(cacheDir, "packages", `${name}@latest`),
168
- ]),
169
- cacheBunLock: join(cacheDir, "bun.lock"),
170
- cachePackageJson: join(cacheDir, "package.json"),
171
- originHistoryPath: join(homeDir, ".opencode", ORIGIN_HISTORY_FILE_NAME),
172
- modernTemplatePath,
173
- legacyTemplatePath,
174
- };
175
- }
176
-
177
- /** Keep V2 plugin-only installation separate from V1 model catalog modes. */
178
- function parseCliArgs(argv = process.argv.slice(2)) {
179
- const args = new Set(argv);
180
- if (args.has("--help") || args.has("-h")) {
181
- return {
182
- wantsHelp: true,
183
- };
184
- }
185
-
186
- const requestedModern = args.has("--modern");
187
- const requestedFull = args.has("--full");
188
- const requestedLegacy = args.has("--legacy");
189
- const explicitPluginOnly = args.has("--plugin-only");
190
-
191
- const requestedModes = [requestedModern, requestedFull, requestedLegacy]
192
- .filter(Boolean).length;
193
- if (requestedModes > 1) {
194
- throw new Error("Choose only one of --modern, --full, or --legacy.");
195
- }
196
- if (explicitPluginOnly && requestedModes > 0) {
197
- throw new Error("--plugin-only cannot be combined with --modern, --full, or --legacy.");
198
- }
199
- const pluginOnly = explicitPluginOnly || requestedModes === 0;
200
- if (args.has("--v2") && !pluginOnly) {
201
- throw new Error("--v2 registers the plugin only; omit --modern, --full, and --legacy.");
202
- }
203
-
204
- return {
205
- wantsHelp: false,
206
- dryRun: args.has("--dry-run"),
207
- skipCacheClear: args.has("--no-cache-clear"),
208
- pluginOnly,
209
- v2: args.has("--v2"),
210
- configMode: requestedFull ? "full" : requestedLegacy ? "legacy" : "modern",
211
- };
212
- }
213
-
214
- function parseUpdateArgs(argv) {
215
- const args = new Set(argv);
216
- const supported = new Set(["--dry-run", "--help", "-h"]);
217
- const unknown = argv.find((arg) => !supported.has(arg));
218
- if (unknown) {
219
- throw new Error(`Unknown option for update command: ${unknown}`);
220
- }
221
- return {
222
- wantsHelp: args.has("--help") || args.has("-h"),
223
- dryRun: args.has("--dry-run"),
224
- };
225
- }
226
-
227
- const MANAGED_PACKAGE_ENTRY = "managed-package";
228
- const LOCAL_CHECKOUT_ENTRY = "local-checkout";
229
- const UNRELATED_ENTRY = "unrelated";
230
- const DECLARED_NAME_LOOKUP_DEPTH = 3;
231
-
232
- /** Extract a package/path from V1 tuples or native V2 plugin objects. */
233
- function pluginEntrySpecifier(entry) {
234
- if (typeof entry === "string") return entry;
235
- if (isPlainObject(entry) && typeof entry.package === "string") return entry.package;
236
- // `[specifier, options]` configures a plugin without changing where it loads from.
237
- if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
238
- return null;
239
- }
240
-
241
- /**
242
- * Spellings that can only mean a location on disk: absolute paths, `~`,
243
- * `./`/`../`, Windows drive letters, and UNC shares. Anything else that merely
244
- * contains a separator (`@scope/name`, git URLs, `npm:` aliases) is ambiguous
245
- * and counts as a path only when it resolves on this machine.
246
- */
247
- function isExplicitPathSpecifier(specifier) {
248
- return (
249
- /^[a-zA-Z]:[\\/]/.test(specifier) ||
250
- /^[\\/]/.test(specifier) ||
251
- /^~[\\/]/.test(specifier) ||
252
- /^\.\.?[\\/]/.test(specifier)
253
- );
254
- }
255
-
256
- /**
257
- * The path an entry names, exactly as the config spells it - and only when the
258
- * specifier actually is a path. A `/` alone does not make one: registry and
259
- * URL spellings can end in `oc-codex-multi-auth` without naming this package's
260
- * checkout, so an ambiguous specifier counts only when it resolves on disk.
261
- */
262
- function pluginEntryPath(specifier, baseDirectory) {
263
- const trimmed = specifier.trim();
264
- if (!trimmed) return null;
265
- if (/^file:\/\//i.test(trimmed)) {
266
- try {
267
- return fileURLToPath(trimmed);
268
- } catch {
269
- return null;
270
- }
271
- }
272
- if (isExplicitPathSpecifier(trimmed)) return trimmed;
273
- if (!trimmed.includes("/") && !trimmed.includes("\\")) return null;
274
- const inspectionPath = resolveInspectionPath(trimmed, baseDirectory);
275
- return inspectionPath && existsSync(inspectionPath) ? trimmed : null;
276
- }
277
-
278
- function pluginPathSegments(entryPath) {
279
- return entryPath.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean);
280
- }
281
-
282
- /**
283
- * Compared as written, without resolving symlinks: this asks whether an entry
284
- * names something `clearCache` removes, and `clearCache` removes the paths
285
- * exactly as it spells them - `rm` unlinks a symlink rather than descending
286
- * into it. Cache eviction resolves symlinks because it decides the opposite
287
- * question, whether a recursive delete is safe.
288
- */
289
- function isInsideDirectory(candidate, directory, platform) {
290
- const fold = (value) => (platform === "win32" ? value.toLowerCase() : value);
291
- const relativePath = relative(fold(resolve(directory)), fold(resolve(candidate)));
292
- return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
293
- }
294
-
295
- function isPackageManagerPath(entryPath, options = {}) {
296
- const { platform = process.platform, cacheDirectory, inspectionPath } = options;
297
- // Windows reaches one directory under many spellings, so `NODE_MODULES`
298
- // there is the same package-manager output as `node_modules`. Elsewhere the
299
- // two are different directories and must stay so.
300
- const segments = pluginPathSegments(entryPath).map((segment) =>
301
- platform === "win32" ? segment.toLowerCase() : segment,
302
- );
303
- if (
304
- segments.some(
305
- (segment, index) =>
306
- segment === "node_modules" ||
307
- // OpenCode's plugin cache spells the version into the directory name.
308
- // A `packages/` directory without one is an ordinary monorepo.
309
- (segments[index - 1] === "packages" && segment.includes("@")),
310
- )
311
- ) {
312
- return true;
313
- }
314
- // The cache is where this installer puts its own copies, and `clearCache`
315
- // empties it on the same run. Reading spelling alone leaves the cache's
316
- // unversioned `packages/<name>` looking like somebody's monorepo, so the
317
- // entry is kept while the directory under it is deleted - a config left
318
- // pointing at nothing. Whose directory it is settles that; the spelling
319
- // cannot.
320
- return Boolean(
321
- cacheDirectory &&
322
- inspectionPath &&
323
- isInsideDirectory(inspectionPath, cacheDirectory, platform),
324
- );
325
- }
326
-
327
- /**
328
- * Where an entry points, for reading metadata about it only. OpenCode resolves
329
- * a relative entry against the config file that declares it, so that directory
330
- * is what makes such a path mean anything; the installer's working directory
331
- * would name somewhere else entirely. Null when a relative entry arrives with
332
- * no declaring directory to resolve it against.
333
- */
334
- function resolveInspectionPath(entryPath, baseDirectory) {
335
- if (isAbsolute(entryPath)) return entryPath;
336
- return baseDirectory ? resolve(baseDirectory, entryPath) : null;
337
- }
338
-
339
- /**
340
- * Last-resort identification for a path that is not present on this machine.
341
- * Spelling alone never authorizes deleting an entry; it only names the package a
342
- * missing path was probably meant to point at.
343
- */
344
- function managedNameFromPathSpelling(entryPath) {
345
- const segments = pluginPathSegments(entryPath);
346
- const last = segments.at(-1) === "dist" ? segments.at(-2) : segments.at(-1);
347
- if (!last) return null;
348
- let candidate = last.toLowerCase();
349
- try {
350
- candidate = decodeURIComponent(candidate);
351
- } catch {
352
- // Keep the raw segment when it carries a malformed escape.
353
- }
354
- const versionSuffix = candidate.indexOf("@");
355
- if (versionSuffix > 0) candidate = candidate.slice(0, versionSuffix);
356
- return getManagedPackageNames().find((name) => name.toLowerCase() === candidate) ?? null;
357
- }
358
-
359
- function readDeclaredPackageName(directoryPath) {
360
- try {
361
- const parsed = JSON.parse(readFileSync(join(directoryPath, "package.json"), "utf8"));
362
- const name = parsed?.name;
363
- return typeof name === "string" && name.trim() ? name.trim() : null;
364
- } catch {
365
- return null;
366
- }
367
- }
368
-
369
- /** An entry may point at a build output inside the package, so walk upwards. */
370
- function resolveDeclaredPackageName(entryPath) {
371
- if (!isAbsolute(entryPath)) return null;
372
- let current = resolve(entryPath);
373
- for (let depth = 0; depth <= DECLARED_NAME_LOOKUP_DEPTH; depth += 1) {
374
- const name = readDeclaredPackageName(current);
375
- if (name) return name;
376
- const parent = dirname(current);
377
- if (parent === current) return null;
378
- current = parent;
379
- }
380
- return null;
381
- }
382
-
383
- /**
384
- * Decides what a plugin entry is, by identity rather than by spelling.
385
- *
386
- * The distinction that matters is not which package an entry names but who
387
- * chose the location. A bare specifier or a path inside `node_modules` is a
388
- * reference the installer itself produced and may retire. Any other path is
389
- * somewhere a human deliberately pointed OpenCode - a checkout of this package
390
- * being developed on, most often - and is never the installer's to remove.
391
- */
392
- function classifyPluginEntry(entry, options = {}) {
393
- const {
394
- resolveDeclaredName = resolveDeclaredPackageName,
395
- baseDirectory,
396
- cacheDirectory,
397
- platform = process.platform,
398
- } = options;
399
- const specifier = pluginEntrySpecifier(entry);
400
- if (specifier === null) return { kind: UNRELATED_ENTRY, name: null };
401
-
402
- const entryPath = pluginEntryPath(specifier, baseDirectory);
403
- if (entryPath === null) {
404
- const bare = specifier.trim().toLowerCase();
405
- const name = getManagedPackageNames().find(
406
- (managed) =>
407
- bare === managed.toLowerCase() || bare.startsWith(`${managed.toLowerCase()}@`),
408
- );
409
- return name
410
- ? { kind: MANAGED_PACKAGE_ENTRY, name }
411
- : { kind: UNRELATED_ENTRY, name: null };
412
- }
413
-
414
- const inspectionPath = resolveInspectionPath(entryPath, baseDirectory);
415
- const declaredName = inspectionPath ? resolveDeclaredName(inspectionPath) : null;
416
- const managedName = declaredName
417
- ? getManagedPackageNames().find(
418
- (managed) => managed.toLowerCase() === declaredName.toLowerCase(),
419
- ) ?? null
420
- : managedNameFromPathSpelling(entryPath);
421
-
422
- if (!managedName) return { kind: UNRELATED_ENTRY, name: null };
423
-
424
- return isPackageManagerPath(entryPath, { platform, cacheDirectory, inspectionPath })
425
- ? { kind: MANAGED_PACKAGE_ENTRY, name: managedName }
426
- : {
427
- kind: LOCAL_CHECKOUT_ENTRY,
428
- name: managedName,
429
- path: inspectionPath ?? entryPath,
430
- resolvesOnDisk: Boolean(inspectionPath && existsSync(inspectionPath)),
431
- };
432
- }
433
-
434
- /**
435
- * Ensures this plugin is registered exactly once, without changing how an
436
- * existing registration is spelled. Appending the published package name is the
437
- * fallback for a config that does not reference the plugin at all, not the
438
- * canonical form every config is rewritten into.
439
- */
440
- function normalizePluginList(list, onNotice, options = {}) {
441
- const entries = Array.isArray(list)
442
- ? list.filter((entry) => entry !== null && entry !== undefined && entry !== "")
443
- : [];
444
- const classifications = entries.map((entry) => classifyPluginEntry(entry, options));
445
- // A checkout of this package already IS the registration, so a published
446
- // entry beside it is a second copy of the same plugin for OpenCode to load.
447
- // `options.checkoutRegistered` carries the same fact across config files: a
448
- // checkout registered only in opencode.json still suppresses the published
449
- // name in tui.json, and vice versa. Only a checkout of the CURRENT package
450
- // counts: the former name is valid for cleanup, never as the registration
451
- // the installer exists to ensure.
452
- const checkoutRegistered = options.checkoutRegistered === true || classifications.some(
453
- (classification) =>
454
- classification.kind === LOCAL_CHECKOUT_ENTRY && classification.name === PACKAGE_NAME,
455
- );
456
- const kept = [];
457
- let keptPublishedName = false;
458
-
459
- entries.forEach((entry, index) => {
460
- const classification = classifications[index];
461
-
462
- if (classification.kind === LOCAL_CHECKOUT_ENTRY) {
463
- kept.push(entry);
464
- if (classification.resolvesOnDisk === false) {
465
- onNotice?.(
466
- `Warning: keeping ${classification.path} registered, but it does not resolve on disk; ` +
467
- "the plugin may not load until the path exists again.",
468
- );
469
- } else {
470
- onNotice?.(
471
- `Keeping the local ${classification.name} checkout registered at ${classification.path}`,
472
- );
473
- }
474
- return;
475
- }
476
-
477
- if (classification.kind === MANAGED_PACKAGE_ENTRY) {
478
- // Retire stale duplicates, version pins, renamed packages, and paths
479
- // into package-manager output; keep one published-name entry in place
480
- // unless a checkout already covers it.
481
- const isPublishedName = pluginEntrySpecifier(entry) === PACKAGE_NAME;
482
- if (isPublishedName && !checkoutRegistered && !keptPublishedName) {
483
- keptPublishedName = true;
484
- kept.push(entry);
485
- }
486
- return;
487
- }
488
-
489
- kept.push(entry);
490
- });
491
-
492
- return checkoutRegistered || keptPublishedName ? kept : [...kept, PACKAGE_NAME];
493
- }
494
-
495
- function readLocalCheckoutSightings(historyPath) {
496
- try {
497
- const parsed = JSON.parse(readFileSync(historyPath, "utf8"));
498
- const sightings = parsed?.sightings;
499
- if (!Array.isArray(sightings)) return [];
500
- return sightings.filter(
501
- (sighting) =>
502
- sighting &&
503
- typeof sighting === "object" &&
504
- sighting.isLocalCheckout === true &&
505
- typeof sighting.root === "string" &&
506
- typeof sighting.lastSeen === "string" &&
507
- getManagedPackageNames().includes(sighting.name),
508
- );
509
- } catch {
510
- return [];
511
- }
512
- }
513
-
514
- /**
515
- * A checkout the plugin has run from that the finished config does not
516
- * register. Reported rather than restored: config history is evidence of what
517
- * happened, not authority over what the user wants registered now.
518
- */
519
- function findUnregisteredLocalCheckout(pluginList, historyPath, options = {}) {
520
- const entries = Array.isArray(pluginList) ? pluginList : [];
521
- if (entries.some((entry) => classifyPluginEntry(entry, options).kind === LOCAL_CHECKOUT_ENTRY)) {
522
- return null;
523
- }
524
- const latest = readLocalCheckoutSightings(historyPath)
525
- .sort((left, right) => (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0))
526
- .at(-1);
527
- if (!latest) return null;
528
- // The directory has to still hold the package that was recorded there. A
529
- // path gets reused - a checkout deleted and something else cloned into its
530
- // place - and a recorded path that now declares another project would
531
- // otherwise be offered as somewhere to point OpenCode back at.
532
- const declaredName = resolveDeclaredPackageName(latest.root);
533
- if (!declaredName || declaredName.toLowerCase() !== String(latest.name).toLowerCase()) {
534
- return null;
535
- }
536
- return latest;
537
- }
538
-
539
- function mergeTuiConfig(existingConfig, onNotice, options = {}) {
540
- const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {};
541
- const next = { ...existing };
542
- if (typeof next.$schema !== "string" || !next.$schema.trim()) {
543
- next.$schema = "https://opencode.ai/tui.json";
544
- }
545
- next.plugin = normalizePluginList(existing.plugin, onNotice, options);
546
- return next;
547
- }
548
-
549
- function formatJson(obj) {
550
- return `${JSON.stringify(obj, null, 2)}\n`;
551
- }
552
-
553
- function getStandaloneStoragePath(options, env = process.env) {
554
- if (options.configPath) return resolve(options.configPath);
555
- return join(resolveHomeDirectory(env), ".opencode", "oc-codex-multi-auth-accounts.json");
556
- }
557
-
558
- async function readStandaloneStorage(path) {
559
- try {
560
- const raw = await readFile(path, "utf-8");
561
- const parsed = JSON.parse(raw);
562
- // Shape validation, not just parse validation: a JSON array, scalar, or
563
- // object without an `accounts` array is unreadable by the plugin runtime
564
- // too (normalizeAccountStorage rejects it), so reporting it as a healthy
565
- // empty pool (exit 0, "No accounts configured") hides the corruption
566
- // from scripted callers that key on exit codes.
567
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
568
- return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
569
- }
570
- // Forward-compat mirror of the runtime guard: a newer schema version
571
- // must not be shown as readable accounts by this build.
572
- const version = parsed.version;
573
- if (typeof version === "number" && Number.isFinite(version) && version > 3) {
574
- return {
575
- storage: null,
576
- error: `Unsupported account storage schema version ${version}; this build supports up to version 3.`,
577
- };
578
- }
579
- if (!Array.isArray(parsed.accounts)) {
580
- return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
581
- }
582
- return {
583
- storage: normalizeStandaloneStorage(parsed),
584
- error: null,
585
- };
586
- } catch (error) {
587
- if (error?.code === "ENOENT") return { storage: null, error: null };
588
- return { storage: null, error: formatErrorForLog(error) };
589
- }
590
- }
591
-
592
- function normalizeStandaloneIdentityPart(value) {
593
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
594
- }
595
-
596
- function sameStandaloneIdentity(left, right) {
597
- const normalizedLeft = normalizeStandaloneIdentityPart(left);
598
- const normalizedRight = normalizeStandaloneIdentityPart(right);
599
- return !!normalizedLeft && !!normalizedRight && normalizedLeft === normalizedRight;
600
- }
601
-
602
- function isStandaloneOrgTokenDuplicate(left, right) {
603
- const leftOrganizationId = normalizeStandaloneIdentityPart(left?.organizationId);
604
- const rightOrganizationId = normalizeStandaloneIdentityPart(right?.organizationId);
605
- if (leftOrganizationId && rightOrganizationId && leftOrganizationId !== rightOrganizationId) return false;
606
- const leftOrgLike = !!leftOrganizationId || left?.accountIdSource === "org";
607
- const rightOrgLike = !!rightOrganizationId || right?.accountIdSource === "org";
608
- const leftTokenLike = !leftOrganizationId && left?.accountIdSource === "token";
609
- const rightTokenLike = !rightOrganizationId && right?.accountIdSource === "token";
610
- if (!((leftOrgLike && rightTokenLike) || (rightOrgLike && leftTokenLike))) return false;
611
- return sameStandaloneIdentity(left?.email, right?.email) ||
612
- sameStandaloneIdentity(left?.refreshToken, right?.refreshToken);
613
- }
614
-
615
- function mergeStandaloneAccounts(target, source) {
616
- const targetOrgLike = !!normalizeStandaloneIdentityPart(target?.organizationId) || target?.accountIdSource === "org";
617
- const sourceOrgLike = !!normalizeStandaloneIdentityPart(source?.organizationId) || source?.accountIdSource === "org";
618
- if (targetOrgLike || !sourceOrgLike) {
619
- return {
620
- ...source,
621
- ...target,
622
- organizationId: target.organizationId ?? source.organizationId,
623
- accountId: target.accountId ?? source.accountId,
624
- accountIdSource: target.accountIdSource ?? source.accountIdSource,
625
- accountLabel: target.accountLabel ?? source.accountLabel,
626
- email: target.email ?? source.email,
627
- };
628
- }
629
- return mergeStandaloneAccounts(source, target);
630
- }
631
-
632
- // Mirror of `isStaleGeneratedAccountLabel` / `dropStaleGeneratedLabel` in
633
- // lib/auth/token-utils.ts and lib/storage/normalize.ts. The standalone CLI
634
- // reads the pool through this normalizer and never through the compiled
635
- // `normalizeAccountStorage`, so without the mirror `status`, `list`, `health`,
636
- // `doctor` and `dashboard` keep printing the org-derived label the plugin
637
- // itself now drops - next to the account id, which is the identity the label
638
- // was misnaming. The marker must hold this account's own id suffix so a name
639
- // set with `codex-label` survives.
640
- const GENERATED_LABEL_PATTERN = /\s\[id:[^\]]*\]$/;
641
-
642
- function dropStaleStandaloneLabel(account) {
643
- const label = typeof account?.accountLabel === "string" ? account.accountLabel.trim() : "";
644
- const accountId = typeof account?.accountId === "string" ? account.accountId.trim() : "";
645
- if (!label || !accountId) return account;
646
- const marker = label.match(GENERATED_LABEL_PATTERN)?.[0];
647
- if (!marker) return account;
648
- const suffix = accountId.length > 6 ? accountId.slice(-6) : accountId;
649
- if (marker !== ` [id:${suffix}]`) return account;
650
- const next = { ...account };
651
- delete next.accountLabel;
652
- return next;
653
- }
654
-
655
- function normalizeStandaloneStorage(storage) {
656
- if (!Array.isArray(storage.accounts)) return storage;
657
- const accounts = [...storage.accounts];
658
- const removed = new Set();
659
- for (let i = 0; i < accounts.length; i += 1) {
660
- if (removed.has(i)) continue;
661
- for (let j = i + 1; j < accounts.length; j += 1) {
662
- if (removed.has(j) || !isStandaloneOrgTokenDuplicate(accounts[i], accounts[j])) continue;
663
- const leftOrgLike = !!normalizeStandaloneIdentityPart(accounts[i]?.organizationId) ||
664
- accounts[i]?.accountIdSource === "org";
665
- const targetIndex = leftOrgLike ? i : j;
666
- const sourceIndex = targetIndex === i ? j : i;
667
- accounts[targetIndex] = mergeStandaloneAccounts(accounts[targetIndex], accounts[sourceIndex]);
668
- removed.add(sourceIndex);
669
- if (sourceIndex === i) break;
670
- }
671
- }
672
- const normalizedAccounts = accounts
673
- .filter((_, index) => !removed.has(index))
674
- .map(dropStaleStandaloneLabel);
675
- return {
676
- ...storage,
677
- accounts: normalizedAccounts,
678
- activeIndex: Math.max(0, Math.min(storage.activeIndex ?? 0, Math.max(0, normalizedAccounts.length - 1))),
679
- };
680
- }
681
-
682
- const MASKED_VALUE = "*****";
683
- // The head/tail mask keeps eight characters, so it conceals nothing worth
684
- // concealing below thirteen: `me@x.io` would print in full and `me@x.io12`
685
- // all but its middle character. `doctor` output is what users paste into
686
- // issues, so anything shorter is replaced outright instead. Input is trimmed
687
- // first, or `" me@x.io "` clears the cutoff on padding alone and drops back
688
- // into the partial mask.
689
- const MASK_MIN_LENGTH = 13;
690
-
691
- function maskValue(value, includeSensitive) {
692
- if (includeSensitive || typeof value !== "string") return value;
693
- const trimmed = value.trim();
694
- if (!trimmed) return trimmed;
695
- if (trimmed.length < MASK_MIN_LENGTH) return MASKED_VALUE;
696
- return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
697
- }
698
-
699
- // Six characters when the id is shown in full, matching what the
700
- // in-conversation surfaces print as `id:`. Four when it is head/tail masked,
701
- // which is the tail `maskValue` already discloses as `accountId` in the same
702
- // payload, so the printed identity never reveals more of an id than the field
703
- // beside it. Nothing at all when the id was too short for that mask: the
704
- // `accountId` next to it is then `*****`, and four raw characters of a short
705
- // id can be the whole id.
706
- function accountIdSuffix(accountId, includeSensitive) {
707
- if (!accountId) return undefined;
708
- if (includeSensitive) {
709
- return accountId.length > 6 ? accountId.slice(-6) : accountId;
710
- }
711
- if (accountId.length < MASK_MIN_LENGTH) return undefined;
712
- return accountId.slice(-4);
713
- }
714
-
715
- // A member id is what tells two seats of one Business workspace apart, and no
716
- // fixed-length tail always does it: member ids sharing a six-character tail
717
- // were observed, and in a real nine-seat pool the ids are 67 characters with
718
- // no shared tail at all, so growing a tail until it separates them prints most
719
- // of the id in every row. The renderer below mirrors `resolveSeatRenderer` in
720
- // lib/account-display.ts - a tail, else one window anchored where the ids first
721
- // diverge, else short windows at each position where a pair first differs
722
- // joined by `..`, else a hash prefix, each capped - and the id whole only if
723
- // none of those separate them, which needs a 128-bit SHA-256 collision. The
724
- // hash outcome is reachable, and it prints a value that cannot be matched
725
- // against the id by eye.
726
- const STANDALONE_SEAT_MAX_LENGTH = 12;
727
- const STANDALONE_SEAT_HASH_LENGTHS = [8, 12, 16, 24, 32];
728
- const STANDALONE_SEAT_WINDOW_SEPARATOR = "..";
729
-
730
- function seatIsDisclosable(accountUserId, includeSensitive) {
731
- if (!accountUserId) return false;
732
- return includeSensitive || accountUserId.length >= MASK_MIN_LENGTH;
733
- }
734
-
735
- function seatTail(accountUserId, length) {
736
- return accountUserId.length > length ? accountUserId.slice(-length) : accountUserId;
737
- }
738
-
739
- function seatWindow(accountUserId, start, length) {
740
- if (accountUserId.length <= length) return accountUserId;
741
- const begin = Math.max(0, Math.min(start, accountUserId.length - length));
742
- return accountUserId.slice(begin, begin + length);
743
- }
744
-
745
- function seatCommonPrefixLength(values) {
746
- const [first] = values;
747
- if (first === undefined) return 0;
748
- let shared = first.length;
749
- for (const value of values) {
750
- let index = 0;
751
- while (index < shared && index < value.length && first[index] === value[index]) {
752
- index += 1;
753
- }
754
- shared = index;
755
- if (shared === 0) break;
756
- }
757
- return shared;
758
- }
759
-
760
- function seatFirstDivergence(left, right) {
761
- const limit = Math.min(left.length, right.length);
762
- let index = 0;
763
- while (index < limit && left[index] === right[index]) index += 1;
764
- return index;
765
- }
766
-
767
- // For every pair, the first index at which that pair differs - not every index
768
- // where the ids disagree, which across a handful of random-looking ids is
769
- // nearly all of them and localizes nothing.
770
- function seatDivergenceAnchors(values) {
771
- const anchors = new Set();
772
- for (let left = 0; left < values.length; left += 1) {
773
- for (let right = left + 1; right < values.length; right += 1) {
774
- anchors.add(seatFirstDivergence(values[left], values[right]));
775
- }
776
- }
777
- return [...anchors].sort((left, right) => left - right);
778
- }
779
-
780
- function seatAnchorWindowStarts(anchors, width) {
781
- const starts = [];
782
- for (const anchor of anchors) {
783
- const last = starts[starts.length - 1];
784
- if (last !== undefined && anchor < last + width) continue;
785
- starts.push(anchor);
786
- }
787
- return starts;
788
- }
789
-
790
- function resolveStandaloneSeatRenderer(accountUserIds, includeSensitive) {
791
- // Starts at the length the mask above allows, so masked output widens only
792
- // when leaving it short would print a lie.
793
- const base = includeSensitive ? 6 : 4;
794
- const distinct = [];
795
- const seen = new Set();
796
- for (const accountUserId of accountUserIds) {
797
- if (!seatIsDisclosable(accountUserId, includeSensitive)) continue;
798
- if (seen.has(accountUserId)) continue;
799
- seen.add(accountUserId);
800
- distinct.push(accountUserId);
801
- }
802
- const atBase = (accountUserId) => seatTail(accountUserId, base);
803
- if (distinct.length <= 1) return atBase;
804
-
805
- const separates = (render) => new Set(distinct.map(render)).size === distinct.length;
806
-
807
- for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
808
- const render = (accountUserId) => seatTail(accountUserId, length);
809
- if (separates(render)) return render;
810
- }
811
- const start = seatCommonPrefixLength(distinct);
812
- for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
813
- const render = (accountUserId) => seatWindow(accountUserId, start, length);
814
- if (separates(render)) return render;
815
- }
816
- const anchors = seatDivergenceAnchors(distinct);
817
- for (let width = 2; width <= STANDALONE_SEAT_MAX_LENGTH; width += 1) {
818
- const starts = seatAnchorWindowStarts(anchors, width);
819
- const rendered =
820
- starts.length * width + (starts.length - 1) * STANDALONE_SEAT_WINDOW_SEPARATOR.length;
821
- // Skipped, not abandoned: a wider window can span two nearby anchors
822
- // that needed one window each, so the cost falls as the window count
823
- // does. Mirrors `resolveSeatRenderer` in lib/account-display.ts, where
824
- // the measured counter-example is written out.
825
- if (rendered > STANDALONE_SEAT_MAX_LENGTH) continue;
826
- const render = (accountUserId) =>
827
- starts
828
- .map((windowStart) => accountUserId.slice(windowStart, windowStart + width))
829
- .join(STANDALONE_SEAT_WINDOW_SEPARATOR);
830
- if (separates(render)) return render;
831
- }
832
- for (const length of STANDALONE_SEAT_HASH_LENGTHS) {
833
- const render = (accountUserId) => createHash("sha256").update(accountUserId).digest("hex").slice(0, length);
834
- if (separates(render)) return render;
835
- }
836
- return (accountUserId) => accountUserId;
837
- }
838
-
839
- function summarizeStandaloneAccounts(storage, includeSensitive, tag) {
840
- const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
841
- const normalizedTag = typeof tag === "string" ? tag.trim().toLowerCase() : "";
842
- const entries = accounts
843
- .map((account, index) => ({ account, index }))
844
- .filter(({ account }) => !normalizedTag ||
845
- (Array.isArray(account?.accountTags) &&
846
- account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)));
847
- const renderSeat = resolveStandaloneSeatRenderer(
848
- entries.map(({ account }) =>
849
- (typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "") || undefined,
850
- ),
851
- includeSensitive,
852
- );
853
- return entries
854
- .map(({ account, index }) => {
855
- const trimmedId =
856
- typeof account?.accountId === "string" ? account.accountId.trim() : "";
857
- const accountId = trimmedId || undefined;
858
- // Members of one Business workspace share `accountId`, so the seat is
859
- // what tells them apart. It is carried masked next to its suffix for
860
- // the same reason `accountId` is: so the printed `seat:` discloses no
861
- // more of an id than the field beside it unless telling two seats
862
- // apart requires it.
863
- const trimmedUserId =
864
- typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "";
865
- const accountUserId = trimmedUserId || undefined;
866
- return {
867
- index,
868
- label: account?.accountLabel ?? `Account ${index + 1}`,
869
- email: maskValue(account?.email, includeSensitive),
870
- accountId: maskValue(accountId, includeSensitive),
871
- idSuffix: accountIdSuffix(accountId, includeSensitive),
872
- accountUserId: maskValue(accountUserId, includeSensitive),
873
- seatSuffix: seatIsDisclosable(accountUserId, includeSensitive)
874
- ? renderSeat(accountUserId)
875
- : undefined,
876
- accountIdSource: account?.accountIdSource,
877
- enabled: account?.enabled !== false,
878
- hasRefreshToken: typeof account?.refreshToken === "string" && account.refreshToken.length > 0,
879
- hasAccessToken: typeof account?.accessToken === "string" && account.accessToken.length > 0,
880
- expiresAt: account?.expiresAt,
881
- expired: typeof account?.expiresAt === "number" ? account.expiresAt <= Date.now() : undefined,
882
- tags: Array.isArray(account?.accountTags) ? account.accountTags : [],
883
- note: account?.accountNote,
884
- rateLimitResetTimes: account?.rateLimitResetTimes ?? {},
885
- quotaExhaustedUntil: account?.quotaExhaustedUntil,
886
- };
887
- });
888
- }
889
-
890
- function printStandaloneResult(command, payload, json) {
891
- if (json) {
892
- console.log(JSON.stringify(payload, null, 2));
893
- return;
894
- }
895
- console.log(`oc-codex-multi-auth ${command}`);
896
- if (payload.message) console.log(payload.message);
897
- console.log(`Storage: ${payload.storagePath}`);
898
- console.log(`Accounts: ${payload.totalAccounts}`);
899
- if (Array.isArray(payload.accounts)) {
900
- for (const account of payload.accounts) {
901
- const identity = [
902
- account.email,
903
- account.idSuffix ? `id:${account.idSuffix}` : undefined,
904
- account.seatSuffix ? `seat:${account.seatSuffix}` : undefined,
905
- ]
906
- .filter(Boolean)
907
- .join(", ");
908
- const name = identity ? `${account.label} (${identity})` : account.label;
909
- console.log(`- [${account.index}] ${name} enabled=${account.enabled} refresh=${account.hasRefreshToken} access=${account.hasAccessToken}`);
910
- }
911
- }
912
- if (payload.error) console.log(`Error: ${payload.error}`);
913
- for (const fix of payload.appliedFixes ?? []) console.log(`Fixed: ${fix}`);
914
- for (const error of payload.fixErrors ?? []) console.log(`Repair failed: ${error}`);
915
- if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
916
- }
917
-
918
- /**
919
- * Import compiled modules from dist/ so the standalone CLI behaves identically
920
- * to the in-conversation tools. dist/ ships in the npm package (files
921
- * allowlist) and none of these modules import the OpenCode plugin runtime, so
922
- * they load cleanly in plain Node.
923
- */
924
- async function loadDistModules(relativePaths, label) {
925
- const distRoot = join(repoRoot, "dist", "lib");
926
- const toUrl = (rel) => pathToFileURL(join(distRoot, rel)).href;
927
- try {
928
- return await Promise.all(relativePaths.map((rel) => import(toUrl(rel))));
929
- } catch (error) {
930
- throw new Error(
931
- `Could not load ${label} runtime from dist/. Build the package first (npm run build). Cause: ${formatErrorForLog(error)}`,
932
- );
933
- }
934
- }
935
-
936
- async function loadWarmRuntime(env) {
937
- const [storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod] = await loadDistModules(
938
- [
939
- "storage.js",
940
- "codex-usage.js",
941
- "accounts/warm-request.js",
942
- "accounts/warm.js",
943
- "shutdown.js",
944
- "accounts/warm-recovery.js",
945
- ],
946
- "warm",
947
- );
948
- // Unlike the plugin, this CLI *is* the process, so it owns termination:
949
- // Ctrl+C must abort the warm run rather than wait for it to drain.
950
- // Refreshing a token here persists credentials, which registers the
951
- // shutdown handler via the storage lock.
952
- shutdownMod.setShutdownOwnsProcess(true);
953
- return { storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod };
954
- }
955
-
956
- async function loadLimitsRuntime(env) {
957
- const [storageMod, usageMod, shutdownMod, loggerMod, configMod, planMod] =
958
- await loadDistModules(
959
- [
960
- "storage.js",
961
- "codex-usage.js",
962
- "shutdown.js",
963
- "logger.js",
964
- "config.js",
965
- "plan-allotment.js",
966
- ],
967
- "limits",
968
- );
969
- // Fetching usage can refresh (and therefore persist) a token, so the same
970
- // process-owns-termination rule as `warm` applies.
971
- shutdownMod.setShutdownOwnsProcess(true);
972
- return { storageMod, usageMod, shutdownMod, loggerMod, configMod, planMod };
973
- }
974
-
975
- export async function runWarmCommand(parsed, options = {}) {
976
- const { env = process.env } = options;
977
- const storagePath = getStandaloneStoragePath(parsed, env);
978
-
979
- let runtime;
980
- try {
981
- runtime = await (options.loadWarmRuntime ?? loadWarmRuntime)(env);
982
- } catch (error) {
983
- const payload = { command: "warm", storagePath, error: formatErrorForLog(error) };
984
- printWarmResult(payload, parsed.json);
985
- return { exitCode: 1, action: "warm", storagePath };
986
- }
987
-
988
- const { storageMod, usageMod, warmReqMod, warmMod, recoveryMod } = runtime;
989
- // Point dist storage at the resolved accounts file so a refreshed token is
990
- // persisted to the SAME file the rest of the toolchain reads.
991
- storageMod.setStoragePathDirect(storagePath);
992
-
993
- let storage = null;
994
- try {
995
- storage = await storageMod.loadAccounts();
996
- } catch (error) {
997
- // Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
998
- // upgrade hint; surface them rather than crashing the CLI.
999
- const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
1000
- const payload = { command: "warm", storagePath, error: `${formatErrorForLog(error)}${hint}` };
1001
- printWarmResult(payload, parsed.json);
1002
- return { exitCode: 1, action: "warm", storagePath };
1003
- }
1004
- const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
1005
- if (accounts.length === 0) {
1006
- // `loadAccounts` swallows parse/IO errors and returns null. Probe the
1007
- // file so a corrupt storage fails like `status`/`doctor` do (exit 1)
1008
- // instead of reporting a healthy empty pool. ENOENT stays a silent
1009
- // empty pool: a missing file legitimately means no accounts yet.
1010
- const probe = await readStandaloneStorage(storagePath);
1011
- if (probe.error) {
1012
- const payload = { command: "warm", storagePath, error: probe.error };
1013
- printWarmResult(payload, parsed.json);
1014
- return { exitCode: 1, action: "warm", storagePath };
1015
- }
1016
- const payload = {
1017
- command: "warm",
1018
- storagePath,
1019
- totalAccounts: 0,
1020
- warmed: 0,
1021
- blocksCleared: 0,
1022
- failed: 0,
1023
- skipped: 0,
1024
- results: [],
1025
- message: "No accounts configured.",
1026
- nextAction: "Run opencode auth login.",
1027
- };
1028
- printWarmResult(payload, parsed.json);
1029
- return { exitCode: 0, action: "warm", storagePath };
1030
- }
1031
-
1032
- // Same adapter as lib/tools/codex-warm.ts createWarmOne: refresh → resolve
1033
- // account id → open the usage window; map an exhausted (quota-429) account
1034
- // to a failure so it is not reported as warmed.
1035
- const succeeded = [];
1036
- const warmOne = async (account) => {
1037
- const snapshot = { ...account, rateLimitResetTimes: { ...account.rateLimitResetTimes } };
1038
- const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
1039
- const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
1040
- if (!accountId) {
1041
- return { status: "failed", detail: "could not resolve account id (re-login may be required)" };
1042
- }
1043
- const result = await warmReqMod.warmAccountWindow({
1044
- accountId,
1045
- accessToken,
1046
- organizationId: account.organizationId,
1047
- });
1048
- if (result.status === "exhausted") {
1049
- return { status: "failed", detail: result.detail ?? "quota/usage limit reached" };
1050
- }
1051
- if (!result.rateLimited && result.model) succeeded.push({
1052
- account: { ...snapshot, refreshToken: account.refreshToken }, model: result.model, accessToken,
1053
- });
1054
- return { status: "warmed" };
1055
- };
1056
-
1057
- const summary = await warmMod.warmAccounts(accounts, warmOne);
1058
- let blocksCleared = 0;
1059
- let blockClearError;
1060
- for (const observation of succeeded) {
1061
- let changed = false;
1062
- try {
1063
- const completed = await recoveryMod.recoverWarmedAccount(observation, () => { changed = true; });
1064
- changed = completed || changed;
1065
- } catch {
1066
- blockClearError = "Failed to clear local blocks; warm results are unchanged.";
1067
- }
1068
- if (changed) blocksCleared++;
1069
- }
1070
- const payload = {
1071
- command: "warm",
1072
- blocksCleared,
1073
- blockClearError,
1074
- storagePath,
1075
- totalAccounts: summary.total,
1076
- warmed: summary.warmedCount,
1077
- failed: summary.failedCount,
1078
- skipped: summary.skippedCount,
1079
- results: summary.results.map((r) => ({
1080
- index: r.index,
1081
- email: maskValue(accounts[r.index]?.email, parsed.includeSensitive),
1082
- status: r.status,
1083
- detail: r.detail,
1084
- })),
1085
- };
1086
- printWarmResult(payload, parsed.json);
1087
- return { exitCode: summary.failedCount > 0 ? 1 : 0, action: "warm", storagePath };
1088
- }
1089
-
1090
- function printWarmResult(payload, json) {
1091
- if (json) {
1092
- console.log(JSON.stringify(payload, null, 2));
1093
- return;
1094
- }
1095
- console.log(`oc-codex-multi-auth warm`);
1096
- if (payload.message) console.log(payload.message);
1097
- console.log(`Storage: ${payload.storagePath}`);
1098
- if (payload.error) {
1099
- console.log(`Error: ${payload.error}`);
1100
- return;
1101
- }
1102
- console.log(`Accounts: ${payload.totalAccounts}`);
1103
- for (const r of payload.results ?? []) {
1104
- const label = r.email ? `[${r.index}] ${r.email}` : `[${r.index}]`;
1105
- const detail = r.detail ? ` — ${r.detail}` : "";
1106
- console.log(`- ${label}: ${r.status}${detail}`);
1107
- }
1108
- console.log(`Summary: ${payload.warmed} warmed, ${payload.failed} failed, ${payload.skipped} skipped`);
1109
- console.log(`Blocks cleared: ${payload.blocksCleared ?? 0}`);
1110
- if (payload.blockClearError) console.log(payload.blockClearError);
1111
- if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
1112
- }
1113
-
1114
- /**
1115
- * `limits` — show live 5-hour and weekly Codex usage per account (#209).
1116
- *
1117
- * Reuses the compiled `codex-usage` runtime so the CLI reports the same windows
1118
- * as the in-conversation `codex-limits` tool. The locally persisted
1119
- * `rateLimitResetTimes` is carried in the payload as well: it is the only
1120
- * rate-limit state available for an account whose live fetch fails, and
1121
- * `--json` consumers of the previous behavior still find the field.
1122
- */
1123
- export async function runLimitsCommand(parsed, options = {}) {
1124
- const { env = process.env } = options;
1125
- const storagePath = getStandaloneStoragePath(parsed, env);
1126
-
1127
- let runtime;
1128
- try {
1129
- runtime = await (options.loadLimitsRuntime ?? loadLimitsRuntime)(env);
1130
- } catch (error) {
1131
- const payload = { command: "limits", storagePath, error: formatErrorForLog(error) };
1132
- printLimitsResult(payload, parsed.json);
1133
- return { exitCode: 1, action: "limits", storagePath };
1134
- }
1135
-
1136
- const { storageMod, usageMod, loggerMod, configMod, planMod } = runtime;
1137
- const quotaDisplay = configMod.getQuotaDisplay(configMod.loadPluginConfig());
1138
- // The badge is decoration; the report is the point. A runtime that arrived
1139
- // without the plan module drops the `(5x)` rather than failing the account
1140
- // it was attached to - the per-account catch below would otherwise turn one
1141
- // missing module into an "Error:" line against every account in the pool.
1142
- const planMultiplierOf = (planType) =>
1143
- planMod?.formatPlanMultiplier?.(planType) ?? null;
1144
- // Point dist storage at the resolved accounts file so a refreshed token is
1145
- // persisted to the SAME file the rest of the toolchain reads.
1146
- storageMod.setStoragePathDirect(storagePath);
1147
-
1148
- let storage = null;
1149
- try {
1150
- storage = await storageMod.loadAccounts();
1151
- } catch (error) {
1152
- // Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
1153
- // upgrade hint; surface them rather than crashing the CLI.
1154
- const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
1155
- const payload = { command: "limits", storagePath, error: `${formatErrorForLog(error)}${hint}` };
1156
- printLimitsResult(payload, parsed.json);
1157
- return { exitCode: 1, action: "limits", storagePath };
1158
- }
1159
- const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
1160
- if (accounts.length === 0) {
1161
- // Same probe contract as `warm`: a corrupt file exits 1 like
1162
- // `status`/`doctor`; a missing file stays a silent empty pool.
1163
- const probe = await readStandaloneStorage(storagePath);
1164
- if (probe.error) {
1165
- const payload = { command: "limits", storagePath, error: probe.error };
1166
- printLimitsResult(payload, parsed.json);
1167
- return { exitCode: 1, action: "limits", storagePath };
1168
- }
1169
- const payload = {
1170
- command: "limits",
1171
- storagePath,
1172
- totalAccounts: 0,
1173
- // Same shape as a populated pool: `null` when nothing is readable.
1174
- pool: null,
1175
- poolSummary: null,
1176
- accounts: [],
1177
- message: "No accounts configured.",
1178
- nextAction: "Run opencode auth login.",
1179
- };
1180
- printLimitsResult(payload, parsed.json);
1181
- return { exitCode: 0, action: "limits", storagePath };
1182
- }
1183
-
1184
- // Same workspace dedupe the codex-limits tool applies, so two entries for one
1185
- // workspace are not billed and printed twice.
1186
- const indices = usageMod.deduplicateUsageAccountIndices(storage);
1187
- // `--tag` must gate which accounts are contacted at all, not just which are
1188
- // printed: an untagged account would otherwise be billed a usage fetch and
1189
- // could have its refreshed credentials persisted.
1190
- const normalizedTag =
1191
- typeof parsed.tag === "string" ? parsed.tag.trim().toLowerCase() : "";
1192
- const results = [];
1193
- // Only accounts that answered contribute to the pool total. An account that
1194
- // failed to report is left out entirely rather than counted as full or as
1195
- // empty, since either would state capacity nobody measured.
1196
- const poolMembers = [];
1197
- let failedCount = 0;
1198
-
1199
- for (const index of indices) {
1200
- const account = accounts[index];
1201
- if (!account) continue;
1202
- if (
1203
- normalizedTag &&
1204
- !(
1205
- Array.isArray(account.accountTags) &&
1206
- account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)
1207
- )
1208
- ) {
1209
- continue;
1210
- }
1211
- const entry = {
1212
- index,
1213
- label: account.accountLabel ?? `Account ${index + 1}`,
1214
- email: maskValue(account.email, parsed.includeSensitive),
1215
- rateLimitResetTimes: account.rateLimitResetTimes ?? {},
1216
- quotaExhaustedUntil: account.quotaExhaustedUntil,
1217
- };
1218
- try {
1219
- const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
1220
- const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
1221
- if (!accountId) {
1222
- throw new Error("could not resolve account id (re-login may be required)");
1223
- }
1224
- const usage = usageMod.parseCodexUsagePayload(
1225
- await usageMod.fetchCodexUsage({
1226
- accountId,
1227
- accessToken,
1228
- organizationId: account.organizationId,
1229
- }),
1230
- quotaDisplay,
1231
- );
1232
- const quotaExhaustedResetAtMs = usageMod.getUsageQuotaExhaustedResetAtMs([
1233
- usage.primary,
1234
- usage.secondary,
1235
- ]);
1236
- if (quotaExhaustedResetAtMs !== undefined) {
1237
- try {
1238
- await usageMod.persistUsageQuotaExhaustion(account, quotaExhaustedResetAtMs);
1239
- } catch (error) {
1240
- loggerMod.logWarn(
1241
- `[${PACKAGE_NAME}] Failed to persist exhausted usage quota: ${formatErrorForLog(error)}`,
1242
- );
1243
- }
1244
- }
1245
- if (usageMod.isUsageQuotaRecovered([usage.primary, usage.secondary])) {
1246
- try {
1247
- await usageMod.persistUsageQuotaRecovery(account);
1248
- } catch {
1249
- loggerMod.logWarn("Failed to persist recovered usage quota");
1250
- }
1251
- }
1252
- poolMembers.push({
1253
- planType: usage.planType,
1254
- primary: usage.primary,
1255
- secondary: usage.secondary,
1256
- });
1257
- entry.planType = usage.planType;
1258
- entry.planMultiplier = planMultiplierOf(usage.planType);
1259
- entry.credits = usage.credits;
1260
- // Raw counts stay in `resetCredits` and the rendered line lives in
1261
- // its own field: embedding the English summary inside the counts
1262
- // object would make `--json` consumers parse presentation text to
1263
- // reach a number that is already beside it.
1264
- entry.resetCredits = usage.resetCredits;
1265
- entry.resetCreditsSummary = usage.resetCredits
1266
- ? usageMod.formatResetCredits(usage.resetCredits)
1267
- : null;
1268
- entry.limits = usage.limits;
1269
- } catch (error) {
1270
- // `ensureCodexUsageAccessToken` can surface a raw OAuth refresh
1271
- // response, so the message is redacted through the logger's token
1272
- // patterns before it reaches stdout, JSON output, or CI logs.
1273
- // Truncation alone does not protect bearer/JWT/refresh-token material.
1274
- entry.error = loggerMod.maskString(formatErrorForLog(error)).slice(0, 160);
1275
- failedCount += 1;
1276
- }
1277
- results.push(entry);
1278
- }
1279
-
1280
- const pool = usageMod.summarizeUsagePool(poolMembers);
1281
- const payload = {
1282
- command: "limits",
1283
- storagePath,
1284
- totalAccounts: accounts.length,
1285
- shownAccounts: results.length,
1286
- // Both percentages are stated so a consumer never has to know which way
1287
- // `quotaDisplay` was pointing to read them.
1288
- pool: pool
1289
- ? {
1290
- leftPercent: pool.leftPercent,
1291
- usedPercent: 100 - pool.leftPercent,
1292
- allotment: pool.allotment,
1293
- countedAccounts: pool.countedAccounts,
1294
- }
1295
- : null,
1296
- poolSummary: pool
1297
- ? usageMod.formatUsagePoolSummary(pool, quotaDisplay)
1298
- : null,
1299
- accounts: results,
1300
- };
1301
- printLimitsResult(payload, parsed.json);
1302
- return { exitCode: failedCount > 0 ? 1 : 0, action: "limits", storagePath };
1303
- }
1304
-
1305
- function printLimitsResult(payload, json) {
1306
- if (json) {
1307
- console.log(JSON.stringify(payload, null, 2));
1308
- return;
1309
- }
1310
- console.log(`oc-codex-multi-auth limits`);
1311
- if (payload.message) console.log(payload.message);
1312
- console.log(`Storage: ${payload.storagePath}`);
1313
- if (payload.error) {
1314
- console.log(`Error: ${payload.error}`);
1315
- return;
1316
- }
1317
- console.log(`Accounts: ${payload.totalAccounts}`);
1318
- for (const account of payload.accounts ?? []) {
1319
- const label = account.email ? `${account.label} (${account.email})` : account.label;
1320
- console.log(`- [${account.index}] ${label}`);
1321
- if (account.error) {
1322
- console.log(` Error: ${account.error}`);
1323
- continue;
1324
- }
1325
- for (const limit of account.limits ?? []) {
1326
- console.log(` ${limit.name}: ${limit.summary}`);
1327
- }
1328
- if ((account.limits ?? []).length === 0) {
1329
- console.log(" No usage windows reported yet.");
1330
- }
1331
- if (account.planType) {
1332
- const allotment = account.planMultiplier ? ` (${account.planMultiplier})` : "";
1333
- console.log(` Plan: ${account.planType}${allotment}`);
1334
- }
1335
- if (account.credits) console.log(` Credits: ${account.credits}`);
1336
- if (account.resetCredits && account.resetCredits.available > 0) {
1337
- console.log(` Resets: ${account.resetCreditsSummary}`);
1338
- }
1339
- }
1340
- if (payload.poolSummary) console.log(`Pool: ${payload.poolSummary}`);
1341
- if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
1342
- }
1343
-
1344
- export async function runStandaloneCommand(command, argv = [], options = {}) {
1345
- const parsed = parseStandaloneArgs(argv);
1346
- if (command === "diag") {
1347
- command = "doctor";
1348
- parsed.deep = true;
1349
- }
1350
- if (parsed.help) {
1351
- printHelp();
1352
- return { exitCode: 0, action: "help" };
1353
- }
1354
- if (command === "warm") {
1355
- return runWarmCommand(parsed, options);
1356
- }
1357
- if (command === "limits") {
1358
- return runLimitsCommand(parsed, options);
1359
- }
1360
- const { env = process.env } = options;
1361
- const storagePath = getStandaloneStoragePath(parsed, env);
1362
- const repairRequested = command === "doctor" && parsed.fix;
1363
- let storage = null;
1364
- let error = null;
1365
- if (parsed.configPath || !repairRequested) {
1366
- ({ storage, error } = await readStandaloneStorage(storagePath));
1367
- }
1368
- const appliedFixes = [];
1369
- const fixErrors = [];
1370
- if (repairRequested && !error) {
1371
- const previousKeychain = process.env.CODEX_KEYCHAIN;
1372
- try {
1373
- const loadDoctorRuntime = options.loadDoctorRuntime ?? (() => loadDistModules(
1374
- ["storage.js", "tools/doctor-repair.js", "shutdown.js"], "doctor",
1375
- ));
1376
- const [storageMod, repairMod, shutdownMod] = await loadDoctorRuntime();
1377
- // A CLI file selection must not read or replace the global keychain pool.
1378
- if (parsed.configPath) process.env.CODEX_KEYCHAIN = "0";
1379
- storageMod.setStoragePathDirect(storagePath);
1380
- shutdownMod.setShutdownOwnsProcess(true);
1381
- try {
1382
- storage = await storageMod.loadAccounts();
1383
- } catch (loadError) {
1384
- // Typed storage errors (UNSUPPORTED_SCHEMA_VERSION, unknown V2)
1385
- // carry exact in-tree copy plus an upgrade/recovery hint, and the
1386
- // load never got far enough to attempt a repair. Surface them on
1387
- // the error channel (exit 1) instead of the generic catch below,
1388
- // which is reserved for unknown throws so upstream failure text
1389
- // never reaches output unredacted.
1390
- if (loadError && typeof loadError.code === "string") {
1391
- const hint = typeof loadError.hint === "string" ? ` ${loadError.hint}` : "";
1392
- error = `${formatErrorForLog(loadError)}${hint}`;
1393
- } else {
1394
- throw loadError;
1395
- }
1396
- }
1397
- if (!storage && !error) {
1398
- // `loadAccounts` swallows JSON parse/IO errors and returns null. In
1399
- // default-path mode the pre-read above was skipped (keychain routing
1400
- // may own the pool), so probe the JSON file here: a corrupt file must
1401
- // surface as a parse error (exit 1) instead of "No accounts
1402
- // configured" (exit 0). ENOENT stays silent - a missing file with an
1403
- // empty keychain legitimately means no accounts yet. Skipped when a
1404
- // typed load error already set `error`; the probe would only
1405
- // overwrite the precise schema message with its own paraphrase.
1406
- const probe = await readStandaloneStorage(storagePath);
1407
- if (probe.error) error = probe.error;
1408
- }
1409
- if (!error) {
1410
- const repair = await repairMod.repairDoctorAccounts(storage?.accounts ?? []);
1411
- appliedFixes.push(...repair.appliedFixes);
1412
- fixErrors.push(...repair.fixErrors);
1413
- storage = (await storageMod.loadAccounts()) ?? storage;
1414
- }
1415
- } catch {
1416
- fixErrors.push("Doctor repair could not complete. Check the selected storage file and installed runtime.");
1417
- } finally {
1418
- if (parsed.configPath) {
1419
- if (previousKeychain === undefined) delete process.env.CODEX_KEYCHAIN;
1420
- else process.env.CODEX_KEYCHAIN = previousKeychain;
1421
- }
1422
- }
1423
- }
1424
- const accounts = summarizeStandaloneAccounts(storage, parsed.includeSensitive, parsed.tag);
1425
- const totalAccounts = Array.isArray(storage?.accounts) ? storage.accounts.length : 0;
1426
- const payload = {
1427
- command,
1428
- storagePath,
1429
- totalAccounts,
1430
- shownAccounts: accounts.length,
1431
- activeIndex: typeof storage?.activeIndex === "number" ? storage.activeIndex : 0,
1432
- activeIndexByFamily: storage?.activeIndexByFamily ?? {},
1433
- accounts,
1434
- error,
1435
- };
1436
- if (command === "dashboard") {
1437
- payload.message = "Standalone dashboard server is not launched by this safe CLI; use status/list/limits/health or OpenCode codex-dashboard.";
1438
- payload.nextAction = "Run oc-codex-multi-auth status or open OpenCode and call codex-dashboard.";
1439
- } else if (command === "doctor") {
1440
- payload.message = error ? "Storage could not be parsed." : totalAccounts > 0 ? "Local diagnostics completed." : "No accounts configured.";
1441
- payload.deep = parsed.deep;
1442
- payload.fixApplied = parsed.fix ? appliedFixes.length > 0 : undefined;
1443
- if (parsed.fix) {
1444
- payload.appliedFixes = appliedFixes;
1445
- payload.fixErrors = fixErrors;
1446
- }
1447
- payload.nextAction = totalAccounts > 0 ? "Run oc-codex-multi-auth health --json for scriptable checks." : "Run opencode auth login.";
1448
- } else if (command === "health") {
1449
- payload.healthyCount = accounts.filter((account) => account.enabled && account.hasRefreshToken).length;
1450
- payload.unhealthyCount = accounts.filter((account) => !account.enabled || !account.hasRefreshToken).length;
1451
- } else if (command === "status") {
1452
- payload.message = totalAccounts > 0 ? "Account storage loaded." : "No accounts configured.";
1453
- }
1454
- printStandaloneResult(command, payload, parsed.json);
1455
- return { exitCode: error || fixErrors.length > 0 ? 1 : 0, action: command, storagePath };
1456
- }
1457
-
1458
- // Top-level keys inside `provider.openai` that the installer owns absolutely.
1459
- // These are always sourced from the template (overwritten or removed) so the
1460
- // plugin's required runtime shape is authoritative. Any OTHER key the user has
1461
- // placed under `provider.openai` is preserved as-is. `models` is handled
1462
- // separately because it's a map where user-added model ids must survive while
1463
- // template-shipped ids win on collision.
1464
- const MANAGED_OPENAI_KEYS = new Set(["baseURL", "apiKey", "options"]);
1465
-
1466
- function isPlainObject(value) {
1467
- return value !== null && typeof value === "object" && !Array.isArray(value);
1468
- }
1469
-
1470
- // Deep-merge `provider.openai` preserving unknown user keys while letting the
1471
- // installer overwrite the managed shape it ships. This replaces the earlier
1472
- // wholesale overwrite which clobbered custom user-added keys (see audit top-20
1473
- // #6).
1474
- function mergeOpenaiProvider(existingOpenai, templateOpenai, options = {}) {
1475
- const existingSafe = isPlainObject(existingOpenai) ? existingOpenai : {};
1476
- const templateSafe = isPlainObject(templateOpenai) ? templateOpenai : {};
1477
- const modelKeysToRemove = options.modelKeysToRemove instanceof Set
1478
- ? options.modelKeysToRemove
1479
- : new Set();
1480
-
1481
- const result = {};
1482
-
1483
- // 1. Start with the user's non-managed keys (unknown-to-installer settings).
1484
- for (const [key, value] of Object.entries(existingSafe)) {
1485
- if (MANAGED_OPENAI_KEYS.has(key)) continue;
1486
- if (key === "models") continue; // handled explicitly below
1487
- result[key] = value;
1488
- }
1489
-
1490
- // 2. Apply template-managed keys. Installer is source of truth for these.
1491
- for (const [key, value] of Object.entries(templateSafe)) {
1492
- if (key === "models") continue; // handled explicitly below
1493
- result[key] = value;
1494
- }
1495
-
1496
- // 3. Merge `models` by id: template wins on collision, user-added ids survive.
1497
- const existingModels = isPlainObject(existingSafe.models) ? existingSafe.models : {};
1498
- const templateModels = isPlainObject(templateSafe.models) ? templateSafe.models : {};
1499
- const prunedExistingModels = Object.fromEntries(
1500
- Object.entries(existingModels).filter(([key]) => !modelKeysToRemove.has(key)),
1501
- );
1502
- const mergedModels = { ...prunedExistingModels, ...templateModels };
1503
- if (Object.keys(mergedModels).length > 0) {
1504
- result.models = mergedModels;
1505
- }
1506
-
1507
- return result;
1508
- }
1509
-
1510
- // Naive line-by-line diff for displaying config changes in dry-run. Good enough
1511
- // for eyeballing; not intended to be parsed or round-tripped.
1512
- function formatConfigDiff(existingConfig, nextConfig) {
1513
- const oldText = existingConfig === undefined ? "" : formatJson(existingConfig);
1514
- const newText = formatJson(nextConfig);
1515
- if (oldText === newText) {
1516
- return "(no changes)";
1517
- }
1518
- const lines = [];
1519
- lines.push("--- existing");
1520
- lines.push("+++ proposed");
1521
- if (existingConfig === undefined) {
1522
- lines.push("- (no existing config)");
1523
- } else {
1524
- for (const line of oldText.split("\n")) {
1525
- lines.push(`- ${line}`);
1526
- }
1527
- }
1528
- for (const line of newText.split("\n")) {
1529
- lines.push(`+ ${line}`);
1530
- }
1531
- return lines.join("\n");
1532
- }
1533
-
1534
- function formatRedactedConfigDiff(existingConfig, nextConfig) {
1535
- const missing = Symbol("missing");
1536
- const changes = [];
1537
- const visit = (existing, next, path) => {
1538
- if (existing === missing) {
1539
- changes.push(`+ ${path}`);
1540
- return;
1541
- }
1542
- if (next === missing) {
1543
- changes.push(`- ${path}`);
1544
- return;
1545
- }
1546
- if (Object.is(existing, next)) return;
1547
-
1548
- if (Array.isArray(existing) && Array.isArray(next)) {
1549
- const length = Math.max(existing.length, next.length);
1550
- for (let index = 0; index < length; index += 1) {
1551
- visit(
1552
- index < existing.length ? existing[index] : missing,
1553
- index < next.length ? next[index] : missing,
1554
- `${path}[${index}]`,
1555
- );
1556
- }
1557
- return;
1558
- }
1559
-
1560
- if (isPlainObject(existing) && isPlainObject(next)) {
1561
- const keys = new Set([...Object.keys(existing), ...Object.keys(next)]);
1562
- for (const key of keys) {
1563
- visit(
1564
- Object.hasOwn(existing, key) ? existing[key] : missing,
1565
- Object.hasOwn(next, key) ? next[key] : missing,
1566
- `${path}.${key}`,
1567
- );
1568
- }
1569
- return;
1570
- }
1571
-
1572
- changes.push(`~ ${path}`);
1573
- };
1574
-
1575
- visit(existingConfig === undefined ? missing : existingConfig, nextConfig, "$");
1576
- return changes.length > 0 ? changes.join("\n") : "(no changes)";
1577
- }
1578
-
1579
- function mergeFullTemplate(modernTemplate, legacyTemplate) {
1580
- const modernModels = modernTemplate.provider?.openai?.models ?? {};
1581
- const legacyModels = legacyTemplate.provider?.openai?.models ?? {};
1582
- const overlappingKeys = Object.keys(modernModels).filter((key) => Object.hasOwn(legacyModels, key));
1583
-
1584
- if (overlappingKeys.length > 0) {
1585
- throw new Error(`Full config template collision for model keys: ${overlappingKeys.join(", ")}`);
1586
- }
1587
-
1588
- return {
1589
- ...modernTemplate,
1590
- provider: {
1591
- ...(modernTemplate.provider ?? {}),
1592
- openai: {
1593
- ...(modernTemplate.provider?.openai ?? {}),
1594
- models: {
1595
- ...modernModels,
1596
- ...legacyModels,
1597
- },
1598
- },
1599
- },
1600
- };
1601
- }
1602
-
1603
- function getTemplateModelKeys(template) {
1604
- return new Set(Object.keys(template.provider?.openai?.models ?? {}));
1605
- }
1606
-
1607
- async function readJson(filePath) {
1608
- const content = await readFile(filePath, "utf-8");
1609
- return JSON.parse(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content);
1610
- }
1611
-
1612
- async function renameWithWindowsRetry(sourcePath, destinationPath) {
1613
- let lastError = null;
1614
-
1615
- for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
1616
- try {
1617
- await rename(sourcePath, destinationPath);
1618
- return;
1619
- } catch (error) {
1620
- if (isWindowsLockError(error)) {
1621
- lastError = error;
1622
- await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
1623
- continue;
1624
- }
1625
- throw error;
1626
- }
1627
- }
1628
-
1629
- if (lastError) {
1630
- throw lastError;
1631
- }
1632
- }
1633
-
1634
- async function removeWithWindowsRetry(path, options) {
1635
- let lastError = null;
1636
-
1637
- for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
1638
- try {
1639
- await rm(path, options);
1640
- return;
1641
- } catch (error) {
1642
- if (isWindowsLockError(error)) {
1643
- lastError = error;
1644
- await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
1645
- continue;
1646
- }
1647
- throw error;
1648
- }
1649
- }
1650
-
1651
- if (lastError) {
1652
- throw lastError;
1653
- }
1654
- }
1655
-
1656
- async function writeFileAtomic(filePath, content) {
1657
- const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
1658
- const tempPath = `${filePath}.${uniqueSuffix}.tmp`;
1659
-
1660
- try {
1661
- await mkdir(dirname(filePath), { recursive: true });
1662
- await writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
1663
- await renameWithWindowsRetry(tempPath, filePath);
1664
- } catch (error) {
1665
- await rm(tempPath, { force: true }).catch(() => {});
1666
- throw error;
1667
- }
1668
- }
1669
-
1670
- async function loadTemplate(mode, paths) {
1671
- if (mode === "modern") {
1672
- return readJson(paths.modernTemplatePath);
1673
- }
1674
- if (mode === "legacy") {
1675
- return readJson(paths.legacyTemplatePath);
1676
- }
1677
-
1678
- const [modernTemplate, legacyTemplate] = await Promise.all([
1679
- readJson(paths.modernTemplatePath),
1680
- readJson(paths.legacyTemplatePath),
1681
- ]);
1682
-
1683
- return mergeFullTemplate(modernTemplate, legacyTemplate);
1684
- }
1685
-
1686
- async function copyFileWithWindowsRetry(sourcePath, destinationPath) {
1687
- let lastError = null;
1688
-
1689
- for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
1690
- try {
1691
- await copyFile(sourcePath, destinationPath);
1692
- return;
1693
- } catch (error) {
1694
- if (isWindowsLockError(error)) {
1695
- lastError = error;
1696
- await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
1697
- continue;
1698
- }
1699
- throw error;
1700
- }
1701
- }
1702
-
1703
- if (lastError) {
1704
- throw lastError;
1705
- }
1706
- }
1707
-
1708
- async function backupConfig(sourcePath, dryRun) {
1709
- const timestamp = new Date()
1710
- .toISOString()
1711
- .replace(/[:.]/g, "-")
1712
- .replace("T", "_")
1713
- .replace("Z", "");
1714
- const backupPath = `${sourcePath}.bak-${timestamp}`;
1715
- if (!dryRun) {
1716
- await copyFileWithWindowsRetry(sourcePath, backupPath);
1717
- }
1718
- return backupPath;
1719
- }
1720
-
1721
- async function removePluginFromCachePackage(paths, dryRun) {
1722
- if (!existsSync(paths.cachePackageJson)) {
1723
- return;
1724
- }
1725
- if (!isEvictableCachePath(paths.cachePackageJson, paths.cacheDir)) {
1726
- log(`Warning: refusing to update ${paths.cachePackageJson}: it does not resolve inside the OpenCode cache.`);
1727
- return;
1728
- }
1729
-
1730
- let cacheData;
1731
- try {
1732
- cacheData = await readJson(paths.cachePackageJson);
1733
- } catch (error) {
1734
- log(`Warning: Could not parse ${paths.cachePackageJson} (${formatErrorForLog(error)}). Skipping.`);
1735
- return;
1736
- }
1737
-
1738
- const sections = [
1739
- "dependencies",
1740
- "devDependencies",
1741
- "peerDependencies",
1742
- "optionalDependencies",
1743
- ];
1744
-
1745
- let changed = false;
1746
- for (const section of sections) {
1747
- const deps = cacheData?.[section];
1748
- if (deps && typeof deps === "object") {
1749
- for (const name of getManagedPackageNames()) {
1750
- if (name in deps) {
1751
- delete deps[name];
1752
- changed = true;
1753
- }
1754
- }
1755
- }
1756
- }
1757
-
1758
- if (!changed) {
1759
- return;
1760
- }
1761
-
1762
- if (dryRun) {
1763
- log(`[dry-run] Would update ${paths.cachePackageJson} to remove ${getManagedPackageNames().join(", ")}`);
1764
- return;
1765
- }
1766
-
1767
- await writeFileAtomic(paths.cachePackageJson, formatJson(cacheData));
1768
- }
1769
-
1770
- /**
1771
- * Mirror of `isEvictableCachePath` in lib/auto-update-checker.ts. A recursive
1772
- * delete must never act on a path that only spells like cache: the cache root
1773
- * itself must not resolve through a symlink (`~/.cache/opencode -> ~` would
1774
- * otherwise call the whole home directory "inside the cache"), and the
1775
- * resolved target must stay inside the resolved root.
1776
- */
1777
- function isEvictableCachePath(cachePath, cacheRoot) {
1778
- const absolutePath = resolve(cachePath);
1779
- const absoluteRoot = resolve(cacheRoot);
1780
- if (!isInsideDirectory(absolutePath, absoluteRoot, process.platform)) return false;
1781
- try {
1782
- const realRoot = realpathSync(absoluteRoot);
1783
- const rootIsSymlinked = process.platform === "win32"
1784
- ? realRoot.toLowerCase() !== absoluteRoot.toLowerCase()
1785
- : realRoot !== absoluteRoot;
1786
- if (rootIsSymlinked) return false;
1787
- return isInsideDirectory(realpathSync(absolutePath), realRoot, process.platform);
1788
- } catch {
1789
- return false;
1790
- }
1791
- }
1792
-
1793
- async function clearCache(paths, dryRun, skipCacheClear) {
1794
- if (skipCacheClear) {
1795
- log("Skipping cache clear (--no-cache-clear).");
1796
- await removePluginFromCachePackage(paths, dryRun);
1797
- return;
1798
- }
1799
-
1800
- const cacheTargets = [
1801
- ...paths.cacheNodeModulesPaths,
1802
- ...paths.cachePackagePaths,
1803
- paths.cacheBunLock,
1804
- ];
1805
-
1806
- if (dryRun) {
1807
- for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) {
1808
- log(`[dry-run] Would remove ${cacheNodeModulesPath}`);
1809
- }
1810
- for (const cachePackagePath of paths.cachePackagePaths) {
1811
- log(`[dry-run] Would remove ${cachePackagePath}`);
1812
- }
1813
- log(`[dry-run] Would remove ${paths.cacheBunLock}`);
1814
- } else {
1815
- for (const cacheTarget of cacheTargets) {
1816
- if (!existsSync(cacheTarget)) continue;
1817
- if (!isEvictableCachePath(cacheTarget, paths.cacheDir)) {
1818
- log(`Warning: refusing to remove ${cacheTarget}: it does not resolve inside the OpenCode cache.`);
1819
- continue;
1820
- }
1821
- await removeWithWindowsRetry(cacheTarget, {
1822
- recursive: cacheTarget !== paths.cacheBunLock,
1823
- force: true,
1824
- });
1825
- }
1826
- }
1827
-
1828
- await removePluginFromCachePackage(paths, dryRun);
1829
- }
1830
-
1831
- /** Route V2 installs without rewriting V1 entries or parallel JSONC config. */
1832
- export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1833
- const split = splitCommandArgv(argv);
1834
- if (split.kind === "standalone") {
1835
- return runStandaloneCommand(split.command, split.argv, options);
1836
- }
1837
- if (split.kind === "unknown") {
1838
- printHelp();
1839
- throw new Error(`Unknown command: ${split.command}`);
1840
- }
1841
- const { env = process.env } = options;
1842
- const paths = buildPaths(resolveHomeDirectory(env));
1843
- if (split.kind === "update") {
1844
- const parsedUpdate = parseUpdateArgs(split.argv);
1845
- if (parsedUpdate.wantsHelp) {
1846
- printHelp();
1847
- return { exitCode: 0, action: "help" };
1848
- }
1849
- await clearCache(paths, parsedUpdate.dryRun, false);
1850
- log(`\n${parsedUpdate.dryRun ? "Dry run complete." : "Cache cleared."} Restart OpenCode to install the latest plugin.`);
1851
- return {
1852
- exitCode: 0,
1853
- action: "update",
1854
- dryRun: Boolean(parsedUpdate.dryRun),
1855
- };
1856
- }
1857
- const parsed = parseCliArgs(split.argv);
1858
- if (parsed.wantsHelp) {
1859
- printHelp();
1860
- return { exitCode: 0, action: "help" };
1861
- }
1862
-
1863
- const { configMode, dryRun, skipCacheClear, pluginOnly } = parsed;
1864
- if (parsed.v2) {
1865
- if (existsSync(paths.jsoncConfigPath)) {
1866
- throw new Error(`OpenCode config exists at ${paths.jsoncConfigPath}; edit its plugins list directly instead of writing a second config file.`);
1867
- }
1868
- const existing = existsSync(paths.configPath) ? await readJson(paths.configPath) : {};
1869
- if (!isPlainObject(existing)) throw new Error("OpenCode config root must be an object");
1870
- if (Array.isArray(existing.plugin) && existing.plugin.length > 0) {
1871
- throw new Error("OpenCode V1 plugin entries are present. Use a separate V2 config or migrate them manually; --v2 will not remove your V1 registration.");
1872
- }
1873
- const next = { ...existing, plugins: normalizePluginList(existing.plugins, log, {
1874
- baseDirectory: paths.configDir, cacheDirectory: paths.cacheDir,
1875
- }) };
1876
- next.$schema ??= "https://opencode.ai/config.json";
1877
- if (dryRun) log(`[dry-run] Would register V2 plugin in ${paths.configPath}`);
1878
- else if (formatJson(existing) !== formatJson(next)) {
1879
- if (existsSync(paths.configPath)) await backupConfig(paths.configPath, false);
1880
- await writeFileAtomic(paths.configPath, formatJson(next));
1881
- }
1882
- log(dryRun ? "V2 registration dry run complete." : "V2 plugin registered. Restart the OpenCode service to load it.");
1883
- return { exitCode: 0, action: "install", dryRun: Boolean(dryRun), configMode: "v2" };
1884
- }
1885
- const effectiveConfigMode = pluginOnly ? "plugin-only" : configMode;
1886
- const requiredTemplatePaths = pluginOnly
1887
- ? []
1888
- : configMode === "modern"
1889
- ? [paths.modernTemplatePath]
1890
- : configMode === "legacy"
1891
- ? [paths.legacyTemplatePath]
1892
- : [paths.modernTemplatePath, paths.legacyTemplatePath];
1893
-
1894
- for (const templatePath of requiredTemplatePaths) {
1895
- if (!existsSync(templatePath)) {
1896
- throw new Error(`Config template not found at ${templatePath}`);
1897
- }
1898
- }
1899
-
1900
- const template = pluginOnly
1901
- ? { $schema: "https://opencode.ai/config.json", plugin: [PACKAGE_NAME] }
1902
- : await loadTemplate(configMode, paths);
1903
- template.plugin = [PACKAGE_NAME];
1904
- const modelKeysToRemove = new Set(STALE_MANAGED_MODEL_KEYS);
1905
- if (!pluginOnly && configMode === "modern") {
1906
- for (const key of getTemplateModelKeys(await readJson(paths.legacyTemplatePath))) {
1907
- modelKeysToRemove.add(key);
1908
- }
1909
- }
1910
- if (!pluginOnly && configMode === "legacy") {
1911
- for (const key of getTemplateModelKeys(await readJson(paths.modernTemplatePath))) {
1912
- modelKeysToRemove.add(key);
1913
- }
1914
- }
1915
-
1916
- let existingConfig;
1917
- if (existsSync(paths.configPath)) {
1918
- try {
1919
- const existing = await readJson(paths.configPath);
1920
- if (!isPlainObject(existing)) {
1921
- throw new Error("config root must be a JSON object");
1922
- }
1923
- existingConfig = existing;
1924
- } catch (error) {
1925
- if (pluginOnly) {
1926
- throw new Error(
1927
- `Could not parse existing config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
1928
- );
1929
- }
1930
- log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`);
1931
- existingConfig = undefined;
1932
- }
1933
- } else {
1934
- log("No existing config found. Creating new global config.");
1935
- }
1936
-
1937
- let existingTuiConfig;
1938
- if (existsSync(paths.tuiConfigPath)) {
1939
- try {
1940
- const existing = await readJson(paths.tuiConfigPath);
1941
- if (!isPlainObject(existing)) {
1942
- throw new Error("TUI config root must be a JSON object");
1943
- }
1944
- existingTuiConfig = existing;
1945
- } catch (error) {
1946
- if (pluginOnly) {
1947
- throw new Error(
1948
- `Could not parse existing TUI config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
1949
- );
1950
- }
1951
- log(`Warning: Could not parse existing TUI config (${formatErrorForLog(error)}). Replacing with minimal TUI config.`);
1952
- existingTuiConfig = undefined;
1953
- }
1954
- } else {
1955
- log("No existing TUI config found. Creating new global TUI config.");
1956
- }
1957
-
1958
- // A checkout of this package registered in either file already loads the
1959
- // plugin, so the published name must not be written beside it anywhere -
1960
- // otherwise the checkout in opencode.json and the published package in
1961
- // tui.json both load.
1962
- const pluginListOptions = {
1963
- baseDirectory: paths.configDir,
1964
- cacheDirectory: paths.cacheDir,
1965
- };
1966
- const checkoutRegistered = [existingConfig?.plugin, existingTuiConfig?.plugin]
1967
- .flatMap((list) => (Array.isArray(list) ? list : []))
1968
- .some((entry) => {
1969
- const classification = classifyPluginEntry(entry, pluginListOptions);
1970
- return (
1971
- classification.kind === LOCAL_CHECKOUT_ENTRY &&
1972
- classification.name === PACKAGE_NAME
1973
- );
1974
- });
1975
- const normalizeOptions = { ...pluginListOptions, checkoutRegistered };
1976
-
1977
- let nextConfig;
1978
- if (existingConfig !== undefined) {
1979
- const merged = { ...existingConfig };
1980
- merged.plugin = normalizePluginList(existingConfig.plugin, log, normalizeOptions);
1981
- if (!pluginOnly) {
1982
- const provider = (existingConfig.provider && typeof existingConfig.provider === "object")
1983
- ? { ...existingConfig.provider }
1984
- : {};
1985
- provider.openai = mergeOpenaiProvider(existingConfig.provider?.openai, template.provider?.openai, {
1986
- modelKeysToRemove,
1987
- });
1988
- merged.provider = provider;
1989
- }
1990
- nextConfig = merged;
1991
- } else {
1992
- nextConfig = pluginOnly
1993
- ? { $schema: template.$schema, plugin: [PACKAGE_NAME] }
1994
- : template;
1995
- nextConfig.plugin = normalizePluginList(nextConfig.plugin, log, normalizeOptions);
1996
- }
1997
-
1998
- const nextTuiConfig = mergeTuiConfig(existingTuiConfig, log, normalizeOptions);
1999
-
2000
- const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath, {
2001
- baseDirectory: paths.configDir,
2002
- cacheDirectory: paths.cacheDir,
2003
- });
2004
- if (unregisteredCheckout) {
2005
- log(
2006
- `Note: this plugin last loaded from a checkout at ${unregisteredCheckout.root} on ${unregisteredCheckout.lastSeen}, ` +
2007
- `which ${paths.configPath} does not register. Point the plugin entry back at that path if OpenCode should keep loading your own build.`,
2008
- );
2009
- }
2010
-
2011
- const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig);
2012
- const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig);
2013
- let wrote = false;
2014
- if (dryRun) {
2015
- log(`[dry-run] ${configChanged ? "Would write" : "Would leave unchanged"} ${paths.configPath} using ${effectiveConfigMode} config`);
2016
- log(`[dry-run] Diff for ${paths.configPath}:`);
2017
- log(formatRedactedConfigDiff(existingConfig, nextConfig));
2018
- log(`[dry-run] ${tuiConfigChanged ? "Would write" : "Would leave unchanged"} ${paths.tuiConfigPath} with the TUI status plugin`);
2019
- log(`[dry-run] Diff for ${paths.tuiConfigPath}:`);
2020
- log(formatRedactedConfigDiff(existingTuiConfig, nextTuiConfig));
2021
- } else {
2022
- if (configChanged) {
2023
- if (existsSync(paths.configPath)) {
2024
- const backupPath = await backupConfig(paths.configPath, false);
2025
- log(`Backup created: ${backupPath}`);
2026
- }
2027
- await writeFileAtomic(paths.configPath, formatJson(nextConfig));
2028
- wrote = true;
2029
- log(`Wrote ${paths.configPath} (${effectiveConfigMode} config)`);
2030
- } else {
2031
- log(`Left ${paths.configPath} unchanged`);
2032
- }
2033
- if (tuiConfigChanged) {
2034
- if (existsSync(paths.tuiConfigPath)) {
2035
- const backupPath = await backupConfig(paths.tuiConfigPath, false);
2036
- log(`Backup created: ${backupPath}`);
2037
- }
2038
- await writeFileAtomic(paths.tuiConfigPath, formatJson(nextTuiConfig));
2039
- wrote = true;
2040
- log(`Wrote ${paths.tuiConfigPath} (TUI status plugin)`);
2041
- } else {
2042
- log(`Left ${paths.tuiConfigPath} unchanged`);
2043
- }
2044
- }
2045
-
2046
- await clearCache(paths, dryRun, skipCacheClear);
2047
-
2048
- log("\nDone. Restart OpenCode to (re)install the plugin.");
2049
- log("Example: opencode");
2050
- if (!pluginOnly && configMode === "modern") {
2051
- log("Note: Modern config intentionally shows 10 base OAuth model entries; use the variant picker for reasoning presets.");
2052
- }
2053
- if (!pluginOnly && configMode === "legacy") {
2054
- log("Note: Legacy config writes 53 explicit preset entries and is also safe for older OpenCode versions.");
2055
- }
2056
- if (!pluginOnly && configMode === "full") {
2057
- log("Note: Full config installs both compact base models and explicit preset entries for direct selector IDs.");
2058
- }
2059
-
2060
- return {
2061
- exitCode: 0,
2062
- action: "install",
2063
- configMode: effectiveConfigMode,
2064
- pluginOnly,
2065
- configPath: paths.configPath,
2066
- tuiConfigPath: paths.tuiConfigPath,
2067
- dryRun: Boolean(dryRun),
2068
- wrote,
2069
- };
2070
- }
2071
-
2072
- export const __test = {
2073
- ORIGIN_HISTORY_FILE_NAME,
2074
- buildPaths,
2075
- backupConfig,
2076
- classifyPluginEntry,
2077
- copyFileWithWindowsRetry,
2078
- findUnregisteredLocalCheckout,
2079
- formatConfigDiff,
2080
- formatRedactedConfigDiff,
2081
- mergeFullTemplate,
2082
- mergeOpenaiProvider,
2083
- mergeTuiConfig,
2084
- normalizePluginList,
2085
- parseCliArgs,
2086
- removeWithWindowsRetry,
2087
- runStandaloneCommand,
2088
- splitCommandArgv,
2089
- writeFileAtomic,
2090
- renameWithWindowsRetry,
2091
- resolveHomeDirectory,
2092
- };
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
+ sort: undefined,
54
+ direction: undefined,
55
+ refresh: false,
56
+ help: false,
57
+ };
58
+ for (let index = 0; index < argv.length; index += 1) {
59
+ const arg = argv[index];
60
+ if (arg === "--json") options.json = true;
61
+ else if (arg === "--refresh") options.refresh = true;
62
+ else if (arg === "--sort") options.sort = parseLimitsSortField(argv[++index]);
63
+ else if (arg.startsWith("--sort=")) options.sort = parseLimitsSortField(arg.slice("--sort=".length));
64
+ else if (arg === "--asc") options.direction = "asc";
65
+ else if (arg === "--desc") options.direction = "desc";
66
+ else if (arg === "--include-sensitive") options.includeSensitive = true;
67
+ else if (arg === "--deep") options.deep = true;
68
+ else if (arg === "--fix") options.fix = true;
69
+ else if (arg === "--tag") options.tag = argv[++index];
70
+ else if (arg.startsWith("--tag=")) options.tag = arg.slice("--tag=".length);
71
+ else if (arg === "--config-path") options.configPath = argv[++index];
72
+ else if (arg.startsWith("--config-path=")) options.configPath = arg.slice("--config-path=".length);
73
+ else if (arg === "--help" || arg === "-h") options.help = true;
74
+ else throw new Error(`Unknown option for standalone command: ${arg}`);
75
+ }
76
+ return options;
77
+ }
78
+
79
+ const LIMITS_SORT_ALIASES = new Map([
80
+ ["account", "account"],
81
+ ["number", "account"],
82
+ ["usage", "usage"],
83
+ ["used", "usage"],
84
+ ["reset", "reset"],
85
+ ["renewal", "reset"],
86
+ ]);
87
+
88
+ function parseLimitsSortField(value) {
89
+ const field = LIMITS_SORT_ALIASES.get(String(value ?? "").trim().toLowerCase());
90
+ if (!field) {
91
+ throw new Error(`Unknown --sort value: ${value ?? "(missing)"} (expected account, usage, or reset)`);
92
+ }
93
+ return field;
94
+ }
95
+
96
+ function getManagedPackageNames() {
97
+ return [PACKAGE_NAME, ...LEGACY_PACKAGE_NAMES];
98
+ }
99
+
100
+ export function normalizePathForCompare(path, resolveRealPath = realpathSync) {
101
+ const resolved = resolve(path);
102
+ try {
103
+ const realPath = resolveRealPath(resolved);
104
+ return process.platform === "win32" ? realPath.toLowerCase() : realPath;
105
+ } catch {
106
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
107
+ }
108
+ }
109
+
110
+ export function isDirectRunPath(argvPath, modulePath, resolveRealPath = realpathSync) {
111
+ if (!argvPath || !modulePath) return false;
112
+ return (
113
+ normalizePathForCompare(argvPath, resolveRealPath) ===
114
+ normalizePathForCompare(modulePath, resolveRealPath)
115
+ );
116
+ }
117
+
118
+ function printHelp() {
119
+ console.log(`Usage: ${PACKAGE_NAME} [command] [options]\n\n` +
120
+ "Commands:\n" +
121
+ " install Register plugin entries (default with no command)\n" +
122
+ " update Refresh the cached package without changing OpenCode config\n" +
123
+ " doctor Run local account/config diagnostics\n" +
124
+ " status Show account/config status\n" +
125
+ " list List configured accounts\n" +
126
+ " limits Show 5-hour and weekly usage for each account\n" +
127
+ " dashboard Print dashboard guidance\n" +
128
+ " health Check local token/account health\n" +
129
+ " diag Alias for doctor --deep\n" +
130
+ " warm Open every enabled account's usage window now (one request each)\n\n" +
131
+ "Limits options:\n" +
132
+ " --sort account|usage|reset Order accounts by number, by usage, or by next reset\n" +
133
+ " --asc, --desc Direction (default --asc: lowest number, least used, earliest reset)\n" +
134
+ " --refresh Read every account live instead of the plugin's last readings\n\n" +
135
+ `Installer usage: ${PACKAGE_NAME} install [--plugin-only|--modern|--full|--legacy] [--dry-run] [--no-cache-clear]\n` +
136
+ `Updater usage: ${PACKAGE_NAME} update [--dry-run]\n\n` +
137
+ "Default behavior:\n" +
138
+ " - Registers plugin entries without changing provider.openai\n" +
139
+ " - Enables the prompt status bar TUI plugin at ~/.config/opencode/tui.json\n" +
140
+ " - Installs model catalogs only with --modern, --full, or --legacy\n" +
141
+ " - Ensures plugin is unpinned (latest)\n" +
142
+ " - Clears OpenCode plugin cache\n\n" +
143
+ "Options:\n" +
144
+ " --plugin-only Register plugins without changing provider.openai\n" +
145
+ " --v2 Register for OpenCode V2 (includes automatic quota UI loading)\n" +
146
+ " --modern Force compact modern config (10 base OAuth models + --variant presets)\n" +
147
+ " --full Install compact base models plus 53 explicit selector entries\n" +
148
+ " --legacy Force explicit legacy config (53 preset model entries)\n" +
149
+ " --dry-run Show actions without writing\n" +
150
+ " --no-cache-clear Skip clearing OpenCode cache\n"
151
+ );
152
+ }
153
+
154
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
155
+ const repoRoot = resolve(scriptDir, "..");
156
+ const modernTemplatePath = join(repoRoot, "config", "opencode-modern.json");
157
+ const legacyTemplatePath = join(repoRoot, "config", "opencode-legacy.json");
158
+
159
+ function log(message) {
160
+ console.log(message);
161
+ }
162
+
163
+ function delay(ms) {
164
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
165
+ }
166
+
167
+ function isWindowsLockError(error) {
168
+ const code = error?.code;
169
+ return code === "EPERM" || code === "EBUSY";
170
+ }
171
+
172
+ function formatErrorForLog(error) {
173
+ if (error instanceof Error) {
174
+ return error.message;
175
+ }
176
+ return String(error);
177
+ }
178
+
179
+ function resolveHomeDirectory(env = process.env) {
180
+ return env.HOME || env.USERPROFILE || homedir();
181
+ }
182
+
183
+ /** Resolve both JSON and JSONC config locations before changing V2 registration. */
184
+ function buildPaths(homeDir) {
185
+ const configDir = join(homeDir, ".config", "opencode");
186
+ const cacheDir = join(homeDir, ".cache", "opencode");
187
+ return {
188
+ configDir,
189
+ configPath: join(configDir, "opencode.json"),
190
+ jsoncConfigPath: join(configDir, "opencode.jsonc"),
191
+ tuiConfigPath: join(configDir, "tui.json"),
192
+ cacheDir,
193
+ cacheNodeModulesPaths: getManagedPackageNames().map((name) => join(cacheDir, "node_modules", name)),
194
+ cachePackagePaths: getManagedPackageNames().flatMap((name) => [
195
+ join(cacheDir, "packages", name),
196
+ join(cacheDir, "packages", `${name}@latest`),
197
+ ]),
198
+ cacheBunLock: join(cacheDir, "bun.lock"),
199
+ cachePackageJson: join(cacheDir, "package.json"),
200
+ originHistoryPath: join(homeDir, ".opencode", ORIGIN_HISTORY_FILE_NAME),
201
+ modernTemplatePath,
202
+ legacyTemplatePath,
203
+ };
204
+ }
205
+
206
+ /** Keep V2 plugin-only installation separate from V1 model catalog modes. */
207
+ function parseCliArgs(argv = process.argv.slice(2)) {
208
+ const args = new Set(argv);
209
+ if (args.has("--help") || args.has("-h")) {
210
+ return {
211
+ wantsHelp: true,
212
+ };
213
+ }
214
+
215
+ const requestedModern = args.has("--modern");
216
+ const requestedFull = args.has("--full");
217
+ const requestedLegacy = args.has("--legacy");
218
+ const explicitPluginOnly = args.has("--plugin-only");
219
+
220
+ const requestedModes = [requestedModern, requestedFull, requestedLegacy]
221
+ .filter(Boolean).length;
222
+ if (requestedModes > 1) {
223
+ throw new Error("Choose only one of --modern, --full, or --legacy.");
224
+ }
225
+ if (explicitPluginOnly && requestedModes > 0) {
226
+ throw new Error("--plugin-only cannot be combined with --modern, --full, or --legacy.");
227
+ }
228
+ const pluginOnly = explicitPluginOnly || requestedModes === 0;
229
+ if (args.has("--v2") && !pluginOnly) {
230
+ throw new Error("--v2 registers the plugin only; omit --modern, --full, and --legacy.");
231
+ }
232
+
233
+ return {
234
+ wantsHelp: false,
235
+ dryRun: args.has("--dry-run"),
236
+ skipCacheClear: args.has("--no-cache-clear"),
237
+ pluginOnly,
238
+ v2: args.has("--v2"),
239
+ configMode: requestedFull ? "full" : requestedLegacy ? "legacy" : "modern",
240
+ };
241
+ }
242
+
243
+ function parseUpdateArgs(argv) {
244
+ const args = new Set(argv);
245
+ const supported = new Set(["--dry-run", "--help", "-h"]);
246
+ const unknown = argv.find((arg) => !supported.has(arg));
247
+ if (unknown) {
248
+ throw new Error(`Unknown option for update command: ${unknown}`);
249
+ }
250
+ return {
251
+ wantsHelp: args.has("--help") || args.has("-h"),
252
+ dryRun: args.has("--dry-run"),
253
+ };
254
+ }
255
+
256
+ const MANAGED_PACKAGE_ENTRY = "managed-package";
257
+ const LOCAL_CHECKOUT_ENTRY = "local-checkout";
258
+ const UNRELATED_ENTRY = "unrelated";
259
+ const DECLARED_NAME_LOOKUP_DEPTH = 3;
260
+
261
+ /** Extract a package/path from V1 tuples or native V2 plugin objects. */
262
+ function pluginEntrySpecifier(entry) {
263
+ if (typeof entry === "string") return entry;
264
+ if (isPlainObject(entry) && typeof entry.package === "string") return entry.package;
265
+ // `[specifier, options]` configures a plugin without changing where it loads from.
266
+ if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
267
+ return null;
268
+ }
269
+
270
+ /**
271
+ * Spellings that can only mean a location on disk: absolute paths, `~`,
272
+ * `./`/`../`, Windows drive letters, and UNC shares. Anything else that merely
273
+ * contains a separator (`@scope/name`, git URLs, `npm:` aliases) is ambiguous
274
+ * and counts as a path only when it resolves on this machine.
275
+ */
276
+ function isExplicitPathSpecifier(specifier) {
277
+ return (
278
+ /^[a-zA-Z]:[\\/]/.test(specifier) ||
279
+ /^[\\/]/.test(specifier) ||
280
+ /^~[\\/]/.test(specifier) ||
281
+ /^\.\.?[\\/]/.test(specifier)
282
+ );
283
+ }
284
+
285
+ /**
286
+ * The path an entry names, exactly as the config spells it - and only when the
287
+ * specifier actually is a path. A `/` alone does not make one: registry and
288
+ * URL spellings can end in `oc-codex-multi-auth` without naming this package's
289
+ * checkout, so an ambiguous specifier counts only when it resolves on disk.
290
+ */
291
+ function pluginEntryPath(specifier, baseDirectory) {
292
+ const trimmed = specifier.trim();
293
+ if (!trimmed) return null;
294
+ if (/^file:\/\//i.test(trimmed)) {
295
+ try {
296
+ return fileURLToPath(trimmed);
297
+ } catch {
298
+ return null;
299
+ }
300
+ }
301
+ if (isExplicitPathSpecifier(trimmed)) return trimmed;
302
+ if (!trimmed.includes("/") && !trimmed.includes("\\")) return null;
303
+ const inspectionPath = resolveInspectionPath(trimmed, baseDirectory);
304
+ return inspectionPath && existsSync(inspectionPath) ? trimmed : null;
305
+ }
306
+
307
+ function pluginPathSegments(entryPath) {
308
+ return entryPath.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean);
309
+ }
310
+
311
+ /**
312
+ * Compared as written, without resolving symlinks: this asks whether an entry
313
+ * names something `clearCache` removes, and `clearCache` removes the paths
314
+ * exactly as it spells them - `rm` unlinks a symlink rather than descending
315
+ * into it. Cache eviction resolves symlinks because it decides the opposite
316
+ * question, whether a recursive delete is safe.
317
+ */
318
+ function isInsideDirectory(candidate, directory, platform) {
319
+ const fold = (value) => (platform === "win32" ? value.toLowerCase() : value);
320
+ const relativePath = relative(fold(resolve(directory)), fold(resolve(candidate)));
321
+ return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
322
+ }
323
+
324
+ function isPackageManagerPath(entryPath, options = {}) {
325
+ const { platform = process.platform, cacheDirectory, inspectionPath } = options;
326
+ // Windows reaches one directory under many spellings, so `NODE_MODULES`
327
+ // there is the same package-manager output as `node_modules`. Elsewhere the
328
+ // two are different directories and must stay so.
329
+ const segments = pluginPathSegments(entryPath).map((segment) =>
330
+ platform === "win32" ? segment.toLowerCase() : segment,
331
+ );
332
+ if (
333
+ segments.some(
334
+ (segment, index) =>
335
+ segment === "node_modules" ||
336
+ // OpenCode's plugin cache spells the version into the directory name.
337
+ // A `packages/` directory without one is an ordinary monorepo.
338
+ (segments[index - 1] === "packages" && segment.includes("@")),
339
+ )
340
+ ) {
341
+ return true;
342
+ }
343
+ // The cache is where this installer puts its own copies, and `clearCache`
344
+ // empties it on the same run. Reading spelling alone leaves the cache's
345
+ // unversioned `packages/<name>` looking like somebody's monorepo, so the
346
+ // entry is kept while the directory under it is deleted - a config left
347
+ // pointing at nothing. Whose directory it is settles that; the spelling
348
+ // cannot.
349
+ return Boolean(
350
+ cacheDirectory &&
351
+ inspectionPath &&
352
+ isInsideDirectory(inspectionPath, cacheDirectory, platform),
353
+ );
354
+ }
355
+
356
+ /**
357
+ * Where an entry points, for reading metadata about it only. OpenCode resolves
358
+ * a relative entry against the config file that declares it, so that directory
359
+ * is what makes such a path mean anything; the installer's working directory
360
+ * would name somewhere else entirely. Null when a relative entry arrives with
361
+ * no declaring directory to resolve it against.
362
+ */
363
+ function resolveInspectionPath(entryPath, baseDirectory) {
364
+ if (isAbsolute(entryPath)) return entryPath;
365
+ return baseDirectory ? resolve(baseDirectory, entryPath) : null;
366
+ }
367
+
368
+ /**
369
+ * Last-resort identification for a path that is not present on this machine.
370
+ * Spelling alone never authorizes deleting an entry; it only names the package a
371
+ * missing path was probably meant to point at.
372
+ */
373
+ function managedNameFromPathSpelling(entryPath) {
374
+ const segments = pluginPathSegments(entryPath);
375
+ const last = segments.at(-1) === "dist" ? segments.at(-2) : segments.at(-1);
376
+ if (!last) return null;
377
+ let candidate = last.toLowerCase();
378
+ try {
379
+ candidate = decodeURIComponent(candidate);
380
+ } catch {
381
+ // Keep the raw segment when it carries a malformed escape.
382
+ }
383
+ const versionSuffix = candidate.indexOf("@");
384
+ if (versionSuffix > 0) candidate = candidate.slice(0, versionSuffix);
385
+ return getManagedPackageNames().find((name) => name.toLowerCase() === candidate) ?? null;
386
+ }
387
+
388
+ function readDeclaredPackageName(directoryPath) {
389
+ try {
390
+ const parsed = JSON.parse(readFileSync(join(directoryPath, "package.json"), "utf8"));
391
+ const name = parsed?.name;
392
+ return typeof name === "string" && name.trim() ? name.trim() : null;
393
+ } catch {
394
+ return null;
395
+ }
396
+ }
397
+
398
+ /** An entry may point at a build output inside the package, so walk upwards. */
399
+ function resolveDeclaredPackageName(entryPath) {
400
+ if (!isAbsolute(entryPath)) return null;
401
+ let current = resolve(entryPath);
402
+ for (let depth = 0; depth <= DECLARED_NAME_LOOKUP_DEPTH; depth += 1) {
403
+ const name = readDeclaredPackageName(current);
404
+ if (name) return name;
405
+ const parent = dirname(current);
406
+ if (parent === current) return null;
407
+ current = parent;
408
+ }
409
+ return null;
410
+ }
411
+
412
+ /**
413
+ * Decides what a plugin entry is, by identity rather than by spelling.
414
+ *
415
+ * The distinction that matters is not which package an entry names but who
416
+ * chose the location. A bare specifier or a path inside `node_modules` is a
417
+ * reference the installer itself produced and may retire. Any other path is
418
+ * somewhere a human deliberately pointed OpenCode - a checkout of this package
419
+ * being developed on, most often - and is never the installer's to remove.
420
+ */
421
+ function classifyPluginEntry(entry, options = {}) {
422
+ const {
423
+ resolveDeclaredName = resolveDeclaredPackageName,
424
+ baseDirectory,
425
+ cacheDirectory,
426
+ platform = process.platform,
427
+ } = options;
428
+ const specifier = pluginEntrySpecifier(entry);
429
+ if (specifier === null) return { kind: UNRELATED_ENTRY, name: null };
430
+
431
+ const entryPath = pluginEntryPath(specifier, baseDirectory);
432
+ if (entryPath === null) {
433
+ const bare = specifier.trim().toLowerCase();
434
+ const name = getManagedPackageNames().find(
435
+ (managed) =>
436
+ bare === managed.toLowerCase() || bare.startsWith(`${managed.toLowerCase()}@`),
437
+ );
438
+ return name
439
+ ? { kind: MANAGED_PACKAGE_ENTRY, name }
440
+ : { kind: UNRELATED_ENTRY, name: null };
441
+ }
442
+
443
+ const inspectionPath = resolveInspectionPath(entryPath, baseDirectory);
444
+ const declaredName = inspectionPath ? resolveDeclaredName(inspectionPath) : null;
445
+ const managedName = declaredName
446
+ ? getManagedPackageNames().find(
447
+ (managed) => managed.toLowerCase() === declaredName.toLowerCase(),
448
+ ) ?? null
449
+ : managedNameFromPathSpelling(entryPath);
450
+
451
+ if (!managedName) return { kind: UNRELATED_ENTRY, name: null };
452
+
453
+ return isPackageManagerPath(entryPath, { platform, cacheDirectory, inspectionPath })
454
+ ? { kind: MANAGED_PACKAGE_ENTRY, name: managedName }
455
+ : {
456
+ kind: LOCAL_CHECKOUT_ENTRY,
457
+ name: managedName,
458
+ path: inspectionPath ?? entryPath,
459
+ resolvesOnDisk: Boolean(inspectionPath && existsSync(inspectionPath)),
460
+ };
461
+ }
462
+
463
+ /**
464
+ * Ensures this plugin is registered exactly once, without changing how an
465
+ * existing registration is spelled. Appending the published package name is the
466
+ * fallback for a config that does not reference the plugin at all, not the
467
+ * canonical form every config is rewritten into.
468
+ */
469
+ function normalizePluginList(list, onNotice, options = {}) {
470
+ const entries = Array.isArray(list)
471
+ ? list.filter((entry) => entry !== null && entry !== undefined && entry !== "")
472
+ : [];
473
+ const classifications = entries.map((entry) => classifyPluginEntry(entry, options));
474
+ // A checkout of this package already IS the registration, so a published
475
+ // entry beside it is a second copy of the same plugin for OpenCode to load.
476
+ // `options.checkoutRegistered` carries the same fact across config files: a
477
+ // checkout registered only in opencode.json still suppresses the published
478
+ // name in tui.json, and vice versa. Only a checkout of the CURRENT package
479
+ // counts: the former name is valid for cleanup, never as the registration
480
+ // the installer exists to ensure.
481
+ const checkoutRegistered = options.checkoutRegistered === true || classifications.some(
482
+ (classification) =>
483
+ classification.kind === LOCAL_CHECKOUT_ENTRY && classification.name === PACKAGE_NAME,
484
+ );
485
+ const kept = [];
486
+ let keptPublishedName = false;
487
+
488
+ entries.forEach((entry, index) => {
489
+ const classification = classifications[index];
490
+
491
+ if (classification.kind === LOCAL_CHECKOUT_ENTRY) {
492
+ kept.push(entry);
493
+ if (classification.resolvesOnDisk === false) {
494
+ onNotice?.(
495
+ `Warning: keeping ${classification.path} registered, but it does not resolve on disk; ` +
496
+ "the plugin may not load until the path exists again.",
497
+ );
498
+ } else {
499
+ onNotice?.(
500
+ `Keeping the local ${classification.name} checkout registered at ${classification.path}`,
501
+ );
502
+ }
503
+ return;
504
+ }
505
+
506
+ if (classification.kind === MANAGED_PACKAGE_ENTRY) {
507
+ // Retire stale duplicates, version pins, renamed packages, and paths
508
+ // into package-manager output; keep one published-name entry in place
509
+ // unless a checkout already covers it.
510
+ const isPublishedName = pluginEntrySpecifier(entry) === PACKAGE_NAME;
511
+ if (isPublishedName && !checkoutRegistered && !keptPublishedName) {
512
+ keptPublishedName = true;
513
+ kept.push(entry);
514
+ }
515
+ return;
516
+ }
517
+
518
+ kept.push(entry);
519
+ });
520
+
521
+ return checkoutRegistered || keptPublishedName ? kept : [...kept, PACKAGE_NAME];
522
+ }
523
+
524
+ function readLocalCheckoutSightings(historyPath) {
525
+ try {
526
+ const parsed = JSON.parse(readFileSync(historyPath, "utf8"));
527
+ const sightings = parsed?.sightings;
528
+ if (!Array.isArray(sightings)) return [];
529
+ return sightings.filter(
530
+ (sighting) =>
531
+ sighting &&
532
+ typeof sighting === "object" &&
533
+ sighting.isLocalCheckout === true &&
534
+ typeof sighting.root === "string" &&
535
+ typeof sighting.lastSeen === "string" &&
536
+ getManagedPackageNames().includes(sighting.name),
537
+ );
538
+ } catch {
539
+ return [];
540
+ }
541
+ }
542
+
543
+ /**
544
+ * A checkout the plugin has run from that the finished config does not
545
+ * register. Reported rather than restored: config history is evidence of what
546
+ * happened, not authority over what the user wants registered now.
547
+ */
548
+ function findUnregisteredLocalCheckout(pluginList, historyPath, options = {}) {
549
+ const entries = Array.isArray(pluginList) ? pluginList : [];
550
+ if (entries.some((entry) => classifyPluginEntry(entry, options).kind === LOCAL_CHECKOUT_ENTRY)) {
551
+ return null;
552
+ }
553
+ const latest = readLocalCheckoutSightings(historyPath)
554
+ .sort((left, right) => (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0))
555
+ .at(-1);
556
+ if (!latest) return null;
557
+ // The directory has to still hold the package that was recorded there. A
558
+ // path gets reused - a checkout deleted and something else cloned into its
559
+ // place - and a recorded path that now declares another project would
560
+ // otherwise be offered as somewhere to point OpenCode back at.
561
+ const declaredName = resolveDeclaredPackageName(latest.root);
562
+ if (!declaredName || declaredName.toLowerCase() !== String(latest.name).toLowerCase()) {
563
+ return null;
564
+ }
565
+ return latest;
566
+ }
567
+
568
+ function mergeTuiConfig(existingConfig, onNotice, options = {}) {
569
+ const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {};
570
+ const next = { ...existing };
571
+ if (typeof next.$schema !== "string" || !next.$schema.trim()) {
572
+ next.$schema = "https://opencode.ai/tui.json";
573
+ }
574
+ next.plugin = normalizePluginList(existing.plugin, onNotice, options);
575
+ return next;
576
+ }
577
+
578
+ function formatJson(obj) {
579
+ return `${JSON.stringify(obj, null, 2)}\n`;
580
+ }
581
+
582
+ function getStandaloneStoragePath(options, env = process.env) {
583
+ if (options.configPath) return resolve(options.configPath);
584
+ return join(resolveHomeDirectory(env), ".opencode", "oc-codex-multi-auth-accounts.json");
585
+ }
586
+
587
+ async function readStandaloneStorage(path) {
588
+ try {
589
+ const raw = await readFile(path, "utf-8");
590
+ const parsed = JSON.parse(raw);
591
+ // Shape validation, not just parse validation: a JSON array, scalar, or
592
+ // object without an `accounts` array is unreadable by the plugin runtime
593
+ // too (normalizeAccountStorage rejects it), so reporting it as a healthy
594
+ // empty pool (exit 0, "No accounts configured") hides the corruption
595
+ // from scripted callers that key on exit codes.
596
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
597
+ return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
598
+ }
599
+ // Forward-compat mirror of the runtime guard: a newer schema version
600
+ // must not be shown as readable accounts by this build.
601
+ const version = parsed.version;
602
+ if (typeof version === "number" && Number.isFinite(version) && version > 3) {
603
+ return {
604
+ storage: null,
605
+ error: `Unsupported account storage schema version ${version}; this build supports up to version 3.`,
606
+ };
607
+ }
608
+ if (!Array.isArray(parsed.accounts)) {
609
+ return { storage: null, error: "Storage file must be a JSON object with an accounts array." };
610
+ }
611
+ return {
612
+ storage: normalizeStandaloneStorage(parsed),
613
+ error: null,
614
+ };
615
+ } catch (error) {
616
+ if (error?.code === "ENOENT") return { storage: null, error: null };
617
+ return { storage: null, error: formatErrorForLog(error) };
618
+ }
619
+ }
620
+
621
+ function normalizeStandaloneIdentityPart(value) {
622
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
623
+ }
624
+
625
+ function sameStandaloneIdentity(left, right) {
626
+ const normalizedLeft = normalizeStandaloneIdentityPart(left);
627
+ const normalizedRight = normalizeStandaloneIdentityPart(right);
628
+ return !!normalizedLeft && !!normalizedRight && normalizedLeft === normalizedRight;
629
+ }
630
+
631
+ function isStandaloneOrgTokenDuplicate(left, right) {
632
+ const leftOrganizationId = normalizeStandaloneIdentityPart(left?.organizationId);
633
+ const rightOrganizationId = normalizeStandaloneIdentityPart(right?.organizationId);
634
+ if (leftOrganizationId && rightOrganizationId && leftOrganizationId !== rightOrganizationId) return false;
635
+ const leftOrgLike = !!leftOrganizationId || left?.accountIdSource === "org";
636
+ const rightOrgLike = !!rightOrganizationId || right?.accountIdSource === "org";
637
+ const leftTokenLike = !leftOrganizationId && left?.accountIdSource === "token";
638
+ const rightTokenLike = !rightOrganizationId && right?.accountIdSource === "token";
639
+ if (!((leftOrgLike && rightTokenLike) || (rightOrgLike && leftTokenLike))) return false;
640
+ return sameStandaloneIdentity(left?.email, right?.email) ||
641
+ sameStandaloneIdentity(left?.refreshToken, right?.refreshToken);
642
+ }
643
+
644
+ function mergeStandaloneAccounts(target, source) {
645
+ const targetOrgLike = !!normalizeStandaloneIdentityPart(target?.organizationId) || target?.accountIdSource === "org";
646
+ const sourceOrgLike = !!normalizeStandaloneIdentityPart(source?.organizationId) || source?.accountIdSource === "org";
647
+ if (targetOrgLike || !sourceOrgLike) {
648
+ return {
649
+ ...source,
650
+ ...target,
651
+ organizationId: target.organizationId ?? source.organizationId,
652
+ accountId: target.accountId ?? source.accountId,
653
+ accountIdSource: target.accountIdSource ?? source.accountIdSource,
654
+ accountLabel: target.accountLabel ?? source.accountLabel,
655
+ email: target.email ?? source.email,
656
+ };
657
+ }
658
+ return mergeStandaloneAccounts(source, target);
659
+ }
660
+
661
+ // Mirror of `isStaleGeneratedAccountLabel` / `dropStaleGeneratedLabel` in
662
+ // lib/auth/token-utils.ts and lib/storage/normalize.ts. The standalone CLI
663
+ // reads the pool through this normalizer and never through the compiled
664
+ // `normalizeAccountStorage`, so without the mirror `status`, `list`, `health`,
665
+ // `doctor` and `dashboard` keep printing the org-derived label the plugin
666
+ // itself now drops - next to the account id, which is the identity the label
667
+ // was misnaming. The marker must hold this account's own id suffix so a name
668
+ // set with `codex-label` survives.
669
+ const GENERATED_LABEL_PATTERN = /\s\[id:[^\]]*\]$/;
670
+
671
+ function dropStaleStandaloneLabel(account) {
672
+ const label = typeof account?.accountLabel === "string" ? account.accountLabel.trim() : "";
673
+ const accountId = typeof account?.accountId === "string" ? account.accountId.trim() : "";
674
+ if (!label || !accountId) return account;
675
+ const marker = label.match(GENERATED_LABEL_PATTERN)?.[0];
676
+ if (!marker) return account;
677
+ const suffix = accountId.length > 6 ? accountId.slice(-6) : accountId;
678
+ if (marker !== ` [id:${suffix}]`) return account;
679
+ const next = { ...account };
680
+ delete next.accountLabel;
681
+ return next;
682
+ }
683
+
684
+ function normalizeStandaloneStorage(storage) {
685
+ if (!Array.isArray(storage.accounts)) return storage;
686
+ const accounts = [...storage.accounts];
687
+ const removed = new Set();
688
+ for (let i = 0; i < accounts.length; i += 1) {
689
+ if (removed.has(i)) continue;
690
+ for (let j = i + 1; j < accounts.length; j += 1) {
691
+ if (removed.has(j) || !isStandaloneOrgTokenDuplicate(accounts[i], accounts[j])) continue;
692
+ const leftOrgLike = !!normalizeStandaloneIdentityPart(accounts[i]?.organizationId) ||
693
+ accounts[i]?.accountIdSource === "org";
694
+ const targetIndex = leftOrgLike ? i : j;
695
+ const sourceIndex = targetIndex === i ? j : i;
696
+ accounts[targetIndex] = mergeStandaloneAccounts(accounts[targetIndex], accounts[sourceIndex]);
697
+ removed.add(sourceIndex);
698
+ if (sourceIndex === i) break;
699
+ }
700
+ }
701
+ const normalizedAccounts = accounts
702
+ .filter((_, index) => !removed.has(index))
703
+ .map(dropStaleStandaloneLabel);
704
+ return {
705
+ ...storage,
706
+ accounts: normalizedAccounts,
707
+ activeIndex: Math.max(0, Math.min(storage.activeIndex ?? 0, Math.max(0, normalizedAccounts.length - 1))),
708
+ };
709
+ }
710
+
711
+ const MASKED_VALUE = "*****";
712
+ // The head/tail mask keeps eight characters, so it conceals nothing worth
713
+ // concealing below thirteen: `me@x.io` would print in full and `me@x.io12`
714
+ // all but its middle character. `doctor` output is what users paste into
715
+ // issues, so anything shorter is replaced outright instead. Input is trimmed
716
+ // first, or `" me@x.io "` clears the cutoff on padding alone and drops back
717
+ // into the partial mask.
718
+ const MASK_MIN_LENGTH = 13;
719
+
720
+ function maskValue(value, includeSensitive) {
721
+ if (includeSensitive || typeof value !== "string") return value;
722
+ const trimmed = value.trim();
723
+ if (!trimmed) return trimmed;
724
+ if (trimmed.length < MASK_MIN_LENGTH) return MASKED_VALUE;
725
+ return `${trimmed.slice(0, 4)}...${trimmed.slice(-4)}`;
726
+ }
727
+
728
+ // Six characters when the id is shown in full, matching what the
729
+ // in-conversation surfaces print as `id:`. Four when it is head/tail masked,
730
+ // which is the tail `maskValue` already discloses as `accountId` in the same
731
+ // payload, so the printed identity never reveals more of an id than the field
732
+ // beside it. Nothing at all when the id was too short for that mask: the
733
+ // `accountId` next to it is then `*****`, and four raw characters of a short
734
+ // id can be the whole id.
735
+ function accountIdSuffix(accountId, includeSensitive) {
736
+ if (!accountId) return undefined;
737
+ if (includeSensitive) {
738
+ return accountId.length > 6 ? accountId.slice(-6) : accountId;
739
+ }
740
+ if (accountId.length < MASK_MIN_LENGTH) return undefined;
741
+ return accountId.slice(-4);
742
+ }
743
+
744
+ // A member id is what tells two seats of one Business workspace apart, and no
745
+ // fixed-length tail always does it: member ids sharing a six-character tail
746
+ // were observed, and in a real nine-seat pool the ids are 67 characters with
747
+ // no shared tail at all, so growing a tail until it separates them prints most
748
+ // of the id in every row. The renderer below mirrors `resolveSeatRenderer` in
749
+ // lib/account-display.ts - a tail, else one window anchored where the ids first
750
+ // diverge, else short windows at each position where a pair first differs
751
+ // joined by `..`, else a hash prefix, each capped - and the id whole only if
752
+ // none of those separate them, which needs a 128-bit SHA-256 collision. The
753
+ // hash outcome is reachable, and it prints a value that cannot be matched
754
+ // against the id by eye.
755
+ const STANDALONE_SEAT_MAX_LENGTH = 12;
756
+ const STANDALONE_SEAT_HASH_LENGTHS = [8, 12, 16, 24, 32];
757
+ const STANDALONE_SEAT_WINDOW_SEPARATOR = "..";
758
+
759
+ function seatIsDisclosable(accountUserId, includeSensitive) {
760
+ if (!accountUserId) return false;
761
+ return includeSensitive || accountUserId.length >= MASK_MIN_LENGTH;
762
+ }
763
+
764
+ function seatTail(accountUserId, length) {
765
+ return accountUserId.length > length ? accountUserId.slice(-length) : accountUserId;
766
+ }
767
+
768
+ function seatWindow(accountUserId, start, length) {
769
+ if (accountUserId.length <= length) return accountUserId;
770
+ const begin = Math.max(0, Math.min(start, accountUserId.length - length));
771
+ return accountUserId.slice(begin, begin + length);
772
+ }
773
+
774
+ function seatCommonPrefixLength(values) {
775
+ const [first] = values;
776
+ if (first === undefined) return 0;
777
+ let shared = first.length;
778
+ for (const value of values) {
779
+ let index = 0;
780
+ while (index < shared && index < value.length && first[index] === value[index]) {
781
+ index += 1;
782
+ }
783
+ shared = index;
784
+ if (shared === 0) break;
785
+ }
786
+ return shared;
787
+ }
788
+
789
+ function seatFirstDivergence(left, right) {
790
+ const limit = Math.min(left.length, right.length);
791
+ let index = 0;
792
+ while (index < limit && left[index] === right[index]) index += 1;
793
+ return index;
794
+ }
795
+
796
+ // For every pair, the first index at which that pair differs - not every index
797
+ // where the ids disagree, which across a handful of random-looking ids is
798
+ // nearly all of them and localizes nothing.
799
+ function seatDivergenceAnchors(values) {
800
+ const anchors = new Set();
801
+ for (let left = 0; left < values.length; left += 1) {
802
+ for (let right = left + 1; right < values.length; right += 1) {
803
+ anchors.add(seatFirstDivergence(values[left], values[right]));
804
+ }
805
+ }
806
+ return [...anchors].sort((left, right) => left - right);
807
+ }
808
+
809
+ function seatAnchorWindowStarts(anchors, width) {
810
+ const starts = [];
811
+ for (const anchor of anchors) {
812
+ const last = starts[starts.length - 1];
813
+ if (last !== undefined && anchor < last + width) continue;
814
+ starts.push(anchor);
815
+ }
816
+ return starts;
817
+ }
818
+
819
+ function resolveStandaloneSeatRenderer(accountUserIds, includeSensitive) {
820
+ // Starts at the length the mask above allows, so masked output widens only
821
+ // when leaving it short would print a lie.
822
+ const base = includeSensitive ? 6 : 4;
823
+ const distinct = [];
824
+ const seen = new Set();
825
+ for (const accountUserId of accountUserIds) {
826
+ if (!seatIsDisclosable(accountUserId, includeSensitive)) continue;
827
+ if (seen.has(accountUserId)) continue;
828
+ seen.add(accountUserId);
829
+ distinct.push(accountUserId);
830
+ }
831
+ const atBase = (accountUserId) => seatTail(accountUserId, base);
832
+ if (distinct.length <= 1) return atBase;
833
+
834
+ const separates = (render) => new Set(distinct.map(render)).size === distinct.length;
835
+
836
+ for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
837
+ const render = (accountUserId) => seatTail(accountUserId, length);
838
+ if (separates(render)) return render;
839
+ }
840
+ const start = seatCommonPrefixLength(distinct);
841
+ for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
842
+ const render = (accountUserId) => seatWindow(accountUserId, start, length);
843
+ if (separates(render)) return render;
844
+ }
845
+ const anchors = seatDivergenceAnchors(distinct);
846
+ for (let width = 2; width <= STANDALONE_SEAT_MAX_LENGTH; width += 1) {
847
+ const starts = seatAnchorWindowStarts(anchors, width);
848
+ const rendered =
849
+ starts.length * width + (starts.length - 1) * STANDALONE_SEAT_WINDOW_SEPARATOR.length;
850
+ // Skipped, not abandoned: a wider window can span two nearby anchors
851
+ // that needed one window each, so the cost falls as the window count
852
+ // does. Mirrors `resolveSeatRenderer` in lib/account-display.ts, where
853
+ // the measured counter-example is written out.
854
+ if (rendered > STANDALONE_SEAT_MAX_LENGTH) continue;
855
+ const render = (accountUserId) =>
856
+ starts
857
+ .map((windowStart) => accountUserId.slice(windowStart, windowStart + width))
858
+ .join(STANDALONE_SEAT_WINDOW_SEPARATOR);
859
+ if (separates(render)) return render;
860
+ }
861
+ for (const length of STANDALONE_SEAT_HASH_LENGTHS) {
862
+ const render = (accountUserId) => createHash("sha256").update(accountUserId).digest("hex").slice(0, length);
863
+ if (separates(render)) return render;
864
+ }
865
+ return (accountUserId) => accountUserId;
866
+ }
867
+
868
+ function summarizeStandaloneAccounts(storage, includeSensitive, tag) {
869
+ const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
870
+ const normalizedTag = typeof tag === "string" ? tag.trim().toLowerCase() : "";
871
+ const entries = accounts
872
+ .map((account, index) => ({ account, index }))
873
+ .filter(({ account }) => !normalizedTag ||
874
+ (Array.isArray(account?.accountTags) &&
875
+ account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)));
876
+ const renderSeat = resolveStandaloneSeatRenderer(
877
+ entries.map(({ account }) =>
878
+ (typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "") || undefined,
879
+ ),
880
+ includeSensitive,
881
+ );
882
+ return entries
883
+ .map(({ account, index }) => {
884
+ const trimmedId =
885
+ typeof account?.accountId === "string" ? account.accountId.trim() : "";
886
+ const accountId = trimmedId || undefined;
887
+ // Members of one Business workspace share `accountId`, so the seat is
888
+ // what tells them apart. It is carried masked next to its suffix for
889
+ // the same reason `accountId` is: so the printed `seat:` discloses no
890
+ // more of an id than the field beside it unless telling two seats
891
+ // apart requires it.
892
+ const trimmedUserId =
893
+ typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "";
894
+ const accountUserId = trimmedUserId || undefined;
895
+ return {
896
+ index,
897
+ label: account?.accountLabel ?? `Account ${index + 1}`,
898
+ email: maskValue(account?.email, includeSensitive),
899
+ accountId: maskValue(accountId, includeSensitive),
900
+ idSuffix: accountIdSuffix(accountId, includeSensitive),
901
+ accountUserId: maskValue(accountUserId, includeSensitive),
902
+ seatSuffix: seatIsDisclosable(accountUserId, includeSensitive)
903
+ ? renderSeat(accountUserId)
904
+ : undefined,
905
+ accountIdSource: account?.accountIdSource,
906
+ enabled: account?.enabled !== false,
907
+ hasRefreshToken: typeof account?.refreshToken === "string" && account.refreshToken.length > 0,
908
+ hasAccessToken: typeof account?.accessToken === "string" && account.accessToken.length > 0,
909
+ expiresAt: account?.expiresAt,
910
+ expired: typeof account?.expiresAt === "number" ? account.expiresAt <= Date.now() : undefined,
911
+ tags: Array.isArray(account?.accountTags) ? account.accountTags : [],
912
+ note: account?.accountNote,
913
+ rateLimitResetTimes: account?.rateLimitResetTimes ?? {},
914
+ quotaExhaustedUntil: account?.quotaExhaustedUntil,
915
+ };
916
+ });
917
+ }
918
+
919
+ function printStandaloneResult(command, payload, json) {
920
+ if (json) {
921
+ console.log(JSON.stringify(payload, null, 2));
922
+ return;
923
+ }
924
+ console.log(`oc-codex-multi-auth ${command}`);
925
+ if (payload.message) console.log(payload.message);
926
+ console.log(`Storage: ${payload.storagePath}`);
927
+ console.log(`Accounts: ${payload.totalAccounts}`);
928
+ if (Array.isArray(payload.accounts)) {
929
+ for (const account of payload.accounts) {
930
+ const identity = [
931
+ account.email,
932
+ account.idSuffix ? `id:${account.idSuffix}` : undefined,
933
+ account.seatSuffix ? `seat:${account.seatSuffix}` : undefined,
934
+ ]
935
+ .filter(Boolean)
936
+ .join(", ");
937
+ const name = identity ? `${account.label} (${identity})` : account.label;
938
+ console.log(`- [${account.index}] ${name} enabled=${account.enabled} refresh=${account.hasRefreshToken} access=${account.hasAccessToken}`);
939
+ }
940
+ }
941
+ if (payload.error) console.log(`Error: ${payload.error}`);
942
+ for (const fix of payload.appliedFixes ?? []) console.log(`Fixed: ${fix}`);
943
+ for (const error of payload.fixErrors ?? []) console.log(`Repair failed: ${error}`);
944
+ if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
945
+ }
946
+
947
+ /**
948
+ * Import compiled modules from dist/ so the standalone CLI behaves identically
949
+ * to the in-conversation tools. dist/ ships in the npm package (files
950
+ * allowlist) and none of these modules import the OpenCode plugin runtime, so
951
+ * they load cleanly in plain Node.
952
+ */
953
+ async function loadDistModules(relativePaths, label) {
954
+ const distRoot = join(repoRoot, "dist", "lib");
955
+ const toUrl = (rel) => pathToFileURL(join(distRoot, rel)).href;
956
+ try {
957
+ return await Promise.all(relativePaths.map((rel) => import(toUrl(rel))));
958
+ } catch (error) {
959
+ throw new Error(
960
+ `Could not load ${label} runtime from dist/. Build the package first (npm run build). Cause: ${formatErrorForLog(error)}`,
961
+ );
962
+ }
963
+ }
964
+
965
+ async function loadWarmRuntime(env) {
966
+ const [storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod] = await loadDistModules(
967
+ [
968
+ "storage.js",
969
+ "codex-usage.js",
970
+ "accounts/warm-request.js",
971
+ "accounts/warm.js",
972
+ "shutdown.js",
973
+ "accounts/warm-recovery.js",
974
+ ],
975
+ "warm",
976
+ );
977
+ // Unlike the plugin, this CLI *is* the process, so it owns termination:
978
+ // Ctrl+C must abort the warm run rather than wait for it to drain.
979
+ // Refreshing a token here persists credentials, which registers the
980
+ // shutdown handler via the storage lock.
981
+ shutdownMod.setShutdownOwnsProcess(true);
982
+ return { storageMod, usageMod, warmReqMod, warmMod, shutdownMod, recoveryMod };
983
+ }
984
+
985
+ async function loadLimitsRuntime(env) {
986
+ const [
987
+ storageMod,
988
+ usageMod,
989
+ shutdownMod,
990
+ loggerMod,
991
+ configMod,
992
+ planMod,
993
+ planTierMod,
994
+ quotaCacheMod,
995
+ quotaOverviewMod,
996
+ themeMod,
997
+ ] = await loadDistModules(
998
+ [
999
+ "storage.js",
1000
+ "codex-usage.js",
1001
+ "shutdown.js",
1002
+ "logger.js",
1003
+ "config.js",
1004
+ "plan-allotment.js",
1005
+ "auth/plan-tier.js",
1006
+ "tui-quota-cache.js",
1007
+ "tui-quota-overview.js",
1008
+ "ui/theme.js",
1009
+ ],
1010
+ "limits",
1011
+ );
1012
+ // Fetching usage can refresh (and therefore persist) a token, so the same
1013
+ // process-owns-termination rule as `warm` applies.
1014
+ shutdownMod.setShutdownOwnsProcess(true);
1015
+ return {
1016
+ storageMod,
1017
+ usageMod,
1018
+ shutdownMod,
1019
+ loggerMod,
1020
+ configMod,
1021
+ planMod,
1022
+ planTierMod,
1023
+ quotaCacheMod,
1024
+ quotaOverviewMod,
1025
+ themeMod,
1026
+ };
1027
+ }
1028
+
1029
+ export async function runWarmCommand(parsed, options = {}) {
1030
+ const { env = process.env } = options;
1031
+ const storagePath = getStandaloneStoragePath(parsed, env);
1032
+
1033
+ let runtime;
1034
+ try {
1035
+ runtime = await (options.loadWarmRuntime ?? loadWarmRuntime)(env);
1036
+ } catch (error) {
1037
+ const payload = { command: "warm", storagePath, error: formatErrorForLog(error) };
1038
+ printWarmResult(payload, parsed.json);
1039
+ return { exitCode: 1, action: "warm", storagePath };
1040
+ }
1041
+
1042
+ const { storageMod, usageMod, warmReqMod, warmMod, recoveryMod } = runtime;
1043
+ // Point dist storage at the resolved accounts file so a refreshed token is
1044
+ // persisted to the SAME file the rest of the toolchain reads.
1045
+ storageMod.setStoragePathDirect(storagePath);
1046
+
1047
+ let storage = null;
1048
+ try {
1049
+ storage = await storageMod.loadAccounts();
1050
+ } catch (error) {
1051
+ // Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
1052
+ // upgrade hint; surface them rather than crashing the CLI.
1053
+ const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
1054
+ const payload = { command: "warm", storagePath, error: `${formatErrorForLog(error)}${hint}` };
1055
+ printWarmResult(payload, parsed.json);
1056
+ return { exitCode: 1, action: "warm", storagePath };
1057
+ }
1058
+ const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
1059
+ if (accounts.length === 0) {
1060
+ // `loadAccounts` swallows parse/IO errors and returns null. Probe the
1061
+ // file so a corrupt storage fails like `status`/`doctor` do (exit 1)
1062
+ // instead of reporting a healthy empty pool. ENOENT stays a silent
1063
+ // empty pool: a missing file legitimately means no accounts yet.
1064
+ const probe = await readStandaloneStorage(storagePath);
1065
+ if (probe.error) {
1066
+ const payload = { command: "warm", storagePath, error: probe.error };
1067
+ printWarmResult(payload, parsed.json);
1068
+ return { exitCode: 1, action: "warm", storagePath };
1069
+ }
1070
+ const payload = {
1071
+ command: "warm",
1072
+ storagePath,
1073
+ totalAccounts: 0,
1074
+ warmed: 0,
1075
+ blocksCleared: 0,
1076
+ failed: 0,
1077
+ skipped: 0,
1078
+ results: [],
1079
+ message: "No accounts configured.",
1080
+ nextAction: "Run opencode auth login.",
1081
+ };
1082
+ printWarmResult(payload, parsed.json);
1083
+ return { exitCode: 0, action: "warm", storagePath };
1084
+ }
1085
+
1086
+ // Same adapter as lib/tools/codex-warm.ts createWarmOne: refresh → resolve
1087
+ // account id → open the usage window; map an exhausted (quota-429) account
1088
+ // to a failure so it is not reported as warmed.
1089
+ const succeeded = [];
1090
+ const warmOne = async (account) => {
1091
+ const snapshot = { ...account, rateLimitResetTimes: { ...account.rateLimitResetTimes } };
1092
+ const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
1093
+ const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
1094
+ if (!accountId) {
1095
+ return { status: "failed", detail: "could not resolve account id (re-login may be required)" };
1096
+ }
1097
+ const result = await warmReqMod.warmAccountWindow({
1098
+ accountId,
1099
+ accessToken,
1100
+ organizationId: account.organizationId,
1101
+ });
1102
+ if (result.status === "exhausted") {
1103
+ return { status: "failed", detail: result.detail ?? "quota/usage limit reached" };
1104
+ }
1105
+ if (!result.rateLimited && result.model) succeeded.push({
1106
+ account: { ...snapshot, refreshToken: account.refreshToken }, model: result.model, accessToken,
1107
+ });
1108
+ return { status: "warmed" };
1109
+ };
1110
+
1111
+ const summary = await warmMod.warmAccounts(accounts, warmOne);
1112
+ let blocksCleared = 0;
1113
+ let blockClearError;
1114
+ for (const observation of succeeded) {
1115
+ let changed = false;
1116
+ try {
1117
+ const completed = await recoveryMod.recoverWarmedAccount(observation, () => { changed = true; });
1118
+ changed = completed || changed;
1119
+ } catch {
1120
+ blockClearError = "Failed to clear local blocks; warm results are unchanged.";
1121
+ }
1122
+ if (changed) blocksCleared++;
1123
+ }
1124
+ const payload = {
1125
+ command: "warm",
1126
+ blocksCleared,
1127
+ blockClearError,
1128
+ storagePath,
1129
+ totalAccounts: summary.total,
1130
+ warmed: summary.warmedCount,
1131
+ failed: summary.failedCount,
1132
+ skipped: summary.skippedCount,
1133
+ results: summary.results.map((r) => ({
1134
+ index: r.index,
1135
+ email: maskValue(accounts[r.index]?.email, parsed.includeSensitive),
1136
+ status: r.status,
1137
+ detail: r.detail,
1138
+ })),
1139
+ };
1140
+ printWarmResult(payload, parsed.json);
1141
+ return { exitCode: summary.failedCount > 0 ? 1 : 0, action: "warm", storagePath };
1142
+ }
1143
+
1144
+ function printWarmResult(payload, json) {
1145
+ if (json) {
1146
+ console.log(JSON.stringify(payload, null, 2));
1147
+ return;
1148
+ }
1149
+ console.log(`oc-codex-multi-auth warm`);
1150
+ if (payload.message) console.log(payload.message);
1151
+ console.log(`Storage: ${payload.storagePath}`);
1152
+ if (payload.error) {
1153
+ console.log(`Error: ${payload.error}`);
1154
+ return;
1155
+ }
1156
+ console.log(`Accounts: ${payload.totalAccounts}`);
1157
+ for (const r of payload.results ?? []) {
1158
+ const label = r.email ? `[${r.index}] ${r.email}` : `[${r.index}]`;
1159
+ const detail = r.detail ? ` — ${r.detail}` : "";
1160
+ console.log(`- ${label}: ${r.status}${detail}`);
1161
+ }
1162
+ console.log(`Summary: ${payload.warmed} warmed, ${payload.failed} failed, ${payload.skipped} skipped`);
1163
+ console.log(`Blocks cleared: ${payload.blocksCleared ?? 0}`);
1164
+ if (payload.blockClearError) console.log(payload.blockClearError);
1165
+ if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
1166
+ }
1167
+
1168
+ /**
1169
+ * `limits` — show 5-hour and weekly Codex usage per account (#209).
1170
+ *
1171
+ * Reports the plugin's own last reading of each account by default, and reads
1172
+ * an account live only when the plugin holds none for it or under
1173
+ * `--refresh`. A live read goes through the compiled `codex-usage` runtime, so
1174
+ * the CLI reports the same windows as the in-conversation `codex-limits`
1175
+ * tool. The locally persisted
1176
+ * `rateLimitResetTimes` is carried in the payload as well: it is the only
1177
+ * rate-limit state available for an account whose live fetch fails, and
1178
+ * `--json` consumers of the previous behavior still find the field.
1179
+ */
1180
+ export async function runLimitsCommand(parsed, options = {}) {
1181
+ const { env = process.env } = options;
1182
+ const storagePath = getStandaloneStoragePath(parsed, env);
1183
+
1184
+ let runtime;
1185
+ try {
1186
+ runtime = await (options.loadLimitsRuntime ?? loadLimitsRuntime)(env);
1187
+ } catch (error) {
1188
+ const payload = { command: "limits", storagePath, error: formatErrorForLog(error) };
1189
+ printLimitsResult(payload, parsed.json);
1190
+ return { exitCode: 1, action: "limits", storagePath };
1191
+ }
1192
+
1193
+ const {
1194
+ storageMod,
1195
+ usageMod,
1196
+ loggerMod,
1197
+ configMod,
1198
+ planMod,
1199
+ planTierMod,
1200
+ quotaCacheMod,
1201
+ quotaOverviewMod,
1202
+ themeMod,
1203
+ } = runtime;
1204
+ const pluginConfig = configMod.loadPluginConfig();
1205
+ const quotaDisplay = configMod.getQuotaDisplay(pluginConfig);
1206
+ const configuredSort = configMod.getLimitsSort?.(pluginConfig) ?? { by: "account", direction: "asc" };
1207
+ const sort = {
1208
+ by: parsed.sort ?? configuredSort.by,
1209
+ direction: parsed.direction ?? configuredSort.direction,
1210
+ };
1211
+ const render = {
1212
+ usageMod,
1213
+ quotaDisplay,
1214
+ color: !parsed.json && (themeMod?.shouldUseColor?.(process.stdout, env) ?? false),
1215
+ };
1216
+ const planNameOf = (planType) =>
1217
+ planTierMod?.formatPlanType?.(planType) ?? (typeof planType === "string" ? planType : null);
1218
+ // The badge is decoration; the report is the point. A runtime that arrived
1219
+ // without the plan module drops the `(5x)` rather than failing the account
1220
+ // it was attached to - the per-account catch below would otherwise turn one
1221
+ // missing module into an "Error:" line against every account in the pool.
1222
+ const planMultiplierOf = (planType) =>
1223
+ planMod?.formatPlanMultiplier?.(planType) ?? null;
1224
+ // Point dist storage at the resolved accounts file so a refreshed token is
1225
+ // persisted to the SAME file the rest of the toolchain reads.
1226
+ storageMod.setStoragePathDirect(storagePath);
1227
+
1228
+ let storage = null;
1229
+ try {
1230
+ storage = await storageMod.loadAccounts();
1231
+ } catch (error) {
1232
+ // Typed storage errors (e.g. UNSUPPORTED_SCHEMA_VERSION) carry the
1233
+ // upgrade hint; surface them rather than crashing the CLI.
1234
+ const hint = error && typeof error.hint === "string" ? ` ${error.hint}` : "";
1235
+ const payload = { command: "limits", storagePath, error: `${formatErrorForLog(error)}${hint}` };
1236
+ printLimitsResult(payload, parsed.json);
1237
+ return { exitCode: 1, action: "limits", storagePath };
1238
+ }
1239
+ const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
1240
+ if (accounts.length === 0) {
1241
+ // Same probe contract as `warm`: a corrupt file exits 1 like
1242
+ // `status`/`doctor`; a missing file stays a silent empty pool.
1243
+ const probe = await readStandaloneStorage(storagePath);
1244
+ if (probe.error) {
1245
+ const payload = { command: "limits", storagePath, error: probe.error };
1246
+ printLimitsResult(payload, parsed.json);
1247
+ return { exitCode: 1, action: "limits", storagePath };
1248
+ }
1249
+ const payload = {
1250
+ command: "limits",
1251
+ storagePath,
1252
+ totalAccounts: 0,
1253
+ // Same shape as a populated pool: `null` when nothing is readable.
1254
+ pool: null,
1255
+ poolSummary: null,
1256
+ readings: null,
1257
+ sort,
1258
+ accounts: [],
1259
+ message: "No accounts configured.",
1260
+ nextAction: "Run opencode auth login.",
1261
+ };
1262
+ printLimitsResult(payload, parsed.json);
1263
+ return { exitCode: 0, action: "limits", storagePath };
1264
+ }
1265
+
1266
+ // Same workspace dedupe the codex-limits tool applies, so two entries for one
1267
+ // workspace are not billed and printed twice.
1268
+ const indices = usageMod.deduplicateUsageAccountIndices(storage);
1269
+ // `--tag` must gate which accounts are contacted at all, not just which are
1270
+ // printed: an untagged account would otherwise be billed a usage fetch and
1271
+ // could have its refreshed credentials persisted.
1272
+ const normalizedTag =
1273
+ typeof parsed.tag === "string" ? parsed.tag.trim().toLowerCase() : "";
1274
+ const stateDir = resolveOpenCodeStateDir(env);
1275
+ const now = Date.now();
1276
+ // The plugin already polls every account's usage for the pool status line
1277
+ // and keeps the result on disk. Reading that instead of asking upstream
1278
+ // again is what makes this report instant, and it spares a large pool a
1279
+ // burst of usage requests. `--refresh` still reads every account live.
1280
+ const previous = await readPluginQuotaReadings(quotaCacheMod, quotaOverviewMod, stateDir);
1281
+ const cached = parsed.refresh ? undefined : previous;
1282
+ const results = [];
1283
+ // Only accounts that answered contribute to the pool total. An account that
1284
+ // failed to report is left out entirely rather than counted as full or as
1285
+ // empty, since either would state capacity nobody measured.
1286
+ const poolMembers = [];
1287
+ const sortKeys = new Map();
1288
+ const entryAccounts = new Map();
1289
+ const entryWorkspaceIds = new Map();
1290
+ const liveOverviewAccounts = [];
1291
+ let failedCount = 0;
1292
+
1293
+ const readLive = async (account, index, entry) => {
1294
+ const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
1295
+ const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
1296
+ if (!accountId) {
1297
+ throw new Error("could not resolve account id (re-login may be required)");
1298
+ }
1299
+ entryWorkspaceIds.set(entry, accountId);
1300
+ const usage = usageMod.parseCodexUsagePayload(
1301
+ await usageMod.fetchCodexUsage({
1302
+ accountId,
1303
+ accessToken,
1304
+ organizationId: account.organizationId,
1305
+ }),
1306
+ quotaDisplay,
1307
+ );
1308
+ const quotaExhaustedResetAtMs = usageMod.getUsageQuotaExhaustedResetAtMs([
1309
+ usage.primary,
1310
+ usage.secondary,
1311
+ ]);
1312
+ if (quotaExhaustedResetAtMs !== undefined) {
1313
+ try {
1314
+ await usageMod.persistUsageQuotaExhaustion(account, quotaExhaustedResetAtMs);
1315
+ } catch (error) {
1316
+ loggerMod.logWarn(
1317
+ `[${PACKAGE_NAME}] Failed to persist exhausted usage quota: ${formatErrorForLog(error)}`,
1318
+ );
1319
+ }
1320
+ }
1321
+ if (usageMod.isUsageQuotaRecovered([usage.primary, usage.secondary])) {
1322
+ try {
1323
+ await usageMod.persistUsageQuotaRecovery(account);
1324
+ } catch {
1325
+ loggerMod.logWarn("Failed to persist recovered usage quota");
1326
+ }
1327
+ }
1328
+ const overviewAccount = quotaOverviewMod?.toOverviewAccount?.({
1329
+ // Taken after the fetch: a refresh above rotates the token the
1330
+ // fingerprint is derived from.
1331
+ fingerprint: usageMod.createUsageAccountFingerprint(account),
1332
+ index: index + 1,
1333
+ usage,
1334
+ email: account.email,
1335
+ label: account.accountLabel,
1336
+ });
1337
+ if (overviewAccount) liveOverviewAccounts.push({ ...overviewAccount, fetchedAt: now });
1338
+ return {
1339
+ source: "live",
1340
+ readAt: now,
1341
+ planType: usage.planType,
1342
+ windows: [usage.primary, usage.secondary],
1343
+ limits: usage.limits,
1344
+ credits: usage.credits,
1345
+ // Raw counts stay in `resetCredits` and the rendered line lives in
1346
+ // its own field: embedding the English summary inside the counts
1347
+ // object would make `--json` consumers parse presentation text to
1348
+ // reach a number that is already beside it.
1349
+ resetCredits: usage.resetCredits,
1350
+ resetCreditsSummary: usage.resetCredits
1351
+ ? usageMod.formatResetCredits(usage.resetCredits)
1352
+ : null,
1353
+ };
1354
+ };
1355
+
1356
+ for (const index of indices) {
1357
+ const account = accounts[index];
1358
+ if (!account) continue;
1359
+ if (
1360
+ normalizedTag &&
1361
+ !(
1362
+ Array.isArray(account.accountTags) &&
1363
+ account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)
1364
+ )
1365
+ ) {
1366
+ continue;
1367
+ }
1368
+ const entry = {
1369
+ index,
1370
+ label: account.accountLabel ?? `Account ${index + 1}`,
1371
+ email: maskValue(account.email, parsed.includeSensitive),
1372
+ rateLimitResetTimes: account.rateLimitResetTimes ?? {},
1373
+ quotaExhaustedUntil: account.quotaExhaustedUntil,
1374
+ };
1375
+ entryAccounts.set(entry, account);
1376
+ entryWorkspaceIds.set(entry, account.accountId);
1377
+ try {
1378
+ // An account the plugin has no reading for (added since its last
1379
+ // poll, or a different pool than the one it polled) is read live
1380
+ // rather than left blank.
1381
+ const cachedAccount = cached && findPluginQuotaReading(cached, account, usageMod);
1382
+ const reading = cachedAccount
1383
+ ? toCachedLimitsReading(cachedAccount, usageMod, quotaDisplay)
1384
+ : await readLive(account, index, entry);
1385
+ poolMembers.push({
1386
+ planType: reading.planType,
1387
+ primary: reading.windows[0] ?? {},
1388
+ secondary: reading.windows[1] ?? {},
1389
+ });
1390
+ sortKeys.set(entry, readLimitsSortKeys(usageMod, reading.windows));
1391
+ entry.source = reading.source;
1392
+ entry.readAt = reading.readAt;
1393
+ entry.planType = reading.planType;
1394
+ entry.planName = planNameOf(reading.planType);
1395
+ entry.planMultiplier = planMultiplierOf(reading.planType);
1396
+ entry.credits = reading.credits;
1397
+ entry.resetCredits = reading.resetCredits;
1398
+ entry.resetCreditsSummary = reading.resetCreditsSummary;
1399
+ entry.limits = reading.limits;
1400
+ } catch (error) {
1401
+ // `ensureCodexUsageAccessToken` can surface a raw OAuth refresh
1402
+ // response, so the message is redacted through the logger's token
1403
+ // patterns before it reaches stdout, JSON output, or CI logs.
1404
+ // Truncation alone does not protect bearer/JWT/refresh-token material.
1405
+ entry.error = loggerMod.maskString(formatErrorForLog(error)).slice(0, 160);
1406
+ failedCount += 1;
1407
+ }
1408
+ results.push(entry);
1409
+ }
1410
+
1411
+ await attachWorkspaceNames({
1412
+ results,
1413
+ entryAccounts,
1414
+ entryWorkspaceIds,
1415
+ stateDir,
1416
+ refresh: parsed.refresh,
1417
+ lookup: async (account, readLive) => {
1418
+ // An account reported from the plugin's readings is not worth a
1419
+ // token refresh just to name it: only a stored token still valid
1420
+ // is used, and the name waits for a run that reads it live.
1421
+ const accessToken = readLive
1422
+ ? (await usageMod.ensureCodexUsageAccessToken({ storage, account })).accessToken
1423
+ : typeof account.accessToken === "string" && account.expiresAt > Date.now()
1424
+ ? account.accessToken
1425
+ : undefined;
1426
+ if (!accessToken) return undefined;
1427
+ const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
1428
+ if (!accountId) throw new Error("could not resolve account id");
1429
+ return usageMod.fetchCodexWorkspaceNames({
1430
+ accountId,
1431
+ accessToken,
1432
+ organizationId: account.organizationId,
1433
+ timeoutMs: WORKSPACE_NAME_LOOKUP_TIMEOUT_MS,
1434
+ });
1435
+ },
1436
+ warn: (message) => loggerMod.logWarn(`[${PACKAGE_NAME}] ${loggerMod.maskString(message)}`),
1437
+ });
1438
+
1439
+ // Hand a full live read back to the plugin, so its status line and the next
1440
+ // `limits` see it too. Only a read of the plugin's own pool qualifies: a
1441
+ // `--tag` subset would drop every other account from the snapshot, and a
1442
+ // `--config-path` store is not the pool the status line describes.
1443
+ const allLive = results.every((entry) => entry.source !== "cache");
1444
+ if (!normalizedTag && !parsed.configPath && allLive && liveOverviewAccounts.length > 0) {
1445
+ try {
1446
+ await writePluginQuotaReadings({
1447
+ quotaCacheMod,
1448
+ stateDir,
1449
+ now,
1450
+ live: liveOverviewAccounts,
1451
+ previous,
1452
+ pool: indices.map((index) => ({ index, account: accounts[index] })).filter(({ account }) => account),
1453
+ usageMod,
1454
+ });
1455
+ } catch (error) {
1456
+ loggerMod.logWarn(
1457
+ `[${PACKAGE_NAME}] Failed to cache the pool quota snapshot: ${formatErrorForLog(error)}`,
1458
+ );
1459
+ }
1460
+ }
1461
+
1462
+ const cachedEntries = results.filter((entry) => entry.source === "cache");
1463
+ const readings = results.some((entry) => entry.source)
1464
+ ? {
1465
+ source: cachedEntries.length === 0
1466
+ ? "live"
1467
+ : cachedEntries.length === results.filter((entry) => entry.source).length
1468
+ ? "cache"
1469
+ : "mixed",
1470
+ // The newest reading names the report; any account read at another
1471
+ // moment carries its own time.
1472
+ readAt: cachedEntries.length === 0
1473
+ ? now
1474
+ : Math.max(...cachedEntries.map((entry) => entry.readAt)),
1475
+ }
1476
+ : null;
1477
+ const pool = usageMod.summarizeUsagePool(poolMembers);
1478
+ const payload = {
1479
+ command: "limits",
1480
+ storagePath,
1481
+ totalAccounts: accounts.length,
1482
+ shownAccounts: results.length,
1483
+ // Both percentages are stated so a consumer never has to know which way
1484
+ // `quotaDisplay` was pointing to read them.
1485
+ pool: pool
1486
+ ? {
1487
+ leftPercent: pool.leftPercent,
1488
+ usedPercent: 100 - pool.leftPercent,
1489
+ allotment: pool.allotment,
1490
+ countedAccounts: pool.countedAccounts,
1491
+ }
1492
+ : null,
1493
+ poolSummary: pool
1494
+ ? usageMod.formatUsagePoolSummary(pool, quotaDisplay)
1495
+ : null,
1496
+ readings,
1497
+ sort,
1498
+ accounts: sortLimitsEntries(results, sortKeys, sort),
1499
+ };
1500
+ printLimitsResult(payload, parsed.json, render);
1501
+ return { exitCode: failedCount > 0 ? 1 : 0, action: "limits", storagePath };
1502
+ }
1503
+
1504
+ /** Where OpenCode keeps its state, and so where the plugin's quota caches live. */
1505
+ function resolveOpenCodeStateDir(env) {
1506
+ const explicit = env.OPENCODE_STATE_DIR?.trim();
1507
+ if (explicit) return explicit;
1508
+ const stateHome = env.XDG_STATE_HOME?.trim() || join(resolveHomeDirectory(env), ".local", "state");
1509
+ return join(stateHome, "opencode");
1510
+ }
1511
+
1512
+ /**
1513
+ * The plugin's last reading of the pool: the snapshot its status line polls,
1514
+ * with the request path's newer reading of the serving account folded in.
1515
+ */
1516
+ async function readPluginQuotaReadings(quotaCacheMod, quotaOverviewMod, stateDir) {
1517
+ if (!quotaCacheMod?.readTuiQuotaOverviewSnapshot) return undefined;
1518
+ const raw = await quotaCacheMod.readTuiQuotaOverviewSnapshot(
1519
+ quotaCacheMod.getTuiQuotaOverviewCachePath(stateDir),
1520
+ );
1521
+ if (!raw) return undefined;
1522
+ const latest = await quotaCacheMod.readTuiQuotaSnapshot(
1523
+ quotaCacheMod.getTuiQuotaCachePath(stateDir),
1524
+ );
1525
+ const snapshot = quotaOverviewMod?.mergeOverviewWithLatestAccount?.(raw, latest) ?? raw;
1526
+ return { raw, snapshot };
1527
+ }
1528
+
1529
+ /**
1530
+ * Pair an account with its entry in a plugin snapshot, by the credential
1531
+ * fingerprint alone. Pool position and email do not identify an account: one
1532
+ * email can hold a personal account and several workspace seats, so a looser
1533
+ * match could report one seat's quota as another's. An account whose token
1534
+ * has rotated since the plugin's poll is simply read live.
1535
+ */
1536
+ function findPluginQuotaEntry(snapshot, account, usageMod) {
1537
+ const fingerprint = usageMod.createUsageAccountFingerprint(account);
1538
+ return snapshot.accounts.find((candidate) => candidate.fingerprint === fingerprint);
1539
+ }
1540
+
1541
+ function findPluginQuotaReading(readings, account, usageMod) {
1542
+ const found = findPluginQuotaEntry(readings.snapshot, account, usageMod);
1543
+ if (!found) return undefined;
1544
+ return { account: found, readAt: found.fetchedAt ?? readings.snapshot.fetchedAt };
1545
+ }
1546
+
1547
+ /**
1548
+ * A window nobody has drawn from reports "now plus the window" as its reset.
1549
+ * The plugin now drops that reset, but a snapshot an older build wrote still
1550
+ * carries it, so it is recognized by lying a whole window after the reading.
1551
+ */
1552
+ function isCachedWindowNotStarted(limit, readAt) {
1553
+ if (limit.usedPercent !== 0) return false;
1554
+ if (limit.resetAtMs === undefined) return true;
1555
+ const windowMs = (limit.windowMinutes ?? 0) * 60_000;
1556
+ return windowMs > 0 && limit.resetAtMs - readAt >= windowMs - 60_000;
1557
+ }
1558
+
1559
+ function toCachedLimitsReading(reading, usageMod, quotaDisplay) {
1560
+ const windows = reading.account.limits.map((limit) => {
1561
+ const usedPercent =
1562
+ typeof limit.usedPercent === "number"
1563
+ ? limit.usedPercent
1564
+ : typeof limit.leftPercent === "number"
1565
+ ? 100 - limit.leftPercent
1566
+ : undefined;
1567
+ const window = { usedPercent, windowMinutes: limit.windowMinutes };
1568
+ if (isCachedWindowNotStarted({ ...limit, usedPercent }, reading.readAt)) {
1569
+ return { ...window, notStarted: true };
1570
+ }
1571
+ return { ...window, resetAtMs: limit.resetAtMs };
1572
+ });
1573
+ const count = reading.account.resetCredits;
1574
+ const applicable = reading.account.resetCreditsApplicable;
1575
+ return {
1576
+ source: "cache",
1577
+ readAt: reading.readAt,
1578
+ planType: reading.account.planType ?? null,
1579
+ windows,
1580
+ limits: windows.map((window) =>
1581
+ usageMod.toUsageLimitPayload(
1582
+ usageMod.formatUsageLimitTitle(window.windowMinutes),
1583
+ window,
1584
+ quotaDisplay,
1585
+ ),
1586
+ ),
1587
+ // The snapshot keeps neither the credit balance nor the banked total
1588
+ // beside the redeemable count, so only what it does keep is reported.
1589
+ credits: null,
1590
+ resetCredits: null,
1591
+ resetCreditsSummary:
1592
+ typeof count === "number" && count > 0
1593
+ ? `${count} ${typeof applicable === "number" ? "applicable now" : "banked"}`
1594
+ : null,
1595
+ };
1596
+ }
1597
+
1598
+ /**
1599
+ * Write a live read of the whole pool as the plugin's snapshot, in the shape
1600
+ * its poller writes. The file is shared by every OpenCode window on the
1601
+ * machine, so it is written only when the result is at least as complete and
1602
+ * as current as what it replaces:
1603
+ *
1604
+ * - an account this run failed to read keeps its previous entry, and the
1605
+ * snapshot then keeps the older time, exactly as the poller does; an account
1606
+ * with no previous entry to keep means no write, because a snapshot missing
1607
+ * an account would judge the pool on a subset;
1608
+ * - a previous snapshot that describes a different pool is left alone, judged
1609
+ * by fingerprint or by pool position and email, since a rotated token
1610
+ * changes a fingerprint without changing the pool;
1611
+ * - a snapshot another process wrote while this run was reading is left
1612
+ * alone, since it is as new as this one or newer.
1613
+ */
1614
+ async function writePluginQuotaReadings({ quotaCacheMod, stateDir, now, live, previous, pool, usageMod }) {
1615
+ if (!quotaCacheMod?.writeTuiQuotaOverviewSnapshot) return;
1616
+ const samePoolAs = (entry) =>
1617
+ pool.some(({ index, account }) =>
1618
+ entry.fingerprint === usageMod.createUsageAccountFingerprint(account) ||
1619
+ (entry.index === index + 1 &&
1620
+ typeof account.email === "string" &&
1621
+ entry.email?.trim().toLowerCase() === account.email.trim().toLowerCase()),
1622
+ );
1623
+ if (previous && !previous.raw.accounts.every(samePoolAs)) return;
1624
+ const accounts = [...live];
1625
+ let carriedOver = false;
1626
+ for (const { index, account } of pool) {
1627
+ if (accounts.some((entry) => entry.index === index + 1)) continue;
1628
+ const kept = previous && findPluginQuotaEntry(previous.raw, account, usageMod);
1629
+ if (!kept) return;
1630
+ accounts.push({ ...kept, fetchedAt: kept.fetchedAt ?? previous.raw.fetchedAt });
1631
+ carriedOver = true;
1632
+ }
1633
+ accounts.sort((left, right) => left.index - right.index);
1634
+ const path = quotaCacheMod.getTuiQuotaOverviewCachePath(stateDir);
1635
+ const current = await quotaCacheMod.readTuiQuotaOverviewSnapshot(path);
1636
+ if (JSON.stringify(current) !== JSON.stringify(previous?.raw)) return;
1637
+ await quotaCacheMod.writeTuiQuotaOverviewSnapshot(
1638
+ {
1639
+ version: quotaCacheMod.TUI_QUOTA_CACHE_VERSION,
1640
+ fetchedAt: carriedOver && previous ? Math.min(previous.raw.fetchedAt, now) : now,
1641
+ accounts,
1642
+ },
1643
+ path,
1644
+ );
1645
+ }
1646
+
1647
+ const WORKSPACE_NAME_CACHE_FILE = "oc-codex-multi-auth-workspace-names.json";
1648
+ const WORKSPACE_NAME_CACHE_VERSION = 1;
1649
+ // The name is decoration, so a slow lookup gives up long before a usage fetch
1650
+ // would.
1651
+ const WORKSPACE_NAME_LOOKUP_TIMEOUT_MS = 5_000;
1652
+
1653
+ async function readWorkspaceNameCache(stateDir) {
1654
+ const names = new Map();
1655
+ try {
1656
+ const parsed = JSON.parse(await readFile(join(stateDir, WORKSPACE_NAME_CACHE_FILE), "utf-8"));
1657
+ if (parsed?.version !== WORKSPACE_NAME_CACHE_VERSION || typeof parsed.accounts !== "object") {
1658
+ return names;
1659
+ }
1660
+ for (const [id, record] of Object.entries(parsed.accounts ?? {})) {
1661
+ const name = record?.name;
1662
+ if (name === null) names.set(id, null);
1663
+ else if (typeof name === "string") {
1664
+ names.set(id, name.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").trim() || null);
1665
+ }
1666
+ }
1667
+ } catch {
1668
+ // A missing or unreadable cache is an empty one.
1669
+ }
1670
+ return names;
1671
+ }
1672
+
1673
+ async function writeWorkspaceNameCache(stateDir, names, now) {
1674
+ const accounts = Object.fromEntries(
1675
+ [...names].map(([id, name]) => [id, { name, checkedAt: now }]),
1676
+ );
1677
+ await writeFileAtomic(
1678
+ join(stateDir, WORKSPACE_NAME_CACHE_FILE),
1679
+ `${JSON.stringify({ version: WORKSPACE_NAME_CACHE_VERSION, accounts }, null, 2)}\n`,
1680
+ );
1681
+ }
1682
+
1683
+ /**
1684
+ * Name the Business workspace each account belongs to.
1685
+ *
1686
+ * Names are remembered on disk, because a workspace is renamed far more
1687
+ * rarely than `limits` is run: an account is asked about only the first time
1688
+ * it is seen, or under `--refresh`. One answer lists every workspace the login
1689
+ * is a member of, so an id another account already named is not asked about
1690
+ * again, and a personal account is remembered as having no name. The lookup
1691
+ * is decoration: a failure only drops the line.
1692
+ */
1693
+ async function attachWorkspaceNames({ results, entryAccounts, entryWorkspaceIds, stateDir, refresh, lookup, warn }) {
1694
+ const known = refresh ? new Map() : await readWorkspaceNameCache(stateDir);
1695
+ let changed = false;
1696
+ for (const entry of results) {
1697
+ const workspaceId = entryWorkspaceIds.get(entry);
1698
+ if (!workspaceId || known.has(workspaceId) || entry.error) continue;
1699
+ try {
1700
+ const names = await lookup(entryAccounts.get(entry), entry.source === "live");
1701
+ if (!names) continue;
1702
+ for (const [id, name] of names) known.set(id, name);
1703
+ if (!known.has(workspaceId)) known.set(workspaceId, null);
1704
+ changed = true;
1705
+ } catch (error) {
1706
+ warn(`Failed to read workspace names: ${formatErrorForLog(error)}`);
1707
+ }
1708
+ }
1709
+ for (const entry of results) {
1710
+ const workspaceId = entryWorkspaceIds.get(entry);
1711
+ entry.workspaceName = (workspaceId && known.get(workspaceId)) ?? null;
1712
+ }
1713
+ if (!changed) return;
1714
+ try {
1715
+ await writeWorkspaceNameCache(stateDir, known, Date.now());
1716
+ } catch (error) {
1717
+ warn(`Failed to cache workspace names: ${formatErrorForLog(error)}`);
1718
+ }
1719
+ }
1720
+
1721
+ /**
1722
+ * What `--sort usage` and `--sort reset` compare: the account's governing
1723
+ * window, the one with the least headroom, since that is the one that stops a
1724
+ * request. Ties go to the later reset, so an account with both windows spent
1725
+ * is ranked by when it actually becomes usable again. Only the two windows
1726
+ * that govern ordinary requests count, matching the pool total, and a window
1727
+ * that has not started has no renewal to rank by.
1728
+ */
1729
+ function readLimitsSortKeys(usageMod, windows) {
1730
+ let governing;
1731
+ for (const window of windows) {
1732
+ if (!usageMod.hasUsageWindow(window) || !Number.isFinite(window.usedPercent)) continue;
1733
+ if (
1734
+ !governing ||
1735
+ window.usedPercent > governing.usedPercent ||
1736
+ (window.usedPercent === governing.usedPercent &&
1737
+ (window.resetAtMs ?? -Infinity) > (governing.resetAtMs ?? -Infinity))
1738
+ ) {
1739
+ governing = window;
1740
+ }
1741
+ }
1742
+ return {
1743
+ usedPercent: governing?.usedPercent,
1744
+ resetAtMs: governing && !governing.notStarted && Number.isFinite(governing.resetAtMs)
1745
+ ? governing.resetAtMs
1746
+ : undefined,
1747
+ };
1748
+ }
1749
+
1750
+ /**
1751
+ * Accounts whose key is unknown (a failed fetch, a window not yet started)
1752
+ * sort last in either direction, and ties fall back to the account number so
1753
+ * the order is stable between runs.
1754
+ */
1755
+ function sortLimitsEntries(entries, sortKeys, sort) {
1756
+ const keyOf = (entry) => {
1757
+ if (sort.by === "account") return entry.index;
1758
+ const keys = sortKeys.get(entry);
1759
+ return sort.by === "usage" ? keys?.usedPercent : keys?.resetAtMs;
1760
+ };
1761
+ const direction = sort.direction === "desc" ? -1 : 1;
1762
+ return [...entries].sort((left, right) => {
1763
+ const leftKey = keyOf(left);
1764
+ const rightKey = keyOf(right);
1765
+ if (leftKey === undefined && rightKey !== undefined) return 1;
1766
+ if (rightKey === undefined && leftKey !== undefined) return -1;
1767
+ if (leftKey !== undefined && leftKey !== rightKey) {
1768
+ return direction * (leftKey - rightKey);
1769
+ }
1770
+ return left.index - right.index;
1771
+ });
1772
+ }
1773
+
1774
+ const LIMITS_USAGE_COLORS = [
1775
+ [99, "\u001b[31m"],
1776
+ [80, "\u001b[38;5;208m"],
1777
+ [60, "\u001b[33m"],
1778
+ [0, "\u001b[32m"],
1779
+ ];
1780
+
1781
+ /**
1782
+ * Colour keyed on consumption whatever `quotaDisplay` words it as. It follows
1783
+ * the rounded figure printed beside it, so `99% used` is never shown orange.
1784
+ */
1785
+ function colorLimitsPercent(text, usedPercent, render) {
1786
+ if (!render?.color) return text;
1787
+ const code = LIMITS_USAGE_COLORS.find(([floor]) => usedPercent >= floor)?.[1];
1788
+ return code ? `${code}${text}\u001b[0m` : text;
1789
+ }
1790
+
1791
+ function formatLimitsPercent(limit, render) {
1792
+ if (typeof limit.leftPercent !== "number") return "unavailable";
1793
+ const mode = render?.quotaDisplay ?? "free";
1794
+ const usedPercent = 100 - limit.leftPercent;
1795
+ const text = mode === "used" ? `${usedPercent}% used` : `${limit.leftPercent}% left`;
1796
+ return colorLimitsPercent(text, usedPercent, render);
1797
+ }
1798
+
1799
+ function formatLimitsRenewal(limit, render, now) {
1800
+ if (limit.notStarted) return "not started (the window opens on first use)";
1801
+ const usageMod = render?.usageMod;
1802
+ if (!Number.isFinite(limit.resetAtMs) || !usageMod?.formatUsageResetTimestamp) return undefined;
1803
+ const at = usageMod.formatUsageResetTimestamp(limit.resetAtMs);
1804
+ if (!at) return undefined;
1805
+ if (limit.resetAtMs <= now) return `${at} (passed since this reading)`;
1806
+ const countdown = usageMod.formatUsageCountdown(limit.resetAtMs - now);
1807
+ return countdown ? `${at} (in ${countdown})` : at;
1808
+ }
1809
+
1810
+ /** `2026-09-27 13:17:22 (14m ago)`. */
1811
+ function formatLimitsReadTime(readAt, render, now) {
1812
+ const usageMod = render?.usageMod;
1813
+ if (!Number.isFinite(readAt) || !usageMod?.formatUsageResetTimestamp) return undefined;
1814
+ const at = usageMod.formatUsageResetTimestamp(Math.floor(readAt / 1000) * 1000);
1815
+ const age = now - readAt >= 60_000 ? `${usageMod.formatUsageCountdown(now - readAt)} ago` : "just now";
1816
+ return `${at} (${age})`;
1817
+ }
1818
+
1819
+ function describeLimitsReadings(readings, render, now) {
1820
+ const at = readings && formatLimitsReadTime(readings.readAt, render, now);
1821
+ if (!at) return undefined;
1822
+ if (readings.source === "live") return `read live at ${at}`;
1823
+ return `the plugin's last readings, taken ${at}; --refresh reads every account live`;
1824
+ }
1825
+
1826
+ function buildLimitsAccountRows(account, render, now, readings) {
1827
+ const rows = [];
1828
+ if (account.workspaceName) rows.push(["Business account", account.workspaceName]);
1829
+ if (account.error) {
1830
+ rows.push(["Error", account.error]);
1831
+ return rows;
1832
+ }
1833
+ for (const limit of account.limits ?? []) {
1834
+ rows.push([limit.name, formatLimitsPercent(limit, render)]);
1835
+ const renewal = formatLimitsRenewal(limit, render, now);
1836
+ if (renewal) rows.push(["Renews", renewal]);
1837
+ }
1838
+ if ((account.limits ?? []).length === 0) rows.push([null, "No usage windows reported yet."]);
1839
+ const planName = account.planName ?? account.planType;
1840
+ if (planName) {
1841
+ const allotment = account.planMultiplier ? ` (${account.planMultiplier})` : "";
1842
+ rows.push(["Plan", `${planName}${allotment}`]);
1843
+ }
1844
+ if (account.credits) rows.push(["Credits", account.credits]);
1845
+ const hasResets = account.resetCredits
1846
+ ? account.resetCredits.available > 0
1847
+ : Boolean(account.resetCreditsSummary);
1848
+ if (hasResets) rows.push(["Resets", account.resetCreditsSummary]);
1849
+ // Only a reading taken at a different moment from the one the header
1850
+ // names gets its own time.
1851
+ if (readings && Number.isFinite(account.readAt) && account.readAt !== readings.readAt) {
1852
+ const at = formatLimitsReadTime(account.readAt, render, now);
1853
+ if (at) rows.push(["Read", account.source === "live" ? `${at}, live` : at]);
1854
+ }
1855
+ return rows;
1856
+ }
1857
+
1858
+ function limitsKeyWidth(rows) {
1859
+ return Math.max(0, ...rows.filter(([key]) => key !== null).map(([key]) => key.length));
1860
+ }
1861
+
1862
+ function printLimitsRows(rows, indent, width = limitsKeyWidth(rows)) {
1863
+ for (const [key, value] of rows) {
1864
+ console.log(key === null ? `${indent}${value}` : `${indent}${`${key}:`.padEnd(width + 1)} ${value}`);
1865
+ }
1866
+ }
1867
+
1868
+ function printLimitsResult(payload, json, render) {
1869
+ if (json) {
1870
+ console.log(JSON.stringify(payload, null, 2));
1871
+ return;
1872
+ }
1873
+ console.log(`oc-codex-multi-auth limits`);
1874
+ if (payload.message) console.log(payload.message);
1875
+ if (payload.error) {
1876
+ printLimitsRows([["Storage", payload.storagePath], ["Error", payload.error]], "");
1877
+ return;
1878
+ }
1879
+ const now = Date.now();
1880
+ const readings = describeLimitsReadings(payload.readings, render, now);
1881
+ const header = [
1882
+ ["Storage", payload.storagePath],
1883
+ ["Accounts", String(payload.totalAccounts)],
1884
+ ...(payload.sort ? [["Sort", `${payload.sort.by} (${payload.sort.direction})`]] : []),
1885
+ ...(readings ? [["Readings", readings]] : []),
1886
+ ];
1887
+ const footer = [
1888
+ ...(payload.poolSummary ? [["Pool", payload.poolSummary]] : []),
1889
+ ...(payload.nextAction ? [["Next", payload.nextAction]] : []),
1890
+ ];
1891
+ // The header and the pool total share one column, and every account's rows
1892
+ // share another, so the report reads down straight edges rather than each
1893
+ // block lining up only with itself.
1894
+ const topWidth = limitsKeyWidth([...header, ...footer]);
1895
+ printLimitsRows(header, "", topWidth);
1896
+ const accountRows = (payload.accounts ?? []).map((account) => ({
1897
+ account,
1898
+ rows: buildLimitsAccountRows(account, render, now, payload.readings),
1899
+ }));
1900
+ const rowWidth = limitsKeyWidth(accountRows.flatMap(({ rows }) => rows));
1901
+ for (const { account, rows } of accountRows) {
1902
+ console.log("");
1903
+ const label = account.email ? `${account.label} (${account.email})` : account.label;
1904
+ console.log(`- [${account.index}] ${label}`);
1905
+ printLimitsRows(rows, " ", rowWidth);
1906
+ }
1907
+ if (footer.length > 0) {
1908
+ console.log("");
1909
+ printLimitsRows(footer, "", topWidth);
1910
+ }
1911
+ }
1912
+
1913
+ export async function runStandaloneCommand(command, argv = [], options = {}) {
1914
+ const parsed = parseStandaloneArgs(argv);
1915
+ if (command === "diag") {
1916
+ command = "doctor";
1917
+ parsed.deep = true;
1918
+ }
1919
+ if (parsed.help) {
1920
+ printHelp();
1921
+ return { exitCode: 0, action: "help" };
1922
+ }
1923
+ if (command === "warm") {
1924
+ return runWarmCommand(parsed, options);
1925
+ }
1926
+ if (command === "limits") {
1927
+ return runLimitsCommand(parsed, options);
1928
+ }
1929
+ const { env = process.env } = options;
1930
+ const storagePath = getStandaloneStoragePath(parsed, env);
1931
+ const repairRequested = command === "doctor" && parsed.fix;
1932
+ let storage = null;
1933
+ let error = null;
1934
+ if (parsed.configPath || !repairRequested) {
1935
+ ({ storage, error } = await readStandaloneStorage(storagePath));
1936
+ }
1937
+ const appliedFixes = [];
1938
+ const fixErrors = [];
1939
+ if (repairRequested && !error) {
1940
+ const previousKeychain = process.env.CODEX_KEYCHAIN;
1941
+ try {
1942
+ const loadDoctorRuntime = options.loadDoctorRuntime ?? (() => loadDistModules(
1943
+ ["storage.js", "tools/doctor-repair.js", "shutdown.js"], "doctor",
1944
+ ));
1945
+ const [storageMod, repairMod, shutdownMod] = await loadDoctorRuntime();
1946
+ // A CLI file selection must not read or replace the global keychain pool.
1947
+ if (parsed.configPath) process.env.CODEX_KEYCHAIN = "0";
1948
+ storageMod.setStoragePathDirect(storagePath);
1949
+ shutdownMod.setShutdownOwnsProcess(true);
1950
+ try {
1951
+ storage = await storageMod.loadAccounts();
1952
+ } catch (loadError) {
1953
+ // Typed storage errors (UNSUPPORTED_SCHEMA_VERSION, unknown V2)
1954
+ // carry exact in-tree copy plus an upgrade/recovery hint, and the
1955
+ // load never got far enough to attempt a repair. Surface them on
1956
+ // the error channel (exit 1) instead of the generic catch below,
1957
+ // which is reserved for unknown throws so upstream failure text
1958
+ // never reaches output unredacted.
1959
+ if (loadError && typeof loadError.code === "string") {
1960
+ const hint = typeof loadError.hint === "string" ? ` ${loadError.hint}` : "";
1961
+ error = `${formatErrorForLog(loadError)}${hint}`;
1962
+ } else {
1963
+ throw loadError;
1964
+ }
1965
+ }
1966
+ if (!storage && !error) {
1967
+ // `loadAccounts` swallows JSON parse/IO errors and returns null. In
1968
+ // default-path mode the pre-read above was skipped (keychain routing
1969
+ // may own the pool), so probe the JSON file here: a corrupt file must
1970
+ // surface as a parse error (exit 1) instead of "No accounts
1971
+ // configured" (exit 0). ENOENT stays silent - a missing file with an
1972
+ // empty keychain legitimately means no accounts yet. Skipped when a
1973
+ // typed load error already set `error`; the probe would only
1974
+ // overwrite the precise schema message with its own paraphrase.
1975
+ const probe = await readStandaloneStorage(storagePath);
1976
+ if (probe.error) error = probe.error;
1977
+ }
1978
+ if (!error) {
1979
+ const repair = await repairMod.repairDoctorAccounts(storage?.accounts ?? []);
1980
+ appliedFixes.push(...repair.appliedFixes);
1981
+ fixErrors.push(...repair.fixErrors);
1982
+ storage = (await storageMod.loadAccounts()) ?? storage;
1983
+ }
1984
+ } catch {
1985
+ fixErrors.push("Doctor repair could not complete. Check the selected storage file and installed runtime.");
1986
+ } finally {
1987
+ if (parsed.configPath) {
1988
+ if (previousKeychain === undefined) delete process.env.CODEX_KEYCHAIN;
1989
+ else process.env.CODEX_KEYCHAIN = previousKeychain;
1990
+ }
1991
+ }
1992
+ }
1993
+ const accounts = summarizeStandaloneAccounts(storage, parsed.includeSensitive, parsed.tag);
1994
+ const totalAccounts = Array.isArray(storage?.accounts) ? storage.accounts.length : 0;
1995
+ const payload = {
1996
+ command,
1997
+ storagePath,
1998
+ totalAccounts,
1999
+ shownAccounts: accounts.length,
2000
+ activeIndex: typeof storage?.activeIndex === "number" ? storage.activeIndex : 0,
2001
+ activeIndexByFamily: storage?.activeIndexByFamily ?? {},
2002
+ accounts,
2003
+ error,
2004
+ };
2005
+ if (command === "dashboard") {
2006
+ payload.message = "Standalone dashboard server is not launched by this safe CLI; use status/list/limits/health or OpenCode codex-dashboard.";
2007
+ payload.nextAction = "Run oc-codex-multi-auth status or open OpenCode and call codex-dashboard.";
2008
+ } else if (command === "doctor") {
2009
+ payload.message = error ? "Storage could not be parsed." : totalAccounts > 0 ? "Local diagnostics completed." : "No accounts configured.";
2010
+ payload.deep = parsed.deep;
2011
+ payload.fixApplied = parsed.fix ? appliedFixes.length > 0 : undefined;
2012
+ if (parsed.fix) {
2013
+ payload.appliedFixes = appliedFixes;
2014
+ payload.fixErrors = fixErrors;
2015
+ }
2016
+ payload.nextAction = totalAccounts > 0 ? "Run oc-codex-multi-auth health --json for scriptable checks." : "Run opencode auth login.";
2017
+ } else if (command === "health") {
2018
+ payload.healthyCount = accounts.filter((account) => account.enabled && account.hasRefreshToken).length;
2019
+ payload.unhealthyCount = accounts.filter((account) => !account.enabled || !account.hasRefreshToken).length;
2020
+ } else if (command === "status") {
2021
+ payload.message = totalAccounts > 0 ? "Account storage loaded." : "No accounts configured.";
2022
+ }
2023
+ printStandaloneResult(command, payload, parsed.json);
2024
+ return { exitCode: error || fixErrors.length > 0 ? 1 : 0, action: command, storagePath };
2025
+ }
2026
+
2027
+ // Top-level keys inside `provider.openai` that the installer owns absolutely.
2028
+ // These are always sourced from the template (overwritten or removed) so the
2029
+ // plugin's required runtime shape is authoritative. Any OTHER key the user has
2030
+ // placed under `provider.openai` is preserved as-is. `models` is handled
2031
+ // separately because it's a map where user-added model ids must survive while
2032
+ // template-shipped ids win on collision.
2033
+ const MANAGED_OPENAI_KEYS = new Set(["baseURL", "apiKey", "options"]);
2034
+
2035
+ function isPlainObject(value) {
2036
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2037
+ }
2038
+
2039
+ // Deep-merge `provider.openai` preserving unknown user keys while letting the
2040
+ // installer overwrite the managed shape it ships. This replaces the earlier
2041
+ // wholesale overwrite which clobbered custom user-added keys (see audit top-20
2042
+ // #6).
2043
+ function mergeOpenaiProvider(existingOpenai, templateOpenai, options = {}) {
2044
+ const existingSafe = isPlainObject(existingOpenai) ? existingOpenai : {};
2045
+ const templateSafe = isPlainObject(templateOpenai) ? templateOpenai : {};
2046
+ const modelKeysToRemove = options.modelKeysToRemove instanceof Set
2047
+ ? options.modelKeysToRemove
2048
+ : new Set();
2049
+
2050
+ const result = {};
2051
+
2052
+ // 1. Start with the user's non-managed keys (unknown-to-installer settings).
2053
+ for (const [key, value] of Object.entries(existingSafe)) {
2054
+ if (MANAGED_OPENAI_KEYS.has(key)) continue;
2055
+ if (key === "models") continue; // handled explicitly below
2056
+ result[key] = value;
2057
+ }
2058
+
2059
+ // 2. Apply template-managed keys. Installer is source of truth for these.
2060
+ for (const [key, value] of Object.entries(templateSafe)) {
2061
+ if (key === "models") continue; // handled explicitly below
2062
+ result[key] = value;
2063
+ }
2064
+
2065
+ // 3. Merge `models` by id: template wins on collision, user-added ids survive.
2066
+ const existingModels = isPlainObject(existingSafe.models) ? existingSafe.models : {};
2067
+ const templateModels = isPlainObject(templateSafe.models) ? templateSafe.models : {};
2068
+ const prunedExistingModels = Object.fromEntries(
2069
+ Object.entries(existingModels).filter(([key]) => !modelKeysToRemove.has(key)),
2070
+ );
2071
+ const mergedModels = { ...prunedExistingModels, ...templateModels };
2072
+ if (Object.keys(mergedModels).length > 0) {
2073
+ result.models = mergedModels;
2074
+ }
2075
+
2076
+ return result;
2077
+ }
2078
+
2079
+ // Naive line-by-line diff for displaying config changes in dry-run. Good enough
2080
+ // for eyeballing; not intended to be parsed or round-tripped.
2081
+ function formatConfigDiff(existingConfig, nextConfig) {
2082
+ const oldText = existingConfig === undefined ? "" : formatJson(existingConfig);
2083
+ const newText = formatJson(nextConfig);
2084
+ if (oldText === newText) {
2085
+ return "(no changes)";
2086
+ }
2087
+ const lines = [];
2088
+ lines.push("--- existing");
2089
+ lines.push("+++ proposed");
2090
+ if (existingConfig === undefined) {
2091
+ lines.push("- (no existing config)");
2092
+ } else {
2093
+ for (const line of oldText.split("\n")) {
2094
+ lines.push(`- ${line}`);
2095
+ }
2096
+ }
2097
+ for (const line of newText.split("\n")) {
2098
+ lines.push(`+ ${line}`);
2099
+ }
2100
+ return lines.join("\n");
2101
+ }
2102
+
2103
+ function formatRedactedConfigDiff(existingConfig, nextConfig) {
2104
+ const missing = Symbol("missing");
2105
+ const changes = [];
2106
+ const visit = (existing, next, path) => {
2107
+ if (existing === missing) {
2108
+ changes.push(`+ ${path}`);
2109
+ return;
2110
+ }
2111
+ if (next === missing) {
2112
+ changes.push(`- ${path}`);
2113
+ return;
2114
+ }
2115
+ if (Object.is(existing, next)) return;
2116
+
2117
+ if (Array.isArray(existing) && Array.isArray(next)) {
2118
+ const length = Math.max(existing.length, next.length);
2119
+ for (let index = 0; index < length; index += 1) {
2120
+ visit(
2121
+ index < existing.length ? existing[index] : missing,
2122
+ index < next.length ? next[index] : missing,
2123
+ `${path}[${index}]`,
2124
+ );
2125
+ }
2126
+ return;
2127
+ }
2128
+
2129
+ if (isPlainObject(existing) && isPlainObject(next)) {
2130
+ const keys = new Set([...Object.keys(existing), ...Object.keys(next)]);
2131
+ for (const key of keys) {
2132
+ visit(
2133
+ Object.hasOwn(existing, key) ? existing[key] : missing,
2134
+ Object.hasOwn(next, key) ? next[key] : missing,
2135
+ `${path}.${key}`,
2136
+ );
2137
+ }
2138
+ return;
2139
+ }
2140
+
2141
+ changes.push(`~ ${path}`);
2142
+ };
2143
+
2144
+ visit(existingConfig === undefined ? missing : existingConfig, nextConfig, "$");
2145
+ return changes.length > 0 ? changes.join("\n") : "(no changes)";
2146
+ }
2147
+
2148
+ function mergeFullTemplate(modernTemplate, legacyTemplate) {
2149
+ const modernModels = modernTemplate.provider?.openai?.models ?? {};
2150
+ const legacyModels = legacyTemplate.provider?.openai?.models ?? {};
2151
+ const overlappingKeys = Object.keys(modernModels).filter((key) => Object.hasOwn(legacyModels, key));
2152
+
2153
+ if (overlappingKeys.length > 0) {
2154
+ throw new Error(`Full config template collision for model keys: ${overlappingKeys.join(", ")}`);
2155
+ }
2156
+
2157
+ return {
2158
+ ...modernTemplate,
2159
+ provider: {
2160
+ ...(modernTemplate.provider ?? {}),
2161
+ openai: {
2162
+ ...(modernTemplate.provider?.openai ?? {}),
2163
+ models: {
2164
+ ...modernModels,
2165
+ ...legacyModels,
2166
+ },
2167
+ },
2168
+ },
2169
+ };
2170
+ }
2171
+
2172
+ function getTemplateModelKeys(template) {
2173
+ return new Set(Object.keys(template.provider?.openai?.models ?? {}));
2174
+ }
2175
+
2176
+ async function readJson(filePath) {
2177
+ const content = await readFile(filePath, "utf-8");
2178
+ return JSON.parse(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content);
2179
+ }
2180
+
2181
+ async function renameWithWindowsRetry(sourcePath, destinationPath) {
2182
+ let lastError = null;
2183
+
2184
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
2185
+ try {
2186
+ await rename(sourcePath, destinationPath);
2187
+ return;
2188
+ } catch (error) {
2189
+ if (isWindowsLockError(error)) {
2190
+ lastError = error;
2191
+ await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
2192
+ continue;
2193
+ }
2194
+ throw error;
2195
+ }
2196
+ }
2197
+
2198
+ if (lastError) {
2199
+ throw lastError;
2200
+ }
2201
+ }
2202
+
2203
+ async function removeWithWindowsRetry(path, options) {
2204
+ let lastError = null;
2205
+
2206
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
2207
+ try {
2208
+ await rm(path, options);
2209
+ return;
2210
+ } catch (error) {
2211
+ if (isWindowsLockError(error)) {
2212
+ lastError = error;
2213
+ await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
2214
+ continue;
2215
+ }
2216
+ throw error;
2217
+ }
2218
+ }
2219
+
2220
+ if (lastError) {
2221
+ throw lastError;
2222
+ }
2223
+ }
2224
+
2225
+ async function writeFileAtomic(filePath, content) {
2226
+ const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
2227
+ const tempPath = `${filePath}.${uniqueSuffix}.tmp`;
2228
+
2229
+ try {
2230
+ await mkdir(dirname(filePath), { recursive: true });
2231
+ await writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 });
2232
+ await renameWithWindowsRetry(tempPath, filePath);
2233
+ } catch (error) {
2234
+ await rm(tempPath, { force: true }).catch(() => {});
2235
+ throw error;
2236
+ }
2237
+ }
2238
+
2239
+ async function loadTemplate(mode, paths) {
2240
+ if (mode === "modern") {
2241
+ return readJson(paths.modernTemplatePath);
2242
+ }
2243
+ if (mode === "legacy") {
2244
+ return readJson(paths.legacyTemplatePath);
2245
+ }
2246
+
2247
+ const [modernTemplate, legacyTemplate] = await Promise.all([
2248
+ readJson(paths.modernTemplatePath),
2249
+ readJson(paths.legacyTemplatePath),
2250
+ ]);
2251
+
2252
+ return mergeFullTemplate(modernTemplate, legacyTemplate);
2253
+ }
2254
+
2255
+ async function copyFileWithWindowsRetry(sourcePath, destinationPath) {
2256
+ let lastError = null;
2257
+
2258
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
2259
+ try {
2260
+ await copyFile(sourcePath, destinationPath);
2261
+ return;
2262
+ } catch (error) {
2263
+ if (isWindowsLockError(error)) {
2264
+ lastError = error;
2265
+ await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
2266
+ continue;
2267
+ }
2268
+ throw error;
2269
+ }
2270
+ }
2271
+
2272
+ if (lastError) {
2273
+ throw lastError;
2274
+ }
2275
+ }
2276
+
2277
+ async function backupConfig(sourcePath, dryRun) {
2278
+ const timestamp = new Date()
2279
+ .toISOString()
2280
+ .replace(/[:.]/g, "-")
2281
+ .replace("T", "_")
2282
+ .replace("Z", "");
2283
+ const backupPath = `${sourcePath}.bak-${timestamp}`;
2284
+ if (!dryRun) {
2285
+ await copyFileWithWindowsRetry(sourcePath, backupPath);
2286
+ }
2287
+ return backupPath;
2288
+ }
2289
+
2290
+ async function removePluginFromCachePackage(paths, dryRun) {
2291
+ if (!existsSync(paths.cachePackageJson)) {
2292
+ return;
2293
+ }
2294
+ if (!isEvictableCachePath(paths.cachePackageJson, paths.cacheDir)) {
2295
+ log(`Warning: refusing to update ${paths.cachePackageJson}: it does not resolve inside the OpenCode cache.`);
2296
+ return;
2297
+ }
2298
+
2299
+ let cacheData;
2300
+ try {
2301
+ cacheData = await readJson(paths.cachePackageJson);
2302
+ } catch (error) {
2303
+ log(`Warning: Could not parse ${paths.cachePackageJson} (${formatErrorForLog(error)}). Skipping.`);
2304
+ return;
2305
+ }
2306
+
2307
+ const sections = [
2308
+ "dependencies",
2309
+ "devDependencies",
2310
+ "peerDependencies",
2311
+ "optionalDependencies",
2312
+ ];
2313
+
2314
+ let changed = false;
2315
+ for (const section of sections) {
2316
+ const deps = cacheData?.[section];
2317
+ if (deps && typeof deps === "object") {
2318
+ for (const name of getManagedPackageNames()) {
2319
+ if (name in deps) {
2320
+ delete deps[name];
2321
+ changed = true;
2322
+ }
2323
+ }
2324
+ }
2325
+ }
2326
+
2327
+ if (!changed) {
2328
+ return;
2329
+ }
2330
+
2331
+ if (dryRun) {
2332
+ log(`[dry-run] Would update ${paths.cachePackageJson} to remove ${getManagedPackageNames().join(", ")}`);
2333
+ return;
2334
+ }
2335
+
2336
+ await writeFileAtomic(paths.cachePackageJson, formatJson(cacheData));
2337
+ }
2338
+
2339
+ /**
2340
+ * Mirror of `isEvictableCachePath` in lib/auto-update-checker.ts. A recursive
2341
+ * delete must never act on a path that only spells like cache: the cache root
2342
+ * itself must not resolve through a symlink (`~/.cache/opencode -> ~` would
2343
+ * otherwise call the whole home directory "inside the cache"), and the
2344
+ * resolved target must stay inside the resolved root.
2345
+ */
2346
+ function isEvictableCachePath(cachePath, cacheRoot) {
2347
+ const absolutePath = resolve(cachePath);
2348
+ const absoluteRoot = resolve(cacheRoot);
2349
+ if (!isInsideDirectory(absolutePath, absoluteRoot, process.platform)) return false;
2350
+ try {
2351
+ const realRoot = realpathSync(absoluteRoot);
2352
+ const rootIsSymlinked = process.platform === "win32"
2353
+ ? realRoot.toLowerCase() !== absoluteRoot.toLowerCase()
2354
+ : realRoot !== absoluteRoot;
2355
+ if (rootIsSymlinked) return false;
2356
+ return isInsideDirectory(realpathSync(absolutePath), realRoot, process.platform);
2357
+ } catch {
2358
+ return false;
2359
+ }
2360
+ }
2361
+
2362
+ async function clearCache(paths, dryRun, skipCacheClear) {
2363
+ if (skipCacheClear) {
2364
+ log("Skipping cache clear (--no-cache-clear).");
2365
+ await removePluginFromCachePackage(paths, dryRun);
2366
+ return;
2367
+ }
2368
+
2369
+ const cacheTargets = [
2370
+ ...paths.cacheNodeModulesPaths,
2371
+ ...paths.cachePackagePaths,
2372
+ paths.cacheBunLock,
2373
+ ];
2374
+
2375
+ if (dryRun) {
2376
+ for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) {
2377
+ log(`[dry-run] Would remove ${cacheNodeModulesPath}`);
2378
+ }
2379
+ for (const cachePackagePath of paths.cachePackagePaths) {
2380
+ log(`[dry-run] Would remove ${cachePackagePath}`);
2381
+ }
2382
+ log(`[dry-run] Would remove ${paths.cacheBunLock}`);
2383
+ } else {
2384
+ for (const cacheTarget of cacheTargets) {
2385
+ if (!existsSync(cacheTarget)) continue;
2386
+ if (!isEvictableCachePath(cacheTarget, paths.cacheDir)) {
2387
+ log(`Warning: refusing to remove ${cacheTarget}: it does not resolve inside the OpenCode cache.`);
2388
+ continue;
2389
+ }
2390
+ await removeWithWindowsRetry(cacheTarget, {
2391
+ recursive: cacheTarget !== paths.cacheBunLock,
2392
+ force: true,
2393
+ });
2394
+ }
2395
+ }
2396
+
2397
+ await removePluginFromCachePackage(paths, dryRun);
2398
+ }
2399
+
2400
+ /** Route V2 installs without rewriting V1 entries or parallel JSONC config. */
2401
+ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
2402
+ const split = splitCommandArgv(argv);
2403
+ if (split.kind === "standalone") {
2404
+ return runStandaloneCommand(split.command, split.argv, options);
2405
+ }
2406
+ if (split.kind === "unknown") {
2407
+ printHelp();
2408
+ throw new Error(`Unknown command: ${split.command}`);
2409
+ }
2410
+ const { env = process.env } = options;
2411
+ const paths = buildPaths(resolveHomeDirectory(env));
2412
+ if (split.kind === "update") {
2413
+ const parsedUpdate = parseUpdateArgs(split.argv);
2414
+ if (parsedUpdate.wantsHelp) {
2415
+ printHelp();
2416
+ return { exitCode: 0, action: "help" };
2417
+ }
2418
+ await clearCache(paths, parsedUpdate.dryRun, false);
2419
+ log(`\n${parsedUpdate.dryRun ? "Dry run complete." : "Cache cleared."} Restart OpenCode to install the latest plugin.`);
2420
+ return {
2421
+ exitCode: 0,
2422
+ action: "update",
2423
+ dryRun: Boolean(parsedUpdate.dryRun),
2424
+ };
2425
+ }
2426
+ const parsed = parseCliArgs(split.argv);
2427
+ if (parsed.wantsHelp) {
2428
+ printHelp();
2429
+ return { exitCode: 0, action: "help" };
2430
+ }
2431
+
2432
+ const { configMode, dryRun, skipCacheClear, pluginOnly } = parsed;
2433
+ if (parsed.v2) {
2434
+ if (existsSync(paths.jsoncConfigPath)) {
2435
+ throw new Error(`OpenCode config exists at ${paths.jsoncConfigPath}; edit its plugins list directly instead of writing a second config file.`);
2436
+ }
2437
+ const existing = existsSync(paths.configPath) ? await readJson(paths.configPath) : {};
2438
+ if (!isPlainObject(existing)) throw new Error("OpenCode config root must be an object");
2439
+ if (Array.isArray(existing.plugin) && existing.plugin.length > 0) {
2440
+ throw new Error("OpenCode V1 plugin entries are present. Use a separate V2 config or migrate them manually; --v2 will not remove your V1 registration.");
2441
+ }
2442
+ const next = { ...existing, plugins: normalizePluginList(existing.plugins, log, {
2443
+ baseDirectory: paths.configDir, cacheDirectory: paths.cacheDir,
2444
+ }) };
2445
+ next.$schema ??= "https://opencode.ai/config.json";
2446
+ if (dryRun) log(`[dry-run] Would register V2 plugin in ${paths.configPath}`);
2447
+ else if (formatJson(existing) !== formatJson(next)) {
2448
+ if (existsSync(paths.configPath)) await backupConfig(paths.configPath, false);
2449
+ await writeFileAtomic(paths.configPath, formatJson(next));
2450
+ }
2451
+ log(dryRun ? "V2 registration dry run complete." : "V2 plugin registered. Restart the OpenCode service to load it.");
2452
+ return { exitCode: 0, action: "install", dryRun: Boolean(dryRun), configMode: "v2" };
2453
+ }
2454
+ const effectiveConfigMode = pluginOnly ? "plugin-only" : configMode;
2455
+ const requiredTemplatePaths = pluginOnly
2456
+ ? []
2457
+ : configMode === "modern"
2458
+ ? [paths.modernTemplatePath]
2459
+ : configMode === "legacy"
2460
+ ? [paths.legacyTemplatePath]
2461
+ : [paths.modernTemplatePath, paths.legacyTemplatePath];
2462
+
2463
+ for (const templatePath of requiredTemplatePaths) {
2464
+ if (!existsSync(templatePath)) {
2465
+ throw new Error(`Config template not found at ${templatePath}`);
2466
+ }
2467
+ }
2468
+
2469
+ const template = pluginOnly
2470
+ ? { $schema: "https://opencode.ai/config.json", plugin: [PACKAGE_NAME] }
2471
+ : await loadTemplate(configMode, paths);
2472
+ template.plugin = [PACKAGE_NAME];
2473
+ const modelKeysToRemove = new Set(STALE_MANAGED_MODEL_KEYS);
2474
+ if (!pluginOnly && configMode === "modern") {
2475
+ for (const key of getTemplateModelKeys(await readJson(paths.legacyTemplatePath))) {
2476
+ modelKeysToRemove.add(key);
2477
+ }
2478
+ }
2479
+ if (!pluginOnly && configMode === "legacy") {
2480
+ for (const key of getTemplateModelKeys(await readJson(paths.modernTemplatePath))) {
2481
+ modelKeysToRemove.add(key);
2482
+ }
2483
+ }
2484
+
2485
+ let existingConfig;
2486
+ if (existsSync(paths.configPath)) {
2487
+ try {
2488
+ const existing = await readJson(paths.configPath);
2489
+ if (!isPlainObject(existing)) {
2490
+ throw new Error("config root must be a JSON object");
2491
+ }
2492
+ existingConfig = existing;
2493
+ } catch (error) {
2494
+ if (pluginOnly) {
2495
+ throw new Error(
2496
+ `Could not parse existing config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
2497
+ );
2498
+ }
2499
+ log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`);
2500
+ existingConfig = undefined;
2501
+ }
2502
+ } else {
2503
+ log("No existing config found. Creating new global config.");
2504
+ }
2505
+
2506
+ let existingTuiConfig;
2507
+ if (existsSync(paths.tuiConfigPath)) {
2508
+ try {
2509
+ const existing = await readJson(paths.tuiConfigPath);
2510
+ if (!isPlainObject(existing)) {
2511
+ throw new Error("TUI config root must be a JSON object");
2512
+ }
2513
+ existingTuiConfig = existing;
2514
+ } catch (error) {
2515
+ if (pluginOnly) {
2516
+ throw new Error(
2517
+ `Could not parse existing TUI config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
2518
+ );
2519
+ }
2520
+ log(`Warning: Could not parse existing TUI config (${formatErrorForLog(error)}). Replacing with minimal TUI config.`);
2521
+ existingTuiConfig = undefined;
2522
+ }
2523
+ } else {
2524
+ log("No existing TUI config found. Creating new global TUI config.");
2525
+ }
2526
+
2527
+ // A checkout of this package registered in either file already loads the
2528
+ // plugin, so the published name must not be written beside it anywhere -
2529
+ // otherwise the checkout in opencode.json and the published package in
2530
+ // tui.json both load.
2531
+ const pluginListOptions = {
2532
+ baseDirectory: paths.configDir,
2533
+ cacheDirectory: paths.cacheDir,
2534
+ };
2535
+ const checkoutRegistered = [existingConfig?.plugin, existingTuiConfig?.plugin]
2536
+ .flatMap((list) => (Array.isArray(list) ? list : []))
2537
+ .some((entry) => {
2538
+ const classification = classifyPluginEntry(entry, pluginListOptions);
2539
+ return (
2540
+ classification.kind === LOCAL_CHECKOUT_ENTRY &&
2541
+ classification.name === PACKAGE_NAME
2542
+ );
2543
+ });
2544
+ const normalizeOptions = { ...pluginListOptions, checkoutRegistered };
2545
+
2546
+ let nextConfig;
2547
+ if (existingConfig !== undefined) {
2548
+ const merged = { ...existingConfig };
2549
+ merged.plugin = normalizePluginList(existingConfig.plugin, log, normalizeOptions);
2550
+ if (!pluginOnly) {
2551
+ const provider = (existingConfig.provider && typeof existingConfig.provider === "object")
2552
+ ? { ...existingConfig.provider }
2553
+ : {};
2554
+ provider.openai = mergeOpenaiProvider(existingConfig.provider?.openai, template.provider?.openai, {
2555
+ modelKeysToRemove,
2556
+ });
2557
+ merged.provider = provider;
2558
+ }
2559
+ nextConfig = merged;
2560
+ } else {
2561
+ nextConfig = pluginOnly
2562
+ ? { $schema: template.$schema, plugin: [PACKAGE_NAME] }
2563
+ : template;
2564
+ nextConfig.plugin = normalizePluginList(nextConfig.plugin, log, normalizeOptions);
2565
+ }
2566
+
2567
+ const nextTuiConfig = mergeTuiConfig(existingTuiConfig, log, normalizeOptions);
2568
+
2569
+ const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath, {
2570
+ baseDirectory: paths.configDir,
2571
+ cacheDirectory: paths.cacheDir,
2572
+ });
2573
+ if (unregisteredCheckout) {
2574
+ log(
2575
+ `Note: this plugin last loaded from a checkout at ${unregisteredCheckout.root} on ${unregisteredCheckout.lastSeen}, ` +
2576
+ `which ${paths.configPath} does not register. Point the plugin entry back at that path if OpenCode should keep loading your own build.`,
2577
+ );
2578
+ }
2579
+
2580
+ const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig);
2581
+ const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig);
2582
+ let wrote = false;
2583
+ if (dryRun) {
2584
+ log(`[dry-run] ${configChanged ? "Would write" : "Would leave unchanged"} ${paths.configPath} using ${effectiveConfigMode} config`);
2585
+ log(`[dry-run] Diff for ${paths.configPath}:`);
2586
+ log(formatRedactedConfigDiff(existingConfig, nextConfig));
2587
+ log(`[dry-run] ${tuiConfigChanged ? "Would write" : "Would leave unchanged"} ${paths.tuiConfigPath} with the TUI status plugin`);
2588
+ log(`[dry-run] Diff for ${paths.tuiConfigPath}:`);
2589
+ log(formatRedactedConfigDiff(existingTuiConfig, nextTuiConfig));
2590
+ } else {
2591
+ if (configChanged) {
2592
+ if (existsSync(paths.configPath)) {
2593
+ const backupPath = await backupConfig(paths.configPath, false);
2594
+ log(`Backup created: ${backupPath}`);
2595
+ }
2596
+ await writeFileAtomic(paths.configPath, formatJson(nextConfig));
2597
+ wrote = true;
2598
+ log(`Wrote ${paths.configPath} (${effectiveConfigMode} config)`);
2599
+ } else {
2600
+ log(`Left ${paths.configPath} unchanged`);
2601
+ }
2602
+ if (tuiConfigChanged) {
2603
+ if (existsSync(paths.tuiConfigPath)) {
2604
+ const backupPath = await backupConfig(paths.tuiConfigPath, false);
2605
+ log(`Backup created: ${backupPath}`);
2606
+ }
2607
+ await writeFileAtomic(paths.tuiConfigPath, formatJson(nextTuiConfig));
2608
+ wrote = true;
2609
+ log(`Wrote ${paths.tuiConfigPath} (TUI status plugin)`);
2610
+ } else {
2611
+ log(`Left ${paths.tuiConfigPath} unchanged`);
2612
+ }
2613
+ }
2614
+
2615
+ await clearCache(paths, dryRun, skipCacheClear);
2616
+
2617
+ log("\nDone. Restart OpenCode to (re)install the plugin.");
2618
+ log("Example: opencode");
2619
+ if (!pluginOnly && configMode === "modern") {
2620
+ log("Note: Modern config intentionally shows 10 base OAuth model entries; use the variant picker for reasoning presets.");
2621
+ }
2622
+ if (!pluginOnly && configMode === "legacy") {
2623
+ log("Note: Legacy config writes 53 explicit preset entries and is also safe for older OpenCode versions.");
2624
+ }
2625
+ if (!pluginOnly && configMode === "full") {
2626
+ log("Note: Full config installs both compact base models and explicit preset entries for direct selector IDs.");
2627
+ }
2628
+
2629
+ return {
2630
+ exitCode: 0,
2631
+ action: "install",
2632
+ configMode: effectiveConfigMode,
2633
+ pluginOnly,
2634
+ configPath: paths.configPath,
2635
+ tuiConfigPath: paths.tuiConfigPath,
2636
+ dryRun: Boolean(dryRun),
2637
+ wrote,
2638
+ };
2639
+ }
2640
+
2641
+ export const __test = {
2642
+ ORIGIN_HISTORY_FILE_NAME,
2643
+ buildPaths,
2644
+ backupConfig,
2645
+ classifyPluginEntry,
2646
+ copyFileWithWindowsRetry,
2647
+ findUnregisteredLocalCheckout,
2648
+ formatConfigDiff,
2649
+ formatRedactedConfigDiff,
2650
+ mergeFullTemplate,
2651
+ mergeOpenaiProvider,
2652
+ mergeTuiConfig,
2653
+ normalizePluginList,
2654
+ parseCliArgs,
2655
+ removeWithWindowsRetry,
2656
+ runStandaloneCommand,
2657
+ splitCommandArgv,
2658
+ writeFileAtomic,
2659
+ renameWithWindowsRetry,
2660
+ resolveHomeDirectory,
2661
+ };