5.5 KiB
title, date, last_refreshed, category, module, problem_type, component, symptoms, root_cause, resolution_type, severity, related_issues, related_components, tags
| title | date | last_refreshed | 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 | 2026-06-20 | integration-issues | cli-converter | integration_issue | tooling |
|
config_error | code_fix | high |
|
|
|
Colon-namespaced skill names break filesystem paths on Windows
Problem
Earlier plugin versions allowed skill names containing colons (e.g., ce:brainstorm, ce:plan) to flow directly into target writer 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 present at the time because all used skill.name directly in path.join() calls. Current CE source skills are hyphenated (ce-brainstorm, ce-plan), but the sanitizer still matters for compatibility fixtures, imported third-party plugins, and legacy artifact cleanup.
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 two layers:
Layer 1: Target writers
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)))
Currently applied in the maintained target writers and managed-artifact cleanup path. When this fix was first written, a separate src/sync/ directory also held path-construction logic that needed the same treatment; that layer has since been consolidated into target writers.
Layer 2: Converter dedupe sets and manifests
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 converters that maintain dedupe sets — currently src/converters/claude-to-copilot.ts:
usedSkillNames.add(sanitizePathName(skill.name))instead of rawskill.name
Any future converter that maintains a name-collision set or emits a manifest must apply the same sanitization so the in-memory set matches the on-disk paths.
Why This Works
The core issue was a mismatch between the logical name domain (where older plugin data used colons as namespace separators) and the filesystem domain (where colons are illegal on Windows). The fix sanitizes at the boundary: legacy/imported names can keep colons in data structures, but paths use hyphens. Current CE source directories and frontmatter use hyphenated names directly (skills/ce-brainstorm/, name: ce-brainstorm), so the sanitizer is now primarily a compatibility guard.
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 managed artifact paths.
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 and compatibility artifact paths) 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.