diff --git a/.opencode/INSTALL.md b/.opencode/INSTALL.md index 0d5589bca..64d2d59e0 100644 --- a/.opencode/INSTALL.md +++ b/.opencode/INSTALL.md @@ -8,7 +8,7 @@ Add Compound Engineering to the `plugin` array in your global or project `openco } ``` -Restart OpenCode after changing the config. The OpenCode plugin registers the Compound Engineering skills directory directly; no Bun installer or generated skill copy is required. +Restart OpenCode after changing the config. The bundled entrypoint supports both the legacy OpenCode v1 plugin loader and the OpenCode v2 plugin loader, so the same declaration works with either binary. It registers the Compound Engineering skills directly; no Bun installer or generated skill copy is required. To pin a release, add a tag. Replace `X.Y.Z` with the release you want — see the [releases page](https://github.com/EveryInc/compound-engineering-plugin/releases) for available tags: diff --git a/.opencode/plugins/compound-engineering.js b/.opencode/plugins/compound-engineering.js index 2515dcdd6..209c39037 100644 --- a/.opencode/plugins/compound-engineering.js +++ b/.opencode/plugins/compound-engineering.js @@ -1,6 +1,8 @@ import path from "path" import fs from "fs" import { fileURLToPath } from "url" +import { createV1Plugin } from "./compound-engineering/opencode-v1.js" +import { createV2Setup } from "./compound-engineering/opencode-v2.js" const pluginDir = path.dirname(fileURLToPath(import.meta.url)) const skillsDir = path.resolve(pluginDir, "../../skills") @@ -23,52 +25,50 @@ function parseFrontmatter(content) { const pair = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/) if (pair) fields[pair[1]] = unquote(pair[2].trim()) } - return fields + return { + fields, + body: content.slice(block[0].length).replace(/^\r?\n/, ""), + } } function loadSkills() { - const commands = {} + const skills = [] let entries try { entries = fs.readdirSync(skillsDir) } catch { - return commands + return skills } for (const entry of entries) { + const skillPath = path.join(skillsDir, entry, "SKILL.md") let content try { - content = fs.readFileSync(path.join(skillsDir, entry, "SKILL.md"), "utf8") + content = fs.readFileSync(skillPath, "utf8") } catch { continue } - const fields = parseFrontmatter(content) - if (!fields || !fields.name) continue - if (fields["user-invocable"] === "false") continue - const command = { - template: `Load and execute the \`${fields.name}\` skill.\n\n$ARGUMENTS`, - } - if (fields.description) command.description = fields.description - commands[fields.name] = command + const parsed = parseFrontmatter(content) + if (!parsed || !parsed.fields.name) continue + skills.push({ + name: parsed.fields.name, + description: parsed.fields.description, + body: parsed.body, + skillPath, + suppressed: parsed.fields["user-invocable"] === "false", + }) } - return commands + return skills } -const skillCommands = loadSkills() +const skills = loadSkills() -export const CompoundEngineeringPlugin = async () => ({ - config: async (config) => { - config.skills = config.skills || {} - config.skills.paths = config.skills.paths || [] - if (!config.skills.paths.includes(skillsDir)) { - config.skills.paths.push(skillsDir) - } - config.command = config.command || {} - for (const [name, cmd] of Object.entries(skillCommands)) { - if (!(name in config.command)) { - config.command[name] = cmd - } - } - }, -}) +const CompoundEngineeringPlugin = createV1Plugin({ skills, skillsDir }) +const setupV2 = createV2Setup(skills) + +export { CompoundEngineeringPlugin } -export default CompoundEngineeringPlugin +export default { + id: "compound-engineering", + server: CompoundEngineeringPlugin, + setup: setupV2, +} diff --git a/.opencode/plugins/compound-engineering/opencode-v1.js b/.opencode/plugins/compound-engineering/opencode-v1.js new file mode 100644 index 000000000..c4ba66eab --- /dev/null +++ b/.opencode/plugins/compound-engineering/opencode-v1.js @@ -0,0 +1,20 @@ +export function createV1Plugin({ skills, skillsDir }) { + return async () => ({ + config: async (config) => { + config.skills = config.skills || {} + config.skills.paths = config.skills.paths || [] + if (!config.skills.paths.includes(skillsDir)) { + config.skills.paths.push(skillsDir) + } + config.command = config.command || {} + for (const skill of skills) { + if (skill.suppressed || skill.name in config.command) continue + const command = { + template: `Load and execute the \`${skill.name}\` skill.\n\n$ARGUMENTS`, + } + if (skill.description) command.description = skill.description + config.command[skill.name] = command + } + }, + }) +} diff --git a/.opencode/plugins/compound-engineering/opencode-v2.js b/.opencode/plugins/compound-engineering/opencode-v2.js new file mode 100644 index 000000000..92fe8dcc6 --- /dev/null +++ b/.opencode/plugins/compound-engineering/opencode-v2.js @@ -0,0 +1,60 @@ +function v2SkillInfo(skill, slash) { + return { + name: skill.name, + ...(skill.description ? { description: skill.description } : {}), + slash, + location: skill.skillPath, + content: skill.body, + } +} + +function v2Skill(skill, slash) { + return { + id: skill.name, + ...v2SkillInfo(skill, slash), + } +} + +export function createV2Setup(skills) { + return async function setupV2(context) { + let commandsRegistered = false + const commandTransform = context?.command?.transform + const prompt = context?.session?.prompt + + if (typeof commandTransform === "function" && typeof prompt === "function") { + await commandTransform((draft) => { + if (typeof draft?.add !== "function") return + commandsRegistered = true + for (const skill of skills) { + if (skill.suppressed) continue + draft.add({ + name: skill.name, + description: skill.description, + execute: async (input) => { + const promptInput = input?.prompt ?? {} + const attachedSkills = promptInput.skills ?? [] + const skillAlreadyAttached = attachedSkills.some((attached) => attached.id === skill.name) + await prompt({ + ...promptInput, + sessionID: input.sessionID, + text: promptInput.text || "", + skills: skillAlreadyAttached ? attachedSkills : [...attachedSkills, { id: skill.name }], + delivery: input.delivery, + }) + }, + }) + } + }) + } + + const skillTransform = context?.skill?.transform + if (typeof skillTransform !== "function") return + + await skillTransform((draft) => { + if (typeof draft?.add !== "function") return + for (const skill of skills) { + draft.add(v2Skill(skill, commandsRegistered ? false : !skill.suppressed)) + } + }) + } +} diff --git a/tests/opencode-plugin-commands.test.ts b/tests/opencode-plugin-commands.test.ts index bbc559055..38095d98b 100644 --- a/tests/opencode-plugin-commands.test.ts +++ b/tests/opencode-plugin-commands.test.ts @@ -3,6 +3,8 @@ import path from "path" import { describe, expect, test } from "bun:test" // @ts-expect-error -- plain JS plugin entrypoint, no type declarations import { CompoundEngineeringPlugin } from "../.opencode/plugins/compound-engineering.js" +// @ts-expect-error -- plain JS plugin entrypoint, no type declarations +import OpenCodePlugin from "../.opencode/plugins/compound-engineering.js" import { parseFrontmatter } from "../src/utils/frontmatter" const skillsDir = path.resolve(import.meta.dir, "../skills") @@ -13,6 +15,25 @@ type OpenCodeConfig = { command?: Record } +type V2Command = { + name: string + description?: string + execute: (input: { + sessionID: string + prompt: { text: string; skills?: Array<{ id: string }> } + delivery: string + }) => Promise +} + +type V2Skill = { + id: string + name: string + description?: string + slash?: boolean + location: string + content: string +} + async function applyPlugin(config: OpenCodeConfig = {}): Promise { const plugin = await CompoundEngineeringPlugin() await plugin.config(config) @@ -37,16 +58,19 @@ const skills = fs }) const expectedTemplate = (name: string) => `Load and execute the \`${name}\` skill.\n\n$ARGUMENTS` +const invocableSkillNames = skills.filter((skill) => !skill.suppressed).map((skill) => skill.name).sort() describe("opencode plugin skill commands", () => { + test("exports both OpenCode plugin contracts", () => { + expect(OpenCodePlugin.id).toBe("compound-engineering") + expect(typeof OpenCodePlugin.server).toBe("function") + expect(typeof OpenCodePlugin.setup).toBe("function") + }) + test("registers a command for every user-invocable skill", async () => { const config = await applyPlugin() - const expected = skills - .filter((skill) => !skill.suppressed) - .map((skill) => skill.name) - .sort() - expect(Object.keys(config.command ?? {}).sort()).toEqual(expected) + expect(Object.keys(config.command ?? {}).sort()).toEqual(invocableSkillNames) }) test("each command carries a $ARGUMENTS template and the skill description", async () => { @@ -98,4 +122,46 @@ describe("opencode plugin skill commands", () => { expect(dirs.has(name)).toBe(true) } }) + + test("registers v2 commands and embedded skill definitions", async () => { + const commands: V2Command[] = [] + const registeredSkills: V2Skill[] = [] + const prompts: unknown[] = [] + + await OpenCodePlugin.setup({ + command: { + transform: async (callback: (draft: { add: (command: V2Command) => void }) => void) => + callback({ add: (command) => commands.push(command) }), + }, + skill: { + transform: async (callback: (draft: { add: (skill: V2Skill) => void }) => void) => + callback({ add: (skill) => registeredSkills.push(skill) }), + }, + session: { + prompt: async (input: unknown) => { + prompts.push(input) + }, + }, + }) + + expect(commands.map((command) => command.name).sort()).toEqual(invocableSkillNames) + expect(registeredSkills.map((skill) => skill.id).sort()).toEqual(skills.map((skill) => skill.name).sort()) + expect(registeredSkills.every((skill) => skill.slash === false)).toBe(true) + expect(registeredSkills.every((skill) => !skill.content.startsWith("---"))).toBe(true) + + const command = commands.find((item) => item.name === "ce-plan")! + const existingSkill = { id: "existing-skill" } + await command.execute({ + sessionID: "session", + prompt: { text: "extra context", skills: [existingSkill] }, + delivery: "steer", + }) + expect(prompts[0]).toEqual({ + sessionID: "session", + text: "extra context", + skills: [existingSkill, { id: command.name }], + delivery: "steer", + }) + }) + })