123 lines
5.2 KiB
Markdown
123 lines
5.2 KiB
Markdown
---
|
|
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.
|