Files
everyinc--compound-engineer…/docs/solutions/integrations/colon-namespaced-names-break-windows-paths.md
2026-07-13 12:20:01 +08:00

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
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

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:

  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 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 raw skill.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.