feat: add OpenCode/Codex outputs and update changelog (#104)

* Add OpenCode converter coverage and specs

* Add Codex target support and spec docs

* Generate Codex command skills and refresh spec docs

* Add global Codex install path

* fix: harden plugin path loading and codex descriptions

* feat: ensure codex agents block on convert/install

* docs: clarify target branch usage for review

* chore: prep npm package metadata and release notes

* docs: mention opencode and codex in changelog

* docs: update CLI usage and remove stale todos

* feat: install from GitHub with global outputs
This commit is contained in:
Kieran Klaassen
2026-01-21 17:00:30 -08:00
committed by GitHub
parent c50208d413
commit e97f85bd53
61 changed files with 3303 additions and 5 deletions

View File

@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { loadClaudePlugin } from "../src/parsers/claude"
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
const mcpFixtureRoot = path.join(import.meta.dir, "fixtures", "mcp-file")
const customPathsRoot = path.join(import.meta.dir, "fixtures", "custom-paths")
const invalidCommandPathRoot = path.join(import.meta.dir, "fixtures", "invalid-command-path")
const invalidHooksPathRoot = path.join(import.meta.dir, "fixtures", "invalid-hooks-path")
const invalidMcpPathRoot = path.join(import.meta.dir, "fixtures", "invalid-mcp-path")
describe("loadClaudePlugin", () => {
test("loads manifest, agents, commands, skills, hooks", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
expect(plugin.manifest.name).toBe("compound-engineering")
expect(plugin.agents.length).toBe(2)
expect(plugin.commands.length).toBe(6)
expect(plugin.skills.length).toBe(1)
expect(plugin.hooks).toBeDefined()
expect(plugin.mcpServers).toBeDefined()
const researchAgent = plugin.agents.find((agent) => agent.name === "repo-research-analyst")
expect(researchAgent?.capabilities).toEqual(["Capability A", "Capability B"])
const reviewCommand = plugin.commands.find((command) => command.name === "workflows:review")
expect(reviewCommand?.allowedTools).toEqual([
"Read",
"Write",
"Edit",
"Bash(ls:*)",
"Bash(git:*)",
"Grep",
"Glob",
"List",
"Patch",
"Task",
])
const planReview = plugin.commands.find((command) => command.name === "plan_review")
expect(planReview?.allowedTools).toEqual(["Read", "Edit"])
const skillCommand = plugin.commands.find((command) => command.name === "create-agent-skill")
expect(skillCommand?.allowedTools).toEqual(["Skill(create-agent-skills)"])
const modelCommand = plugin.commands.find((command) => command.name === "workflows:work")
expect(modelCommand?.allowedTools).toEqual(["WebFetch"])
const patternCommand = plugin.commands.find((command) => command.name === "report-bug")
expect(patternCommand?.allowedTools).toEqual(["Read(.env)", "Bash(git:*)"])
const planCommand = plugin.commands.find((command) => command.name === "workflows:plan")
expect(planCommand?.allowedTools).toEqual(["Question", "TodoWrite", "TodoRead"])
expect(plugin.mcpServers?.context7?.url).toBe("https://mcp.context7.com/mcp")
})
test("loads MCP servers from .mcp.json when manifest is empty", async () => {
const plugin = await loadClaudePlugin(mcpFixtureRoot)
expect(plugin.mcpServers?.remote?.url).toBe("https://example.com/stream")
})
test("merges default and custom component paths", async () => {
const plugin = await loadClaudePlugin(customPathsRoot)
expect(plugin.agents.map((agent) => agent.name).sort()).toEqual(["custom-agent", "default-agent"])
expect(plugin.commands.map((command) => command.name).sort()).toEqual(["custom-command", "default-command"])
expect(plugin.skills.map((skill) => skill.name).sort()).toEqual(["custom-skill", "default-skill"])
expect(plugin.hooks?.hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("echo default")
expect(plugin.hooks?.hooks.PostToolUse?.[0]?.hooks[0]?.command).toBe("echo custom")
})
test("rejects custom component paths that escape the plugin root", async () => {
await expect(loadClaudePlugin(invalidCommandPathRoot)).rejects.toThrow(
"Invalid commands path: ../outside-commands. Paths must stay within the plugin root.",
)
})
test("rejects hook paths that escape the plugin root", async () => {
await expect(loadClaudePlugin(invalidHooksPathRoot)).rejects.toThrow(
"Invalid hooks path: ../outside-hooks.json. Paths must stay within the plugin root.",
)
})
test("rejects MCP paths that escape the plugin root", async () => {
await expect(loadClaudePlugin(invalidMcpPathRoot)).rejects.toThrow(
"Invalid mcpServers path: ../outside-mcp.json. Paths must stay within the plugin root.",
)
})
})

289
tests/cli.test.ts Normal file
View File

@@ -0,0 +1,289 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
async function runGit(args: string[], cwd: string, env?: NodeJS.ProcessEnv): Promise<void> {
const proc = Bun.spawn(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
env: env ?? process.env,
})
const exitCode = await proc.exited
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`git ${args.join(" ")} failed (exit ${exitCode}).\nstderr: ${stderr}`)
}
}
describe("CLI", () => {
test("install converts fixture plugin to OpenCode output", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-opencode-"))
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
const proc = Bun.spawn([
"bun",
"run",
"src/index.ts",
"install",
fixtureRoot,
"--to",
"opencode",
"--output",
tempRoot,
], {
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("Installed compound-engineering")
expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "agents", "repo-research-analyst.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "agents", "security-sentinel.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "plugins", "converted-hooks.ts"))).toBe(true)
})
test("install defaults output to ~/.opencode", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-local-default-"))
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
const repoRoot = path.join(import.meta.dir, "..")
const proc = Bun.spawn([
"bun",
"run",
path.join(repoRoot, "src", "index.ts"),
"install",
fixtureRoot,
"--to",
"opencode",
], {
cwd: tempRoot,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
HOME: tempRoot,
},
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("Installed compound-engineering")
expect(await exists(path.join(tempRoot, ".opencode", "opencode.json"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "agents", "repo-research-analyst.md"))).toBe(true)
})
test("list returns plugins in a temp workspace", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-list-"))
const pluginsRoot = path.join(tempRoot, "plugins", "demo-plugin", ".claude-plugin")
await fs.mkdir(pluginsRoot, { recursive: true })
await fs.writeFile(path.join(pluginsRoot, "plugin.json"), "{\n \"name\": \"demo-plugin\",\n \"version\": \"1.0.0\"\n}\n")
const repoRoot = path.join(import.meta.dir, "..")
const proc = Bun.spawn(["bun", "run", path.join(repoRoot, "src", "index.ts"), "list"], {
cwd: tempRoot,
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("demo-plugin")
})
test("install pulls from GitHub when local path is missing", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-github-install-"))
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-github-workspace-"))
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-github-repo-"))
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
const pluginRoot = path.join(repoRoot, "plugins", "compound-engineering")
await fs.mkdir(path.dirname(pluginRoot), { recursive: true })
await fs.cp(fixtureRoot, pluginRoot, { recursive: true })
const gitEnv = {
...process.env,
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "test@example.com",
}
await runGit(["init"], repoRoot, gitEnv)
await runGit(["add", "."], repoRoot, gitEnv)
await runGit(["commit", "-m", "fixture"], repoRoot, gitEnv)
const projectRoot = path.join(import.meta.dir, "..")
const proc = Bun.spawn([
"bun",
"run",
path.join(projectRoot, "src", "index.ts"),
"install",
"compound-engineering",
"--to",
"opencode",
], {
cwd: workspaceRoot,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
HOME: tempRoot,
COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot,
},
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("Installed compound-engineering")
expect(await exists(path.join(tempRoot, ".opencode", "opencode.json"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "agents", "repo-research-analyst.md"))).toBe(true)
})
test("convert writes OpenCode output", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-convert-"))
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
const proc = Bun.spawn([
"bun",
"run",
"src/index.ts",
"convert",
fixtureRoot,
"--to",
"opencode",
"--output",
tempRoot,
], {
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("Converted compound-engineering")
expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true)
})
test("convert supports --codex-home for codex output", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-codex-home-"))
const codexRoot = path.join(tempRoot, ".codex")
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
const proc = Bun.spawn([
"bun",
"run",
"src/index.ts",
"convert",
fixtureRoot,
"--to",
"codex",
"--codex-home",
codexRoot,
], {
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("Converted compound-engineering")
expect(stdout).toContain(codexRoot)
expect(await exists(path.join(codexRoot, "prompts", "workflows-review.md"))).toBe(true)
expect(await exists(path.join(codexRoot, "skills", "workflows-review", "SKILL.md"))).toBe(true)
expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true)
})
test("install supports --also with codex output", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-also-"))
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
const codexRoot = path.join(tempRoot, ".codex")
const proc = Bun.spawn([
"bun",
"run",
"src/index.ts",
"install",
fixtureRoot,
"--to",
"opencode",
"--also",
"codex",
"--codex-home",
codexRoot,
"--output",
tempRoot,
], {
cwd: path.join(import.meta.dir, ".."),
stdout: "pipe",
stderr: "pipe",
})
const exitCode = await proc.exited
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
if (exitCode !== 0) {
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
}
expect(stdout).toContain("Installed compound-engineering")
expect(stdout).toContain(codexRoot)
expect(await exists(path.join(codexRoot, "prompts", "workflows-review.md"))).toBe(true)
expect(await exists(path.join(codexRoot, "skills", "workflows-review", "SKILL.md"))).toBe(true)
expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true)
})
})

