Skip to content

Commit 74a613f

Browse files
fix(opencode): generate slash-command stubs for each skill
Each skill installed to OpenCode now also writes a corresponding slash-command file under `~/.config/opencode/commands/<name>.md`. This lets users invoke /ce-work, /ce-plan, etc. directly in OpenCode, mirroring the experience Claude Code users have via native skill loading. Skills with `disable-model-invocation: true` are excluded, and platform-filtered skills (e.g. claude-only) never reach the converter. The `argument-hint` frontmatter field is forwarded from the skill when present.
1 parent 1f3c646 commit 74a613f

2 files changed

Lines changed: 55 additions & 4 deletions

File tree

src/converters/claude-to-opencode.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type ClaudeHooks,
77
type ClaudePlugin,
88
type ClaudeMcpServer,
9+
type ClaudeSkill,
910
filterSkillsByPlatform,
1011
} from "../types/claude"
1112
import type {
@@ -86,7 +87,13 @@ export function convertClaudeToOpenCode(
8687
options: ClaudeToOpenCodeOptions,
8788
): OpenCodeBundle {
8889
const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options))
89-
const cmdFiles = convertCommands(plugin.commands)
90+
const openCodeSkills = filterSkillsByPlatform(plugin.skills, "opencode")
91+
// Commands from the plugin's commands/ directory, plus one generated per skill
92+
// so each skill is invocable as a /ce-<name> slash command in OpenCode.
93+
const cmdFiles = [
94+
...convertCommands(plugin.commands),
95+
...convertSkillsToCommands(openCodeSkills),
96+
]
9097
const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined
9198
const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : []
9299

@@ -103,7 +110,7 @@ export function convertClaudeToOpenCode(
103110
agents: agentFiles,
104111
commandFiles: cmdFiles,
105112
plugins,
106-
skillDirs: filterSkillsByPlatform(plugin.skills, "opencode").map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
113+
skillDirs: openCodeSkills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
107114
}
108115
}
109116

@@ -154,6 +161,26 @@ function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] {
154161
return files
155162
}
156163

164+
// Generate a slash-command stub for each skill so OpenCode users can invoke
165+
// /ce-work, /ce-plan, etc. just like Claude Code users do.
166+
// The stub body delegates to the skill tool so the full skill content is loaded.
167+
function convertSkillsToCommands(skills: ClaudeSkill[]): OpenCodeCommandFile[] {
168+
const files: OpenCodeCommandFile[] = []
169+
for (const skill of skills) {
170+
if (skill.disableModelInvocation) continue
171+
const frontmatter: Record<string, unknown> = {
172+
description: skill.description,
173+
}
174+
if (skill.argumentHint) {
175+
frontmatter["argument-hint"] = skill.argumentHint
176+
}
177+
const body = `Load and execute the \`${skill.name}\` skill.\n\n$ARGUMENTS`
178+
const content = formatFrontmatter(frontmatter, body)
179+
files.push({ name: skill.name, content })
180+
}
181+
return files
182+
}
183+
157184
function convertMcp(servers: Record<string, ClaudeMcpServer>): Record<string, OpenCodeMcpServer> {
158185
const result: Record<string, OpenCodeMcpServer> = {}
159186
for (const [name, server] of Object.entries(servers)) {

tests/converter.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const compoundEngineeringRoot = path.join(
1515
)
1616

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

2626
expect(bundle.agents.length).toBeGreaterThan(0)
2727
expect(bundle.skillDirs.length).toBeGreaterThan(0)
28-
expect(bundle.commandFiles).toHaveLength(0)
28+
// Each skill now also generates a slash-command stub (skills with disable-model-invocation are excluded)
29+
expect(bundle.commandFiles.length).toBeGreaterThan(0)
30+
expect(bundle.commandFiles.length).toBeLessThanOrEqual(bundle.skillDirs.length)
2931
expect(bundle.plugins).toHaveLength(0)
3032
expect(bundle.config.tools).toBeUndefined()
3133

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

38+
test("skills generate slash-command stubs with description and argument-hint frontmatter", async () => {
39+
const plugin = await loadClaudePlugin(fixtureRoot)
40+
const bundle = convertClaudeToOpenCode(plugin, {
41+
agentMode: "subagent",
42+
inferTemperature: false,
43+
permissions: "none",
44+
})
45+
46+
// skill-one is the only opencode-eligible skill (disabled-skill is skipped, claude-only-skill is platform-filtered)
47+
const cmd = bundle.commandFiles.find((f) => f.name === "skill-one")
48+
expect(cmd).toBeDefined()
49+
const parsed = parseFrontmatter(cmd!.content)
50+
expect(parsed.data.description).toBe("Sample skill")
51+
expect(parsed.body.trim()).toContain("skill-one")
52+
expect(parsed.body.trim()).toContain("$ARGUMENTS")
53+
54+
// disabled-skill must not appear
55+
expect(bundle.commandFiles.find((f) => f.name === "disabled-skill")).toBeUndefined()
56+
// claude-only-skill is filtered before convertSkillsToCommands — also absent
57+
expect(bundle.commandFiles.find((f) => f.name === "claude-only-skill")).toBeUndefined()
58+
})
59+
3660
test("from-command mode: map allowedTools to global permission block", async () => {
3761
const plugin = await loadClaudePlugin(fixtureRoot)
3862
const bundle = convertClaudeToOpenCode(plugin, {

0 commit comments

Comments
 (0)