oc-codex-multi-auth 6.21.0 → 6.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/README.md +168 -10
  2. package/config/README.md +53 -33
  3. package/config/opencode-legacy.json +129 -279
  4. package/config/opencode-modern.json +43 -132
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +293 -57
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/account-display.d.ts +46 -0
  9. package/dist/lib/account-display.d.ts.map +1 -1
  10. package/dist/lib/account-display.js +260 -0
  11. package/dist/lib/account-display.js.map +1 -1
  12. package/dist/lib/accounts/warm-request.d.ts.map +1 -1
  13. package/dist/lib/accounts/warm-request.js +9 -11
  14. package/dist/lib/accounts/warm-request.js.map +1 -1
  15. package/dist/lib/accounts.d.ts +4 -0
  16. package/dist/lib/accounts.d.ts.map +1 -1
  17. package/dist/lib/accounts.js +20 -15
  18. package/dist/lib/accounts.js.map +1 -1
  19. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  20. package/dist/lib/auth/login-runner.js +79 -2
  21. package/dist/lib/auth/login-runner.js.map +1 -1
  22. package/dist/lib/auto-update-checker.d.ts +17 -1
  23. package/dist/lib/auto-update-checker.d.ts.map +1 -1
  24. package/dist/lib/auto-update-checker.js +49 -3
  25. package/dist/lib/auto-update-checker.js.map +1 -1
  26. package/dist/lib/cli.d.ts +1 -0
  27. package/dist/lib/cli.d.ts.map +1 -1
  28. package/dist/lib/cli.js +9 -3
  29. package/dist/lib/cli.js.map +1 -1
  30. package/dist/lib/codex-usage.d.ts +4 -3
  31. package/dist/lib/codex-usage.d.ts.map +1 -1
  32. package/dist/lib/codex-usage.js +13 -11
  33. package/dist/lib/codex-usage.js.map +1 -1
  34. package/dist/lib/config.d.ts +75 -0
  35. package/dist/lib/config.d.ts.map +1 -1
  36. package/dist/lib/config.js +152 -1
  37. package/dist/lib/config.js.map +1 -1
  38. package/dist/lib/constants.d.ts +13 -0
  39. package/dist/lib/constants.d.ts.map +1 -1
  40. package/dist/lib/constants.js +13 -0
  41. package/dist/lib/constants.js.map +1 -1
  42. package/dist/lib/plan-allotment.d.ts +72 -0
  43. package/dist/lib/plan-allotment.d.ts.map +1 -0
  44. package/dist/lib/plan-allotment.js +142 -0
  45. package/dist/lib/plan-allotment.js.map +1 -0
  46. package/dist/lib/plugin-origin.d.ts +77 -0
  47. package/dist/lib/plugin-origin.d.ts.map +1 -0
  48. package/dist/lib/plugin-origin.js +301 -0
  49. package/dist/lib/plugin-origin.js.map +1 -0
  50. package/dist/lib/prompts/codex.d.ts +12 -2
  51. package/dist/lib/prompts/codex.d.ts.map +1 -1
  52. package/dist/lib/prompts/codex.js +47 -16
  53. package/dist/lib/prompts/codex.js.map +1 -1
  54. package/dist/lib/quota-display.d.ts +40 -0
  55. package/dist/lib/quota-display.d.ts.map +1 -0
  56. package/dist/lib/quota-display.js +45 -0
  57. package/dist/lib/quota-display.js.map +1 -0
  58. package/dist/lib/quota-notifications.d.ts +2 -1
  59. package/dist/lib/quota-notifications.d.ts.map +1 -1
  60. package/dist/lib/quota-notifications.js +34 -14
  61. package/dist/lib/quota-notifications.js.map +1 -1
  62. package/dist/lib/quota-overview.d.ts +253 -0
  63. package/dist/lib/quota-overview.d.ts.map +1 -0
  64. package/dist/lib/quota-overview.js +707 -0
  65. package/dist/lib/quota-overview.js.map +1 -0
  66. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  67. package/dist/lib/request/fetch-helpers.js +84 -50
  68. package/dist/lib/request/fetch-helpers.js.map +1 -1
  69. package/dist/lib/request/helpers/model-map.d.ts +10 -0
  70. package/dist/lib/request/helpers/model-map.d.ts.map +1 -1
  71. package/dist/lib/request/helpers/model-map.js +39 -11
  72. package/dist/lib/request/helpers/model-map.js.map +1 -1
  73. package/dist/lib/request/helpers/responses-lite.d.ts.map +1 -1
  74. package/dist/lib/request/helpers/responses-lite.js +4 -1
  75. package/dist/lib/request/helpers/responses-lite.js.map +1 -1
  76. package/dist/lib/request/request-transformer.d.ts.map +1 -1
  77. package/dist/lib/request/request-transformer.js +37 -15
  78. package/dist/lib/request/request-transformer.js.map +1 -1
  79. package/dist/lib/request/retry-budget.d.ts +26 -0
  80. package/dist/lib/request/retry-budget.d.ts.map +1 -1
  81. package/dist/lib/request/retry-budget.js +43 -0
  82. package/dist/lib/request/retry-budget.js.map +1 -1
  83. package/dist/lib/schemas.d.ts +73 -0
  84. package/dist/lib/schemas.d.ts.map +1 -1
  85. package/dist/lib/schemas.js +53 -0
  86. package/dist/lib/schemas.js.map +1 -1
  87. package/dist/lib/storage/backup.d.ts +33 -0
  88. package/dist/lib/storage/backup.d.ts.map +1 -1
  89. package/dist/lib/storage/backup.js +43 -7
  90. package/dist/lib/storage/backup.js.map +1 -1
  91. package/dist/lib/storage/credential-snapshots.d.ts +78 -0
  92. package/dist/lib/storage/credential-snapshots.d.ts.map +1 -0
  93. package/dist/lib/storage/credential-snapshots.js +302 -0
  94. package/dist/lib/storage/credential-snapshots.js.map +1 -0
  95. package/dist/lib/storage/export-import.d.ts.map +1 -1
  96. package/dist/lib/storage/export-import.js +7 -0
  97. package/dist/lib/storage/export-import.js.map +1 -1
  98. package/dist/lib/storage/flagged.d.ts.map +1 -1
  99. package/dist/lib/storage/flagged.js +23 -1
  100. package/dist/lib/storage/flagged.js.map +1 -1
  101. package/dist/lib/storage/load-save.d.ts +4 -0
  102. package/dist/lib/storage/load-save.d.ts.map +1 -1
  103. package/dist/lib/storage/load-save.js +50 -0
  104. package/dist/lib/storage/load-save.js.map +1 -1
  105. package/dist/lib/storage/paths.d.ts +1 -0
  106. package/dist/lib/storage/paths.d.ts.map +1 -1
  107. package/dist/lib/storage/paths.js +1 -1
  108. package/dist/lib/storage/paths.js.map +1 -1
  109. package/dist/lib/storage/test-home-guard.d.ts +35 -0
  110. package/dist/lib/storage/test-home-guard.d.ts.map +1 -0
  111. package/dist/lib/storage/test-home-guard.js +59 -0
  112. package/dist/lib/storage/test-home-guard.js.map +1 -0
  113. package/dist/lib/tools/codex-dashboard.d.ts.map +1 -1
  114. package/dist/lib/tools/codex-dashboard.js +3 -2
  115. package/dist/lib/tools/codex-dashboard.js.map +1 -1
  116. package/dist/lib/tools/codex-doctor.d.ts.map +1 -1
  117. package/dist/lib/tools/codex-doctor.js +16 -0
  118. package/dist/lib/tools/codex-doctor.js.map +1 -1
  119. package/dist/lib/tools/codex-health.d.ts.map +1 -1
  120. package/dist/lib/tools/codex-health.js +10 -2
  121. package/dist/lib/tools/codex-health.js.map +1 -1
  122. package/dist/lib/tools/codex-label.d.ts.map +1 -1
  123. package/dist/lib/tools/codex-label.js +1 -0
  124. package/dist/lib/tools/codex-label.js.map +1 -1
  125. package/dist/lib/tools/codex-limits.d.ts.map +1 -1
  126. package/dist/lib/tools/codex-limits.js +13 -9
  127. package/dist/lib/tools/codex-limits.js.map +1 -1
  128. package/dist/lib/tools/codex-list.d.ts.map +1 -1
  129. package/dist/lib/tools/codex-list.js +28 -3
  130. package/dist/lib/tools/codex-list.js.map +1 -1
  131. package/dist/lib/tools/codex-note.d.ts.map +1 -1
  132. package/dist/lib/tools/codex-note.js +4 -1
  133. package/dist/lib/tools/codex-note.js.map +1 -1
  134. package/dist/lib/tools/codex-pool.d.ts.map +1 -1
  135. package/dist/lib/tools/codex-pool.js +6 -2
  136. package/dist/lib/tools/codex-pool.js.map +1 -1
  137. package/dist/lib/tools/codex-refresh.d.ts.map +1 -1
  138. package/dist/lib/tools/codex-refresh.js +4 -1
  139. package/dist/lib/tools/codex-refresh.js.map +1 -1
  140. package/dist/lib/tools/codex-remove.d.ts.map +1 -1
  141. package/dist/lib/tools/codex-remove.js +1 -0
  142. package/dist/lib/tools/codex-remove.js.map +1 -1
  143. package/dist/lib/tools/codex-reset.d.ts.map +1 -1
  144. package/dist/lib/tools/codex-reset.js +13 -7
  145. package/dist/lib/tools/codex-reset.js.map +1 -1
  146. package/dist/lib/tools/codex-status.d.ts.map +1 -1
  147. package/dist/lib/tools/codex-status.js +25 -2
  148. package/dist/lib/tools/codex-status.js.map +1 -1
  149. package/dist/lib/tools/codex-switch.d.ts.map +1 -1
  150. package/dist/lib/tools/codex-switch.js +1 -0
  151. package/dist/lib/tools/codex-switch.js.map +1 -1
  152. package/dist/lib/tools/codex-tag.d.ts.map +1 -1
  153. package/dist/lib/tools/codex-tag.js +4 -1
  154. package/dist/lib/tools/codex-tag.js.map +1 -1
  155. package/dist/lib/tools/codex-warm.d.ts.map +1 -1
  156. package/dist/lib/tools/codex-warm.js +1 -0
  157. package/dist/lib/tools/codex-warm.js.map +1 -1
  158. package/dist/lib/tools/index.d.ts +9 -0
  159. package/dist/lib/tools/index.d.ts.map +1 -1
  160. package/dist/lib/tools/index.js.map +1 -1
  161. package/dist/lib/tui-quota-cache.d.ts +35 -0
  162. package/dist/lib/tui-quota-cache.d.ts.map +1 -1
  163. package/dist/lib/tui-quota-cache.js +76 -11
  164. package/dist/lib/tui-quota-cache.js.map +1 -1
  165. package/dist/lib/tui-quota-overview.d.ts +55 -0
  166. package/dist/lib/tui-quota-overview.d.ts.map +1 -0
  167. package/dist/lib/tui-quota-overview.js +209 -0
  168. package/dist/lib/tui-quota-overview.js.map +1 -0
  169. package/dist/lib/tui-status.d.ts +55 -0
  170. package/dist/lib/tui-status.d.ts.map +1 -1
  171. package/dist/lib/tui-status.js +155 -14
  172. package/dist/lib/tui-status.js.map +1 -1
  173. package/dist/lib/ui/auth-menu.d.ts +2 -0
  174. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  175. package/dist/lib/ui/auth-menu.js +10 -6
  176. package/dist/lib/ui/auth-menu.js.map +1 -1
  177. package/dist/tui.d.ts +55 -0
  178. package/dist/tui.d.ts.map +1 -1
  179. package/dist/tui.js +511 -29
  180. package/dist/tui.js.map +1 -1
  181. package/package.json +1 -1
  182. package/scripts/install-oc-codex-multi-auth-core.js +583 -66
