Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .opencode/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
60 changes: 30 additions & 30 deletions .opencode/plugins/compound-engineering.js
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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,
}
20 changes: 20 additions & 0 deletions .opencode/plugins/compound-engineering/opencode-v1.js
Original file line number Diff line number Diff line change
@@ -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
}
},
})
}
60 changes: 60 additions & 0 deletions .opencode/plugins/compound-engineering/opencode-v2.js
Original file line number Diff line number Diff line change
@@ -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
Comment thread
ucirello marked this conversation as resolved.
for (const skill of skills) {
draft.add(v2Skill(skill, commandsRegistered ? false : !skill.suppressed))
}
})
}
}
76 changes: 71 additions & 5 deletions tests/opencode-plugin-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -13,6 +15,25 @@ type OpenCodeConfig = {
command?: Record<string, OpenCodeCommand>
}

type V2Command = {
name: string
description?: string
execute: (input: {
sessionID: string
prompt: { text: string; skills?: Array<{ id: string }> }
delivery: string
}) => Promise<void>
}

type V2Skill = {
id: string
name: string
description?: string
slash?: boolean
location: string
content: string
}

async function applyPlugin(config: OpenCodeConfig = {}): Promise<OpenCodeConfig> {
const plugin = await CompoundEngineeringPlugin()
await plugin.config(config)
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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",
})
})

})