Skip to content
33 changes: 31 additions & 2 deletions src/converters/claude-to-opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ClaudeHooks,
type ClaudePlugin,
type ClaudeMcpServer,
type ClaudeSkill,
filterSkillsByPlatform,
} from "../types/claude"
import type {
Expand Down Expand Up @@ -86,7 +87,15 @@ export function convertClaudeToOpenCode(
options: ClaudeToOpenCodeOptions,
): OpenCodeBundle {
const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options))
const cmdFiles = convertCommands(plugin.commands)
const openCodeSkills = filterSkillsByPlatform(plugin.skills, "opencode")
// Commands from the plugin's commands/ directory take priority; skill stubs
// are only appended for names that don't already have an explicit command.
const explicitCommands = convertCommands(plugin.commands)
const explicitCommandNames = new Set(explicitCommands.map((c) => c.name))
const skillStubs = convertSkillsToCommands(openCodeSkills).filter(
(stub) => !explicitCommandNames.has(stub.name),
Comment thread
lucabattistini marked this conversation as resolved.
Outdated
)
const cmdFiles = [...explicitCommands, ...skillStubs]
Comment thread
lucabattistini marked this conversation as resolved.
const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined
const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : []

Expand All @@ -103,7 +112,7 @@ export function convertClaudeToOpenCode(
agents: agentFiles,
commandFiles: cmdFiles,
plugins,
skillDirs: filterSkillsByPlatform(plugin.skills, "opencode").map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
skillDirs: openCodeSkills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
}
}

Expand Down Expand Up @@ -154,6 +163,26 @@ function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] {
return files
}

// Generate a slash-command stub for each skill so OpenCode users can invoke
// /ce-work, /ce-plan, etc. just like Claude Code users do.
// The stub body delegates to the skill tool so the full skill content is loaded.
function convertSkillsToCommands(skills: ClaudeSkill[]): OpenCodeCommandFile[] {
const files: OpenCodeCommandFile[] = []
for (const skill of skills) {
if (skill.disableModelInvocation) continue
Comment thread
lucabattistini marked this conversation as resolved.
const frontmatter: Record<string, unknown> = {
description: skill.description,
}
if (skill.argumentHint) {
frontmatter["argument-hint"] = skill.argumentHint
}
const body = `Load and execute the \`${skill.name}\` skill.\n\n$ARGUMENTS`
const content = formatFrontmatter(frontmatter, body)
files.push({ name: skill.name, content })
}
return files
}

function convertMcp(servers: Record<string, ClaudeMcpServer>): Record<string, OpenCodeMcpServer> {
const result: Record<string, OpenCodeMcpServer> = {}
for (const [name, server] of Object.entries(servers)) {
Expand Down
47 changes: 45 additions & 2 deletions tests/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const compoundEngineeringRoot = path.join(
)

describe("convertClaudeToOpenCode", () => {
test("current compound-engineering output is skills and subagents, not commands", async () => {
test("current compound-engineering output has skills, subagents, and one command per skill", async () => {
const plugin = await loadClaudePlugin(compoundEngineeringRoot)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
Expand All @@ -25,14 +25,57 @@ describe("convertClaudeToOpenCode", () => {

expect(bundle.agents.length).toBeGreaterThan(0)
expect(bundle.skillDirs.length).toBeGreaterThan(0)
expect(bundle.commandFiles).toHaveLength(0)
// Each skill now also generates a slash-command stub (skills with disable-model-invocation are excluded)
expect(bundle.commandFiles.length).toBeGreaterThan(0)
expect(bundle.commandFiles.length).toBeLessThanOrEqual(bundle.skillDirs.length)
expect(bundle.plugins).toHaveLength(0)
expect(bundle.config.tools).toBeUndefined()

const parsedAgents = bundle.agents.map((agent) => parseFrontmatter(agent.content))
expect(parsedAgents.every((agent) => agent.data.mode === "subagent")).toBe(true)
})

test("skills generate slash-command stubs with description and argument-hint frontmatter", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})

// skill-one is the only opencode-eligible skill (disabled-skill is skipped, claude-only-skill is platform-filtered)
const cmd = bundle.commandFiles.find((f) => f.name === "skill-one")
expect(cmd).toBeDefined()
const parsed = parseFrontmatter(cmd!.content)
expect(parsed.data.description).toBe("Sample skill")
expect(parsed.body.trim()).toContain("skill-one")
expect(parsed.body.trim()).toContain("$ARGUMENTS")

// disabled-skill must not appear
expect(bundle.commandFiles.find((f) => f.name === "disabled-skill")).toBeUndefined()
// claude-only-skill is filtered before convertSkillsToCommands — also absent
expect(bundle.commandFiles.find((f) => f.name === "claude-only-skill")).toBeUndefined()
})

test("explicit command takes priority over same-named skill stub", async () => {
// If a plugin ships both a commands/foo and a skills/foo, the explicit
// command body must win — the skill stub must not overwrite it.
const plugin = await loadClaudePlugin(fixtureRoot)
// The fixture has a "review" command; confirm it is NOT replaced by a skill stub
// (the fixture has no skill named "review", but this test exercises the dedup path
// by checking that the command count equals explicit commands + non-colliding stubs)
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})

// No command name should appear more than once
const names = bundle.commandFiles.map((f) => f.name)
const uniqueNames = new Set(names)
expect(names.length).toBe(uniqueNames.size)
})

test("from-command mode: map allowedTools to global permission block", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
Expand Down