@@ -1,17 +1,32 @@
1
- import { existsSync, realpathSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
3
  import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
4
  import { homedir } from "node:os";
4
- import { dirname, join, resolve } from "node:path";
5
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
6
  import { fileURLToPath, pathToFileURL } from "node:url";
6
7
 
7
8
  const PACKAGE_NAME = "oc-codex-multi-auth";
8
9
  const LEGACY_PACKAGE_NAMES = ["oc-chatgpt-multi-auth"];
10
+ const ORIGIN_HISTORY_FILE_NAME = "oc-codex-multi-auth-origin.json";
9
11
  const WINDOWS_RENAME_RETRY_ATTEMPTS = 5;
10
12
  const WINDOWS_RENAME_RETRY_BASE_DELAY_MS = 10;
11
13
  const STALE_MANAGED_MODEL_KEYS = new Set([
12
14
  "gpt-5.2",
13
15
  "gpt-5.3-codex",
14
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}`),
15
30
  ]);
16
31
  const STANDALONE_COMMANDS = new Set(["doctor", "status", "list", "limits", "dashboard", "health", "diag", "warm"]);
17
32
  const INSTALLER_COMMANDS = new Set(["install"]);
@@ -98,9 +113,9 @@ function printHelp() {
98
113
  " - Clears OpenCode plugin cache\n\n" +
99
114
  "Options:\n" +
100
115
  " --plugin-only Register plugins without changing provider.openai\n" +
101
- " --modern Force compact modern config (13 base OAuth models + --variant presets)\n" +
102
- " --full Install compact base models plus 59 explicit selector entries\n" +
103
- " --legacy Force explicit legacy config (59 preset model entries)\n" +
116
+ " --modern Force compact modern config (10 base OAuth models + --variant presets)\n" +
117
+ " --full Install compact base models plus 53 explicit selector entries\n" +
118
+ " --legacy Force explicit legacy config (53 preset model entries)\n" +
104
119
  " --dry-run Show actions without writing\n" +
105
120
  " --no-cache-clear Skip clearing OpenCode cache\n"
106
121
  );
@@ -150,6 +165,7 @@ function buildPaths(homeDir) {
150
165
  ]),
151
166
  cacheBunLock: join(cacheDir, "bun.lock"),
152
167
  cachePackageJson: join(cacheDir, "package.json"),
168
+ originHistoryPath: join(homeDir, ".opencode", ORIGIN_HISTORY_FILE_NAME),
153
169
  modernTemplatePath,
154
170
  legacyTemplatePath,
155
171
  };
@@ -200,47 +216,323 @@ function parseUpdateArgs(argv) {
200
216
  };
201
217
  }
202
218
 
203
- function normalizePluginEntryForMatch(entry) {
204
- const trimmed = entry.trim();
205
- let normalized = trimmed.toLowerCase();
219
+ const MANAGED_PACKAGE_ENTRY = "managed-package";
220
+ const LOCAL_CHECKOUT_ENTRY = "local-checkout";
221
+ const UNRELATED_ENTRY = "unrelated";
222
+ const DECLARED_NAME_LOOKUP_DEPTH = 3;
223
+
224
+ function pluginEntrySpecifier(entry) {
225
+ if (typeof entry === "string") return entry;
226
+ // `[specifier, options]` configures a plugin without changing where it loads from.
227
+ if (Array.isArray(entry) && typeof entry[0] === "string") return entry[0];
228
+ return null;
229
+ }
230
+
231
+ /**
232
+ * Spellings that can only mean a location on disk: absolute paths, `~`,
233
+ * `./`/`../`, Windows drive letters, and UNC shares. Anything else that merely
234
+ * contains a separator (`@scope/name`, git URLs, `npm:` aliases) is ambiguous
235
+ * and counts as a path only when it resolves on this machine.
236
+ */
237
+ function isExplicitPathSpecifier(specifier) {
238
+ return (
239
+ /^[a-zA-Z]:[\\/]/.test(specifier) ||
240
+ /^[\\/]/.test(specifier) ||
241
+ /^~[\\/]/.test(specifier) ||
242
+ /^\.\.?[\\/]/.test(specifier)
243
+ );
244
+ }
245
+
246
+ /**
247
+ * The path an entry names, exactly as the config spells it - and only when the
248
+ * specifier actually is a path. A `/` alone does not make one: registry and
249
+ * URL spellings can end in `oc-codex-multi-auth` without naming this package's
250
+ * checkout, so an ambiguous specifier counts only when it resolves on disk.
251
+ */
252
+ function pluginEntryPath(specifier, baseDirectory) {
253
+ const trimmed = specifier.trim();
254
+ if (!trimmed) return null;
255
+ if (/^file:\/\//i.test(trimmed)) {
256
+ try {
257
+ return fileURLToPath(trimmed);
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+ if (isExplicitPathSpecifier(trimmed)) return trimmed;
263
+ if (!trimmed.includes("/") && !trimmed.includes("\\")) return null;
264
+ const inspectionPath = resolveInspectionPath(trimmed, baseDirectory);
265
+ return inspectionPath && existsSync(inspectionPath) ? trimmed : null;
266
+ }
267
+
268
+ function pluginPathSegments(entryPath) {
269
+ return entryPath.replaceAll("\\", "/").replace(/\/+$/, "").split("/").filter(Boolean);
270
+ }
271
+
272
+ /**
273
+ * Compared as written, without resolving symlinks: this asks whether an entry
274
+ * names something `clearCache` removes, and `clearCache` removes the paths
275
+ * exactly as it spells them - `rm` unlinks a symlink rather than descending
276
+ * into it. Cache eviction resolves symlinks because it decides the opposite
277
+ * question, whether a recursive delete is safe.
278
+ */
279
+ function isInsideDirectory(candidate, directory, platform) {
280
+ const fold = (value) => (platform === "win32" ? value.toLowerCase() : value);
281
+ const relativePath = relative(fold(resolve(directory)), fold(resolve(candidate)));
282
+ return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
283
+ }
284
+
285
+ function isPackageManagerPath(entryPath, options = {}) {
286
+ const { platform = process.platform, cacheDirectory, inspectionPath } = options;
287
+ // Windows reaches one directory under many spellings, so `NODE_MODULES`
288
+ // there is the same package-manager output as `node_modules`. Elsewhere the
289
+ // two are different directories and must stay so.
290
+ const segments = pluginPathSegments(entryPath).map((segment) =>
291
+ platform === "win32" ? segment.toLowerCase() : segment,
292
+ );
293
+ if (
294
+ segments.some(
295
+ (segment, index) =>
296
+ segment === "node_modules" ||
297
+ // OpenCode's plugin cache spells the version into the directory name.
298
+ // A `packages/` directory without one is an ordinary monorepo.
299
+ (segments[index - 1] === "packages" && segment.includes("@")),
300
+ )
301
+ ) {
302
+ return true;
303
+ }
304
+ // The cache is where this installer puts its own copies, and `clearCache`
305
+ // empties it on the same run. Reading spelling alone leaves the cache's
306
+ // unversioned `packages/<name>` looking like somebody's monorepo, so the
307
+ // entry is kept while the directory under it is deleted - a config left
308
+ // pointing at nothing. Whose directory it is settles that; the spelling
309
+ // cannot.
310
+ return Boolean(
311
+ cacheDirectory &&
312
+ inspectionPath &&
313
+ isInsideDirectory(inspectionPath, cacheDirectory, platform),
314
+ );
315
+ }
316
+
317
+ /**
318
+ * Where an entry points, for reading metadata about it only. OpenCode resolves
319
+ * a relative entry against the config file that declares it, so that directory
320
+ * is what makes such a path mean anything; the installer's working directory
321
+ * would name somewhere else entirely. Null when a relative entry arrives with
322
+ * no declaring directory to resolve it against.
323
+ */
324
+ function resolveInspectionPath(entryPath, baseDirectory) {
325
+ if (isAbsolute(entryPath)) return entryPath;
326
+ return baseDirectory ? resolve(baseDirectory, entryPath) : null;
327
+ }
328
+
329
+ /**
330
+ * Last-resort identification for a path that is not present on this machine.
331
+ * Spelling alone never authorizes deleting an entry; it only names the package a
332
+ * missing path was probably meant to point at.
333
+ */
334
+ function managedNameFromPathSpelling(entryPath) {
335
+ const segments = pluginPathSegments(entryPath);
336
+ const last = segments.at(-1) === "dist" ? segments.at(-2) : segments.at(-1);
337
+ if (!last) return null;
338
+ let candidate = last.toLowerCase();
206
339
  try {
207
- normalized = decodeURIComponent(normalized);
340
+ candidate = decodeURIComponent(candidate);
208
341
  } catch {
209
- // Keep the raw lowercased value when a malformed URI escape is present.
342
+ // Keep the raw segment when it carries a malformed escape.
210
343
  }
211
- normalized = normalized.replace(/\\/g, "/").replace(/\/+$/g, "");
212
- if (normalized.endsWith("/dist")) {
213
- normalized = normalized.slice(0, -"/dist".length);
344
+ const versionSuffix = candidate.indexOf("@");
345
+ if (versionSuffix > 0) candidate = candidate.slice(0, versionSuffix);
346
+ return getManagedPackageNames().find((name) => name.toLowerCase() === candidate) ?? null;
347
+ }
348
+
349
+ function readDeclaredPackageName(directoryPath) {
350
+ try {
351
+ const parsed = JSON.parse(readFileSync(join(directoryPath, "package.json"), "utf8"));
352
+ const name = parsed?.name;
353
+ return typeof name === "string" && name.trim() ? name.trim() : null;
354
+ } catch {
355
+ return null;
214
356
  }
215
- return normalized;
216
357
  }
217
358
 
218
- function isManagedPluginEntry(entry) {
219
- if (typeof entry !== "string") return false;
220
- const trimmed = entry.trim().toLowerCase();
221
- const normalized = normalizePluginEntryForMatch(entry);
222
- return getManagedPackageNames().some((name) => {
223
- const lowerName = name.toLowerCase();
224
- return trimmed === lowerName ||
225
- trimmed.startsWith(`${lowerName}@`) ||
226
- normalized.endsWith(`/${lowerName}`) ||
227
- normalized.endsWith(`/node_modules/${lowerName}`);
359
+ /** An entry may point at a build output inside the package, so walk upwards. */
360
+ function resolveDeclaredPackageName(entryPath) {
361
+ if (!isAbsolute(entryPath)) return null;
362
+ let current = resolve(entryPath);
363
+ for (let depth = 0; depth <= DECLARED_NAME_LOOKUP_DEPTH; depth += 1) {
364
+ const name = readDeclaredPackageName(current);
365
+ if (name) return name;
366
+ const parent = dirname(current);
367
+ if (parent === current) return null;
368
+ current = parent;
369
+ }
370
+ return null;
371
+ }
372
+
373
+ /**
374
+ * Decides what a plugin entry is, by identity rather than by spelling.
375
+ *
376
+ * The distinction that matters is not which package an entry names but who
377
+ * chose the location. A bare specifier or a path inside `node_modules` is a
378
+ * reference the installer itself produced and may retire. Any other path is
379
+ * somewhere a human deliberately pointed OpenCode - a checkout of this package
380
+ * being developed on, most often - and is never the installer's to remove.
381
+ */
382
+ function classifyPluginEntry(entry, options = {}) {
383
+ const {
384
+ resolveDeclaredName = resolveDeclaredPackageName,
385
+ baseDirectory,
386
+ cacheDirectory,
387
+ platform = process.platform,
388
+ } = options;
389
+ const specifier = pluginEntrySpecifier(entry);
390
+ if (specifier === null) return { kind: UNRELATED_ENTRY, name: null };
391
+
392
+ const entryPath = pluginEntryPath(specifier, baseDirectory);
393
+ if (entryPath === null) {
394
+ const bare = specifier.trim().toLowerCase();
395
+ const name = getManagedPackageNames().find(
396
+ (managed) =>
397
+ bare === managed.toLowerCase() || bare.startsWith(`${managed.toLowerCase()}@`),
398
+ );
399
+ return name
400
+ ? { kind: MANAGED_PACKAGE_ENTRY, name }
401
+ : { kind: UNRELATED_ENTRY, name: null };
402
+ }
403
+
404
+ const inspectionPath = resolveInspectionPath(entryPath, baseDirectory);
405
+ const declaredName = inspectionPath ? resolveDeclaredName(inspectionPath) : null;
406
+ const managedName = declaredName
407
+ ? getManagedPackageNames().find(
408
+ (managed) => managed.toLowerCase() === declaredName.toLowerCase(),
409
+ ) ?? null
410
+ : managedNameFromPathSpelling(entryPath);
411
+
412
+ if (!managedName) return { kind: UNRELATED_ENTRY, name: null };
413
+
414
+ return isPackageManagerPath(entryPath, { platform, cacheDirectory, inspectionPath })
415
+ ? { kind: MANAGED_PACKAGE_ENTRY, name: managedName }
416
+ : {
417
+ kind: LOCAL_CHECKOUT_ENTRY,
418
+ name: managedName,
419
+ path: inspectionPath ?? entryPath,
420
+ resolvesOnDisk: Boolean(inspectionPath && existsSync(inspectionPath)),
421
+ };
422
+ }
423
+
424
+ /**
425
+ * Ensures this plugin is registered exactly once, without changing how an
426
+ * existing registration is spelled. Appending the published package name is the
427
+ * fallback for a config that does not reference the plugin at all, not the
428
+ * canonical form every config is rewritten into.
429
+ */
430
+ function normalizePluginList(list, onNotice, options = {}) {
431
+ const entries = Array.isArray(list)
432
+ ? list.filter((entry) => entry !== null && entry !== undefined && entry !== "")
433
+ : [];
434
+ const classifications = entries.map((entry) => classifyPluginEntry(entry, options));
435
+ // A checkout of this package already IS the registration, so a published
436
+ // entry beside it is a second copy of the same plugin for OpenCode to load.
437
+ // `options.checkoutRegistered` carries the same fact across config files: a
438
+ // checkout registered only in opencode.json still suppresses the published
439
+ // name in tui.json, and vice versa. Only a checkout of the CURRENT package
440
+ // counts: the former name is valid for cleanup, never as the registration
441
+ // the installer exists to ensure.
442
+ const checkoutRegistered = options.checkoutRegistered === true || classifications.some(
443
+ (classification) =>
444
+ classification.kind === LOCAL_CHECKOUT_ENTRY && classification.name === PACKAGE_NAME,
445
+ );
446
+ const kept = [];
447
+ let keptPublishedName = false;
448
+
449
+ entries.forEach((entry, index) => {
450
+ const classification = classifications[index];
451
+
452
+ if (classification.kind === LOCAL_CHECKOUT_ENTRY) {
453
+ kept.push(entry);
454
+ if (classification.resolvesOnDisk === false) {
455
+ onNotice?.(
456
+ `Warning: keeping ${classification.path} registered, but it does not resolve on disk; ` +
457
+ "the plugin may not load until the path exists again.",
458
+ );
459
+ } else {
460
+ onNotice?.(
461
+ `Keeping the local ${classification.name} checkout registered at ${classification.path}`,
462
+ );
463
+ }
464
+ return;
465
+ }
466
+
467
+ if (classification.kind === MANAGED_PACKAGE_ENTRY) {
468
+ // Retire stale duplicates, version pins, renamed packages, and paths
469
+ // into package-manager output; keep one published-name entry in place
470
+ // unless a checkout already covers it.
471
+ const isPublishedName = pluginEntrySpecifier(entry) === PACKAGE_NAME;
472
+ if (isPublishedName && !checkoutRegistered && !keptPublishedName) {
473
+ keptPublishedName = true;
474
+ kept.push(entry);
475
+ }
476
+ return;
477
+ }
478
+
479
+ kept.push(entry);
228
480
  });
481
+
482
+ return checkoutRegistered || keptPublishedName ? kept : [...kept, PACKAGE_NAME];
229
483
  }
230
484
 
231
- function normalizePluginList(list) {
232
- const entries = Array.isArray(list) ? list.filter(Boolean) : [];
233
- const filtered = entries.filter((entry) => !isManagedPluginEntry(entry));
234
- return [...filtered, PACKAGE_NAME];
485
+ function readLocalCheckoutSightings(historyPath) {
486
+ try {
487
+ const parsed = JSON.parse(readFileSync(historyPath, "utf8"));
488
+ const sightings = parsed?.sightings;
489
+ if (!Array.isArray(sightings)) return [];
490
+ return sightings.filter(
491
+ (sighting) =>
492
+ sighting &&
493
+ typeof sighting === "object" &&
494
+ sighting.isLocalCheckout === true &&
495
+ typeof sighting.root === "string" &&
496
+ typeof sighting.lastSeen === "string" &&
497
+ getManagedPackageNames().includes(sighting.name),
498
+ );
499
+ } catch {
500
+ return [];
501
+ }
502
+ }
503
+
504
+ /**
505
+ * A checkout the plugin has run from that the finished config does not
506
+ * register. Reported rather than restored: config history is evidence of what
507
+ * happened, not authority over what the user wants registered now.
508
+ */
509
+ function findUnregisteredLocalCheckout(pluginList, historyPath, options = {}) {
510
+ const entries = Array.isArray(pluginList) ? pluginList : [];
511
+ if (entries.some((entry) => classifyPluginEntry(entry, options).kind === LOCAL_CHECKOUT_ENTRY)) {
512
+ return null;
513
+ }
514
+ const latest = readLocalCheckoutSightings(historyPath)
515
+ .sort((left, right) => (Date.parse(left.lastSeen) || 0) - (Date.parse(right.lastSeen) || 0))
516
+ .at(-1);
517
+ if (!latest) return null;
518
+ // The directory has to still hold the package that was recorded there. A
519
+ // path gets reused - a checkout deleted and something else cloned into its
520
+ // place - and a recorded path that now declares another project would
521
+ // otherwise be offered as somewhere to point OpenCode back at.
522
+ const declaredName = resolveDeclaredPackageName(latest.root);
523
+ if (!declaredName || declaredName.toLowerCase() !== String(latest.name).toLowerCase()) {
524
+ return null;
525
+ }
526
+ return latest;
235
527
  }
236
528
 
237
- function mergeTuiConfig(existingConfig) {
529
+ function mergeTuiConfig(existingConfig, onNotice, options = {}) {
238
530
  const existing = isPlainObject(existingConfig) ? { ...existingConfig } : {};
239
531
  const next = { ...existing };
240
532
  if (typeof next.$schema !== "string" || !next.$schema.trim()) {
241
533
  next.$schema = "https://opencode.ai/tui.json";
242
534
  }
243
- next.plugin = normalizePluginList(existing.plugin);
535
+ next.plugin = normalizePluginList(existing.plugin, onNotice, options);
244
536
  return next;
245
537
  }
246
538
 
@@ -410,24 +702,167 @@ function accountIdSuffix(accountId, includeSensitive) {
410
702
  return accountId.slice(-4);
411
703
  }
412
704
 
705
+ // A member id is what tells two seats of one Business workspace apart, and no
706
+ // fixed-length tail always does it: member ids sharing a six-character tail
707
+ // were observed, and in a real nine-seat pool the ids are 67 characters with
708
+ // no shared tail at all, so growing a tail until it separates them prints most
709
+ // of the id in every row. The renderer below mirrors `resolveSeatRenderer` in
710
+ // lib/account-display.ts - a tail, else one window anchored where the ids first
711
+ // diverge, else short windows at each position where a pair first differs
712
+ // joined by `..`, else a hash prefix, each capped - and the id whole only if
713
+ // none of those separate them, which needs a 128-bit SHA-256 collision. The
714
+ // hash outcome is reachable, and it prints a value that cannot be matched
715
+ // against the id by eye.
716
+ const STANDALONE_SEAT_MAX_LENGTH = 12;
717
+ const STANDALONE_SEAT_HASH_LENGTHS = [8, 12, 16, 24, 32];
718
+ const STANDALONE_SEAT_WINDOW_SEPARATOR = "..";
719
+
720
+ function seatIsDisclosable(accountUserId, includeSensitive) {
721
+ if (!accountUserId) return false;
722
+ return includeSensitive || accountUserId.length >= MASK_MIN_LENGTH;
723
+ }
724
+
725
+ function seatTail(accountUserId, length) {
726
+ return accountUserId.length > length ? accountUserId.slice(-length) : accountUserId;
727
+ }
728
+
729
+ function seatWindow(accountUserId, start, length) {
730
+ if (accountUserId.length <= length) return accountUserId;
731
+ const begin = Math.max(0, Math.min(start, accountUserId.length - length));
732
+ return accountUserId.slice(begin, begin + length);
733
+ }
734
+
735
+ function seatCommonPrefixLength(values) {
736
+ const [first] = values;
737
+ if (first === undefined) return 0;
738
+ let shared = first.length;
739
+ for (const value of values) {
740
+ let index = 0;
741
+ while (index < shared && index < value.length && first[index] === value[index]) {
742
+ index += 1;
743
+ }
744
+ shared = index;
745
+ if (shared === 0) break;
746
+ }
747
+ return shared;
748
+ }
749
+
750
+ function seatFirstDivergence(left, right) {
751
+ const limit = Math.min(left.length, right.length);
752
+ let index = 0;
753
+ while (index < limit && left[index] === right[index]) index += 1;
754
+ return index;
755
+ }
756
+
757
+ // For every pair, the first index at which that pair differs - not every index
758
+ // where the ids disagree, which across a handful of random-looking ids is
759
+ // nearly all of them and localizes nothing.
760
+ function seatDivergenceAnchors(values) {
761
+ const anchors = new Set();
762
+ for (let left = 0; left < values.length; left += 1) {
763
+ for (let right = left + 1; right < values.length; right += 1) {
764
+ anchors.add(seatFirstDivergence(values[left], values[right]));
765
+ }
766
+ }
767
+ return [...anchors].sort((left, right) => left - right);
768
+ }
769
+
770
+ function seatAnchorWindowStarts(anchors, width) {
771
+ const starts = [];
772
+ for (const anchor of anchors) {
773
+ const last = starts[starts.length - 1];
774
+ if (last !== undefined && anchor < last + width) continue;
775
+ starts.push(anchor);
776
+ }
777
+ return starts;
778
+ }
779
+
780
+ function resolveStandaloneSeatRenderer(accountUserIds, includeSensitive) {
781
+ // Starts at the length the mask above allows, so masked output widens only
782
+ // when leaving it short would print a lie.
783
+ const base = includeSensitive ? 6 : 4;
784
+ const distinct = [];
785
+ const seen = new Set();
786
+ for (const accountUserId of accountUserIds) {
787
+ if (!seatIsDisclosable(accountUserId, includeSensitive)) continue;
788
+ if (seen.has(accountUserId)) continue;
789
+ seen.add(accountUserId);
790
+ distinct.push(accountUserId);
791
+ }
792
+ const atBase = (accountUserId) => seatTail(accountUserId, base);
793
+ if (distinct.length <= 1) return atBase;
794
+
795
+ const separates = (render) => new Set(distinct.map(render)).size === distinct.length;
796
+
797
+ for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
798
+ const render = (accountUserId) => seatTail(accountUserId, length);
799
+ if (separates(render)) return render;
800
+ }
801
+ const start = seatCommonPrefixLength(distinct);
802
+ for (let length = base; length <= STANDALONE_SEAT_MAX_LENGTH; length += 1) {
803
+ const render = (accountUserId) => seatWindow(accountUserId, start, length);
804
+ if (separates(render)) return render;
805
+ }
806
+ const anchors = seatDivergenceAnchors(distinct);
807
+ for (let width = 2; width <= STANDALONE_SEAT_MAX_LENGTH; width += 1) {
808
+ const starts = seatAnchorWindowStarts(anchors, width);
809
+ const rendered =
810
+ starts.length * width + (starts.length - 1) * STANDALONE_SEAT_WINDOW_SEPARATOR.length;
811
+ // Skipped, not abandoned: a wider window can span two nearby anchors
812
+ // that needed one window each, so the cost falls as the window count
813
+ // does. Mirrors `resolveSeatRenderer` in lib/account-display.ts, where
814
+ // the measured counter-example is written out.
815
+ if (rendered > STANDALONE_SEAT_MAX_LENGTH) continue;
816
+ const render = (accountUserId) =>
817
+ starts
818
+ .map((windowStart) => accountUserId.slice(windowStart, windowStart + width))
819
+ .join(STANDALONE_SEAT_WINDOW_SEPARATOR);
820
+ if (separates(render)) return render;
821
+ }
822
+ for (const length of STANDALONE_SEAT_HASH_LENGTHS) {
823
+ const render = (accountUserId) => createHash("sha256").update(accountUserId).digest("hex").slice(0, length);
824
+ if (separates(render)) return render;
825
+ }
826
+ return (accountUserId) => accountUserId;
827
+ }
828
+
413
829
  function summarizeStandaloneAccounts(storage, includeSensitive, tag) {
414
830
  const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
415
831
  const normalizedTag = typeof tag === "string" ? tag.trim().toLowerCase() : "";
416
- return accounts
832
+ const entries = accounts
417
833
  .map((account, index) => ({ account, index }))
418
834
  .filter(({ account }) => !normalizedTag ||
419
835
  (Array.isArray(account?.accountTags) &&
420
- account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)))
836
+ account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)));
837
+ const renderSeat = resolveStandaloneSeatRenderer(
838
+ entries.map(({ account }) =>
839
+ (typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "") || undefined,
840
+ ),
841
+ includeSensitive,
842
+ );
843
+ return entries
421
844
  .map(({ account, index }) => {
422
845
  const trimmedId =
423
846
  typeof account?.accountId === "string" ? account.accountId.trim() : "";
424
847
  const accountId = trimmedId || undefined;
848
+ // Members of one Business workspace share `accountId`, so the seat is
849
+ // what tells them apart. It is carried masked next to its suffix for
850
+ // the same reason `accountId` is: so the printed `seat:` discloses no
851
+ // more of an id than the field beside it unless telling two seats
852
+ // apart requires it.
853
+ const trimmedUserId =
854
+ typeof account?.accountUserId === "string" ? account.accountUserId.trim() : "";
855
+ const accountUserId = trimmedUserId || undefined;
425
856
  return {
426
857
  index,
427
858
  label: account?.accountLabel ?? `Account ${index + 1}`,
428
859
  email: maskValue(account?.email, includeSensitive),
429
860
  accountId: maskValue(accountId, includeSensitive),
430
861
  idSuffix: accountIdSuffix(accountId, includeSensitive),
862
+ accountUserId: maskValue(accountUserId, includeSensitive),
863
+ seatSuffix: seatIsDisclosable(accountUserId, includeSensitive)
864
+ ? renderSeat(accountUserId)
865
+ : undefined,
431
866
  accountIdSource: account?.accountIdSource,
432
867
  enabled: account?.enabled !== false,
433
868
  hasRefreshToken: typeof account?.refreshToken === "string" && account.refreshToken.length > 0,
@@ -453,7 +888,11 @@ function printStandaloneResult(command, payload, json) {
453
888
  console.log(`Accounts: ${payload.totalAccounts}`);
454
889
  if (Array.isArray(payload.accounts)) {
455
890
  for (const account of payload.accounts) {
456
- const identity = [account.email, account.idSuffix ? `id:${account.idSuffix}` : undefined]
891
+ const identity = [
892
+ account.email,
893
+ account.idSuffix ? `id:${account.idSuffix}` : undefined,
894
+ account.seatSuffix ? `seat:${account.seatSuffix}` : undefined,
895
+ ]
457
896
  .filter(Boolean)
458
897
  .join(", ");
459
898
  const name = identity ? `${account.label} (${identity})` : account.label;
@@ -505,14 +944,15 @@ async function loadWarmRuntime(env) {
505
944
  }
506
945
 
507
946
  async function loadLimitsRuntime(env) {
508
- const [storageMod, usageMod, shutdownMod, loggerMod] = await loadDistModules(
509
- ["storage.js", "codex-usage.js", "shutdown.js", "logger.js"],
510
- "limits",
511
- );
947
+ const [storageMod, usageMod, shutdownMod, loggerMod, configMod] =
948
+ await loadDistModules(
949
+ ["storage.js", "codex-usage.js", "shutdown.js", "logger.js", "config.js"],
950
+ "limits",
951
+ );
512
952
  // Fetching usage can refresh (and therefore persist) a token, so the same
513
953
  // process-owns-termination rule as `warm` applies.
514
954
  shutdownMod.setShutdownOwnsProcess(true);
515
- return { storageMod, usageMod, shutdownMod, loggerMod };
955
+ return { storageMod, usageMod, shutdownMod, loggerMod, configMod };
516
956
  }
517
957
 
518
958
  export async function runWarmCommand(parsed, options = {}) {
@@ -676,7 +1116,8 @@ export async function runLimitsCommand(parsed, options = {}) {
676
1116
  return { exitCode: 1, action: "limits", storagePath };
677
1117
  }
678
1118
 
679
- const { storageMod, usageMod, loggerMod } = runtime;
1119
+ const { storageMod, usageMod, loggerMod, configMod } = runtime;
1120
+ const quotaDisplay = configMod.getQuotaDisplay(configMod.loadPluginConfig());
680
1121
  // Point dist storage at the resolved accounts file so a refreshed token is
681
1122
  // persisted to the SAME file the rest of the toolchain reads.
682
1123
  storageMod.setStoragePathDirect(storagePath);
@@ -756,6 +1197,7 @@ export async function runLimitsCommand(parsed, options = {}) {
756
1197
  accessToken,
757
1198
  organizationId: account.organizationId,
758
1199
  }),
1200
+ quotaDisplay,
759
1201
  );
760
1202
  const quotaExhaustedResetAtMs = usageMod.getUsageQuotaExhaustedResetAtMs([
761
1203
  usage.primary,
@@ -1226,6 +1668,10 @@ async function removePluginFromCachePackage(paths, dryRun) {
1226
1668
  if (!existsSync(paths.cachePackageJson)) {
1227
1669
  return;
1228
1670
  }
1671
+ if (!isEvictableCachePath(paths.cachePackageJson, paths.cacheDir)) {
1672
+ log(`Warning: refusing to update ${paths.cachePackageJson}: it does not resolve inside the OpenCode cache.`);
1673
+ return;
1674
+ }
1229
1675
 
1230
1676
  let cacheData;
1231
1677
  try {
@@ -1267,6 +1713,29 @@ async function removePluginFromCachePackage(paths, dryRun) {
1267
1713
  await writeFileAtomic(paths.cachePackageJson, formatJson(cacheData));
1268
1714
  }
1269
1715
 
1716
+ /**
1717
+ * Mirror of `isEvictableCachePath` in lib/auto-update-checker.ts. A recursive
1718
+ * delete must never act on a path that only spells like cache: the cache root
1719
+ * itself must not resolve through a symlink (`~/.cache/opencode -> ~` would
1720
+ * otherwise call the whole home directory "inside the cache"), and the
1721
+ * resolved target must stay inside the resolved root.
1722
+ */
1723
+ function isEvictableCachePath(cachePath, cacheRoot) {
1724
+ const absolutePath = resolve(cachePath);
1725
+ const absoluteRoot = resolve(cacheRoot);
1726
+ if (!isInsideDirectory(absolutePath, absoluteRoot, process.platform)) return false;
1727
+ try {
1728
+ const realRoot = realpathSync(absoluteRoot);
1729
+ const rootIsSymlinked = process.platform === "win32"
1730
+ ? realRoot.toLowerCase() !== absoluteRoot.toLowerCase()
1731
+ : realRoot !== absoluteRoot;
1732
+ if (rootIsSymlinked) return false;
1733
+ return isInsideDirectory(realpathSync(absolutePath), realRoot, process.platform);
1734
+ } catch {
1735
+ return false;
1736
+ }
1737
+ }
1738
+
1270
1739
  async function clearCache(paths, dryRun, skipCacheClear) {
1271
1740
  if (skipCacheClear) {
1272
1741
  log("Skipping cache clear (--no-cache-clear).");
@@ -1274,6 +1743,12 @@ async function clearCache(paths, dryRun, skipCacheClear) {
1274
1743
  return;
1275
1744
  }
1276
1745
 
1746
+ const cacheTargets = [
1747
+ ...paths.cacheNodeModulesPaths,
1748
+ ...paths.cachePackagePaths,
1749
+ paths.cacheBunLock,
1750
+ ];
1751
+
1277
1752
  if (dryRun) {
1278
1753
  for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) {
1279
1754
  log(`[dry-run] Would remove ${cacheNodeModulesPath}`);
@@ -1283,13 +1758,17 @@ async function clearCache(paths, dryRun, skipCacheClear) {
1283
1758
  }
1284
1759
  log(`[dry-run] Would remove ${paths.cacheBunLock}`);
1285
1760
  } else {
1286
- for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) {
1287
- await removeWithWindowsRetry(cacheNodeModulesPath, { recursive: true, force: true });
1288
- }
1289
- for (const cachePackagePath of paths.cachePackagePaths) {
1290
- await removeWithWindowsRetry(cachePackagePath, { recursive: true, force: true });
1761
+ for (const cacheTarget of cacheTargets) {
1762
+ if (!existsSync(cacheTarget)) continue;
1763
+ if (!isEvictableCachePath(cacheTarget, paths.cacheDir)) {
1764
+ log(`Warning: refusing to remove ${cacheTarget}: it does not resolve inside the OpenCode cache.`);
1765
+ continue;
1766
+ }
1767
+ await removeWithWindowsRetry(cacheTarget, {
1768
+ recursive: cacheTarget !== paths.cacheBunLock,
1769
+ force: true,
1770
+ });
1291
1771
  }
1292
- await removeWithWindowsRetry(paths.cacheBunLock, { force: true });
1293
1772
  }
1294
1773
 
1295
1774
  await removePluginFromCachePackage(paths, dryRun);
@@ -1358,9 +1837,6 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1358
1837
  }
1359
1838
  }
1360
1839
 
1361
- let nextConfig = pluginOnly
1362
- ? { $schema: template.$schema, plugin: [PACKAGE_NAME] }
1363
- : template;
1364
1840
  let existingConfig;
1365
1841
  if (existsSync(paths.configPath)) {
1366
1842
  try {
@@ -1369,18 +1845,6 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1369
1845
  throw new Error("config root must be a JSON object");
1370
1846
  }
1371
1847
  existingConfig = existing;
1372
- const merged = { ...existing };
1373
- merged.plugin = normalizePluginList(existing.plugin);
1374
- if (!pluginOnly) {
1375
- const provider = (existing.provider && typeof existing.provider === "object")
1376
- ? { ...existing.provider }
1377
- : {};
1378
- provider.openai = mergeOpenaiProvider(existing.provider?.openai, template.provider?.openai, {
1379
- modelKeysToRemove,
1380
- });
1381
- merged.provider = provider;
1382
- }
1383
- nextConfig = merged;
1384
1848
  } catch (error) {
1385
1849
  if (pluginOnly) {
1386
1850
  throw new Error(
@@ -1389,13 +1853,11 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1389
1853
  }
1390
1854
  log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`);
1391
1855
  existingConfig = undefined;
1392
- nextConfig = template;
1393
1856
  }
