Skip to content

Commit 6bba41d

Browse files
fix(opencode): normalize command names before dedup to prevent cross-form collisions
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.
1 parent 785ffb2 commit 6bba41d

4 files changed

Lines changed: 53 additions & 5 deletions

File tree

src/converters/claude-to-opencode.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { formatFrontmatter } from "../utils/frontmatter"
22
import { normalizeModelWithProvider } from "../utils/model"
3+
import { commandNameToRelativePath } from "../utils/files"
34
import {
45
type ClaudeAgent,
56
type ClaudeCommand,
@@ -90,10 +91,13 @@ export function convertClaudeToOpenCode(
9091
const openCodeSkills = filterSkillsByPlatform(plugin.skills, "opencode")
9192
// Commands from the plugin's commands/ directory take priority; skill stubs
9293
// are only appended for names that don't already have an explicit command.
94+
// Dedup uses the normalized path key (colons → slashes) to match the writer's
95+
// on-disk layout — "foo:bar" and a skill named "foo/bar" both resolve to
96+
// commands/foo/bar.md, so they must be treated as the same command here.
9397
const explicitCommands = convertCommands(plugin.commands)
94-
const explicitCommandNames = new Set(explicitCommands.map((c) => c.name))
98+
const explicitCommandPaths = new Set(explicitCommands.map((c) => commandNameToRelativePath(c.name)))
9599
const skillStubs = convertSkillsToCommands(openCodeSkills).filter(
96-
(stub) => !explicitCommandNames.has(stub.name),
100+
(stub) => !explicitCommandPaths.has(commandNameToRelativePath(stub.name)),
97101
)
98102
const cmdFiles = [...explicitCommands, ...skillStubs]
99103
const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined

src/targets/opencode.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from "path"
2-
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJson, writeText } from "../utils/files"
2+
import { backupFile, commandNameToRelativePath, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJson, writeText } from "../utils/files"
33
import { transformSkillContentForOpenCode } from "../converters/claude-to-opencode"
44
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
55
import { getLegacyOpenCodeArtifacts } from "../data/plugin-legacy-artifacts"
@@ -70,7 +70,7 @@ export async function writeOpenCodeBundle(
7070
? await readManagedInstallManifestWithLegacyFallback(openCodePaths.managedDir, pluginName)
7171
: null
7272
const currentAgents = bundle.agents.map((agent) => `${sanitizePathName(agent.name)}.md`)
73-
const currentCommands = bundle.commandFiles.map((commandFile) => `${commandFile.name.split(":").join("/")}.md`)
73+
const currentCommands = bundle.commandFiles.map((commandFile) => `${commandNameToRelativePath(commandFile.name)}.md`)
7474
const currentPlugins = bundle.plugins.map((plugin) => plugin.name)
7575
const currentSkills = bundle.skillDirs.map((skill) => sanitizePathName(skill.name))
7676

@@ -103,7 +103,7 @@ export async function writeOpenCodeBundle(
103103
}
104104

105105
for (const commandFile of bundle.commandFiles) {
106-
const dest = path.join(openCodePaths.commandDir, ...commandFile.name.split(":")) + ".md"
106+
const dest = path.join(openCodePaths.commandDir, ...commandNameToRelativePath(commandFile.name).split("/")) + ".md"
107107
const cmdBackupPath = await backupFile(dest)
108108
if (cmdBackupPath) {
109109
console.log(`Backed up existing command file to ${cmdBackupPath}`)

src/utils/files.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,17 @@ export function isSafeManagedPath(rootDir: string, candidate: unknown): candidat
122122
return true
123123
}
124124

125+
/**
126+
* Normalizes a command name to the relative path key used by the OpenCode writer,
127+
* without touching the filesystem. Colons become path separators, matching the
128+
* `name.split(":")` logic in `writeOpenCodeBundle`.
129+
*
130+
* Example: `"foo:bar"` → `"foo/bar"`
131+
*/
132+
export function commandNameToRelativePath(name: string): string {
133+
return name.split(":").join("/")
134+
}
135+
125136
/**
126137
* Resolve a colon-separated command name into a filesystem path.
127138
* e.g. resolveCommandPath("/commands", "ce:plan", ".md") -> "/commands/ce/plan.md"

tests/converter.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,39 @@ describe("convertClaudeToOpenCode", () => {
7676
expect(names.length).toBe(uniqueNames.size)
7777
})
7878

79+
test("explicit command foo:bar blocks skill stub foo/bar from being emitted", async () => {
80+
// "foo:bar" (explicit command) and "foo/bar" (skill name) both normalize to
81+
// the same on-disk path commands/foo/bar.md — the skill stub must be dropped.
82+
const plugin: ClaudePlugin = {
83+
manifest: { name: "test-plugin", version: "1.0.0", description: "" },
84+
agents: [],
85+
commands: [{ name: "foo:bar", description: "explicit", body: "explicit body" }],
86+
skills: [
87+
{
88+
name: "foo/bar",
89+
description: "skill",
90+
sourceDir: "/tmp/fake-skill",
91+
platforms: [],
92+
},
93+
],
94+
mcpServers: undefined,
95+
hooks: undefined,
96+
}
97+
const bundle = convertClaudeToOpenCode(plugin, {
98+
agentMode: "subagent",
99+
inferTemperature: false,
100+
permissions: "none",
101+
})
102+
103+
const fooBarCommands = bundle.commandFiles.filter((f) =>
104+
f.name === "foo:bar" || f.name === "foo/bar",
105+
)
106+
// Only the explicit command should survive
107+
expect(fooBarCommands).toHaveLength(1)
108+
expect(fooBarCommands[0].name).toBe("foo:bar")
109+
expect(fooBarCommands[0].content).toContain("explicit body")
110+
})
111+
79112
test("from-command mode: map allowedTools to global permission block", async () => {
80113
const plugin = await loadClaudePlugin(fixtureRoot)
81114
const bundle = convertClaudeToOpenCode(plugin, {

0 commit comments

Comments
 (0)