fix: sanitize colons in skill/agent names for Windows path compatibility (#398)

This commit is contained in:
Trevin Chow
2026-03-26 16:15:48 -07:00
committed by GitHub
parent 0877b693ce
commit b25480af9e
31 changed files with 356 additions and 61 deletions

View File

@@ -216,7 +216,7 @@ describe("CLI", () => {
expect(stdout).toContain("Installed compound-engineering")
expect(stdout).toContain(codexRoot)
expect(await exists(path.join(codexRoot, "prompts", "ce-plan.md"))).toBe(true)
expect(await exists(path.join(codexRoot, "skills", "ce:plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(codexRoot, "skills", "ce-plan", "SKILL.md"))).toBe(true)
expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true)
})
@@ -690,7 +690,7 @@ describe("CLI", () => {
expect(stdout).toContain("Synced to gemini")
expect(stdout).not.toContain("cursor")
expect(await exists(path.join(tempHome, ".config", "opencode", "commands", "workflows:plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".config", "opencode", "commands", "workflows", "plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "config.toml"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "prompts", "workflows-plan.md"))).toBe(true)
expect(await exists(path.join(tempHome, ".codex", "skills", "workflows-plan", "SKILL.md"))).toBe(true)

View File

@@ -144,7 +144,7 @@ Use /deepen-plan for deeper research.
await writeCodexBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, ".codex", "skills", "ce:brainstorm", "SKILL.md"),
path.join(tempRoot, ".codex", "skills", "ce-brainstorm", "SKILL.md"),
"utf8",
)
expect(installedSkill).toContain("/prompts:ce-plan")
@@ -152,7 +152,7 @@ Use /deepen-plan for deeper research.
expect(installedSkill).toContain("/prompts:deepen-plan")
const notes = await fs.readFile(
path.join(tempRoot, ".codex", "skills", "ce:brainstorm", "notes.md"),
path.join(tempRoot, ".codex", "skills", "ce-brainstorm", "notes.md"),
"utf8",
)
expect(notes).toContain("/ce:plan")
@@ -194,7 +194,7 @@ Also run bare agents:
await writeCodexBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, ".codex", "skills", "ce:plan", "SKILL.md"),
path.join(tempRoot, ".codex", "skills", "ce-plan", "SKILL.md"),
"utf8",
)

View File

@@ -485,4 +485,35 @@ Task best-practices-researcher(topic)`
expect(result).toContain("the dhh-rails-reviewer agent")
expect(result).not.toContain("@security-sentinel")
})
test("generated skill deduplicates against sanitized pass-through skill names", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
agents: [],
commands: [
{
name: "ce:plan",
description: "Planning command",
model: "inherit",
allowedTools: [],
body: "Plan the work.",
sourcePath: "/tmp/plugin/commands/ce-plan.md",
},
],
skills: [
{
name: "ce:plan",
description: "Planning skill",
sourceDir: "/tmp/plugin/skills/ce-plan",
skillPath: "/tmp/plugin/skills/ce-plan/SKILL.md",
},
],
}
const bundle = convertClaudeToCopilot(plugin, defaultOptions)
// The generated skill from the command should get a deduplicated name
// since "ce:plan" and "ce-plan" both map to "ce-plan" on disk
expect(bundle.generatedSkills[0].name).not.toBe("ce-plan")
})
})

View File

@@ -193,7 +193,7 @@ Run these research agents:
await writeCopilotBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, ".github", "skills", "ce:plan", "SKILL.md"),
path.join(tempRoot, ".github", "skills", "ce-plan", "SKILL.md"),
"utf8",
)

View File

@@ -75,7 +75,7 @@ Run these research agents:
await writeDroidBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, ".factory", "skills", "ce:plan", "SKILL.md"),
path.join(tempRoot, ".factory", "skills", "ce-plan", "SKILL.md"),
"utf8",
)

View File

@@ -94,7 +94,7 @@ Run these research agents:
await writeGeminiBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, ".gemini", "skills", "ce:plan", "SKILL.md"),
path.join(tempRoot, ".gemini", "skills", "ce-plan", "SKILL.md"),
"utf8",
)

View File

@@ -126,7 +126,7 @@ Run these research agents:
await writeKiroBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, ".kiro", "skills", "ce:plan", "SKILL.md"),
path.join(tempRoot, ".kiro", "skills", "ce-plan", "SKILL.md"),
"utf8",
)

View File

@@ -113,7 +113,7 @@ describe("convertClaudeToOpenClaw", () => {
properties: {},
})
expect(bundle.manifest.skills).toContain("skills/agent-security-reviewer")
expect(bundle.manifest.skills).toContain("skills/cmd-workflows:plan")
expect(bundle.manifest.skills).toContain("skills/cmd-workflows-plan")
expect(bundle.manifest.skills).toContain("skills/existing-skill")
})
@@ -201,4 +201,27 @@ describe("convertClaudeToOpenClaw", () => {
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
expect(bundle.openclawConfig).toBeUndefined()
})
test("manifest skill paths use sanitized names matching filesystem output", () => {
const plugin: ClaudePlugin = {
...fixturePlugin,
skills: [
{
name: "ce:plan",
description: "Planning skill",
sourceDir: "/tmp/plugin/skills/ce-plan",
skillPath: "/tmp/plugin/skills/ce-plan/SKILL.md",
},
],
}
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
// Manifest paths must not contain colons
for (const skillPath of bundle.manifest.skills) {
expect(skillPath).not.toContain(":")
}
expect(bundle.manifest.skills).toContain("skills/ce-plan")
expect(bundle.manifest.skills).toContain("skills/cmd-workflows-plan")
})
})

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { loadClaudePlugin } from "../src/parsers/claude"
import { sanitizePathName } from "../src/utils/files"
const pluginRoot = path.join(process.cwd(), "plugins", "compound-engineering")
describe("sanitizePathName", () => {
test("replaces colons with hyphens", () => {
expect(sanitizePathName("ce:brainstorm")).toBe("ce-brainstorm")
expect(sanitizePathName("ce:plan")).toBe("ce-plan")
})
test("passes through names without colons", () => {
expect(sanitizePathName("frontend-design")).toBe("frontend-design")
})
test("handles multiple colons", () => {
expect(sanitizePathName("a:b:c")).toBe("a-b-c")
})
})
describe("path sanitization collision detection", () => {
test("no two skill names collide after sanitization", async () => {
const plugin = await loadClaudePlugin(pluginRoot)
const sanitized = plugin.skills.map((skill) => sanitizePathName(skill.name))
const unique = new Set(sanitized)
expect(unique.size).toBe(sanitized.length)
})
test("no two agent names collide after sanitization", async () => {
const plugin = await loadClaudePlugin(pluginRoot)
const sanitized = plugin.agents.map((agent) => sanitizePathName(agent.name))
const unique = new Set(sanitized)
expect(unique.size).toBe(sanitized.length)
})
})

View File

@@ -80,7 +80,7 @@ Run these research agents:
await writePiBundle(outputRoot, bundle)
const installedSkill = await fs.readFile(
path.join(outputRoot, "skills", "ce:plan", "SKILL.md"),
path.join(outputRoot, "skills", "ce-plan", "SKILL.md"),
"utf8",
)

View File

@@ -592,3 +592,42 @@ describe("normalizeName", () => {
expect(normalizeName("123-agent")).toBe("item")
})
})
describe("convertClaudeToWindsurf dedupe", () => {
test("agent skill deduplicates against sanitized pass-through skill names", () => {
const { convertClaudeToWindsurf } = require("../src/converters/claude-to-windsurf")
const plugin: import("../src/types/claude").ClaudePlugin = {
root: "/tmp/plugin",
manifest: { name: "fixture", version: "1.0.0" },
agents: [
{
name: "ce:plan",
description: "Planning agent",
body: "Plan things.",
sourcePath: "/tmp/plugin/agents/ce-plan.md",
},
],
commands: [],
skills: [
{
name: "ce:plan",
description: "Planning skill",
sourceDir: "/tmp/plugin/skills/ce-plan",
skillPath: "/tmp/plugin/skills/ce-plan/SKILL.md",
},
],
hooks: undefined,
mcpServers: undefined,
}
const bundle = convertClaudeToWindsurf(plugin, {
agentMode: "subagent" as const,
inferTemperature: false,
permissions: "none" as const,
})
// The agent skill should get a deduplicated name since "ce:plan" normalizes
// to "ce-plan" which collides with the pass-through skill on disk
expect(bundle.agentSkills[0].name).not.toBe("ce-plan")
})
})

View File

@@ -112,7 +112,7 @@ Run these research agents:
await writeWindsurfBundle(tempRoot, bundle)
const installedSkill = await fs.readFile(
path.join(tempRoot, "skills", "ce:plan", "SKILL.md"),
path.join(tempRoot, "skills", "ce-plan", "SKILL.md"),
"utf8",
)