From 74a613f92a59086b0ff6c83eefc54fe1809a110a Mon Sep 17 00:00:00 2001 From: lucabattistini Date: Tue, 5 May 2026 12:50:34 +0200 Subject: [PATCH 1/7] 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/.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. --- src/converters/claude-to-opencode.ts | 31 ++++++++++++++++++++++++++-- tests/converter.test.ts | 28 +++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 50b378fb7..e6a9228d0 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -6,6 +6,7 @@ import { type ClaudeHooks, type ClaudePlugin, type ClaudeMcpServer, + type ClaudeSkill, filterSkillsByPlatform, } from "../types/claude" import type { @@ -86,7 +87,13 @@ 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, plus one generated per skill + // so each skill is invocable as a /ce- slash command in OpenCode. + const cmdFiles = [ + ...convertCommands(plugin.commands), + ...convertSkillsToCommands(openCodeSkills), + ] const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : [] @@ -103,7 +110,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 +161,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 + 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)) { diff --git a/tests/converter.test.ts b/tests/converter.test.ts index aae363245..123cae45a 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,28 @@ 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) + 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("from-command mode: map allowedTools to global permission block", async () => { const plugin = await loadClaudePlugin(fixtureRoot) const bundle = convertClaudeToOpenCode(plugin, { From 785ffb210b8712b6967e084f86f08313858f43de Mon Sep 17 00:00:00 2001 From: lucabattistini Date: Tue, 5 May 2026 12:59:56 +0200 Subject: [PATCH 2/7] =?UTF-8?q?fix(opencode):=20deduplicate=20command=20na?= =?UTF-8?q?mes=20=E2=80=94=20explicit=20commands=20win=20over=20skill=20st?= =?UTF-8?q?ubs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If a plugin ships both a commands/ entry and a same-named skill, the explicit command body previously could be silently overwritten by the generic skill stub. Now the skill stub is filtered out whenever an explicit command with the same name already exists. Addresses review feedback from PR #776. --- src/converters/claude-to-opencode.ts | 14 ++++++++------ tests/converter.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index e6a9228d0..9e5e25a3c 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -88,12 +88,14 @@ export function convertClaudeToOpenCode( ): OpenCodeBundle { const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options)) const openCodeSkills = filterSkillsByPlatform(plugin.skills, "opencode") - // Commands from the plugin's commands/ directory, plus one generated per skill - // so each skill is invocable as a /ce- slash command in OpenCode. - const cmdFiles = [ - ...convertCommands(plugin.commands), - ...convertSkillsToCommands(openCodeSkills), - ] + // 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), + ) + const cmdFiles = [...explicitCommands, ...skillStubs] const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : [] diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 123cae45a..530b0d4f8 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -57,6 +57,25 @@ describe("convertClaudeToOpenCode", () => { 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, { From 6bba41d3826652c95fede83df331ac442a86b6ef Mon Sep 17 00:00:00 2001 From: lucabattistini Date: Tue, 5 May 2026 13:06:20 +0200 Subject: [PATCH 3/7] fix(opencode): normalize command names before dedup to prevent cross-form collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplication was comparing raw name strings, so an explicit command 'foo:bar' would not block a skill stub named 'foo/bar' — both resolve to commands/foo/bar.md on disk, causing the stub to silently overwrite the explicit command. Fix: extract commandNameToRelativePath() into src/utils/files.ts and use it in both the converter's dedup set and the writer's path resolution, so the same normalized key is used everywhere. Addresses second review comment on PR #776. --- src/converters/claude-to-opencode.ts | 8 +++++-- src/targets/opencode.ts | 6 ++--- src/utils/files.ts | 11 ++++++++++ tests/converter.test.ts | 33 ++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 9e5e25a3c..54273f23e 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -1,5 +1,6 @@ import { formatFrontmatter } from "../utils/frontmatter" import { normalizeModelWithProvider } from "../utils/model" +import { commandNameToRelativePath } from "../utils/files" import { type ClaudeAgent, type ClaudeCommand, @@ -90,10 +91,13 @@ export function convertClaudeToOpenCode( 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 explicitCommandNames = new Set(explicitCommands.map((c) => c.name)) + const explicitCommandPaths = new Set(explicitCommands.map((c) => commandNameToRelativePath(c.name))) const skillStubs = convertSkillsToCommands(openCodeSkills).filter( - (stub) => !explicitCommandNames.has(stub.name), + (stub) => !explicitCommandPaths.has(commandNameToRelativePath(stub.name)), ) const cmdFiles = [...explicitCommands, ...skillStubs] const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined 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/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/converter.test.ts b/tests/converter.test.ts index 530b0d4f8..079936c73 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -76,6 +76,39 @@ describe("convertClaudeToOpenCode", () => { 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, { From 8bb53309725611f50b685adfe86c7702fe87b09a Mon Sep 17 00:00:00 2001 From: lucabattistini Date: Tue, 5 May 2026 13:44:12 +0200 Subject: [PATCH 4/7] fix(converters): deduplicate skill stubs by normalized path before appending Two skills whose names normalize to the same command path (e.g. 'foo:bar' and 'foo/bar') would both write to commands/foo/bar.md, silently overwriting the first. Deduplicate convertSkillsToCommands output by commandNameToRelativePath before appending, so only the first occurrence is kept. --- src/converters/claude-to-opencode.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 54273f23e..57498f262 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -96,9 +96,17 @@ export function convertClaudeToOpenCode( // 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))) - const skillStubs = convertSkillsToCommands(openCodeSkills).filter( - (stub) => !explicitCommandPaths.has(commandNameToRelativePath(stub.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)] : [] From 65e623ec27de520eb1ef1b9e730f5f9619001436 Mon Sep 17 00:00:00 2001 From: lucabattistini Date: Tue, 5 May 2026 13:54:01 +0200 Subject: [PATCH 5/7] fix(converters): skip user-invocable: false skills when generating command stubs Skills marked user-invocable: false (e.g. ce-session-inventory, ce-session-extract) are agent-only primitives and must not be exposed as user-facing slash commands. Parse the user-invocable frontmatter field into ClaudeSkill and filter these skills out in convertSkillsToCommands alongside the existing disable-model-invocation check. --- src/converters/claude-to-opencode.ts | 1 + src/parsers/claude.ts | 2 ++ src/types/claude.ts | 3 ++- tests/claude-parser.test.ts | 13 ++++++++++++- tests/converter.test.ts | 5 ++++- .../sample-plugin/skills/agent-only-skill/SKILL.md | 7 +++++++ 6 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/sample-plugin/skills/agent-only-skill/SKILL.md diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 57498f262..127356f8a 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -182,6 +182,7 @@ 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, } 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/types/claude.ts b/src/types/claude.ts index c9820415e..fa2c87dec 100644 --- a/src/types/claude.ts +++ b/src/types/claude.ts @@ -44,11 +44,12 @@ export type ClaudeCommand = { sourcePath: string } -export type ClaudeSkill = { + export type ClaudeSkill = { name: string description?: string argumentHint?: string disableModelInvocation?: boolean + userInvocable?: boolean ce_platforms?: string[] sourceDir: string skillPath: string 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 079936c73..51b982d90 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -43,7 +43,8 @@ describe("convertClaudeToOpenCode", () => { permissions: "none", }) - // skill-one is the only opencode-eligible skill (disabled-skill is skipped, claude-only-skill is platform-filtered) + // 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) @@ -55,6 +56,8 @@ describe("convertClaudeToOpenCode", () => { 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 () => { 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. From 031b2ad73526dff97447ef0cd7e4441b8c67dccb Mon Sep 17 00:00:00 2001 From: lucabattistini Date: Tue, 5 May 2026 14:00:55 +0200 Subject: [PATCH 6/7] fix(converters): always allow skill tool when emitting skill stubs under from-commands applyPermissions only scanned plugin.commands for allowedTools, so a skill-only plugin (or any plugin whose commands don't list 'skill') would produce permission.skill = "deny", immediately breaking every generated stub. When hasSkillStubs is true, unconditionally add 'skill' to the enabled set in from-commands mode. --- src/converters/claude-to-opencode.ts | 9 ++++++++- tests/converter.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 127356f8a..19a5491e0 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -116,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, @@ -381,6 +381,7 @@ function applyPermissions( config: OpenCodeConfig, commands: ClaudeCommand[], mode: PermissionMode, + hasSkillStubs = false, ) { if (mode === "none") return @@ -419,6 +420,12 @@ 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. + if (hasSkillStubs) { + enabled.add("skill") + } } const permission: Record> = {} diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 51b982d90..8a0132f60 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -163,6 +163,30 @@ 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("normalizes models and infers temperature", async () => { const plugin = await loadClaudePlugin(fixtureRoot) const bundle = convertClaudeToOpenCode(plugin, { From 6f10ed9418ac6763a8a9048bcaa9e18b93ee332d Mon Sep 17 00:00:00 2001 From: lucabattistini Date: Tue, 5 May 2026 14:13:33 +0200 Subject: [PATCH 7/7] fix(converters): clear patterned skill rules when emitting stubs under from-commands When a command uses skill(foo-*), patterns["skill"] is populated and the permission build emits { "*": "deny", "foo-*": "allow" }, blocking all stubs for skills outside the pattern even though enabled.add("skill") was set. Fix: delete patterns["skill"] when hasSkillStubs is true so the build emits a flat "allow" for the skill tool, allowing all generated stubs to execute. Also fix stray leading space on ClaudeSkill type declaration. --- src/converters/claude-to-opencode.ts | 6 +++++- src/types/claude.ts | 2 +- tests/converter.test.ts | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 19a5491e0..863412e7c 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -422,9 +422,13 @@ 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. + // 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"] } } diff --git a/src/types/claude.ts b/src/types/claude.ts index fa2c87dec..05080cb1d 100644 --- a/src/types/claude.ts +++ b/src/types/claude.ts @@ -44,7 +44,7 @@ export type ClaudeCommand = { sourcePath: string } - export type ClaudeSkill = { +export type ClaudeSkill = { name: string description?: string argumentHint?: string diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 8a0132f60..eccf513b3 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -187,6 +187,27 @@ describe("convertClaudeToOpenCode", () => { 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, {