oc-codex-multi-auth 6.10.1 → 6.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +30 -13
  2. package/config/README.md +7 -5
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +22 -220
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/accounts/state.d.ts +1 -1
  7. package/dist/lib/accounts/state.js +1 -1
  8. package/dist/lib/accounts/warm-request.d.ts +14 -1
  9. package/dist/lib/accounts/warm-request.d.ts.map +1 -1
  10. package/dist/lib/accounts/warm-request.js +140 -21
  11. package/dist/lib/accounts/warm-request.js.map +1 -1
  12. package/dist/lib/auth/server.d.ts +1 -1
  13. package/dist/lib/auth/server.js +1 -1
  14. package/dist/lib/auto-update-checker.d.ts.map +1 -1
  15. package/dist/lib/auto-update-checker.js +3 -2
  16. package/dist/lib/auto-update-checker.js.map +1 -1
  17. package/dist/lib/codex-usage.d.ts +1 -0
  18. package/dist/lib/codex-usage.d.ts.map +1 -1
  19. package/dist/lib/codex-usage.js +17 -2
  20. package/dist/lib/codex-usage.js.map +1 -1
  21. package/dist/lib/config.d.ts +7 -0
  22. package/dist/lib/config.d.ts.map +1 -1
  23. package/dist/lib/config.js +7 -7
  24. package/dist/lib/config.js.map +1 -1
  25. package/dist/lib/errors.d.ts +1 -1
  26. package/dist/lib/errors.js +1 -1
  27. package/dist/lib/recovery.d.ts +2 -2
  28. package/dist/lib/recovery.js +2 -2
  29. package/dist/lib/request/fetch-helpers.d.ts +8 -2
  30. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  31. package/dist/lib/request/fetch-helpers.js +8 -2
  32. package/dist/lib/request/fetch-helpers.js.map +1 -1
  33. package/dist/lib/request/request-transformer.d.ts +4 -1
  34. package/dist/lib/request/request-transformer.d.ts.map +1 -1
  35. package/dist/lib/request/request-transformer.js +4 -1
  36. package/dist/lib/request/request-transformer.js.map +1 -1
  37. package/dist/lib/request/response-handler.d.ts +1 -0
  38. package/dist/lib/request/response-handler.d.ts.map +1 -1
  39. package/dist/lib/request/response-handler.js +1 -0
  40. package/dist/lib/request/response-handler.js.map +1 -1
  41. package/dist/lib/runtime.d.ts +1 -1
  42. package/dist/lib/runtime.js +1 -1
  43. package/dist/lib/storage/backup.d.ts +1 -1
  44. package/dist/lib/storage/backup.js +1 -1
  45. package/dist/lib/storage/export-import.d.ts +6 -3
  46. package/dist/lib/storage/export-import.d.ts.map +1 -1
  47. package/dist/lib/storage/export-import.js +5 -2
  48. package/dist/lib/storage/export-import.js.map +1 -1
  49. package/dist/lib/storage/keychain.d.ts +2 -2
  50. package/dist/lib/storage/keychain.js +2 -2
  51. package/dist/lib/storage/load-save.js +1 -1
  52. package/dist/lib/storage/load-save.js.map +1 -1
  53. package/dist/lib/storage/worktree-lock.d.ts +2 -2
  54. package/dist/lib/storage/worktree-lock.js +2 -2
  55. package/dist/lib/storage.d.ts +2 -2
  56. package/dist/lib/storage.js +2 -2
  57. package/dist/lib/tools/codex-diag.d.ts +1 -1
  58. package/dist/lib/tools/codex-diag.js +1 -1
  59. package/dist/lib/tools/codex-diff.d.ts.map +1 -1
  60. package/dist/lib/tools/codex-diff.js +1 -2
  61. package/dist/lib/tools/codex-diff.js.map +1 -1
  62. package/dist/lib/tools/index.d.ts +1 -1
  63. package/dist/lib/tools/index.js +1 -1
  64. package/package.json +7 -5
  65. package/scripts/install-oc-codex-multi-auth-core.js +379 -68
@@ -15,11 +15,13 @@ const STALE_MANAGED_MODEL_KEYS = new Set([
15
15
  ]);
16
16
  const STANDALONE_COMMANDS = new Set(["doctor", "status", "list", "limits", "dashboard", "health", "diag", "warm"]);
17
17
  const INSTALLER_COMMANDS = new Set(["install"]);
18
+ const UPDATE_COMMANDS = new Set(["update"]);
18
19
 