View File

@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import {
CODEX_AGENTS_BLOCK_END,
CODEX_AGENTS_BLOCK_START,
ensureCodexAgentsFile,
} from "../src/utils/codex-agents"
async function readFile(filePath: string): Promise<string> {
return fs.readFile(filePath, "utf8")
}
describe("ensureCodexAgentsFile", () => {
test("creates AGENTS.md with managed block when missing", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agents-"))
await ensureCodexAgentsFile(tempRoot)
const agentsPath = path.join(tempRoot, "AGENTS.md")
const content = await readFile(agentsPath)
expect(content).toContain(CODEX_AGENTS_BLOCK_START)
expect(content).toContain("Tool mapping")
expect(content).toContain(CODEX_AGENTS_BLOCK_END)
})
test("appends block without touching existing content", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agents-existing-"))
const agentsPath = path.join(tempRoot, "AGENTS.md")
await fs.writeFile(agentsPath, "# My Rules\n\nKeep this.")
await ensureCodexAgentsFile(tempRoot)
const content = await readFile(agentsPath)
expect(content).toContain("# My Rules")
expect(content).toContain("Keep this.")
expect(content).toContain(CODEX_AGENTS_BLOCK_START)
expect(content).toContain(CODEX_AGENTS_BLOCK_END)
})
test("replaces only the managed block when present", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agents-update-"))
const agentsPath = path.join(tempRoot, "AGENTS.md")
const seed = [
"Intro text",
CODEX_AGENTS_BLOCK_START,
"old content",
CODEX_AGENTS_BLOCK_END,
"Footer text",
].join("\n")
await fs.writeFile(agentsPath, seed)
await ensureCodexAgentsFile(tempRoot)
const content = await readFile(agentsPath)
expect(content).toContain("Intro text")
expect(content).toContain("Footer text")
expect(content).not.toContain("old content")
expect(content).toContain(CODEX_AGENTS_BLOCK_START)
expect(content).toContain(CODEX_AGENTS_BLOCK_END)
})
})

