fix: sanitize colons in skill/agent names for Windows path compatibility (#398)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
39
tests/path-sanitization.test.ts
Normal file
39
tests/path-sanitization.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user