19
20
  function splitCommandArgv(argv) {
20
21
  const [first, ...rest] = argv;
21
22
  if (!first) return { kind: "install", argv };
22
23
  if (INSTALLER_COMMANDS.has(first)) return { kind: "install", argv: rest };
24
+ if (UPDATE_COMMANDS.has(first)) return { kind: "update", argv: rest };
23
25
  if (STANDALONE_COMMANDS.has(first)) return { kind: "standalone", command: first, argv: rest };
24
26
  if (first.startsWith("-")) return { kind: "install", argv };
25
27
  return { kind: "unknown", command: first, argv: rest };
@@ -76,23 +78,26 @@ export function isDirectRunPath(argvPath, modulePath, resolveRealPath = realpath
76
78
  function printHelp() {
77
79
  console.log(`Usage: ${PACKAGE_NAME} [command] [options]\n\n` +
78
80
  "Commands:\n" +
79
- " install Install/update OpenCode config (default with no command)\n" +
81
+ " install Register plugin entries (default with no command)\n" +
82
+ " update Refresh the cached package without changing OpenCode config\n" +
80
83
  " doctor Run local account/config diagnostics\n" +
81
84
  " status Show account/config status\n" +
82
85
  " list List configured accounts\n" +
83
- " limits Show stored rate-limit state\n" +
86
+ " limits Show live 5-hour and weekly usage for each account\n" +
84
87
  " dashboard Print dashboard guidance\n" +
85
88
  " health Check local token/account health\n" +
86
89
  " diag Alias for doctor --deep\n" +
87
90
  " warm Open every enabled account's usage window now (one request each)\n\n" +
88
- `Installer usage: ${PACKAGE_NAME} [--modern|--full|--legacy] [--dry-run] [--no-cache-clear]\n\n` +
91
+ `Installer usage: ${PACKAGE_NAME} install [--plugin-only|--modern|--full|--legacy] [--dry-run] [--no-cache-clear]\n` +
92
+ `Updater usage: ${PACKAGE_NAME} update [--dry-run]\n\n` +
89
93
  "Default behavior:\n" +
90
- " - Installs/updates global config at ~/.config/opencode/opencode.json\n" +
94
+ " - Registers plugin entries without changing provider.openai\n" +
91
95
  " - Enables the prompt status bar TUI plugin at ~/.config/opencode/tui.json\n" +
92
- " - Uses compact UI config by default (12 base OAuth models + variant picker presets)\n" +
96
+ " - Installs model catalogs only with --modern, --full, or --legacy\n" +
93
97
  " - Ensures plugin is unpinned (latest)\n" +
94
98
  " - Clears OpenCode plugin cache\n\n" +
95
99
  "Options:\n" +
100
+ " --plugin-only Register plugins without changing provider.openai\n" +
96
101
  " --modern Force compact modern config (12 base OAuth models + --variant presets)\n" +
97
102
  " --full Install compact base models plus 53 explicit selector entries\n" +
98
103
  " --legacy Force explicit legacy config (53 preset model entries)\n" +
@@ -139,7 +144,10 @@ function buildPaths(homeDir) {
139
144
  tuiConfigPath: join(configDir, "tui.json"),
140
145
  cacheDir,
141
146
  cacheNodeModulesPaths: getManagedPackageNames().map((name) => join(cacheDir, "node_modules", name)),
142
- cachePackagePaths: getManagedPackageNames().map((name) => join(cacheDir, "packages", `${name}@latest`)),
147
+ cachePackagePaths: getManagedPackageNames().flatMap((name) => [
148
+ join(cacheDir, "packages", name),
149
+ join(cacheDir, "packages", `${name}@latest`),
150
+ ]),
143
151
  cacheBunLock: join(cacheDir, "bun.lock"),
144
152
  cachePackageJson: join(cacheDir, "package.json"),
145
153
  modernTemplatePath,
@@ -158,21 +166,40 @@ function parseCliArgs(argv = process.argv.slice(2)) {
158
166
  const requestedModern = args.has("--modern");
159
167
  const requestedFull = args.has("--full");
160
168
  const requestedLegacy = args.has("--legacy");
169
+ const explicitPluginOnly = args.has("--plugin-only");
161
170
 
162
171
  const requestedModes = [requestedModern, requestedFull, requestedLegacy]
163
172
  .filter(Boolean).length;
164
173
  if (requestedModes > 1) {
165
174
  throw new Error("Choose only one of --modern, --full, or --legacy.");
166
175
  }
176
+ if (explicitPluginOnly && requestedModes > 0) {
177
+ throw new Error("--plugin-only cannot be combined with --modern, --full, or --legacy.");
178
+ }
179
+ const pluginOnly = explicitPluginOnly || requestedModes === 0;
167
180
 
168
181
  return {
169
182
  wantsHelp: false,
170
183
  dryRun: args.has("--dry-run"),
171
184
  skipCacheClear: args.has("--no-cache-clear"),
185
+ pluginOnly,
172
186
  configMode: requestedFull ? "full" : requestedLegacy ? "legacy" : "modern",
173
187
  };
174
188
  }
175
189
 
190
+ function parseUpdateArgs(argv) {
191
+ const args = new Set(argv);
192
+ const supported = new Set(["--dry-run", "--help", "-h"]);
193
+ const unknown = argv.find((arg) => !supported.has(arg));
194
+ if (unknown) {
195
+ throw new Error(`Unknown option for update command: ${unknown}`);
196
+ }
197
+ return {
198
+ wantsHelp: args.has("--help") || args.has("-h"),
199
+ dryRun: args.has("--dry-run"),
200
+ };
201
+ }
202
+
176
203
  function normalizePluginEntryForMatch(entry) {
177
204
  const trimmed = entry.trim();
178
205
  let normalized = trimmed.toLowerCase();
@@ -353,34 +380,54 @@ function printStandaloneResult(command, payload, json) {
353
380
  if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
354
381
  }
355
382
 
356
- async function loadWarmRuntime(env) {
357
- // Reuse the COMPILED warm logic from dist/ so the standalone CLI behaves
358
- // identically to the in-conversation codex-warm tool. dist/ ships in the
359
- // npm package (files allowlist) and none of these modules import the
360
- // OpenCode plugin runtime, so they load cleanly in plain Node.
383
+ /**
384
+ * Import compiled modules from dist/ so the standalone CLI behaves identically
385
+ * to the in-conversation tools. dist/ ships in the npm package (files
386
+ * allowlist) and none of these modules import the OpenCode plugin runtime, so
387
+ * they load cleanly in plain Node.
388
+ */
389
+ async function loadDistModules(relativePaths, label) {
361
390
  const distRoot = join(repoRoot, "dist", "lib");
362
391
  const toUrl = (rel) => pathToFileURL(join(distRoot, rel)).href;
363
392
  try {
364
- const [storageMod, usageMod, warmReqMod, warmMod, shutdownMod] = await Promise.all([
365
- import(toUrl("storage.js")),
366
- import(toUrl("codex-usage.js")),
367
- import(toUrl("accounts/warm-request.js")),
368
- import(toUrl("accounts/warm.js")),
369
- import(toUrl("shutdown.js")),
370
- ]);
371
- // Unlike the plugin, this CLI *is* the process, so it owns termination:
372
- // Ctrl+C must abort the warm run rather than wait for it to drain.
373
- // Refreshing a token here persists credentials, which registers the
374
- // shutdown handler via the storage lock.
375
- shutdownMod.setShutdownOwnsProcess(true);
376
- return { storageMod, usageMod, warmReqMod, warmMod, shutdownMod };
393
+ return await Promise.all(relativePaths.map((rel) => import(toUrl(rel))));
377
394
  } catch (error) {
378
395
  throw new Error(
379
- `Could not load warm runtime from dist/. Build the package first (npm run build). Cause: ${formatErrorForLog(error)}`,
396
+ `Could not load ${label} runtime from dist/. Build the package first (npm run build). Cause: ${formatErrorForLog(error)}`,
380
397
  );
381
398
  }
382
399
  }
383
400
 
401
+ async function loadWarmRuntime(env) {
402
+ const [storageMod, usageMod, warmReqMod, warmMod, shutdownMod] = await loadDistModules(
403
+ [
404
+ "storage.js",
405
+ "codex-usage.js",
406
+ "accounts/warm-request.js",
407
+ "accounts/warm.js",
408
+ "shutdown.js",
409
+ ],
410
+ "warm",
411
+ );
412
+ // Unlike the plugin, this CLI *is* the process, so it owns termination:
413
+ // Ctrl+C must abort the warm run rather than wait for it to drain.
414
+ // Refreshing a token here persists credentials, which registers the
415
+ // shutdown handler via the storage lock.
416
+ shutdownMod.setShutdownOwnsProcess(true);
417
+ return { storageMod, usageMod, warmReqMod, warmMod, shutdownMod };
418
+ }
419
+
420
+ async function loadLimitsRuntime(env) {
421
+ const [storageMod, usageMod, shutdownMod, loggerMod] = await loadDistModules(
422
+ ["storage.js", "codex-usage.js", "shutdown.js", "logger.js"],
423
+ "limits",
424
+ );
425
+ // Fetching usage can refresh (and therefore persist) a token, so the same
426
+ // process-owns-termination rule as `warm` applies.
427
+ shutdownMod.setShutdownOwnsProcess(true);
428
+ return { storageMod, usageMod, shutdownMod, loggerMod };
429
+ }
430
+
384
431
  export async function runWarmCommand(parsed, options = {}) {
385
432
  const { env = process.env } = options;
386
433
  const storagePath = getStandaloneStoragePath(parsed, env);
@@ -478,6 +525,147 @@ function printWarmResult(payload, json) {
478
525
  if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
479
526
  }
480
527
 
528
+ /**
529
+ * `limits` — show live 5-hour and weekly Codex usage per account (#209).
530
+ *
531
+ * Reuses the compiled `codex-usage` runtime so the CLI reports the same windows
532
+ * as the in-conversation `codex-limits` tool. The locally persisted
533
+ * `rateLimitResetTimes` is carried in the payload as well: it is the only
534
+ * rate-limit state available for an account whose live fetch fails, and
535
+ * `--json` consumers of the previous behavior still find the field.
536
+ */
537
+ export async function runLimitsCommand(parsed, options = {}) {
538
+ const { env = process.env } = options;
539
+ const storagePath = getStandaloneStoragePath(parsed, env);
540
+
541
+ let runtime;
542
+ try {
543
+ runtime = await loadLimitsRuntime(env);
544
+ } catch (error) {
545
+ const payload = { command: "limits", storagePath, error: formatErrorForLog(error) };
546
+ printLimitsResult(payload, parsed.json);
547
+ return { exitCode: 1, action: "limits", storagePath };
548
+ }
549
+
550
+ const { storageMod, usageMod, loggerMod } = runtime;
551
+ // Point dist storage at the resolved accounts file so a refreshed token is
552
+ // persisted to the SAME file the rest of the toolchain reads.
553
+ storageMod.setStoragePathDirect(storagePath);
554
+
555
+ const storage = await storageMod.loadAccounts();
556
+ const accounts = Array.isArray(storage?.accounts) ? storage.accounts : [];
557
+ if (accounts.length === 0) {
558
+ const payload = {
559
+ command: "limits",
560
+ storagePath,
561
+ totalAccounts: 0,
562
+ accounts: [],
563
+ message: "No accounts configured.",
564
+ nextAction: "Run opencode auth login.",
565
+ };
566
+ printLimitsResult(payload, parsed.json);
567
+ return { exitCode: 0, action: "limits", storagePath };
568
+ }
569
+
570
+ // Same workspace dedupe the codex-limits tool applies, so two entries for one
571
+ // workspace are not billed and printed twice.
572
+ const indices = usageMod.deduplicateUsageAccountIndices(storage);
573
+ // `--tag` must gate which accounts are contacted at all, not just which are
574
+ // printed: an untagged account would otherwise be billed a usage fetch and
575
+ // could have its refreshed credentials persisted.
576
+ const normalizedTag =
577
+ typeof parsed.tag === "string" ? parsed.tag.trim().toLowerCase() : "";
578
+ const results = [];
579
+ let failedCount = 0;
580
+
581
+ for (const index of indices) {
582
+ const account = accounts[index];
583
+ if (!account) continue;
584
+ if (
585
+ normalizedTag &&
586
+ !(
587
+ Array.isArray(account.accountTags) &&
588
+ account.accountTags.some((entry) => String(entry).toLowerCase() === normalizedTag)
589
+ )
590
+ ) {
591
+ continue;
592
+ }
593
+ const entry = {
594
+ index,
595
+ label: account.accountLabel ?? `Account ${index + 1}`,
596
+ email: maskValue(account.email, parsed.includeSensitive),
597
+ rateLimitResetTimes: account.rateLimitResetTimes ?? {},
598
+ };
599
+ try {
600
+ const { accessToken } = await usageMod.ensureCodexUsageAccessToken({ storage, account });
601
+ const accountId = usageMod.resolveCodexUsageAccountId({ account, accessToken });
602
+ if (!accountId) {
603
+ throw new Error("could not resolve account id (re-login may be required)");
604
+ }
605
+ const usage = usageMod.parseCodexUsagePayload(
606
+ await usageMod.fetchCodexUsage({
607
+ accountId,
608
+ accessToken,
609
+ organizationId: account.organizationId,
610
+ }),
611
+ );
612
+ entry.planType = usage.planType;
613
+ entry.credits = usage.credits;
614
+ entry.limits = usage.limits;
615
+ } catch (error) {
616
+ // `ensureCodexUsageAccessToken` can surface a raw OAuth refresh
617
+ // response, so the message is redacted through the logger's token
618
+ // patterns before it reaches stdout, JSON output, or CI logs.
619
+ // Truncation alone does not protect bearer/JWT/refresh-token material.
620
+ entry.error = loggerMod.maskString(formatErrorForLog(error)).slice(0, 160);
621
+ failedCount += 1;
622
+ }
623
+ results.push(entry);
624
+ }
625
+
626
+ const payload = {
627
+ command: "limits",
628
+ storagePath,
629
+ totalAccounts: accounts.length,
630
+ shownAccounts: results.length,
631
+ accounts: results,
632
+ };
633
+ printLimitsResult(payload, parsed.json);
634
+ return { exitCode: failedCount > 0 ? 1 : 0, action: "limits", storagePath };
635
+ }
636
+
637
+ function printLimitsResult(payload, json) {
638
+ if (json) {
639
+ console.log(JSON.stringify(payload, null, 2));
640
+ return;
641
+ }
642
+ console.log(`oc-codex-multi-auth limits`);
643
+ if (payload.message) console.log(payload.message);
644
+ console.log(`Storage: ${payload.storagePath}`);
645
+ if (payload.error) {
646
+ console.log(`Error: ${payload.error}`);
647
+ return;
648
+ }
649
+ console.log(`Accounts: ${payload.totalAccounts}`);
650
+ for (const account of payload.accounts ?? []) {
651
+ const label = account.email ? `${account.label} (${account.email})` : account.label;
652
+ console.log(`- [${account.index}] ${label}`);
653
+ if (account.error) {
654
+ console.log(` Error: ${account.error}`);
655
+ continue;
656
+ }
657
+ for (const limit of account.limits ?? []) {
658
+ console.log(` ${limit.name}: ${limit.summary}`);
659
+ }
660
+ if ((account.limits ?? []).length === 0) {
661
+ console.log(" No usage windows reported yet.");
662
+ }
663
+ if (account.planType) console.log(` Plan: ${account.planType}`);
664
+ if (account.credits) console.log(` Credits: ${account.credits}`);
665
+ }
666
+ if (payload.nextAction) console.log(`Next: ${payload.nextAction}`);
667
+ }
668
+
481
669
  export async function runStandaloneCommand(command, argv = [], options = {}) {
482
670
  const parsed = parseStandaloneArgs(argv);
483
671
  if (command === "diag") {
@@ -491,6 +679,9 @@ export async function runStandaloneCommand(command, argv = [], options = {}) {
491
679
  if (command === "warm") {
492
680
  return runWarmCommand(parsed, options);
493
681
  }
682
+ if (command === "limits") {
683
+ return runLimitsCommand(parsed, options);
684
+ }
494
685
  const { env = process.env } = options;
495
686
  const storagePath = getStandaloneStoragePath(parsed, env);
496
687
  const { storage, error } = await readStandaloneStorage(storagePath);
@@ -514,8 +705,6 @@ export async function runStandaloneCommand(command, argv = [], options = {}) {
514
705
  payload.deep = parsed.deep;
515
706
  payload.fixApplied = parsed.fix ? false : undefined;
516
707
  payload.nextAction = totalAccounts > 0 ? "Run oc-codex-multi-auth health --json for scriptable checks." : "Run opencode auth login.";
517
- } else if (command === "limits") {
518
- payload.rateLimits = accounts.map((account) => ({ index: account.index, rateLimitResetTimes: account.rateLimitResetTimes }));
519
708
  } else if (command === "health") {
520
709
  payload.healthyCount = accounts.filter((account) => account.enabled && account.hasRefreshToken).length;
521
710
  payload.unhealthyCount = accounts.filter((account) => !account.enabled || !account.hasRefreshToken).length;
@@ -602,6 +791,51 @@ function formatConfigDiff(existingConfig, nextConfig) {
602
791
  return lines.join("\n");
603
792
  }
604
793
 
794
+ function formatRedactedConfigDiff(existingConfig, nextConfig) {
795
+ const missing = Symbol("missing");
796
+ const changes = [];
797
+ const visit = (existing, next, path) => {
798
+ if (existing === missing) {
799
+ changes.push(`+ ${path}`);
800
+ return;
801
+ }
802
+ if (next === missing) {
803
+ changes.push(`- ${path}`);
804
+ return;
805
+ }
806
+ if (Object.is(existing, next)) return;
807
+
808
+ if (Array.isArray(existing) && Array.isArray(next)) {
809
+ const length = Math.max(existing.length, next.length);
810
+ for (let index = 0; index < length; index += 1) {
811
+ visit(
812
+ index < existing.length ? existing[index] : missing,
813
+ index < next.length ? next[index] : missing,
814
+ `${path}[${index}]`,
815
+ );
816
+ }
817
+ return;
818
+ }
819
+
820
+ if (isPlainObject(existing) && isPlainObject(next)) {
821
+ const keys = new Set([...Object.keys(existing), ...Object.keys(next)]);
822
+ for (const key of keys) {
823
+ visit(
824
+ Object.hasOwn(existing, key) ? existing[key] : missing,
825
+ Object.hasOwn(next, key) ? next[key] : missing,
826
+ `${path}.${key}`,
827
+ );
828
+ }
829
+ return;
830
+ }
831
+
832
+ changes.push(`~ ${path}`);
833
+ };
834
+
835
+ visit(existingConfig === undefined ? missing : existingConfig, nextConfig, "$");
836
+ return changes.length > 0 ? changes.join("\n") : "(no changes)";
837
+ }
838
+
605
839
  function mergeFullTemplate(modernTemplate, legacyTemplate) {
606
840
  const modernModels = modernTemplate.provider?.openai?.models ?? {};
607
841
  const legacyModels = legacyTemplate.provider?.openai?.models ?? {};
@@ -657,6 +891,28 @@ async function renameWithWindowsRetry(sourcePath, destinationPath) {
657
891
  }
658
892
  }
659
893
 
894
+ async function removeWithWindowsRetry(path, options) {
895
+ let lastError = null;
896
+
897
+ for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) {
898
+ try {
899
+ await rm(path, options);
900
+ return;
901
+ } catch (error) {
902
+ if (isWindowsLockError(error)) {
903
+ lastError = error;
904
+ await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt);
905
+ continue;
906
+ }
907
+ throw error;
908
+ }
909
+ }
910
+
911
+ if (lastError) {
912
+ throw lastError;
913
+ }
914
+ }
915
+
660
916
  async function writeFileAtomic(filePath, content) {
661
917
  const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
662
918
  const tempPath = `${filePath}.${uniqueSuffix}.tmp`;
@@ -784,12 +1040,12 @@ async function clearCache(paths, dryRun, skipCacheClear) {
784
1040
  log(`[dry-run] Would remove ${paths.cacheBunLock}`);
785
1041
  } else {
786
1042
  for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) {
787
- await rm(cacheNodeModulesPath, { recursive: true, force: true });
1043
+ await removeWithWindowsRetry(cacheNodeModulesPath, { recursive: true, force: true });
788
1044
  }
789
1045
  for (const cachePackagePath of paths.cachePackagePaths) {
790
- await rm(cachePackagePath, { recursive: true, force: true });
1046
+ await removeWithWindowsRetry(cachePackagePath, { recursive: true, force: true });
791
1047
  }
792
- await rm(paths.cacheBunLock, { force: true });
1048
+ await removeWithWindowsRetry(paths.cacheBunLock, { force: true });
793
1049
  }
794
1050
 
795
1051
  await removePluginFromCachePackage(paths, dryRun);
@@ -804,20 +1060,37 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
804
1060
  printHelp();
805
1061
  throw new Error(`Unknown command: ${split.command}`);
806
1062
  }
1063
+ const { env = process.env } = options;
1064
+ const paths = buildPaths(resolveHomeDirectory(env));
1065
+ if (split.kind === "update") {
1066
+ const parsedUpdate = parseUpdateArgs(split.argv);
1067
+ if (parsedUpdate.wantsHelp) {
1068
+ printHelp();
1069
+ return { exitCode: 0, action: "help" };
1070
+ }
1071
+ await clearCache(paths, parsedUpdate.dryRun, false);
1072
+ log(`\n${parsedUpdate.dryRun ? "Dry run complete." : "Cache cleared."} Restart OpenCode to install the latest plugin.`);
1073
+ return {
1074
+ exitCode: 0,
1075
+ action: "update",
1076
+ dryRun: Boolean(parsedUpdate.dryRun),
1077
+ };
1078
+ }
807
1079
  const parsed = parseCliArgs(split.argv);
808
1080
  if (parsed.wantsHelp) {
809
1081
  printHelp();
810
1082
  return { exitCode: 0, action: "help" };
811
1083
  }
812
1084
 
813
- const { env = process.env } = options;
814
- const { configMode, dryRun, skipCacheClear } = parsed;
815
- const paths = buildPaths(resolveHomeDirectory(env));
816
- const requiredTemplatePaths = configMode === "modern"
817
- ? [paths.modernTemplatePath]
818
- : configMode === "legacy"
819
- ? [paths.legacyTemplatePath]
820
- : [paths.modernTemplatePath, paths.legacyTemplatePath];
1085
+ const { configMode, dryRun, skipCacheClear, pluginOnly } = parsed;
1086
+ const effectiveConfigMode = pluginOnly ? "plugin-only" : configMode;
1087
+ const requiredTemplatePaths = pluginOnly
1088
+ ? []
1089
+ : configMode === "modern"
1090
+ ? [paths.modernTemplatePath]
1091
+ : configMode === "legacy"
1092
+ ? [paths.legacyTemplatePath]
1093
+ : [paths.modernTemplatePath, paths.legacyTemplatePath];
821
1094
 
822
1095
  for (const templatePath of requiredTemplatePaths) {
823
1096
  if (!existsSync(templatePath)) {
@@ -825,40 +1098,51 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
825
1098
  }
826
1099
  }
827
1100
 
828
- const template = await loadTemplate(configMode, paths);
1101
+ const template = pluginOnly
1102
+ ? { $schema: "https://opencode.ai/config.json", plugin: [PACKAGE_NAME] }
1103
+ : await loadTemplate(configMode, paths);
829
1104
  template.plugin = [PACKAGE_NAME];
830
1105
  const modelKeysToRemove = new Set(STALE_MANAGED_MODEL_KEYS);
831
- if (configMode === "modern") {
1106
+ if (!pluginOnly && configMode === "modern") {
832
1107
  for (const key of getTemplateModelKeys(await readJson(paths.legacyTemplatePath))) {
833
1108
  modelKeysToRemove.add(key);
834
1109
  }
835
1110
  }
836
- if (configMode === "legacy") {
1111
+ if (!pluginOnly && configMode === "legacy") {
837
1112
  for (const key of getTemplateModelKeys(await readJson(paths.modernTemplatePath))) {
838
1113
  modelKeysToRemove.add(key);
839
1114
  }
840
1115
  }
841
1116
 
842
- let nextConfig = template;
1117
+ let nextConfig = pluginOnly
1118
+ ? { $schema: template.$schema, plugin: [PACKAGE_NAME] }
1119
+ : template;
843
1120
  let existingConfig;
844
1121
  if (existsSync(paths.configPath)) {
845
- const backupPath = await backupConfig(paths.configPath, dryRun);
846
- log(`${dryRun ? "[dry-run] Would create backup" : "Backup created"}: ${backupPath}`);
847
-
848
1122
  try {
849
1123
  const existing = await readJson(paths.configPath);
1124
+ if (!isPlainObject(existing)) {
1125
+ throw new Error("config root must be a JSON object");
1126
+ }
850
1127
  existingConfig = existing;
851
1128
  const merged = { ...existing };
852
1129
  merged.plugin = normalizePluginList(existing.plugin);
853
- const provider = (existing.provider && typeof existing.provider === "object")
854
- ? { ...existing.provider }
855
- : {};
856
- provider.openai = mergeOpenaiProvider(existing.provider?.openai, template.provider?.openai, {
857
- modelKeysToRemove,
858
- });
859
- merged.provider = provider;
1130
+ if (!pluginOnly) {
1131
+ const provider = (existing.provider && typeof existing.provider === "object")
1132
+ ? { ...existing.provider }
1133
+ : {};
1134
+ provider.openai = mergeOpenaiProvider(existing.provider?.openai, template.provider?.openai, {
1135
+ modelKeysToRemove,
1136
+ });
1137
+ merged.provider = provider;
1138
+ }
860
1139
  nextConfig = merged;
861
1140
  } catch (error) {
1141
+ if (pluginOnly) {
1142
+ throw new Error(
1143
+ `Could not parse existing config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
1144
+ );
1145
+ }
862
1146
  log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`);
863
1147
  existingConfig = undefined;
864
1148
  nextConfig = template;
@@ -870,14 +1154,19 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
870
1154
  let nextTuiConfig = mergeTuiConfig(undefined);
871
1155
  let existingTuiConfig;
872
1156
  if (existsSync(paths.tuiConfigPath)) {
873
- const backupPath = await backupConfig(paths.tuiConfigPath, dryRun);
874
- log(`${dryRun ? "[dry-run] Would create backup" : "Backup created"}: ${backupPath}`);
875
-
876
1157
  try {
877
1158
  const existing = await readJson(paths.tuiConfigPath);
1159
+ if (!isPlainObject(existing)) {
1160
+ throw new Error("TUI config root must be a JSON object");
1161
+ }
878
1162
  existingTuiConfig = existing;
879
1163
  nextTuiConfig = mergeTuiConfig(existing);
880
1164
  } catch (error) {
1165
+ if (pluginOnly) {
1166
+ throw new Error(
1167
+ `Could not parse existing TUI config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`,
1168
+ );
1169
+ }
881
1170
  log(`Warning: Could not parse existing TUI config (${formatErrorForLog(error)}). Replacing with minimal TUI config.`);
882
1171
  existingTuiConfig = undefined;
883
1172
  nextTuiConfig = mergeTuiConfig(undefined);
@@ -886,40 +1175,60 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) {
886
1175
  log("No existing TUI config found. Creating new global TUI config.");
887
1176
  }
888
1177
 
1178
+ const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig);
1179
+ const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig);
889
1180
  let wrote = false;
890
1181
  if (dryRun) {
891
- log(`[dry-run] Would write ${paths.configPath} using ${configMode} config`);
1182
+ log(`[dry-run] ${configChanged ? "Would write" : "Would leave unchanged"} ${paths.configPath} using ${effectiveConfigMode} config`);
892
1183
  log(`[dry-run] Diff for ${paths.configPath}:`);
893
- log(formatConfigDiff(existingConfig, nextConfig));
894
- log(`[dry-run] Would write ${paths.tuiConfigPath} with the TUI status plugin`);
1184
+ log(formatRedactedConfigDiff(existingConfig, nextConfig));
1185
+ log(`[dry-run] ${tuiConfigChanged ? "Would write" : "Would leave unchanged"} ${paths.tuiConfigPath} with the TUI status plugin`);
895
1186
  log(`[dry-run] Diff for ${paths.tuiConfigPath}:`);
896
- log(formatConfigDiff(existingTuiConfig, nextTuiConfig));
1187
+ log(formatRedactedConfigDiff(existingTuiConfig, nextTuiConfig));
897
1188
  } else {
898
- await writeFileAtomic(paths.configPath, formatJson(nextConfig));
899
- await writeFileAtomic(paths.tuiConfigPath, formatJson(nextTuiConfig));
900
- wrote = true;
901
- log(`Wrote ${paths.configPath} (${configMode} config)`);
902
- log(`Wrote ${paths.tuiConfigPath} (TUI status plugin)`);
1189
+ if (configChanged) {
1190
+ if (existsSync(paths.configPath)) {
1191
+ const backupPath = await backupConfig(paths.configPath, false);
1192
+ log(`Backup created: ${backupPath}`);
1193
+ }
1194
+ await writeFileAtomic(paths.configPath, formatJson(nextConfig));
1195
+ wrote = true;
1196
+ log(`Wrote ${paths.configPath} (${effectiveConfigMode} config)`);
1197
+ } else {
1198
+ log(`Left ${paths.configPath} unchanged`);
1199
+ }
1200
+ if (tuiConfigChanged) {
1201
+ if (existsSync(paths.tuiConfigPath)) {
1202
+ const backupPath = await backupConfig(paths.tuiConfigPath, false);
1203
+ log(`Backup created: ${backupPath}`);
1204
+ }
1205
+ await writeFileAtomic(paths.tuiConfigPath, formatJson(nextTuiConfig));
1206
+ wrote = true;
1207
+ log(`Wrote ${paths.tuiConfigPath} (TUI status plugin)`);
1208
+ } else {
1209
+ log(`Left ${paths.tuiConfigPath} unchanged`);
1210
+ }
903
1211
  }
904
1212
 
905
1213
  await clearCache(paths, dryRun, skipCacheClear);
906
1214
 
907
1215
  log("\nDone. Restart OpenCode to (re)install the plugin.");
908
1216
  log("Example: opencode");
909
- if (configMode === "modern") {
1217
+ if (!pluginOnly && configMode === "modern") {
910
1218
  log("Note: Modern config intentionally shows 12 base OAuth model entries; use the variant picker for reasoning presets.");
911
1219
  }
912
- if (configMode === "legacy") {
1220
+ if (!pluginOnly && configMode === "legacy") {
913
1221
  log("Note: Legacy config writes 53 explicit preset entries and is also safe for older OpenCode versions.");
914
1222
  }
915
- if (configMode === "full") {
1223
+ if (!pluginOnly && configMode === "full") {
916
1224
  log("Note: Full config installs both compact base models and explicit preset entries for direct selector IDs.");
917
1225
  }
918
1226
 
919
1227
  return {
920
1228
  exitCode: 0,
921
1229
  action: "install",
922
- configMode,
1230
+ configMode: effectiveConfigMode,
1231
+ pluginOnly,
923
1232
  configPath: paths.configPath,
924
1233
  tuiConfigPath: paths.tuiConfigPath,
925
1234
  dryRun: Boolean(dryRun),
@@ -932,10 +1241,12 @@ export const __test = {
932
1241
  backupConfig,
933
1242
  copyFileWithWindowsRetry,
934
1243
  formatConfigDiff,
1244
+ formatRedactedConfigDiff,
935
1245
  mergeFullTemplate,
936
1246
  mergeOpenaiProvider,
937
1247
  mergeTuiConfig,
938
1248
  parseCliArgs,
1249
+ removeWithWindowsRetry,
939
1250
  runStandaloneCommand,
940
1251
  splitCommandArgv,
941
1252
  writeFileAtomic,