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"] }