diff --git a/AGENTS.md b/AGENTS.md
index f190eb4..45beee5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,7 +27,7 @@ When encountering file references (e.g., @references/workflow.md), use Read tool
index.ts
- agent registration from bundled assets on startup
+ agent, skill, and command registration from bundled assets on startup
ESM + type declarations
@@ -49,6 +49,10 @@ When encountering file references (e.g., @references/workflow.md), use Read tool
- Delegate to `opencode-publisher`, fed by the packager's output
- Transforms to npm-ready structure, adds CLI entry point, extracts installer module
+
+ - Delegate from `architect` to the bundled `opencode-v2-upgrade` skill
+ - Inventories extensions, ports v1 plugin code and v1 tool files, rewrites configs to v2-native keys, and reports v2 recommendations; no-ops on already-v2 projects, skips consumer-modified files, and never touches the OpenCode app installation
+
diff --git a/CONTEXT.md b/CONTEXT.md
index eabac12..382aa3b 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -103,6 +103,17 @@ publishes it.
The reference routing examples (request → analysis → agent selection →
execution order) the architect reads before delegating.
+**Upgrade skill**:
+The bundled `opencode-v2-upgrade` SKILL.md the plugin registers at load. It
+defines the one-shot consumer v1→v2 upgrade: inventory, plugin and tool port,
+config rewrite, and a recommendations report.
+_Avoid_: migration guide (it is a procedure, not prose documentation)
+
+**Upgrade command**:
+The bundled `upgrade-opencode-v2` slash command the plugin registers at load.
+It submits the upgrade prompt and attaches the upgrade skill.
+_Avoid_: upgrade tool
+
### Distribution
**Consumer**:
diff --git a/README.md b/README.md
index 77d2846..26e844b 100644
--- a/README.md
+++ b/README.md
@@ -74,6 +74,13 @@ Also bundled and installed with the agents:
- **References** — self-contained docs covering stable OpenCode fundamentals: agents, commands, config, MCP servers, plugins, prompt engineering, skills, tools, plus worked one-shot examples
- **Templates** — starter files for new skills, plugins, package manifests, and TypeScript configs
+- **Upgrade skill and command** — the `opencode-v2-upgrade` skill plus the `/upgrade-opencode-v2` slash command, registered at load
+
+## One-shot upgrade from OpenCode v1 to v2
+
+Say "upgrade my plugin/extensions package to opencode v2" (or run `/upgrade-opencode-v2`) and the suite upgrades one project in a single pass: it inventories your extensions, ports v1 plugin files to the Effect-first v2 plugin API, ports v1 file-based tool files to plugin-registered tools, rewrites your configs to v2-native keys, and finishes with a report recommending v2 capabilities to adopt — richer session hooks, plugin RPC, TUI plugins, MCP Code Mode, and saved approvals.
+
+The upgrade is safe by construction: an already-v2 project is a clean no-op, files you modified after install are skipped with a warning rather than clobbered, and the OpenCode application installation is never touched. The bundled `opencode-v2-upgrade` skill defines the full procedure and every phase's completion criteria.
## When to use these OpenCode agents
diff --git a/agents/opencode-architect.md b/agents/opencode-architect.md
index 17982ee..1c82d47 100644
--- a/agents/opencode-architect.md
+++ b/agents/opencode-architect.md
@@ -25,6 +25,7 @@ Before any routing decision, read `../references/opencode-architect-oneshots.md`
Route by first match in priority order, delegating through the task tool:
1. Explicit request for an agent: obey the user's choice.
+1b. Upgrade an existing project or package from OpenCode v1 to v2 ("upgrade my plugin/extensions package to opencode v2", or the `/upgrade-opencode-v2` command): load the bundled upgrade skill `../skills/opencode-v2-upgrade/SKILL.md` and follow it end to end, delegating each phase to the named specialist (auditor for the inventory, plugin-engineer for the plugin port, tool-builder for the v1 tool-file port); the config rewrite and the recommendations report are the skill's own steps.
2. Create or refine agent definitions and prompts: opencode-agent-designer.
3. Analyze `.opencode/` contents or packaging readiness: opencode-extension-auditor.
3b. Assess an existing built package for conformance to this suite's design ("is this aligned with opencode-architect guidance?", "assess conformance to best practice", "does it account for the manifest implementation?"): opencode-extension-auditor, prompted for a conformance review of the named package path against `../references/conformance-checklist.md`, reporting item verdicts with file:line evidence. Preflight: the prompt requires the auditor to report the absolute path + version of the criteria copy it resolved, and to refuse a Conformant verdict when that copy is stale relative to this suite's repo.
@@ -93,6 +94,7 @@ Bundled reference files are addressed relative to this agent file's own director
- Use `../references/mcp-servers.md` for MCP configuration and scoping.
- Use `../references/config.md` for config precedence and schema options.
- Use `../references/prompt-engineering.md` for prompt engineering and skill-authoring techniques.
+- Use `../skills/opencode-v2-upgrade/SKILL.md` for the one-shot v1→v2 consumer upgrade; follow it end to end when the request is an upgrade.
## Reference resolution
diff --git a/commands/upgrade-opencode-v2.md b/commands/upgrade-opencode-v2.md
new file mode 100644
index 0000000..a58d05a
--- /dev/null
+++ b/commands/upgrade-opencode-v2.md
@@ -0,0 +1,16 @@
+---
+description: "Upgrade this project's OpenCode extensions to v2 — inventory, plugin and tool port, config rewrite, and a v2 recommendations report"
+---
+
+Act as `opencode-architect` and run the suite's one-shot OpenCode v1 → v2
+upgrade for this project. Load and follow the `opencode-v2-upgrade` skill end
+to end: inventory the extensions, port every v1 plugin file to the
+Effect-first v2 plugin API, port v1 file-based tool files to plugin-registered
+tools, rewrite the configs to v2-native keys per the verified mapping, and
+finish with the v2 capability recommendations report.
+
+Honor the skill's safety contract: no-op on an already-v2 project, skip
+consumer-modified files with a warning, and never touch the OpenCode
+application installation.
+
+$ARGUMENTS
diff --git a/package.json b/package.json
index 899e0b4..75f6226 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,9 @@
"src",
"agents",
"references",
- "templates"
+ "templates",
+ "skills",
+ "commands"
],
"dependencies": {
"@opencode/plugin": "2.0.23",
diff --git a/references/opencode-architect-oneshots.md b/references/opencode-architect-oneshots.md
index 899ed02..7ff46d8 100644
--- a/references/opencode-architect-oneshots.md
+++ b/references/opencode-architect-oneshots.md
@@ -317,3 +317,39 @@ export default Plugin.define({
3. Sequential: `opencode-packager` (package for local sharing first)
4. Ask: "Package ready. Publish to npm?"
5. If yes, Sequential: `opencode-publisher` (transform and publish)
+
+---
+
+## Example 10: One-Shot v1 → v2 Consumer Upgrade
+
+**User Request:**
+> Upgrade my plugin/extensions package to opencode v2.
+
+**Analysis:**
+- Whole-project v1→v2 upgrade → the bundled `opencode-v2-upgrade` skill
+ (registered by this package at load), executed by the architect and its
+ specialists
+- Inventory → `opencode-extension-auditor`
+- Plugin port → `opencode-plugin-engineer`
+- V1 tool-file port → `opencode-tool-builder`
+- Config rewrite and recommendations report → the skill's own steps
+
+**Execution:**
+1. Load `opencode-v2-upgrade` (SKILL.md) and run it end to end.
+2. Sequential: `opencode-extension-auditor` — inventory skills, commands,
+ agents, plugins, v1 file-based tool files, and configs (facts §12).
+3. If the inventory finds no v1 remnant: report the clean no-op and stop.
+4. Sequential: `opencode-plugin-engineer` — port v1 plugin files to the
+ Effect-first v2 plugin API (facts §1, §2, §9).
+5. Sequential: `opencode-tool-builder` — port v1 file-based tool files to
+ plugin-registered tools (facts §8).
+6. Rewrite each config to v2-native keys per the verified mapping
+ (facts §5, §6).
+7. Report: consumer-modified files skipped by manifest hash, then the v2
+ recommendations — session hooks, plugin RPC, TUI plugins, MCP Code Mode,
+ saved approvals (facts §4, §9, §10, §11).
+
+Honor the safety contract throughout: a clean no-op on an already-v2
+project, modified files skipped with a warning rather than clobbered, and the
+OpenCode application installation never touched (facts §7).
+
diff --git a/skills/opencode-v2-upgrade/SKILL.md b/skills/opencode-v2-upgrade/SKILL.md
new file mode 100644
index 0000000..3b01eb6
--- /dev/null
+++ b/skills/opencode-v2-upgrade/SKILL.md
@@ -0,0 +1,174 @@
+---
+name: "opencode-v2-upgrade"
+description: "Upgrades a consumer's OpenCode v1 extension package or .opencode/ tree to OpenCode v2 in one pass: extension inventory, plugin code port to the Effect-first plugin API, v1 tool-file port to plugin-registered tools, config rewrite to v2-native keys, and a v2 capability recommendations report. Use when the user asks to upgrade, migrate, or modernize a plugin, custom tools, or an extensions package from OpenCode v1 to v2."
+---
+
+# OpenCode v2 one-shot consumer upgrade
+
+Run a v1 → v2 upgrade for one project in a single pass. Every opencode API
+claim below traces to the suite's verified-facts record (cited as "facts §n");
+the mapping and rules in this skill are self-contained, so cite the fact
+number rather than restating fragile API details. The facts record itself
+lives in the suite repository as `opencode-v2-facts.md` — a maintainer source
+of truth, not a consumer runtime dependency.
+
+## Done when
+
+- An inventory of the project's extensions exists, each classified v1 or v2.
+- Every v1 plugin file runs under the v2 Effect-first plugin API.
+- Every v1 file-based tool file is a plugin-registered tool.
+- Every consumer config is rewritten to v2-native keys per the mapping below.
+- A recommendations report names the v2 capabilities worth adopting.
+- Already-v2 projects are left byte-for-byte untouched (a clean no-op).
+
+## Safety contract (non-negotiable)
+
+1. **Never touch the OpenCode application installation.** Do not install,
+ upgrade, pin, or remove the `opencode` binary, its global cache, or its
+ npm package. The upgrade changes the consumer's project only (facts §7).
+2. **No-op on already-v2 projects.** If the inventory finds no v1 remnant,
+ stop and report zero changes. Re-running the upgrade is always safe
+ (facts §5, §6, §9).
+3. **Never clobber consumer-modified files.** Before rewriting any file the
+ suite's manifest tracks (`/.manifest.json`), compare
+ its current sha256 against the recorded hash. On mismatch, skip the file
+ with a warning and list it in the report; taking ownership is CLI-only
+ behind an explicit `--force` (facts §5, §13 row 10). Manifest rules are
+ the source of truth: version match alone is not enough.
+4. **Preserve what you cannot parse.** A config file that fails to parse is
+ reported and left byte-for-byte; never rewrite it from an empty object
+ (facts §5, §13 row 3).
+5. **Surgical config writes.** Splice v2 keys into place; never
+ parse-and-reserialize a file with comments or custom formatting.
+6. **Legacy entries are read-only.** A singular v1 `plugin` entry — and the
+ inert legacy `config.json` candidate — are reported with an upgrade
+ advisory, never silently rewritten (facts §13 row 3).
+
+## Phase 1 — Inventory (extension auditor)
+
+Delegate to `opencode-extension-auditor` in inventory mode. It scans the
+discovered config roots for both directory spellings (facts §12):
+
+- skills: `skill/` + `skills/` (a `SKILL.md` per directory)
+- commands: `command/` + `commands/`
+- agents: `agent/` + `agents/`
+- plugins: `plugin/` + `plugins/` (TypeScript or JavaScript)
+- configs: `opencode.json` / `opencode.jsonc` at the global and project roots
+- v1 file-based tool files: v1 `.opencode/tools/*.ts` (no v2 equivalent)
+
+Record per extension: type, name, path, v1 or v2, and the specific v1
+remnants found. The auditor's report is the input to every later phase.
+
+## Phase 2 — Classify the project
+
+Stop here when the auditor reports no v1 remnant: report the clean no-op and
+list what made the project v2-native. Classify every remaining finding by the
+mapping tables below, then run phases 3–5.
+
+## Phase 3 — Config rewrite (verified mapping)
+
+Rewrite each config to v2-native keys. All facts §5 unless noted.
+
+| v1 key | v2 destination |
+| --- | --- |
+| `plugin` (singular) | `plugins` (array: `name@latest` strings or `{ package, options }`) |
+| `permission` (keyed record) | `permissions` (ordered `{ action, resource, effect }` ruleset array, facts §4) |
+| `tools` (config map) | `permissions` rules (allow/deny per tool) |
+| `agent` | `agents` |
+| `mode` (config block) | merged into `agents` |
+| `command` | `commands` |
+| `mcp` command string | `mcp..command` array; oauth to snake_case; timeout split (facts §10) |
+| `skills` (`{ paths, urls }`) | `skills` (string array) |
+| `autoupdate` | `update` (`disable`/`notify`/`auto`) |
+| `autoshare` | `share` (`manual`/`auto`/`disabled`) |
+| `snapshot` | `snapshots` |
+| `attachment` | `media` |
+| `reference` | `references` |
+| `small_model` | `agents.title.model` |
+| `enabled_providers` / `disabled_providers` | `experimental` permission rules on the `provider.use` action |
+| a `provider/model` model string | `{ "providerID": "...", "model": "...", "variant": "..." }` |
+
+Dropped v1 keys (`logLevel`, `server`, `layout`) are removed as unsupported
+top-level keys. Legacy `config.json` is never read in v2: report an entry
+there as inert and point the consumer at `opencode.json(c)`.
+
+Agent frontmatter is migrated, not dropped (facts §6):
+
+| v1 frontmatter | v2 destination |
+| --- | --- |
+| `prompt` | `system` (the markdown body) |
+| `model` + `variant` | a `{ providerID, model, variant? }` selection object |
+| `temperature`, `top_p`, other provider keys | `request` (passthrough into the request body) |
+| `tools` boolean map | `permissions` rules (`edit` gates write/edit/patch) |
+| `permission` keyed record | `permissions` ruleset array |
+| `maxSteps` | `steps` |
+| `disable` | `disabled` |
+| theme-name `color` | hex only |
+
+## Phase 4 — Port plugin code (plugin engineer)
+
+Delegate to `opencode-plugin-engineer` for every v1 plugin file. The port
+target is the Effect-first v2 plugin API (facts §1, §2, §9):
+
+- Author against `@opencode/plugin`; import `Plugin` from
+ `@opencode/plugin/effect`. Default-export
+ `Plugin.define({ id, effect(ctx) {...} })`; the Promise root is the
+ documented fallback for trivial plugins. The `id` is load-bearing.
+- Replace the returned hooks object with per-domain `transform(...)` and
+ `hook(name, callback)` registrations, and the v1 `event` catch-all with
+ `ctx.event.subscribe()` (facts §9). Cite the hook-family map instead of
+ restating each signature.
+- Replace the v1 custom-tool helper with `ctx.tool.transform` +
+ `editor.add(...)` (facts §8).
+- Declare `exports["./server"]` in any distributed package (facts §7, §13
+ row 9). Consumer-facing versions resolve `@latest`, never pinned
+ (facts §15).
+- Preserve the startup non-interference invariant: hooks never throw
+ (facts §2, §7).
+
+## Phase 5 — Port v1 tool files (tool builder)
+
+Delegate to `opencode-tool-builder` for every v1 file-based tool file (v1
+`.opencode/tools/*.ts`). v1 file-based tool definitions have no v2
+equivalent: map each to a plugin-registered tool via `ctx.tool.transform`,
+with `input` as raw JSON Schema, an Effect codec, or a Standard Schema
+validator (facts §8). The v1 `tool.schema` helper style is gone. Tools live
+in the plugin package, not as standalone files.
+
+## Phase 6 — Recommendations report
+
+Finish with a report that tells the consumer what v2 buys them beyond
+parity. Enumerate at least these, each with a one-line rationale and a
+pointer to the fact:
+
+- **Richer session hooks** — per-request-kind hooks for context, compaction,
+ generate, and title, plus retry and native HTTP/WebSocket hooks (facts §9).
+- **Plugin RPC** — `ctx.rpc.register` contracts with an optional `./rpc`
+ export for typed client calls (facts §11).
+- **TUI plugins** — a `./tui` export with Solid helpers for terminal UI
+ extensions (facts §11).
+- **MCP Code Mode** — `codemode` on local and remote MCP server config, and
+ per-tool Code Mode exposure (facts §8, §10).
+- **Saved approvals** — persisted permission approvals with
+ `PermissionSaved.Info` and `Request.save` (facts §4).
+
+## Report format
+
+Write one report with these sections:
+
+1. **Inventory** — every extension found, type, path, v1 or v2.
+2. **No-op or changes** — "clean no-op" and why, or the list of rewrites.
+3. **Skipped** — consumer-modified files skipped by hash mismatch, with the
+ warning text (empty when none).
+4. **Recommendations** — the v2 capabilities worth adopting.
+5. **App installation** — state explicitly that the OpenCode application
+ installation was not touched.
+
+## References
+
+- The suite's verified-facts record (`opencode-v2-facts.md`) — the source of
+ truth behind every "facts §n" citation above (maintainer copy).
+- The suite's bundled v2 fundamentals: `references/config.md`,
+ `references/plugins.md`, `references/tools.md`, `references/agents.md`.
+- `references/conformance-checklist.md` — the v2 conformance rubric to
+ check the upgraded package against.
diff --git a/src/agent-loader.ts b/src/agent-loader.ts
index 1facf3e..43add32 100644
--- a/src/agent-loader.ts
+++ b/src/agent-loader.ts
@@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
-import { parse as parseYaml } from "yaml";
import type { Agent } from "@opencode/plugin/effect";
+import { FrontmatterParser } from "./frontmatter";
export const AGENT_FILENAMES: readonly string[] = [
"opencode-agent-designer.md",
@@ -16,7 +16,6 @@ export const AGENT_FILENAMES: readonly string[] = [
"opencode-tool-builder.md",
];
-const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
export const RELATIVE_REFERENCE_REGEX = /`((?:\.{1,2})(?:[\\/][^`\\/]+)+)`/g;
export interface LoadedAgent {
@@ -38,6 +37,7 @@ type PermissionEffect = "allow" | "ask" | "deny";
export class AgentLoader {
private readonly agentsDir: string;
+ private readonly frontmatter = new FrontmatterParser();
public constructor(agentsDir: string) {
this.agentsDir = agentsDir;
@@ -61,19 +61,9 @@ export class AgentLoader {
content: string,
agentName: string,
): Promise {
- const match = content.match(FRONTMATTER_REGEX);
-
- if (!match || match.length < 3) {
- throw new Error(`Agent ${agentName} must have YAML frontmatter`);
- }
-
- const frontmatterYaml = match[1] as string;
- const rawPrompt = match[2] as string;
- const frontmatter = parseYaml(frontmatterYaml) as AgentFrontmatter;
- const system = this.resolveReferencePaths(
- rawPrompt.replace(/^\r?\n/, ""),
- path.dirname(agentPath),
- );
+ const document = this.frontmatter.parse(content, `Agent ${agentName}`);
+ const frontmatter = document.attributes as unknown as AgentFrontmatter;
+ const system = this.resolveReferencePaths(document.body, path.dirname(agentPath));
return {
name: agentName,
diff --git a/src/command-loader.ts b/src/command-loader.ts
new file mode 100644
index 0000000..035b1c5
--- /dev/null
+++ b/src/command-loader.ts
@@ -0,0 +1,55 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { FrontmatterParser } from "./frontmatter";
+
+export const COMMAND_FILENAMES: readonly string[] = ["upgrade-opencode-v2.md"];
+
+export interface LoadedCommand {
+ name: string;
+ description: string;
+ template: string;
+}
+
+interface CommandFrontmatter {
+ description: string | null;
+}
+
+export class CommandLoader {
+ private readonly commandsDir: string;
+ private readonly frontmatter = new FrontmatterParser();
+
+ public constructor(commandsDir: string) {
+ this.commandsDir = commandsDir;
+ }
+
+ public async loadCommands(): Promise {
+ const commands: LoadedCommand[] = [];
+
+ for (const filename of COMMAND_FILENAMES) {
+ commands.push(await this.loadCommand(filename));
+ }
+
+ return commands;
+ }
+
+ private async loadCommand(filename: string): Promise {
+ const commandPath = path.join(this.commandsDir, filename);
+ const content = await readFile(commandPath, "utf-8");
+ const document = this.frontmatter.parse(content, `Command ${filename}`);
+ const frontmatter = document.attributes as unknown as CommandFrontmatter;
+
+ return {
+ name: path.basename(filename, ".md"),
+ description: this.commandDescription(frontmatter, filename),
+ template: document.body.trimEnd(),
+ };
+ }
+
+ private commandDescription(frontmatter: CommandFrontmatter, filename: string): string {
+ const description = frontmatter.description;
+ if (typeof description !== "string" || description.length === 0) {
+ throw new Error(`Command ${filename} must declare a description`);
+ }
+ return description;
+ }
+}
diff --git a/src/frontmatter.ts b/src/frontmatter.ts
new file mode 100644
index 0000000..6f93589
--- /dev/null
+++ b/src/frontmatter.ts
@@ -0,0 +1,23 @@
+import { parse as parseYaml } from "yaml";
+
+const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
+
+export interface FrontmatterDocument {
+ attributes: Record;
+ body: string;
+}
+
+export class FrontmatterParser {
+ public parse(content: string, label: string): FrontmatterDocument {
+ const match = content.match(FRONTMATTER_REGEX);
+
+ if (!match || match.length < 3) {
+ throw new Error(`${label} must have YAML frontmatter`);
+ }
+
+ return {
+ attributes: (parseYaml(match[1] as string) ?? {}) as Record,
+ body: (match[2] as string).replace(/^\r?\n/, ""),
+ };
+ }
+}
diff --git a/src/plugin.ts b/src/plugin.ts
index 70a559b..5416718 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -1,18 +1,29 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { Effect, type Scope } from "effect";
-import { Plugin } from "@opencode/plugin/effect";
+import { Plugin, Skill } from "@opencode/plugin/effect";
import type { AgentEditor } from "@opencode/plugin/effect/agent";
+import type { CommandDefinition, CommandInvocation } from "@opencode/plugin/effect/command";
import { AgentLoader, type LoadedAgent } from "./agent-loader";
import { AssetPermissionAdvisor } from "./asset-permission-advisor";
+import { CommandLoader, type LoadedCommand } from "./command-loader";
import { Installer } from "./installer";
import { NpmCache } from "./npm-cache";
+import { SkillLoader, UPGRADE_SKILL_ID, type LoadedSkill } from "./skill-loader";
const PACKAGE_NAME = "opencode-architect";
const PLUGIN_ID = "opencode-architect";
+interface BundledContent {
+ agents: Record;
+ skills: LoadedSkill[];
+ commands: LoadedCommand[];
+}
+
export class OpencodeArchitectPlugin {
private readonly agentsDir: string;
+ private readonly skillsDir: string;
+ private readonly commandsDir: string;
private readonly assetsDir: string;
private readonly readVersion: () => Promise;
private readonly hasInstall: (projectDir: string) => Promise;
@@ -26,8 +37,10 @@ export class OpencodeArchitectPlugin {
readVersion: (() => Promise) | null = null,
hasInstall: ((projectDir: string) => Promise) | null = null,
) {
- this.agentsDir = agentsDir ?? path.join(import.meta.dirname, "..", "agents");
this.assetsDir = assetsDir ?? path.join(import.meta.dirname, "..");
+ this.agentsDir = agentsDir ?? path.join(this.assetsDir, "agents");
+ this.skillsDir = path.join(this.assetsDir, "skills");
+ this.commandsDir = path.join(this.assetsDir, "commands");
this.readVersion = readVersion ?? (() => this.readPackageMetadata());
this.hasInstall = hasInstall ?? ((projectDir) => new Installer().hasManifestAnywhere(projectDir));
this.advisor = new AssetPermissionAdvisor(this.assetsDir);
@@ -48,29 +61,39 @@ export class OpencodeArchitectPlugin {
private registerBundledAssets(context: Plugin.Context): Effect.Effect {
const plugin = this;
const loaded = Effect.tryPromise({
- try: () => new AgentLoader(plugin.agentsDir).loadAgents(),
+ try: () => plugin.loadBundledContent(),
catch: (error: unknown) => (error instanceof Error ? error.message : String(error)),
});
return Effect.matchEffect(loaded, {
onFailure: (message) => Effect.as(plugin.advisoryEffect(() => plugin.adviseFailureOnce(message)), false),
- onSuccess: (agents) => Effect.as(plugin.registrationEffect(context, agents), true),
+ onSuccess: (content) => Effect.as(plugin.registrationEffect(context, content), true),
});
}
+ private async loadBundledContent(): Promise {
+ const agents = await new AgentLoader(this.agentsDir).loadAgents();
+ const skills = await new SkillLoader(this.skillsDir).loadSkills();
+ const commands = await new CommandLoader(this.commandsDir).loadCommands();
+ return { agents, skills, commands };
+ }
+
private registrationEffect(
context: Plugin.Context,
- agents: Record,
+ content: BundledContent,
): Effect.Effect {
const plugin = this;
- return Effect.flatMap(
- context.agent.transform((editor) => {
- for (const agent of Object.values(agents)) plugin.injectAgent(editor, agent);
- }),
- () =>
- context.permission.hook("evaluate", (input) =>
- Effect.sync(() => plugin.advisor.evaluate(input)),
- ),
- );
+ return Effect.gen(function* () {
+ yield* context.agent.transform((editor) => {
+ for (const agent of Object.values(content.agents)) plugin.injectAgent(editor, agent);
+ });
+ yield* context.skill.transform((editor) => {
+ for (const skill of content.skills) editor.add(plugin.toSkillInfo(skill));
+ });
+ yield* context.command.transform((editor) => {
+ for (const command of content.commands) editor.add(plugin.toCommandDefinition(context, command));
+ });
+ yield* context.permission.hook("evaluate", (input) => Effect.sync(() => plugin.advisor.evaluate(input)));
+ });
}
private injectAgent(editor: AgentEditor, loaded: LoadedAgent): void {
@@ -82,6 +105,48 @@ export class OpencodeArchitectPlugin {
});
}
+ private toSkillInfo(skill: LoadedSkill): Skill.Info {
+ return {
+ id: Skill.ID.make(skill.id),
+ name: Skill.Name.make(skill.name),
+ description: skill.description,
+ path: skill.path as Skill.Info["path"],
+ content: skill.content,
+ };
+ }
+
+ private toCommandDefinition(context: Plugin.Context, command: LoadedCommand): CommandDefinition {
+ const plugin = this;
+ return {
+ name: command.name,
+ description: command.description,
+ execute: (invocation: CommandInvocation) => plugin.deliverCommand(context, command, invocation),
+ };
+ }
+
+ private deliverCommand(
+ context: Plugin.Context,
+ command: LoadedCommand,
+ invocation: CommandInvocation,
+ ): Effect.Effect {
+ return Effect.asVoid(
+ context.session.prompt({
+ sessionID: invocation.sessionID,
+ delivery: invocation.delivery,
+ text: this.renderCommandTemplate(command, invocation.prompt.text),
+ skills: [{ id: Skill.ID.make(UPGRADE_SKILL_ID) }],
+ }),
+ );
+ }
+
+ private renderCommandTemplate(command: LoadedCommand, args: string): string {
+ const trimmed = args.trim();
+ if (!command.template.includes("$ARGUMENTS")) {
+ return trimmed.length === 0 ? command.template : `${command.template}\n\n${trimmed}`;
+ }
+ return command.template.replaceAll("$ARGUMENTS", trimmed);
+ }
+
private advisoryEffect(run: () => Promise): Effect.Effect {
return Effect.asVoid(
Effect.orElseSucceed(Effect.tryPromise({ try: run, catch: () => "advisory failed" }), () => undefined),
@@ -108,7 +173,7 @@ export class OpencodeArchitectPlugin {
private async emitAdvisory(message: string): Promise {
const prefix =
- `Failed to load the bundled agent suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` +
+ `Failed to load the bundled extension suite. Run: bunx ${PACKAGE_NAME} clear-cache, then ` +
`reinstall and restart OpenCode. The stale cache copy is `;
let text: string;
try {
diff --git a/src/skill-loader.ts b/src/skill-loader.ts
new file mode 100644
index 0000000..1dc3eb4
--- /dev/null
+++ b/src/skill-loader.ts
@@ -0,0 +1,70 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { FrontmatterParser } from "./frontmatter";
+
+export const UPGRADE_SKILL_ID = "opencode-v2-upgrade";
+
+export const SKILL_DIRECTORIES: readonly string[] = [UPGRADE_SKILL_ID];
+
+const SKILL_FILENAME = "SKILL.md";
+
+export interface LoadedSkill {
+ id: string;
+ name: string;
+ description: string;
+ path: string;
+ content: string;
+}
+
+interface SkillFrontmatter {
+ name: string | null;
+ description: string | null;
+}
+
+export class SkillLoader {
+ private readonly skillsDir: string;
+ private readonly frontmatter = new FrontmatterParser();
+
+ public constructor(skillsDir: string) {
+ this.skillsDir = skillsDir;
+ }
+
+ public async loadSkills(): Promise {
+ const skills: LoadedSkill[] = [];
+
+ for (const directory of SKILL_DIRECTORIES) {
+ skills.push(await this.loadSkill(directory));
+ }
+
+ return skills;
+ }
+
+ private async loadSkill(directory: string): Promise {
+ const skillPath = path.join(this.skillsDir, directory, SKILL_FILENAME);
+ const content = await readFile(skillPath, "utf-8");
+ const document = this.frontmatter.parse(content, `Skill ${directory}`);
+ const frontmatter = document.attributes as unknown as SkillFrontmatter;
+
+ return {
+ id: directory,
+ name: this.skillName(frontmatter, directory),
+ description: this.skillDescription(frontmatter, directory),
+ path: skillPath,
+ content: document.body,
+ };
+ }
+
+ private skillName(frontmatter: SkillFrontmatter, directory: string): string {
+ const name = frontmatter.name;
+ if (typeof name !== "string" || name.length === 0) return directory;
+ return name;
+ }
+
+ private skillDescription(frontmatter: SkillFrontmatter, directory: string): string {
+ const description = frontmatter.description;
+ if (typeof description !== "string" || description.length === 0) {
+ throw new Error(`Skill ${directory} must declare a description`);
+ }
+ return description;
+ }
+}
diff --git a/tests/bundled-content.test.ts b/tests/bundled-content.test.ts
new file mode 100644
index 0000000..a75f2e6
--- /dev/null
+++ b/tests/bundled-content.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, test } from "bun:test";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { CommandLoader, COMMAND_FILENAMES } from "../src/command-loader";
+import { SkillLoader, UPGRADE_SKILL_ID } from "../src/skill-loader";
+import { ConfigSchemaValidator } from "./config-schema-validator";
+import { DocsFactGate } from "./docs-fact-gate";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const FIXTURE = path.join(ROOT, "tests", "fixtures", "v1-extension-package");
+const V2_FIXTURE = path.join(ROOT, "tests", "fixtures", "v2-extension-package");
+const gate = new DocsFactGate(ROOT);
+
+const SKILL_PATHS = [path.join(ROOT, "skills", UPGRADE_SKILL_ID, "SKILL.md")];
+const COMMAND_PATHS = COMMAND_FILENAMES.map((name) => path.join(ROOT, "commands", name));
+
+describe("bundled upgrade skill", () => {
+ test("loads with a name, description, content, and a resolvable path", async () => {
+ const skills = await new SkillLoader(path.join(ROOT, "skills")).loadSkills();
+
+ expect(skills.map((skill) => skill.id)).toEqual([UPGRADE_SKILL_ID]);
+ const skill = skills[0];
+ if (!skill) throw new Error("upgrade skill did not load");
+ expect(skill.name.length).toBeGreaterThan(0);
+ expect(skill.description.length).toBeGreaterThan(0);
+ expect(skill.content.trim().length).toBeGreaterThan(0);
+ expect(skill.path.replaceAll("\\", "/")).toContain(`skills/${UPGRADE_SKILL_ID}/SKILL.md`);
+ });
+
+ test("documents every phase, the safety contract, and the v2 recommendations", async () => {
+ const content = await readFile(SKILL_PATHS[0] as string, "utf-8");
+
+ for (const phase of ["Inventory", "Classify", "Config rewrite", "Port plugin code", "Port v1 tool files", "Recommendations"]) {
+ expect(content).toContain(phase);
+ }
+ for (const guarantee of [
+ "Never touch the OpenCode application installation",
+ "No-op on already-v2 projects",
+ "Never clobber consumer-modified files",
+ "manifest.json",
+ "never rewrite it from an empty object",
+ ]) {
+ expect(content).toContain(guarantee);
+ }
+ for (const capability of ["session hooks", "RPC", "TUI plugins", "Code Mode", "Saved approvals"]) {
+ expect(content).toContain(capability);
+ }
+ });
+});
+
+describe("bundled upgrade command", () => {
+ test("loads with a description, a template, and an architect agent", async () => {
+ const commands = await new CommandLoader(path.join(ROOT, "commands")).loadCommands();
+
+ expect(commands.map((command) => command.name)).toEqual(["upgrade-opencode-v2"]);
+ const command = commands[0];
+ if (!command) throw new Error("upgrade command did not load");
+ expect(command.description.length).toBeGreaterThan(0);
+ expect(command.template).toContain("$ARGUMENTS");
+ expect(command.template).toContain(UPGRADE_SKILL_ID);
+ });
+});
+
+describe("bundled content traces to the verified-facts record (no v1 claims)", () => {
+ test("the upgrade skill carries no known-false v1 claim", async () => {
+ const violations: string[] = [];
+ for (const skillPath of SKILL_PATHS) {
+ const content = await readFile(skillPath, "utf-8");
+ violations.push(...(await gate.findViolations(path.relative(ROOT, skillPath), content)));
+ }
+ expect(violations).toEqual([]);
+ });
+
+ test("the upgrade command carries no known-false v1 claim", async () => {
+ const violations: string[] = [];
+ for (const commandPath of COMMAND_PATHS) {
+ const content = await readFile(commandPath, "utf-8");
+ violations.push(...(await gate.findViolations(path.relative(ROOT, commandPath), content)));
+ }
+ expect(violations).toEqual([]);
+ });
+});
+
+describe("the upgrade guidance covers the v1 fixture package", () => {
+ test("every v1 config key in the fixture is documented in the mapping", async () => {
+ const content = await readFile(SKILL_PATHS[0] as string, "utf-8");
+ const config = JSON.parse(await readFile(path.join(FIXTURE, ".opencode", "opencode.json"), "utf-8")) as Record;
+
+ for (const key of Object.keys(config).filter((key) => !key.startsWith("$"))) {
+ expect(content, `skill does not document the v1 config key ${key}`).toContain(key);
+ }
+ });
+
+ test("the code-port guidance names the v1 plugin file and the v1 file-based tool file", async () => {
+ const content = await readFile(SKILL_PATHS[0] as string, "utf-8");
+ expect(content).toContain("v1 plugin file");
+ expect(content).toContain(".opencode/tools");
+ expect(content).toContain("tool.schema");
+ });
+});
+
+describe("the golden v2 fixture is the upgraded output of the v1 fixture", () => {
+ test("the rewritten config validates against the pinned v2 schema", async () => {
+ const text = await readFile(path.join(V2_FIXTURE, ".opencode", "opencode.json"), "utf-8");
+ const verdict = new ConfigSchemaValidator().validateText(text, false);
+ expect(verdict.issue).toBeNull();
+ expect(verdict.ok).toBe(true);
+ });
+
+ test("the upgraded config carries no v1 key", async () => {
+ const text = await readFile(path.join(V2_FIXTURE, ".opencode", "opencode.json"), "utf-8");
+ const config = JSON.parse(text) as Record;
+
+ for (const key of [
+ "plugin",
+ "permission",
+ "agent",
+ "command",
+ "tools",
+ "autoupdate",
+ "small_model",
+ "attachment",
+ "snapshot",
+ "reference",
+ ]) {
+ expect(config, `v1 key ${key} survived the upgrade`).not.toHaveProperty(key);
+ }
+ expect(config).toHaveProperty("plugins");
+ expect(config).toHaveProperty("permissions");
+ expect(config).toHaveProperty("agents");
+ expect(config).toHaveProperty("commands");
+ });
+
+ test("the ported plugin uses the Effect-first v2 API and no v1 authoring package", async () => {
+ const plugin = await readFile(path.join(V2_FIXTURE, ".opencode", "plugins", "notify.ts"), "utf-8");
+ expect(plugin).toContain("Plugin.define");
+ expect(plugin).toContain("@opencode/plugin/effect");
+ expect(plugin).not.toContain("@opencode-ai/plugin");
+ });
+
+ test("the ported agent drops top-level v1 frontmatter fields", async () => {
+ const agent = await readFile(path.join(V2_FIXTURE, ".opencode", "agents", "pr-reviewer.md"), "utf-8");
+ expect(agent).not.toMatch(/^(prompt|maxSteps|disable|temperature|top_p|tools|permission):/m);
+ expect(agent).toMatch(/^request:/m);
+ expect(agent).toMatch(/^steps:/m);
+ expect(agent).toMatch(/^permissions:/m);
+ });
+
+ test("the ported skill drops the v1-only license and metadata fields", async () => {
+ const skill = await readFile(path.join(V2_FIXTURE, ".opencode", "skills", "release-notes", "SKILL.md"), "utf-8");
+ expect(skill).not.toMatch(/^(license|metadata|compatibility):/m);
+ });
+
+ test("the upgrade produced a recommendations report naming the v2 capabilities", async () => {
+ const report = await readFile(path.join(V2_FIXTURE, "upgrade-report.md"), "utf-8");
+ for (const capability of ["session hooks", "RPC", "TUI plugins", "Code Mode", "Saved approvals"]) {
+ expect(report).toContain(capability);
+ }
+ expect(report).toContain("application installation was not touched");
+ expect(report).toContain("clean no-op");
+ });
+});
diff --git a/tests/fixtures/v1-extension-package/.opencode/agents/pr-reviewer.md b/tests/fixtures/v1-extension-package/.opencode/agents/pr-reviewer.md
new file mode 100644
index 0000000..d9ca1b2
--- /dev/null
+++ b/tests/fixtures/v1-extension-package/.opencode/agents/pr-reviewer.md
@@ -0,0 +1,16 @@
+---
+description: "Review pull requests for quality and security"
+mode: "subagent"
+model: "anthropic/claude-sonnet-4-5"
+temperature: 0.2
+maxSteps: 25
+disable: false
+prompt: "You review pull requests for correctness, security, and missing tests."
+tools:
+ write: false
+ edit: false
+ bash: false
+ read: true
+---
+
+Review the pull request and report structured findings.
diff --git a/tests/fixtures/v1-extension-package/.opencode/commands/review.md b/tests/fixtures/v1-extension-package/.opencode/commands/review.md
new file mode 100644
index 0000000..06d480e
--- /dev/null
+++ b/tests/fixtures/v1-extension-package/.opencode/commands/review.md
@@ -0,0 +1,6 @@
+---
+description: "Review the staged changes"
+agent: "pr-reviewer"
+---
+
+Review the staged changes for correctness and missing tests.
diff --git a/tests/fixtures/v1-extension-package/.opencode/opencode.json b/tests/fixtures/v1-extension-package/.opencode/opencode.json
new file mode 100644
index 0000000..d884053
--- /dev/null
+++ b/tests/fixtures/v1-extension-package/.opencode/opencode.json
@@ -0,0 +1,27 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "plugin": ["./.opencode/plugins/notify.ts"],
+ "permission": {
+ "bash": { "*": "deny", "git status": "allow" },
+ "edit": "ask"
+ },
+ "agent": {
+ "pr-reviewer": {
+ "description": "Reviews pull requests for quality and security",
+ "prompt": "./.opencode/agents/pr-reviewer.md"
+ }
+ },
+ "command": {
+ "review": {
+ "template": "Review the staged changes for correctness and missing tests.",
+ "agent": "pr-reviewer"
+ }
+ },
+ "tools": false,
+ "autoupdate": "notify",
+ "small_model": "anthropic/claude-haiku",
+ "mcp": {
+ "docs": { "type": "local", "command": "npx -y @acme/docs-mcp" }
+ },
+ "skills": { "paths": [".opencode/skills"] }
+}
diff --git a/tests/fixtures/v1-extension-package/.opencode/plugins/notify.ts b/tests/fixtures/v1-extension-package/.opencode/plugins/notify.ts
new file mode 100644
index 0000000..cb8e4cc
--- /dev/null
+++ b/tests/fixtures/v1-extension-package/.opencode/plugins/notify.ts
@@ -0,0 +1,16 @@
+import { Plugin } from "@opencode-ai/plugin";
+
+export const NotifyPlugin: Plugin = async ({ $ }) => {
+ return {
+ event: async ({ event }) => {
+ if (event.type === "session.idle") {
+ await $`echo "session idle"`;
+ }
+ },
+ "tool.execute.before": async (input) => {
+ if (input.tool === "bash") {
+ throw new Error("bash is disabled");
+ }
+ },
+ };
+};
diff --git a/tests/fixtures/v1-extension-package/.opencode/skills/release-notes/SKILL.md b/tests/fixtures/v1-extension-package/.opencode/skills/release-notes/SKILL.md
new file mode 100644
index 0000000..50c69d5
--- /dev/null
+++ b/tests/fixtures/v1-extension-package/.opencode/skills/release-notes/SKILL.md
@@ -0,0 +1,8 @@
+---
+name: "release-notes"
+description: "Draft release notes from merged pull requests"
+license: "MIT"
+metadata: "internal"
+---
+
+Draft release notes from merged pull requests, grouped by change type.
diff --git a/tests/fixtures/v1-extension-package/.opencode/tools/validate-commit.ts b/tests/fixtures/v1-extension-package/.opencode/tools/validate-commit.ts
new file mode 100644
index 0000000..dc6a974
--- /dev/null
+++ b/tests/fixtures/v1-extension-package/.opencode/tools/validate-commit.ts
@@ -0,0 +1,10 @@
+import { tool } from "@opencode-ai/plugin";
+import { z } from "zod";
+
+export const validateCommit = tool({
+ description: "Validate a conventional commit message",
+ args: { message: z.string() },
+ execute: async ({ message }) => {
+ return /^(feat|fix|docs|chore)(\(.+\))?:\s.+/.test(message) ? "valid" : "invalid";
+ },
+});
diff --git a/tests/fixtures/v2-extension-package/.opencode/agents/pr-reviewer.md b/tests/fixtures/v2-extension-package/.opencode/agents/pr-reviewer.md
new file mode 100644
index 0000000..07a39fd
--- /dev/null
+++ b/tests/fixtures/v2-extension-package/.opencode/agents/pr-reviewer.md
@@ -0,0 +1,17 @@
+---
+description: "Review pull requests for quality and security"
+mode: "subagent"
+model:
+ providerID: "anthropic"
+ model: "claude-sonnet-4-5"
+request:
+ temperature: 0.2
+steps: 25
+disabled: false
+permissions:
+ - { action: "read", resource: "*", effect: "allow" }
+ - { action: "edit", resource: "*", effect: "deny" }
+ - { action: "shell", resource: "*", effect: "deny" }
+---
+
+Review the pull request and report structured findings.
diff --git a/tests/fixtures/v2-extension-package/.opencode/commands/review.md b/tests/fixtures/v2-extension-package/.opencode/commands/review.md
new file mode 100644
index 0000000..06d480e
--- /dev/null
+++ b/tests/fixtures/v2-extension-package/.opencode/commands/review.md
@@ -0,0 +1,6 @@
+---
+description: "Review the staged changes"
+agent: "pr-reviewer"
+---
+
+Review the staged changes for correctness and missing tests.
diff --git a/tests/fixtures/v2-extension-package/.opencode/opencode.json b/tests/fixtures/v2-extension-package/.opencode/opencode.json
new file mode 100644
index 0000000..acf93ff
--- /dev/null
+++ b/tests/fixtures/v2-extension-package/.opencode/opencode.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "plugins": ["./.opencode/plugins/notify.ts"],
+ "permissions": [
+ { "action": "edit", "resource": "*", "effect": "ask" },
+ { "action": "shell", "resource": "*", "effect": "deny" },
+ { "action": "shell", "resource": "git status", "effect": "allow" }
+ ],
+ "agents": {
+ "pr-reviewer": {
+ "description": "Reviews pull requests for quality and security",
+ "mode": "subagent",
+ "model": { "providerID": "anthropic", "model": "claude-sonnet-4-5" },
+ "request": { "temperature": 0.2 },
+ "steps": 25,
+ "disabled": false,
+ "permissions": [
+ { "action": "read", "resource": "*", "effect": "allow" },
+ { "action": "edit", "resource": "*", "effect": "deny" },
+ { "action": "shell", "resource": "*", "effect": "deny" }
+ ]
+ },
+ "title": {
+ "model": { "providerID": "anthropic", "model": "claude-haiku" }
+ }
+ },
+ "commands": {
+ "review": {
+ "template": "Review the staged changes for correctness and missing tests.",
+ "agent": "pr-reviewer"
+ }
+ },
+ "update": "notify",
+ "mcp": {
+ "docs": { "type": "local", "command": ["npx", "-y", "@acme/docs-mcp"] }
+ },
+ "skills": [".opencode/skills"]
+}
diff --git a/tests/fixtures/v2-extension-package/.opencode/plugins/notify.ts b/tests/fixtures/v2-extension-package/.opencode/plugins/notify.ts
new file mode 100644
index 0000000..66c3ebd
--- /dev/null
+++ b/tests/fixtures/v2-extension-package/.opencode/plugins/notify.ts
@@ -0,0 +1,31 @@
+import { Effect } from "effect";
+import { Plugin } from "@opencode/plugin/effect";
+
+export default Plugin.define({
+ id: "acme-notify",
+ effect: (context) =>
+ Effect.gen(function* () {
+ yield* context.event.subscribe();
+
+ yield* context.tool.hook("execute.before", () => Effect.void);
+
+ yield* context.tool.transform((editor) => {
+ editor.add({
+ name: "validate-commit",
+ description: "Validate a conventional commit message",
+ input: {
+ type: "object",
+ properties: {
+ message: { type: "string", description: "Commit message to validate" },
+ },
+ required: ["message"],
+ additionalProperties: false,
+ },
+ execute: (args) =>
+ Effect.succeed({
+ output: /^(feat|fix|docs|chore)(\(.+\))?:\s.+/.test(args.message) ? "valid" : "invalid",
+ }),
+ });
+ });
+ }),
+});
diff --git a/tests/fixtures/v2-extension-package/.opencode/skills/release-notes/SKILL.md b/tests/fixtures/v2-extension-package/.opencode/skills/release-notes/SKILL.md
new file mode 100644
index 0000000..e25003f
--- /dev/null
+++ b/tests/fixtures/v2-extension-package/.opencode/skills/release-notes/SKILL.md
@@ -0,0 +1,6 @@
+---
+name: "release-notes"
+description: "Draft release notes from merged pull requests"
+---
+
+Draft release notes from merged pull requests, grouped by change type.
diff --git a/tests/fixtures/v2-extension-package/upgrade-report.md b/tests/fixtures/v2-extension-package/upgrade-report.md
new file mode 100644
index 0000000..711c1ec
--- /dev/null
+++ b/tests/fixtures/v2-extension-package/upgrade-report.md
@@ -0,0 +1,53 @@
+# OpenCode v1 → v2 upgrade report — acme-toolkit
+
+Produced by running the `opencode-v2-upgrade` skill against the sibling
+`v1-extension-package` fixture.
+
+## Inventory
+
+| Extension | Type | State after upgrade |
+| --- | --- | --- |
+| notify | plugin | Effect-first `Plugin.define({ id, effect })`; event stream + tool hook + registered tool |
+| validate-commit | tool | plugin-registered tool with a JSON Schema `input` |
+| pr-reviewer | agent | v2 frontmatter: selection model, `request`, `steps`, `disabled`, `permissions` |
+| release-notes | skill | v2 frontmatter; v1 `license`/`metadata` dropped |
+| review | command | unchanged (already v2-valid) |
+| config | config | rewritten to v2-native keys |
+
+## Changes
+
+- Config: singular `plugin` → `plugins`; keyed `permission` and `tools` → the
+ `permissions` ruleset array (`bash` → `shell`); `agent` → `agents`;
+ `command` → `commands`; `autoupdate` → `update`; `small_model` →
+ `agents.title.model`; MCP command string → `command` array; `skills`
+ object → a `skills` string array.
+- Plugin code ported to the Effect-first v2 API (returned hooks object →
+ context-domain transforms and the event stream; v1 tool helper →
+ `ctx.tool.transform`).
+- V1 file-based tool file ported to a plugin-registered tool.
+
+## Skipped
+
+None. No manifest hash mismatch was found, so nothing was skipped. (On a
+project with an install manifest, a mismatch would be listed here and
+skipped with a warning instead of clobbered.)
+
+## Recommendations
+
+- Richer session hooks: `session.hook("context")`, `"compaction"`,
+ `"retry"`, and native HTTP/WebSocket hooks.
+- Plugin RPC via `ctx.rpc.register` with a `./rpc` export.
+- TUI plugins via a `./tui` export with Solid helpers.
+- MCP Code Mode via `codemode` on the server and per-tool `options`.
+- Saved approvals via `PermissionSaved.Info` / `Request.save`.
+
+## App installation
+
+The OpenCode application installation was not touched: no binary, global
+cache, or npm package change. Only the project was rewritten.
+
+## Re-run safety
+
+Re-running the upgrade against this tree is a clean no-op: the inventory
+finds no v1 remnant. Consumer-modified files would be skipped with a
+warning, never clobbered.
diff --git a/tests/load-hook.test.ts b/tests/load-hook.test.ts
index 92a7902..f34ea9e 100644
--- a/tests/load-hook.test.ts
+++ b/tests/load-hook.test.ts
@@ -5,7 +5,9 @@ import path from "node:path";
import { Effect } from "effect";
import { Agent, Plugin } from "@opencode/plugin/effect";
import type { AgentEditor } from "@opencode/plugin/effect/agent";
+import type { CommandEditor } from "@opencode/plugin/effect/command";
import type { PermissionEvaluation } from "@opencode/plugin/effect/permission";
+import type { SkillEditor } from "@opencode/plugin/effect/skill";
import { OpencodeArchitectPlugin } from "../src/plugin";
import { captureConsole } from "./test-helpers";
@@ -34,6 +36,16 @@ interface PermissionDomainSpy {
evaluations: Array<(input: PermissionEvaluation) => Effect.Effect>;
}
+interface SkillDomainSpy {
+ transform: (callback: (editor: SkillEditor) => void) => Effect.Effect;
+ registered: Map;
+}
+
+interface CommandDomainSpy {
+ transform: (callback: (editor: CommandEditor) => void) => Effect.Effect;
+ registered: Map;
+}
+
function blankAgent(id: string): RegisteredAgent {
const created = Agent.Info.default(Agent.ID.make(id)) as Partial & {
id: string;
@@ -83,12 +95,52 @@ function permissionDomainSpy(): PermissionDomainSpy {
return { hook, evaluations };
}
+function skillDomainSpy(): SkillDomainSpy {
+ const registered = new Map();
+ const transform = (callback: (editor: SkillEditor) => void) =>
+ Effect.sync(() => {
+ const editor: SkillEditor = {
+ list: () => [...registered.values()] as never,
+ get: (id: string) => registered.get(id) as never,
+ add: (skill) => {
+ registered.set(skill.id, skill);
+ },
+ update: (id: string, update: (skill: never) => void) => {
+ const current = registered.get(id);
+ if (current === undefined) return;
+ update(current as never);
+ },
+ remove: (id: string) => {
+ registered.delete(id);
+ },
+ };
+ callback(editor);
+ });
+ return { transform, registered };
+}
+
+function commandDomainSpy(): CommandDomainSpy {
+ const registered = new Map();
+ const transform = (callback: (editor: CommandEditor) => void) =>
+ Effect.sync(() => {
+ const editor: CommandEditor = {
+ add: (definition) => {
+ registered.set(definition.name, definition);
+ },
+ };
+ callback(editor);
+ });
+ return { transform, registered };
+}
+
function contextWith(
agent: AgentDomainSpy,
permission: PermissionDomainSpy,
directory: string,
+ skill: SkillDomainSpy = skillDomainSpy(),
+ command: CommandDomainSpy = commandDomainSpy(),
): Plugin.Context {
- return { location: { directory }, agent, permission } as unknown as Plugin.Context;
+ return { location: { directory }, agent, skill, command, permission } as unknown as Plugin.Context;
}
async function activate(definition: Plugin.Plugin, context: Plugin.Context): Promise {
diff --git a/tests/v2-host-harness.ts b/tests/v2-host-harness.ts
index 3963398..5831da9 100644
--- a/tests/v2-host-harness.ts
+++ b/tests/v2-host-harness.ts
@@ -6,8 +6,12 @@ import { resolveModule } from "@opencode/util/runtime-import";
import * as Host from "@opencode/plugin/host";
import { Agent, Plugin } from "@opencode/plugin/effect";
import type { AgentEditor } from "@opencode/plugin/effect/agent";
+import type { CommandEditor } from "@opencode/plugin/effect/command";
import type { PermissionEvaluation } from "@opencode/plugin/effect/permission";
+import type { SkillEditor } from "@opencode/plugin/effect/skill";
import { AGENT_FILENAMES } from "../src/agent-loader";
+import { COMMAND_FILENAMES } from "../src/command-loader";
+import { SKILL_DIRECTORIES } from "../src/skill-loader";
import { flattenIssue } from "./config-schema-validator";
const REPO_ROOT = path.resolve(import.meta.dirname, "..");
@@ -46,6 +50,12 @@ export interface RegistrationAudit {
agentsRegistered: string[];
missingAgents: string[];
unexpectedAgents: string[];
+ skillsExpected: string[];
+ skillsRegistered: string[];
+ missingSkills: string[];
+ commandsExpected: string[];
+ commandsRegistered: string[];
+ missingCommands: string[];
permissionHookCount: number;
violations: string[];
}
@@ -53,6 +63,8 @@ export interface RegistrationAudit {
export interface RecordingContext {
context: Plugin.Context;
agents: Map;
+ skills: Map;
+ commands: Map;
permissionHooks: Array<(input: PermissionEvaluation) => Effect.Effect>;
}
@@ -65,6 +77,14 @@ export class V2HostHarness {
return AGENT_FILENAMES.map((filename) => path.basename(filename, ".md"));
}
+ public suiteSkillNames(): string[] {
+ return [...SKILL_DIRECTORIES];
+ }
+
+ public suiteCommandNames(): string[] {
+ return COMMAND_FILENAMES.map((filename) => path.basename(filename, ".md"));
+ }
+
public resolveServerEntrypoint(directory: string, name: string | null): string | null {
try {
return resolveServerKindEntrypoint(directory, name);
@@ -112,10 +132,14 @@ export class V2HostHarness {
public recordingContext(directory: string): RecordingContext {
const agents = new Map();
+ const skills = new Map();
+ const commands = new Map();
const permissionHooks: Array<(input: PermissionEvaluation) => Effect.Effect> = [];
const context = {
location: { directory },
agent: { transform: (callback: (editor: AgentEditor) => void) => Effect.sync(() => callback(recordingAgentEditor(agents))) },
+ skill: { transform: (callback: (editor: SkillEditor) => void) => Effect.sync(() => callback(recordingSkillEditor(skills))) },
+ command: { transform: (callback: (editor: CommandEditor) => void) => Effect.sync(() => callback(recordingCommandEditor(commands))) },
permission: {
hook: (_name: string, callback: (input: PermissionEvaluation) => Effect.Effect) =>
Effect.sync(() => {
@@ -123,7 +147,7 @@ export class V2HostHarness {
}),
},
} as unknown as Plugin.Context;
- return { context, agents, permissionHooks };
+ return { context, agents, skills, commands, permissionHooks };
}
public activate(definition: Plugin.Plugin, recording: RecordingContext): Promise {
@@ -133,6 +157,10 @@ export class V2HostHarness {
public audit(recording: RecordingContext, pluginId: string, entrypointResolved: boolean): RegistrationAudit {
const agentsExpected = this.suiteAgentNames();
const agentsRegistered = [...recording.agents.keys()];
+ const skillsExpected = this.suiteSkillNames();
+ const skillsRegistered = [...recording.skills.keys()];
+ const commandsExpected = this.suiteCommandNames();
+ const commandsRegistered = [...recording.commands.keys()];
const audit: RegistrationAudit = {
pluginId,
entrypointResolved,
@@ -140,6 +168,12 @@ export class V2HostHarness {
agentsRegistered,
missingAgents: agentsExpected.filter((name) => !recording.agents.has(name)),
unexpectedAgents: agentsRegistered.filter((name) => !agentsExpected.includes(name)),
+ skillsExpected,
+ skillsRegistered,
+ missingSkills: skillsExpected.filter((name) => !recording.skills.has(name)),
+ commandsExpected,
+ commandsRegistered,
+ missingCommands: commandsExpected.filter((name) => !recording.commands.has(name)),
permissionHookCount: recording.permissionHooks.length,
violations: [],
};
@@ -147,6 +181,8 @@ export class V2HostHarness {
for (const name of audit.missingAgents) audit.violations.push(`agent not registered: ${name}`);
for (const name of audit.unexpectedAgents) audit.violations.push(`unexpected agent registered: ${name}`);
for (const name of agentsRegistered) this.auditAgent(audit, name, recording.agents.get(name));
+ for (const name of audit.missingSkills) audit.violations.push(`skill not registered: ${name}`);
+ for (const name of audit.missingCommands) audit.violations.push(`command not registered: ${name}`);
if (recording.permissionHooks.length !== 1) {
audit.violations.push(`permission evaluate hook registered ${recording.permissionHooks.length} times, expected 1`);
}
@@ -205,6 +241,32 @@ function recordingAgentEditor(registered: Map): AgentEditor {
};
}
+function recordingSkillEditor(registered: Map): SkillEditor {
+ return {
+ list: () => [...registered.values()] as never,
+ get: (id: string) => registered.get(id) as never,
+ add: (skill) => {
+ registered.set(skill.id, skill);
+ },
+ update: (id: string, update: (skill: never) => void) => {
+ const current = registered.get(id);
+ if (current === undefined) return;
+ update(current as never);
+ },
+ remove: (id: string) => {
+ registered.delete(id);
+ },
+ };
+}
+
+function recordingCommandEditor(registered: Map): CommandEditor {
+ return {
+ add: (definition) => {
+ registered.set(definition.name, definition);
+ },
+ };
+}
+
function normalizeAgent(record: unknown): HostAgentRecord {
const candidate = (record ?? {}) as Partial;
return {
diff --git a/tests/v2-host.test.ts b/tests/v2-host.test.ts
index a876798..24498d0 100644
--- a/tests/v2-host.test.ts
+++ b/tests/v2-host.test.ts
@@ -137,6 +137,8 @@ describe("built plugin under the v2 host contract", () => {
}
expect(audit.agentsRegistered).toContain("opencode-architect");
expect(audit.agentsRegistered).toContain("opencode-skill-creator");
+ expect(audit.skillsRegistered).toEqual(["opencode-v2-upgrade"]);
+ expect(audit.commandsRegistered).toEqual(["upgrade-opencode-v2"]);
logs.restore();
warnings.restore();
});
@@ -181,6 +183,8 @@ describe("the harness fails on registration loss", () => {
expect(audit.permissionHookCount).toBe(0);
expect(audit.violations.length).toBeGreaterThanOrEqual(10);
expect(audit.violations).toContain("permission evaluate hook registered 0 times, expected 1");
+ expect(audit.violations).toContain("skill not registered: opencode-v2-upgrade");
+ expect(audit.violations).toContain("command not registered: upgrade-opencode-v2");
expect(audit.missingAgents).toContain("opencode-architect");
expect(warnings.lines[0]).toContain("bunx opencode-architect clear-cache");
await rm(scratch, { recursive: true, force: true });
diff --git a/tsconfig.json b/tsconfig.json
index b44e35a..820bc72 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -26,5 +26,6 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
- }
+ },
+ "exclude": ["node_modules", "tests/fixtures"]
}