diff --git a/AGENTS.md b/AGENTS.md index ff4b29a4..9e05ce77 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ Package version: 6.9.1 - Canonical package/plugin name is `oc-codex-multi-auth`. - The npm bin is an installer and thin standalone CLI, not a long-running runtime daemon. - OpenCode loads the provider plugin and TUI plugin from built package exports. -- Default installer mode writes compact modern OpenCode config (12 bases / 53 variants); `--full` adds 53 explicit selector IDs; `--legacy` writes legacy explicit-only config; `--dry-run` and `--no-cache-clear` are supported. +- Default installer mode only registers plugin entries and preserves `provider.openai`; `--modern` writes compact config (12 bases / 53 variants), `--full` adds 53 explicit selector IDs, and `--legacy` writes legacy explicit-only config; `--dry-run` and `--no-cache-clear` are supported. - Runtime requests preserve Codex stateless requirements: `store: false` and `reasoning.encrypted_content`. - GPT-5.6 uses responses-lite shaping and default client identity `opencode`; other models default to `codex_cli_rs`. - Account selection uses `rotationStrategy` (`hybrid` default) with health scoring in `lib/rotation.ts`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 889280c2..0f274872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added a cache-only `update` command and provider-preserving `install --plugin-only` mode. Updating no longer requires invoking the provider/model installer, and manual update notifications now recommend the config-safe command. + +### Changed +- Default install now manages only the OpenCode/TUI plugin entries and preserves `provider.openai`; model catalogs require explicit `--modern`, `--full`, or `--legacy`. Installer writes and backups are skipped when merged configuration is semantically unchanged, plugin-only mode rejects non-object JSON roots, dry-run diffs report changed paths without values, and managed cache cleanup covers bare and `@latest` layouts with retries for transient Windows cache locks. + ## [6.10.1] - 2026-07-23 ### Fixed diff --git a/README.md b/README.md index 5a714d4d..2ed2b9fe 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Use it when you want OpenCode to run Codex-style coding workflows from your own | Surface | Purpose | | --- | --- | -| `oc-codex-multi-auth` | npm installer bin; updates `~/.config/opencode/opencode.json`, manages `tui.json`, normalizes stale plugin entries, and clears OpenCode plugin cache. Also runs standalone commands: `doctor`, `status`, `list`, `limits`, `dashboard`, `health`, `diag`, `warm` | +| `oc-codex-multi-auth` | npm CLI; explicit install modes manage OpenCode provider/TUI config, while `update` only clears the managed package cache. Also runs standalone commands: `doctor`, `status`, `list`, `limits`, `dashboard`, `health`, `diag`, `warm` | | OpenCode plugin entry (`index.ts`) | auth loader, OAuth login modes, provider fetch pipeline, account rotation, retry/failover, and `codex-*` tool registry | | OpenCode TUI plugin (`tui.ts`) | prompt quota status, quota details, shared quota cache, and active-account-aware display | | 24 `codex-*` tools | setup, help, status, list, switch, warm, limits, health, metrics, doctor, dashboard, pool, backup, keychain, diagnostics, and recovery actions | @@ -76,9 +76,9 @@ The plugin does not replace OpenCode. OpenCode remains the host; this package in
For Humans -### Option A: Standard install (compact modern) +### Option A: Standard install (preserve provider config) -Default mode writes 12 base OAuth model families and leaves reasoning depth to OpenCode's variant picker. +Default mode registers the OpenCode and TUI plugin entries without changing `provider.openai`. ```bash npx -y oc-codex-multi-auth@latest @@ -88,13 +88,22 @@ Installer flags: | Flag | Effect | | --- | --- | -| (default) / `--modern` | Compact modern catalog: 12 bases, 53 variants | +| (default) / `--plugin-only` | Register the plugin and TUI integration without changing `provider.openai` | +| `--modern` | Install compact modern catalog: 12 bases, 53 variants | | `--full` | Compact bases plus 53 explicit selector IDs | | `--legacy` | Explicit-only catalog for older OpenCode | -| `--dry-run` | Show actions without writing | +| `--dry-run` | Show changed config paths without values or writes | | `--no-cache-clear` | Skip clearing the OpenCode plugin cache | -### Option B: Full explicit model catalog +### Option B: Compact modern model catalog + +```bash +npx -y oc-codex-multi-auth@latest --modern +``` + +Use this when OpenCode does not already provide the OAuth model definitions or you want the shipped variant presets. + +### Option C: Full explicit model catalog Use this when you want direct selector IDs such as `openai/gpt-5.5-medium` in addition to OpenCode variants. @@ -102,7 +111,15 @@ Use this when you want direct selector IDs such as `openai/gpt-5.5-medium` in ad npx -y oc-codex-multi-auth@latest --full ``` -### Option C: Verify wiring +### Updating without config changes + +```bash +npx -y oc-codex-multi-auth@latest update +``` + +`update` clears only the OpenCode-managed package cache. It does not read or write `opencode.json` or `tui.json`; restart OpenCode afterward to install the current package. + +### Option D: Verify wiring ```bash opencode --version @@ -110,7 +127,7 @@ opencode debug config opencode auth login ``` -The installer updates `~/.config/opencode/opencode.json`, backs up the previous config, normalizes the plugin entry to `"oc-codex-multi-auth"`, enables the TUI status plugin in `~/.config/opencode/tui.json`, and clears the OpenCode cached plugin copy so OpenCode reinstalls the latest package. +The default installer only normalizes the plugin entry in `~/.config/opencode/opencode.json`, enables the TUI status plugin in `~/.config/opencode/tui.json`, and clears the cached plugin copy. Catalog modes additionally merge their selected `provider.openai` definitions. Changed config files are backed up before writing. ### Standalone CLI (no agent / no token cost) @@ -133,13 +150,14 @@ oc-codex-multi-auth diag ### Step-by-step -1. Install or refresh config: +1. Register the plugin without changing `provider.openai`: - `npx -y oc-codex-multi-auth@latest` + - Use `--modern` only when the shipped compact model catalog is required. 2. Run first login flow: - `opencode auth login` 3. Validate config: - `opencode debug config` -4. Run a smoke request (compact modern selectors): +4. Run a smoke request (after OpenCode or `--modern` supplies the selector): - `opencode run "Explain this repository" --model=openai/gpt-5.5 --variant=medium` 5. Inspect plugin state with the OpenCode tool surface: - `codex-status` diff --git a/config/README.md b/config/README.md index 354240bb..ca65bd56 100644 --- a/config/README.md +++ b/config/README.md @@ -13,17 +13,19 @@ This directory contains the official OpenCode config templates for `oc-codex-mul | Installer flag | What gets written | |----------------|-------------------| -| default / `--modern` | Compact modern: 12 base OAuth families + variant picker | +| default / `--plugin-only` | Register plugin entries; preserve `provider.openai` | +| `--modern` | Compact modern: 12 base OAuth families + variant picker | | `--full` | Modern bases **plus** explicit legacy selector IDs | | `--legacy` | Explicit-only catalog (53 preset model entries) | ```bash -npx -y oc-codex-multi-auth@latest # compact modern (default) +npx -y oc-codex-multi-auth@latest # plugin entries only +npx -y oc-codex-multi-auth@latest --modern # compact modern catalog npx -y oc-codex-multi-auth@latest --full # modern + explicit IDs npx -y oc-codex-multi-auth@latest --legacy # explicit only ``` -Rerun the default installer to remove explicit preset IDs and stale base models left by earlier plugin catalogs. +Run the installer with `--modern` to remove explicit preset IDs and stale base models left by earlier plugin catalogs. ## Quick pick diff --git a/docs/architecture.md b/docs/architecture.md index 8c6c6b75..b1c4ddc6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,8 @@ Install modes: | Flag | Config written | | --- | --- | -| (default) / `--modern` | Compact modern: 12 base model families + variant picker (53 variants total) | +| (default) / `--plugin-only` | Register plugin entries; preserve `provider.openai` | +| `--modern` | Compact modern: 12 base model families + variant picker (53 variants total) | | `--full` | Compact modern bases **plus** explicit legacy selector IDs | | `--legacy` | Explicit-only catalog (53 model entries) | diff --git a/docs/configuration.md b/docs/configuration.md index ef213112..3d491125 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -53,7 +53,7 @@ controls how much thinking the model does. | `gpt-5.1-codex-mini` | medium, high | | `gpt-5.1` | none, low, medium, high | -The shipped config templates include 12 base model families and 53 shipped presets overall (53 modern variants or 53 legacy explicit entries). The default installer uses the compact modern template so the TUI model picker shows base OAuth model families and the separate variant picker selects the reasoning preset. Use `--full` to install explicit selector IDs too. `gpt-5.5-pro` is ChatGPT-only (not routed by this plugin), while `gpt-5.3-codex-spark` remains a manual add-on for entitled workspaces only. +The shipped config templates include 12 base model families and 53 shipped presets overall (53 modern variants or 53 legacy explicit entries). Default install preserves `provider.openai`; use `--modern` to install the compact base families and variant picker, or `--full` to install explicit selector IDs too. `gpt-5.5-pro` is ChatGPT-only (not routed by this plugin), while `gpt-5.3-codex-spark` remains a manual add-on for entitled workspaces only. Base families: @@ -106,14 +106,14 @@ model normalization aliases: - `gpt-5.5*`, `gpt-5.5-fast*`, and user-typed `gpt-5.5-pro*` normalize to the public Codex model id `gpt-5.5` - legacy `gpt-5` maps to `gpt-5.5`; legacy `gpt-5-mini` / `gpt-5-nano` map to `gpt-5.4-mini` / `gpt-5.4-nano` - snapshot ids `gpt-5.4-2026-03-05*`, `gpt-5.4-mini-2026-03-05*`, and `gpt-5.4-pro-2026-03-05*` map to stable `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.4-pro` -- `opencode debug config` is the reliable way to confirm merged custom/template model entries; default installs expose compact entries like `gpt-5.5` and `gpt-5.5-fast`, while `--full` also exposes explicit entries like `gpt-5.5-medium`, `gpt-5.5-fast-medium`, and `gpt-5.5-high` +- `opencode debug config` is the reliable way to confirm merged custom/template model entries; `--modern` exposes compact entries like `gpt-5.5` and `gpt-5.5-fast`, while `--full` also exposes explicit entries like `gpt-5.5-medium`, `gpt-5.5-fast-medium`, and `gpt-5.5-high` if your OpenCode runtime supports global compaction tuning, you can set: - `model_context_window = 1000000` - `model_auto_compact_token_limit = 900000` selector note: -- the default installer is optimized for the TUI model picker: choose a base `(OAuth)` model, then choose a variant +- `--modern` is optimized for the TUI model picker: choose a base `(OAuth)` model, then choose a variant - install with `--full` when you need direct selector IDs such as `openai/gpt-5.5-medium` or `openai/gpt-5.5-fast-medium` what they mean: @@ -464,7 +464,7 @@ global (`~/.config/opencode/opencode.json`): project override (`/.opencode.json`) can set a default model or per-project provider options without changing the global install. -### Compact modern selectors (default install) +### Compact modern selectors (`--modern` install) ```bash opencode run "task" --model=openai/gpt-5.5 --variant=medium diff --git a/docs/development/ARCHITECTURE.md b/docs/development/ARCHITECTURE.md index 46fa0b19..e4752a68 100644 --- a/docs/development/ARCHITECTURE.md +++ b/docs/development/ARCHITECTURE.md @@ -24,14 +24,15 @@ Runtime architecture for the `oc-codex-multi-auth` OpenCode plugin, installer, C Install / refresh / standalone CLI | | npx -y oc-codex-multi-auth@latest - | default compact modern | --full | --legacy + | install: default plugin-only | --modern | --full | --legacy + | update: managed package cache only | [--dry-run] [--no-cache-clear] | standalone: doctor | status | list | limits | dashboard | health | diag | warm v scripts/install-oc-codex-multi-auth.js |- delegates to scripts/install-oc-codex-multi-auth-core.js - |- writes ~/.config/opencode/opencode.json - |- writes ~/.config/opencode/tui.json + |- install writes changed ~/.config/opencode/opencode.json and tui.json + |- update never reads or writes OpenCode config |- merges config/opencode-modern.json and/or config/opencode-legacy.json |- normalizes old package/plugin entries |- clears OpenCode plugin cache (unless --no-cache-clear) @@ -260,7 +261,7 @@ The request path also writes quota snapshots from response headers, so the TUI c ## Model Catalog and Fallback Notes -The default installer writes the modern OpenCode template (`config/opencode-modern.json`): +The default installer preserves `provider.openai`. `--modern` writes the modern OpenCode template (`config/opencode-modern.json`): - 12 base model families in the picker: - `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index 82dbcc0e..0b287e90 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -278,7 +278,7 @@ the tool does not automatically prune them because they may be valid elsewhere. Use these commands when validating config fields. -### Compact modern (default install) +### Compact modern (`--modern` install) ```bash opencode debug config diff --git a/docs/development/CONFIG_FLOW.md b/docs/development/CONFIG_FLOW.md index c986165c..111a3ecf 100644 --- a/docs/development/CONFIG_FLOW.md +++ b/docs/development/CONFIG_FLOW.md @@ -48,12 +48,13 @@ That file controls plugin behavior such as retry policy, rotation strategy, begi `scripts/install-oc-codex-multi-auth.js` performs these steps: 1. Load the selected template set: - - default / `--modern`: `config/opencode-modern.json` (compact 12 bases / 53 variants) + - default / `--plugin-only`: preserve `provider.openai` + - `--modern`: `config/opencode-modern.json` (compact 12 bases / 53 variants) - `--full`: modern bases merged with `config/opencode-legacy.json` explicit entries - `--legacy`: `config/opencode-legacy.json` only (53 explicit IDs) -2. Back up an existing `~/.config/opencode/opencode.json`. +2. Back up an existing `~/.config/opencode/opencode.json` only when the merged result changes. 3. Normalize the plugin list so it ends with plain `oc-codex-multi-auth`. -4. Replace `provider.openai` with the selected shipped template block. +4. Merge `provider.openai` with the selected shipped template block; `--plugin-only` skips this step entirely. 5. Enable the TUI plugin in `~/.config/opencode/tui.json`. 6. Clear the cached OpenCode plugin copy under `~/.cache/opencode/` unless `--no-cache-clear`. @@ -61,15 +62,19 @@ Additional flags: | Flag | Effect | |------|--------| -| `--dry-run` | Print planned actions without writing | +| `--dry-run` | Print changed config paths without values or writes | | `--no-cache-clear` | Skip OpenCode plugin cache cleanup | +| `--plugin-only` | Explicit alias for default plugin/TUI registration without changing `provider.openai` | +| `update [--dry-run]` | Clear managed package caches without reading or writing OpenCode config | | standalone first arg | Run CLI without install: `doctor`, `status`, `list`, `limits`, `dashboard`, `health`, `diag`, `warm` | Important detail: - The installer intentionally writes the plugin entry as `oc-codex-multi-auth`, not `oc-codex-multi-auth@latest`. -- The default install mode uses the compact modern base-model template so the TUI model picker shows real OAuth model families and leaves reasoning depth to the variant picker. +- The default install mode only manages plugin entries; catalog installation is explicit. +- `--modern` uses the compact base-model template so the TUI model picker shows real OAuth model families and leaves reasoning depth to the variant picker. - `--full` merges the modern base-model template with the explicit legacy preset entries for scripts that require direct selector IDs. +- `update` returns before template loading and config parsing, so even malformed user config is left untouched. ## Shipped Template Structure @@ -107,11 +112,16 @@ gpt-5-codex ### Default installer mode -The default installer mode writes: +The default installer mode writes only: -- the 12 modern base model entries from `config/opencode-modern.json` +- the `oc-codex-multi-auth` entry in OpenCode's plugin list +- the `oc-codex-multi-auth` entry in the TUI plugin list + +It preserves `provider.openai` exactly. + +### Modern installer mode -That compact install mode keeps the OpenCode TUI model picker focused on actual OAuth model families. Reasoning presets are selected through the separate variant picker. +`--modern` writes the 12 modern base model entries from `config/opencode-modern.json`. Reasoning presets are selected through the separate variant picker. Example shape: @@ -148,7 +158,7 @@ Example shape: } ``` -Default install uses base model IDs plus variants: +Modern install uses base model IDs plus variants: ```bash opencode run "task" --model=openai/gpt-5.5 --variant=medium @@ -221,7 +231,7 @@ ENABLE_PLUGIN_REQUEST_LOGGING=1 opencode run "ping" --model=openai/gpt-5.6-sol - Important runtime behavior: - `opencode debug config` shows merged provider models from your config. -- The default install shows compact OAuth base entries such as `gpt-5.5`, `gpt-5.5-fast`, and `gpt-5.6-sol`. +- `--modern` shows compact OAuth base entries such as `gpt-5.5`, `gpt-5.5-fast`, and `gpt-5.6-sol`. - `--full` additionally shows explicit entries such as `gpt-5.5-medium` / `gpt-5.5-fast-medium` / `gpt-5.6-sol-high`. - Compact verification should use `--model=openai/gpt-5.5 --variant=medium`, not `gpt-5.5-medium`, unless `--full` or `--legacy` was installed. diff --git a/docs/faq.md b/docs/faq.md index 8c6dc3c5..c114da0a 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -44,7 +44,7 @@ The shipped templates include **12 base families** and **53 presets** total: GPT-5.6 is entitlement-gated for some accounts. Without access, the plugin auto-falls back `sol → terra → luna → gpt-5.5` (disable with `CODEX_AUTH_DISABLE_GPT56_AUTO_FALLBACK=1`). Optional or entitlement-gated model IDs can be added manually when your workspace supports them. `gpt-5.5-pro` is ChatGPT-only and is not routed through this Codex plugin. -Default install is compact modern (bases + variants). Use `--full` for modern + explicit IDs, or `--legacy` for explicit-only. +Default install preserves `provider.openai` and only registers plugin entries. Use `--modern` for compact bases + variants, `--full` for modern + explicit IDs, or `--legacy` for explicit-only. ## Can I use multiple accounts? diff --git a/docs/getting-started.md b/docs/getting-started.md index 004c3c82..c8b37f20 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -27,9 +27,15 @@ opencode auth login opencode run "Explain this repository" --model=openai/gpt-5.5 --variant=medium ``` -The installer updates `~/.config/opencode/opencode.json`, backs up the previous config, normalizes the plugin entry to `oc-codex-multi-auth`, enables the TUI status plugin, and clears the cached plugin copy so OpenCode reinstalls the latest package. +The default installer normalizes the plugin entry in `~/.config/opencode/opencode.json`, enables the TUI status plugin, and clears the cached plugin copy so OpenCode reinstalls the latest package. It preserves `provider.openai`. -By default, the installer writes the **compact modern** config so the model picker shows **12 base OAuth model families** (including `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-fast`, and the Codex families). The separate model variant picker selects reasoning presets. Altogether the modern catalog covers **53 variants**. Rerunning the default installer also removes explicit preset entries and stale base models left by earlier plugin catalogs. +To install the **compact modern** config so the model picker shows **12 base OAuth model families** and **53 variants**, opt in explicitly: + +```bash +npx -y oc-codex-multi-auth@latest --modern +``` + +Rerunning `--modern` also removes explicit preset entries and stale base models left by earlier plugin catalogs. If you want direct explicit selector IDs such as `openai/gpt-5.5-medium` (modern bases **plus** explicit entries): @@ -43,6 +49,20 @@ If you explicitly want the older explicit-only layout (53 individual model keys) npx -y oc-codex-multi-auth@latest --legacy ``` +To register the plugin without changing an existing `provider.openai` configuration: + +```bash +npx -y oc-codex-multi-auth@latest install --plugin-only +``` + +To refresh an existing installation without reading or writing either OpenCode config file: + +```bash +npx -y oc-codex-multi-auth@latest update +``` + +The update command clears only the managed package cache. Restart OpenCode afterward. The plugin's automatic updater uses the same cache-only behavior. + ## Install from Source Use this only when you want to develop or test the plugin locally. @@ -163,7 +183,7 @@ ENABLE_PLUGIN_REQUEST_LOGGING=1 opencode run "test" --model=openai/gpt-5.5 --var The first request should create logs under `~/.opencode/logs/codex-plugin/`. -Use `opencode debug config` when you want to verify that template-defined or custom models were merged into your effective config. The default install exposes compact OAuth model entries such as `gpt-5.5` and `gpt-5.6-sol`; `--full` additionally exposes explicit entries such as `gpt-5.5-medium` / `gpt-5.5-fast-medium` / `gpt-5.5-high`. +Use `opencode debug config` when you want to verify custom or template-defined models. Default install preserves the existing model catalog; `--modern` installs compact entries such as `gpt-5.5` and `gpt-5.6-sol`, while `--full` additionally exposes explicit entries such as `gpt-5.5-medium` / `gpt-5.5-fast-medium` / `gpt-5.5-high`. ## Multi-Account Setup diff --git a/docs/tools-and-cli.md b/docs/tools-and-cli.md index 9b26b161..93a524f1 100644 --- a/docs/tools-and-cli.md +++ b/docs/tools-and-cli.md @@ -144,8 +144,9 @@ Bin: `oc-codex-multi-auth` (also via `npx -y oc-codex-multi-auth@latest …`). | `warm` | Open every enabled account's usage window (same idea as `codex-warm`) | ```bash -oc-codex-multi-auth # install (default) +oc-codex-multi-auth # register plugin entries; preserve provider.openai oc-codex-multi-auth install +oc-codex-multi-auth update # cache-only; does not change config oc-codex-multi-auth doctor oc-codex-multi-auth status oc-codex-multi-auth list @@ -162,13 +163,14 @@ oc-codex-multi-auth warm | Flag | Effect | |------|--------| -| `--modern` | Force compact modern config (12 bases + variants) | +| (default) / `--plugin-only` | Register plugin/TUI entries without changing `provider.openai` | +| `--modern` | Install compact modern config (12 bases + variants) | | `--full` | Compact bases plus explicit selector entries | | `--legacy` | Explicit-only catalog (53 entries) | -| `--dry-run` | Show actions without writing | +| `--dry-run` | Show changed config paths without values or writes | | `--no-cache-clear` | Skip clearing OpenCode plugin cache | -Choose only one of `--modern`, `--full`, or `--legacy`. +Choose only one of `--plugin-only`, `--modern`, `--full`, or `--legacy`. Use `update [--dry-run]` when refreshing the package; it clears the managed OpenCode cache without reading or writing `opencode.json` or `tui.json`. ### Standalone options diff --git a/lib/auto-update-checker.ts b/lib/auto-update-checker.ts index 8dd6b7b7..1e91b020 100644 --- a/lib/auto-update-checker.ts +++ b/lib/auto-update-checker.ts @@ -112,6 +112,7 @@ function getManagedPackageNames(): string[] { function getManagedCachePaths(): string[] { return getManagedPackageNames().flatMap((name) => [ + join(OPENCODE_CACHE_DIR, "packages", name), join(OPENCODE_CACHE_DIR, "packages", `${name}@latest`), join(OPENCODE_CACHE_DIR, "node_modules", name), ]); @@ -158,7 +159,7 @@ export async function checkForUpdates(force = false): Promise hasUpdate, currentVersion, latestVersion: cache.latestVersion, - updateCommand: `npm update -g ${PACKAGE_NAME}`, + updateCommand: `npx -y ${PACKAGE_NAME}@latest update`, }; } @@ -176,7 +177,7 @@ export async function checkForUpdates(force = false): Promise hasUpdate, currentVersion, latestVersion, - updateCommand: `npm update -g ${PACKAGE_NAME}`, + updateCommand: `npx -y ${PACKAGE_NAME}@latest update`, }; } diff --git a/scripts/install-oc-codex-multi-auth-core.js b/scripts/install-oc-codex-multi-auth-core.js index 894055d5..36af4016 100644 --- a/scripts/install-oc-codex-multi-auth-core.js +++ b/scripts/install-oc-codex-multi-auth-core.js @@ -15,11 +15,13 @@ const STALE_MANAGED_MODEL_KEYS = new Set([ ]); const STANDALONE_COMMANDS = new Set(["doctor", "status", "list", "limits", "dashboard", "health", "diag", "warm"]); const INSTALLER_COMMANDS = new Set(["install"]); +const UPDATE_COMMANDS = new Set(["update"]); function splitCommandArgv(argv) { const [first, ...rest] = argv; if (!first) return { kind: "install", argv }; if (INSTALLER_COMMANDS.has(first)) return { kind: "install", argv: rest }; + if (UPDATE_COMMANDS.has(first)) return { kind: "update", argv: rest }; if (STANDALONE_COMMANDS.has(first)) return { kind: "standalone", command: first, argv: rest }; if (first.startsWith("-")) return { kind: "install", argv }; return { kind: "unknown", command: first, argv: rest }; @@ -76,7 +78,8 @@ export function isDirectRunPath(argvPath, modulePath, resolveRealPath = realpath function printHelp() { console.log(`Usage: ${PACKAGE_NAME} [command] [options]\n\n` + "Commands:\n" + - " install Install/update OpenCode config (default with no command)\n" + + " install Register plugin entries (default with no command)\n" + + " update Refresh the cached package without changing OpenCode config\n" + " doctor Run local account/config diagnostics\n" + " status Show account/config status\n" + " list List configured accounts\n" + @@ -85,14 +88,16 @@ function printHelp() { " health Check local token/account health\n" + " diag Alias for doctor --deep\n" + " warm Open every enabled account's usage window now (one request each)\n\n" + - `Installer usage: ${PACKAGE_NAME} [--modern|--full|--legacy] [--dry-run] [--no-cache-clear]\n\n` + + `Installer usage: ${PACKAGE_NAME} install [--plugin-only|--modern|--full|--legacy] [--dry-run] [--no-cache-clear]\n` + + `Updater usage: ${PACKAGE_NAME} update [--dry-run]\n\n` + "Default behavior:\n" + - " - Installs/updates global config at ~/.config/opencode/opencode.json\n" + + " - Registers plugin entries without changing provider.openai\n" + " - Enables the prompt status bar TUI plugin at ~/.config/opencode/tui.json\n" + - " - Uses compact UI config by default (12 base OAuth models + variant picker presets)\n" + + " - Installs model catalogs only with --modern, --full, or --legacy\n" + " - Ensures plugin is unpinned (latest)\n" + " - Clears OpenCode plugin cache\n\n" + "Options:\n" + + " --plugin-only Register plugins without changing provider.openai\n" + " --modern Force compact modern config (12 base OAuth models + --variant presets)\n" + " --full Install compact base models plus 53 explicit selector entries\n" + " --legacy Force explicit legacy config (53 preset model entries)\n" + @@ -139,7 +144,10 @@ function buildPaths(homeDir) { tuiConfigPath: join(configDir, "tui.json"), cacheDir, cacheNodeModulesPaths: getManagedPackageNames().map((name) => join(cacheDir, "node_modules", name)), - cachePackagePaths: getManagedPackageNames().map((name) => join(cacheDir, "packages", `${name}@latest`)), + cachePackagePaths: getManagedPackageNames().flatMap((name) => [ + join(cacheDir, "packages", name), + join(cacheDir, "packages", `${name}@latest`), + ]), cacheBunLock: join(cacheDir, "bun.lock"), cachePackageJson: join(cacheDir, "package.json"), modernTemplatePath, @@ -158,21 +166,40 @@ function parseCliArgs(argv = process.argv.slice(2)) { const requestedModern = args.has("--modern"); const requestedFull = args.has("--full"); const requestedLegacy = args.has("--legacy"); + const explicitPluginOnly = args.has("--plugin-only"); const requestedModes = [requestedModern, requestedFull, requestedLegacy] .filter(Boolean).length; if (requestedModes > 1) { throw new Error("Choose only one of --modern, --full, or --legacy."); } + if (explicitPluginOnly && requestedModes > 0) { + throw new Error("--plugin-only cannot be combined with --modern, --full, or --legacy."); + } + const pluginOnly = explicitPluginOnly || requestedModes === 0; return { wantsHelp: false, dryRun: args.has("--dry-run"), skipCacheClear: args.has("--no-cache-clear"), + pluginOnly, configMode: requestedFull ? "full" : requestedLegacy ? "legacy" : "modern", }; } +function parseUpdateArgs(argv) { + const args = new Set(argv); + const supported = new Set(["--dry-run", "--help", "-h"]); + const unknown = argv.find((arg) => !supported.has(arg)); + if (unknown) { + throw new Error(`Unknown option for update command: ${unknown}`); + } + return { + wantsHelp: args.has("--help") || args.has("-h"), + dryRun: args.has("--dry-run"), + }; +} + function normalizePluginEntryForMatch(entry) { const trimmed = entry.trim(); let normalized = trimmed.toLowerCase(); @@ -602,6 +629,51 @@ function formatConfigDiff(existingConfig, nextConfig) { return lines.join("\n"); } +function formatRedactedConfigDiff(existingConfig, nextConfig) { + const missing = Symbol("missing"); + const changes = []; + const visit = (existing, next, path) => { + if (existing === missing) { + changes.push(`+ ${path}`); + return; + } + if (next === missing) { + changes.push(`- ${path}`); + return; + } + if (Object.is(existing, next)) return; + + if (Array.isArray(existing) && Array.isArray(next)) { + const length = Math.max(existing.length, next.length); + for (let index = 0; index < length; index += 1) { + visit( + index < existing.length ? existing[index] : missing, + index < next.length ? next[index] : missing, + `${path}[${index}]`, + ); + } + return; + } + + if (isPlainObject(existing) && isPlainObject(next)) { + const keys = new Set([...Object.keys(existing), ...Object.keys(next)]); + for (const key of keys) { + visit( + Object.hasOwn(existing, key) ? existing[key] : missing, + Object.hasOwn(next, key) ? next[key] : missing, + `${path}.${key}`, + ); + } + return; + } + + changes.push(`~ ${path}`); + }; + + visit(existingConfig === undefined ? missing : existingConfig, nextConfig, "$"); + return changes.length > 0 ? changes.join("\n") : "(no changes)"; +} + function mergeFullTemplate(modernTemplate, legacyTemplate) { const modernModels = modernTemplate.provider?.openai?.models ?? {}; const legacyModels = legacyTemplate.provider?.openai?.models ?? {}; @@ -657,6 +729,28 @@ async function renameWithWindowsRetry(sourcePath, destinationPath) { } } +async function removeWithWindowsRetry(path, options) { + let lastError = null; + + for (let attempt = 0; attempt < WINDOWS_RENAME_RETRY_ATTEMPTS; attempt += 1) { + try { + await rm(path, options); + return; + } catch (error) { + if (isWindowsLockError(error)) { + lastError = error; + await delay(WINDOWS_RENAME_RETRY_BASE_DELAY_MS * 2 ** attempt); + continue; + } + throw error; + } + } + + if (lastError) { + throw lastError; + } +} + async function writeFileAtomic(filePath, content) { const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const tempPath = `${filePath}.${uniqueSuffix}.tmp`; @@ -784,12 +878,12 @@ async function clearCache(paths, dryRun, skipCacheClear) { log(`[dry-run] Would remove ${paths.cacheBunLock}`); } else { for (const cacheNodeModulesPath of paths.cacheNodeModulesPaths) { - await rm(cacheNodeModulesPath, { recursive: true, force: true }); + await removeWithWindowsRetry(cacheNodeModulesPath, { recursive: true, force: true }); } for (const cachePackagePath of paths.cachePackagePaths) { - await rm(cachePackagePath, { recursive: true, force: true }); + await removeWithWindowsRetry(cachePackagePath, { recursive: true, force: true }); } - await rm(paths.cacheBunLock, { force: true }); + await removeWithWindowsRetry(paths.cacheBunLock, { force: true }); } await removePluginFromCachePackage(paths, dryRun); @@ -804,20 +898,37 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { printHelp(); throw new Error(`Unknown command: ${split.command}`); } + const { env = process.env } = options; + const paths = buildPaths(resolveHomeDirectory(env)); + if (split.kind === "update") { + const parsedUpdate = parseUpdateArgs(split.argv); + if (parsedUpdate.wantsHelp) { + printHelp(); + return { exitCode: 0, action: "help" }; + } + await clearCache(paths, parsedUpdate.dryRun, false); + log(`\n${parsedUpdate.dryRun ? "Dry run complete." : "Cache cleared."} Restart OpenCode to install the latest plugin.`); + return { + exitCode: 0, + action: "update", + dryRun: Boolean(parsedUpdate.dryRun), + }; + } const parsed = parseCliArgs(split.argv); if (parsed.wantsHelp) { printHelp(); return { exitCode: 0, action: "help" }; } - const { env = process.env } = options; - const { configMode, dryRun, skipCacheClear } = parsed; - const paths = buildPaths(resolveHomeDirectory(env)); - const requiredTemplatePaths = configMode === "modern" - ? [paths.modernTemplatePath] - : configMode === "legacy" - ? [paths.legacyTemplatePath] - : [paths.modernTemplatePath, paths.legacyTemplatePath]; + const { configMode, dryRun, skipCacheClear, pluginOnly } = parsed; + const effectiveConfigMode = pluginOnly ? "plugin-only" : configMode; + const requiredTemplatePaths = pluginOnly + ? [] + : configMode === "modern" + ? [paths.modernTemplatePath] + : configMode === "legacy" + ? [paths.legacyTemplatePath] + : [paths.modernTemplatePath, paths.legacyTemplatePath]; for (const templatePath of requiredTemplatePaths) { if (!existsSync(templatePath)) { @@ -825,40 +936,51 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { } } - const template = await loadTemplate(configMode, paths); + const template = pluginOnly + ? { $schema: "https://opencode.ai/config.json", plugin: [PACKAGE_NAME] } + : await loadTemplate(configMode, paths); template.plugin = [PACKAGE_NAME]; const modelKeysToRemove = new Set(STALE_MANAGED_MODEL_KEYS); - if (configMode === "modern") { + if (!pluginOnly && configMode === "modern") { for (const key of getTemplateModelKeys(await readJson(paths.legacyTemplatePath))) { modelKeysToRemove.add(key); } } - if (configMode === "legacy") { + if (!pluginOnly && configMode === "legacy") { for (const key of getTemplateModelKeys(await readJson(paths.modernTemplatePath))) { modelKeysToRemove.add(key); } } - let nextConfig = template; + let nextConfig = pluginOnly + ? { $schema: template.$schema, plugin: [PACKAGE_NAME] } + : template; let existingConfig; if (existsSync(paths.configPath)) { - const backupPath = await backupConfig(paths.configPath, dryRun); - log(`${dryRun ? "[dry-run] Would create backup" : "Backup created"}: ${backupPath}`); - try { const existing = await readJson(paths.configPath); + if (!isPlainObject(existing)) { + throw new Error("config root must be a JSON object"); + } existingConfig = existing; const merged = { ...existing }; merged.plugin = normalizePluginList(existing.plugin); - const provider = (existing.provider && typeof existing.provider === "object") - ? { ...existing.provider } - : {}; - provider.openai = mergeOpenaiProvider(existing.provider?.openai, template.provider?.openai, { - modelKeysToRemove, - }); - merged.provider = provider; + if (!pluginOnly) { + const provider = (existing.provider && typeof existing.provider === "object") + ? { ...existing.provider } + : {}; + provider.openai = mergeOpenaiProvider(existing.provider?.openai, template.provider?.openai, { + modelKeysToRemove, + }); + merged.provider = provider; + } nextConfig = merged; } catch (error) { + if (pluginOnly) { + throw new Error( + `Could not parse existing config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`, + ); + } log(`Warning: Could not parse existing config (${formatErrorForLog(error)}). Replacing with template.`); existingConfig = undefined; nextConfig = template; @@ -870,14 +992,19 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { let nextTuiConfig = mergeTuiConfig(undefined); let existingTuiConfig; if (existsSync(paths.tuiConfigPath)) { - const backupPath = await backupConfig(paths.tuiConfigPath, dryRun); - log(`${dryRun ? "[dry-run] Would create backup" : "Backup created"}: ${backupPath}`); - try { const existing = await readJson(paths.tuiConfigPath); + if (!isPlainObject(existing)) { + throw new Error("TUI config root must be a JSON object"); + } existingTuiConfig = existing; nextTuiConfig = mergeTuiConfig(existing); } catch (error) { + if (pluginOnly) { + throw new Error( + `Could not parse existing TUI config (${formatErrorForLog(error)}). Refusing to replace it in --plugin-only mode.`, + ); + } log(`Warning: Could not parse existing TUI config (${formatErrorForLog(error)}). Replacing with minimal TUI config.`); existingTuiConfig = undefined; nextTuiConfig = mergeTuiConfig(undefined); @@ -886,40 +1013,60 @@ export async function runInstaller(argv = process.argv.slice(2), options = {}) { log("No existing TUI config found. Creating new global TUI config."); } + const configChanged = existingConfig === undefined || formatJson(existingConfig) !== formatJson(nextConfig); + const tuiConfigChanged = existingTuiConfig === undefined || formatJson(existingTuiConfig) !== formatJson(nextTuiConfig); let wrote = false; if (dryRun) { - log(`[dry-run] Would write ${paths.configPath} using ${configMode} config`); + log(`[dry-run] ${configChanged ? "Would write" : "Would leave unchanged"} ${paths.configPath} using ${effectiveConfigMode} config`); log(`[dry-run] Diff for ${paths.configPath}:`); - log(formatConfigDiff(existingConfig, nextConfig)); - log(`[dry-run] Would write ${paths.tuiConfigPath} with the TUI status plugin`); + log(formatRedactedConfigDiff(existingConfig, nextConfig)); + log(`[dry-run] ${tuiConfigChanged ? "Would write" : "Would leave unchanged"} ${paths.tuiConfigPath} with the TUI status plugin`); log(`[dry-run] Diff for ${paths.tuiConfigPath}:`); - log(formatConfigDiff(existingTuiConfig, nextTuiConfig)); + log(formatRedactedConfigDiff(existingTuiConfig, nextTuiConfig)); } else { - await writeFileAtomic(paths.configPath, formatJson(nextConfig)); - await writeFileAtomic(paths.tuiConfigPath, formatJson(nextTuiConfig)); - wrote = true; - log(`Wrote ${paths.configPath} (${configMode} config)`); - log(`Wrote ${paths.tuiConfigPath} (TUI status plugin)`); + if (configChanged) { + if (existsSync(paths.configPath)) { + const backupPath = await backupConfig(paths.configPath, false); + log(`Backup created: ${backupPath}`); + } + await writeFileAtomic(paths.configPath, formatJson(nextConfig)); + wrote = true; + log(`Wrote ${paths.configPath} (${effectiveConfigMode} config)`); + } else { + log(`Left ${paths.configPath} unchanged`); + } + if (tuiConfigChanged) { + if (existsSync(paths.tuiConfigPath)) { + const backupPath = await backupConfig(paths.tuiConfigPath, false); + log(`Backup created: ${backupPath}`); + } + await writeFileAtomic(paths.tuiConfigPath, formatJson(nextTuiConfig)); + wrote = true; + log(`Wrote ${paths.tuiConfigPath} (TUI status plugin)`); + } else { + log(`Left ${paths.tuiConfigPath} unchanged`); + } } await clearCache(paths, dryRun, skipCacheClear); log("\nDone. Restart OpenCode to (re)install the plugin."); log("Example: opencode"); - if (configMode === "modern") { + if (!pluginOnly && configMode === "modern") { log("Note: Modern config intentionally shows 12 base OAuth model entries; use the variant picker for reasoning presets."); } - if (configMode === "legacy") { + if (!pluginOnly && configMode === "legacy") { log("Note: Legacy config writes 53 explicit preset entries and is also safe for older OpenCode versions."); } - if (configMode === "full") { + if (!pluginOnly && configMode === "full") { log("Note: Full config installs both compact base models and explicit preset entries for direct selector IDs."); } return { exitCode: 0, action: "install", - configMode, + configMode: effectiveConfigMode, + pluginOnly, configPath: paths.configPath, tuiConfigPath: paths.tuiConfigPath, dryRun: Boolean(dryRun), @@ -932,10 +1079,12 @@ export const __test = { backupConfig, copyFileWithWindowsRetry, formatConfigDiff, + formatRedactedConfigDiff, mergeFullTemplate, mergeOpenaiProvider, mergeTuiConfig, parseCliArgs, + removeWithWindowsRetry, runStandaloneCommand, splitCommandArgv, writeFileAtomic, diff --git a/skills/oc-codex-setup/SKILL.md b/skills/oc-codex-setup/SKILL.md index 48654ac4..3c81bbbd 100644 --- a/skills/oc-codex-setup/SKILL.md +++ b/skills/oc-codex-setup/SKILL.md @@ -7,13 +7,37 @@ description: Install or refresh oc-codex-multi-auth in OpenCode, choose the righ Use this skill when the user wants to install, reinstall, upgrade, or troubleshoot `oc-codex-multi-auth` in OpenCode. -## Default install (compact modern) +## Default install (provider preserving) ```bash npx -y oc-codex-multi-auth@latest ``` -This is the default. It installs the compact modern catalog: 12 base OAuth model families with OpenCode variant presets (53 total variants). The TUI model picker shows bases such as `gpt-5.5` and `gpt-5.6-sol`; reasoning depth is selected with `--variant`. +This is the default. It registers the OpenCode and TUI plugin entries without changing `provider.openai`. + +## Compact modern catalog + +```bash +npx -y oc-codex-multi-auth@latest --modern +``` + +Use this when the shipped 12 base OAuth model families and 53 OpenCode variant presets are required. + +## Config-safe update + +```bash +npx -y oc-codex-multi-auth@latest update +``` + +Use this to refresh an existing installation. It only clears the managed package cache and never reads or writes `opencode.json` or `tui.json`. Restart OpenCode afterward. + +## Plugin-only install + +```bash +npx -y oc-codex-multi-auth@latest install --plugin-only +``` + +Use this when the user already manages `provider.openai`. It registers the OpenCode and TUI plugin entries without changing that provider configuration. ## Full install (explicit selector IDs) @@ -33,9 +57,10 @@ Use this on older OpenCode versions that do not support variant-based model entr ## Other installer flags -- `--dry-run` — show planned actions without writing files +- `--dry-run` — show changed config paths without values or writes - `--no-cache-clear` — skip clearing the OpenCode plugin cache -- `--modern` — same compact modern catalog as the default +- `--modern` — install the compact modern catalog +- `--plugin-only` — preserve `provider.openai`; cannot be combined with a catalog mode ## Standalone CLI (no agent cost) @@ -51,7 +76,7 @@ Also available: `limits`, `dashboard`, `health`, `diag`. ## Login and verification 1. Run `opencode auth login`. -2. Run a quick verification request with **compact modern** selectors (default install): +2. Run a quick verification request after OpenCode or `--modern` supplies the selector: ```bash opencode run "Explain this repository" --model=openai/gpt-5.5 --variant=medium diff --git a/test/auto-update-checker.test.ts b/test/auto-update-checker.test.ts index 69f61c60..6f64aeae 100644 --- a/test/auto-update-checker.test.ts +++ b/test/auto-update-checker.test.ts @@ -232,7 +232,7 @@ describe("auto-update-checker", () => { const result = await checkForUpdates(true); - expect(result.updateCommand).toContain("npm update -g"); + expect(result.updateCommand).toBe("npx -y oc-codex-multi-auth@latest update"); }); }); @@ -268,7 +268,7 @@ describe("auto-update-checker", () => { }); expect(showToast).toHaveBeenCalledWith( - expect.stringContaining("Run: npm update -g"), + expect.stringContaining("Run: npx -y oc-codex-multi-auth@latest update"), "info" ); expect(scheduleCacheClear).not.toHaveBeenCalled(); diff --git a/test/install-oc-codex-multi-auth.test.ts b/test/install-oc-codex-multi-auth.test.ts index c29087a4..7247e9bb 100644 --- a/test/install-oc-codex-multi-auth.test.ts +++ b/test/install-oc-codex-multi-auth.test.ts @@ -62,7 +62,7 @@ describe("install-oc-codex-multi-auth script", () => { expect(isDirectRunPath(undefined, scriptPath)).toBe(false); }); - it("writes compact UI catalog by default, preserves user model entries, and normalizes plugin entries", async () => { + it("writes compact UI catalog with --modern, preserves user model entries, and normalizes plugin entries", async () => { vi.resetModules(); tempHome = await createTempHome(); const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); @@ -96,7 +96,7 @@ describe("install-oc-codex-multi-auth script", () => { ); await expect( - runInstaller(["--no-cache-clear"], { + runInstaller(["--modern", "--no-cache-clear"], { env: { ...process.env, HOME: tempHome, @@ -145,6 +145,45 @@ describe("install-oc-codex-multi-auth script", () => { ); }); + it("default install registers plugin entries without changing provider.openai", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const openai = { + baseURL: "https://example.invalid/v1", + apiKey: "{env:OPENAI_API_KEY}", + options: { store: true, customOption: "keep" }, + models: { custom: { name: "Custom model" } }, + }; + + await mkdir(configDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ plugin: ["existing-plugin"], provider: { openai } }, null, 2), + "utf-8", + ); + + await expect( + runInstaller(["--no-cache-clear"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ + action: "install", + configMode: "plugin-only", + pluginOnly: true, + exitCode: 0, + }); + + const saved = JSON.parse(await readFile(configPath, "utf-8")) as { + plugin: string[]; + provider: { openai: typeof openai }; + }; + expect(saved.plugin).toEqual(["existing-plugin", "oc-codex-multi-auth"]); + expect(saved.provider.openai).toEqual(openai); + }); + it("writes the merged full catalog when --full is requested", async () => { vi.resetModules(); tempHome = await createTempHome(); @@ -267,7 +306,7 @@ describe("install-oc-codex-multi-auth script", () => { await writeFile(configPath, `\uFEFF${JSON.stringify(existing, null, 2)}`, "utf-8"); await expect( - runInstaller(["--no-cache-clear"], { + runInstaller(["--modern", "--no-cache-clear"], { env: { ...process.env, HOME: tempHome, @@ -324,7 +363,7 @@ describe("install-oc-codex-multi-auth script", () => { ); await expect( - runInstaller(["--no-cache-clear"], { + runInstaller(["--modern", "--no-cache-clear"], { env: { ...process.env, HOME: tempHome, @@ -379,7 +418,7 @@ describe("install-oc-codex-multi-auth script", () => { ); await expect( - runInstaller(["--no-cache-clear"], { + runInstaller(["--modern", "--no-cache-clear"], { env: { ...process.env, HOME: tempHome, @@ -428,8 +467,12 @@ describe("install-oc-codex-multi-auth script", () => { configPath, JSON.stringify({ plugin: ["existing-plugin"], + secretToken: "do-not-print-this-token", + accountEmail: "private@example.com", + rawPrompt: "do not print this prompt", provider: { openai: { + apiKey: "do-not-print-this-api-key", models: { "pre-existing": { name: "pre-existing" } }, }, }, @@ -437,7 +480,7 @@ describe("install-oc-codex-multi-auth script", () => { "utf-8", ); - const result = await runInstaller(["--dry-run", "--no-cache-clear"], { + const result = await runInstaller(["--modern", "--dry-run", "--no-cache-clear"], { env: { ...process.env, HOME: tempHome, @@ -449,9 +492,11 @@ describe("install-oc-codex-multi-auth script", () => { const stdout = logSpy.mock.calls.map((call) => String(call[0])).join("\n"); expect(stdout).toContain("[dry-run] Diff for"); - expect(stdout).toContain("provider"); - expect(stdout).toContain("--- existing"); - expect(stdout).toContain("+++ proposed"); + expect(stdout).toContain("$.provider"); + expect(stdout).not.toContain("do-not-print-this-token"); + expect(stdout).not.toContain("private@example.com"); + expect(stdout).not.toContain("do not print this prompt"); + expect(stdout).not.toContain("do-not-print-this-api-key"); // Disk state must remain the literal prior contents (no overwrite, no backup write). const onDisk = JSON.parse(await readFile(configPath, "utf-8")) as { @@ -475,6 +520,194 @@ describe("install-oc-codex-multi-auth script", () => { expect(diff).toContain("provider"); }); + it("update clears bare and latest cache layouts without reading or writing config", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const tuiConfigPath = join(configDir, "tui.json"); + const cacheDir = join(tempHome, ".cache", "opencode", "packages"); + const bareCache = join(cacheDir, "oc-codex-multi-auth"); + const latestCache = join(cacheDir, "oc-codex-multi-auth@latest"); + const invalidConfig = "{ this is intentionally invalid json"; + const invalidTuiConfig = "{ this is also intentionally invalid json"; + + await mkdir(configDir, { recursive: true }); + await mkdir(bareCache, { recursive: true }); + await mkdir(latestCache, { recursive: true }); + await writeFile(configPath, invalidConfig, "utf-8"); + await writeFile(tuiConfigPath, invalidTuiConfig, "utf-8"); + await writeFile(join(bareCache, "package.json"), "{}", "utf-8"); + await writeFile(join(latestCache, "package.json"), "{}", "utf-8"); + + await expect( + runInstaller(["update"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ action: "update", dryRun: false, exitCode: 0 }); + + await expect(readFile(configPath, "utf-8")).resolves.toBe(invalidConfig); + await expect(readFile(tuiConfigPath, "utf-8")).resolves.toBe(invalidTuiConfig); + await expect(readdir(cacheDir)).resolves.toEqual([]); + await expect(readdir(configDir)).resolves.toEqual(["opencode.json", "tui.json"]); + }); + + it("install --plugin-only preserves provider.openai while registering the plugin", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const openai = { + baseURL: "https://example.invalid/v1", + apiKey: "{env:OPENAI_API_KEY}", + options: { store: true, customOption: "keep" }, + models: { custom: { name: "Custom model" } }, + }; + + await mkdir(configDir, { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ plugin: ["existing-plugin"], provider: { openai } }, null, 2), + "utf-8", + ); + + await expect( + runInstaller(["install", "--plugin-only", "--no-cache-clear"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ + action: "install", + pluginOnly: true, + exitCode: 0, + }); + + const saved = JSON.parse(await readFile(configPath, "utf-8")) as { + plugin: string[]; + provider: { openai: typeof openai }; + }; + expect(saved.plugin).toEqual(["existing-plugin", "oc-codex-multi-auth"]); + expect(saved.provider.openai).toEqual(openai); + }); + + it("does not rewrite or back up semantically unchanged plugin-only config", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const tuiConfigPath = join(configDir, "tui.json"); + const configText = '{"plugin":["oc-codex-multi-auth"],"provider":{"openai":{"custom":true}}}'; + const tuiText = '{"$schema":"https://opencode.ai/tui.json","plugin":["oc-codex-multi-auth"]}'; + + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, configText, "utf-8"); + await writeFile(tuiConfigPath, tuiText, "utf-8"); + + await expect( + runInstaller(["install", "--plugin-only", "--no-cache-clear"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ wrote: false, pluginOnly: true, exitCode: 0 }); + + await expect(readFile(configPath, "utf-8")).resolves.toBe(configText); + await expect(readFile(tuiConfigPath, "utf-8")).resolves.toBe(tuiText); + await expect(readdir(configDir)).resolves.toEqual(["opencode.json", "tui.json"]); + }); + + it("install --plugin-only refuses to replace malformed config", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const invalidConfig = "{ invalid"; + + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, invalidConfig, "utf-8"); + + await expect( + runInstaller(["install", "--plugin-only"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).rejects.toThrow("Could not parse existing config"); + await expect(readFile(configPath, "utf-8")).resolves.toBe(invalidConfig); + await expect(readdir(configDir)).resolves.toEqual(["opencode.json"]); + }); + + it.each([ + { label: "null", content: "null" }, + { label: "array", content: "[]" }, + { label: "string", content: '"invalid"' }, + { label: "number", content: "42" }, + ])("default install refuses structurally invalid $label config", async ({ content }) => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, content, "utf-8"); + + await expect( + runInstaller([], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).rejects.toThrow("config root must be a JSON object"); + await expect(readFile(configPath, "utf-8")).resolves.toBe(content); + await expect(readdir(configDir)).resolves.toEqual(["opencode.json"]); + }); + + it("install --plugin-only refuses to replace malformed TUI config", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const tuiConfigPath = join(configDir, "tui.json"); + const configText = '{"plugin":["oc-codex-multi-auth"]}'; + const invalidTuiConfig = "{ invalid"; + + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, configText, "utf-8"); + await writeFile(tuiConfigPath, invalidTuiConfig, "utf-8"); + + await expect( + runInstaller(["install", "--plugin-only"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).rejects.toThrow("Could not parse existing TUI config"); + await expect(readFile(configPath, "utf-8")).resolves.toBe(configText); + await expect(readFile(tuiConfigPath, "utf-8")).resolves.toBe(invalidTuiConfig); + await expect(readdir(configDir)).resolves.toEqual(["opencode.json", "tui.json"]); + }); + + it("default install refuses structurally invalid TUI config", async () => { + vi.resetModules(); + tempHome = await createTempHome(); + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + const configDir = join(tempHome, ".config", "opencode"); + const configPath = join(configDir, "opencode.json"); + const tuiConfigPath = join(configDir, "tui.json"); + const configText = '{"plugin":["oc-codex-multi-auth"]}'; + const invalidTuiConfig = "[]"; + + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, configText, "utf-8"); + await writeFile(tuiConfigPath, invalidTuiConfig, "utf-8"); + + await expect( + runInstaller([], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).rejects.toThrow("TUI config root must be a JSON object"); + await expect(readFile(configPath, "utf-8")).resolves.toBe(configText); + await expect(readFile(tuiConfigPath, "utf-8")).resolves.toBe(invalidTuiConfig); + await expect(readdir(configDir)).resolves.toEqual(["opencode.json", "tui.json"]); + }); + it("mergeOpenaiProvider unit: strips unknown managed keys even when template omits them", async () => { vi.resetModules(); const { __test } = await import("../scripts/install-oc-codex-multi-auth-core.js"); @@ -577,7 +810,7 @@ describe("install-oc-codex-multi-auth script", () => { }), ).resolves.toMatchObject({ action: "install", - configMode: "modern", + configMode: "plugin-only", exitCode: 0, }); @@ -643,7 +876,7 @@ describe("install-oc-codex-multi-auth script", () => { }), ).resolves.toMatchObject({ action: "install", - configMode: "modern", + configMode: "plugin-only", exitCode: 0, }); @@ -734,4 +967,31 @@ describe("install-oc-codex-multi-auth script", () => { expect(renameMock).toHaveBeenNthCalledWith(1, "from.tmp", "to.json"); expect(renameMock).toHaveBeenNthCalledWith(2, "from.tmp", "to.json"); }); + + it.each(["EPERM", "EBUSY"])("retries update cache removal after transient Windows %s errors", async (code) => { + vi.resetModules(); + tempHome = await createTempHome(); + const rmMock = vi.fn() + .mockRejectedValueOnce(Object.assign(new Error("locked"), { code })) + .mockResolvedValue(undefined); + + vi.doMock("node:fs/promises", async () => { + const actual = await vi.importActual("node:fs/promises"); + return { + ...actual, + rm: rmMock, + }; + }); + + const { runInstaller } = await import("../scripts/install-oc-codex-multi-auth-core.js"); + await expect( + runInstaller(["update"], { + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome }, + }), + ).resolves.toMatchObject({ action: "update", exitCode: 0 }); + + const firstCachePath = join(tempHome, ".cache", "opencode", "node_modules", "oc-codex-multi-auth"); + expect(rmMock).toHaveBeenNthCalledWith(1, firstCachePath, { recursive: true, force: true }); + expect(rmMock).toHaveBeenNthCalledWith(2, firstCachePath, { recursive: true, force: true }); + }); });