View File

@@ -0,0 +1,121 @@
import { describe, expect, test } from "bun:test"
import { convertClaudeToCodex } from "../src/converters/claude-to-codex"
import { parseFrontmatter } from "../src/utils/frontmatter"
import type { ClaudePlugin } from "../src/types/claude"
const fixturePlugin: ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [
{
name: "Security Reviewer",
description: "Security-focused agent",
capabilities: ["Threat modeling", "OWASP"],
model: "claude-sonnet-4-20250514",
body: "Focus on vulnerabilities.",
sourcePath: "/tmp/plugin/agents/security-reviewer.md",
},
],
commands: [
{
name: "workflows:plan",
description: "Planning command",
argumentHint: "[FOCUS]",
model: "inherit",
allowedTools: ["Read"],
body: "Plan the work.",
sourcePath: "/tmp/plugin/commands/workflows/plan.md",
},
],
skills: [
{
name: "existing-skill",
description: "Existing skill",
sourceDir: "/tmp/plugin/skills/existing-skill",
skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md",
},
],
hooks: undefined,
mcpServers: {
local: { command: "echo", args: ["hello"] },
},
}
describe("convertClaudeToCodex", () => {
test("converts commands to prompts and agents to skills", () => {
const bundle = convertClaudeToCodex(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(bundle.prompts).toHaveLength(1)
const prompt = bundle.prompts[0]
expect(prompt.name).toBe("workflows-plan")
const parsedPrompt = parseFrontmatter(prompt.content)
expect(parsedPrompt.data.description).toBe("Planning command")
expect(parsedPrompt.data["argument-hint"]).toBe("[FOCUS]")
expect(parsedPrompt.body).toContain("$workflows-plan")
expect(parsedPrompt.body).toContain("Plan the work.")
expect(bundle.skillDirs[0]?.name).toBe("existing-skill")
expect(bundle.generatedSkills).toHaveLength(2)
const commandSkill = bundle.generatedSkills.find((skill) => skill.name === "workflows-plan")
expect(commandSkill).toBeDefined()
const parsedCommandSkill = parseFrontmatter(commandSkill!.content)
expect(parsedCommandSkill.data.name).toBe("workflows-plan")
expect(parsedCommandSkill.data.description).toBe("Planning command")
expect(parsedCommandSkill.body).toContain("Allowed tools")
const agentSkill = bundle.generatedSkills.find((skill) => skill.name === "security-reviewer")
expect(agentSkill).toBeDefined()
const parsedSkill = parseFrontmatter(agentSkill!.content)
expect(parsedSkill.data.name).toBe("security-reviewer")
expect(parsedSkill.data.description).toBe("Security-focused agent")
expect(parsedSkill.body).toContain("Capabilities")
expect(parsedSkill.body).toContain("Threat modeling")
})
test("passes through MCP servers", () => {
const bundle = convertClaudeToCodex(fixturePlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(bundle.mcpServers?.local?.command).toBe("echo")
expect(bundle.mcpServers?.local?.args).toEqual(["hello"])
})
test("truncates generated skill descriptions to Codex limits and single line", () => {
const longDescription = `Line one\nLine two ${"a".repeat(2000)}`
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [
{
name: "Long Description Agent",
description: longDescription,
body: "Body",
sourcePath: "/tmp/plugin/agents/long.md",
},
],
commands: [],
skills: [],
}
const bundle = convertClaudeToCodex(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const generated = bundle.generatedSkills[0]
const parsed = parseFrontmatter(generated.content)
const description = String(parsed.data.description ?? "")
expect(description.length).toBeLessThanOrEqual(1024)
expect(description).not.toContain("\n")
expect(description.endsWith("...")).toBe(true)
})
})

View File

@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { writeCodexBundle } from "../src/targets/codex"
import type { CodexBundle } from "../src/types/codex"
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
describe("writeCodexBundle", () => {
test("writes prompts, skills, and config", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-test-"))
const bundle: CodexBundle = {
prompts: [{ name: "command-one", content: "Prompt content" }],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
generatedSkills: [{ name: "agent-skill", content: "Skill content" }],
mcpServers: {
local: { command: "echo", args: ["hello"], env: { KEY: "VALUE" } },
remote: {
url: "https://example.com/mcp",
headers: { Authorization: "Bearer token" },
},
},
}
await writeCodexBundle(tempRoot, bundle)
expect(await exists(path.join(tempRoot, ".codex", "prompts", "command-one.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".codex", "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".codex", "skills", "agent-skill", "SKILL.md"))).toBe(true)
const configPath = path.join(tempRoot, ".codex", "config.toml")
expect(await exists(configPath)).toBe(true)
const config = await fs.readFile(configPath, "utf8")
expect(config).toContain("[mcp_servers.local]")
expect(config).toContain("command = \"echo\"")
expect(config).toContain("args = [\"hello\"]")
expect(config).toContain("[mcp_servers.local.env]")
expect(config).toContain("KEY = \"VALUE\"")
expect(config).toContain("[mcp_servers.remote]")
expect(config).toContain("url = \"https://example.com/mcp\"")
expect(config).toContain("http_headers")
})
test("writes directly into a .codex output root", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-home-"))
const codexRoot = path.join(tempRoot, ".codex")
const bundle: CodexBundle = {
prompts: [{ name: "command-one", content: "Prompt content" }],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
generatedSkills: [],
}
await writeCodexBundle(codexRoot, bundle)
expect(await exists(path.join(codexRoot, "prompts", "command-one.md"))).toBe(true)
expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
})
})

