From c2a6ab8064527d26b9bb7b200a4019f2d41c841b Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:14:47 -0400 Subject: [PATCH 01/21] fix(conformity): declare content:code deployment plan (E1) The package ships agents and registers a plugin hook, making it code-backed; package.json now carries the content declaration the deployment plan requires. --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 15016b7..1879fdd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "opencode-architect", "version": "0.8.0", + "content": "code", "description": "OpenCode plugin and CLI with ten specialist agents for agent skills, slash commands, custom tools, plugins, and MCP server integration", "keywords": [ "opencode", From 834d3c60dc943fe90b2c63566415f28b19a847a1 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:16:33 -0400 Subject: [PATCH 02/21] fix(conformity): quote every frontmatter value in shipped agents (D6) mode and permission values were bare; the checklist requires double quoting for all frontmatter property values (native booleans exempt). Adds an enforcement test over assets/agents so bare values fail CI. --- assets/agents/opencode-agent-designer.md | 2 +- assets/agents/opencode-architect.md | 2 +- assets/agents/opencode-command-crafter.md | 2 +- assets/agents/opencode-extension-auditor.md | 18 ++++++------ assets/agents/opencode-mcp-integrator.md | 2 +- assets/agents/opencode-packager.md | 2 +- assets/agents/opencode-plugin-engineer.md | 2 +- assets/agents/opencode-publisher.md | 2 +- assets/agents/opencode-skill-creator.md | 2 +- assets/agents/opencode-tool-builder.md | 2 +- tests/agents.test.ts | 32 ++++++++++++++++++++- 11 files changed, 49 insertions(+), 19 deletions(-) diff --git a/assets/agents/opencode-agent-designer.md b/assets/agents/opencode-agent-designer.md index 48e9d89..8522fc7 100644 --- a/assets/agents/opencode-agent-designer.md +++ b/assets/agents/opencode-agent-designer.md @@ -1,6 +1,6 @@ --- description: "Designs OpenCode agents and orchestrator subagents - roles, constraints, tools, permissions" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-architect.md b/assets/agents/opencode-architect.md index 1dcb12c..8b5d45a 100644 --- a/assets/agents/opencode-architect.md +++ b/assets/agents/opencode-architect.md @@ -1,6 +1,6 @@ --- description: "Routes OpenCode meta tasks to specialist subagents - agents, skills, commands, tools, plugins, MCP setup, packaging, publishing" -mode: primary +mode: "primary" tools: read: true write: true diff --git a/assets/agents/opencode-command-crafter.md b/assets/agents/opencode-command-crafter.md index bc9f369..731f55c 100644 --- a/assets/agents/opencode-command-crafter.md +++ b/assets/agents/opencode-command-crafter.md @@ -1,6 +1,6 @@ --- description: "Creates OpenCode slash commands in .opencode/commands - prompt templates, $ARGUMENTS, frontmatter" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-extension-auditor.md b/assets/agents/opencode-extension-auditor.md index 6a9ba50..a231b6f 100644 --- a/assets/agents/opencode-extension-auditor.md +++ b/assets/agents/opencode-extension-auditor.md @@ -1,20 +1,20 @@ --- description: "Analyzes .opencode/ contents for packaging readiness, and reviews existing plugin packages for conformance to this suite's design - inventory, dependencies, complications, conformance verdict" -mode: subagent +mode: "subagent" tools: read: true glob: true grep: true permission: bash: - "*": deny - "bun test*": allow - "bun run check*": allow - "bun -e *": allow - "node -e *": allow - "git diff*": allow - "git log*": allow - "git status": allow + "*": "deny" + "bun test*": "allow" + "bun run check*": "allow" + "bun -e *": "allow" + "node -e *": "allow" + "git diff*": "allow" + "git log*": "allow" + "git status": "allow" --- Prefer Exa MCP over default websearch tools and grepai MCP over default codebase search tools, when available. diff --git a/assets/agents/opencode-mcp-integrator.md b/assets/agents/opencode-mcp-integrator.md index 54bb112..acb3d28 100644 --- a/assets/agents/opencode-mcp-integrator.md +++ b/assets/agents/opencode-mcp-integrator.md @@ -1,6 +1,6 @@ --- description: "Configures MCP servers and tool scoping in opencode.json - local/remote servers, permissions" -mode: subagent +mode: "subagent" tools: read: true write: false diff --git a/assets/agents/opencode-packager.md b/assets/agents/opencode-packager.md index 76fc2a2..11ec472 100644 --- a/assets/agents/opencode-packager.md +++ b/assets/agents/opencode-packager.md @@ -1,6 +1,6 @@ --- description: "Packages OpenCode extensions for local sharing across projects - file:/// plugin packages" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-plugin-engineer.md b/assets/agents/opencode-plugin-engineer.md index f7b8b54..e3c020f 100644 --- a/assets/agents/opencode-plugin-engineer.md +++ b/assets/agents/opencode-plugin-engineer.md @@ -1,6 +1,6 @@ --- description: "Builds OpenCode plugins in .opencode/plugins - event hooks, custom tools, TypeScript" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-publisher.md b/assets/agents/opencode-publisher.md index 8e390e2..581928a 100644 --- a/assets/agents/opencode-publisher.md +++ b/assets/agents/opencode-publisher.md @@ -1,6 +1,6 @@ --- description: "Publishes OpenCode extensions to npm - transform local packages, share with others, make distributable" -mode: primary +mode: "primary" tools: read: true write: true diff --git a/assets/agents/opencode-skill-creator.md b/assets/agents/opencode-skill-creator.md index c6db37c..6fbd011 100644 --- a/assets/agents/opencode-skill-creator.md +++ b/assets/agents/opencode-skill-creator.md @@ -1,6 +1,6 @@ --- description: "Creates OpenCode skills in .opencode/skills - SKILL.md, frontmatter, progressive disclosure" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/assets/agents/opencode-tool-builder.md b/assets/agents/opencode-tool-builder.md index 008d151..52cac3f 100644 --- a/assets/agents/opencode-tool-builder.md +++ b/assets/agents/opencode-tool-builder.md @@ -1,6 +1,6 @@ --- description: "Creates OpenCode custom tools in .opencode/tools - Zod schemas and execute logic" -mode: subagent +mode: "subagent" tools: read: true write: true diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 641738a..048c4cc 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { AGENT_FILENAMES, AgentLoader } from "../agent-loader"; @@ -23,6 +23,36 @@ function isReferenceFilePath(candidate: string): boolean { return looksAbsolute && /\.[A-Za-z0-9]+$/.test(lastSegment); } +function frontmatterLines(content: string): string[] { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return []; + return (match[1] ?? "").split(/\r?\n/); +} + +function frontmatterViolations(filename: string): string[] { + const violations: string[] = []; + for (const line of frontmatterLines(readFileSync(path.join(AGENTS_DIR, filename), "utf-8"))) { + if (line.trim().length === 0) continue; + const indented = /^[ \t]/.test(line); + const property = line.match(/^([A-Za-z_-]+):\s*(.*)$/); + const entry = indented ? line.match(/^\s+(?:"[^"]+"|[A-Za-z_-]+):\s*(.*)$/) : null; + const value = (property && !indented ? (property[2] ?? "") : (entry?.[1] ?? "")).trim(); + if (value.length === 0) continue; + if (/^(true|false|-?\d+(\.\d+)?)$/.test(value)) continue; + if (!/^".*"$/.test(value)) { + violations.push(`${filename}: unquoted value ${value}`); + } + } + return violations; +} + +describe("frontmatter hygiene (D6)", () => { + test("every frontmatter property value in shipped agents is double-quoted", () => { + const violations = AGENT_FILENAMES.flatMap((filename) => frontmatterViolations(filename)); + expect(violations).toEqual([]); + }); +}); + describe("AgentLoader", () => { test("loadAgents returns all ten agents with non-empty prompts", async () => { const agents = await new AgentLoader(AGENTS_DIR).loadAgents(); From d29d7cced368938b00d42125e7de7efac408ac94 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:18:07 -0400 Subject: [PATCH 03/21] fix(conformity): wrap load hook, one-shot advisory for missing bundled assets (B4, D4) The config hook could reject into config assembly when bundled assets are absent (partial npm cache artifact), stalling startup. The entire hook body is now wrapped: a failure degrades to a single warning plus one advisory naming bunx opencode-architect clear-cache, the reinstall command, and the package-qualified cache directory. The advisory builder carries a static fallback and the emitter swallows independently; separate once-guards; the hook never deletes cache. --- index.ts | 70 +++++++++++++++++++++++++------- tests/load-hook.test.ts | 88 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 tests/load-hook.test.ts diff --git a/index.ts b/index.ts index 261d0fa..41d381b 100644 --- a/index.ts +++ b/index.ts @@ -1,26 +1,68 @@ import type { Plugin } from "@opencode-ai/plugin"; +import { readFile } from "node:fs/promises"; import path from "node:path"; import { AgentLoader } from "./agent-loader"; import { AssetPermissionRegistrar } from "./permission-registrar"; -const AGENTS_DIR = path.join(import.meta.dirname, "assets", "agents"); -const ASSETS_DIR = path.join(import.meta.dirname, "assets"); +const PACKAGE_NAME = "opencode-architect"; -const OpencodeArchitect: Plugin = async () => { - const agents = await new AgentLoader(AGENTS_DIR).loadAgents(); - const permissionRegistrar = new AssetPermissionRegistrar(ASSETS_DIR); +export function createOpencodeArchitect(agentsDir: string, assetsDir: string): Plugin { + let failureAdvised = false; - return { - config: async (config) => { - config.agent = config.agent || {}; + async function adviseFailureOnce(message: string): Promise { + if (failureAdvised) return; + failureAdvised = true; + await emitAdvisory(message); + } - for (const [name, agentConfig] of Object.entries(agents)) { - config.agent[name] = agentConfig; - } + async function emitAdvisory(message: string): Promise { + let text: string; + try { + const version = await readPackageVersion(); + text = + `Failed to load the bundled agent suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` + + `reinstall and restart OpenCode. The stale cache copy is ` + + `~/.cache/opencode/packages/${PACKAGE_NAME}@${version}. Cause: ${message}`; + } catch { + text = + `Failed to load the bundled agent suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` + + `reinstall and restart OpenCode. The stale cache copy lives under ` + + `~/.cache/opencode/packages/. Cause: ${message}`; + } + try { + console.warn(`[${PACKAGE_NAME}] ${text}`); + } catch {} + } - permissionRegistrar.register(config); + return async () => ({ + config: async (config) => { + try { + const agents = await new AgentLoader(agentsDir).loadAgents(); + config.agent = config.agent || {}; + for (const [name, agentConfig] of Object.entries(agents)) { + config.agent[name] = agentConfig; + } + new AssetPermissionRegistrar(assetsDir).register(config); + } catch (error) { + await adviseFailureOnce(error instanceof Error ? error.message : String(error)); + } }, - }; -}; + }); +} + +async function readPackageVersion(): Promise { + const manifest = JSON.parse( + await readFile(path.join(import.meta.dirname, "package.json"), "utf-8"), + ) as { version: string }; + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + throw new Error("unreadable package metadata"); + } + return manifest.version; +} + +const OpencodeArchitect: Plugin = createOpencodeArchitect( + path.join(import.meta.dirname, "assets", "agents"), + path.join(import.meta.dirname, "assets"), +); export default OpencodeArchitect; diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts new file mode 100644 index 0000000..132b060 --- /dev/null +++ b/tests/load-hook.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Config } from "@opencode-ai/plugin"; +import { createOpencodeArchitect } from "../index"; + +const REPO_ROOT = path.resolve(import.meta.dirname, ".."); +const REAL_AGENTS_DIR = path.join(REPO_ROOT, "assets", "agents"); +const REAL_ASSETS_DIR = path.join(REPO_ROOT, "assets"); + +async function makeScratchDirs(): Promise<{ agentsDir: string; assetsDir: string }> { + const root = await mkdtemp(path.join(tmpdir(), "opencode-architect-hook-")); + const agentsDir = path.join(root, "agents"); + const assetsDir = path.join(root, "assets"); + await mkdir(agentsDir, { recursive: true }); + await mkdir(assetsDir, { recursive: true }); + return { agentsDir, assetsDir }; +} + +type PluginHooks = Awaited>>; + +async function hooksFrom(plugin: ReturnType): Promise { + const build = plugin as unknown as () => Promise; + return build(); +} + +async function configFrom(plugin: ReturnType): Promise { + const hooks = await hooksFrom(plugin); + const register = hooks.config; + if (!register) throw new Error("missing config hook"); + const config = {} as Config; + await register(config); + return config; +} + +describe("plugin load hook (startup non-interference)", () => { + let warnSpy: ReturnType | null = null; + + afterEach(() => { + warnSpy?.mockRestore(); + warnSpy = null; + }); + + function captureWarnings(): string[] { + const warnings: string[] = []; + warnSpy = spyOn(console, "warn").mockImplementation((message: unknown) => { + warnings.push(String(message)); + }); + return warnings; + } + + test("missing bundled agents degrade to exactly one advisory instead of throwing", async () => { + const { agentsDir, assetsDir } = await makeScratchDirs(); + const warnings = captureWarnings(); + const plugin = createOpencodeArchitect(agentsDir, assetsDir); + + const config = await configFrom(plugin); + + expect(Object.keys(config.agent ?? {})).toEqual([]); + 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("ENOENT"); + }); + + test("repeated failing invocations emit exactly one advisory", async () => { + const { agentsDir, assetsDir } = await makeScratchDirs(); + const warnings = captureWarnings(); + const plugin = createOpencodeArchitect(agentsDir, assetsDir); + const hooks = await hooksFrom(plugin); + + await hooks.config({} as Config); + await hooks.config({} as Config); + await hooks.config({} as Config); + + expect(warnings).toHaveLength(1); + }); + + test("healthy assets register the agent suite in-memory with no warning", async () => { + const warnings = captureWarnings(); + const config = await configFrom(createOpencodeArchitect(REAL_AGENTS_DIR, REAL_ASSETS_DIR)); + + expect(Object.keys(config.agent ?? {}).length).toBe(10); + expect(Object.keys(config.permission?.external_directory ?? {}).length).toBeGreaterThan(0); + expect(warnings).toEqual([]); + }); +}); From b3c11ee3c6aaf9b03a364074b0dd49cc2038dee8 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:18:44 -0400 Subject: [PATCH 04/21] fix(conformity): type-safe repeated-invocation coverage in load-hook test Reuses configFrom so the once-guard closure is exercised through the same plugin instance without optional-call type errors. --- tests/load-hook.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts index 132b060..3278493 100644 --- a/tests/load-hook.test.ts +++ b/tests/load-hook.test.ts @@ -68,11 +68,10 @@ describe("plugin load hook (startup non-interference)", () => { const { agentsDir, assetsDir } = await makeScratchDirs(); const warnings = captureWarnings(); const plugin = createOpencodeArchitect(agentsDir, assetsDir); - const hooks = await hooksFrom(plugin); - await hooks.config({} as Config); - await hooks.config({} as Config); - await hooks.config({} as Config); + await configFrom(plugin); + await configFrom(plugin); + await configFrom(plugin); expect(warnings).toHaveLength(1); }); From c39e55c7c89c7a0be2088e6525d4be63b0c5a13f Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:21:22 -0400 Subject: [PATCH 05/21] fix(conformity): manifest renamed to .manifest.json (A1) The suite's own manifest broke the .manifest.json convention its checklist and generated templates follow, so drift against the conformant layout forced a nonstandard record name. Installs, status, and uninstall now read the legacy opencode-architect.json as a fallback, rewrite the record at the conformant name, and sweep the legacy file; README updated. --- README.md | 2 +- installer.ts | 55 ++++++++++++++++++++++++++++++++--------- tests/installer.test.ts | 29 +++++++++++++++++++++- 3 files changed, 72 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 1a795b9..4944204 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,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` 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.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` 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. ```bash # Project scope (default): edits the ./.opencode/ or repo-root config diff --git a/installer.ts b/installer.ts index af36b3d..256bafd 100644 --- a/installer.ts +++ b/installer.ts @@ -57,7 +57,8 @@ export interface StatusOutcome { } const PACKAGE_NAME = "opencode-architect"; -const MANIFEST_NAME = "opencode-architect.json"; +const MANIFEST_NAME = "opencode-architect.manifest.json"; +const LEGACY_MANIFEST_NAME = "opencode-architect.json"; export class Installer { private readonly editor = new PluginConfigEditor(); @@ -72,8 +73,10 @@ export class Installer { const base = this.scopeBase(scope, options.projectDir); const manifestPath = path.join(base, MANIFEST_NAME); + const legacyManifestPath = path.join(base, LEGACY_MANIFEST_NAME); + const record = await this.readManifestRecord(base); + const existing = record.manifest; const version = await this.getPackageVersion(); - const existing = await this.readManifest(manifestPath); let removedPayload: string[] = []; let action: InstallAction; @@ -98,7 +101,7 @@ export class Installer { action = existing.version === version && registration.action === "noop" ? "noop" : "upgraded"; } - if (action !== "noop" || options.force) { + if (action !== "noop" || options.force || record.path === legacyManifestPath) { const manifest: Manifest = { version, mode: "plugin", @@ -109,6 +112,9 @@ export class Installer { }; await mkdir(base, { recursive: true }); await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + "\n"); + if (await exists(legacyManifestPath)) { + await rm(legacyManifestPath); + } } const cache = await this.prunePackageCache(version); @@ -154,8 +160,8 @@ export class Installer { public async uninstall(scope: Scope, projectDir: string): Promise { const base = this.scopeBase(scope, projectDir); - const manifestPath = path.join(base, MANIFEST_NAME); - const manifest = await this.readManifest(manifestPath); + const record = await this.readManifestRecord(base); + const manifest = record.manifest; const removed: string[] = []; const removal = await this.editor.removePluginEntry(PACKAGE_NAME, { scope, projectDir }); @@ -166,13 +172,11 @@ export class Installer { if (manifest !== null && manifest.mode === "copy") { removed.push(...(await this.removePayloadPerManifest(base, manifest.hashes ?? []))); - await rm(manifestPath); - removed.push(manifestPath); + removed.push(...(await this.removeManifestFiles(base))); } if (manifest !== null && manifest.mode === "plugin") { - await rm(manifestPath); - removed.push(manifestPath); + removed.push(...(await this.removeManifestFiles(base))); } if (manifest === null) { @@ -208,9 +212,14 @@ export class Installer { public async status(scope: Scope, projectDir: string): Promise { const base = this.scopeBase(scope, projectDir); - const manifest = await this.readManifest(path.join(base, MANIFEST_NAME)); - if (manifest !== null) { - return { scope, mode: manifest.mode, version: manifest.version, configPath: manifest.configPath }; + const record = await this.readManifestRecord(base); + if (record.manifest !== null) { + return { + scope, + mode: record.manifest.mode, + version: record.manifest.version, + configPath: record.manifest.configPath, + }; } const registrationPath = await this.editor.findRegistration(PACKAGE_NAME, { scope, projectDir }); if (registrationPath !== null) { @@ -248,6 +257,28 @@ export class Installer { if (contents.length === 0) await rmdir(directory); } + private manifestCandidates(base: string): string[] { + return [path.join(base, MANIFEST_NAME), path.join(base, LEGACY_MANIFEST_NAME)]; + } + + private async readManifestRecord(base: string): Promise<{ manifest: Manifest | null; path: string | null }> { + for (const candidate of this.manifestCandidates(base)) { + const manifest = await this.readManifest(candidate); + if (manifest !== null) return { manifest, path: candidate }; + } + return { manifest: null, path: null }; + } + + private async removeManifestFiles(base: string): Promise { + const removed: string[] = []; + for (const candidate of this.manifestCandidates(base)) { + if (!(await exists(candidate))) continue; + await rm(candidate); + removed.push(candidate); + } + return removed; + } + private async readManifest(manifestPath: string): Promise { try { const parsed = JSON.parse(await readFile(manifestPath, "utf-8")) as Partial; diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 5624afa..60a1ef2 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync } from "node:fs"; -import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { Installer, contentHash, type Manifest, type Scope } from "../installer"; @@ -45,6 +45,10 @@ function scopeBase(scope: Scope): string { } function manifestPath(scope: Scope): string { + return path.join(scopeBase(scope), "opencode-architect.manifest.json"); +} + +function legacyManifestPath(scope: Scope): string { return path.join(scopeBase(scope), "opencode-architect.json"); } @@ -260,6 +264,29 @@ describe("Installer.install", () => { await readPackageVersion(), ); }); + + test("relocates a manifest left at the legacy name on the next install", async () => { + await install("local"); + await rename(manifestPath("local"), legacyManifestPath("local")); + + const outcome = await install("local"); + + expect(outcome.action).toBe("noop"); + expect(existsSync(manifestPath("local"))).toBe(true); + expect(existsSync(legacyManifestPath("local"))).toBe(false); + const status = await installer.status("local", projectDir); + expect(status.version).toBe(await readPackageVersion()); + }); + + test("uninstall removes manifests left at either name", async () => { + await install("local"); + await rename(manifestPath("local"), legacyManifestPath("local")); + + await installer.uninstall("local", projectDir); + + expect(existsSync(manifestPath("local"))).toBe(false); + expect(existsSync(legacyManifestPath("local"))).toBe(false); + }); }); describe("Installer.install cache pruning", () => { From b63edbffbdbb7f51412b958b60f93b4a1b9e4301 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:26:55 -0400 Subject: [PATCH 06/21] fix(conformity): write plugin entries canonically as name@latest (B2, D8) New entries landed as the bare package name in spliced and created configs and in the manifest record; the canonical form is name@latest. Semantic matching is unchanged, so existing bare or pinned entries remain zero-write no-ops. README snippets now show opencode-architect@ latest, and the surgical-editor template carries the same behavior. --- README.md | 4 +-- assets/templates/plugin-config.template.txt | 25 +++++++++++++----- installer.ts | 3 ++- plugin-config.ts | 22 ++++++++++++---- tests/installer.test.ts | 12 ++++----- tests/plugin-config.test.ts | 29 ++++++++++++++------- 6 files changed, 65 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 4944204..c150b5b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Add the package to the `plugin` array in your OpenCode config — `.opencode/ope ```json { - "plugin": ["opencode-architect"] + "plugin": ["opencode-architect@latest"] } ``` @@ -24,7 +24,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` 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 `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. ```bash # Project scope (default): edits the ./.opencode/ or repo-root config diff --git a/assets/templates/plugin-config.template.txt b/assets/templates/plugin-config.template.txt index af005e1..0c30059 100644 --- a/assets/templates/plugin-config.template.txt +++ b/assets/templates/plugin-config.template.txt @@ -2,13 +2,12 @@ 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); 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 via `PluginNameNormalizer` (`name`, `name@latest`, `name@x.y.z` are the same package); 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 `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); 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. --- import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; -import { PluginNameNormalizer } from "./plugin-name.ts"; export type ConfigScope = "local" | "global"; @@ -54,6 +53,7 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { + const canonical = this.canonicalEntry(packageName); for (const candidate of this.candidateConfigs(options)) { if (!(await exists(candidate.path))) continue; const text = await readFile(candidate.path, "utf-8"); @@ -71,7 +71,7 @@ export class PluginConfigEditor { return { action: "noop", configPath: candidate.path, warning: null }; } if (!candidate.writable) continue; - const spliced = this.spliceEntry(text, packageName, candidate.lenient); + const spliced = this.spliceEntry(text, canonical, packageName, candidate.lenient); if (spliced === null) { return { action: "blocked", @@ -85,7 +85,7 @@ export class PluginConfigEditor { } const target = this.defaultConfigPath(options); - const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", packageName); + const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", canonical); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, content); return { action: "created", configPath: target, warning: null }; @@ -163,6 +163,12 @@ export class PluginConfigEditor { return name === packageName; } + private canonicalEntry(packageName: string): string { + const specIndex = packageName.lastIndexOf("@"); + if (specIndex > 0) return packageName; + return `${packageName}@latest`; + } + private candidateConfigs(options: EnsurePluginEntryOptions): CandidateConfig[] { const scopeBase = this.scopeBase(options.scope, options.projectDir); const repoRoot = options.projectDir; @@ -264,13 +270,18 @@ export class PluginConfigEditor { return chars.join(""); } - private spliceEntry(text: string, packageName: string, lenient: boolean): string | null { + private spliceEntry( + text: string, + entryToWrite: string, + packageName: string, + lenient: boolean, + ): string | null { const navigable = this.blankComments(text); const range = this.findPluginArrayRange(navigable); const spliced = range === null - ? this.splicePluginKey(text, navigable, packageName) - : this.spliceArrayEntry(text, navigable, range, packageName); + ? this.splicePluginKey(text, navigable, entryToWrite) + : this.spliceArrayEntry(text, navigable, range, entryToWrite); if (spliced === null) return null; const plugins = this.parsePluginArray(spliced, lenient); if (plugins === null || !this.hasMatchingEntry(plugins, packageName)) return null; diff --git a/installer.ts b/installer.ts index 256bafd..509220f 100644 --- a/installer.ts +++ b/installer.ts @@ -57,6 +57,7 @@ export interface StatusOutcome { } const PACKAGE_NAME = "opencode-architect"; +const PLUGIN_ENTRY = "opencode-architect@latest"; const MANIFEST_NAME = "opencode-architect.manifest.json"; const LEGACY_MANIFEST_NAME = "opencode-architect.json"; @@ -105,7 +106,7 @@ export class Installer { const manifest: Manifest = { version, mode: "plugin", - entry: PACKAGE_NAME, + entry: PLUGIN_ENTRY, configPath: registration.configPath, "content-hash": null, hashes: null, diff --git a/plugin-config.ts b/plugin-config.ts index 032a447..03eb75b 100644 --- a/plugin-config.ts +++ b/plugin-config.ts @@ -46,6 +46,7 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { + const canonical = this.canonicalEntry(packageName); for (const candidate of this.candidateConfigs(options)) { if (!(await exists(candidate.path))) continue; const text = await readFile(candidate.path, "utf-8"); @@ -63,7 +64,7 @@ export class PluginConfigEditor { return { action: "noop", configPath: candidate.path, warning: null }; } if (!candidate.writable) continue; - const spliced = this.spliceEntry(text, packageName, candidate.lenient); + const spliced = this.spliceEntry(text, canonical, packageName, candidate.lenient); if (spliced === null) { return { action: "blocked", @@ -77,7 +78,7 @@ export class PluginConfigEditor { } const target = this.defaultConfigPath(options); - const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", packageName); + const content = DEFAULT_CONFIG_TEMPLATE.replace("__PACKAGE_NAME__", canonical); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, content); return { action: "created", configPath: target, warning: null }; @@ -155,6 +156,12 @@ export class PluginConfigEditor { return name === packageName; } + private canonicalEntry(packageName: string): string { + const specIndex = packageName.lastIndexOf("@"); + if (specIndex > 0) return packageName; + return `${packageName}@latest`; + } + private candidateConfigs(options: EnsurePluginEntryOptions): CandidateConfig[] { const scopeBase = this.scopeBase(options.scope, options.projectDir); const repoRoot = options.projectDir; @@ -258,13 +265,18 @@ export class PluginConfigEditor { return chars.join(""); } - private spliceEntry(text: string, packageName: string, lenient: boolean): string | null { + private spliceEntry( + text: string, + entryToWrite: string, + packageName: string, + lenient: boolean, + ): string | null { const navigable = this.blankComments(text); const range = this.findPluginArrayRange(navigable); const spliced = range === null - ? this.splicePluginKey(text, navigable, packageName) - : this.spliceArrayEntry(text, navigable, range, packageName); + ? this.splicePluginKey(text, navigable, entryToWrite) + : this.spliceArrayEntry(text, navigable, range, entryToWrite); if (spliced === null) return null; const plugins = this.parsePluginArray(spliced, lenient); if (plugins === null || !this.hasMatchingEntry(plugins, packageName)) return null; diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 60a1ef2..70f248d 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -91,13 +91,13 @@ describe("Installer.install", () => { expect(outcome.manifestPath).toBe(manifestPath("local")); const config = await readJson(configPath); - expect(config.plugin).toEqual(["opencode-architect"]); + expect(config.plugin).toEqual(["opencode-architect@latest"]); expect(config.theme).toBe("dark"); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; expect(manifest.version).toBe(await readPackageVersion()); expect(manifest.mode).toBe("plugin"); - expect(manifest.entry).toBe("opencode-architect"); + expect(manifest.entry).toBe("opencode-architect@latest"); expect(manifest.configPath).toBe(configPath); expect(manifest["content-hash"]).toBeNull(); }); @@ -163,8 +163,8 @@ describe("Installer.install", () => { const after = await readFile(configPath, "utf-8"); expect(after).toContain("// my precious comment"); expect(after).toContain("// plugin note"); - expect(after.indexOf("opencode-architect")).toBeLessThan(after.indexOf("other-extension")); - const withoutEntry = after.replace(`\n "opencode-architect",`, ""); + expect(after.indexOf("opencode-architect@latest")).toBeLessThan(after.indexOf("other-extension")); + const withoutEntry = after.replace(`\n "opencode-architect@latest",`, ""); expect(withoutEntry).toBe(original); }); @@ -185,7 +185,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"]'); + expect(text).toContain('"plugin": ["opencode-architect@latest"]'); }); test("refuses copy mode with an explanatory error", async () => { @@ -229,7 +229,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"]); + expect(config.plugin).toEqual(["opencode-architect@latest"]); const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; expect(manifest.mode).toBe("plugin"); }); diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index 5bc8640..b37d27e 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -103,15 +103,26 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { expect(outcome.action).toBe("updated"); expect(outcome.configPath).toBe(configPath); const after = await readFile(configPath, "utf-8"); - expect(after).toContain('"my-pkg"'); - const without = after.replace('"my-pkg",', "").replace(/,\s*,/g, ","); + 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]); const parsed = parseJsonc(after); - expect(parsed.plugin).toContain("my-pkg"); + expect(parsed.plugin).toContain("my-pkg@latest"); expect(parsed.plugin).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 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"); + }); + test("performs no write when a semantically matching entry exists", async () => { const projectDir = await makeDir("project"); for (const entry of ["my-pkg", "my-pkg@1.2.3", "my-pkg@latest"]) { @@ -133,7 +144,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"]); + expect(parsed.plugin).toEqual(["my-pkg@latest"]); expect(parsed.$schema).toContain("config.json"); }); @@ -177,7 +188,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { 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"]); + expect(parsed.plugin).toEqual(["my-pkg@latest"]); }); test("scope base config wins over repo root; existing scope-base file is edited", async () => { @@ -233,7 +244,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"]); + expect(created.plugin).toEqual(["my-pkg@latest"]); } finally { delete process.env.XDG_CONFIG_HOME; } @@ -244,7 +255,7 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const configPath = await write("project/opencode.json", '{"plugin":[]}\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"]}\n'); + expect(await readFile(configPath, "utf-8")).toBe('{"plugin":["my-pkg@latest"]}\n'); }); test("config without a plugin key gets one spliced in, rest untouched", async () => { @@ -256,9 +267,9 @@ 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"],'); + expect(after).toContain('"plugin": ["my-pkg@latest"],'); expect(after).toContain('"model": "x/y"'); const parsed = JSON.parse(after.replace(/,(\s*[}\]])/g, "$1")); - expect(parsed.plugin).toEqual(["my-pkg"]); + expect(parsed.plugin).toEqual(["my-pkg@latest"]); }); }); From 850032805d4c9797909cd22bf4c9935361635917 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:27:52 -0400 Subject: [PATCH 07/21] fix(conformity): gate legacy copy migration behind --force consent (A3, D2) Copy-to-plugin migration deleted the recorded payload unconditionally, including files the consumer edited after installing. Migration now aborts with an explanatory error unless --force is given, matching the template's consent-gated migrate semantics; --force help text says so. --- cli.ts | 3 ++- installer.ts | 8 ++++++++ tests/installer.test.ts | 24 ++++++++++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/cli.ts b/cli.ts index 572d87b..4d5c9ff 100644 --- a/cli.ts +++ b/cli.ts @@ -150,7 +150,8 @@ Options: -s, --scope "local" (project) or "global" (XDG/home config); default local -m, --mode "plugin" (default) or "copy"; copy is refused for this code-backed package - -f, --force re-register and rewrite the manifest even when it is up to date + -f, --force re-register and rewrite the manifest even when it is up to date; + consent to migrating a legacy copy install (removes its copied payload) --package clear-cache: remove and every @* instead; requires --yes --all clear-cache: remove the whole OpenCode cache directory; diff --git a/installer.ts b/installer.ts index 509220f..b75733f 100644 --- a/installer.ts +++ b/installer.ts @@ -82,6 +82,14 @@ export class Installer { let removedPayload: string[] = []; let action: InstallAction; if (existing !== null && existing.mode === "copy") { + if (!options.force) { + throw new Error( + `A legacy copy install of ${PACKAGE_NAME} was found at ${base}. ` + + `Migrating it to plugin registration removes the copied payload it recorded, ` + + `including any files you edited after installing. ` + + `Re-run with --force to consent.`, + ); + } const check = await this.editor.checkParseable({ scope, projectDir: options.projectDir }); if (!check.ok) throw new Error(check.warning); removedPayload = await this.removePayloadPerManifest(base, existing.hashes ?? []); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 70f248d..0c49253 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -217,7 +217,7 @@ describe("Installer.install", () => { const consumerAgent = path.join(scopeBase("local"), "agents", "consumer-own.md"); await writeText(consumerAgent, "# consumer's own"); - const outcome = await install("local"); + const outcome = await install("local", { force: true }); expect(outcome.action).toBe("migrated"); expect(outcome.removedPayload).toContain(path.join(scopeBase("local"), "agents", "opencode-architect.md")); @@ -234,6 +234,26 @@ describe("Installer.install", () => { expect(manifest.mode).toBe("plugin"); }); + test("migration requires --force consent and leaves everything intact without it", async () => { + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeJson(configPath, {}); + const legacy = { + version: "0.0.1", + hashes: [{ path: path.join("agents", "opencode-architect.md"), hash: "deadbeef" }], + }; + await writeJson(manifestPath("local"), legacy); + await mkdir(path.join(scopeBase("local"), "agents"), { recursive: true }); + await writeText(path.join(scopeBase("local"), "agents", "opencode-architect.md"), "old agent"); + + await expect(install("local")).rejects.toThrow(/--force/); + + expect(existsSync(path.join(scopeBase("local"), "agents", "opencode-architect.md"))).toBe(true); + expect(existsSync(manifestPath("local"))).toBe(true); + expect(existsSync(configPath)).toBe(true); + const config = await readJson(configPath); + expect(config.plugin).toBeUndefined(); + }); + test("migration aborts with the payload intact when a config is unparseable", async () => { const legacy = { version: "0.0.1", @@ -245,7 +265,7 @@ describe("Installer.install", () => { const configPath = path.join(scopeBase("local"), "opencode.json"); await writeText(configPath, "{ broken ]"); - await expect(install("local")).rejects.toThrow(/could not be parsed/); + await expect(install("local", { force: true })).rejects.toThrow(/could not be parsed/); expect(existsSync(path.join(scopeBase("local"), "agents", "opencode-architect.md"))).toBe(true); expect(existsSync(manifestPath("local"))).toBe(true); From 592f6ecb050e0b48f453882292a2a417495e5b07 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:30:20 -0400 Subject: [PATCH 08/21] fix(conformity): fail loudly when bundled assets are absent (A5) A partial npm cache extraction could register the plugin and record a manifest as installed with no agents on disk. Install now verifies the agents, references, and templates directories up front and aborts with an error naming the missing path, the package version, the cache directory, and the clear-cache remediation command; no manifest is written. The error class ships in the files whitelist. --- bundled-assets-missing-error.ts | 11 +++++++++++ installer.ts | 16 ++++++++++++++++ package.json | 1 + tests/installer.test.ts | 29 +++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 bundled-assets-missing-error.ts diff --git a/bundled-assets-missing-error.ts b/bundled-assets-missing-error.ts new file mode 100644 index 0000000..28a77cd --- /dev/null +++ b/bundled-assets-missing-error.ts @@ -0,0 +1,11 @@ +export class BundledAssetsMissingError extends Error { + constructor(missingPath: string, cacheRoot: string, version: string) { + super( + `Bundled asset directory missing or empty: ${missingPath}. ` + + `The opencode-architect package cache at ${cacheRoot} is partial ` + + `(expected opencode-architect@${version}). ` + + `Clear it with: bunx opencode-architect clear-cache, then reinstall with: bunx opencode-architect install`, + ); + this.name = "BundledAssetsMissingError"; + } +} diff --git a/installer.ts b/installer.ts index b75733f..ae05de7 100644 --- a/installer.ts +++ b/installer.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os"; import path from "node:path"; import { hashElement } from "folder-hash"; import { AGENT_FILENAMES } from "./agent-loader"; +import { BundledAssetsMissingError } from "./bundled-assets-missing-error"; import { PluginConfigEditor } from "./plugin-config"; export type Scope = "local" | "global"; @@ -63,6 +64,11 @@ const LEGACY_MANIFEST_NAME = "opencode-architect.json"; export class Installer { private readonly editor = new PluginConfigEditor(); + private readonly assetsDir: string; + + constructor(assetsDir: string | null = null) { + this.assetsDir = assetsDir ?? path.join(import.meta.dirname, "assets"); + } public async install(scope: Scope, options: InstallOptions): Promise { if (options.mode === "copy") { @@ -78,6 +84,7 @@ export class Installer { const record = await this.readManifestRecord(base); const existing = record.manifest; const version = await this.getPackageVersion(); + await this.requireBundledAssets(version); let removedPayload: string[] = []; let action: InstallAction; @@ -140,6 +147,15 @@ export class Installer { }; } + private async requireBundledAssets(version: string): Promise { + for (const name of ["agents", "references", "templates"]) { + const dir = path.join(this.assetsDir, name); + if (!(await exists(dir))) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version); + const contents = await readdir(dir); + if (contents.length === 0) throw new BundledAssetsMissingError(dir, this.packageCacheRoot(), version); + } + } + private async prunePackageCache(version: string): Promise<{ removed: string[]; warnings: string[] }> { const removed: string[] = []; const warnings: string[] = []; diff --git a/package.json b/package.json index 1879fdd..db61799 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "clear-cache-usage-error.ts", "cli.ts", "installer.ts", + "bundled-assets-missing-error.ts", "plugin-config.ts", "permission-registrar.ts", "assets" diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 0c49253..dfad13b 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -193,6 +193,35 @@ describe("Installer.install", () => { expect(existsSync(manifestPath("local"))).toBe(false); }); + test("fails loudly when bundled assets are absent (partial cache artifact)", async () => { + const partialCache = await mkdtemp(path.join(tmpdir(), "oa-partial-cache-")); + try { + const broken = new Installer(partialCache); + + await expect( + broken.install("local", { force: false, mode: "plugin", projectDir }), + ).rejects.toThrow(/Bundled asset directory missing or empty.*clear-cache/s); + + expect(existsSync(manifestPath("local"))).toBe(false); + } finally { + await rm(partialCache, { recursive: true, force: true }); + } + }); + + test("fails loudly when a bundled asset directory is empty", async () => { + const partialCache = await mkdtemp(path.join(tmpdir(), "oa-partial-cache-")); + await mkdir(path.join(partialCache, "agents"), { recursive: true }); + try { + const broken = new Installer(partialCache); + + await expect( + broken.install("local", { force: false, mode: "plugin", projectDir }), + ).rejects.toThrow(/missing or empty.*agents/s); + } finally { + await rm(partialCache, { recursive: true, force: true }); + } + }); + test("migrates a legacy copy install: payload removed per manifest, entry added", async () => { const configPath = path.join(scopeBase("local"), "opencode.json"); await writeJson(configPath, {}); From 4276651734fb070440a5cd3c8230ab6fd2d18946 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:33:30 -0400 Subject: [PATCH 09/21] fix(conformity): warn on unparseable candidates before match short-circuit (C1) Detection silently treated an unparseable config as unregistered, and a matching earlier candidate short-circuited before later unparseable candidates were ever reported. All candidates are now read up front, each unparseable one warns, and matching/short-circuiting happens after; the surgical-editor template carries the same behavior. --- assets/templates/plugin-config.template.txt | 36 ++++++++++++------- plugin-config.ts | 34 +++++++++++------- tests/plugin-config.test.ts | 40 ++++++++++++++++++++- 3 files changed, 84 insertions(+), 26 deletions(-) diff --git a/assets/templates/plugin-config.template.txt b/assets/templates/plugin-config.template.txt index 0c30059..5cfa71e 100644 --- a/assets/templates/plugin-config.template.txt +++ b/assets/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); 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 `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. --- import { exists, mkdir, readFile, writeFile } from "node:fs/promises"; @@ -54,10 +54,7 @@ export class PluginConfigEditor { options: EnsurePluginEntryOptions, ): Promise { const canonical = this.canonicalEntry(packageName); - 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); + for (const { candidate, text, plugins } of await this.readCandidates(options)) { if (plugins === null) { return { action: "blocked", @@ -111,10 +108,7 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { - 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); + for (const { candidate, plugins } of await this.readCandidates(options)) { if (plugins === null) continue; if (this.hasMatchingEntry(plugins, packageName)) return candidate.path; } @@ -125,10 +119,7 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { - for (const candidate of this.candidateConfigs(options)) { - if (!(await exists(candidate.path)) || !candidate.writable) continue; - const text = await readFile(candidate.path, "utf-8"); - const plugins = this.parsePluginArray(text, candidate.lenient); + for (const { candidate, text, plugins } of await this.readCandidates(options)) { if (plugins === null) { return { action: "blocked", @@ -138,6 +129,7 @@ export class PluginConfigEditor { `Fix or remove the file and re-run the uninstall.`, }; } + if (!candidate.writable) continue; if (!this.hasMatchingEntry(plugins, packageName)) continue; const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); if (spliced === null) { @@ -187,6 +179,24 @@ export class PluginConfigEditor { return configs; } + private async readCandidates( + options: EnsurePluginEntryOptions, + ): Promise> { + const reads: Array<{ candidate: CandidateConfig; text: string; plugins: string[] | null }> = []; + 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) { + console.warn( + `Warning: ${candidate.path} could not be parsed; refusing to treat it as a registration candidate.`, + ); + } + reads.push({ candidate, text, plugins }); + } + return reads; + } + private defaultConfigPath(options: EnsurePluginEntryOptions): string { if (options.scope === "global") { return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); diff --git a/plugin-config.ts b/plugin-config.ts index 03eb75b..3fd8c0f 100644 --- a/plugin-config.ts +++ b/plugin-config.ts @@ -47,10 +47,7 @@ export class PluginConfigEditor { options: EnsurePluginEntryOptions, ): Promise { const canonical = this.canonicalEntry(packageName); - 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); + for (const { candidate, text, plugins } of await this.readCandidates(options)) { if (plugins === null) { return { action: "blocked", @@ -104,10 +101,7 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { - 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); + for (const { candidate, plugins } of await this.readCandidates(options)) { if (plugins === null) continue; if (this.hasMatchingEntry(plugins, packageName)) return candidate.path; } @@ -118,10 +112,7 @@ export class PluginConfigEditor { packageName: string, options: EnsurePluginEntryOptions, ): Promise { - for (const candidate of this.candidateConfigs(options)) { - if (!(await exists(candidate.path)) || !candidate.writable) continue; - const text = await readFile(candidate.path, "utf-8"); - const plugins = this.parsePluginArray(text, candidate.lenient); + for (const { candidate, text, plugins } of await this.readCandidates(options)) { if (plugins === null) { return { action: "blocked", @@ -131,6 +122,7 @@ export class PluginConfigEditor { `Fix or remove the file and re-run the uninstall.`, }; } + if (!candidate.writable) continue; if (!this.hasMatchingEntry(plugins, packageName)) continue; const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); if (spliced === null) { @@ -180,6 +172,24 @@ export class PluginConfigEditor { return configs; } + private async readCandidates( + options: EnsurePluginEntryOptions, + ): Promise> { + const reads: Array<{ candidate: CandidateConfig; text: string; plugins: string[] | null }> = []; + 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) { + console.warn( + `Warning: ${candidate.path} could not be parsed; refusing to treat it as a registration candidate.`, + ); + } + reads.push({ candidate, text, plugins }); + } + return reads; + } + private defaultConfigPath(options: EnsurePluginEntryOptions): string { if (options.scope === "global") { return path.join(this.scopeBase("global", options.projectDir), "opencode.jsonc"); diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index b37d27e..9b002f6 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { PluginConfigEditor } from "../plugin-config"; @@ -160,6 +160,44 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { expect(await readFile(configPath, "utf-8")).toBe(before); }); + 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'); + const brokenRoot = await write("project/opencode.json", "{ broken ]"); + const warnings: string[] = []; + const spy = spyOn(console, "warn").mockImplementation((message: unknown) => { + warnings.push(String(message)); + }); + + try { + const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(warnings.some((message) => message.includes(brokenRoot))).toBe(true); + } finally { + spy.mockRestore(); + } + }); + + 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 warnings: string[] = []; + const spy = spyOn(console, "warn").mockImplementation((message: unknown) => { + warnings.push(String(message)); + }); + + try { + const found = await editor().findRegistration("my-pkg", { scope: "local", projectDir }); + + expect(found).toBe(rootConfig); + expect(warnings.some((message) => message.includes(".opencode"))).toBe(true); + } finally { + spy.mockRestore(); + } + }); + test("strict .json rejects comments and trailing commas", async () => { const projectDir = await makeDir("project"); await write("project/opencode.json", '{ "plugin": ["a",], }\n'); From 1cbcd43aca1e999dd76e789ec3852708df052625 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:34:29 -0400 Subject: [PATCH 10/21] fix(conformity): named CopyModeUnsupportedError for copy-mode refusal (E2) Aligns the suite's own installer with the corrected template pattern: the hard copy-mode error is an identifiable class shipped in the package, with the contract test asserting the type. --- copy-mode-unsupported-error.ts | 9 +++++++++ installer.ts | 6 ++---- package.json | 1 + tests/installer.test.ts | 3 ++- 4 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 copy-mode-unsupported-error.ts diff --git a/copy-mode-unsupported-error.ts b/copy-mode-unsupported-error.ts new file mode 100644 index 0000000..cab65ba --- /dev/null +++ b/copy-mode-unsupported-error.ts @@ -0,0 +1,9 @@ +export class CopyModeUnsupportedError extends Error { + constructor(packageName: string) { + super( + `${packageName} is a code-backed package: it ships agents, which only work through ` + + `plugin registration. Copy install cannot express that. Run without --mode copy.`, + ); + this.name = "CopyModeUnsupportedError"; + } +} diff --git a/installer.ts b/installer.ts index ae05de7..38f4b8d 100644 --- a/installer.ts +++ b/installer.ts @@ -4,6 +4,7 @@ import path from "node:path"; 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 { PluginConfigEditor } from "./plugin-config"; export type Scope = "local" | "global"; @@ -72,10 +73,7 @@ export class Installer { public async install(scope: Scope, options: InstallOptions): Promise { if (options.mode === "copy") { - throw new Error( - `${PACKAGE_NAME} is a code-backed package: it ships agents, which only work through ` + - `plugin registration. Copy install cannot express that. Run without --mode copy.`, - ); + throw new CopyModeUnsupportedError(PACKAGE_NAME); } const base = this.scopeBase(scope, options.projectDir); diff --git a/package.json b/package.json index db61799..d3a6627 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "cache-cleaner.ts", "clear-cache-usage-error.ts", "cli.ts", + "copy-mode-unsupported-error.ts", "installer.ts", "bundled-assets-missing-error.ts", "plugin-config.ts", diff --git a/tests/installer.test.ts b/tests/installer.test.ts index dfad13b..6bca2ec 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -4,6 +4,7 @@ import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from import { tmpdir } from "node:os"; import path from "node:path"; import { Installer, contentHash, type Manifest, type Scope } from "../installer"; +import { CopyModeUnsupportedError } from "../copy-mode-unsupported-error"; const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); @@ -189,7 +190,7 @@ describe("Installer.install", () => { }); test("refuses copy mode with an explanatory error", async () => { - await expect(install("local", { mode: "copy" })).rejects.toThrow(/code-backed/); + await expect(install("local", { mode: "copy" })).rejects.toThrow(CopyModeUnsupportedError); expect(existsSync(manifestPath("local"))).toBe(false); }); From dd1f07d1af7e114e17c7cde09738925aca05ac2a Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:36:35 -0400 Subject: [PATCH 11/21] fix(conformity): canonical single-line badge row with Bun and Linux (D7, D8) The row was missing the Bun runtime and Platforms badges the 4b markup requires, and linked a dangling ./LICENSE.md-style path style. Now one line directly below the heading: npm version, Bun runtime, MIT license (linking the real LICENSE.md), URL-encoded Linux platforms anchor to the real heading slug, and the fixed OpenCode plugin badge; DeepWiki omitted because indexing could not be confirmed. Adds contract tests for the row, link resolution, canonical casing, and D8 snippet keys. --- README.md | 5 +--- tests/readme.test.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 tests/readme.test.ts diff --git a/README.md b/README.md index c150b5b..9ac21ad 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,5 @@ # opencode-architect - -[![npm version](https://img.shields.io/npm/v/opencode-architect)](https://www.npmjs.com/package/opencode-architect) -[![License: MIT](https://img.shields.io/npm/l/opencode-architect)](./LICENSE.md) -[![OpenCode plugin](https://img.shields.io/badge/opencode-plugin-blueviolet)](https://opencode.ai/docs/plugins) +[![npm version](https://img.shields.io/npm/v/opencode-architect?color=cb3837&label=npm)](https://www.npmjs.com/package/opencode-architect) [![Bun](https://img.shields.io/badge/Runtime-Bun-f9f1e1?logo=bun&logoColor=black)](https://bun.sh) [![License: MIT](https://img.shields.io/badge/License-MIT-22c55e)](LICENSE.md) [![Platforms](https://img.shields.io/badge/Platforms-Linux-6366f1)](#quick-start-install-the-opencode-plugin-suite) [![OpenCode plugin](https://img.shields.io/badge/opencode-plugin-blueviolet)](https://opencode.ai/docs/plugins) **Ten specialist agents that design, build, and package OpenCode extensions — agent skills, slash commands, custom tools, plugins, and MCP server integrations — right inside your AI coding assistant.** diff --git a/tests/readme.test.ts b/tests/readme.test.ts new file mode 100644 index 0000000..4a804dc --- /dev/null +++ b/tests/readme.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +const README_PATH = path.resolve(import.meta.dirname, "..", "README.md"); + +async function badgeRow(): Promise { + const lines = (await readFile(README_PATH, "utf-8")).split(/\r?\n/); + const headingIndex = lines.findIndex((line) => line.startsWith("# ")); + const row = lines[headingIndex + 1] ?? ""; + return row.trim(); +} + +describe("README badge row (D7)", () => { + test("one single line directly below the heading with the required badges", async () => { + const row = await badgeRow(); + + expect(row).toContain("img.shields.io/npm/v/opencode-architect"); + expect(row).toContain("Runtime-Bun"); + expect(row).toContain("License-MIT"); + expect(row).toContain("Platforms-Linux"); + expect(row).toContain("opencode-plugin-blueviolet"); + expect(row).not.toMatch(/\r?\n.*shields\.io/); + }); + + test("badge links resolve: license file and platforms anchor exist", async () => { + const row = await badgeRow(); + + const licenseTarget = row.match(/\[!\[License: MIT\]\([^)]+\)\]\(([^)]+)\)/)?.[1] ?? ""; + expect(licenseTarget).toBe("LICENSE.md"); + expect(existsSync(path.resolve(path.dirname(README_PATH), licenseTarget))).toBe(true); + + const platformsTarget = row.match(/\[!\[Platforms\]\([^)]+\)\]\(([^)]+)\)/)?.[1] ?? ""; + expect(platformsTarget.startsWith("#")).toBe(true); + const readme = await readFile(README_PATH, "utf-8"); + expect(headingSlugExists(readme, platformsTarget.slice(1))).toBe(true); + }); + + test("badge URLs use the repo's canonical casing", async () => { + const row = await badgeRow(); + expect(row).not.toContain("expert-vision-software/opencode-architect"); + }); +}); + +function headingSlugExists(readme: string, slug: string): boolean { + return readme + .split(/\r?\n/) + .filter((line) => line.startsWith("##")) + .some((line) => { + const text = line.replace(/^#+\s*/, "").toLowerCase().replace(/[^a-z0-9 -]/g, "").trim(); + return text.replace(/\s+/g, "-") === slug; + }); +} + +describe("shipped config snippets (D8)", () => { + test("README snippets use the plugin key with canonical entries, never plugins", async () => { + const readme = await readFile(README_PATH, "utf-8"); + + expect(readme).toContain('"plugin": ["opencode-architect@latest"]'); + expect(readme).not.toContain('"plugins"'); + expect(readme).toContain("opencode-architect@latest"); + }); +}); From d302248b3f326a446feab1aea94c725fceb79edd Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:36:54 -0400 Subject: [PATCH 12/21] fix(conformity): retire promoted-source leftovers from .opencode/ (D9) The end state after promotion holds only the config holding the reference plus hook-managed content; the source package.json, its lockfile, and node_modules were still present (the tracked lockfile kept collecting local churn). They are gone now; opencode.json keeps the ../index.ts plugin registration for this checkout. --- .opencode/.gitignore | 1 + .opencode/package-lock.json | 399 ------------------------------------ 2 files changed, 1 insertion(+), 399 deletions(-) delete mode 100644 .opencode/package-lock.json diff --git a/.opencode/.gitignore b/.opencode/.gitignore index 20507c0..37a4d41 100644 --- a/.opencode/.gitignore +++ b/.opencode/.gitignore @@ -1,5 +1,6 @@ node_modules package.json +package-lock.json bun.lock docs diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json deleted file mode 100644 index 7215230..0000000 --- a/.opencode/package-lock.json +++ /dev/null @@ -1,399 +0,0 @@ -{ - "name": ".opencode", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@opencode-ai/plugin": "1.18.31" - } - }, - "node_modules/@ai-sdk/provider": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", - "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@opencode-ai/plugin": { - "version": "1.18.31", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.31.tgz", - "integrity": "sha512-Rdc1bPK06PByaGyGd0kf7JUZ4pTkexz2OOUNlqZWLpHOiMEZ+/rFGjt46ypZ3QwA697gNdWwwwQbHbKG5NMwGA==", - "license": "MIT", - "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@opencode-ai/sdk": "1.18.31", - "effect": "4.0.0-beta.83", - "zod": "4.1.8" - }, - "peerDependencies": { - "@opentui/core": ">=0.4.5", - "@opentui/keymap": ">=0.4.5", - "@opentui/solid": ">=0.4.5" - }, - "peerDependenciesMeta": { - "@opentui/core": { - "optional": true - }, - "@opentui/keymap": { - "optional": true - }, - "@opentui/solid": { - "optional": true - } - } - }, - "node_modules/@opencode-ai/sdk": { - "version": "1.18.31", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.31.tgz", - "integrity": "sha512-Raouthf8Lhe9edjvYeeSK7SgvdoU6bBjH9qV3f70dHoa6h+z0X2TMz/e22/wKp/StlFUZ4kIRpYYxFnY8/k01w==", - "license": "MIT", - "dependencies": { - "cross-spawn": "7.0.6" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/effect": { - "version": "4.0.0-beta.83", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", - "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.8.0", - "find-my-way-ts": "^0.1.6", - "ini": "^7.0.0", - "kubernetes-types": "^1.30.0", - "msgpackr": "^2.0.1", - "multipasta": "^0.2.7", - "toml": "^4.1.1", - "uuid": "^14.0.0", - "yaml": "^2.9.0" - } - }, - "node_modules/fast-check": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", - "integrity": "sha512-iK2f+YrcmoeGqk6fA0ea2bptcu/itMIm4NfEozq6N25+aG6h7s5HZbB/k1aV7b5w5sFLMCbbtRUsTVR+BgC3xw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } - }, - "node_modules/find-my-way-ts": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", - "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", - "license": "MIT" - }, - "node_modules/ini": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", - "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", - "license": "ISC", - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, - "node_modules/kubernetes-types": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", - "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", - "license": "Apache-2.0" - }, - "node_modules/msgpackr": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", - "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.4" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" - } - }, - "node_modules/multipasta": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", - "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", - "license": "MIT" - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pure-rand": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", - "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/toml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", - "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/uuid": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", - "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/yaml": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", - "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, - "node_modules/zod": { - "version": "4.1.8", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} From 6103c9359d1aa2ebba9f770d2c8a31efd767a162 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 21:37:40 -0400 Subject: [PATCH 13/21] test(conformity): adversarial jsonc, entry-removal drift, scope non-leakage (D3) Pins the checklist's comment-between-trailing-comma-and-closer input, proves a version match without a live registration is never a no-op (B6), and locks in that re-registering one scope never touches the other (C2). --- tests/installer.test.ts | 27 +++++++++++++++++++++++++++ tests/plugin-config.test.ts | 12 ++++++++++++ 2 files changed, 39 insertions(+) diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 6bca2ec..be9a82d 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -315,6 +315,33 @@ describe("Installer.install", () => { ); }); + test("a removed plugin entry is not up to date: install re-registers", async () => { + await install("local"); + const configPath = path.join(scopeBase("local"), "opencode.json"); + await writeJson(configPath, { theme: "dark" }); + + const outcome = await install("local"); + + expect(outcome.action).toBe("upgraded"); + const config = await readJson(configPath); + expect(config.plugin).toEqual(["opencode-architect@latest"]); + const manifest = (await readJson(manifestPath("local"))) as unknown as Manifest; + expect(manifest.version).toBe(await readPackageVersion()); + }); + + test("re-registering one scope leaves the other scope untouched", async () => { + await install("local"); + await install("global"); + const globalConfigPath = path.join(scopeBase("global"), "opencode.jsonc"); + const globalConfigBefore = await readFile(globalConfigPath, "utf-8"); + const globalManifestBefore = await readFile(manifestPath("global"), "utf-8"); + + await install("local", { force: true }); + + expect(await readFile(globalConfigPath, "utf-8")).toBe(globalConfigBefore); + expect(await readFile(manifestPath("global"), "utf-8")).toBe(globalManifestBefore); + }); + test("relocates a manifest left at the legacy name on the next install", async () => { await install("local"); await rename(manifestPath("local"), legacyManifestPath("local")); diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index 9b002f6..b39bdb4 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -215,6 +215,18 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { expect(outcome.action).toBe("updated"); }); + 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'); + + 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"); + }); + test("schema URLs with // and escaped quotes survive splicing and parsing", async () => { const projectDir = await makeDir("project"); const configPath = await write( From 9873b78bcfbeee4ca8fc9ed8dd357877dab36289 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 22:10:23 -0400 Subject: [PATCH 14/21] refactor(conformity): plugin hook as a class; version-placeholder fallback (review) Addresses the standards review: the hook concern moves from free functions into OpencodeArchitectPlugin (classes-over-helpers), the advisory prefix is no longer duplicated across try/catch branches, and the metadata-unreadable fallback now keeps the package-qualified cache path with a literal placeholder (D4). The version reader is injectable so the fallback is exercised through the public path. --- index.ts | 99 +++++++++++++++++++++++------------------ tests/load-hook.test.ts | 51 ++++++++++++++------- 2 files changed, 90 insertions(+), 60 deletions(-) diff --git a/index.ts b/index.ts index 41d381b..ccef4d0 100644 --- a/index.ts +++ b/index.ts @@ -6,63 +6,74 @@ import { AssetPermissionRegistrar } from "./permission-registrar"; const PACKAGE_NAME = "opencode-architect"; -export function createOpencodeArchitect(agentsDir: string, assetsDir: string): Plugin { - let failureAdvised = false; +export class OpencodeArchitectPlugin { + private readonly agentsDir: string; + private readonly assetsDir: string; + private readonly readVersion: () => Promise; + private failureAdvised = false; - async function adviseFailureOnce(message: string): Promise { - if (failureAdvised) return; - failureAdvised = true; - await emitAdvisory(message); + constructor( + agentsDir: string | null = null, + assetsDir: string | null = null, + readVersion: (() => Promise) | null = null, + ) { + this.agentsDir = agentsDir ?? path.join(import.meta.dirname, "assets", "agents"); + this.assetsDir = assetsDir ?? path.join(import.meta.dirname, "assets"); + this.readVersion = readVersion ?? (() => this.readPackageMetadata()); } - async function emitAdvisory(message: string): Promise { + public toPlugin(): Plugin { + return async (input) => { + const directory = typeof input?.directory === "string" ? input.directory : process.cwd(); + return { + config: async (config) => { + try { + const agents = await new AgentLoader(this.agentsDir).loadAgents(); + config.agent = config.agent || {}; + for (const [name, agentConfig] of Object.entries(agents)) { + config.agent[name] = agentConfig; + } + new AssetPermissionRegistrar(this.assetsDir).register(config); + } catch (error) { + await this.adviseFailureOnce(error instanceof Error ? error.message : String(error)); + } + }, + }; + }; + } + + private async adviseFailureOnce(message: string): Promise { + if (this.failureAdvised) return; + this.failureAdvised = true; + await this.emitAdvisory(message); + } + + private async emitAdvisory(message: string): Promise { + const prefix = + `Failed to load the bundled agent suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` + + `reinstall and restart OpenCode. The stale cache copy is `; let text: string; try { - const version = await readPackageVersion(); - text = - `Failed to load the bundled agent suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` + - `reinstall and restart OpenCode. The stale cache copy is ` + - `~/.cache/opencode/packages/${PACKAGE_NAME}@${version}. Cause: ${message}`; + text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@${await this.readVersion()}. Cause: ${message}`; } catch { - text = - `Failed to load the bundled agent suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` + - `reinstall and restart OpenCode. The stale cache copy lives under ` + - `~/.cache/opencode/packages/. Cause: ${message}`; + text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@. Cause: ${message}`; } try { console.warn(`[${PACKAGE_NAME}] ${text}`); } catch {} } - return async () => ({ - config: async (config) => { - try { - const agents = await new AgentLoader(agentsDir).loadAgents(); - config.agent = config.agent || {}; - for (const [name, agentConfig] of Object.entries(agents)) { - config.agent[name] = agentConfig; - } - new AssetPermissionRegistrar(assetsDir).register(config); - } catch (error) { - await adviseFailureOnce(error instanceof Error ? error.message : String(error)); - } - }, - }); -} - -async function readPackageVersion(): Promise { - const manifest = JSON.parse( - await readFile(path.join(import.meta.dirname, "package.json"), "utf-8"), - ) as { version: string }; - if (typeof manifest.version !== "string" || manifest.version.length === 0) { - throw new Error("unreadable package metadata"); + private async readPackageMetadata(): Promise { + const manifest = JSON.parse( + await readFile(path.join(import.meta.dirname, "package.json"), "utf-8"), + ) as { version: string }; + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + throw new Error("unreadable package metadata"); + } + return manifest.version; } - return manifest.version; } -const OpencodeArchitect: Plugin = createOpencodeArchitect( - path.join(import.meta.dirname, "assets", "agents"), - path.join(import.meta.dirname, "assets"), -); +const opencodeArchitect = new OpencodeArchitectPlugin(); -export default OpencodeArchitect; +export default opencodeArchitect.toPlugin(); diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts index 3278493..2a41529 100644 --- a/tests/load-hook.test.ts +++ b/tests/load-hook.test.ts @@ -3,29 +3,21 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Config } from "@opencode-ai/plugin"; -import { createOpencodeArchitect } from "../index"; +import type { Plugin } from "@opencode-ai/plugin"; +import { OpencodeArchitectPlugin } from "../index"; const REPO_ROOT = path.resolve(import.meta.dirname, ".."); const REAL_AGENTS_DIR = path.join(REPO_ROOT, "assets", "agents"); const REAL_ASSETS_DIR = path.join(REPO_ROOT, "assets"); -async function makeScratchDirs(): Promise<{ agentsDir: string; assetsDir: string }> { - const root = await mkdtemp(path.join(tmpdir(), "opencode-architect-hook-")); - const agentsDir = path.join(root, "agents"); - const assetsDir = path.join(root, "assets"); - await mkdir(agentsDir, { recursive: true }); - await mkdir(assetsDir, { recursive: true }); - return { agentsDir, assetsDir }; -} - -type PluginHooks = Awaited>>; +type PluginHooks = Awaited>; -async function hooksFrom(plugin: ReturnType): Promise { +async function hooksFrom(plugin: Plugin): Promise { const build = plugin as unknown as () => Promise; return build(); } -async function configFrom(plugin: ReturnType): Promise { +async function configFrom(plugin: Plugin): Promise { const hooks = await hooksFrom(plugin); const register = hooks.config; if (!register) throw new Error("missing config hook"); @@ -34,6 +26,19 @@ async function configFrom(plugin: ReturnType): P return config; } +function pluginWith(agentsDir: string, assetsDir: string): Plugin { + return new OpencodeArchitectPlugin(agentsDir, assetsDir).toPlugin(); +} + +async function makeScratchDirs(): Promise<{ agentsDir: string; assetsDir: string }> { + const root = await mkdtemp(path.join(tmpdir(), "opencode-architect-hook-")); + const agentsDir = path.join(root, "agents"); + const assetsDir = path.join(root, "assets"); + await mkdir(agentsDir, { recursive: true }); + await mkdir(assetsDir, { recursive: true }); + return { agentsDir, assetsDir }; +} + describe("plugin load hook (startup non-interference)", () => { let warnSpy: ReturnType | null = null; @@ -53,7 +58,7 @@ describe("plugin load hook (startup non-interference)", () => { test("missing bundled agents degrade to exactly one advisory instead of throwing", async () => { const { agentsDir, assetsDir } = await makeScratchDirs(); const warnings = captureWarnings(); - const plugin = createOpencodeArchitect(agentsDir, assetsDir); + const plugin = pluginWith(agentsDir, assetsDir); const config = await configFrom(plugin); @@ -64,10 +69,24 @@ describe("plugin load hook (startup non-interference)", () => { expect(warnings[0]).toContain("ENOENT"); }); + test("the advisory fallback keeps a package-qualified cache path with a literal version placeholder", async () => { + const { agentsDir, assetsDir } = await makeScratchDirs(); + const warnings = captureWarnings(); + const plugin = new OpencodeArchitectPlugin(agentsDir, assetsDir, async () => { + throw new Error("unreadable package metadata"); + }); + + await configFrom(plugin.toPlugin()); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("opencode-architect@"); + expect(warnings[0]).toContain("Cause: ENOENT"); + }); + test("repeated failing invocations emit exactly one advisory", async () => { const { agentsDir, assetsDir } = await makeScratchDirs(); const warnings = captureWarnings(); - const plugin = createOpencodeArchitect(agentsDir, assetsDir); + const plugin = pluginWith(agentsDir, assetsDir); await configFrom(plugin); await configFrom(plugin); @@ -78,7 +97,7 @@ describe("plugin load hook (startup non-interference)", () => { test("healthy assets register the agent suite in-memory with no warning", async () => { const warnings = captureWarnings(); - const config = await configFrom(createOpencodeArchitect(REAL_AGENTS_DIR, REAL_ASSETS_DIR)); + const config = await configFrom(pluginWith(REAL_AGENTS_DIR, REAL_ASSETS_DIR)); expect(Object.keys(config.agent ?? {}).length).toBe(10); expect(Object.keys(config.permission?.external_directory ?? {}).length).toBeGreaterThan(0); From f113e37121a31b3e5e4701b000410745020623e5 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 22:11:26 -0400 Subject: [PATCH 15/21] fix(conformity): one-shot not-installed advisory with separate guard (D5, review) The checklist requires the failure advisory and the not-installed advisory as distinct advisories with separate once-guards; only the failure advisory existed. After a healthy in-memory start the hook now probes both scopes for an install manifest (read-only via Installer.hasManifestAnywhere) and, when none exists, advises bunx opencode-architect install once per session. Tests pin the once-guard, suppression when a manifest exists, and independence from the failure advisory. --- index.ts | 18 +++++++++++ installer.ts | 10 ++++++ tests/load-hook.test.ts | 70 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index ccef4d0..c283ab3 100644 --- a/index.ts +++ b/index.ts @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { AgentLoader } from "./agent-loader"; import { AssetPermissionRegistrar } from "./permission-registrar"; +import { Installer } from "./installer"; const PACKAGE_NAME = "opencode-architect"; @@ -10,16 +11,20 @@ export class OpencodeArchitectPlugin { private readonly agentsDir: string; private readonly assetsDir: string; private readonly readVersion: () => Promise; + private readonly hasInstall: (projectDir: string) => Promise; private failureAdvised = false; + private notInstalledAdvised = false; constructor( agentsDir: string | null = null, assetsDir: string | null = null, readVersion: (() => Promise) | null = null, + hasInstall: ((projectDir: string) => Promise) | null = null, ) { this.agentsDir = agentsDir ?? path.join(import.meta.dirname, "assets", "agents"); this.assetsDir = assetsDir ?? path.join(import.meta.dirname, "assets"); this.readVersion = readVersion ?? (() => this.readPackageMetadata()); + this.hasInstall = hasInstall ?? ((projectDir) => new Installer().hasManifestAnywhere(projectDir)); } public toPlugin(): Plugin { @@ -34,6 +39,7 @@ export class OpencodeArchitectPlugin { config.agent[name] = agentConfig; } new AssetPermissionRegistrar(this.assetsDir).register(config); + await this.adviseNotInstalledOnce(directory); } catch (error) { await this.adviseFailureOnce(error instanceof Error ? error.message : String(error)); } @@ -42,6 +48,18 @@ export class OpencodeArchitectPlugin { }; } + private async adviseNotInstalledOnce(directory: string): Promise { + if (this.notInstalledAdvised) return; + if (await this.hasInstall(directory)) return; + this.notInstalledAdvised = true; + try { + console.log( + `[${PACKAGE_NAME}] Not installed in any scope. Run: bunx ${PACKAGE_NAME} install --scope global ` + + `(or: bunx ${PACKAGE_NAME} install).`, + ); + } catch {} + } + private async adviseFailureOnce(message: string): Promise { if (this.failureAdvised) return; this.failureAdvised = true; diff --git a/installer.ts b/installer.ts index 38f4b8d..678d7d8 100644 --- a/installer.ts +++ b/installer.ts @@ -251,6 +251,16 @@ export class Installer { return { scope, mode: "none", version: null, configPath: null }; } + public async hasManifestAnywhere(projectDir: string): Promise { + for (const scope of ["global", "local"] as const) { + const base = this.scopeBase(scope, projectDir); + for (const candidate of this.manifestCandidates(base)) { + if (await exists(candidate)) return true; + } + } + return false; + } + private async removePayloadPerManifest( base: string, hashes: ManifestHashEntry[], diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts index 2a41529..c7017a0 100644 --- a/tests/load-hook.test.ts +++ b/tests/load-hook.test.ts @@ -97,10 +97,78 @@ describe("plugin load hook (startup non-interference)", () => { test("healthy assets register the agent suite in-memory with no warning", async () => { const warnings = captureWarnings(); - const config = await configFrom(pluginWith(REAL_AGENTS_DIR, REAL_ASSETS_DIR)); + const plugin = new OpencodeArchitectPlugin(REAL_AGENTS_DIR, REAL_ASSETS_DIR, null, async () => true); + const config = await configFrom(plugin.toPlugin()); expect(Object.keys(config.agent ?? {}).length).toBe(10); expect(Object.keys(config.permission?.external_directory ?? {}).length).toBeGreaterThan(0); expect(warnings).toEqual([]); }); }); + +describe("not-installed advisory (D5)", () => { + let logSpy: ReturnType | null = null; + let warnSpy: ReturnType | null = null; + + afterEach(() => { + logSpy?.mockRestore(); + warnSpy?.mockRestore(); + logSpy = null; + warnSpy = null; + }); + + function captureOutput(): { logs: string[]; warnings: string[] } { + const logs: string[] = []; + const warnings: string[] = []; + logSpy = spyOn(console, "log").mockImplementation((message: unknown) => { + logs.push(String(message)); + }); + warnSpy = spyOn(console, "warn").mockImplementation((message: unknown) => { + warnings.push(String(message)); + }); + return { logs, warnings }; + } + + test("fires once when no scope holds an install, then stays suppressed", async () => { + const { logs } = captureOutput(); + let installed = false; + const plugin = new OpencodeArchitectPlugin(REAL_AGENTS_DIR, REAL_ASSETS_DIR, null, async () => installed); + + await configFrom(plugin.toPlugin()); + await configFrom(plugin.toPlugin()); + + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Not installed in any scope"); + expect(logs[0]).toContain("bunx opencode-architect install --scope global"); + + installed = true; + await configFrom(plugin.toPlugin()); + expect(logs).toHaveLength(1); + }); + + test("fires independently of the failure advisory (separate once-guards)", async () => { + const { logs, warnings } = captureOutput(); + const failing = new OpencodeArchitectPlugin( + path.join(tmpdir(), "missing-agents-"), + path.join(tmpdir(), "missing-assets-"), + ); + await configFrom(failing.toPlugin()); + expect(warnings).toHaveLength(1); + + const healthy = new OpencodeArchitectPlugin(REAL_AGENTS_DIR, REAL_ASSETS_DIR, null, async () => false); + await configFrom(healthy.toPlugin()); + + expect(logs).toHaveLength(1); + expect(logs[0]).toContain("Not installed in any scope"); + }); + + test("never fires when a scope already holds an install", async () => { + const { logs } = captureOutput(); + const plugin = new OpencodeArchitectPlugin(REAL_AGENTS_DIR, REAL_ASSETS_DIR, null, async () => true); + + await configFrom(plugin.toPlugin()); + await configFrom(plugin.toPlugin()); + + expect(logs).toEqual([]); + }); +}); From b7d15cd2433b71d72dc59d0a7516289f7335adf5 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 22:12:15 -0400 Subject: [PATCH 16/21] fix(conformity): unparseable read-only configs never block removal (review) Batch-8's up-front read made removePluginEntry return blocked for an unparseable global config.json, a file removal never writes; the pre-change behavior was to skip it. Writable candidates still block; read-only ones are warned about (C1) and skipped. Template carries the same order; regression test pins warn + skip + byte preservation. --- assets/templates/plugin-config.template.txt | 2 +- plugin-config.ts | 2 +- tests/plugin-config.test.ts | 22 +++++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/assets/templates/plugin-config.template.txt b/assets/templates/plugin-config.template.txt index 5cfa71e..bf66779 100644 --- a/assets/templates/plugin-config.template.txt +++ b/assets/templates/plugin-config.template.txt @@ -120,6 +120,7 @@ export class PluginConfigEditor { options: EnsurePluginEntryOptions, ): Promise { for (const { candidate, text, plugins } of await this.readCandidates(options)) { + if (!candidate.writable) continue; if (plugins === null) { return { action: "blocked", @@ -129,7 +130,6 @@ export class PluginConfigEditor { `Fix or remove the file and re-run the uninstall.`, }; } - if (!candidate.writable) continue; if (!this.hasMatchingEntry(plugins, packageName)) continue; const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); if (spliced === null) { diff --git a/plugin-config.ts b/plugin-config.ts index 3fd8c0f..218a73a 100644 --- a/plugin-config.ts +++ b/plugin-config.ts @@ -113,6 +113,7 @@ export class PluginConfigEditor { options: EnsurePluginEntryOptions, ): Promise { for (const { candidate, text, plugins } of await this.readCandidates(options)) { + if (!candidate.writable) continue; if (plugins === null) { return { action: "blocked", @@ -122,7 +123,6 @@ export class PluginConfigEditor { `Fix or remove the file and re-run the uninstall.`, }; } - if (!candidate.writable) continue; if (!this.hasMatchingEntry(plugins, packageName)) continue; const spliced = this.spliceOutEntry(text, packageName, candidate.lenient); if (spliced === null) { diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index b39bdb4..6ed5b16 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -198,6 +198,28 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { } }); + test("unparseable read-only candidate is warned about and skipped, never blocking removal", async () => { + const projectDir = await makeDir("project"); + const xdg = await makeDir("xdg"); + const readOnlyConfig = await write("xdg/opencode/config.json", "{ broken ]"); + process.env.XDG_CONFIG_HOME = xdg; + const warnings: string[] = []; + const spy = spyOn(console, "warn").mockImplementation((message: unknown) => { + warnings.push(String(message)); + }); + + try { + const outcome = await editor().removePluginEntry("my-pkg", { scope: "global", projectDir }); + + expect(outcome.action).toBe("noop"); + expect(warnings.some((message) => message.includes(readOnlyConfig))).toBe(true); + expect(await readFile(readOnlyConfig, "utf-8")).toBe("{ broken ]"); + } finally { + spy.mockRestore(); + delete process.env.XDG_CONFIG_HOME; + } + }); + test("strict .json rejects comments and trailing commas", async () => { const projectDir = await makeDir("project"); await write("project/opencode.json", '{ "plugin": ["a",], }\n'); From 1445a962858037de03fcde1a3fed37f038792b06 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 22:13:04 -0400 Subject: [PATCH 17/21] refactor(conformity): named manifest hit path, asset dirs, candidate reads (review) readManifestRecord's path field becomes foundPath and status() destructures; the bundled asset directory list is a named constant; the readCandidates return shape gets a CandidateRead type in the editor and its template. Judgement-call cleanups from the standards review; spliceEntry's parameter list intentionally mirrors the template and stays. --- assets/templates/plugin-config.template.txt | 12 +++++++---- installer.ts | 22 +++++++++------------ plugin-config.ts | 12 +++++++---- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/assets/templates/plugin-config.template.txt b/assets/templates/plugin-config.template.txt index bf66779..990dbac 100644 --- a/assets/templates/plugin-config.template.txt +++ b/assets/templates/plugin-config.template.txt @@ -34,6 +34,12 @@ interface CandidateConfig { writable: boolean; } +interface CandidateRead { + candidate: CandidateConfig; + text: string; + plugins: string[] | null; +} + export type ConfigCheck = { ok: true } | { ok: false; warning: string }; interface PluginArrayRange { @@ -179,10 +185,8 @@ export class PluginConfigEditor { return configs; } - private async readCandidates( - options: EnsurePluginEntryOptions, - ): Promise> { - const reads: Array<{ candidate: CandidateConfig; text: string; plugins: string[] | null }> = []; + private async readCandidates(options: EnsurePluginEntryOptions): Promise { + const reads: CandidateRead[] = []; for (const candidate of this.candidateConfigs(options)) { if (!(await exists(candidate.path))) continue; const text = await readFile(candidate.path, "utf-8"); diff --git a/installer.ts b/installer.ts index 678d7d8..6424af6 100644 --- a/installer.ts +++ b/installer.ts @@ -59,6 +59,7 @@ export interface StatusOutcome { } const PACKAGE_NAME = "opencode-architect"; +const BUNDLED_ASSET_DIRS = ["agents", "references", "templates"]; const PLUGIN_ENTRY = "opencode-architect@latest"; const MANIFEST_NAME = "opencode-architect.manifest.json"; const LEGACY_MANIFEST_NAME = "opencode-architect.json"; @@ -115,7 +116,7 @@ export class Installer { action = existing.version === version && registration.action === "noop" ? "noop" : "upgraded"; } - if (action !== "noop" || options.force || record.path === legacyManifestPath) { + if (action !== "noop" || options.force || record.foundPath === legacyManifestPath) { const manifest: Manifest = { version, mode: "plugin", @@ -146,7 +147,7 @@ export class Installer { } private async requireBundledAssets(version: string): Promise { - for (const name of ["agents", "references", "templates"]) { + 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); const contents = await readdir(dir); @@ -235,14 +236,9 @@ export class Installer { public async status(scope: Scope, projectDir: string): Promise { const base = this.scopeBase(scope, projectDir); - const record = await this.readManifestRecord(base); - if (record.manifest !== null) { - return { - scope, - mode: record.manifest.mode, - version: record.manifest.version, - configPath: record.manifest.configPath, - }; + const { manifest } = await this.readManifestRecord(base); + if (manifest !== null) { + return { scope, mode: manifest.mode, version: manifest.version, configPath: manifest.configPath }; } const registrationPath = await this.editor.findRegistration(PACKAGE_NAME, { scope, projectDir }); if (registrationPath !== null) { @@ -294,12 +290,12 @@ export class Installer { return [path.join(base, MANIFEST_NAME), path.join(base, LEGACY_MANIFEST_NAME)]; } - private async readManifestRecord(base: string): Promise<{ manifest: Manifest | null; path: string | null }> { + private async readManifestRecord(base: string): Promise<{ manifest: Manifest | null; foundPath: string | null }> { for (const candidate of this.manifestCandidates(base)) { const manifest = await this.readManifest(candidate); - if (manifest !== null) return { manifest, path: candidate }; + if (manifest !== null) return { manifest, foundPath: candidate }; } - return { manifest: null, path: null }; + return { manifest: null, foundPath: null }; } private async removeManifestFiles(base: string): Promise { diff --git a/plugin-config.ts b/plugin-config.ts index 218a73a..79c8b54 100644 --- a/plugin-config.ts +++ b/plugin-config.ts @@ -21,6 +21,12 @@ interface CandidateConfig { writable: boolean; } +interface CandidateRead { + candidate: CandidateConfig; + text: string; + plugins: string[] | null; +} + export interface RemovePluginEntryOutcome { action: "noop" | "removed" | "blocked"; configPath: string | null; @@ -172,10 +178,8 @@ export class PluginConfigEditor { return configs; } - private async readCandidates( - options: EnsurePluginEntryOptions, - ): Promise> { - const reads: Array<{ candidate: CandidateConfig; text: string; plugins: string[] | null }> = []; + private async readCandidates(options: EnsurePluginEntryOptions): Promise { + const reads: CandidateRead[] = []; for (const candidate of this.candidateConfigs(options)) { if (!(await exists(candidate.path))) continue; const text = await readFile(candidate.path, "utf-8"); From 2f3f4c85dce865e588c53f0c7ab5669373aa61ef Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 22:13:44 -0400 Subject: [PATCH 18/21] test(conformity): D6 enforcement scans every shipped markdown file (review) The quoting rule covers agent definitions, SKILL.md, and command files; the test only walked the agent filename list, so a future SKILL.md with bare values would have slipped through. The scan now covers every .md under assets/ with leading frontmatter. --- tests/agents.test.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 048c4cc..f132e92 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { AGENT_FILENAMES, AgentLoader } from "../agent-loader"; @@ -29,9 +29,17 @@ function frontmatterLines(content: string): string[] { return (match[1] ?? "").split(/\r?\n/); } -function frontmatterViolations(filename: string): string[] { +function listShippedMarkdown(): string[] { + const assetsRoot = path.resolve(import.meta.dirname, "..", "assets"); + return readdirSync(assetsRoot, { recursive: true }) + .map(String) + .filter((relative) => relative.endsWith(".md")) + .map((relative) => path.join(assetsRoot, relative)); +} + +function frontmatterViolations(filePath: string): string[] { const violations: string[] = []; - for (const line of frontmatterLines(readFileSync(path.join(AGENTS_DIR, filename), "utf-8"))) { + for (const line of frontmatterLines(readFileSync(filePath, "utf-8"))) { if (line.trim().length === 0) continue; const indented = /^[ \t]/.test(line); const property = line.match(/^([A-Za-z_-]+):\s*(.*)$/); @@ -40,15 +48,15 @@ function frontmatterViolations(filename: string): string[] { if (value.length === 0) continue; if (/^(true|false|-?\d+(\.\d+)?)$/.test(value)) continue; if (!/^".*"$/.test(value)) { - violations.push(`${filename}: unquoted value ${value}`); + violations.push(`${path.basename(filePath)}: unquoted value ${value}`); } } return violations; } describe("frontmatter hygiene (D6)", () => { - test("every frontmatter property value in shipped agents is double-quoted", () => { - const violations = AGENT_FILENAMES.flatMap((filename) => frontmatterViolations(filename)); + test("every frontmatter value in every shipped markdown file is double-quoted", () => { + const violations = listShippedMarkdown().flatMap((filePath) => frontmatterViolations(filePath)); expect(violations).toEqual([]); }); }); From 98c50276fdf644401dcf4c1e442779887335217e Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 22:15:11 -0400 Subject: [PATCH 19/21] refactor(conformity): shared captureConsole helper for console spies (review) The warn-capture spy boilerplate repeated across load-hook and plugin-config tests; it now lives once in test-helpers and both suites restore through the returned handle. --- tests/load-hook.test.ts | 43 ++++++++++++++++--------------------- tests/plugin-config.test.ts | 30 ++++++++++---------------- tests/test-helpers.ts | 15 ++++++++++++- 3 files changed, 44 insertions(+), 44 deletions(-) diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts index c7017a0..4838e4a 100644 --- a/tests/load-hook.test.ts +++ b/tests/load-hook.test.ts @@ -1,10 +1,11 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { Config } from "@opencode-ai/plugin"; import type { Plugin } from "@opencode-ai/plugin"; import { OpencodeArchitectPlugin } from "../index"; +import { captureConsole } from "./test-helpers"; const REPO_ROOT = path.resolve(import.meta.dirname, ".."); const REAL_AGENTS_DIR = path.join(REPO_ROOT, "assets", "agents"); @@ -40,19 +41,17 @@ async function makeScratchDirs(): Promise<{ agentsDir: string; assetsDir: string } describe("plugin load hook (startup non-interference)", () => { - let warnSpy: ReturnType | null = null; + let restoreWarnings: (() => void) | null = null; afterEach(() => { - warnSpy?.mockRestore(); - warnSpy = null; + restoreWarnings?.(); + restoreWarnings = null; }); function captureWarnings(): string[] { - const warnings: string[] = []; - warnSpy = spyOn(console, "warn").mockImplementation((message: unknown) => { - warnings.push(String(message)); - }); - return warnings; + const captured = captureConsole("warn"); + restoreWarnings = captured.restore; + return captured.lines; } test("missing bundled agents degrade to exactly one advisory instead of throwing", async () => { @@ -107,26 +106,22 @@ describe("plugin load hook (startup non-interference)", () => { }); describe("not-installed advisory (D5)", () => { - let logSpy: ReturnType | null = null; - let warnSpy: ReturnType | null = null; + let restoreLogs: (() => void) | null = null; + let restoreWarnings: (() => void) | null = null; afterEach(() => { - logSpy?.mockRestore(); - warnSpy?.mockRestore(); - logSpy = null; - warnSpy = null; + restoreLogs?.(); + restoreWarnings?.(); + restoreLogs = null; + restoreWarnings = null; }); function captureOutput(): { logs: string[]; warnings: string[] } { - const logs: string[] = []; - const warnings: string[] = []; - logSpy = spyOn(console, "log").mockImplementation((message: unknown) => { - logs.push(String(message)); - }); - warnSpy = spyOn(console, "warn").mockImplementation((message: unknown) => { - warnings.push(String(message)); - }); - return { logs, warnings }; + const logs = captureConsole("log"); + const warnings = captureConsole("warn"); + restoreLogs = logs.restore; + restoreWarnings = warnings.restore; + return { logs: logs.lines, warnings: warnings.lines }; } test("fires once when no scope holds an install, then stays suppressed", async () => { diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index 6ed5b16..e2731d3 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -1,7 +1,8 @@ -import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { PluginConfigEditor } from "../plugin-config"; +import { captureConsole } from "./test-helpers"; const ROOT = path.join(import.meta.dirname, "..", ".tmp-plugin-config-test"); @@ -164,18 +165,15 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const projectDir = await makeDir("project"); await write("project/.opencode/opencode.json", '{ "plugin": ["my-pkg"] }\n'); const brokenRoot = await write("project/opencode.json", "{ broken ]"); - const warnings: string[] = []; - const spy = spyOn(console, "warn").mockImplementation((message: unknown) => { - warnings.push(String(message)); - }); + const captured = captureConsole("warn"); try { const outcome = await editor().ensurePluginEntry("my-pkg", { scope: "local", projectDir }); expect(outcome.action).toBe("noop"); - expect(warnings.some((message) => message.includes(brokenRoot))).toBe(true); + expect(captured.lines.some((message) => message.includes(brokenRoot))).toBe(true); } finally { - spy.mockRestore(); + captured.restore(); } }); @@ -183,18 +181,15 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { 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 warnings: string[] = []; - const spy = spyOn(console, "warn").mockImplementation((message: unknown) => { - warnings.push(String(message)); - }); + const captured = captureConsole("warn"); try { const found = await editor().findRegistration("my-pkg", { scope: "local", projectDir }); expect(found).toBe(rootConfig); - expect(warnings.some((message) => message.includes(".opencode"))).toBe(true); + expect(captured.lines.some((message) => message.includes(".opencode"))).toBe(true); } finally { - spy.mockRestore(); + captured.restore(); } }); @@ -203,19 +198,16 @@ describe("PluginConfigEditor.ensurePluginEntry", () => { const xdg = await makeDir("xdg"); const readOnlyConfig = await write("xdg/opencode/config.json", "{ broken ]"); process.env.XDG_CONFIG_HOME = xdg; - const warnings: string[] = []; - const spy = spyOn(console, "warn").mockImplementation((message: unknown) => { - warnings.push(String(message)); - }); + const captured = captureConsole("warn"); try { const outcome = await editor().removePluginEntry("my-pkg", { scope: "global", projectDir }); expect(outcome.action).toBe("noop"); - expect(warnings.some((message) => message.includes(readOnlyConfig))).toBe(true); + expect(captured.lines.some((message) => message.includes(readOnlyConfig))).toBe(true); expect(await readFile(readOnlyConfig, "utf-8")).toBe("{ broken ]"); } finally { - spy.mockRestore(); + captured.restore(); delete process.env.XDG_CONFIG_HOME; } }); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index 8fccfbf..610d8b6 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -1,8 +1,21 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; -import { expect } from "bun:test"; +import { expect, spyOn } from "bun:test"; import { ClearCacheUsageError } from "../clear-cache-usage-error"; +export interface CapturedConsole { + lines: string[]; + restore: () => void; +} + +export function captureConsole(method: "log" | "warn"): CapturedConsole { + const lines: string[] = []; + const spy = spyOn(console, method).mockImplementation((message: unknown) => { + lines.push(String(message)); + }); + return { lines, restore: () => spy.mockRestore() }; +} + export async function seedCachedPackage(dir: string, name: string): Promise { const target = path.join(dir, name); await mkdir(path.join(target, "nested"), { recursive: true }); From a9cf2074eff358c9177b895692ab3ceca4a02dfe Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 22:44:29 -0400 Subject: [PATCH 20/21] refactor(conformity): package-root content layout, src/ code layout (review) Drops the legacy assets/ intermediary the suite itself teaches packagers to retire: agents/, references/, and templates/ now sit at the package root and implementation modules live in src/, matching the generated-package convention (plugin.ts at the root, index.ts as the re-export surface). Installer and plugin resolve bundled content from the package root; agents' ../references links stay valid since both sides of the relative path moved together. package.json bin/files and the test suites follow; CONTEXT.md's References and Templates entries now document the root-level directories. Also fixes this repo's own .opencode/opencode.json trailing comma, which strict .json parsing rightly refused. --- .opencode/opencode.json | 2 +- CONTEXT.md | 4 +- .../opencode-agent-designer.md | 0 .../agents => agents}/opencode-architect.md | 0 .../opencode-command-crafter.md | 0 .../opencode-extension-auditor.md | 0 .../opencode-mcp-integrator.md | 0 .../agents => agents}/opencode-packager.md | 0 .../opencode-plugin-engineer.md | 0 .../agents => agents}/opencode-publisher.md | 0 .../opencode-skill-creator.md | 0 .../opencode-tool-builder.md | 0 index.ts | 98 +------------------ package.json | 17 ++-- plugin.ts | 97 ++++++++++++++++++ {assets/references => references}/agents.md | 0 {assets/references => references}/commands.md | 0 {assets/references => references}/config.md | 0 .../conformance-checklist.md | 0 .../live-knowledge-fallback.md | 0 .../references => references}/mcp-servers.md | 0 .../opencode-architect-oneshots.md | 0 {assets/references => references}/plugins.md | 0 .../prompt-engineering.md | 0 {assets/references => references}/skills.md | 0 {assets/references => references}/tools.md | 0 agent-loader.ts => src/agent-loader.ts | 0 .../bundled-assets-missing-error.ts | 0 cache-cleaner.ts => src/cache-cleaner.ts | 0 .../clear-cache-usage-error.ts | 0 cli.ts => src/cli.ts | 2 +- .../copy-mode-unsupported-error.ts | 0 installer.ts => src/installer.ts | 4 +- .../permission-registrar.ts | 0 plugin-config.ts => src/plugin-config.ts | 0 .../templates => templates}/cli.template.txt | 0 .../index.template.txt | 0 .../installer.template.txt | 0 .../manifest.template.txt | 0 .../package-basics.template.json | 0 .../package-full.template.json | 0 .../plugin-config.template.txt | 0 .../plugin-local.template.txt | 0 .../plugin-name.template.txt | 0 .../prompts.template.txt | 0 .../registration.template.txt | 0 .../skill-structure.template.md | 0 .../tsconfig.template.json | 0 tests/agents.test.ts | 6 +- tests/cache-cleaner.test.ts | 2 +- tests/cli.test.ts | 2 +- tests/content-declaration.test.ts | 8 +- tests/deployment-plan.test.ts | 6 +- tests/installer.test.ts | 4 +- tests/load-hook.test.ts | 4 +- tests/permission-registrar.test.ts | 6 +- tests/plugin-config.test.ts | 2 +- tests/test-helpers.ts | 2 +- 58 files changed, 131 insertions(+), 135 deletions(-) rename {assets/agents => agents}/opencode-agent-designer.md (100%) rename {assets/agents => agents}/opencode-architect.md (100%) rename {assets/agents => agents}/opencode-command-crafter.md (100%) rename {assets/agents => agents}/opencode-extension-auditor.md (100%) rename {assets/agents => agents}/opencode-mcp-integrator.md (100%) rename {assets/agents => agents}/opencode-packager.md (100%) rename {assets/agents => agents}/opencode-plugin-engineer.md (100%) rename {assets/agents => agents}/opencode-publisher.md (100%) rename {assets/agents => agents}/opencode-skill-creator.md (100%) rename {assets/agents => agents}/opencode-tool-builder.md (100%) create mode 100644 plugin.ts rename {assets/references => references}/agents.md (100%) rename {assets/references => references}/commands.md (100%) rename {assets/references => references}/config.md (100%) rename {assets/references => references}/conformance-checklist.md (100%) rename {assets/references => references}/live-knowledge-fallback.md (100%) rename {assets/references => references}/mcp-servers.md (100%) rename {assets/references => references}/opencode-architect-oneshots.md (100%) rename {assets/references => references}/plugins.md (100%) rename {assets/references => references}/prompt-engineering.md (100%) rename {assets/references => references}/skills.md (100%) rename {assets/references => references}/tools.md (100%) rename agent-loader.ts => src/agent-loader.ts (100%) rename bundled-assets-missing-error.ts => src/bundled-assets-missing-error.ts (100%) rename cache-cleaner.ts => src/cache-cleaner.ts (100%) rename clear-cache-usage-error.ts => src/clear-cache-usage-error.ts (100%) rename cli.ts => src/cli.ts (99%) rename copy-mode-unsupported-error.ts => src/copy-mode-unsupported-error.ts (100%) rename installer.ts => src/installer.ts (99%) rename permission-registrar.ts => src/permission-registrar.ts (100%) rename plugin-config.ts => src/plugin-config.ts (100%) rename {assets/templates => templates}/cli.template.txt (100%) rename {assets/templates => templates}/index.template.txt (100%) rename {assets/templates => templates}/installer.template.txt (100%) rename {assets/templates => templates}/manifest.template.txt (100%) rename {assets/templates => templates}/package-basics.template.json (100%) rename {assets/templates => templates}/package-full.template.json (100%) rename {assets/templates => templates}/plugin-config.template.txt (100%) rename {assets/templates => templates}/plugin-local.template.txt (100%) rename {assets/templates => templates}/plugin-name.template.txt (100%) rename {assets/templates => templates}/prompts.template.txt (100%) rename {assets/templates => templates}/registration.template.txt (100%) rename {assets/templates => templates}/skill-structure.template.md (100%) rename {assets/templates => templates}/tsconfig.template.json (100%) diff --git a/.opencode/opencode.json b/.opencode/opencode.json index e913a75..935b625 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -1,6 +1,6 @@ { "$schema": "https://opencode.ai/config.json", "plugin": [ - "../index.ts", + "../index.ts" ] } \ No newline at end of file diff --git a/CONTEXT.md b/CONTEXT.md index e4871e0..3bb4e37 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -203,11 +203,11 @@ taking ownership of them is CLI-only with an explicit force flag. ### Bundled files **References**: -The static markdown guides bundled with the package at `assets/references/`, +The static markdown guides bundled with the package at `references/`, covering stable OpenCode fundamentals. **Templates**: -The static scaffolding files bundled with the package at `assets/templates/`, +The static scaffolding files bundled with the package at `templates/`, from which the packager and publisher render a generated package's code files (plugin entry, manifest, name normalizer, registration detector, CLI, installer). The structural source of truth for generated packages: example diff --git a/assets/agents/opencode-agent-designer.md b/agents/opencode-agent-designer.md similarity index 100% rename from assets/agents/opencode-agent-designer.md rename to agents/opencode-agent-designer.md diff --git a/assets/agents/opencode-architect.md b/agents/opencode-architect.md similarity index 100% rename from assets/agents/opencode-architect.md rename to agents/opencode-architect.md diff --git a/assets/agents/opencode-command-crafter.md b/agents/opencode-command-crafter.md similarity index 100% rename from assets/agents/opencode-command-crafter.md rename to agents/opencode-command-crafter.md diff --git a/assets/agents/opencode-extension-auditor.md b/agents/opencode-extension-auditor.md similarity index 100% rename from assets/agents/opencode-extension-auditor.md rename to agents/opencode-extension-auditor.md diff --git a/assets/agents/opencode-mcp-integrator.md b/agents/opencode-mcp-integrator.md similarity index 100% rename from assets/agents/opencode-mcp-integrator.md rename to agents/opencode-mcp-integrator.md diff --git a/assets/agents/opencode-packager.md b/agents/opencode-packager.md similarity index 100% rename from assets/agents/opencode-packager.md rename to agents/opencode-packager.md diff --git a/assets/agents/opencode-plugin-engineer.md b/agents/opencode-plugin-engineer.md similarity index 100% rename from assets/agents/opencode-plugin-engineer.md rename to agents/opencode-plugin-engineer.md diff --git a/assets/agents/opencode-publisher.md b/agents/opencode-publisher.md similarity index 100% rename from assets/agents/opencode-publisher.md rename to agents/opencode-publisher.md diff --git a/assets/agents/opencode-skill-creator.md b/agents/opencode-skill-creator.md similarity index 100% rename from assets/agents/opencode-skill-creator.md rename to agents/opencode-skill-creator.md diff --git a/assets/agents/opencode-tool-builder.md b/agents/opencode-tool-builder.md similarity index 100% rename from assets/agents/opencode-tool-builder.md rename to agents/opencode-tool-builder.md diff --git a/index.ts b/index.ts index c283ab3..17aaa50 100644 --- a/index.ts +++ b/index.ts @@ -1,97 +1 @@ -import type { Plugin } from "@opencode-ai/plugin"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { AgentLoader } from "./agent-loader"; -import { AssetPermissionRegistrar } from "./permission-registrar"; -import { Installer } from "./installer"; - -const PACKAGE_NAME = "opencode-architect"; - -export class OpencodeArchitectPlugin { - private readonly agentsDir: string; - private readonly assetsDir: string; - private readonly readVersion: () => Promise; - private readonly hasInstall: (projectDir: string) => Promise; - private failureAdvised = false; - private notInstalledAdvised = false; - - constructor( - agentsDir: string | null = null, - assetsDir: string | null = null, - readVersion: (() => Promise) | null = null, - hasInstall: ((projectDir: string) => Promise) | null = null, - ) { - this.agentsDir = agentsDir ?? path.join(import.meta.dirname, "assets", "agents"); - this.assetsDir = assetsDir ?? path.join(import.meta.dirname, "assets"); - this.readVersion = readVersion ?? (() => this.readPackageMetadata()); - this.hasInstall = hasInstall ?? ((projectDir) => new Installer().hasManifestAnywhere(projectDir)); - } - - public toPlugin(): Plugin { - return async (input) => { - const directory = typeof input?.directory === "string" ? input.directory : process.cwd(); - return { - config: async (config) => { - try { - const agents = await new AgentLoader(this.agentsDir).loadAgents(); - config.agent = config.agent || {}; - for (const [name, agentConfig] of Object.entries(agents)) { - config.agent[name] = agentConfig; - } - new AssetPermissionRegistrar(this.assetsDir).register(config); - await this.adviseNotInstalledOnce(directory); - } catch (error) { - await this.adviseFailureOnce(error instanceof Error ? error.message : String(error)); - } - }, - }; - }; - } - - private async adviseNotInstalledOnce(directory: string): Promise { - if (this.notInstalledAdvised) return; - if (await this.hasInstall(directory)) return; - this.notInstalledAdvised = true; - try { - console.log( - `[${PACKAGE_NAME}] Not installed in any scope. Run: bunx ${PACKAGE_NAME} install --scope global ` + - `(or: bunx ${PACKAGE_NAME} install).`, - ); - } catch {} - } - - private async adviseFailureOnce(message: string): Promise { - if (this.failureAdvised) return; - this.failureAdvised = true; - await this.emitAdvisory(message); - } - - private async emitAdvisory(message: string): Promise { - const prefix = - `Failed to load the bundled agent suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` + - `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}`; - } catch { - text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@. Cause: ${message}`; - } - try { - console.warn(`[${PACKAGE_NAME}] ${text}`); - } catch {} - } - - private async readPackageMetadata(): Promise { - const manifest = JSON.parse( - await readFile(path.join(import.meta.dirname, "package.json"), "utf-8"), - ) as { version: string }; - if (typeof manifest.version !== "string" || manifest.version.length === 0) { - throw new Error("unreadable package metadata"); - } - return manifest.version; - } -} - -const opencodeArchitect = new OpencodeArchitectPlugin(); - -export default opencodeArchitect.toPlugin(); +export { OpencodeArchitectPlugin, default } from "./plugin.ts"; diff --git a/package.json b/package.json index d3a6627..186f6c2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "type": "module", "module": "index.ts", "bin": { - "opencode-architect": "cli.ts" + "opencode-architect": "src/cli.ts" }, "repository": { "type": "git", @@ -45,16 +45,11 @@ }, "files": [ "index.ts", - "agent-loader.ts", - "cache-cleaner.ts", - "clear-cache-usage-error.ts", - "cli.ts", - "copy-mode-unsupported-error.ts", - "installer.ts", - "bundled-assets-missing-error.ts", - "plugin-config.ts", - "permission-registrar.ts", - "assets" + "plugin.ts", + "src", + "agents", + "references", + "templates" ], "dependencies": { "@opencode-ai/plugin": "*", diff --git a/plugin.ts b/plugin.ts new file mode 100644 index 0000000..3b40632 --- /dev/null +++ b/plugin.ts @@ -0,0 +1,97 @@ +import type { Plugin } from "@opencode-ai/plugin"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { AgentLoader } from "./src/agent-loader"; +import { AssetPermissionRegistrar } from "./src/permission-registrar"; +import { Installer } from "./src/installer"; + +const PACKAGE_NAME = "opencode-architect"; + +export class OpencodeArchitectPlugin { + private readonly agentsDir: string; + private readonly assetsDir: string; + private readonly readVersion: () => Promise; + private readonly hasInstall: (projectDir: string) => Promise; + private failureAdvised = false; + private notInstalledAdvised = false; + + constructor( + agentsDir: string | null = null, + assetsDir: string | null = null, + readVersion: (() => Promise) | null = null, + hasInstall: ((projectDir: string) => Promise) | null = null, + ) { + this.agentsDir = agentsDir ?? path.join(import.meta.dirname, "agents"); + this.assetsDir = assetsDir ?? import.meta.dirname; + this.readVersion = readVersion ?? (() => this.readPackageMetadata()); + this.hasInstall = hasInstall ?? ((projectDir) => new Installer().hasManifestAnywhere(projectDir)); + } + + public toPlugin(): Plugin { + return async (input) => { + const directory = typeof input?.directory === "string" ? input.directory : process.cwd(); + return { + config: async (config) => { + try { + const agents = await new AgentLoader(this.agentsDir).loadAgents(); + config.agent = config.agent || {}; + for (const [name, agentConfig] of Object.entries(agents)) { + config.agent[name] = agentConfig; + } + new AssetPermissionRegistrar(this.assetsDir).register(config); + await this.adviseNotInstalledOnce(directory); + } catch (error) { + await this.adviseFailureOnce(error instanceof Error ? error.message : String(error)); + } + }, + }; + }; + } + + private async adviseNotInstalledOnce(directory: string): Promise { + if (this.notInstalledAdvised) return; + if (await this.hasInstall(directory)) return; + this.notInstalledAdvised = true; + try { + console.log( + `[${PACKAGE_NAME}] Not installed in any scope. Run: bunx ${PACKAGE_NAME} install --scope global ` + + `(or: bunx ${PACKAGE_NAME} install).`, + ); + } catch {} + } + + private async adviseFailureOnce(message: string): Promise { + if (this.failureAdvised) return; + this.failureAdvised = true; + await this.emitAdvisory(message); + } + + private async emitAdvisory(message: string): Promise { + const prefix = + `Failed to load the bundled agent suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` + + `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}`; + } catch { + text = `${prefix}~/.cache/opencode/packages/${PACKAGE_NAME}@. Cause: ${message}`; + } + try { + console.warn(`[${PACKAGE_NAME}] ${text}`); + } catch {} + } + + private async readPackageMetadata(): Promise { + const manifest = JSON.parse( + await readFile(path.join(import.meta.dirname, "package.json"), "utf-8"), + ) as { version: string }; + if (typeof manifest.version !== "string" || manifest.version.length === 0) { + throw new Error("unreadable package metadata"); + } + return manifest.version; + } +} + +const opencodeArchitect = new OpencodeArchitectPlugin(); + +export default opencodeArchitect.toPlugin(); diff --git a/assets/references/agents.md b/references/agents.md similarity index 100% rename from assets/references/agents.md rename to references/agents.md diff --git a/assets/references/commands.md b/references/commands.md similarity index 100% rename from assets/references/commands.md rename to references/commands.md diff --git a/assets/references/config.md b/references/config.md similarity index 100% rename from assets/references/config.md rename to references/config.md diff --git a/assets/references/conformance-checklist.md b/references/conformance-checklist.md similarity index 100% rename from assets/references/conformance-checklist.md rename to references/conformance-checklist.md diff --git a/assets/references/live-knowledge-fallback.md b/references/live-knowledge-fallback.md similarity index 100% rename from assets/references/live-knowledge-fallback.md rename to references/live-knowledge-fallback.md diff --git a/assets/references/mcp-servers.md b/references/mcp-servers.md similarity index 100% rename from assets/references/mcp-servers.md rename to references/mcp-servers.md diff --git a/assets/references/opencode-architect-oneshots.md b/references/opencode-architect-oneshots.md similarity index 100% rename from assets/references/opencode-architect-oneshots.md rename to references/opencode-architect-oneshots.md diff --git a/assets/references/plugins.md b/references/plugins.md similarity index 100% rename from assets/references/plugins.md rename to references/plugins.md diff --git a/assets/references/prompt-engineering.md b/references/prompt-engineering.md similarity index 100% rename from assets/references/prompt-engineering.md rename to references/prompt-engineering.md diff --git a/assets/references/skills.md b/references/skills.md similarity index 100% rename from assets/references/skills.md rename to references/skills.md diff --git a/assets/references/tools.md b/references/tools.md similarity index 100% rename from assets/references/tools.md rename to references/tools.md diff --git a/agent-loader.ts b/src/agent-loader.ts similarity index 100% rename from agent-loader.ts rename to src/agent-loader.ts diff --git a/bundled-assets-missing-error.ts b/src/bundled-assets-missing-error.ts similarity index 100% rename from bundled-assets-missing-error.ts rename to src/bundled-assets-missing-error.ts diff --git a/cache-cleaner.ts b/src/cache-cleaner.ts similarity index 100% rename from cache-cleaner.ts rename to src/cache-cleaner.ts diff --git a/clear-cache-usage-error.ts b/src/clear-cache-usage-error.ts similarity index 100% rename from clear-cache-usage-error.ts rename to src/clear-cache-usage-error.ts diff --git a/cli.ts b/src/cli.ts similarity index 99% rename from cli.ts rename to src/cli.ts index 4d5c9ff..c86ca21 100644 --- a/cli.ts +++ b/src/cli.ts @@ -4,7 +4,7 @@ import { Installer, type Scope } from "./installer"; import { CacheCleaner } from "./cache-cleaner"; import { ClearCacheUsageError } from "./clear-cache-usage-error"; -const VERSION = (JSON.parse(await Bun.file(`${import.meta.dirname}/package.json`).text()) as { version: string }).version; +const VERSION = (JSON.parse(await Bun.file(`${import.meta.dirname}/../package.json`).text()) as { version: string }).version; async function main(): Promise { const { positionals, values } = parseArgs({ diff --git a/copy-mode-unsupported-error.ts b/src/copy-mode-unsupported-error.ts similarity index 100% rename from copy-mode-unsupported-error.ts rename to src/copy-mode-unsupported-error.ts diff --git a/installer.ts b/src/installer.ts similarity index 99% rename from installer.ts rename to src/installer.ts index 6424af6..c69aa3e 100644 --- a/installer.ts +++ b/src/installer.ts @@ -69,7 +69,7 @@ export class Installer { private readonly assetsDir: string; constructor(assetsDir: string | null = null) { - this.assetsDir = assetsDir ?? path.join(import.meta.dirname, "assets"); + this.assetsDir = assetsDir ?? path.join(import.meta.dirname, ".."); } public async install(scope: Scope, options: InstallOptions): Promise { @@ -337,7 +337,7 @@ export class Installer { } private async getPackageVersion(): Promise { - const content = await readFile(path.join(import.meta.dirname, "package.json"), "utf-8"); + const content = await readFile(path.join(import.meta.dirname, "..", "package.json"), "utf-8"); return (JSON.parse(content) as { version: string }).version; } diff --git a/permission-registrar.ts b/src/permission-registrar.ts similarity index 100% rename from permission-registrar.ts rename to src/permission-registrar.ts diff --git a/plugin-config.ts b/src/plugin-config.ts similarity index 100% rename from plugin-config.ts rename to src/plugin-config.ts diff --git a/assets/templates/cli.template.txt b/templates/cli.template.txt similarity index 100% rename from assets/templates/cli.template.txt rename to templates/cli.template.txt diff --git a/assets/templates/index.template.txt b/templates/index.template.txt similarity index 100% rename from assets/templates/index.template.txt rename to templates/index.template.txt diff --git a/assets/templates/installer.template.txt b/templates/installer.template.txt similarity index 100% rename from assets/templates/installer.template.txt rename to templates/installer.template.txt diff --git a/assets/templates/manifest.template.txt b/templates/manifest.template.txt similarity index 100% rename from assets/templates/manifest.template.txt rename to templates/manifest.template.txt diff --git a/assets/templates/package-basics.template.json b/templates/package-basics.template.json similarity index 100% rename from assets/templates/package-basics.template.json rename to templates/package-basics.template.json diff --git a/assets/templates/package-full.template.json b/templates/package-full.template.json similarity index 100% rename from assets/templates/package-full.template.json rename to templates/package-full.template.json diff --git a/assets/templates/plugin-config.template.txt b/templates/plugin-config.template.txt similarity index 100% rename from assets/templates/plugin-config.template.txt rename to templates/plugin-config.template.txt diff --git a/assets/templates/plugin-local.template.txt b/templates/plugin-local.template.txt similarity index 100% rename from assets/templates/plugin-local.template.txt rename to templates/plugin-local.template.txt diff --git a/assets/templates/plugin-name.template.txt b/templates/plugin-name.template.txt similarity index 100% rename from assets/templates/plugin-name.template.txt rename to templates/plugin-name.template.txt diff --git a/assets/templates/prompts.template.txt b/templates/prompts.template.txt similarity index 100% rename from assets/templates/prompts.template.txt rename to templates/prompts.template.txt diff --git a/assets/templates/registration.template.txt b/templates/registration.template.txt similarity index 100% rename from assets/templates/registration.template.txt rename to templates/registration.template.txt diff --git a/assets/templates/skill-structure.template.md b/templates/skill-structure.template.md similarity index 100% rename from assets/templates/skill-structure.template.md rename to templates/skill-structure.template.md diff --git a/assets/templates/tsconfig.template.json b/templates/tsconfig.template.json similarity index 100% rename from assets/templates/tsconfig.template.json rename to templates/tsconfig.template.json diff --git a/tests/agents.test.ts b/tests/agents.test.ts index f132e92..5252e0b 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -2,9 +2,9 @@ import { describe, expect, test } from "bun:test"; import { existsSync, readFileSync, readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; -import { AGENT_FILENAMES, AgentLoader } from "../agent-loader"; +import { AGENT_FILENAMES, AgentLoader } from "../src/agent-loader"; -const AGENTS_DIR = path.resolve(import.meta.dirname, "..", "assets", "agents"); +const AGENTS_DIR = path.resolve(import.meta.dirname, "..", "agents"); const RELATIVE_REFERENCE_REGEX = /`((?:\.{1,2})(?:[\\/][^`\\/]+)+)`/g; const ABSOLUTE_PATH_REGEX = /`([A-Za-z]:[\\/][^`]+|\/[^`]+)`/g; @@ -30,7 +30,7 @@ function frontmatterLines(content: string): string[] { } function listShippedMarkdown(): string[] { - const assetsRoot = path.resolve(import.meta.dirname, "..", "assets"); + const assetsRoot = path.resolve(import.meta.dirname, ".."); return readdirSync(assetsRoot, { recursive: true }) .map(String) .filter((relative) => relative.endsWith(".md")) diff --git a/tests/cache-cleaner.test.ts b/tests/cache-cleaner.test.ts index 2011957..5dcdfc5 100644 --- a/tests/cache-cleaner.test.ts +++ b/tests/cache-cleaner.test.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { chmod, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { CacheCleaner } from "../cache-cleaner"; +import { CacheCleaner } from "../src/cache-cleaner"; import { seedCachedPackage, expectClearCacheUsageError } from "./test-helpers"; let cacheDir = ""; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index fd77c11..eb62631 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -6,7 +6,7 @@ import path from "node:path"; import { seedCachedPackage } from "./test-helpers"; const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); -const CLI_PATH = path.join(PACKAGE_ROOT, "cli.ts"); +const CLI_PATH = path.join(PACKAGE_ROOT, "src", "cli.ts"); interface CliRun { exitCode: number; diff --git a/tests/content-declaration.test.ts b/tests/content-declaration.test.ts index b55fee8..df1501c 100644 --- a/tests/content-declaration.test.ts +++ b/tests/content-declaration.test.ts @@ -10,8 +10,8 @@ const readRepoFile = (relativePath: string): Promise => describe("content declaration", () => { test("both package.json templates declare the content field", async () => { for (const template of [ - "assets/templates/package-basics.template.json", - "assets/templates/package-full.template.json", + "templates/package-basics.template.json", + "templates/package-full.template.json", ]) { const source = await readRepoFile(template); const body = source.split("---")[1] ?? ""; @@ -21,13 +21,13 @@ describe("content declaration", () => { }); test("packager derives the declaration from its inventory", async () => { - const source = await readRepoFile("assets/agents/opencode-packager.md"); + const source = await readRepoFile("agents/opencode-packager.md"); expect(source).toContain('"content": "assets"'); expect(source).toContain('"code"'); }); test("publisher verifies and carries the declaration", async () => { - const source = await readRepoFile("assets/agents/opencode-publisher.md"); + const source = await readRepoFile("agents/opencode-publisher.md"); expect(source).toContain("content` declaration"); expect(source).toContain('declares `"content"`'); }); diff --git a/tests/deployment-plan.test.ts b/tests/deployment-plan.test.ts index b9b2d2d..727cf74 100644 --- a/tests/deployment-plan.test.ts +++ b/tests/deployment-plan.test.ts @@ -298,14 +298,14 @@ describe("packager self-audit gate", () => { }); async function readTemplate(name: string): Promise { - const source = await readFile(path.join(REPO_ROOT, "assets/templates", name), "utf-8"); + const source = await readFile(path.join(REPO_ROOT, "templates", name), "utf-8"); return source.split("---").slice(1).join("---"); } async function readAgent(name: string): Promise { - return readFile(path.join(REPO_ROOT, "assets/agents", name), "utf-8"); + return readFile(path.join(REPO_ROOT, "agents", name), "utf-8"); } async function readReference(name: string): Promise { - return readFile(path.join(REPO_ROOT, "assets/references", name), "utf-8"); + return readFile(path.join(REPO_ROOT, "references", name), "utf-8"); } diff --git a/tests/installer.test.ts b/tests/installer.test.ts index be9a82d..0a0d8a3 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -3,8 +3,8 @@ import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { Installer, contentHash, type Manifest, type Scope } from "../installer"; -import { CopyModeUnsupportedError } from "../copy-mode-unsupported-error"; +import { Installer, contentHash, type Manifest, type Scope } from "../src/installer"; +import { CopyModeUnsupportedError } from "../src/copy-mode-unsupported-error"; const PACKAGE_ROOT = path.resolve(import.meta.dirname, ".."); diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts index 4838e4a..5595fde 100644 --- a/tests/load-hook.test.ts +++ b/tests/load-hook.test.ts @@ -8,8 +8,8 @@ import { OpencodeArchitectPlugin } from "../index"; import { captureConsole } from "./test-helpers"; const REPO_ROOT = path.resolve(import.meta.dirname, ".."); -const REAL_AGENTS_DIR = path.join(REPO_ROOT, "assets", "agents"); -const REAL_ASSETS_DIR = path.join(REPO_ROOT, "assets"); +const REAL_AGENTS_DIR = path.join(REPO_ROOT, "agents"); +const REAL_ASSETS_DIR = REPO_ROOT; type PluginHooks = Awaited>; diff --git a/tests/permission-registrar.test.ts b/tests/permission-registrar.test.ts index 3e8855f..49792bb 100644 --- a/tests/permission-registrar.test.ts +++ b/tests/permission-registrar.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; import type { Config } from "@opencode-ai/plugin"; -import { AssetPermissionRegistrar } from "../permission-registrar"; +import { AssetPermissionRegistrar } from "../src/permission-registrar"; -const ASSETS_DIR = path.resolve(import.meta.dirname, "..", "assets"); +const ASSETS_DIR = path.resolve(import.meta.dirname, ".."); const ASSETS_PATTERN = path.join(ASSETS_DIR, "*").replaceAll("\\", "/"); function configWithPermission(value: unknown): Config { @@ -68,6 +68,6 @@ describe("AssetPermissionRegistrar", () => { const pattern = Object.keys(externalDirectory(config) as Record)[0] ?? ""; expect(pattern.includes("\\")).toBe(false); - expect(pattern.endsWith("/assets/*")).toBe(true); + expect(pattern.endsWith("/*")).toBe(true); }); }); diff --git a/tests/plugin-config.test.ts b/tests/plugin-config.test.ts index e2731d3..c7c5cff 100644 --- a/tests/plugin-config.test.ts +++ b/tests/plugin-config.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { PluginConfigEditor } from "../plugin-config"; +import { PluginConfigEditor } from "../src/plugin-config"; import { captureConsole } from "./test-helpers"; const ROOT = path.join(import.meta.dirname, "..", ".tmp-plugin-config-test"); diff --git a/tests/test-helpers.ts b/tests/test-helpers.ts index 610d8b6..e10f653 100644 --- a/tests/test-helpers.ts +++ b/tests/test-helpers.ts @@ -1,7 +1,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, spyOn } from "bun:test"; -import { ClearCacheUsageError } from "../clear-cache-usage-error"; +import { ClearCacheUsageError } from "../src/clear-cache-usage-error"; export interface CapturedConsole { lines: string[]; From dd19beb451fd034eae1fa850f4cd552261f6b724 Mon Sep 17 00:00:00 2001 From: "Diego B." Date: Fri, 2 Oct 2026 22:45:49 -0400 Subject: [PATCH 21/21] refactor(conformity): runCli export and index.ts dispatch shim (template parity) Mirrors the generated-package entry convention: src/cli.ts exports runCli(argv) returning an exit code under an import.meta.main guard, and index.ts dispatches to it so bun run index.ts install works the way the packager's verify steps prescribe, while remaining the plugin re-export surface. --- index.ts | 5 +++++ src/cli.ts | 25 +++++++++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/index.ts b/index.ts index 17aaa50..eaec43a 100644 --- a/index.ts +++ b/index.ts @@ -1 +1,6 @@ export { OpencodeArchitectPlugin, default } from "./plugin.ts"; + +if (import.meta.main) { + const { runCli } = await import("./src/cli.ts"); + process.exitCode = await runCli(process.argv.slice(2)); +} diff --git a/src/cli.ts b/src/cli.ts index c86ca21..e86b9ab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,8 +6,9 @@ import { ClearCacheUsageError } from "./clear-cache-usage-error"; const VERSION = (JSON.parse(await Bun.file(`${import.meta.dirname}/../package.json`).text()) as { version: string }).version; -async function main(): Promise { +export async function runCli(argv: string[]): Promise { const { positionals, values } = parseArgs({ + args: argv, options: { scope: { type: "string", short: "s" }, mode: { type: "string", short: "m" }, @@ -25,23 +26,23 @@ async function main(): Promise { if (values.version) { console.log(`opencode-architect v${VERSION}`); - return; + return 0; } if (values.help || positionals.length === 0) { printHelp(); - return; + return 0; } const command = positionals[0]; const scopeInput = values.scope; if (scopeInput !== undefined && scopeInput !== "local" && scopeInput !== "global") { console.error(`Invalid scope: ${scopeInput}. Must be "local" or "global".`); - process.exit(1); + return 1; } const scope: Scope = scopeInput === "global" ? "global" : "local"; if (values.mode !== undefined && values.mode !== "plugin" && values.mode !== "copy") { console.error(`Invalid mode: ${values.mode}. Must be "plugin" or "copy".`); - process.exit(1); + return 1; } const mode: "plugin" | "copy" = values.mode === "copy" ? "copy" : "plugin"; const installer = new Installer(); @@ -93,7 +94,7 @@ async function main(): Promise { case "clear-cache": { if (positionals.length > 1) { console.error(`Unexpected arguments for clear-cache: ${positionals.slice(1).join(" ")}`); - process.exit(1); + return 1; } let outcome; try { @@ -101,7 +102,7 @@ async function main(): Promise { } catch (error) { if (error instanceof ClearCacheUsageError) { console.error(error.message); - process.exit(1); + return 1; } throw error; } @@ -122,15 +123,20 @@ async function main(): Promise { default: console.error(`Unknown command: ${command}`); printHelp(); - process.exit(1); + return 1; } + return 0; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); - process.exit(1); + return 1; } } +if (import.meta.main) { + process.exitCode = await runCli(process.argv.slice(2)); +} + function printHelp(): void { console.log(` opencode-architect v${VERSION} @@ -173,4 +179,3 @@ Examples: `); } -main();