1394
1857
  } else {
1395
1858
  log("No existing config found. Creating new global config.");
1396
1859
  }
1397
1860
 
1398
- let nextTuiConfig = mergeTuiConfig(undefined);
1399
1861
  let existingTuiConfig;
1400
1862
  if (existsSync(paths.tuiConfigPath)) {
1401
1863
  try {
@@ -1404,7 +1866,6 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1404
1866
  throw new Error("TUI config root must be a JSON object");
1405
1867
  }
1406
1868
  existingTuiConfig = existing;
1407
- nextTuiConfig = mergeTuiConfig(existing);
1408
1869
  } catch (error) {
1409
1870
  if (pluginOnly) {
1410
1871
  throw new Error(
@@ -1413,12 +1874,64 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1413
1874
  }
1414
1875
  log(`Warning: Could not parse existing TUI config (${formatErrorForLog(error)}). Replacing with minimal TUI config.`);
1415
1876
  existingTuiConfig = undefined;
1416
- nextTuiConfig = mergeTuiConfig(undefined);
1417
1877
  }
1418
1878
  } else {
1419
1879
  log("No existing TUI config found. Creating new global TUI config.");
1420
1880
  }
1421
1881
 
1882
+ // A checkout of this package registered in either file already loads the
1883
+ // plugin, so the published name must not be written beside it anywhere -
1884
+ // otherwise the checkout in opencode.json and the published package in
1885
+ // tui.json both load.
1886
+ const pluginListOptions = {
1887
+ baseDirectory: paths.configDir,
1888
+ cacheDirectory: paths.cacheDir,
1889
+ };
1890
+ const checkoutRegistered = [existingConfig?.plugin, existingTuiConfig?.plugin]
1891
+ .flatMap((list) => (Array.isArray(list) ? list : []))
1892
+ .some((entry) => {
1893
+ const classification = classifyPluginEntry(entry, pluginListOptions);
1894
+ return (
1895
+ classification.kind === LOCAL_CHECKOUT_ENTRY &&
1896
+ classification.name === PACKAGE_NAME
1897
+ );
1898
+ });
1899
+ const normalizeOptions = { ...pluginListOptions, checkoutRegistered };
1900
+
1901
+ let nextConfig;
1902
+ if (existingConfig !== undefined) {
1903
+ const merged = { ...existingConfig };
1904
+ merged.plugin = normalizePluginList(existingConfig.plugin, log, normalizeOptions);
1905
+ if (!pluginOnly) {
1906
+ const provider = (existingConfig.provider && typeof existingConfig.provider === "object")
1907
+ ? { ...existingConfig.provider }
1908
+ : {};
1909
+ provider.openai = mergeOpenaiProvider(existingConfig.provider?.openai, template.provider?.openai, {
1910
+ modelKeysToRemove,
1911
+ });
1912
+ merged.provider = provider;
1913
+ }
1914
+ nextConfig = merged;
1915
+ } else {
1916
+ nextConfig = pluginOnly
1917
+ ? { $schema: template.$schema, plugin: [PACKAGE_NAME] }
1918
+ : template;
1919
+ nextConfig.plugin = normalizePluginList(nextConfig.plugin, log, normalizeOptions);
1920
+ }
1921
+
1922
+ const nextTuiConfig = mergeTuiConfig(existingTuiConfig, log, normalizeOptions);
1923
+
1924
+ const unregisteredCheckout = findUnregisteredLocalCheckout(nextConfig.plugin, paths.originHistoryPath, {
1925
+ baseDirectory: paths.configDir,
1926
+ cacheDirectory: paths.cacheDir,
1927
+ });
1928
+ if (unregisteredCheckout) {
1929
+ log(
1930
+ `Note: this plugin last loaded from a checkout at ${unregisteredCheckout.root} on ${unregisteredCheckout.lastSeen}, ` +
1931
+ `which ${paths.configPath} does not register. Point the plugin entry back at that path if OpenCode should keep loading your own build.`,
1932
+ );
1933
+ }
1934
+
1422
1935
  const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig);