171
tests/converter.test.ts Normal file
View File

@@ -0,0 +1,171 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { loadClaudePlugin } from "../src/parsers/claude"
import { convertClaudeToOpenCode } from "../src/converters/claude-to-opencode"
import { parseFrontmatter } from "../src/utils/frontmatter"
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
describe("convertClaudeToOpenCode", () => {
test("maps commands, permissions, and agents", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "from-commands",
})
expect(bundle.config.command?.["workflows:review"]).toBeDefined()
expect(bundle.config.command?.["plan_review"]).toBeDefined()
const permission = bundle.config.permission as Record<string, string | Record<string, string>>
expect(Object.keys(permission).sort()).toEqual([
"bash",
"edit",
"glob",
"grep",
"list",
"patch",
"question",
"read",
"skill",
"task",
"todoread",
"todowrite",
"webfetch",
"write",
])
expect(permission.edit).toBe("allow")
expect(permission.write).toBe("allow")
const bashPermission = permission.bash as Record<string, string>
expect(bashPermission["ls *"]).toBe("allow")
expect(bashPermission["git *"]).toBe("allow")
expect(permission.webfetch).toBe("allow")
const readPermission = permission.read as Record<string, string>
expect(readPermission["*"]).toBe("deny")
expect(readPermission[".env"]).toBe("allow")
expect(permission.question).toBe("allow")
expect(permission.todowrite).toBe("allow")
expect(permission.todoread).toBe("allow")
const agentFile = bundle.agents.find((agent) => agent.name === "repo-research-analyst")
expect(agentFile).toBeDefined()
const parsed = parseFrontmatter(agentFile!.content)
expect(parsed.data.mode).toBe("subagent")
})
test("normalizes models and infers temperature", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: true,
permissions: "none",
})
const securityAgent = bundle.agents.find((agent) => agent.name === "security-sentinel")
expect(securityAgent).toBeDefined()
const parsed = parseFrontmatter(securityAgent!.content)
expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514")
expect(parsed.data.temperature).toBe(0.1)
const modelCommand = bundle.config.command?.["workflows:work"]
expect(modelCommand?.model).toBe("openai/gpt-4o")
})
test("converts hooks into plugin file", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const hookFile = bundle.plugins.find((file) => file.name === "converted-hooks.ts")
expect(hookFile).toBeDefined()
expect(hookFile!.content).toContain("\"tool.execute.before\"")
expect(hookFile!.content).toContain("\"tool.execute.after\"")
expect(hookFile!.content).toContain("\"session.created\"")
expect(hookFile!.content).toContain("\"session.deleted\"")
expect(hookFile!.content).toContain("\"session.idle\"")
expect(hookFile!.content).toContain("\"experimental.session.compacting\"")
expect(hookFile!.content).toContain("\"permission.requested\"")
expect(hookFile!.content).toContain("\"permission.replied\"")
expect(hookFile!.content).toContain("\"message.created\"")
expect(hookFile!.content).toContain("\"message.updated\"")
expect(hookFile!.content).toContain("echo before")
expect(hookFile!.content).toContain("echo before two")
expect(hookFile!.content).toContain("// timeout: 30s")
expect(hookFile!.content).toContain("// Prompt hook for Write|Edit")
expect(hookFile!.content).toContain("// Agent hook for Write|Edit: security-sentinel")
})
test("converts MCP servers", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
const mcp = bundle.config.mcp ?? {}
expect(mcp["local-tooling"]).toEqual({
type: "local",
command: ["echo", "fixture"],
environment: undefined,
enabled: true,
})
expect(mcp.context7).toEqual({
type: "remote",
url: "https://mcp.context7.com/mcp",
headers: undefined,
enabled: true,
})
})
test("permission modes set expected keys", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const noneBundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})
expect(noneBundle.config.permission).toBeUndefined()
const broadBundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "broad",
})
expect(broadBundle.config.permission).toEqual({
read: "allow",
write: "allow",
edit: "allow",
bash: "allow",
grep: "allow",
glob: "allow",
list: "allow",
webfetch: "allow",
skill: "allow",
patch: "allow",
task: "allow",
question: "allow",
todowrite: "allow",
todoread: "allow",
})
})
test("supports primary agent mode", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "primary",
inferTemperature: false,
permissions: "none",
})
const agentFile = bundle.agents.find((agent) => agent.name === "repo-research-analyst")
const parsed = parseFrontmatter(agentFile!.content)
expect(parsed.data.mode).toBe("primary")
})
})

