From 28ca1b56625d85396fff126113b9ddb8b7736a3d Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 01:57:39 -0400 Subject: [PATCH 1/7] feat(config): v2-native plugins registrations with legacy v1 read-only tolerance (issue #26) The editor now writes and verifies the v2 plugins key; the v1 plugin key is parsed read-only everywhere: a matching legacy entry is a zero-write no-op with an upgrade advisory (config.json additionally flagged as no longer read by v2 per opencode-v2-facts SS5, SS13-3). v2 object-form entries ({ package, options }) match semantically and uninstall removes them. Splice machinery targets the plugins key; findRegistration covers both keys so status sees legacy registrations. --- src/plugin-config.ts | 121 ++++++++++++++++++++++++------- tests/plugin-config.test.ts | 138 +++++++++++++++++++++++++++++------- 2 files changed, 208 insertions(+), 51 deletions(-) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 79c8b54..1cec2c4 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -25,6 +25,7 @@ interface CandidateRead { candidate: CandidateConfig; text: string; plugins: string[] | null; + legacyPlugins: string[] | null; } export interface RemovePluginEntryOutcome { @@ -43,7 +44,7 @@ interface PluginArrayRange { const DEFAULT_CONFIG_TEMPLATE = `{ // OpenCode configuration "$schema": "https://opencode.ai/config.json", - "plugin": ["__PACKAGE_NAME__"] + "plugins": ["__PACKAGE_NAME__"] } `; @@ -53,7 +54,7 @@ export class PluginConfigEditor { options: EnsurePluginEntryOptions, ): Promise { const canonical = this.canonicalEntry(packageName); - for (const { candidate, text, plugins } of await this.readCandidates(options)) { + for (const { candidate, text, plugins, legacyPlugins } of await this.readCandidates(options)) { if (plugins === null) { return { action: "blocked", @@ -66,6 +67,13 @@ export class PluginConfigEditor { if (this.hasMatchingEntry(plugins, packageName)) { return { action: "noop", configPath: candidate.path, warning: null }; } + if (legacyPlugins !== null && this.hasMatchingEntry(legacyPlugins, packageName)) { + return { + action: "noop", + configPath: candidate.path, + warning: this.legacyEntryAdvisory(candidate, packageName), + }; + } if (!candidate.writable) continue; const spliced = this.spliceEntry(text, canonical, packageName, candidate.lenient); if (spliced === null) { @@ -107,9 +115,12 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { - for (const { candidate, plugins } of await this.readCandidates(options)) { + for (const { candidate, plugins, legacyPlugins } of await this.readCandidates(options)) { if (plugins === null) continue; - if (this.hasMatchingEntry(plugins, packageName)) return candidate.path; + const registered = + this.hasMatchingEntry(plugins, packageName) || + (legacyPlugins !== null && this.hasMatchingEntry(legacyPlugins, packageName)); + if (registered) return candidate.path; } return null; } @@ -118,7 +129,8 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { - for (const { candidate, text, plugins } of await this.readCandidates(options)) { + let legacyPath: string | null = null; + for (const { candidate, text, plugins, legacyPlugins } of await this.readCandidates(options)) { if (!candidate.writable) continue; if (plugins === null) { return { @@ -129,17 +141,28 @@ export class PluginConfigEditor { `Fix or remove the file and re-run the uninstall.`, }; } - if (!this.hasMatchingEntry(plugins, packageName)) continue; - const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); - if (spliced === null) { - return { - action: "blocked", - configPath: candidate.path, - warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, - }; + if (this.hasMatchingEntry(plugins, packageName)) { + const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); + if (spliced === null) { + return { + action: "blocked", + configPath: candidate.path, + warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + }; + } + await writeFile(candidate.path, spliced); + return { action: "removed", configPath: candidate.path, warning: null }; } - await writeFile(candidate.path, spliced); - return { action: "removed", configPath: candidate.path, warning: null }; + if (legacyPath === null && legacyPlugins !== null && this.hasMatchingEntry(legacyPlugins, packageName)) { + legacyPath = candidate.path; + } + } + if (legacyPath !== null) { + return { + action: "noop", + configPath: legacyPath, + warning: this.legacyRemovalAdvisory(legacyPath, packageName), + }; } return { action: "noop", configPath: null, warning: null }; } @@ -183,17 +206,56 @@ export class PluginConfigEditor { for (const candidate of this.candidateConfigs(options)) { if (!(await exists(candidate.path))) continue; const text = await readFile(candidate.path, "utf-8"); - const plugins = this.parsePluginArray(text, candidate.lenient); - if (plugins === null) { + const config = this.parseConfig(text, candidate.lenient); + const plugins = config === null ? null : this.keyEntries(config, "plugins"); + const legacyPlugins = config === null ? null : this.keyEntries(config, "plugin"); + if (config === null) { console.warn( `Warning: ${candidate.path} could not be parsed; refusing to treat it as a registration candidate.`, ); } - reads.push({ candidate, text, plugins }); + reads.push({ candidate, text, plugins, legacyPlugins }); } return reads; } + private keyEntries(config: Record, key: string): string[] { + const value = config[key]; + if (!Array.isArray(value)) return []; + return value.map((entry) => this.normalizeEntry(entry)).filter((entry): entry is string => entry !== null); + } + + private normalizeEntry(entry: unknown): string | null { + if (typeof entry === "string") return entry; + if (entry !== null && typeof entry === "object") { + const pkg = (entry as { package?: unknown }).package; + if (typeof pkg === "string") return pkg; + } + return null; + } + + private legacyEntryAdvisory(candidate: CandidateConfig, packageName: string): string { + if (path.basename(candidate.path) === "config.json") { + return ( + `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + + `OpenCode v2 no longer reads config.json, so the entry is inert, and this installer never edits it. ` + + `Add ${packageName} to the "plugins" key of an opencode.json or opencode.jsonc config instead.` + ); + } + return ( + `${candidate.path} registers ${packageName} under the legacy v1 "plugin" key. ` + + `OpenCode v2 still loads it through config migration, and this installer leaves legacy entries ` + + `read-only. Move the entry to the v2 "plugins" key to upgrade, then re-run the install.` + ); + } + + private legacyRemovalAdvisory(configPath: string, packageName: string): string { + return ( + `${configPath} registers ${packageName} under the legacy v1 "plugin" key; it was left untouched. ` + + `Remove it by editing the file, or move the entry to the v2 "plugins" key and re-run the uninstall.` + ); + } + private defaultConfigPath(options: EnsurePluginEntryOptions): string { if (options.scope === "global") { return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); @@ -211,9 +273,7 @@ export class PluginConfigEditor { private parsePluginArray(text: string, lenient: boolean): string[] | null { const config = this.parseConfig(text, lenient); if (config === null) return null; - const plugins = config.plugin; - if (!Array.isArray(plugins)) return []; - return plugins.filter((entry): entry is string => typeof entry === "string"); + return this.keyEntries(config, "plugins"); } private parseConfig(text: string, lenient: boolean): Record | null { @@ -306,7 +366,7 @@ export class PluginConfigEditor { const elements = this.arrayElementRanges(navigable, innerStart, innerEnd); const target = elements.find((element) => { const raw = text.slice(element.start, element.end); - return this.matchesEntry(this.unquote(raw), packageName); + return this.matchesEntry(this.elementSpec(raw), packageName); }); if (!target) return null; const withComma = this.dropAdjacentComma(navigable, elements, target, innerStart, innerEnd); @@ -388,6 +448,17 @@ export class PluginConfigEditor { return trimmed; } + private elementSpec(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) return this.unquote(trimmed); + try { + const parsed = JSON.parse(trimmed.replace(/,(\s*[}\]])/g, "$1")) as { package?: unknown }; + return typeof parsed.package === "string" ? parsed.package : ""; + } catch { + return ""; + } + } + private spliceArrayEntry( text: string, navigable: string, @@ -413,7 +484,7 @@ export class PluginConfigEditor { if (nextContentOffset === -1) return null; const insertAt = objectStart + 1 + nextContentOffset; const isClosingBrace = rest[nextContentOffset] === "}"; - const entry = isClosingBrace ? `"plugin": ["${packageName}"]` : `"plugin": ["${packageName}"],`; + const entry = isClosingBrace ? `"plugins": ["${packageName}"]` : `"plugins": ["${packageName}"],`; const leadingWhitespace = nextContentOffset > 0 ? rest.slice(0, nextContentOffset) : ""; return text.slice(0, insertAt) + leadingWhitespace + entry + text.slice(insertAt); } @@ -421,10 +492,10 @@ export class PluginConfigEditor { private findPluginArrayRange(navigable: string): PluginArrayRange | null { let searchFrom = 0; while (searchFrom < navigable.length) { - const keyIndex = navigable.indexOf('"plugin"', searchFrom); + const keyIndex = navigable.indexOf('"plugins"', searchFrom); if (keyIndex === -1) return null; if (this.precededByStructuralChar(navigable, keyIndex, ["{", ","])) { - const colonIndex = this.nextOutsideString(navigable, keyIndex + 8, ":"); + const colonIndex = this.nextOutsideString(navigable, keyIndex + 9, ":"); if (colonIndex !== -1) { const bracketStart = this.nextOutsideString(navigable, colonIndex + 1, "["); if (bracketStart !== -1) { diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index c7c5cff..43e5010 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -90,7 +90,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { " // consumer comment, keep me", ' "$schema": "https://opencode.ai/config.json",', ' "model": "x/y",', - ' "plugin": [', + ' "plugins": [', ' "some-other-plugin",', " ],", "}", @@ -106,22 +106,22 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const after = await readFile(configPath, "utf-8"); expect(after).toContain('"my-pkg@latest"'); const without = after.replace('"my-pkg@latest",', "").replace(/,\s*,/g, ","); - expect(without.split('"plugin"')[0]).toBe(before.split('"plugin"')[0]); + expect(without.split('"plugins"')[0]).toBe(before.split('"plugins"')[0]); const parsed = parseJsonc(after); - expect(parsed.plugin).toContain("my-pkg@latest"); - expect(parsed.plugin).toContain("some-other-plugin"); + expect(parsed.plugins).toContain("my-pkg@latest"); + expect(parsed.plugins).toContain("some-other-plugin"); expect(parsed.model).toBe("x/y"); }); test("writes a new entry in the canonical name@latest form", async () => { const projectDir = await makeDir("project"); - await write("project/opencode.json", '{ "plugin": ["other"] }\n'); + await write("project/opencode.json", '{ "plugins": ["other"] }\n'); await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); const parsed = parseJsonc(await readFile(path.join(projectDir, "opencode.json"), "utf-8")); - expect(parsed.plugin).toContain("my-pkg@latest"); - expect(parsed.plugin).not.toContain("my-pkg"); + expect(parsed.plugins).toContain("my-pkg@latest"); + expect(parsed.plugins).not.toContain("my-pkg"); }); test("performs no write when a semantically matching entry exists", async () => { @@ -129,7 +129,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { for (const entry of ["my-pkg", "my-pkg@1.2.3", "my-pkg@latest"]) { const configPath = await write( "project/opencode.json", - `{ "plugin": ["other", "${entry}"] }\n`, + `{ "plugins": ["other", "${entry}"] }\n`, ); const before = await readFile(configPath, "utf-8"); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); @@ -145,7 +145,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { expect(outcome.action).toBe("created"); expect(outcome.configPath).toBe(path.join(projectDir, "opencode.jsonc")); const parsed = parseJsonc(await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8")); - expect(parsed.plugin).toEqual(["my-pkg@latest"]); + expect(parsed.plugins).toEqual(["my-pkg@latest"]); expect(parsed.$schema).toContain("config.json"); }); @@ -163,7 +163,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("warns about an unparseable candidate even when an earlier candidate matches", async () => { const projectDir = await makeDir("project"); - await write("project/.opencode/opencode.json", '{ "plugin": ["my-pkg"] }\n'); + await write("project/.opencode/opencode.json", '{ "plugins": ["my-pkg"] }\n'); const brokenRoot = await write("project/opencode.json", "{ broken ]"); const captured = captureConsole("warn"); @@ -180,7 +180,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("findRegistration warns about unparseable candidates and still finds later registrations", async () => { const projectDir = await makeDir("project"); await write("project/.opencode/opencode.json", "{ broken ]"); - const rootConfig = await write("project/opencode.json", '{ "plugin": ["my-pkg@1.0.0"] }\n'); + const rootConfig = await write("project/opencode.json", '{ "plugins": ["my-pkg@1.0.0"] }\n'); const captured = captureConsole("warn"); try { @@ -214,7 +214,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("strict .json rejects comments and trailing commas", async () => { const projectDir = await makeDir("project"); - await write("project/opencode.json", '{ "plugin": ["a",], }\n'); + await write("project/opencode.json", '{ "plugins": ["a",], }\n'); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("blocked"); }); @@ -223,7 +223,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const projectDir = await makeDir("project"); await write( "project/opencode.jsonc", - '{ /* c */ "plugin": ["a",], // trailing\n}\n', + '{ /* c */ "plugins": ["a",], // trailing\n}\n', ); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); @@ -231,37 +231,37 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("lenient .jsonc tolerates a comment between a trailing comma and its closer", async () => { const projectDir = await makeDir("project"); - await write("project/opencode.jsonc", '{ "plugin": ["other",/* c */], }\n'); + await write("project/opencode.jsonc", '{ "plugins": ["other",/* c */], }\n'); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); const parsed = parseJsonc(await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8")); - expect(parsed.plugin).toContain("my-pkg@latest"); - expect(parsed.plugin).toContain("other"); + expect(parsed.plugins).toContain("my-pkg@latest"); + expect(parsed.plugins).toContain("other"); }); test("schema URLs with // and escaped quotes survive splicing and parsing", async () => { const projectDir = await makeDir("project"); const configPath = await write( "project/opencode.jsonc", - '{ "$schema": "https://opencode.ai/config.json", "key": "a \\"quoted\\" // value", "plugin": [] }\n', + '{ "$schema": "https://opencode.ai/config.json", "key": "a \\"quoted\\" // value", "plugins": [] }\n', ); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); const parsed = JSON.parse((await readFile(configPath, "utf-8")).replace(/,(\s*[}\]])/g, "$1")); expect(parsed.$schema).toBe("https://opencode.ai/config.json"); expect(parsed.key).toBe('a "quoted" // value'); - expect(parsed.plugin).toEqual(["my-pkg@latest"]); + expect(parsed.plugins).toEqual(["my-pkg@latest"]); }); test("scope base config wins over repo root; existing scope-base file is edited", async () => { const projectDir = await makeDir("project"); const scopeBasePath = await write( "project/.opencode/opencode.json", - '{ "plugin": [] }\n', + '{ "plugins": [] }\n', ); - await write("project/opencode.json", '{ "plugin": [] }\n'); + await write("project/opencode.json", '{ "plugins": [] }\n'); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); expect(outcome.configPath).toBe(scopeBasePath); @@ -280,7 +280,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { } }); - test("global config.json with the entry is a read-only no-op", async () => { + test("global config.json with the entry is a read-only no-op with an upgrade advisory", async () => { const projectDir = await makeDir("project"); const xdg = await makeDir("xdg"); const globalConfig = await write( @@ -292,6 +292,8 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "global", projectDir }); expect(outcome.action).toBe("noop"); expect(outcome.configPath).toBe(globalConfig); + expect(outcome.warning).toContain("legacy v1"); + expect(outcome.warning).toContain("no longer reads config.json"); expect(await readFile(globalConfig, "utf-8")).toBe('{ "plugin": ["my-pkg@2.0.0"] }\n'); } finally { delete process.env.XDG_CONFIG_HOME; @@ -308,7 +310,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { expect(outcome.action).toBe("created"); expect(await readFile(globalConfig, "utf-8")).toBe('{ "model": "x/y" }\n'); const created = parseJsonc(await readFile(path.join(xdg, "opencode", "opencode.jsonc"), "utf-8")); - expect(created.plugin).toEqual(["my-pkg@latest"]); + expect(created.plugins).toEqual(["my-pkg@latest"]); } finally { delete process.env.XDG_CONFIG_HOME; } @@ -316,10 +318,10 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { test("splices an inline empty plugin array in a minified config", async () => { const projectDir = await makeDir("project"); - const configPath = await write("project/opencode.json", '{"plugin":[]}\n'); + const configPath = await write("project/opencode.json", '{"plugins":[]}\n'); const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); - expect(await readFile(configPath, "utf-8")).toBe('{"plugin":["my-pkg@latest"]}\n'); + expect(await readFile(configPath, "utf-8")).toBe('{"plugins":["my-pkg@latest"]}\n'); }); test("config without a plugin key gets one spliced in, rest untouched", async () => { @@ -331,9 +333,93 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("updated"); const after = await readFile(configPath, "utf-8"); - expect(after).toContain('"plugin": ["my-pkg@latest"],'); + expect(after).toContain('"plugins": ["my-pkg@latest"],'); expect(after).toContain('"model": "x/y"'); const parsed = JSON.parse(after.replace(/,(\s*[}\]])/g, "$1")); - expect(parsed.plugin).toEqual(["my-pkg@latest"]); + expect(parsed.plugins).toEqual(["my-pkg@latest"]); + }); + + test("a legacy v1 plugin entry is a zero-write no-op with an upgrade advisory", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.json", + '{ "plugin": ["other", "my-pkg@1.0.0"] }\n', + ); + const before = await readFile(configPath, "utf-8"); + + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(outcome.configPath).toBe(configPath); + expect(outcome.warning).toContain("legacy v1"); + expect(outcome.warning).toContain(configPath); + expect(outcome.warning).toContain('"plugins"'); + expect(await readFile(configPath, "utf-8")).toBe(before); + }); + + test("writes the v2 plugins key alongside a legacy plugin key holding other packages", async () => { + const projectDir = await makeDir("project"); + const configPath = await write("project/opencode.json", '{ "plugin": ["old-one"] }\n'); + + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("updated"); + const parsed = parseJsonc(await readFile(configPath, "utf-8")); + expect(parsed.plugins).toEqual(["my-pkg@latest"]); + expect(parsed.plugin).toEqual(["old-one"]); + }); + + test("an object-form v2 entry matches semantically without a write", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.json", + '{ "plugins": [{ "package": "my-pkg", "options": { "x": 1 } }] }\n', + ); + const before = await readFile(configPath, "utf-8"); + + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(await readFile(configPath, "utf-8")).toBe(before); + }); + + test("findRegistration finds a legacy v1 plugin registration", async () => { + const projectDir = await makeDir("project"); + const configPath = await write("project/opencode.json", '{ "plugin": ["my-pkg"] }\n'); + + const found = await editor().findRegistration("my-pkg", { scope: "local", projectDir }); + + expect(found).toBe(configPath); + }); +}); + +describe("PluginConfigEditor.removePluginEntry", () => { + test("leaves a legacy-only entry untouched and advises instead of removing", async () => { + const projectDir = await makeDir("project"); + const configPath = await write("project/opencode.json", '{ "plugin": ["my-pkg"] }\n'); + const before = await readFile(configPath, "utf-8"); + + const outcome = await editor().removePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(outcome.configPath).toBe(configPath); + expect(outcome.warning).toContain("legacy v1"); + expect(outcome.warning).toContain(configPath); + expect(await readFile(configPath, "utf-8")).toBe(before); + }); + + test("removes an object-form v2 entry and keeps the rest of the array", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.json", + '{ "plugins": [{ "package": "my-pkg", "options": {} }, "other"] }\n', + ); + + const outcome = await editor().removePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("removed"); + expect(outcome.configPath).toBe(configPath); + const parsed = JSON.parse(await readFile(configPath, "utf-8")); + expect(parsed.plugins).toEqual(["other"]); }); }); From dd74385f19e26fc2a0a22cf0b7c351f7e7411c9d Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 01:57:49 -0400 Subject: [PATCH 2/7] refactor(cache): npm generation cache location per v2 reality (issue #26) Cache roots move from /opencode/packages to /opencode/npm per opencode-v2-facts SS7: install-time pruning and clear-cache now operate on generation-cache key dirs (each key holding every generation), and the stale-copy advisory in the plugin load hook cites the npm path. Install/uninstall outcomes carry the config advisory (configWarning) so legacy entries surface through the API; uninstall reports plugin mode when only a legacy registration exists. --- src/cache-cleaner.ts | 14 +++---- src/installer.ts | 30 ++++++++++---- src/plugin.ts | 4 +- tests/cache-cleaner.test.ts | 19 ++++++++- tests/installer.test.ts | 82 +++++++++++++++++++++++++++++++------ tests/load-hook.test.ts | 2 +- 6 files changed, 119 insertions(+), 32 deletions(-) diff --git a/src/cache-cleaner.ts b/src/cache-cleaner.ts index 0f2d2a3..0219908 100644 --- a/src/cache-cleaner.ts +++ b/src/cache-cleaner.ts @@ -19,14 +19,14 @@ export interface ClearCacheOutcome { } export class CacheCleaner { - public packagesCacheRoot(): string { + public npmCacheRoot(): string { const xdgCacheHome = process.env.XDG_CACHE_HOME; - if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages"); - return path.join(homedir(), ".cache", "opencode", "packages"); + if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "npm"); + return path.join(homedir(), ".cache", "opencode", "npm"); } public opencodeCacheRoot(): string { - return path.dirname(this.packagesCacheRoot()); + return path.dirname(this.npmCacheRoot()); } public isUnsafePackageName(name: string): boolean { @@ -64,10 +64,10 @@ export class CacheCleaner { } const name = packageName ?? PACKAGE_NAME; - const packagesRoot = this.packagesCacheRoot(); + const npmRoot = this.npmCacheRoot(); let entries: string[]; try { - entries = await readdir(packagesRoot); + entries = await readdir(npmRoot); } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ENOENT") return { removed, warnings, dryRun }; throw error; @@ -77,7 +77,7 @@ export class CacheCleaner { const targets = entries .filter((entry) => entry === name || entry.startsWith(prefix)) .sort() - .map((entry) => path.join(packagesRoot, entry)); + .map((entry) => path.join(npmRoot, entry)); if (dryRun) { removed.push(...targets); diff --git a/src/installer.ts b/src/installer.ts index c69aa3e..00342c6 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -38,6 +38,7 @@ export interface InstallOutcome { manifestPath: string; configPath: string | null; configAction: "noop" | "updated" | "created" | "blocked"; + configWarning: string | null; removedPayload: string[]; clearedCache: string[]; cacheWarnings: string[]; @@ -49,6 +50,7 @@ export interface UninstallOutcome { removed: string[]; pluginRemoved: boolean; configPath: string | null; + configWarning: string | null; } export interface StatusOutcome { @@ -140,6 +142,7 @@ export class Installer { manifestPath, configPath: registration.configPath, configAction: registration.action, + configWarning: registration.warning, removedPayload, clearedCache: cache.removed, cacheWarnings: cache.warnings, @@ -149,9 +152,9 @@ export class Installer { private async requireBundledAssets(version: string): Promise { for (const name of BUNDLED_ASSET_DIRS) { const dir = path.join(this.assetsDir, name); - if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version); + if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.npmCacheRoot(), version); const contents = await readdir(dir); - if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version); + if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.npmCacheRoot(), version); } } @@ -162,7 +165,7 @@ export class Installer { PACKAGE_NAME, `${PACKAGE_NAME}@latest`, `${PACKAGE_NAME}@${version}`, - ].map((name) => path.join(this.packageCacheRoot(), name)); + ].map((name) => path.join(this.npmCacheRoot(), name)); for (const target of targets) { if (!(await exists(target))) continue; try { @@ -176,10 +179,10 @@ export class Installer { return { removed, warnings }; } - private packageCacheRoot(): string { + private npmCacheRoot(): string { const xdgCacheHome = process.env.XDG_CACHE_HOME; - if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "packages"); - return path.join(homedir(), ".cache", "opencode", "packages"); + if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "npm"); + return path.join(homedir(), ".cache", "opencode", "npm"); } public async uninstall(scope: Scope, projectDir: string): Promise { @@ -208,8 +211,19 @@ export class Installer { } const mode: InstallMode = - manifest !== null ? manifest.mode : pluginRemoved ? "plugin" : "none"; - return { scope, mode, removed, pluginRemoved, configPath: removal.configPath }; + manifest !== null + ? manifest.mode + : pluginRemoved || removal.configPath !== null + ? "plugin" + : "none"; + return { + scope, + mode, + removed, + pluginRemoved, + configPath: removal.configPath, + configWarning: removal.warning, + }; } private async removeResidualPayload(base: string): Promise { diff --git a/src/plugin.ts b/src/plugin.ts index 17a6c64..b0bafae 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -111,9 +111,9 @@ export class OpencodeArchitectPlugin { `reinstall and restart OpenCode. The stale cache copy is `; let text: string; try { - text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@${await this.readVersion()}. Cause: ${message}`; + text = `${prefix}~/.cache/opencode/npm/${PACKAGE_NAME}@${await this.readVersion()}. Cause: ${message}`; } catch { - text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@. Cause: ${message}`; + text = `${prefix}~/.cache/opencode/npm/${PACKAGE_NAME}@. Cause: ${message}`; } try { console.warn(`[${PACKAGE_NAME}] ${text}`); diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index 5dcdfc5..606e591 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { CacheCleaner } from "../src/cache-cleaner"; @@ -24,7 +24,7 @@ afterEach(async () => { }); function cacheRoot(): string { - return cleaner.packagesCacheRoot(); + return cleaner.npmCacheRoot(); } describe("clearCache default mode", () => { @@ -57,6 +57,21 @@ describe("clearCache default mode", () => { expect(outcome.removed).toEqual([]); expect(outcome.warnings).toEqual([]); }); + + test("removes every generation under a key in the v2 generation cache layout", async () => { + const keyDir = path.join(cacheRoot(), "opencode-architect@latest"); + for (const generation of ["1738848000000", "1738851600000"]) { + const pkgDir = path.join(keyDir, generation, "node_modules", "opencode-architect"); + await mkdir(pkgDir, { recursive: true }); + await writeFile(path.join(pkgDir, "index.ts"), "cached"); + } + + const outcome = await cleaner.clear({ packageName: null, all: false, yes: false, dryRun: false }); + + expect(outcome.warnings).toEqual([]); + expect(outcome.removed).toEqual([keyDir]); + expect(existsSync(keyDir)).toBe(false); + }); }); describe("clearCache --package mode", () => { diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 0a0d8a3..fd652a7 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -92,7 +92,7 @@ describe("Installer.install", () => { expect(outcome.manifestPath).toBe(manifestPath("local")); const config = await readJson(configPath); - expect(config.plugin).toEqual(["opencode-architect@latest"]); + expect(config.plugins).toEqual(["opencode-architect@latest"]); expect(config.theme).toBe("dark"); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; @@ -127,11 +127,48 @@ describe("Installer.install", () => { expect(await readFile(configPath, "utf-8")).toBe(configBefore); }); + test("a legacy v1 entry is left read-only with an advisory and never registered twice", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeText(configPath, '{ "plugin": ["opencode-architect@0.9.0"] }\n'); + const before = await readFile(configPath, "utf-8"); + + const outcome = await install("local"); + + expect(outcome.configAction).toBe("noop"); + expect(outcome.configWarning).toContain("legacy v1"); + expect(outcome.configWarning).toContain('"plugins"'); + expect(await readFile(configPath, "utf-8")).toBe(before); + expect(outcome.action).toBe("installed"); + + const manifestBefore = await readFile(manifestPath("local"), "utf-8"); + const repeat = await install("local"); + + expect(repeat.action).toBe("noop"); + expect(repeat.configAction).toBe("noop"); + expect(await readFile(configPath, "utf-8")).toBe(before); + expect(await readFile(manifestPath("local"), "utf-8")).toBe(manifestBefore); + }); + + test("uninstall leaves a legacy v1 entry untouched and advises", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + const legacyConfig = '{ "plugin": ["opencode-architect"] }\n'; + await writeText(configPath, legacyConfig); + + const outcome = await installer.uninstall("local", projectDir); + + expect(outcome.mode).toBe("plugin"); + expect(outcome.pluginRemoved).toBe(false); + expect(outcome.configPath).toBe(configPath); + expect(outcome.configWarning).toContain("legacy v1"); + expect(outcome.configWarning).toContain(configPath); + expect(await readFile(configPath, "utf-8")).toBe(legacyConfig); + }); + test("an existing entry with a version spec matches semantically without a write", async () => { const configPath = path.join(scopeBase("local"), "opencode.jsonc"); await writeText( configPath, - `{\n // keep this comment\n "plugin": ["other", "opencode-architect@latest"],\n}\n`, + `{\n // keep this comment\n "plugins": ["other", "opencode-architect@latest"],\n}\n`, ); const before = await readFile(configPath, "utf-8"); @@ -149,7 +186,7 @@ describe("Installer.install", () => { "{", " // my precious comment", ' "$schema": "https://opencode.ai/config.json",', - ' "plugin": [', + ' "plugins": [', ' // plugin note', ' "other-extension",', " ],", @@ -186,7 +223,7 @@ describe("Installer.install", () => { expect(outcome.configAction).toBe("created"); expect(outcome.configPath).toBe(path.join(projectDir, "opencode.jsonc")); const text = await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8"); - expect(text).toContain('"plugin": ["opencode-architect@latest"]'); + expect(text).toContain('"plugins": ["opencode-architect@latest"]'); }); test("refuses copy mode with an explanatory error", async () => { @@ -259,7 +296,7 @@ describe("Installer.install", () => { expect(existsSync(path.join(scopeBase("local"), "opencode-architect"))).toBe(false); const config = await readJson(path.join(scopeBase("local"), "opencode.json")); - expect(config.plugin).toEqual(["opencode-architect@latest"]); + expect(config.plugins).toEqual(["opencode-architect@latest"]); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; expect(manifest.mode).toBe("plugin"); }); @@ -281,7 +318,7 @@ describe("Installer.install", () => { expect(existsSync(manifestPath("local"))).toBe(true); expect(existsSync(configPath)).toBe(true); const config = await readJson(configPath); - expect(config.plugin).toBeUndefined(); + expect(config.plugins).toBeUndefined(); }); test("migration aborts with the payload intact when a config is unparseable", async () => { @@ -324,7 +361,7 @@ describe("Installer.install", () => { expect(outcome.action).toBe("upgraded"); const config = await readJson(configPath); - expect(config.plugin).toEqual(["opencode-architect@latest"]); + expect(config.plugins).toEqual(["opencode-architect@latest"]); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; expect(manifest.version).toBe(await readPackageVersion()); }); @@ -368,7 +405,7 @@ describe("Installer.install", () => { describe("Installer.install cache pruning", () => { function cacheRoot(): string { - return path.join(cacheDir, "opencode", "packages"); + return path.join(cacheDir, "opencode", "npm"); } async function seedCache(): Promise { @@ -380,12 +417,13 @@ describe("Installer.install cache pruning", () => { "opencode-architect@0.6.0", "other-package", ]) { - await mkdir(path.join(cacheRoot(), name, "nested"), { recursive: true }); - await writeFile(path.join(cacheRoot(), name, "nested", "file.txt"), "cached"); + const generation = path.join(cacheRoot(), name, "1738848000000", "node_modules", name.replace(/@.*$/, "")); + await mkdir(generation, { recursive: true }); + await writeFile(path.join(generation, "index.ts"), "cached"); } } - test("removes stale non-pinned copies and preserves pinned and foreign dirs", async () => { + test("removes stale non-pinned key dirs and preserves pinned and foreign keys", async () => { await seedCache(); const version = await readPackageVersion(); @@ -404,6 +442,14 @@ describe("Installer.install cache pruning", () => { expect(existsSync(path.join(cacheRoot(), "other-package"))).toBe(true); }); + test("a removed key takes every cached generation with it", async () => { + await seedCache(); + + await install("local"); + + expect(existsSync(path.join(cacheRoot(), "opencode-architect@latest", "1738848000000"))).toBe(false); + }); + test("prunes even when the install is a no-op", async () => { await install("local"); await seedCache(); @@ -461,7 +507,7 @@ describe("Installer.uninstall", () => { expect(outcome.configPath).toBe(configPath); expect(existsSync(manifestPath("local"))).toBe(false); const config = await readJson(configPath); - expect(config.plugin).toEqual([]); + expect(config.plugins).toEqual([]); expect((await readFile(configPath, "utf-8")).length).toBeLessThan(before.length); }); @@ -555,6 +601,18 @@ describe("Installer.status", () => { }); test("detects a registration without a manifest", async () => { + await writeJson(path.join(scopeBase("local"), "opencode.json"), { + plugins: ["opencode-architect"], + }); + + const outcome = await installer.status("local", projectDir); + + expect(outcome.mode).toBe("plugin"); + expect(outcome.version).toBeNull(); + expect(outcome.configPath).toBe(path.join(scopeBase("local"), "opencode.json")); + }); + + test("detects a legacy v1 registration without a manifest", async () => { await writeJson(path.join(scopeBase("local"), "opencode.json"), { plugin: ["opencode-architect"], }); diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts index 909af1b..545dcf2 100644 --- a/tests/load-hook.test.ts +++ b/tests/load-hook.test.ts @@ -135,7 +135,7 @@ describe("plugin activation (startup non-interference)", () => { expect(permission.evaluations.length).toBe(0); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain("bunx opencode-architect clear-cache"); - expect(warnings[0]).toContain(`~/.cache/opencode/packages/opencode-architect@`); + expect(warnings[0]).toContain("~/.cache/opencode/npm/opencode-architect@"); expect(warnings[0]).toContain("ENOENT"); }); From 7f1038413af22d0aa4a39917c5a8473e9811c019 Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 01:58:01 -0400 Subject: [PATCH 3/7] feat(cli): surface legacy-entry advisories; help text names the plugins key (issue #26) install and uninstall print the config advisory as a warning while the command surface (install/uninstall/status/clear-cache, flags, exit codes) stays unchanged. New CLI test covers install against a legacy v1 config: exit 0, file untouched, advisory on stderr. --- src/cli.ts | 11 +++++++++-- tests/cli.test.ts | 28 +++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e86b9ab..004c37d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -62,6 +62,9 @@ export async function runCli(argv: string[]): Promise { if (outcome.configPath !== null) { console.log(` Plugin entry: ${outcome.configPath}`); } + if (outcome.configWarning !== null) { + console.warn(` Warning: ${outcome.configWarning}`); + } console.log(` Manifest: ${outcome.manifestPath}`); for (const cachePath of outcome.clearedCache) { console.log(` Cleared cache: ${cachePath}`); @@ -82,6 +85,9 @@ export async function runCli(argv: string[]): Promise { if (outcome.pluginRemoved && outcome.configPath !== null) { console.log(` Removed the plugin entry from ${outcome.configPath}.`); } + if (outcome.configWarning !== null) { + console.warn(` Warning: ${outcome.configWarning}`); + } break; } case "status": { @@ -141,9 +147,10 @@ function printHelp(): void { console.log(` opencode-architect v${VERSION} -Registers the opencode-architect plugin in a config file's plugin array so the +Registers the opencode-architect plugin in a config file's plugins array so the agent suite loads from the package at startup. Nothing is copied: the plugin -registers the agents and resolves reference paths at load time. +registers the agents and resolves reference paths at load time. Legacy v1 +"plugin" entries are detected and left read-only with an upgrade advisory. Commands: install Ensure the plugin entry and write the install manifest diff --git a/tests/cli.test.ts b/tests/cli.test.ts index eb62631..e728cad 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { seedCachedPackage } from "./test-helpers"; @@ -96,7 +96,7 @@ describe("cli", () => { if (process.getuid?.() === 0) return; const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-")); const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cache-")); - const blocked = path.join(cacheDir, "opencode", "packages", "opencode-architect@latest", "nested"); + const blocked = path.join(cacheDir, "opencode", "npm", "opencode-architect@latest", "nested"); await mkdir(blocked, { recursive: true }); await writeFile(path.join(blocked, "file.txt"), "cached"); await chmod(blocked, 0o500); @@ -105,18 +105,36 @@ describe("cli", () => { expect(run.exitCode).toBe(0); expect(run.stdout).toContain("Registered"); - expect(run.stderr).toContain(`Could not clear cached package ${path.join(cacheDir, "opencode", "packages", "opencode-architect@latest")}`); - expect(existsSync(path.join(cacheDir, "opencode", "packages", "opencode-architect"))).toBe(false); + expect(run.stderr).toContain(`Could not clear cached package ${path.join(cacheDir, "opencode", "npm", "opencode-architect@latest")}`); + expect(existsSync(path.join(cacheDir, "opencode", "npm", "opencode-architect"))).toBe(false); } finally { await chmod(blocked, 0o700); await rm(dir, { recursive: true, force: true }); await rm(cacheDir, { recursive: true, force: true }); } }); + + test("install with a legacy v1 plugin entry keeps it read-only and advises upgrading", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-")); + const configPath = path.join(dir, "opencode.json"); + const legacyConfig = '{ "plugin": ["opencode-architect@0.9.0"] }\n'; + await writeFile(configPath, legacyConfig); + try { + const run = await runCli(["install"], dir); + + expect(run.exitCode).toBe(0); + expect(run.stdout).toContain("Registered"); + expect(run.stderr).toContain("legacy v1"); + expect(run.stderr).toContain('"plugins"'); + expect(await readFile(configPath, "utf-8")).toBe(legacyConfig); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); }); describe("cli clear-cache", () => { - const packagesDir = (cacheDir: string) => path.join(cacheDir, "opencode", "packages"); + const packagesDir = (cacheDir: string) => path.join(cacheDir, "opencode", "npm"); const seedCache = seedCachedPackage; test("default mode removes only this package's cache dirs", async () => { From 615a6b036cf07e83b15e2cc6a02ed65a16e72540 Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 01:58:07 -0400 Subject: [PATCH 4/7] docs(readme): v2 plugins snippets and npm cache paths (issue #26) Consumer-facing install snippets teach the v2 plugins key with the canonical @latest entry; cache paragraphs describe the npm generation cache; the D8 snippet contract flips to forbid the v1 plugin key as a registration target. --- README.md | 10 +++++----- tests/readme.test.ts | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9ac21ad..438e06f 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ opencode-architect is an [OpenCode](https://opencode.ai) plugin and CLI that shi ### Option 1 — Install as an OpenCode plugin -Add the package to the `plugin` array in your OpenCode config — `.opencode/opencode.json` in your project, or `~/.config/opencode/opencode.json` for all projects: +Add the package to the `plugins` array in your OpenCode config — `.opencode/opencode.json` in your project, or `~/.config/opencode/opencode.json` for all projects: ```json { - "plugin": ["opencode-architect@latest"] + "plugins": ["opencode-architect@latest"] } ``` @@ -21,7 +21,7 @@ The plugin registers the full agent suite at startup, with self-contained bundle ### Option 2 — Install with the CLI (bunx or npx) -The CLI registers the package as a plugin: it adds `opencode-architect@latest` to the `plugin` array of your OpenCode config with surgical text editing (comments and formatting elsewhere in the file are preserved), then records the registration in an `opencode-architect.manifest.json` manifest at the scope base. Nothing is copied — the agents, references, and templates all load from the package at startup. +The CLI registers the package as a plugin: it adds `opencode-architect@latest` to the `plugins` array of your OpenCode config with surgical text editing (comments and formatting elsewhere in the file are preserved), then records the registration in an `opencode-architect.manifest.json` manifest at the scope base. Nothing is copied — the agents, references, and templates all load from the package at startup. A legacy v1 `plugin` entry is left untouched and reported with an upgrade advisory. ```bash # Project scope (default): edits the ./.opencode/ or repo-root config @@ -47,11 +47,11 @@ bunx opencode-architect --help # full usage Re-running install when the manifest matches reality is a zero-write no-op. `--mode copy` is refused with an explanatory error: this package is code-backed (it ships agents), and copying cannot express plugin registration. -Every install — including a no-op — also clears this package's stale copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@`. Pinned copies like `opencode-architect@0.6.0` and other packages' cache dirs are left untouched. This makes OpenCode re-fetch the just-installed version on next start instead of reusing a stale or partial extraction. Removal is best-effort: a failure prints a warning but the install still succeeds. +Every install — including a no-op — also clears this package's stale cache keys from OpenCode's plugin cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`): `opencode-architect`, `opencode-architect@latest`, and `opencode-architect@` — each key holding every cached generation beneath it. Pinned keys like `opencode-architect@0.6.0` and other packages' cache keys are left untouched. This makes OpenCode re-fetch the just-installed version on next start instead of reusing a stale or partial extraction. Removal is best-effort: a failure prints a warning but the install still succeeds. **Upgrading from a copy install (pre-0.8):** if a previous version copied agents into your scope base, install detects the old manifest, removes exactly the files it lists, prints a notice, and switches the scope to plugin registration in one step. Locally modified files are tracked by hash; uninstall and migration only remove what the manifest recorded. -`clear-cache` is a manual-only command (never invoked at load time) for removing cached copies from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`). With no flags it removes `opencode-architect` and every `opencode-architect@*` copy. `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory (`$XDG_CACHE_HOME/opencode`, falling back to `~/.cache/opencode`). `--dry-run` lists what any mode would remove without deleting anything, and previews broad modes without `--yes`. Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code. +`clear-cache` is a manual-only command (never invoked at load time) for removing cached package keys from OpenCode's plugin cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`), where each key directory holds every cached generation beneath it. With no flags it removes `opencode-architect` and every `opencode-architect@*` key. `--package ` removes `` and every `@*`; `--all` removes the whole OpenCode cache directory (`$XDG_CACHE_HOME/opencode`, falling back to `~/.cache/opencode`). `--dry-run` lists what any mode would remove without deleting anything, and previews broad modes without `--yes`. Both broad modes require `--yes` to confirm, are mutually exclusive, and package names containing path separators or `..` are rejected. The command is idempotent — running with nothing cached succeeds — and removal failures warn without changing the exit code. ## What you get: ten specialist OpenCode agents diff --git a/tests/readme.test.ts b/tests/readme.test.ts index 4a804dc..9330d47 100644 --- a/tests/readme.test.ts +++ b/tests/readme.test.ts @@ -54,11 +54,11 @@ function headingSlugExists(readme: string, slug: string): boolean { } describe("shipped config snippets (D8)", () => { - test("README snippets use the plugin key with canonical entries, never plugins", async () => { + test("README snippets use the v2 plugins key with canonical entries, never the v1 plugin key", async () => { const readme = await readFile(README_PATH, "utf-8"); - expect(readme).toContain('"plugin": ["opencode-architect@latest"]'); - expect(readme).not.toContain('"plugins"'); + expect(readme).toContain('"plugins": ["opencode-architect@latest"]'); + expect(readme).not.toContain('"plugin": ["opencode-architect'); expect(readme).toContain("opencode-architect@latest"); }); }); From dd86e016d00fc2bf04b2294ebb5748687bb062bc Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 02:07:51 -0400 Subject: [PATCH 5/7] refactor(cache): shared NpmCache class; XDG-aware stale-copy advisory (review of #26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm cache root was resolved independently in CacheCleaner, Installer, and hardcoded a third time in the plugin advisory — three sites that had to move in lockstep for the v2 location change. A single NpmCache class now owns the resolution; the load-failure advisory uses it, so it honors XDG_CACHE_HOME instead of printing a hardcoded ~/.cache path. --- src/cache-cleaner.ts | 16 ++++------------ src/installer.ts | 14 +++++--------- src/npm-cache.ts | 14 ++++++++++++++ src/plugin.ts | 5 +++-- tests/cache-cleaner.test.ts | 13 ++++++++----- tests/load-hook.test.ts | 2 +- 6 files changed, 35 insertions(+), 29 deletions(-) create mode 100644 src/npm-cache.ts diff --git a/src/cache-cleaner.ts b/src/cache-cleaner.ts index 0219908..2351428 100644 --- a/src/cache-cleaner.ts +++ b/src/cache-cleaner.ts @@ -1,7 +1,7 @@ import { exists, readdir, rm } from "node:fs/promises"; -import { homedir } from "node:os"; import path from "node:path"; import { ClearCacheUsageError } from "./clear-cache-usage-error"; +import { NpmCache } from "./npm-cache"; export const PACKAGE_NAME = "opencode-architect"; @@ -19,15 +19,7 @@ export interface ClearCacheOutcome { } export class CacheCleaner { - public npmCacheRoot(): string { - const xdgCacheHome = process.env.XDG_CACHE_HOME; - if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "npm"); - return path.join(homedir(), ".cache", "opencode", "npm"); - } - - public opencodeCacheRoot(): string { - return path.dirname(this.npmCacheRoot()); - } + private readonly cache = new NpmCache(); public isUnsafePackageName(name: string): boolean { return name.includes("/") || name.includes("\\") || name.includes("..") || name === "."; @@ -54,7 +46,7 @@ export class CacheCleaner { const warnings: string[] = []; if (all) { - const target = this.opencodeCacheRoot(); + const target = this.cache.opencodeRoot(); if (dryRun) { if (await exists(target)) removed.push(target); return dryRunOutcome(removed, warnings); @@ -64,7 +56,7 @@ export class CacheCleaner { } const name = packageName ?? PACKAGE_NAME; - const npmRoot = this.npmCacheRoot(); + const npmRoot = this.cache.root(); let entries: string[]; try { entries = await readdir(npmRoot); diff --git a/src/installer.ts b/src/installer.ts index 00342c6..713939f 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -5,6 +5,7 @@ import { hashElement } from "folder-hash"; import { AGENT_FILENAMES } from "./agent-loader"; import { BundledAssetsMissingError } from "./bundled-assets-missing-error"; import { CopyModeUnsupportedError } from "./copy-mode-unsupported-error"; +import { NpmCache } from "./npm-cache"; import { PluginConfigEditor } from "./plugin-config"; export type Scope = "local" | "global"; @@ -68,6 +69,7 @@ const LEGACY_MANIFEST_NAME = "opencode-architect.json"; export class Installer { private readonly editor = new PluginConfigEditor(); + private readonly cache = new NpmCache(); private readonly assetsDir: string; constructor(assetsDir: string | null = null) { @@ -152,9 +154,9 @@ export class Installer { private async requireBundledAssets(version: string): Promise { for (const name of BUNDLED_ASSET_DIRS) { const dir = path.join(this.assetsDir, name); - if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.npmCacheRoot(), version); + if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.cache.root(), version); const contents = await readdir(dir); - if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.npmCacheRoot(), version); + if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.cache.root(), version); } } @@ -165,7 +167,7 @@ export class Installer { PACKAGE_NAME, `${PACKAGE_NAME}@latest`, `${PACKAGE_NAME}@${version}`, - ].map((name) => path.join(this.npmCacheRoot(), name)); + ].map((name) => path.join(this.cache.root(), name)); for (const target of targets) { if (!(await exists(target))) continue; try { @@ -179,12 +181,6 @@ export class Installer { return { removed, warnings }; } - private npmCacheRoot(): string { - const xdgCacheHome = process.env.XDG_CACHE_HOME; - if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "npm"); - return path.join(homedir(), ".cache", "opencode", "npm"); - } - public async uninstall(scope: Scope, projectDir: string): Promise { const base = this.scopeBase(scope, projectDir); const record = await this.readManifestRecord(base); diff --git a/src/npm-cache.ts b/src/npm-cache.ts new file mode 100644 index 0000000..89e79f0 --- /dev/null +++ b/src/npm-cache.ts @@ -0,0 +1,14 @@ +import { homedir } from "node:os"; +import path from "node:path"; + +export class NpmCache { + public root(): string { + const xdgCacheHome = process.env.XDG_CACHE_HOME; + if (xdgCacheHome) return path.join(xdgCacheHome, "opencode", "npm"); + return path.join(homedir(), ".cache", "opencode", "npm"); + } + + public opencodeRoot(): string { + return path.dirname(this.root()); + } +} diff --git a/src/plugin.ts b/src/plugin.ts index b0bafae..70a559b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -6,6 +6,7 @@ import type { AgentEditor } from "@opencode/plugin/effect/agent"; import { AgentLoader, type LoadedAgent } from "./agent-loader"; import { AssetPermissionAdvisor } from "./asset-permission-advisor"; import { Installer } from "./installer"; +import { NpmCache } from "./npm-cache"; const PACKAGE_NAME = "opencode-architect"; const PLUGIN_ID = "opencode-architect"; @@ -111,9 +112,9 @@ export class OpencodeArchitectPlugin { `reinstall and restart OpenCode. The stale cache copy is `; let text: string; try { - text = `${prefix}~/.cache/opencode/npm/${PACKAGE_NAME}@${await this.readVersion()}. Cause: ${message}`; + text = `${prefix}${new NpmCache().root()}${path.sep}${PACKAGE_NAME}@${await this.readVersion()}. Cause: ${message}`; } catch { - text = `${prefix}~/.cache/opencode/npm/${PACKAGE_NAME}@. Cause: ${message}`; + text = `${prefix}${new NpmCache().root()}${path.sep}${PACKAGE_NAME}@. Cause: ${message}`; } try { console.warn(`[${PACKAGE_NAME}] ${text}`); diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index 606e591..729bb47 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -4,17 +4,20 @@ import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { CacheCleaner } from "../src/cache-cleaner"; +import { NpmCache } from "../src/npm-cache"; import { seedCachedPackage, expectClearCacheUsageError } from "./test-helpers"; let cacheDir = ""; let originalXdgCache: string | undefined; let cleaner = new CacheCleaner(); +let cache = new NpmCache(); beforeEach(async () => { cacheDir = await mkdtemp(path.join(tmpdir(), "oa-clear-cache-")); originalXdgCache = process.env.XDG_CACHE_HOME; process.env.XDG_CACHE_HOME = cacheDir; cleaner = new CacheCleaner(); + cache = new NpmCache(); }); afterEach(async () => { @@ -24,7 +27,7 @@ afterEach(async () => { }); function cacheRoot(): string { - return cleaner.npmCacheRoot(); + return cache.root(); } describe("clearCache default mode", () => { @@ -106,13 +109,13 @@ describe("clearCache --package mode", () => { describe("clearCache --all mode", () => { test("removes the entire opencode cache directory", async () => { await seedCachedPackage(cacheRoot(), "opencode-architect"); - const unrelated = path.join(cleaner.opencodeCacheRoot(), "other-tool"); + const unrelated = path.join(cache.opencodeRoot(), "other-tool"); await seedCachedPackage(unrelated, "data"); const outcome = await cleaner.clear({ packageName: null, all: true, yes: true, dryRun: false }); expect(outcome.warnings).toEqual([]); - expect(existsSync(cleaner.opencodeCacheRoot())).toBe(false); + expect(existsSync(cache.opencodeRoot())).toBe(false); }); test("requires --yes and deletes nothing without it", async () => { @@ -164,8 +167,8 @@ describe("clearCache dry-run", () => { const outcome = await cleaner.clear({ packageName: null, all: true, yes: false, dryRun: true }); expect(outcome.dryRun).toBe(true); - expect(outcome.removed).toEqual([cleaner.opencodeCacheRoot()]); - expect(existsSync(cleaner.opencodeCacheRoot())).toBe(true); + expect(outcome.removed).toEqual([cache.opencodeRoot()]); + expect(existsSync(cache.opencodeRoot())).toBe(true); }); test("with nothing cached lists nothing and succeeds", async () => { diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts index 545dcf2..92a7902 100644 --- a/tests/load-hook.test.ts +++ b/tests/load-hook.test.ts @@ -135,7 +135,7 @@ describe("plugin activation (startup non-interference)", () => { expect(permission.evaluations.length).toBe(0); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain("bunx opencode-architect clear-cache"); - expect(warnings[0]).toContain("~/.cache/opencode/npm/opencode-architect@"); + expect(warnings[0]).toContain(path.join(".cache", "opencode", "npm", "opencode-architect@")); expect(warnings[0]).toContain("ENOENT"); }); From c044485cd21ead3cb964e6efcf806e9c4523d1d2 Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 02:07:58 -0400 Subject: [PATCH 6/7] refactor(config): mixed-key uninstall warns about leftover legacy entries (review of #26) Removing the v2 plugins entry while the same package also sat under a legacy plugin key reported uninstalled success while the suite kept loading via migration; the removal outcome now carries the legacy advisory alongside it. Also: shared hasLegacyEntry/packageOf helpers, PluginConfigKey union so a typo'd key fails to compile, and the plugins key length derived from its literal instead of a magic offset. --- src/plugin-config.ts | 41 +++++++++++++++++++++++-------------- tests/plugin-config.test.ts | 17 +++++++++++++++ 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 1cec2c4..2b899fa 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import path from "node:path"; export type ConfigScope = "local" | "global"; +export type PluginConfigKey = "plugins" | "plugin"; export interface EnsurePluginEntryOptions { scope: ConfigScope; @@ -67,7 +68,7 @@ export class PluginConfigEditor { if (this.hasMatchingEntry(plugins, packageName)) { return { action: "noop", configPath: candidate.path, warning: null }; } - if (legacyPlugins !== null && this.hasMatchingEntry(legacyPlugins, packageName)) { + if (this.hasLegacyEntry(legacyPlugins, packageName)) { return { action: "noop", configPath: candidate.path, @@ -118,8 +119,7 @@ export class PluginConfigEditor { for (const { candidate, plugins, legacyPlugins } of await this.readCandidates(options)) { if (plugins === null) continue; const registered = - this.hasMatchingEntry(plugins, packageName) || - (legacyPlugins !== null && this.hasMatchingEntry(legacyPlugins, packageName)); + this.hasMatchingEntry(plugins, packageName) || this.hasLegacyEntry(legacyPlugins, packageName); if (registered) return candidate.path; } return null; @@ -129,8 +129,9 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { + const reads = await this.readCandidates(options); let legacyPath: string | null = null; - for (const { candidate, text, plugins, legacyPlugins } of await this.readCandidates(options)) { + for (const { candidate, text, plugins, legacyPlugins } of reads) { if (!candidate.writable) continue; if (plugins === null) { return { @@ -151,9 +152,14 @@ export class PluginConfigEditor { }; } await writeFile(candidate.path, spliced); - return { action: "removed", configPath: candidate.path, warning: null }; + const leftover = reads.find((read) => this.hasLegacyEntry(read.legacyPlugins, packageName)); + return { + action: "removed", + configPath: candidate.path, + warning: leftover === undefined ? null : this.legacyRemovalAdvisory(leftover.candidate.path, packageName), + }; } - if (legacyPath === null && legacyPlugins !== null && this.hasMatchingEntry(legacyPlugins, packageName)) { + if (legacyPath === null && this.hasLegacyEntry(legacyPlugins, packageName)) { legacyPath = candidate.path; } } @@ -171,6 +177,10 @@ export class PluginConfigEditor { return entries.some((entry) => this.matchesEntry(entry, packageName)); } + private hasLegacyEntry(legacyPlugins: string[] | null, packageName: string): boolean { + return legacyPlugins !== null && this.hasMatchingEntry(legacyPlugins, packageName); + } + private matchesEntry(entry: string, packageName: string): boolean { const specIndex = entry.lastIndexOf("@"); const name = specIndex > 0 ? entry.slice(0, specIndex) : entry; @@ -219,7 +229,7 @@ export class PluginConfigEditor { return reads; } - private keyEntries(config: Record, key: string): string[] { + private keyEntries(config: Record, key: PluginConfigKey): string[] { const value = config[key]; if (!Array.isArray(value)) return []; return value.map((entry) => this.normalizeEntry(entry)).filter((entry): entry is string => entry !== null); @@ -227,11 +237,13 @@ export class PluginConfigEditor { private normalizeEntry(entry: unknown): string | null { if (typeof entry === "string") return entry; - if (entry !== null && typeof entry === "object") { - const pkg = (entry as { package?: unknown }).package; - if (typeof pkg === "string") return pkg; - } - return null; + return this.packageOf(entry); + } + + private packageOf(entry: unknown): string | null { + if (entry === null || typeof entry !== "object") return null; + const pkg = (entry as { package: unknown }).package; + return typeof pkg === "string" ? pkg : null; } private legacyEntryAdvisory(candidate: CandidateConfig, packageName: string): string { @@ -452,8 +464,7 @@ export class PluginConfigEditor { const trimmed = raw.trim(); if (!trimmed.startsWith("{")) return this.unquote(trimmed); try { - const parsed = JSON.parse(trimmed.replace(/,(\s*[}\]])/g, "$1")) as { package?: unknown }; - return typeof parsed.package === "string" ? parsed.package : ""; + return this.packageOf(JSON.parse(trimmed.replace(/,(\s*[}\]])/g, "$1"))) ?? ""; } catch { return ""; } @@ -495,7 +506,7 @@ export class PluginConfigEditor { const keyIndex = navigable.indexOf('"plugins"', searchFrom); if (keyIndex === -1) return null; if (this.precededByStructuralChar(navigable, keyIndex, ["{", ","])) { - const colonIndex = this.nextOutsideString(navigable, keyIndex + 9, ":"); + const colonIndex = this.nextOutsideString(navigable, keyIndex + '"plugins"'.length, ":"); if (colonIndex !== -1) { const bracketStart = this.nextOutsideString(navigable, colonIndex + 1, "["); if (bracketStart !== -1) { diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index 43e5010..f22905a 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -422,4 +422,21 @@ describe("PluginConfigEditor.removePluginEntry", () => { const parsed = JSON.parse(await readFile(configPath, "utf-8")); expect(parsed.plugins).toEqual(["other"]); }); + + test("removing the v2 entry warns about a leftover legacy entry in the same config", async () => { + const projectDir = await makeDir("project"); + const configPath = await write( + "project/opencode.json", + '{ "plugins": ["my-pkg"], "plugin": ["my-pkg"] }\n', + ); + + const outcome = await editor().removePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("removed"); + expect(outcome.warning).toContain("legacy v1"); + expect(outcome.warning).toContain(configPath); + const parsed = JSON.parse(await readFile(configPath, "utf-8")); + expect(parsed.plugins).toEqual([]); + expect(parsed.plugin).toEqual(["my-pkg"]); + }); }); From ebb81eaaaf29c390f8fd53227ede645eac6ce5b3 Mon Sep 17 00:00:00 2001 From: Diego Bustamante Date: Tue, 6 Oct 2026 02:28:05 -0400 Subject: [PATCH 7/7] fix(tests): stale v1-era cache paths left from the layout migration (ci fix for #26) CI caught the cache-pruning removal-failure test chmod'ing the old /nested path that the v2 generation-layout seeding no longer creates. A fresh audit of the migrated test files found the same class twice more: a silently no-op existsSync assertion against opencode/packages in the cli clear-cache test, and the shared seedCachedPackage helper plus the cli removal-failure test seeding the v1 layout. All now seed and block the v2 //node_modules/ layout. finally-block chmod cleanup is tolerant so the real assertion failure surfaces on platforms where the dir bit does not block removal. --- tests/cache-cleaner.test.ts | 2 +- tests/cli.test.ts | 16 ++++++++++++---- tests/installer.test.ts | 10 ++++++++-- tests/test-helpers.ts | 5 +++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index 729bb47..eae16fa 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -198,7 +198,7 @@ describe("clearCache failure tolerance", () => { expect(outcome.warnings.length).toBe(1); expect(outcome.warnings[0]).toContain(kept); } finally { - await chmod(kept, 0o700); + await chmod(kept, 0o700).catch(() => {}); } }); }); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index e728cad..137eb68 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -96,9 +96,17 @@ describe("cli", () => { if (process.getuid?.() === 0) return; const dir = await mkdtemp(path.join(tmpdir(), "oa-cli-")); const cacheDir = await mkdtemp(path.join(tmpdir(), "oa-cli-cache-")); - const blocked = path.join(cacheDir, "opencode", "npm", "opencode-architect@latest", "nested"); + const blocked = path.join( + cacheDir, + "opencode", + "npm", + "opencode-architect@latest", + "1738848000000", + "node_modules", + "opencode-architect", + ); await mkdir(blocked, { recursive: true }); - await writeFile(path.join(blocked, "file.txt"), "cached"); + await writeFile(path.join(blocked, "index.ts"), "cached"); await chmod(blocked, 0o500); try { const run = await runCli(["install"], dir, { XDG_CACHE_HOME: cacheDir }); @@ -108,7 +116,7 @@ describe("cli", () => { expect(run.stderr).toContain(`Could not clear cached package ${path.join(cacheDir, "opencode", "npm", "opencode-architect@latest")}`); expect(existsSync(path.join(cacheDir, "opencode", "npm", "opencode-architect"))).toBe(false); } finally { - await chmod(blocked, 0o700); + await chmod(blocked, 0o700).catch(() => {}); await rm(dir, { recursive: true, force: true }); await rm(cacheDir, { recursive: true, force: true }); } @@ -148,7 +156,7 @@ describe("cli clear-cache", () => { expect(run.exitCode).toBe(0); expect(existsSync(ours)).toBe(false); - expect(existsSync(path.join(cacheDir, "opencode", "packages", "opencode-architect@latest"))).toBe(false); + expect(existsSync(path.join(cacheDir, "opencode", "npm", "opencode-architect@latest"))).toBe(false); expect(existsSync(other)).toBe(true); } finally { await rm(cacheDir, { recursive: true, force: true }); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index fd652a7..7613620 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -471,7 +471,13 @@ describe("Installer.install cache pruning", () => { test("removal failure warns but the install still succeeds", async () => { await seedCache(); - const blocked = path.join(cacheRoot(), "opencode-architect@latest", "nested"); + const blocked = path.join( + cacheRoot(), + "opencode-architect@latest", + "1738848000000", + "node_modules", + "opencode-architect", + ); await chmod(blocked, 0o500); try { const outcome = await install("local"); @@ -482,7 +488,7 @@ describe("Installer.install cache pruning", () => { expect(existsSync(path.join(cacheRoot(), "opencode-architect"))).toBe(false); expect(existsSync(path.join(cacheRoot(), "opencode-architect@latest"))).toBe(true); } finally { - await chmod(blocked, 0o700); + await chmod(blocked, 0o700).catch(() => {}); } }); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index e10f653..320487f 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -18,8 +18,9 @@ export function captureConsole(method: "log" | "warn"): CapturedConsole { export async function seedCachedPackage(dir: string, name: string): Promise { const target = path.join(dir, name); - await mkdir(path.join(target, "nested"), { recursive: true }); - await writeFile(path.join(target, "nested", "file.txt"), "cached"); + const pkgDir = path.join(target, "1738848000000", "node_modules", name.replace(/@.*$/, "")); + await mkdir(pkgDir, { recursive: true }); + await writeFile(path.join(pkgDir, "index.ts"), "cached"); return target; }