---
title: "Colon-namespaced skill names break filesystem paths on Windows"
date: 2026-03-26
category: integration-issues
module: cli-converter
problem_type: integration_issue
component: tooling
symptoms:
  - "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"
root_cause: config_error
resolution_type: code_fix
severity: high
related_issues:
  - "https://github.com/EveryInc/compound-engineering-plugin/issues/366"
related_components:
  - targets
  - sync
  - converters
tags:
  - 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:

```typescript
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:

```typescript
// 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:

```typescript
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.