View File

@@ -0,0 +1,8 @@
{
"name": "custom-paths",
"version": "1.0.0",
"agents": "./custom-agents",
"commands": ["./custom-commands"],
"skills": "./custom-skills",
"hooks": "./custom-hooks/hooks.json"
}

View File

@@ -0,0 +1,5 @@
---
name: default-agent
---
Default agent

View File

@@ -0,0 +1,5 @@
---
name: default-command
---
Default command

View File

@@ -0,0 +1,5 @@
---
name: custom-agent
---
Custom agent

View File

@@ -0,0 +1,5 @@
---
name: custom-command
---
Custom command

View File

@@ -0,0 +1,7 @@
{
"hooks": {
"PostToolUse": [
{ "matcher": "Write", "hooks": [{ "type": "command", "command": "echo custom" }] }
]
}
}

View File

@@ -0,0 +1,5 @@
---
name: custom-skill
---
Custom skill

View File

@@ -0,0 +1,7 @@
{
"hooks": {
"PreToolUse": [
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "echo default" }] }
]
}
}

View File

@@ -0,0 +1,5 @@
---
name: default-skill
---
Default skill

View File

@@ -0,0 +1,5 @@
{
"name": "invalid-command-path",
"version": "1.0.0",
"commands": ["../outside-commands"]
}

