From d74d9bf63db82b103ef4ce4b31d3166a902937c9 Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 22:46:34 -0400 Subject: [PATCH 01/10] feat(templates): plugin-config template renders the v2-native editor (issue #29) --- templates/plugin-config.template.txt | 142 +++++++++++++++++++++------ 1 file changed, 113 insertions(+), 29 deletions(-) diff --git a/templates/plugin-config.template.txt b/templates/plugin-config.template.txt index 990dbac..fb7d21f 100644 --- a/templates/plugin-config.template.txt +++ b/templates/plugin-config.template.txt @@ -2,7 +2,7 @@ None — emit as-is as src/plugin-config.ts. -**Load-bearing — do not simplify:** the editor is a surgical text splice into the `plugin` array with every other byte of the config untouched — never a parse-then-reserialize (comments, key order, and formatting survive); candidates are consulted in priority order: for local scope the nested `.opencode/opencode.json` then `opencode.jsonc`, then the repo-root `opencode.json` then `opencode.jsonc`; for global scope the same pair in the global scope base; and finally the global `config.json` which is read-only (detection only, never written); every candidate is read up front and every unparseable one is warned about **before** any short-circuit on a successful match; a config that fails to parse blocks the edit — never rewrite config from `{}`; `.jsonc` parses leniently (comments blanked string-aware so `$schema` URLs and escaped quotes survive, trailing commas removed) and `.json` stays strict; matching is semantic (`name`, `name@latest`, `name@x.y.z` are the same package) and new entries are written canonically as `name@latest`; an existing semantically matching entry is a zero-write no-op; when no config exists a minimal `opencode.jsonc` is created; every splice re-parses the result and aborts untouched if the entry is not present after the edit. +**Load-bearing — do not simplify:** the editor is a surgical text splice into the v2 `plugins` array with every other byte of the config untouched — never a parse-then-reserialize (comments, key order, and formatting survive); candidates are consulted in priority order: for local scope the nested `.opencode/opencode.json` then `opencode.jsonc`, then the repo-root `opencode.json` then `opencode.jsonc`; for global scope the same pair in the global scope base; and finally the global `config.json` which is read-only (detection only, never written); every candidate is read up front and every unparseable one is warned about **before** any short-circuit on a successful match; a config that fails to parse blocks the edit — never rewrite config from `{}`; `.jsonc` parses leniently (comments blanked string-aware so `$schema` URLs and escaped quotes survive, trailing commas removed) and `.json` stays strict; both array shapes are read: the v2 `plugins` key is authoritative and the legacy v1 `plugin` key is tolerated read-only — a semantically matching legacy entry is a zero-write no-op with an upgrade advisory, and a legacy entry in `config.json` is reported as inert (v2 never reads that file); entries match by package name whether written as strings or `{ "package": ... }` objects; matching is semantic (`name`, `name@latest`, `name@x.y.z` are the same package) and new entries are written canonically as `name@latest` under `plugins`; an existing v2 entry is a zero-write no-op; when no config exists a minimal `opencode.jsonc` is created; every splice re-parses the result and aborts untouched if the entry is not present after the edit. --- import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; @@ -10,6 +10,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; @@ -22,12 +23,6 @@ export interface EnsurePluginEntryOutcome { warning: string | null; } -export interface RemovePluginEntryOutcome { - action: "noop" | "removed" | "blocked"; - configPath: string | null; - warning: string | null; -} - interface CandidateConfig { path: string; lenient: boolean; @@ -38,6 +33,13 @@ interface CandidateRead { candidate: CandidateConfig; text: string; plugins: string[] | null; + legacyPlugins: string[] | null; +} + +export interface RemovePluginEntryOutcome { + action: "noop" | "removed" | "blocked"; + configPath: string | null; + warning: string | null; } export type ConfigCheck = { ok: true } | { ok: false; warning: string }; @@ -50,7 +52,7 @@ interface PluginArrayRange { const DEFAULT_CONFIG_TEMPLATE = `{ // OpenCode configuration "$schema": "https://opencode.ai/config.json", - "plugin": ["__PACKAGE_NAME__"] + "plugins": ["__PACKAGE_NAME__"] } `; @@ -60,7 +62,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", @@ -73,6 +75,13 @@ export class PluginConfigEditor { if (this.hasMatchingEntry(plugins, packageName)) { return { action: "noop", configPath: candidate.path, warning: null }; } + if (this.hasLegacyEntry(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) { @@ -114,9 +123,11 @@ 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) || this.hasLegacyEntry(legacyPlugins, packageName); + if (registered) return candidate.path; } return null; } @@ -125,7 +136,9 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { - for (const { candidate, text, plugins } of await this.readCandidates(options)) { + const reads = await this.readCandidates(options); + let legacyPath: string | null = null; + for (const { candidate, text, plugins, legacyPlugins } of reads) { if (!candidate.writable) continue; if (plugins === null) { return { @@ -136,17 +149,33 @@ 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) { + 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); + const leftover = reads.find((read) => this.hasLegacyEntry(read.legacyPlugins, packageName)); return { - action: "blocked", + action: "removed", configPath: candidate.path, - warning: `Plugin array in ${candidate.path} could not be safely edited; file left untouched.`, + warning: leftover === undefined ? null : this.legacyRemovalAdvisory(leftover.candidate.path, packageName), }; } - await writeFile(candidate.path, spliced); - return { action: "removed", configPath: candidate.path, warning: null }; + if (legacyPath === null && this.hasLegacyEntry(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 }; } @@ -155,6 +184,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; @@ -190,17 +223,58 @@ 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: 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); + } + + private normalizeEntry(entry: unknown): string | null { + if (typeof entry === "string") return entry; + 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 { + 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"); @@ -218,9 +292,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 { @@ -271,12 +343,14 @@ export class PluginConfigEditor { if (current === "/" && next === "/") { chars[i] = " "; chars[i + 1] = " "; + i++; inLineComment = true; continue; } if (current === "/" && next === "*") { chars[i] = " "; chars[i + 1] = " "; + i++; inBlockComment = true; continue; } @@ -311,7 +385,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); @@ -393,6 +467,16 @@ export class PluginConfigEditor { return trimmed; } + private elementSpec(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) return this.unquote(trimmed); + try { + return this.packageOf(JSON.parse(trimmed.replace(/,(\s*[}\]])/g, "$1"))) ?? ""; + } catch { + return ""; + } + } + private spliceArrayEntry( text: string, navigable: string, @@ -418,7 +502,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); } @@ -426,10 +510,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 + '"plugins"'.length, ":"); if (colonIndex !== -1) { const bracketStart = this.nextOutsideString(navigable, colonIndex + 1, "["); if (bracketStart !== -1) { From f75cbe83b12d3d42d4a07127d5f997fd7a34fc44 Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 22:47:34 -0400 Subject: [PATCH 02/10] feat(templates): registration + manifest templates read the v2 plugins key, legacy tolerated read-only (issue #29) --- templates/manifest.template.txt | 22 ++++++++++++--- templates/registration.template.txt | 43 +++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/templates/manifest.template.txt b/templates/manifest.template.txt index 4c339c9..56dbb5c 100644 --- a/templates/manifest.template.txt +++ b/templates/manifest.template.txt @@ -4,7 +4,7 @@ |---|---|---|---| | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; the manifest file is `/.manifest.json` | — | -**Load-bearing — do not simplify:** idempotency is per-file sha256 (`Bun.CryptoHasher`), never a `.version` marker; a missing or malformed manifest reads as "not installed" (drift), never as a reason to write without an ensure path; a manifest without a `mode` field reads as `"copy"` so pre-generalization copy installs migrate cleanly; the manifest is one record per scope for BOTH install modes — `mode` and the registration fields record the deployment plan, while `files` records whatever payload the load-time hook has ensured (so a plugin-mode manifest gains payload hashes after the first load, and the CLI must preserve them when it rewrites registration); plugin-mode up-to-dateness includes the registration — the recorded entry must still be present in the recorded config file (semantic name match, `@latest`-aware) — plus the recorded payload hashes; consumer-modified files that were skipped during install are recorded with their **packaged** hash — never the consumer's on-disk content — so the modification stays detectable and `--force` can still take ownership of it. +**Load-bearing — do not simplify:** idempotency is per-file sha256 (`Bun.CryptoHasher`), never a `.version` marker; a missing or malformed manifest reads as "not installed" (drift), never as a reason to write without an ensure path; a manifest without a `mode` field reads as `"copy"` so pre-generalization copy installs migrate cleanly; the manifest is one record per scope for BOTH install modes — `mode` and the registration fields record the deployment plan, while `files` records whatever payload the load-time hook has ensured (so a plugin-mode manifest gains payload hashes after the first load, and the CLI must preserve them when it rewrites registration); plugin-mode up-to-dateness includes the registration — the recorded entry must still be present in the recorded config file (semantic name match, `@latest`-aware) — plus the recorded payload hashes; consumer-modified files that were skipped during install are recorded with their **packaged** hash — never the consumer's on-disk content — so the modification stays detectable and `--force` can still take ownership of it; registration presence is checked against the v2 `plugins` key first and the legacy v1 `plugin` key read-only, with entries matched by package name whether written as strings or `{ "package": ... }` objects. --- import { readdir, readFile, writeFile, mkdir } from "node:fs/promises"; @@ -133,14 +133,28 @@ export class InstallManifest { function pluginEntries(text: string, configPath: string): string[] { try { - const config = (configPath.endsWith(".jsonc") ? parseJsonc(text) : JSON.parse(text)) as { plugin?: unknown }; - if (!Array.isArray(config.plugin)) return []; - return config.plugin.filter((entry): entry is string => typeof entry === "string"); + const config = (configPath.endsWith(".jsonc") ? parseJsonc(text) : JSON.parse(text)) as Record; + const v2 = entryNames(config.plugins); + if (v2.length > 0) return v2; + return entryNames(config.plugin); } catch { return []; } } +function entryNames(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => (typeof entry === "string" ? entry : entryPackage(entry))) + .filter((entry): entry is string => entry !== null); +} + +function entryPackage(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; +} + export async function listFilesRecursive(rootDir: string): Promise { const entries = await readdir(rootDir, { withFileTypes: true, recursive: true }); return entries.filter(entry => entry.isFile()).map(entry => join(entry.parentPath, entry.name)); diff --git a/templates/registration.template.txt b/templates/registration.template.txt index 673fdca..c7ef154 100644 --- a/templates/registration.template.txt +++ b/templates/registration.template.txt @@ -2,7 +2,7 @@ None — emit as-is as src/registration.ts. -**Load-bearing — do not simplify:** detection is read-only (no writes, ever); it checks the global config base (`opencode.json`, `opencode.jsonc`, and the legacy `config.json`), the repo's `.opencode/opencode.json(c)`, and a repo-root `opencode.json(c)` — both extensions at every base plus global `config.json` — with `@latest`-aware name matching, never the launch directory, which opencode always sets to the consumer repo, and never any directory-identity comparison (plugin dir vs `directory`, `import.meta.dirname`, `process.cwd()`, realpath); a maintainer's own checkout is served by that repo's own config registration, not by a directory check. `.jsonc` parses leniently (string-aware `//` and `/* */` comment stripping plus trailing-comma removal, so a `$schema` URL's `//` and escaped quotes survive); `.json` stays strict. Every unparseable candidate is warned about before any short-circuit. Nested and root configs combine with OR — a present-but-unregistered nested file never masks a repo-root registration. +**Load-bearing — do not simplify:** detection is read-only (no writes, ever); it checks the global config base (`opencode.json`, `opencode.jsonc`, and the legacy `config.json`), the repo's `.opencode/opencode.json(c)`, and a repo-root `opencode.json(c)` — both extensions at every base plus global `config.json` — with `@latest`-aware name matching, never the launch directory, which opencode always sets to the consumer repo, and never any directory-identity comparison (plugin dir vs `directory`, `import.meta.dirname`, `process.cwd()`, realpath); a maintainer's own checkout is served by that repo's own config registration, not by a directory check. The v2 `plugins` key is authoritative and the legacy v1 `plugin` key is tolerated read-only — an entry under either key counts as registered, and entries match by package name whether written as strings or `{ "package": ... }` objects. `.jsonc` parses leniently (string-aware `//` and `/* */` comment stripping plus trailing-comma removal, so a `$schema` URL's `//` and escaped quotes survive); `.json` stays strict. Every unparseable candidate is warned about before any short-circuit. Nested and root configs combine with OR — a present-but-unregistered nested file never masks a repo-root registration. --- import { exists, readFile } from "node:fs/promises"; @@ -77,20 +77,37 @@ export function parseJsonc(text: string): unknown { return JSON.parse(stripTrailingCommas(stripJsoncComments(text))); } -async function readPluginEntries(path: string): Promise { +interface PluginEntries { + plugins: string[]; + legacyPlugins: string[]; +} + +function entryNames(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => (typeof entry === "string" ? entry : entryPackage(entry))) + .filter((entry): entry is string => entry !== null); +} + +function entryPackage(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; +} + +async function readPluginEntries(path: string): Promise { if (!(await exists(path))) return null; try { const raw = await readFile(path, "utf-8"); - const config = (path.endsWith(".jsonc") ? parseJsonc(raw) : JSON.parse(raw)) as { plugin?: unknown }; - if (!Array.isArray(config.plugin)) return []; - return config.plugin.filter((entry): entry is string => typeof entry === "string"); + const config = (path.endsWith(".jsonc") ? parseJsonc(raw) : JSON.parse(raw)) as Record; + return { plugins: entryNames(config.plugins), legacyPlugins: entryNames(config.plugin) }; } catch { console.warn(`Warning: ${path} could not be parsed; ignoring it as a registration candidate.`); return null; } } -async function readEntriesForBase(base: string): Promise { +async function readEntriesForBase(base: string): Promise { for (const file of ["opencode.json", "opencode.jsonc"]) { const entries = await readPluginEntries(join(base, file)); if (entries !== null) return entries; @@ -98,7 +115,7 @@ async function readEntriesForBase(base: string): Promise { return null; } -async function readGlobalEntries(): Promise { +async function readGlobalEntries(): Promise { const entries = await readEntriesForBase(getGlobalConfigPath()); if (entries !== null) return entries; return readPluginEntries(join(getGlobalConfigPath(), "config.json")); @@ -113,16 +130,18 @@ export class RegistrationDetector { const globalEntries = await readGlobalEntries(); const nestedEntries = await readEntriesForBase(join(projectDir, ".opencode")); const rootEntries = await readEntriesForBase(projectDir); - const inGlobal = globalEntries !== null && this.matchesAny(globalEntries); - const inLocal = (nestedEntries !== null && this.matchesAny(nestedEntries)) - || (rootEntries !== null && this.matchesAny(rootEntries)); + const inGlobal = this.matchesAny(globalEntries); + const inLocal = this.matchesAny(nestedEntries) || this.matchesAny(rootEntries); if (inGlobal && inLocal) return "both"; if (inGlobal) return "global"; if (inLocal) return "repo-local"; return "none"; } - private matchesAny(entries: string[]): boolean { - return entries.some(entry => this.normalizer.matches(entry, this.packageName)); + private matchesAny(entries: PluginEntries | null): boolean { + if (entries === null) return false; + return [...entries.plugins, ...entries.legacyPlugins].some((entry) => + this.normalizer.matches(entry, this.packageName), + ); } } From 8d3fc2ed6dd0c932e9fb87e06c6ed684155994a2 Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 22:49:05 -0400 Subject: [PATCH 03/10] fix(templates): installer cache root follows the v2 npm cache location; plugins-key wording (issue #29) --- templates/installer.template.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/installer.template.txt b/templates/installer.template.txt index 257c053..1d22bc4 100644 --- a/templates/installer.template.txt +++ b/templates/installer.template.txt @@ -7,7 +7,7 @@ | `COMMAND NAME` → "my-command.md" | Yes | Command file name in commands/ at the package root | — | | `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; also the manifest file base | — | -**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no plugin array edits; the `plugin` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; bundled content directories sit at the package root (`skills/`, `commands/`, `agents/` — no `assets/` intermediary) and resolve through `ASSET_LAYOUT_DIR`, which is `"."` by default; only when adapting a legacy package that wraps them in `assets/` is the constant overridden to `"assets"`, and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Cache hygiene is self-scoped (ADR-0007): every install — including a no-op — prunes this package's own cache copies (``, `@latest`, `@`) from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/packages`, falling back to `~/.cache/opencode/packages`), best-effort warn-and-continue, never touching other packages' cache dirs or pinned versions, and the load-time hook never prunes — only the CLI's install and `clear-cache` paths do. Every function here is scope-parameterized: the exact same logic serves the local scope base (`/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior. +**Load-bearing — do not simplify:** the deployment plan is content-based (ADR-0008): the content declaration (`"content"` in package.json, `"assets"` or `"code"`) decides the install modes — assets-only packages copy-install by default with `--mode plugin` as the opt-in, code-backed packages always plugin-register and `--mode copy` throws `CopyModeUnsupportedError`; copy install touches no config file at all — no permission blocks, no MCP entries, no `plugins` array edits; the `plugins` array is edited only by the surgical `PluginConfigEditor` (text splice, every other byte untouched, never a parse-then-reserialize, never rewrite config from `{}`, existing entry is a zero-write no-op); manifest-gated idempotency (never `.version` markers), the plugin-mode zero-write no-op includes a present entry (version match alone is not enough), and payload idempotency requires a recorded, hash-matching payload — a plugin-mode manifest with an empty file map means the load-time hook has not ensured assets yet, so the ensure path runs and records the payload hashes while preserving the recorded mode, entry, and target config file; config reading consults both `opencode.json` and `opencode.jsonc` per base, with `.jsonc` parsed leniently and `.json` strict, and every unparseable candidate is warned about; bundled content directories sit at the package root (`skills/`, `commands/`, `agents/` — no `assets/` intermediary) and resolve through `ASSET_LAYOUT_DIR`, which is `"."` by default; only when adapting a legacy package that wraps them in `assets/` is the constant overridden to `"assets"`, and a missing or empty asset source is a hard error naming the path, package name+version, cache dir, and install command — never a silent skip, and a manifest is never written when zero files were written; the manifest hashes the whole payload relative to the config base (skills and commands), and `migrateRootConfig` deletes nothing without a consent callback and is never called from `install()`. Cache hygiene is self-scoped (ADR-0007): every install — including a no-op — prunes this package's own cache copies (``, `@latest`, `@`) from OpenCode's package cache (`$XDG_CACHE_HOME/opencode/npm`, falling back to `~/.cache/opencode/npm`), best-effort warn-and-continue, never touching other packages' cache dirs or pinned versions, and the load-time hook never prunes — only the CLI's install and `clear-cache` paths do. Every function here is scope-parameterized: the exact same logic serves the local scope base (`/.opencode/`) and the global scope base (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`), so a global install and a project install differ only in the base path — never in behavior. --- import { exists, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; @@ -114,10 +114,10 @@ function getPackageDir(): string { return join(import.meta.dirname, ".."); } -function packageCacheRoot(): string { +export function packageCacheRoot(): string { const xdgCacheHome = process.env.XDG_CACHE_HOME; - if (xdgCacheHome) return join(xdgCacheHome, "opencode", "packages"); - return join(homedir(), ".cache", "opencode", "packages"); + if (xdgCacheHome) return join(xdgCacheHome, "opencode", "npm"); + return join(homedir(), ".cache", "opencode", "npm"); } interface CacheOutcome { From c21371a7802134cfd4dcedb1b3791d8e14295ce3 Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 22:50:13 -0400 Subject: [PATCH 04/10] feat(templates): plugin entry template is Effect-first v2 (Plugin.define, never-throw effect, npm cache advisory) (issue #29) --- templates/plugin-local.template.txt | 76 +++++++++++++++++++---------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/templates/plugin-local.template.txt b/templates/plugin-local.template.txt index 98eb0c3..7445e2b 100644 --- a/templates/plugin-local.template.txt +++ b/templates/plugin-local.template.txt @@ -5,16 +5,19 @@ | `EXTENSION NAME` → "myextension" | Yes | Extension identifier used in the advisory text | — | | `SKILL NAME` → "myextension" | Yes | Must match the folder name in skills/ at the package root | — | | `COMMAND NAME` → "my-command.md" | Yes | Command file name in commands/ at the package root | — | -| `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; must match package.json | — | +| `PACKAGE NAME` → "opencode-myextension" | Yes | npm package name; must match package.json; also the stable plugin id | — | -**Load-bearing — do not simplify:** scope detection is read-only config inspection (both `opencode.json` and `opencode.jsonc`, global and project, plus global `config.json` via the detector), never launch-directory checks and never any directory-identity comparison (`import.meta.dirname`, `process.cwd()`, realpath) — a maintainer's own checkout is served by that repo's own config registration; the hook calls only `ensureAssets` — it never resolves an install mode, never edits `plugin` arrays, permission, or any config file, and writes stay inside the detected registration scope(s) only; assets-only and code-backed packages share this hook unchanged: assets are ensured in registered scopes either way, and code-backed assets resolve from the package at load; the **entire hook body** (detection, ensures, advisory) is wrapped in try/catch so a failure degrades to a warning and OpenCode still launches — hooks must never throw; the failure advisory names both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`~/.cache/opencode/packages/@`), tells the consumer to run `bunx clear-cache` for a partial cache artifact instead of describing manual removal, is built inside its own try/catch with a static fallback, and each emitter swallows independently; the failure advisory and the not-installed advisory carry **separate** once-guards so neither suppresses the other; the zero-write no-op covers a fully matching manifest **and** a present plugin entry (an up-to-date manifest alone is not enough for plugin installs), so a healthy startup performs no writes at all; the hook never deletes the cache — it instructs only. +**Load-bearing — do not simplify:** the entry is an Effect-first v2 plugin definition (`Plugin.define({ id, effect })` from `@opencode/plugin/effect`) whose `id` is the stable package name — duplicate ids die activation and storage is scoped per id (facts §2); scope detection is read-only config inspection (both `opencode.json` and `opencode.jsonc`, global and project, plus global `config.json` via the detector), reading the launch directory from `context.location.directory` — never directory-identity comparisons (`import.meta.dirname`, `process.cwd()`, realpath) — a maintainer's own checkout is served by that repo's own config registration; the effect body calls only `ensureAssets` — it never resolves an install mode, never edits `plugins` arrays, permission, or any config file, and writes stay inside the detected registration scope(s) only; assets-only and code-backed packages share this body unchanged: assets are ensured in registered scopes either way, and code-backed assets resolve from the package at load; the **entire effect body** (detection, ensures, advisory) is wrapped so a failure degrades to a warning and OpenCode still launches — the effect's error channel stays `never` and it must never interrupt startup (ADR-0007); the failure advisory names both the remediation command (`bunx install --scope global`) and the package-qualified cache dir (`/opencode/npm/@`), tells the consumer to run `bunx clear-cache` for a partial cache artifact instead of describing manual removal, is built inside its own guarded effect with a static fallback, and each emitter swallows independently; the failure advisory and the not-installed advisory carry **separate** once-guards so neither suppresses the other; the zero-write no-op covers a fully matching manifest **and** a present plugin entry (an up-to-date manifest alone is not enough for plugin installs), so a healthy startup performs no writes at all; the effect never deletes the cache — it instructs only. --- -import type { Plugin } from "@opencode-ai/plugin"; -import { ensureAssets, isInstalledAnywhere } from "./installer.ts"; +import { sep } from "node:path"; +import { Effect, type Scope } from "effect"; +import { Plugin } from "@opencode/plugin/effect"; +import { ensureAssets, isInstalledAnywhere, packageCacheRoot } from "./installer.ts"; import { RegistrationDetector, type RegistrationScope } from "./registration.ts"; const PACKAGE_NAME = "opencode-myextension"; +const EXTENSION_TITLE = "MyExtension"; let notInstalledAdvised = false; let failureAdvised = false; @@ -22,7 +25,7 @@ let failureAdvised = false; async function adviseNotInstalledOnce(): Promise { if (notInstalledAdvised) return; notInstalledAdvised = true; - console.log(`\nOpenCode MyExtension is not registered in any scope.`); + console.log(`\nOpenCode ${EXTENSION_TITLE} is not registered in any scope.`); console.log(` Install globally: bunx ${PACKAGE_NAME} install --scope global`); console.log(` Install locally: bunx ${PACKAGE_NAME} install\n`); } @@ -36,35 +39,54 @@ async function adviseFailureOnce(message: string): Promise { try { version = JSON.parse(await Bun.file(`${import.meta.dirname}/../package.json`).text()).version ?? version; } catch {} - text = `MyExtension load-time install failed. Run: bunx ${PACKAGE_NAME} clear-cache, then: bunx ${PACKAGE_NAME} install --scope global. The stale cache copy is ~/.cache/opencode/packages/${PACKAGE_NAME}@${version}. Cause: ${message}`; + const cacheCopy = `${packageCacheRoot()}${sep}${PACKAGE_NAME}@${version}`; + text = `${EXTENSION_TITLE} load-time install failed. Run: bunx ${PACKAGE_NAME} clear-cache, then: bunx ${PACKAGE_NAME} install --scope global. The stale cache copy is ${cacheCopy}. Cause: ${message}`; } catch { - text = `MyExtension load-time install failed. Run: bunx ${PACKAGE_NAME} clear-cache, then: bunx ${PACKAGE_NAME} install --scope global (the stale copy lives under ~/.cache/opencode/packages/)`; + text = `${EXTENSION_TITLE} load-time install failed. Run: bunx ${PACKAGE_NAME} clear-cache, then: bunx ${PACKAGE_NAME} install --scope global (the stale copy lives under the OpenCode npm cache)`; } try { console.warn(`[${PACKAGE_NAME}] ${text}`); } catch {} } -const plugin: Plugin = async ({ directory }) => ({ - config: async () => { - try { - const scope: RegistrationScope = await new RegistrationDetector(PACKAGE_NAME).detect(directory); - if (scope === "none") { - await adviseNotInstalledOnce(); - return; - } - if (scope === "global" || scope === "both") { - await ensureAssets("global", directory, false); - } - if (scope === "repo-local" || scope === "both") { - await ensureAssets("local", directory, false); - } - if (await isInstalledAnywhere(directory)) return; - await adviseNotInstalledOnce(); - } catch (error) { - await adviseFailureOnce(error instanceof Error ? error.message : String(error)); - } - }, +function advisoryEffect(run: () => Promise): Effect.Effect { + return Effect.asVoid( + Effect.orElseSucceed(Effect.tryPromise({ try: run, catch: () => "advisory failed" }), () => undefined), + ); +} + +function ensureRegisteredScopeAssets(context: Plugin.Context): Effect.Effect { + return Effect.asVoid( + Effect.catchAll( + Effect.tryPromise({ + try: async () => { + const directory = context.location.directory; + const scope: RegistrationScope = await new RegistrationDetector(PACKAGE_NAME).detect(directory); + if (scope === "none") { + await adviseNotInstalledOnce(); + return; + } + if (scope === "global" || scope === "both") { + await ensureAssets("global", directory, false); + } + if (scope === "repo-local" || scope === "both") { + await ensureAssets("local", directory, false); + } + if (await isInstalledAnywhere(directory)) return; + await adviseNotInstalledOnce(); + }, + catch: (error) => (error instanceof Error ? error.message : String(error)), + }), + (message) => advisoryEffect(() => adviseFailureOnce(message)), + ), + ); +} + +const plugin = Plugin.define({ + id: PACKAGE_NAME, + effect: Effect.fn(function* (context) { + yield* ensureRegisteredScopeAssets(context); + }), }); export default plugin; From 7970c38f7d409cf3154e4409c41e3e603cf364bb Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 23:00:07 -0400 Subject: [PATCH 05/10] feat(templates): package manifests declare the v2 server export with latest ranges; skill template drops v1-only frontmatter (issue #29) --- templates/package-basics.template.json | 43 ++++++++++++++++++++------ templates/package-full.template.json | 16 ++++++++++ templates/skill-structure.template.md | 32 +++++++++---------- 3 files changed, 64 insertions(+), 27 deletions(-) diff --git a/templates/package-basics.template.json b/templates/package-basics.template.json index bbac600..9604cf2 100644 --- a/templates/package-basics.template.json +++ b/templates/package-basics.template.json @@ -1,5 +1,5 @@ TEMPLATE INSTRUCTIONS -==================== +===================== Replace the following placeholders before use: PACKAGE NAME @@ -22,16 +22,23 @@ FILES "plugins", or "tools" when the package ships them -BIN - "src/cli.ts" → the bunx entry point; keeps every - `bunx ...` advisory working at - packager time, not only after publishing - CONTENT DECLARATION "assets" → keep when the package ships only skills and/or commands "code" → replace when the package ships any agent, tool, hook, or - other plugin integration (code-backed packages may also - ship skills/commands) + other plugin integration (code-backed packages may also + ship skills/commands) + +EXPORTS + "./server" → keep pointing at ./index.ts — OpenCode v2 resolves + the plugin entrypoint through the ./server export + on every runtime; never rely on the root-index + fallback (per opencode-v2-facts §14.6) + +DEPENDENCIES + "@opencode/plugin": "latest" and "effect": "latest" must stay "latest" — + generated packages resolve current versions at + time of use; never pin a consumer version + (per opencode-v2-facts §15) METADATA "Publisher Name" → your name or organization @@ -45,8 +52,24 @@ METADATA "content": "assets", "type": "module", "module": "index.ts", + "exports": { + ".": "./index.ts", + "./server": "./index.ts" + }, "bin": { "opencode-myextension": "src/cli.ts" }, - "files": ["index.ts", "src", "skills", "commands"] -} \ No newline at end of file + "files": ["index.ts", "src", "skills", "commands"], + "scripts": { + "check": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@opencode/plugin": "latest", + "effect": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.0" + } +} diff --git a/templates/package-full.template.json b/templates/package-full.template.json index b74a138..acbbb48 100644 --- a/templates/package-full.template.json +++ b/templates/package-full.template.json @@ -27,6 +27,18 @@ CONTENT DECLARATION other plugin integration (code-backed packages may also ship skills/commands) +EXPORTS + "./server" → keep pointing at ./index.ts — OpenCode v2 resolves + the plugin entrypoint through the ./server export + on every runtime; never rely on the root-index + fallback (per opencode-v2-facts §14.6) + +DEPENDENCIES + "@opencode/plugin": "latest" and "effect": "latest" must stay "latest" — + generated packages resolve current versions at + time of use; never pin a consumer version + (per opencode-v2-facts §15) + METADATA "Publisher Name" → your name or organization "Author Name " → your name and email @@ -40,6 +52,10 @@ METADATA "content": "assets", "type": "module", "module": "index.ts", + "exports": { + ".": "./index.ts", + "./server": "./index.ts" + }, "bin": { "opencode-myextension": "src/cli.ts" }, diff --git a/templates/skill-structure.template.md b/templates/skill-structure.template.md index 0685d7b..2be705d 100644 --- a/templates/skill-structure.template.md +++ b/templates/skill-structure.template.md @@ -1,30 +1,28 @@ TEMPLATE INSTRUCTIONS -==================== +===================== Replace the following placeholders before use: -EXTENSION NAME - "myextension" → your extension identifier - -VERSION - "1.0.0" → initial version - -TOPICS - "topic1" → your skill topics - "topic2" +NAME + "myextension" → your skill identifier (defaults to the folder-derived id + when omitted, but naming it explicitly is the house rule) DESCRIPTION "Use this skill when the user asks about..." → your skill description + (required for authoring — selection quality depends on it) + +OPTIONAL + disable-model-invocation: "true" → add only when the skill must never be + auto-invoked by the model (maps to v2 `autoinvoke`) + +Do not add v1 frontmatter fields: `license`, `compatibility`, and free-form +`metadata` are dropped by the v2 skill loader — they are not carried into the +skill record (per opencode-v2-facts §12). Every frontmatter property value is +enclosed in double quotation marks. --- --- name: "myextension" description: "Use this skill when the user asks about..." -license: "MIT" -compatibility: "opencode" -metadata: - version: "1.0.0" - audience: "agents" - topic: "topic1, topic2" --- ## Activation Triggers @@ -43,4 +41,4 @@ metadata: Execute search Query sources Return answer - \ No newline at end of file + From 95b071fa4d3f05ad1598934a68199ee6783f625e Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 23:00:07 -0400 Subject: [PATCH 06/10] test(templates): v1-marker gate over templates plus rendered-package typecheck, contract, install, and load-hook gates (issue #29) --- .gitignore | 3 + tests/templates.test.ts | 254 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 tests/templates.test.ts diff --git a/.gitignore b/.gitignore index f04668a..2ff2f90 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .cache *.tsbuildinfo +# rendered-package fixture (tests/templates.test.ts) +.rendered-fixture + # IntelliJ based IDEs .idea diff --git a/tests/templates.test.ts b/tests/templates.test.ts new file mode 100644 index 0000000..52069bc --- /dev/null +++ b/tests/templates.test.ts @@ -0,0 +1,254 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { DocsFactGate } from "./docs-fact-gate"; +import { ConfigSchemaValidator } from "./config-schema-validator"; +import { V2HostHarness } from "./v2-host-harness"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const TEMPLATES_DIR = path.join(REPO_ROOT, "templates"); +const RENDER_ROOT = path.join(REPO_ROOT, ".rendered-fixture"); +const RENDERED_PACKAGE = path.join(RENDER_ROOT, "opencode-myextension"); +const PACKAGE_NAME = "opencode-myextension"; + +const TEMPLATE_FILES = [ + "cli.template.txt", + "index.template.txt", + "installer.template.txt", + "manifest.template.txt", + "package-basics.template.json", + "package-full.template.json", + "plugin-config.template.txt", + "plugin-local.template.txt", + "plugin-name.template.txt", + "prompts.template.txt", + "registration.template.txt", + "skill-structure.template.md", + "tsconfig.template.json", +] as const; + +const gate = new DocsFactGate(REPO_ROOT); +const validator = new ConfigSchemaValidator(); +const harness = new V2HostHarness(); + +function templateBody(name: string): Promise { + return readFile(path.join(TEMPLATES_DIR, name), "utf-8").then((source) => { + const separator = source.match(/^---$/m); + if (separator === null || separator.index === undefined) return source; + return source.slice(separator.index + 4).trimStart(); + }); +} + +async function renderPackage(): Promise { + const write = async (relative: string, content: string): Promise => { + const target = path.join(RENDERED_PACKAGE, relative); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + }; + await rm(RENDER_ROOT, { recursive: true, force: true }); + await write("index.ts", await templateBody("index.template.txt")); + await write("src/plugin.ts", await templateBody("plugin-local.template.txt")); + await write("src/plugin-name.ts", await templateBody("plugin-name.template.txt")); + await write("src/manifest.ts", await templateBody("manifest.template.txt")); + await write("src/registration.ts", await templateBody("registration.template.txt")); + await write("src/plugin-config.ts", await templateBody("plugin-config.template.txt")); + await write("src/installer.ts", await templateBody("installer.template.txt")); + await write("src/cli.ts", await templateBody("cli.template.txt")); + await write("package.json", await templateBody("package-basics.template.json")); + await write("tsconfig.json", await templateBody("tsconfig.template.json")); + await write("skills/myextension/SKILL.md", await templateBody("skill-structure.template.md")); + await write( + "commands/my-command.md", + `---\ndescription: "Rendered command stub"\n---\n\nSay hello from ${PACKAGE_NAME}.\n`, + ); +} + +interface SpawnOutcome { + exitCode: number; + stdout: string; + stderr: string; +} + +function runBun(args: string[], cwd: string, env: Record): Promise { + const proc = Bun.spawn([process.execPath, ...args], { + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + return Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]).then( + ([exitCode, stdout, stderr]) => ({ exitCode, stdout, stderr }), + ); +} + +function isolatedEnv(base: string): Record { + return { + ...process.env, + XDG_CACHE_HOME: path.join(base, "cache"), + XDG_CONFIG_HOME: path.join(base, "config"), + HOME: path.join(base, "home"), + USERPROFILE: path.join(base, "home"), + } as Record; +} + +function failWithOutput(stage: string, outcome: SpawnOutcome): never { + throw new Error( + `${stage} failed (exit ${outcome.exitCode})\nstdout:\n${outcome.stdout}\nstderr:\n${outcome.stderr}`, + ); +} + +describe("templates carry no known-false v1 claim", () => { + for (const name of TEMPLATE_FILES) { + test(`templates/${name}`, async () => { + const content = await readFile(path.join(TEMPLATES_DIR, name), "utf-8"); + const violations = await gate.findViolations(`templates/${name}`, content); + expect(violations).toEqual([]); + }); + } +}); + +describe("package templates are v2-native", () => { + for (const name of ["package-basics.template.json", "package-full.template.json"]) { + test(`templates/${name}`, async () => { + const body = await templateBody(name); + const manifest = JSON.parse(body) as { + content: string; + exports: Record; + scripts: Record; + dependencies: Record; + }; + expect(manifest.content).toBe("assets"); + expect(manifest.exports["./server"], `${name} must declare exports["./server"] (facts §14.6)`).toBe( + "./index.ts", + ); + expect(manifest.dependencies["@opencode/plugin"], "consumer range must be latest (facts §15)").toBe("latest"); + expect(manifest.dependencies["effect"], "consumer range must be latest (facts §15)").toBe("latest"); + expect(manifest.dependencies["@opencode-ai/plugin"]).toBeUndefined(); + expect(manifest.scripts["check"]).toContain("tsc"); + expect(manifest.scripts["test"]).toBe("bun test"); + }); + } +}); + +describe("the plugin entry template is Effect-first v2", () => { + test("plugin-local defines the plugin through the v2 Effect API", async () => { + const body = await templateBody("plugin-local.template.txt"); + expect(body).toContain('"@opencode/plugin/effect"'); + expect(body).toContain("Plugin.define({"); + expect(body).toContain("Effect.fn(function* (context)"); + expect(body).toContain("context.location.directory"); + expect(body).toContain("id: PACKAGE_NAME"); + expect(body).not.toContain('"@opencode-ai/plugin"'); + }); +}); + +describe("rendered package passes its own gates", () => { + const projectDir = path.join(RENDER_ROOT, "consumer-project"); + let isolatedBase = ""; + + beforeAll(async () => { + await renderPackage(); + isolatedBase = await mkdtemp(path.join(tmpdir(), "opencode-architect-render-env-")); + await mkdir(path.join(isolatedBase, "cache"), { recursive: true }); + await mkdir(path.join(isolatedBase, "config"), { recursive: true }); + await mkdir(path.join(isolatedBase, "home"), { recursive: true }); + await mkdir(projectDir, { recursive: true }); + }, 30_000); + + afterAll(async () => { + await rm(RENDER_ROOT, { recursive: true, force: true }); + await rm(isolatedBase, { recursive: true, force: true }); + }); + + test("typecheck (tsc --noEmit with the rendered tsconfig)", async () => { + const tsc = path.join(REPO_ROOT, "node_modules", "typescript", "bin", "tsc"); + const outcome = await runBun( + [tsc, "--noEmit", "-p", path.join(RENDERED_PACKAGE, "tsconfig.json")], + RENDERED_PACKAGE, + isolatedEnv(isolatedBase), + ); + if (outcome.exitCode !== 0) failWithOutput("rendered package typecheck", outcome); + expect(outcome.exitCode).toBe(0); + }, 180_000); + + test("the rendered entry satisfies the v2 Module contract", async () => { + const entrypoint = path.join(RENDERED_PACKAGE, "index.ts"); + const loaded = await import(pathToFileURL(entrypoint).href); + const check = harness.moduleContract(loaded, entrypoint); + expect(check.violation).toBeNull(); + expect(check.effectKind).toBe("effect"); + expect(check.id).toBe(PACKAGE_NAME); + }); + + test("install --mode plugin registers the package in the v2 plugins key", async () => { + const outcome = await runBun( + [path.join(RENDERED_PACKAGE, "src", "cli.ts"), "install", "--scope", "local", "--mode", "plugin"], + projectDir, + isolatedEnv(isolatedBase), + ); + if (outcome.exitCode !== 0) failWithOutput("rendered package install", outcome); + expect(outcome.stdout).toContain("mode: plugin"); + + const configPath = path.join(projectDir, "opencode.jsonc"); + const configText = await readFile(configPath, "utf-8"); + const verdict = validator.validateText(configText, true); + expect(verdict.ok, `created config must decode against the v2 schema: ${verdict.issue}`).toBe(true); + expect(verdict.config?.plugins).toEqual([`${PACKAGE_NAME}@latest`]); + + const manifest = JSON.parse( + await readFile(path.join(projectDir, ".opencode", `${PACKAGE_NAME}.manifest.json`), "utf-8"), + ) as { mode: string; entry: string; entryConfigPath: string }; + expect(manifest.mode).toBe("plugin"); + expect(manifest.entry).toBe(`${PACKAGE_NAME}@latest`); + expect(manifest.entryConfigPath.replaceAll("\\", "/")).toContain("opencode.jsonc"); + }, 60_000); + + test("status reports the plugin registration per scope", async () => { + const outcome = await runBun( + [path.join(RENDERED_PACKAGE, "src", "cli.ts"), "status"], + projectDir, + isolatedEnv(isolatedBase), + ); + if (outcome.exitCode !== 0) failWithOutput("rendered package status", outcome); + expect(outcome.stdout).toMatch(/Local: installed=true/); + expect(outcome.stdout).toMatch(/pluginInConfig=true/); + }, 60_000); + + test("activating the rendered plugin under the host contract ensures the payload", async () => { + const entrypoint = path.join(RENDERED_PACKAGE, "index.ts"); + const loaded = (await import(pathToFileURL(entrypoint).href)) as { default: unknown }; + const definition = loaded.default as Parameters[0]; + const recording = harness.recordingContext(projectDir); + + await harness.activate(definition, recording); + + const skill = await readFile( + path.join(projectDir, ".opencode", "skills", "myextension", "SKILL.md"), + "utf-8", + ); + expect(skill).toContain('name: "myextension"'); + const command = await readFile(path.join(projectDir, ".opencode", "commands", "my-command.md"), "utf-8"); + expect(command).toContain(PACKAGE_NAME); + const manifest = JSON.parse( + await readFile(path.join(projectDir, ".opencode", `${PACKAGE_NAME}.manifest.json`), "utf-8"), + ) as { files: Record }; + expect(Object.keys(manifest.files).length).toBeGreaterThan(0); + }); + + test("uninstall removes the registration and leaves a parseable config", async () => { + const outcome = await runBun( + [path.join(RENDERED_PACKAGE, "src", "cli.ts"), "uninstall"], + projectDir, + isolatedEnv(isolatedBase), + ); + if (outcome.exitCode !== 0) failWithOutput("rendered package uninstall", outcome); + expect(outcome.stdout).toContain("Removed plugin registration"); + + const configText = await readFile(path.join(projectDir, "opencode.jsonc"), "utf-8"); + const verdict = validator.validateText(configText, true); + expect(verdict.ok).toBe(true); + expect(verdict.config?.plugins ?? []).not.toContain(`${PACKAGE_NAME}@latest`); + }, 60_000); +}); From a9d760bd0c0b659c88587d7f3d7688a62f81bb36 Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 23:00:20 -0400 Subject: [PATCH 07/10] fix(templates): self-labelled read-only config.json mentions and Effect v4 catch in rendered code (issue #29) --- templates/plugin-config.template.txt | 8 +++++--- templates/plugin-local.template.txt | 2 +- templates/registration.template.txt | 4 +++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/templates/plugin-config.template.txt b/templates/plugin-config.template.txt index fb7d21f..942f051 100644 --- a/templates/plugin-config.template.txt +++ b/templates/plugin-config.template.txt @@ -2,7 +2,7 @@ None — emit as-is as src/plugin-config.ts. -**Load-bearing — do not simplify:** the editor is a surgical text splice into the v2 `plugins` array with every other byte of the config untouched — never a parse-then-reserialize (comments, key order, and formatting survive); candidates are consulted in priority order: for local scope the nested `.opencode/opencode.json` then `opencode.jsonc`, then the repo-root `opencode.json` then `opencode.jsonc`; for global scope the same pair in the global scope base; and finally the global `config.json` which is read-only (detection only, never written); every candidate is read up front and every unparseable one is warned about **before** any short-circuit on a successful match; a config that fails to parse blocks the edit — never rewrite config from `{}`; `.jsonc` parses leniently (comments blanked string-aware so `$schema` URLs and escaped quotes survive, trailing commas removed) and `.json` stays strict; both array shapes are read: the v2 `plugins` key is authoritative and the legacy v1 `plugin` key is tolerated read-only — a semantically matching legacy entry is a zero-write no-op with an upgrade advisory, and a legacy entry in `config.json` is reported as inert (v2 never reads that file); entries match by package name whether written as strings or `{ "package": ... }` objects; matching is semantic (`name`, `name@latest`, `name@x.y.z` are the same package) and new entries are written canonically as `name@latest` under `plugins`; an existing v2 entry is a zero-write no-op; when no config exists a minimal `opencode.jsonc` is created; every splice re-parses the result and aborts untouched if the entry is not present after the edit. +**Load-bearing — do not simplify:** the editor is a surgical text splice into the v2 `plugins` array with every other byte of the config untouched — never a parse-then-reserialize (comments, key order, and formatting survive); candidates are consulted in priority order: for local scope the nested `.opencode/opencode.json` then `opencode.jsonc`, then the repo-root `opencode.json` then `opencode.jsonc`; for global scope the same pair in the global scope base; and finally the global `config.json` which is read-only (detection only, never written); every candidate is read up front and every unparseable one is warned about **before** any short-circuit on a successful match; a config that fails to parse blocks the edit — never rewrite config from `{}`; `.jsonc` parses leniently (comments blanked string-aware so `$schema` URLs and escaped quotes survive, trailing commas removed) and `.json` stays strict; both array shapes are read: the v2 `plugins` key is authoritative and the legacy v1 `plugin` key is tolerated read-only — a semantically matching legacy entry is a zero-write no-op with an upgrade advisory, and a legacy entry in `config.json` is reported as inert (v2 never reads that file); entries match by package name whether written as strings or `{ "package": ... }` objects; matching is semantic (`name`, `name@latest`, `name@x.y.z` are the same package) and new entries are written canonically as `name@latest` under `plugins`; an existing v2 entry is a zero-write no-op; when no config exists a minimal `opencode.jsonc` is created; every splice re-parses the result and aborts untouched if the entry is not present after the edit. Two deliberate divergences from the suite's own `src/plugin-config.ts` keep every `config.json` mention self-labelling as read-only (the `LEGACY_READ_ONLY_GLOBAL` constant and the `!candidate.writable` inert-advisory branch — `config.json` is the only candidate declared `writable: false`); preserve them when re-syncing. --- import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; @@ -56,6 +56,8 @@ const DEFAULT_CONFIG_TEMPLATE = `{ } `; +const LEGACY_READ_ONLY_GLOBAL = "config.json"; + export class PluginConfigEditor { public async ensurePluginEntry( packageName: string, @@ -214,7 +216,7 @@ export class PluginConfigEditor { configs.push({ path: path.join(scopeBase, file), lenient: file.endsWith(".jsonc"), writable: true }); } } - configs.push({ path: path.join(scopeBase, "config.json"), lenient: false, writable: false }); + configs.push({ path: path.join(scopeBase, LEGACY_READ_ONLY_GLOBAL), lenient: false, writable: false }); return configs; } @@ -254,7 +256,7 @@ export class PluginConfigEditor { } private legacyEntryAdvisory(candidate: CandidateConfig, packageName: string): string { - if (path.basename(candidate.path) === "config.json") { + if (!candidate.writable) { 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. ` + diff --git a/templates/plugin-local.template.txt b/templates/plugin-local.template.txt index 7445e2b..66b0e9f 100644 --- a/templates/plugin-local.template.txt +++ b/templates/plugin-local.template.txt @@ -57,7 +57,7 @@ function advisoryEffect(run: () => Promise): Effect.Effect { return Effect.asVoid( - Effect.catchAll( + Effect.catch( Effect.tryPromise({ try: async () => { const directory = context.location.directory; diff --git a/templates/registration.template.txt b/templates/registration.template.txt index c7ef154..c14f891 100644 --- a/templates/registration.template.txt +++ b/templates/registration.template.txt @@ -12,6 +12,8 @@ import { PluginNameNormalizer } from "./plugin-name.ts"; export type RegistrationScope = "none" | "global" | "repo-local" | "both"; +const LEGACY_READ_ONLY_GLOBAL = "config.json"; + function getGlobalConfigPath(): string { const xdgConfig = process.env.XDG_CONFIG_HOME; if (xdgConfig) return join(xdgConfig, "opencode"); @@ -118,7 +120,7 @@ async function readEntriesForBase(base: string): Promise { async function readGlobalEntries(): Promise { const entries = await readEntriesForBase(getGlobalConfigPath()); if (entries !== null) return entries; - return readPluginEntries(join(getGlobalConfigPath(), "config.json")); + return readPluginEntries(join(getGlobalConfigPath(), LEGACY_READ_ONLY_GLOBAL)); } export class RegistrationDetector { From e8fe3ae53c63d0072a93c4b3afbd05d44524c734 Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 23:03:13 -0400 Subject: [PATCH 08/10] =?UTF-8?q?test(templates):=20migrate=20template=20a?= =?UTF-8?q?ssertions=20to=20v2=20=E2=80=94=20npm=20cache=20root,=20v2=20sk?= =?UTF-8?q?ill=20frontmatter=20(issue=20#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/deployment-plan.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/deployment-plan.test.ts b/tests/deployment-plan.test.ts index 1274693..3d738db 100644 --- a/tests/deployment-plan.test.ts +++ b/tests/deployment-plan.test.ts @@ -93,7 +93,8 @@ describe("generated-package cache hygiene (issue #14)", () => { expect(source).toContain("const cache = await prunePackageCache();"); expect(source).toContain("clearPackageCache"); expect(source).toMatch(/Could not clear cached package/); - expect(source).toContain('"opencode", "packages"'); + expect(source).toContain('"opencode", "npm"'); + expect(source).not.toContain('"opencode", "packages"'); }); test("installer prunes every invocation including no-ops, before mode dispatch", async () => { @@ -242,7 +243,10 @@ describe("frontmatter hygiene (mandatory double-quoting)", () => { test("references and templates model quoted frontmatter", async () => { const structure = await readTemplate("skill-structure.template.md"); expect(structure).toContain('name: "myextension"'); - expect(structure).toContain('audience: "agents"'); + expect(structure).toContain('description: "Use this skill when the user asks about..."'); + expect(structure).not.toMatch(/^license:/m); + expect(structure).not.toMatch(/^compatibility:/m); + expect(structure).not.toMatch(/^metadata:/m); const commands = await readReference("commands.md"); expect(commands).toContain('description: "Run tests with coverage"'); const skills = await readReference("skills.md"); From 0ee92b3bba81d43bdde1418622233028bfa0df79 Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 23:05:06 -0400 Subject: [PATCH 09/10] test(templates): rendered package runs its own bun test gate with a packager-style smoke test (issue #29) --- tests/templates.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/templates.test.ts b/tests/templates.test.ts index 52069bc..3caabd9 100644 --- a/tests/templates.test.ts +++ b/tests/templates.test.ts @@ -41,6 +41,18 @@ function templateBody(name: string): Promise { }); } +const SMOKE_TEST = `import { describe, expect, test } from "bun:test"; +import plugin from "../index.ts"; + +describe("${PACKAGE_NAME}", () => { + test("exports a v2 Effect plugin definition", () => { + const definition = plugin as { id: string; effect: unknown }; + expect(definition.id).toBe("${PACKAGE_NAME}"); + expect(typeof definition.effect).toBe("function"); + }); +}); +`; + async function renderPackage(): Promise { const write = async (relative: string, content: string): Promise => { const target = path.join(RENDERED_PACKAGE, relative); @@ -63,6 +75,9 @@ async function renderPackage(): Promise { "commands/my-command.md", `---\ndescription: "Rendered command stub"\n---\n\nSay hello from ${PACKAGE_NAME}.\n`, ); + // The packager (not a template) provides the package's tests/ dir; simulate its + // minimal contract smoke test so the rendered package's own test gate can run. + await write("tests/plugin.contract.test.ts", SMOKE_TEST); } interface SpawnOutcome { @@ -173,6 +188,14 @@ describe("rendered package passes its own gates", () => { expect(outcome.exitCode).toBe(0); }, 180_000); + test("the package's own test gate runs green", async () => { + const outcome = await runBun(["test"], RENDERED_PACKAGE, isolatedEnv(isolatedBase)); + if (outcome.exitCode !== 0) failWithOutput("rendered package bun test", outcome); + const report = `${outcome.stdout}\n${outcome.stderr}`; + expect(report).toMatch(/1 pass/); + expect(report).not.toMatch(/[1-9]\d* fail/); + }, 120_000); + test("the rendered entry satisfies the v2 Module contract", async () => { const entrypoint = path.join(RENDERED_PACKAGE, "index.ts"); const loaded = await import(pathToFileURL(entrypoint).href); From 79ee939c559a27498ae14ceffa74b890a20741d7 Mon Sep 17 00:00:00 2001 From: diegohb Date: Tue, 6 Oct 2026 23:13:06 -0400 Subject: [PATCH 10/10] =?UTF-8?q?refactor(templates):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20host-claim=20hedged=20out=20of=20the=20legacy=20adv?= =?UTF-8?q?isory,=20BIN=20instructions=20restored,=20gate-test=20polish=20?= =?UTF-8?q?(issue=20#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/package-basics.template.json | 5 +++++ templates/plugin-config.template.txt | 4 ++-- templates/registration.template.txt | 2 +- tests/templates.test.ts | 9 ++++----- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/templates/package-basics.template.json b/templates/package-basics.template.json index 9604cf2..6f3d38d 100644 --- a/templates/package-basics.template.json +++ b/templates/package-basics.template.json @@ -28,6 +28,11 @@ CONTENT DECLARATION other plugin integration (code-backed packages may also ship skills/commands) +BIN + "src/cli.ts" → the bunx entry point; keeps every + `bunx ...` advisory working at + packager time, not only after publishing + EXPORTS "./server" → keep pointing at ./index.ts — OpenCode v2 resolves the plugin entrypoint through the ./server export diff --git a/templates/plugin-config.template.txt b/templates/plugin-config.template.txt index 942f051..f3e1346 100644 --- a/templates/plugin-config.template.txt +++ b/templates/plugin-config.template.txt @@ -265,8 +265,8 @@ export class PluginConfigEditor { } 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.` + `This installer leaves legacy entries read-only; the v2 "plugins" key is the authoritative registration. ` + + `Move the entry to the v2 "plugins" key to upgrade, then re-run the install.` ); } diff --git a/templates/registration.template.txt b/templates/registration.template.txt index c14f891..2f2020e 100644 --- a/templates/registration.template.txt +++ b/templates/registration.template.txt @@ -93,7 +93,7 @@ function entryNames(value: unknown): string[] { function entryPackage(entry: unknown): string | null { if (entry === null || typeof entry !== "object") return null; - const pkg = (entry as { package?: unknown }).package; + const pkg = (entry as { package: unknown }).package; return typeof pkg === "string" ? pkg : null; } diff --git a/tests/templates.test.ts b/tests/templates.test.ts index 3caabd9..e8ef6aa 100644 --- a/tests/templates.test.ts +++ b/tests/templates.test.ts @@ -33,11 +33,13 @@ const gate = new DocsFactGate(REPO_ROOT); const validator = new ConfigSchemaValidator(); const harness = new V2HostHarness(); +const TEMPLATE_SEPARATOR = "---"; + function templateBody(name: string): Promise { return readFile(path.join(TEMPLATES_DIR, name), "utf-8").then((source) => { const separator = source.match(/^---$/m); if (separator === null || separator.index === undefined) return source; - return source.slice(separator.index + 4).trimStart(); + return source.slice(separator.index + TEMPLATE_SEPARATOR.length + 1).trimStart(); }); } @@ -75,8 +77,6 @@ async function renderPackage(): Promise { "commands/my-command.md", `---\ndescription: "Rendered command stub"\n---\n\nSay hello from ${PACKAGE_NAME}.\n`, ); - // The packager (not a template) provides the package's tests/ dir; simulate its - // minimal contract smoke test so the rendered package's own test gate can run. await write("tests/plugin.contract.test.ts", SMOKE_TEST); } @@ -185,10 +185,9 @@ describe("rendered package passes its own gates", () => { isolatedEnv(isolatedBase), ); if (outcome.exitCode !== 0) failWithOutput("rendered package typecheck", outcome); - expect(outcome.exitCode).toBe(0); }, 180_000); - test("the package's own test gate runs green", async () => { + test("the package's own test gate runs green (tests/ is packager-provided, simulated in the fixture)", async () => { const outcome = await runBun(["test"], RENDERED_PACKAGE, isolatedEnv(isolatedBase)); if (outcome.exitCode !== 0) failWithOutput("rendered package bun test", outcome); const report = `${outcome.stdout}\n${outcome.stderr}`;