Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,13 @@ This prevents resolution failures when the plugin is installed alongside other p
- **Plans** live in `docs/plans/` — implementation plans and progress tracking.
- **Solutions** live in `docs/solutions/` — documented decisions and patterns.
- **Specs** live in `docs/specs/` — target platform format specifications.

### Solution categories (`docs/solutions/`)

This repo builds a plugin *for* developers. Categorize solutions from the perspective of the end user (a developer using the plugin), not a contributor to this repo.

- **`developer-experience/`** — Issues with contributing to *this repo*: local dev setup, shell aliases, test ergonomics, CI friction. If the fix only matters to someone with a checkout of this repo, it belongs here.
- **`integrations/`** — Issues where plugin output doesn't work correctly on a target platform or OS. Cross-platform bugs, target writer output problems, and converter compatibility issues go here.
- **`workflow/`**, **`skill-design/`** — Plugin skill and agent design patterns, workflow improvements.

When in doubt: if the bug affects someone running `bun install compound-engineering` or `bun convert`, it's an integration or product issue, not developer-experience.
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
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.
5 changes: 3 additions & 2 deletions src/converters/claude-to-copilot.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { sanitizePathName } from "../utils/files"
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
import type {
CopilotAgent,
Expand All @@ -21,9 +22,9 @@ export function convertClaudeToCopilot(

const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames))

// Reserve skill names first so generated skills (from commands) don't collide
// Reserve sanitized skill names so generated skills (from commands) don't collide on disk
const skillDirs = plugin.skills.map((skill) => {
usedSkillNames.add(skill.name)
usedSkillNames.add(sanitizePathName(skill.name))
return {
name: skill.name,
sourceDir: skill.sourceDir,
Expand Down
7 changes: 4 additions & 3 deletions src/converters/claude-to-openclaw.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { sanitizePathName } from "../utils/files"
import type {
ClaudeAgent,
ClaudeCommand,
Expand Down Expand Up @@ -33,9 +34,9 @@ export function convertClaudeToOpenClaw(
}))

const allSkillDirs = [
...agentSkills.map((s) => s.dir),
...commandSkills.map((s) => s.dir),
...plugin.skills.map((s) => s.name),
...agentSkills.map((s) => sanitizePathName(s.dir)),
...commandSkills.map((s) => sanitizePathName(s.dir)),
...plugin.skills.map((s) => sanitizePathName(s.name)),
]

const manifest = buildManifest(plugin, allSkillDirs)
Expand Down
6 changes: 4 additions & 2 deletions src/converters/claude-to-windsurf.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { formatFrontmatter } from "../utils/frontmatter"
import { sanitizePathName } from "../utils/files"
import { findServersWithPotentialSecrets } from "../utils/secrets"
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
import type { WindsurfBundle, WindsurfGeneratedSkill, WindsurfMcpConfig, WindsurfMcpServerEntry, WindsurfWorkflow } from "../types/windsurf"
Expand All @@ -20,8 +21,9 @@ export function convertClaudeToWindsurf(
sourceDir: skill.sourceDir,
}))