View File

@@ -0,0 +1,5 @@
{
"name": "invalid-hooks-path",
"version": "1.0.0",
"hooks": ["../outside-hooks.json"]
}

View File

@@ -0,0 +1,5 @@
{
"name": "invalid-mcp-path",
"version": "1.0.0",
"mcpServers": ["../outside-mcp.json"]
}

View File

@@ -0,0 +1,5 @@
{
"name": "mcp-file",
"version": "1.0.0",
"description": "MCP file fixture"
}

6
tests/fixtures/mcp-file/.mcp.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"remote": {
"type": "sse",
"url": "https://example.com/stream"
}
}

View File

@@ -0,0 +1,30 @@
{
"name": "compound-engineering",
"version": "2.27.0",
"description": "Fixture aligned with the Compound Engineering plugin",
"author": {
"name": "Kieran Klaassen",
"email": "kieran@every.to",
"url": "https://github.com/kieranklaassen"
},
"homepage": "https://every.to/source-code/my-ai-had-already-fixed-the-code-before-i-saw-it",
"repository": "https://github.com/EveryInc/compound-engineering-plugin",
"license": "MIT",
"keywords": [
"compound-engineering",
"workflow-automation",
"code-review",
"agents"
],
"mcpServers": {
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp"
},
"local-tooling": {
"type": "stdio",
"command": "echo",
"args": ["fixture"]
}
}
}

View File

@@ -0,0 +1,10 @@
---
name: repo-research-analyst
description: Research repository structure and conventions
capabilities:
- "Capability A"
- "Capability B"
model: inherit
---
Repo research analyst body.

View File

@@ -0,0 +1,7 @@
---
name: security-sentinel
description: Security audits and vulnerability assessments
model: claude-sonnet-4-20250514
---
Security sentinel body.

View File

@@ -0,0 +1,7 @@
---
name: workflows:review
description: Run a multi-agent review workflow
allowed-tools: Read, Write, Edit, Bash(ls:*), Bash(git:*), Grep, Glob, List, Patch, Task
---
Workflows review body.