1423
1936
  const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig);
1424
1937
  let wrote = false;
@@ -1459,10 +1972,10 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1459
1972
  log("\nDone. Restart OpenCode to (re)install the plugin.");
1460
1973
  log("Example: opencode");
1461
1974
  if (!pluginOnly && configMode === "modern") {
1462
- log("Note: Modern config intentionally shows 13 base OAuth model entries; use the variant picker for reasoning presets.");
1975
+ log("Note: Modern config intentionally shows 10 base OAuth model entries; use the variant picker for reasoning presets.");
1463
1976
  }
1464
1977
  if (!pluginOnly && configMode === "legacy") {
1465
- log("Note: Legacy config writes 59 explicit preset entries and is also safe for older OpenCode versions.");
1978
+ log("Note: Legacy config writes 53 explicit preset entries and is also safe for older OpenCode versions.");
1466
1979
  }
1467
1980
  if (!pluginOnly && configMode === "full") {
1468
1981
  log("Note: Full config installs both compact base models and explicit preset entries for direct selector IDs.");
@@ -1481,14 +1994,18 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
1481
1994
  }
1482
1995
 
1483
1996
  export const __test = {
1997
+ ORIGIN_HISTORY_FILE_NAME,
1484
1998
  buildPaths,
1485
1999
  backupConfig,
2000
+ classifyPluginEntry,
1486
2001
  copyFileWithWindowsRetry,
2002
+ findUnregisteredLocalCheckout,
1487
2003
  formatConfigDiff,
1488
2004
  formatRedactedConfigDiff,
1489
2005
  mergeFullTemplate,
1490
2006
  mergeOpenaiProvider,
1491
2007
  mergeTuiConfig,
2008
+ normalizePluginList,
1492
2009
  parseCliArgs,
1493
2010
  removeWithWindowsRetry,
1494
2011
  runStandaloneCommand,