Files
claude-engineering-plugin/docs/solutions/integrations/colon-namespaced-names-break-windows-paths-2026-03-26.md

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
ENOTDIR error when running bun convert on Windows
mkdir fails with '.config\opencode\skills\ce:brainstorm'
All target writers (opencode, codex, copilot, etc.) produce colon paths
config_error code_fix high
https://github.com/EveryInc/compound-engineering-plugin/issues/366
targets
sync
converters
windows
cross-platform
path-sanitization
skill-names
colons

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:

  1. It introduces unnecessary directory nesting for what's fundamentally a character-replacement problem
  2. The isValidSkillName and validatePathSafe functions reject / and \, so sanitized names would fail existing validation
  3. 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 raw skill.name
  • Windsurf: Same pattern for agent skill dedupe set
  • OpenClaw: Manifest skills array 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.