5.2 KiB
title, date, category, module, problem_type, component, symptoms, root_cause, resolution_type, severity, related_issues, related_components, tags
| title | date | category | module | problem_type | component | symptoms | root_cause | resolution_type | severity | related_issues | related_components | tags | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Colon-namespaced skill names break filesystem paths on Windows | 2026-03-26 | integration-issues | cli-converter | integration_issue | tooling |
|
config_error | code_fix | high |
|
|
|
Colon-namespaced skill names break filesystem paths on Windows
Problem
Skill names containing colons (e.g., ce:brainstorm, ce:plan) were used directly as directory names in all target writers and sync paths. Colons are illegal in Windows filenames, causing ENOTDIR errors during bun convert or bun install.
Symptoms
{ [Error: ENOTDIR: not a directory, mkdir '.config\opencode\skills\ce:brainstorm']
code: 'ENOTDIR',
path: '.config\\opencode\\skills\\ce:brainstorm',
syscall: 'mkdir',
errno: -20 }
This affected every target (OpenCode, Codex, Copilot, Gemini, Kiro, Windsurf, Droid, OpenClaw, Pi, Qwen) because all used skill.name directly in path.join() calls.
What Didn't Work
Using / (forward slash) as the replacement character was initially considered — turning ce:brainstorm into nested directories ce/brainstorm/. This was rejected because:
- It introduces unnecessary directory nesting for what's fundamentally a character-replacement problem
- The
isValidSkillNameandvalidatePathSafefunctions reject/and\, so sanitized names would fail existing validation - The source directories already use hyphens (
skills/ce-brainstorm/), so the output should match
Solution
Added sanitizePathName() in src/utils/files.ts that replaces colons with hyphens:
export function sanitizePathName(name: string): string {
return name.replace(/:/g, "-")
}
Applied across three layers:
Layer 1: Target writers (10 files)
Every target writer wraps skill/agent names with sanitizePathName() when constructing output paths:
// Before
await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name))
// After
await copyDir(skill.sourceDir, path.join(skillsRoot, sanitizePathName(skill.name)))
Layer 2: Sync paths (3 files)
src/sync/skills.ts, src/sync/commands.ts, and src/sync/gemini.ts received the same treatment. Also fixed a pre-existing bug where syncOpenCodeCommands used raw path.join instead of resolveCommandPath for namespaced command names.
Layer 3: Converter dedupe sets and manifests (3 files)
Sanitizing paths in writers created a secondary bug: converter dedupe logic used unsanitized names, so a pass-through skill ce:plan and a generated skill normalizing to ce-plan wouldn't detect the collision — both would write to skills/ce-plan/ on disk.
Fixed in three converters:
- Copilot:
usedSkillNames.add(sanitizePathName(skill.name))instead of rawskill.name - Windsurf: Same pattern for agent skill dedupe set
- OpenClaw: Manifest
skillsarray now uses sanitized dir names, matching what the writer creates on disk
Why This Works
The core issue was a mismatch between the logical name domain (colons as namespace separators) and the filesystem domain (colons illegal on Windows). The fix sanitizes at the boundary — names keep colons in data structures and frontmatter, but paths use hyphens. This matches the source directory convention (skills/ce-brainstorm/ with frontmatter name: ce:brainstorm).
Prevention
1. Collision detection test
A test in tests/path-sanitization.test.ts loads the real compound-engineering plugin and verifies no two skill or agent names collide after sanitization:
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)
})
2. When adding names to filesystem paths
Always use sanitizePathName() when constructing output paths from skill, agent, or component names. Never pass skill.name or agent.name directly to path.join() in target writers or sync files.
3. When building dedupe sets in converters
If a converter reserves names for collision detection, the reserved names must be sanitized to match what the writer will produce on disk. Raw names in the set + normalized names from generators = missed collisions.
4. Inconsistency with resolveCommandPath
Note that resolveCommandPath (used for commands) converts colons to nested directories (ce:plan -> ce/plan.md), while sanitizePathName (used for skills/agents) converts to hyphens (ce:plan -> ce-plan). This is intentional — commands and skills are different surfaces with different resolution patterns. If a new component type is added, decide which pattern fits and document the choice.