View File

@@ -0,0 +1,8 @@
---
name: workflows:work
description: Execute planned tasks step by step
model: gpt-4o
allowed-tools: WebFetch
---
Workflows work body.

View File

@@ -0,0 +1,9 @@
---
name: plan_review
description: Review a plan with multiple agents
allowed-tools:
- Read
- Edit
---
Plan review body.

View File

@@ -0,0 +1,7 @@
---
name: report-bug
description: Report a bug with structured context
allowed-tools: Read(.env), Bash(git:*)
---
Report bug body.

View File

@@ -0,0 +1,7 @@
---
name: create-agent-skill
description: Create or edit a Claude Code skill
allowed-tools: Skill(create-agent-skills)
---
Create agent skill body.

View File

@@ -0,0 +1,7 @@
---
name: workflows:plan
description: Create a structured plan from requirements
allowed-tools: Question, TodoWrite, TodoRead
---
Workflows plan body.

View File

@@ -0,0 +1,156 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo before",
"timeout": 30
},
{
"type": "command",
"command": "echo before two"
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "prompt",
"prompt": "After write"
},
{
"type": "agent",
"agent": "security-sentinel"
}
]
}
],
"PostToolUseFailure": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo failed"
}
]
}
],
"PermissionRequest": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "echo permission"
}
]
}
],
"UserPromptSubmit": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo prompt"
}
]
}
],
"Notification": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo notify"
}
]
}
],
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo session start"
}
]
}
],
"SessionEnd": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo session end"
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo stop"
}
]
}
],
"PreCompact": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo compact"
}
]
}
],
"Setup": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo setup"
}
]
}
],
"SubagentStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo subagent start"
}
]
}
],
"SubagentStop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "echo subagent stop"
}
]
}
]
}
}

View File

@@ -0,0 +1,6 @@
---
name: skill-one
description: Sample skill
---
Skill body.

20
tests/frontmatter.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import { describe, expect, test } from "bun:test"
import { formatFrontmatter, parseFrontmatter } from "../src/utils/frontmatter"
describe("frontmatter", () => {
test("parseFrontmatter returns body when no frontmatter", () => {
const raw = "Hello\nWorld"
const result = parseFrontmatter(raw)
expect(result.data).toEqual({})
expect(result.body).toBe(raw)
})
test("formatFrontmatter round trips", () => {
const body = "Body text"
const formatted = formatFrontmatter({ name: "agent", description: "Test" }, body)
const parsed = parseFrontmatter(formatted)
expect(parsed.data.name).toBe("agent")
expect(parsed.data.description).toBe("Test")
expect(parsed.body.trim()).toBe(body)
})
})

View File

@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test"
import { promises as fs } from "fs"
import path from "path"
import os from "os"
import { writeOpenCodeBundle } from "../src/targets/opencode"
import type { OpenCodeBundle } from "../src/types/opencode"
async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
describe("writeOpenCodeBundle", () => {
test("writes config, agents, plugins, and skills", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-"))
const bundle: OpenCodeBundle = {
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "agent-one", content: "Agent content" }],
plugins: [{ name: "hook.ts", content: "export {}" }],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
}
await writeOpenCodeBundle(tempRoot, bundle)
expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "agents", "agent-one.md"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "plugins", "hook.ts"))).toBe(true)
expect(await exists(path.join(tempRoot, ".opencode", "skills", "skill-one", "SKILL.md"))).toBe(true)
})
test("writes directly into a .opencode output root", async () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-root-"))
const outputRoot = path.join(tempRoot, ".opencode")
const bundle: OpenCodeBundle = {
config: { $schema: "https://opencode.ai/config.json" },
agents: [{ name: "agent-one", content: "Agent content" }],
plugins: [],
skillDirs: [
{
name: "skill-one",
sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"),
},
],
}
await writeOpenCodeBundle(outputRoot, bundle)
expect(await exists(path.join(outputRoot, "opencode.json"))).toBe(true)
expect(await exists(path.join(outputRoot, "agents", "agent-one.md"))).toBe(true)
expect(await exists(path.join(outputRoot, "skills", "skill-one", "SKILL.md"))).toBe(true)
expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false)
})
})