// Convert agents to skills (seed usedNames with pass-through skill names)
const usedSkillNames = new Set<string>(skillDirs.map((s) => s.name))
// Convert agents to skills (seed usedNames with sanitized pass-through skill names
// so generated agent skills detect collisions that would occur on disk)
const usedSkillNames = new Set<string>(skillDirs.map((s) => sanitizePathName(s.name)))
const agentSkills = plugin.agents.map((agent) =>
convertAgentToSkill(agent, knownAgentNames, usedSkillNames),
)
Expand Down
10 changes: 5 additions & 5 deletions src/sync/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudePlugin } from "../types/claude"
import { backupFile, writeText } from "../utils/files"
import { backupFile, resolveCommandPath, sanitizePathName, writeText } from "../utils/files"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
Expand Down Expand Up @@ -57,7 +57,7 @@ export async function syncOpenCodeCommands(
const bundle = convertClaudeToOpenCode(plugin, DEFAULT_SYNC_OPTIONS)

for (const commandFile of bundle.commandFiles) {
const commandPath = path.join(outputRoot, "commands", `${commandFile.name}.md`)
const commandPath = await resolveCommandPath(path.join(outputRoot, "commands"), commandFile.name, ".md")
const backupPath = await backupFile(commandPath)
if (backupPath) {
console.log(`Backed up existing command file to ${backupPath}`)
Expand All @@ -78,7 +78,7 @@ export async function syncCodexCommands(
await writeText(path.join(outputRoot, "prompts", `${prompt.name}.md`), prompt.content + "\n")
}
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", skill.name, "SKILL.md"), skill.content + "\n")
await writeText(path.join(outputRoot, "skills", sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}

Expand Down Expand Up @@ -121,7 +121,7 @@ export async function syncCopilotCommands(
const bundle = convertClaudeToCopilot(plugin, DEFAULT_SYNC_OPTIONS)

for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", skill.name, "SKILL.md"), skill.content + "\n")
await writeText(path.join(outputRoot, "skills", sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}

Expand All @@ -147,7 +147,7 @@ export async function syncKiroCommands(
const plugin = buildClaudeHomePlugin(config)
const bundle = convertClaudeToKiro(plugin, DEFAULT_SYNC_OPTIONS)
for (const skill of bundle.generatedSkills) {
await writeText(path.join(outputRoot, "skills", skill.name, "SKILL.md"), skill.content + "\n")
await writeText(path.join(outputRoot, "skills", sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/sync/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "fs/promises"
import path from "path"
import type { ClaudeHomeConfig } from "../parsers/claude-home"
import type { ClaudeMcpServer } from "../types/claude"
import { sanitizePathName } from "../utils/files"
import { syncGeminiCommands } from "./commands"
import { mergeJsonConfigAtKey } from "./json-config"
import { syncSkills } from "./skills"
Expand Down Expand Up @@ -85,7 +86,7 @@ async function removeGeminiMirrorConflicts(
sharedSkillsDir: string,
): Promise<void> {
for (const skill of skills) {
const duplicatePath = path.join(skillsDir, skill.name)
const duplicatePath = path.join(skillsDir, sanitizePathName(skill.name))

let stat
try {
Expand Down
4 changes: 2 additions & 2 deletions src/sync/skills.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from "path"
import type { ClaudeSkill } from "../types/claude"
import { ensureDir } from "../utils/files"
import { ensureDir, sanitizePathName } from "../utils/files"
import { forceSymlink, isValidSkillName } from "../utils/symlink"

export async function syncSkills(
Expand All @@ -15,7 +15,7 @@ export async function syncSkills(
continue
}

const target = path.join(skillsDir, skill.name)
const target = path.join(skillsDir, sanitizePathName(skill.name))
await forceSymlink(skill.sourceDir, target)
Comment thread
tmchow marked this conversation as resolved.
Outdated
}
}
6 changes: 3 additions & 3 deletions src/targets/codex.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, writeText } from "../utils/files"
import { backupFile, copySkillDir, ensureDir, sanitizePathName, writeText } from "../utils/files"
import type { CodexBundle } from "../types/codex"
import type { ClaudeMcpServer } from "../types/claude"
import { transformContentForCodex } from "../utils/codex-content"
Expand All @@ -20,7 +20,7 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
for (const skill of bundle.skillDirs) {
await copySkillDir(
skill.sourceDir,
path.join(skillsRoot, skill.name),
path.join(skillsRoot, sanitizePathName(skill.name)),
Comment thread
tmchow marked this conversation as resolved.
(content) => transformContentForCodex(content, bundle.invocationTargets, {
unknownSlashBehavior: "preserve",
}),
Expand All @@ -31,7 +31,7 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
if (bundle.generatedSkills.length > 0) {
const skillsRoot = path.join(codexRoot, "skills")
for (const skill of bundle.generatedSkills) {
await writeText(path.join(skillsRoot, skill.name, "SKILL.md"), skill.content + "\n")
await writeText(path.join(skillsRoot, sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/targets/copilot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, writeJson, writeText } from "../utils/files"
import { backupFile, copySkillDir, ensureDir, sanitizePathName, writeJson, writeText } from "../utils/files"
import { transformContentForCopilot } from "../converters/claude-to-copilot"
import type { CopilotBundle } from "../types/copilot"

Expand All @@ -10,21 +10,21 @@ export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBund
if (bundle.agents.length > 0) {
const agentsDir = path.join(paths.githubDir, "agents")
for (const agent of bundle.agents) {
await writeText(path.join(agentsDir, `${agent.name}.agent.md`), agent.content + "\n")
await writeText(path.join(agentsDir, `${sanitizePathName(agent.name)}.agent.md`), agent.content + "\n")
}
}

if (bundle.generatedSkills.length > 0) {
const skillsDir = path.join(paths.githubDir, "skills")
for (const skill of bundle.generatedSkills) {
await writeText(path.join(skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
await writeText(path.join(skillsDir, sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}

if (bundle.skillDirs.length > 0) {
const skillsDir = path.join(paths.githubDir, "skills")
for (const skill of bundle.skillDirs) {
await copySkillDir(skill.sourceDir, path.join(skillsDir, skill.name), transformContentForCopilot)
await copySkillDir(skill.sourceDir, path.join(skillsDir, sanitizePathName(skill.name)), transformContentForCopilot)
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/targets/droid.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "path"
import { copySkillDir, ensureDir, resolveCommandPath, writeText } from "../utils/files"
import { copySkillDir, ensureDir, resolveCommandPath, sanitizePathName, writeText } from "../utils/files"
import { transformContentForDroid } from "../converters/claude-to-droid"
import type { DroidBundle } from "../types/droid"

Expand All @@ -18,14 +18,14 @@ export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle):
if (bundle.droids.length > 0) {
await ensureDir(paths.droidsDir)
for (const droid of bundle.droids) {
await writeText(path.join(paths.droidsDir, `${droid.name}.md`), droid.content + "\n")
await writeText(path.join(paths.droidsDir, `${sanitizePathName(droid.name)}.md`), droid.content + "\n")
}
}

if (bundle.skillDirs.length > 0) {
await ensureDir(paths.skillsDir)
for (const skill of bundle.skillDirs) {
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, skill.name), transformContentForDroid)
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, sanitizePathName(skill.name)), transformContentForDroid)
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/targets/gemini.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "path"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, resolveCommandPath, writeJson, writeText } from "../utils/files"
import { backupFile, copySkillDir, ensureDir, pathExists, readJson, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files"
import { transformContentForGemini } from "../converters/claude-to-gemini"
import type { GeminiBundle } from "../types/gemini"

Expand All @@ -9,13 +9,13 @@ export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle

if (bundle.generatedSkills.length > 0) {
for (const skill of bundle.generatedSkills) {
await writeText(path.join(paths.skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
await writeText(path.join(paths.skillsDir, sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n")
}
}

if (bundle.skillDirs.length > 0) {
for (const skill of bundle.skillDirs) {
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, skill.name), transformContentForGemini)
await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, sanitizePathName(skill.name)), transformContentForGemini)
}
}

Expand Down
Loading