Skip to content
45 changes: 43 additions & 2 deletions src/converters/claude-to-opencode.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { normalizeModelWithProvider } from "../utils/model"
import { commandNameToRelativePath } from "../utils/files"
import {
type ClaudeAgent,
type ClaudeCommand,
type ClaudeHooks,
type ClaudePlugin,
type ClaudeMcpServer,
type ClaudeSkill,
filterSkillsByPlatform,
} from "../types/claude"
import type {
Expand Down Expand Up @@ -86,7 +88,26 @@ 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.
// Dedup uses the normalized path key (colons → slashes) to match the writer's
// on-disk layout — "foo:bar" and a skill named "foo/bar" both resolve to
// commands/foo/bar.md, so they must be treated as the same command here.
const explicitCommands = convertCommands(plugin.commands)
const explicitCommandPaths = new Set(explicitCommands.map((c) => commandNameToRelativePath(c.name)))
// Also deduplicate skill stubs against each other by normalized path: two
// skills whose names normalize to the same path (e.g. "foo:bar" vs "foo/bar",
// or duplicate name frontmatter across skill dirs) would both write to the
// same commands/foo/bar.md. Keep only the first occurrence.
const seenStubPaths = new Set<string>()
const skillStubs = convertSkillsToCommands(openCodeSkills).filter((stub) => {
const normalizedPath = commandNameToRelativePath(stub.name)
if (explicitCommandPaths.has(normalizedPath) || seenStubPaths.has(normalizedPath)) return false
seenStubPaths.add(normalizedPath)
return true
})
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 +124,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 +175,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
6 changes: 3 additions & 3 deletions src/targets/opencode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJson, writeText } from "../utils/files"
import { backupFile, commandNameToRelativePath, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJson, writeText } from "../utils/files"
import { transformSkillContentForOpenCode } from "../converters/claude-to-opencode"
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
import { getLegacyOpenCodeArtifacts } from "../data/plugin-legacy-artifacts"
Expand Down Expand Up @@ -70,7 +70,7 @@ export async function writeOpenCodeBundle(
? await readManagedInstallManifestWithLegacyFallback(openCodePaths.managedDir, pluginName)
: null
const currentAgents = bundle.agents.map((agent) => `${sanitizePathName(agent.name)}.md`)
const currentCommands = bundle.commandFiles.map((commandFile) => `${commandFile.name.split(":").join("/")}.md`)
const currentCommands = bundle.commandFiles.map((commandFile) => `${commandNameToRelativePath(commandFile.name)}.md`)
const currentPlugins = bundle.plugins.map((plugin) => plugin.name)
const currentSkills = bundle.skillDirs.map((skill) => sanitizePathName(skill.name))

Expand Down Expand Up @@ -103,7 +103,7 @@ export async function writeOpenCodeBundle(
}

for (const commandFile of bundle.commandFiles) {
const dest = path.join(openCodePaths.commandDir, ...commandFile.name.split(":")) + ".md"
const dest = path.join(openCodePaths.commandDir, ...commandNameToRelativePath(commandFile.name).split("/")) + ".md"
const cmdBackupPath = await backupFile(dest)
if (cmdBackupPath) {
console.log(`Backed up existing command file to ${cmdBackupPath}`)
Expand Down
11 changes: 11 additions & 0 deletions src/utils/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,17 @@ export function isSafeManagedPath(rootDir: string, candidate: unknown): candidat
return true
}

/**
* Normalizes a command name to the relative path key used by the OpenCode writer,
* without touching the filesystem. Colons become path separators, matching the
* `name.split(":")` logic in `writeOpenCodeBundle`.
*
* Example: `"foo:bar"` → `"foo/bar"`
*/
export function commandNameToRelativePath(name: string): string {
return name.split(":").join("/")
}

/**
* Resolve a colon-separated command name into a filesystem path.
* e.g. resolveCommandPath("/commands", "ce:plan", ".md") -> "/commands/ce/plan.md"
Expand Down
80 changes: 78 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,90 @@ 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("explicit command foo:bar blocks skill stub foo/bar from being emitted", async () => {
// "foo:bar" (explicit command) and "foo/bar" (skill name) both normalize to
// the same on-disk path commands/foo/bar.md — the skill stub must be dropped.
const plugin: ClaudePlugin = {
manifest: { name: "test-plugin", version: "1.0.0", description: "" },
agents: [],
commands: [{ name: "foo:bar", description: "explicit", body: "explicit body" }],
skills: [
{
name: "foo/bar",
description: "skill",
sourceDir: "/tmp/fake-skill",
platforms: [],
},
],
mcpServers: undefined,
hooks: undefined,
}
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "none",
})

const fooBarCommands = bundle.commandFiles.filter((f) =>
f.name === "foo:bar" || f.name === "foo/bar",
)
// Only the explicit command should survive
expect(fooBarCommands).toHaveLength(1)
expect(fooBarCommands[0].name).toBe("foo:bar")
expect(fooBarCommands[0].content).toContain("explicit body")
})

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