diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 50b378fb7..863412e7c 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -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 { @@ -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() + 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] const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : [] @@ -95,7 +116,7 @@ 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, @@ -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 })), } } @@ -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 + if (skill.userInvocable === false) continue + const frontmatter: Record = { + 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): Record { const result: Record = {} for (const [name, server] of Object.entries(servers)) { @@ -339,6 +381,7 @@ function applyPermissions( config: OpenCodeConfig, commands: ClaudeCommand[], mode: PermissionMode, + hasSkillStubs = false, ) { if (mode === "none") return @@ -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") + delete patterns["skill"] + } } const permission: Record> = {} diff --git a/src/parsers/claude.ts b/src/parsers/claude.ts index e989c269b..5262e91d5 100644 --- a/src/parsers/claude.ts +++ b/src/parsers/claude.ts @@ -107,12 +107,14 @@ async function loadSkills(skillsDirs: string[]): Promise { 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, diff --git a/src/targets/opencode.ts b/src/targets/opencode.ts index 4899c747a..377cdafb3 100644 --- a/src/targets/opencode.ts +++ b/src/targets/opencode.ts @@ -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" @@ -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)) @@ -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}`) diff --git a/src/types/claude.ts b/src/types/claude.ts index c9820415e..05080cb1d 100644 --- a/src/types/claude.ts +++ b/src/types/claude.ts @@ -49,6 +49,7 @@ export type ClaudeSkill = { description?: string argumentHint?: string disableModelInvocation?: boolean + userInvocable?: boolean ce_platforms?: string[] sourceDir: string skillPath: string diff --git a/src/utils/files.ts b/src/utils/files.ts index 35df1dcd4..289c245f3 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -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" diff --git a/tests/claude-parser.test.ts b/tests/claude-parser.test.ts index 48d462991..345cf4aa6 100644 --- a/tests/claude-parser.test.ts +++ b/tests/claude-parser.test.ts @@ -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() @@ -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") diff --git a/tests/converter.test.ts b/tests/converter.test.ts index aae363245..eccf513b3 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -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", @@ -25,7 +25,9 @@ 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() @@ -33,6 +35,83 @@ describe("convertClaudeToOpenCode", () => { 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, { @@ -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 + 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> + expect(permission.skill).toBe("allow") + }) + test("normalizes models and infers temperature", async () => { const plugin = await loadClaudePlugin(fixtureRoot) const bundle = convertClaudeToOpenCode(plugin, { diff --git a/tests/fixtures/sample-plugin/skills/agent-only-skill/SKILL.md b/tests/fixtures/sample-plugin/skills/agent-only-skill/SKILL.md new file mode 100644 index 000000000..334056a64 --- /dev/null +++ b/tests/fixtures/sample-plugin/skills/agent-only-skill/SKILL.md @@ -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.