Skip to content
59 changes: 56 additions & 3 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 @@ -95,15 +116,15 @@ export function convertClaudeToOpenCode(
mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined,
}

applyPermissions(config, plugin.commands, options.permissions)
applyPermissions(config, plugin.commands, options.permissions, skillStubs.length > 0)

return {
pluginName: plugin.manifest.name,
config,
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,27 @@ 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.
if (skill.userInvocable === false) continue
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 Expand Up @@ -339,6 +381,7 @@ function applyPermissions(
config: OpenCodeConfig,
commands: ClaudeCommand[],
mode: PermissionMode,
hasSkillStubs = false,
) {
if (mode === "none") return

Expand Down Expand Up @@ -377,6 +420,16 @@ function applyPermissions(
}
}
}
// Skill stubs require the `skill` tool to load the skill at invocation time.
// If we're emitting stubs, ensure `skill` is allowed even when no explicit
// command listed it in allowed-tools, and even when a command listed a
// patterned skill(foo-*) rule: a pattern-scoped permission would still
// block stubs for skills outside the pattern. Clear any skill patterns so
// the permission build emits a flat "allow" for the skill tool.
if (hasSkillStubs) {
enabled.add("skill")
Comment thread
lucabattistini marked this conversation as resolved.
delete patterns["skill"]
}
}

const permission: Record<string, "allow" | "deny" | Record<string, "allow" | "deny">> = {}
Expand Down
2 changes: 2 additions & 0 deletions src/parsers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,14 @@ async function loadSkills(skillsDirs: string[]): Promise<ClaudeSkill[]> {
const { data } = parseFrontmatter(raw, file)
const name = (data.name as string) ?? path.basename(path.dirname(file))
const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined
const userInvocable = data["user-invocable"] === false ? false : undefined
const ce_platforms = Array.isArray(data.ce_platforms) ? (data.ce_platforms as string[]) : undefined
skills.push({
name,
description: data.description as string | undefined,
argumentHint: data["argument-hint"] as string | undefined,
disableModelInvocation,
userInvocable,
ce_platforms,
sourceDir: path.dirname(file),
skillPath: file,
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
1 change: 1 addition & 0 deletions src/types/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export type ClaudeSkill = {
description?: string
argumentHint?: string
disableModelInvocation?: boolean
userInvocable?: boolean
ce_platforms?: string[]
sourceDir: string
skillPath: string
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
13 changes: 12 additions & 1 deletion tests/claude-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe("loadClaudePlugin", () => {
expect(plugin.manifest.name).toBe("compound-engineering")
expect(plugin.agents.length).toBe(2)
expect(plugin.commands.length).toBe(7)
expect(plugin.skills.length).toBe(3)
expect(plugin.skills.length).toBe(4)
expect(plugin.hooks).toBeDefined()
expect(plugin.mcpServers).toBeDefined()

Expand Down Expand Up @@ -136,6 +136,17 @@ describe("loadClaudePlugin", () => {
expect(normalSkill?.disableModelInvocation).toBeUndefined()
})

test("parses user-invocable: false from skills", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)

const agentOnlySkill = plugin.skills.find((skill) => skill.name === "agent-only-skill")
expect(agentOnlySkill).toBeDefined()
expect(agentOnlySkill?.userInvocable).toBe(false)

const normalSkill = plugin.skills.find((skill) => skill.name === "skill-one")
expect(normalSkill?.userInvocable).toBeUndefined()
})

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")
Expand Down
128 changes: 126 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,93 @@ 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, agent-only-skill has user-invocable: false)
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()
// agent-only-skill has user-invocable: false — must not be exposed as a slash command
expect(bundle.commandFiles.find((f) => f.name === "agent-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 Expand Up @@ -84,6 +163,51 @@ describe("convertClaudeToOpenCode", () => {
expect(parsed.data.mode).toBe("subagent")
})

test("from-commands mode: skill tool is allowed when skill stubs are emitted", async () => {
// Regression: applyPermissions only scanned plugin.commands for allowedTools,
// so a plugin with no explicit commands (or none listing "skill") would get
// permission.skill = "deny", causing every generated stub to fail immediately.
const plugin = await loadClaudePlugin(fixtureRoot)
// Build a minimal plugin with skills but no explicit commands.
const skillOnlyPlugin: ClaudePlugin = {
...plugin,
commands: [],
}
const bundle = convertClaudeToOpenCode(skillOnlyPlugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "from-commands",
})

// Skill stubs should be emitted (skill-one is opencode-eligible)
expect(bundle.commandFiles.some((f) => f.name === "skill-one")).toBe(true)

// The skill tool must be allowed so the stubs can actually load the skill
const permission = bundle.config.permission as Record<string, string>
expect(permission.skill).toBe("allow")
})

test("from-commands mode: patterned skill(...) rule does not block other skill stubs", async () => {
// Regression: when a command uses skill(foo-*), patterns["skill"] is populated,
// causing the permission build to emit { "*": "deny", "foo-*": "allow" }.
// That blocks all stubs for skills outside the pattern even though hasSkillStubs
// forces enabled.add("skill"). The fix clears patterns["skill"] when hasSkillStubs is true.
const plugin = await loadClaudePlugin(fixtureRoot)
// The fixture has skill-command.md with allowed-tools: Skill(create-agent-skills).
// skill-one is the stub-eligible skill — it is NOT in the patterned subset.
const bundle = convertClaudeToOpenCode(plugin, {
agentMode: "subagent",
inferTemperature: false,
permissions: "from-commands",
})

expect(bundle.commandFiles.some((f) => f.name === "skill-one")).toBe(true)

// skill must be a flat "allow", not a pattern object that would deny skill-one
const permission = bundle.config.permission as Record<string, string | Record<string, string>>
expect(permission.skill).toBe("allow")
})

test("normalizes models and infers temperature", async () => {
const plugin = await loadClaudePlugin(fixtureRoot)
const bundle = convertClaudeToOpenCode(plugin, {
Expand Down
7 changes: 7 additions & 0 deletions tests/fixtures/sample-plugin/skills/agent-only-skill/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
name: agent-only-skill
description: An agent-only skill not intended for direct user invocation
user-invocable: false
---

Agent-only skill body.