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:
89
tests/claude-parser.test.ts
Normal file
89
tests/claude-parser.test.ts
Normal 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
289
tests/cli.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
62
tests/codex-agents.test.ts
Normal file
62
tests/codex-agents.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
121
tests/codex-converter.test.ts
Normal file
121
tests/codex-converter.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
76
tests/codex-writer.test.ts
Normal file
76
tests/codex-writer.test.ts
Normal 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
171
tests/converter.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
8
tests/fixtures/custom-paths/.claude-plugin/plugin.json
vendored
Normal file
8
tests/fixtures/custom-paths/.claude-plugin/plugin.json
vendored
Normal 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"
|
||||
}
|
||||
5
tests/fixtures/custom-paths/agents/default-agent.md
vendored
Normal file
5
tests/fixtures/custom-paths/agents/default-agent.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: default-agent
|
||||
---
|
||||
|
||||
Default agent
|
||||
5
tests/fixtures/custom-paths/commands/default-command.md
vendored
Normal file
5
tests/fixtures/custom-paths/commands/default-command.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: default-command
|
||||
---
|
||||
|
||||
Default command
|
||||
5
tests/fixtures/custom-paths/custom-agents/custom-agent.md
vendored
Normal file
5
tests/fixtures/custom-paths/custom-agents/custom-agent.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: custom-agent
|
||||
---
|
||||
|
||||
Custom agent
|
||||
5
tests/fixtures/custom-paths/custom-commands/custom-command.md
vendored
Normal file
5
tests/fixtures/custom-paths/custom-commands/custom-command.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: custom-command
|
||||
---
|
||||
|
||||
Custom command
|
||||
7
tests/fixtures/custom-paths/custom-hooks/hooks.json
vendored
Normal file
7
tests/fixtures/custom-paths/custom-hooks/hooks.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{ "matcher": "Write", "hooks": [{ "type": "command", "command": "echo custom" }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
5
tests/fixtures/custom-paths/custom-skills/custom-skill/SKILL.md
vendored
Normal file
5
tests/fixtures/custom-paths/custom-skills/custom-skill/SKILL.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: custom-skill
|
||||
---
|
||||
|
||||
Custom skill
|
||||
7
tests/fixtures/custom-paths/hooks/hooks.json
vendored
Normal file
7
tests/fixtures/custom-paths/hooks/hooks.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "echo default" }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
5
tests/fixtures/custom-paths/skills/default-skill/SKILL.md
vendored
Normal file
5
tests/fixtures/custom-paths/skills/default-skill/SKILL.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: default-skill
|
||||
---
|
||||
|
||||
Default skill
|
||||
5
tests/fixtures/invalid-command-path/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/invalid-command-path/.claude-plugin/plugin.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "invalid-command-path",
|
||||
"version": "1.0.0",
|
||||
"commands": ["../outside-commands"]
|
||||
}
|
||||
5
tests/fixtures/invalid-hooks-path/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/invalid-hooks-path/.claude-plugin/plugin.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "invalid-hooks-path",
|
||||
"version": "1.0.0",
|
||||
"hooks": ["../outside-hooks.json"]
|
||||
}
|
||||
5
tests/fixtures/invalid-mcp-path/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/invalid-mcp-path/.claude-plugin/plugin.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "invalid-mcp-path",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": ["../outside-mcp.json"]
|
||||
}
|
||||
5
tests/fixtures/mcp-file/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/mcp-file/.claude-plugin/plugin.json
vendored
Normal 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
6
tests/fixtures/mcp-file/.mcp.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"remote": {
|
||||
"type": "sse",
|
||||
"url": "https://example.com/stream"
|
||||
}
|
||||
}
|
||||
30
tests/fixtures/sample-plugin/.claude-plugin/plugin.json
vendored
Normal file
30
tests/fixtures/sample-plugin/.claude-plugin/plugin.json
vendored
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
10
tests/fixtures/sample-plugin/agents/agent-one.md
vendored
Normal file
10
tests/fixtures/sample-plugin/agents/agent-one.md
vendored
Normal 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.
|
||||
7
tests/fixtures/sample-plugin/agents/security-reviewer.md
vendored
Normal file
7
tests/fixtures/sample-plugin/agents/security-reviewer.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: security-sentinel
|
||||
description: Security audits and vulnerability assessments
|
||||
model: claude-sonnet-4-20250514
|
||||
---
|
||||
|
||||
Security sentinel body.
|
||||
7
tests/fixtures/sample-plugin/commands/command-one.md
vendored
Normal file
7
tests/fixtures/sample-plugin/commands/command-one.md
vendored
Normal 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.
|
||||
8
tests/fixtures/sample-plugin/commands/model-command.md
vendored
Normal file
8
tests/fixtures/sample-plugin/commands/model-command.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: workflows:work
|
||||
description: Execute planned tasks step by step
|
||||
model: gpt-4o
|
||||
allowed-tools: WebFetch
|
||||
---
|
||||
|
||||
Workflows work body.
|
||||
9
tests/fixtures/sample-plugin/commands/nested/command-two.md
vendored
Normal file
9
tests/fixtures/sample-plugin/commands/nested/command-two.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: plan_review
|
||||
description: Review a plan with multiple agents
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Edit
|
||||
---
|
||||
|
||||
Plan review body.
|
||||
7
tests/fixtures/sample-plugin/commands/pattern-command.md
vendored
Normal file
7
tests/fixtures/sample-plugin/commands/pattern-command.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: report-bug
|
||||
description: Report a bug with structured context
|
||||
allowed-tools: Read(.env), Bash(git:*)
|
||||
---
|
||||
|
||||
Report bug body.
|
||||
7
tests/fixtures/sample-plugin/commands/skill-command.md
vendored
Normal file
7
tests/fixtures/sample-plugin/commands/skill-command.md
vendored
Normal 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.
|
||||
7
tests/fixtures/sample-plugin/commands/todo-command.md
vendored
Normal file
7
tests/fixtures/sample-plugin/commands/todo-command.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: workflows:plan
|
||||
description: Create a structured plan from requirements
|
||||
allowed-tools: Question, TodoWrite, TodoRead
|
||||
---
|
||||
|
||||
Workflows plan body.
|
||||
156
tests/fixtures/sample-plugin/hooks/hooks.json
vendored
Normal file
156
tests/fixtures/sample-plugin/hooks/hooks.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
6
tests/fixtures/sample-plugin/skills/skill-one/SKILL.md
vendored
Normal file
6
tests/fixtures/sample-plugin/skills/skill-one/SKILL.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: skill-one
|
||||
description: Sample skill
|
||||
---
|
||||
|
||||
Skill body.
|
||||
20
tests/frontmatter.test.ts
Normal file
20
tests/frontmatter.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
62
tests/opencode-writer.test.ts
Normal file
62
tests/opencode-writer.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user