diff --git a/AGENTS.md b/AGENTS.md index 5c52e5e..18083af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/solutions/integrations/colon-namespaced-names-break-windows-paths-2026-03-26.md b/docs/solutions/integrations/colon-namespaced-names-break-windows-paths-2026-03-26.md new file mode 100644 index 0000000..33eb724 --- /dev/null +++ b/docs/solutions/integrations/colon-namespaced-names-break-windows-paths-2026-03-26.md @@ -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. diff --git a/src/converters/claude-to-copilot.ts b/src/converters/claude-to-copilot.ts index 8ea573a..cd377e3 100644 --- a/src/converters/claude-to-copilot.ts +++ b/src/converters/claude-to-copilot.ts @@ -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, @@ -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, diff --git a/src/converters/claude-to-openclaw.ts b/src/converters/claude-to-openclaw.ts index ba2435c..677b40c 100644 --- a/src/converters/claude-to-openclaw.ts +++ b/src/converters/claude-to-openclaw.ts @@ -1,4 +1,5 @@ import { formatFrontmatter } from "../utils/frontmatter" +import { sanitizePathName } from "../utils/files" import type { ClaudeAgent, ClaudeCommand, @@ -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) diff --git a/src/converters/claude-to-windsurf.ts b/src/converters/claude-to-windsurf.ts index 4fa3f89..347b010 100644 --- a/src/converters/claude-to-windsurf.ts +++ b/src/converters/claude-to-windsurf.ts @@ -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" @@ -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(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(skillDirs.map((s) => sanitizePathName(s.name))) const agentSkills = plugin.agents.map((agent) => convertAgentToSkill(agent, knownAgentNames, usedSkillNames), ) diff --git a/src/sync/commands.ts b/src/sync/commands.ts index 03ca3fb..8fcfba1 100644 --- a/src/sync/commands.ts +++ b/src/sync/commands.ts @@ -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" @@ -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}`) @@ -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") } } @@ -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") } } @@ -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") } } diff --git a/src/sync/gemini.ts b/src/sync/gemini.ts index c1c5546..7255ff8 100644 --- a/src/sync/gemini.ts +++ b/src/sync/gemini.ts @@ -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" @@ -85,7 +86,7 @@ async function removeGeminiMirrorConflicts( sharedSkillsDir: string, ): Promise { for (const skill of skills) { - const duplicatePath = path.join(skillsDir, skill.name) + const duplicatePath = path.join(skillsDir, sanitizePathName(skill.name)) let stat try { diff --git a/src/sync/skills.ts b/src/sync/skills.ts index 1fde9f0..6689d28 100644 --- a/src/sync/skills.ts +++ b/src/sync/skills.ts @@ -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( @@ -9,13 +9,21 @@ export async function syncSkills( ): Promise { await ensureDir(skillsDir) + const seen = new Set() for (const skill of skills) { if (!isValidSkillName(skill.name)) { console.warn(`Skipping skill with invalid name: ${skill.name}`) continue } - const target = path.join(skillsDir, skill.name) + const safeName = sanitizePathName(skill.name) + if (seen.has(safeName)) { + console.warn(`Skipping skill "${skill.name}": sanitized name "${safeName}" collides with another skill`) + continue + } + seen.add(safeName) + + const target = path.join(skillsDir, safeName) await forceSymlink(skill.sourceDir, target) } } diff --git a/src/targets/codex.ts b/src/targets/codex.ts index f52902a..25c6780 100644 --- a/src/targets/codex.ts +++ b/src/targets/codex.ts @@ -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" @@ -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)), (content) => transformContentForCodex(content, bundle.invocationTargets, { unknownSlashBehavior: "preserve", }), @@ -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") } } diff --git a/src/targets/copilot.ts b/src/targets/copilot.ts index 6c5195e..ca1a303 100644 --- a/src/targets/copilot.ts +++ b/src/targets/copilot.ts @@ -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" @@ -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) } } diff --git a/src/targets/droid.ts b/src/targets/droid.ts index 7b3ce49..2370232 100644 --- a/src/targets/droid.ts +++ b/src/targets/droid.ts @@ -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" @@ -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) } } } diff --git a/src/targets/gemini.ts b/src/targets/gemini.ts index accecb7..4ce4985 100644 --- a/src/targets/gemini.ts +++ b/src/targets/gemini.ts @@ -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" @@ -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) } } diff --git a/src/targets/kiro.ts b/src/targets/kiro.ts index 64de9fc..4445def 100644 --- a/src/targets/kiro.ts +++ b/src/targets/kiro.ts @@ -1,5 +1,5 @@ import path from "path" -import { backupFile, copySkillDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files" +import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJson, writeText } from "../utils/files" import { transformContentForKiro } from "../converters/claude-to-kiro" import type { KiroBundle } from "../types/kiro" @@ -15,13 +15,13 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P // Write agent JSON config await writeJson( - path.join(paths.agentsDir, `${agent.name}.json`), + path.join(paths.agentsDir, `${sanitizePathName(agent.name)}.json`), agent.config, ) // Write agent prompt file await writeText( - path.join(paths.agentsDir, "prompts", `${agent.name}.md`), + path.join(paths.agentsDir, "prompts", `${sanitizePathName(agent.name)}.md`), agent.promptContent + "\n", ) } @@ -32,7 +32,7 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P for (const skill of bundle.generatedSkills) { validatePathSafe(skill.name, "skill") await writeText( - path.join(paths.skillsDir, skill.name, "SKILL.md"), + path.join(paths.skillsDir, sanitizePathName(skill.name), "SKILL.md"), skill.content + "\n", ) } @@ -42,7 +42,7 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P if (bundle.skillDirs.length > 0) { for (const skill of bundle.skillDirs) { validatePathSafe(skill.name, "skill directory") - const destDir = path.join(paths.skillsDir, skill.name) + const destDir = path.join(paths.skillsDir, sanitizePathName(skill.name)) // Validate destination doesn't escape skills directory const resolvedDest = path.resolve(destDir) @@ -63,7 +63,7 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P for (const file of bundle.steeringFiles) { validatePathSafe(file.name, "steering file") await writeText( - path.join(paths.steeringDir, `${file.name}.md`), + path.join(paths.steeringDir, `${sanitizePathName(file.name)}.md`), file.content + "\n", ) } diff --git a/src/targets/openclaw.ts b/src/targets/openclaw.ts index d2ec688..df68165 100644 --- a/src/targets/openclaw.ts +++ b/src/targets/openclaw.ts @@ -1,6 +1,6 @@ import path from "path" import { promises as fs } from "fs" -import { backupFile, copyDir, ensureDir, pathExists, readJson, walkFiles, writeJson, writeText } from "../utils/files" +import { backupFile, copyDir, ensureDir, pathExists, readJson, sanitizePathName, walkFiles, writeJson, writeText } from "../utils/files" import type { OpenClawBundle } from "../types/openclaw" export async function writeOpenClawBundle(outputRoot: string, bundle: OpenClawBundle): Promise { @@ -18,7 +18,7 @@ export async function writeOpenClawBundle(outputRoot: string, bundle: OpenClawBu // Write generated skills (agents + commands converted to SKILL.md) for (const skill of bundle.skills) { - const skillDir = path.join(paths.skillsDir, skill.dir) + const skillDir = path.join(paths.skillsDir, sanitizePathName(skill.dir)) await ensureDir(skillDir) await writeText(path.join(skillDir, "SKILL.md"), skill.content + "\n") } @@ -26,7 +26,7 @@ export async function writeOpenClawBundle(outputRoot: string, bundle: OpenClawBu // Copy original skill directories (preserving references/, assets/, scripts/) // and rewrite .claude/ paths to .openclaw/ in markdown files for (const skill of bundle.skillDirCopies) { - const destDir = path.join(paths.skillsDir, skill.name) + const destDir = path.join(paths.skillsDir, sanitizePathName(skill.name)) await copyDir(skill.sourceDir, destDir) await rewritePathsInDir(destDir) } diff --git a/src/targets/opencode.ts b/src/targets/opencode.ts index cff2931..ec8b8b0 100644 --- a/src/targets/opencode.ts +++ b/src/targets/opencode.ts @@ -1,5 +1,5 @@ import path from "path" -import { backupFile, copyDir, ensureDir, pathExists, readJson, resolveCommandPath, writeJson, writeText } from "../utils/files" +import { backupFile, copyDir, ensureDir, pathExists, readJson, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files" import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode" // Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002. @@ -70,8 +70,15 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu } const agentsDir = openCodePaths.agentsDir + const seenAgents = new Set() for (const agent of bundle.agents) { - await writeText(path.join(agentsDir, `${agent.name}.md`), agent.content + "\n") + const safeName = sanitizePathName(agent.name) + if (seenAgents.has(safeName)) { + console.warn(`Skipping agent "${agent.name}": sanitized name "${safeName}" collides with another agent`) + continue + } + seenAgents.add(safeName) + await writeText(path.join(agentsDir, `${safeName}.md`), agent.content + "\n") } for (const commandFile of bundle.commandFiles) { @@ -93,7 +100,7 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu if (bundle.skillDirs.length > 0) { const skillsRoot = openCodePaths.skillsDir for (const skill of bundle.skillDirs) { - await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name)) + await copyDir(skill.sourceDir, path.join(skillsRoot, sanitizePathName(skill.name))) } } } diff --git a/src/targets/pi.ts b/src/targets/pi.ts index 61c5375..3fe0c6c 100644 --- a/src/targets/pi.ts +++ b/src/targets/pi.ts @@ -5,6 +5,7 @@ import { ensureDir, pathExists, readText, + sanitizePathName, writeJson, writeText, } from "../utils/files" @@ -34,15 +35,15 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi await ensureDir(paths.extensionsDir) for (const prompt of bundle.prompts) { - await writeText(path.join(paths.promptsDir, `${prompt.name}.md`), prompt.content + "\n") + await writeText(path.join(paths.promptsDir, `${sanitizePathName(prompt.name)}.md`), prompt.content + "\n") } for (const skill of bundle.skillDirs) { - await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, skill.name), transformContentForPi) + await copySkillDir(skill.sourceDir, path.join(paths.skillsDir, sanitizePathName(skill.name)), transformContentForPi) } 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") } for (const extension of bundle.extensions) { diff --git a/src/targets/qwen.ts b/src/targets/qwen.ts index 22fe296..7a4e9c1 100644 --- a/src/targets/qwen.ts +++ b/src/targets/qwen.ts @@ -1,5 +1,5 @@ import path from "path" -import { backupFile, copyDir, ensureDir, resolveCommandPath, writeJson, writeText } from "../utils/files" +import { backupFile, copyDir, ensureDir, resolveCommandPath, sanitizePathName, writeJson, writeText } from "../utils/files" import type { QwenBundle, QwenExtensionConfig } from "../types/qwen" export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): Promise { @@ -24,7 +24,7 @@ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): P await ensureDir(agentsDir) for (const agent of bundle.agents) { const ext = agent.format === "yaml" ? "yaml" : "md" - await writeText(path.join(agentsDir, `${agent.name}.${ext}`), agent.content + "\n") + await writeText(path.join(agentsDir, `${sanitizePathName(agent.name)}.${ext}`), agent.content + "\n") } // Write commands @@ -40,7 +40,7 @@ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): P const skillsRoot = qwenPaths.skillsDir await ensureDir(skillsRoot) for (const skill of bundle.skillDirs) { - await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name)) + await copyDir(skill.sourceDir, path.join(skillsRoot, sanitizePathName(skill.name))) } } } diff --git a/src/targets/windsurf.ts b/src/targets/windsurf.ts index 54b0ced..d19271e 100644 --- a/src/targets/windsurf.ts +++ b/src/targets/windsurf.ts @@ -1,5 +1,5 @@ import path from "path" -import { backupFile, copySkillDir, ensureDir, pathExists, readJson, writeJsonSecure, writeText } from "../utils/files" +import { backupFile, copySkillDir, ensureDir, pathExists, readJson, sanitizePathName, writeJsonSecure, writeText } from "../utils/files" import { formatFrontmatter } from "../utils/frontmatter" import { transformContentForWindsurf } from "../converters/claude-to-windsurf" import type { WindsurfBundle } from "../types/windsurf" @@ -20,7 +20,7 @@ export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBu await ensureDir(skillsDir) for (const skill of bundle.agentSkills) { validatePathSafe(skill.name, "agent skill") - const destDir = path.join(skillsDir, skill.name) + const destDir = path.join(skillsDir, sanitizePathName(skill.name)) const resolvedDest = path.resolve(destDir) if (!resolvedDest.startsWith(path.resolve(skillsDir))) { @@ -51,7 +51,7 @@ export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBu await ensureDir(skillsDir) for (const skill of bundle.skillDirs) { validatePathSafe(skill.name, "skill directory") - const destDir = path.join(skillsDir, skill.name) + const destDir = path.join(skillsDir, sanitizePathName(skill.name)) const resolvedDest = path.resolve(destDir) if (!resolvedDest.startsWith(path.resolve(skillsDir))) { diff --git a/src/utils/files.ts b/src/utils/files.ts index 9acf95f..4bed7fe 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -75,6 +75,16 @@ export async function walkFiles(root: string): Promise { return results } +/** + * Sanitize a name for use as a filesystem path component. + * Replaces colons with hyphens so colon-namespaced names + * (e.g. "ce:brainstorm") become flat directory names ("ce-brainstorm") + * instead of failing on Windows where colons are illegal in filenames. + */ +export function sanitizePathName(name: string): string { + return name.replace(/:/g, "-") +} + /** * Resolve a colon-separated command name into a filesystem path. * e.g. resolveCommandPath("/commands", "ce:plan", ".md") -> "/commands/ce/plan.md" diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 9299db1..a062e09 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -216,7 +216,7 @@ describe("CLI", () => { expect(stdout).toContain("Installed compound-engineering") expect(stdout).toContain(codexRoot) expect(await exists(path.join(codexRoot, "prompts", "ce-plan.md"))).toBe(true) - expect(await exists(path.join(codexRoot, "skills", "ce:plan", "SKILL.md"))).toBe(true) + expect(await exists(path.join(codexRoot, "skills", "ce-plan", "SKILL.md"))).toBe(true) expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true) }) @@ -690,7 +690,7 @@ describe("CLI", () => { expect(stdout).toContain("Synced to gemini") expect(stdout).not.toContain("cursor") - expect(await exists(path.join(tempHome, ".config", "opencode", "commands", "workflows:plan.md"))).toBe(true) + expect(await exists(path.join(tempHome, ".config", "opencode", "commands", "workflows", "plan.md"))).toBe(true) expect(await exists(path.join(tempHome, ".codex", "config.toml"))).toBe(true) expect(await exists(path.join(tempHome, ".codex", "prompts", "workflows-plan.md"))).toBe(true) expect(await exists(path.join(tempHome, ".codex", "skills", "workflows-plan", "SKILL.md"))).toBe(true) diff --git a/tests/codex-writer.test.ts b/tests/codex-writer.test.ts index 4487171..a9000ee 100644 --- a/tests/codex-writer.test.ts +++ b/tests/codex-writer.test.ts @@ -144,7 +144,7 @@ Use /deepen-plan for deeper research. await writeCodexBundle(tempRoot, bundle) const installedSkill = await fs.readFile( - path.join(tempRoot, ".codex", "skills", "ce:brainstorm", "SKILL.md"), + path.join(tempRoot, ".codex", "skills", "ce-brainstorm", "SKILL.md"), "utf8", ) expect(installedSkill).toContain("/prompts:ce-plan") @@ -152,7 +152,7 @@ Use /deepen-plan for deeper research. expect(installedSkill).toContain("/prompts:deepen-plan") const notes = await fs.readFile( - path.join(tempRoot, ".codex", "skills", "ce:brainstorm", "notes.md"), + path.join(tempRoot, ".codex", "skills", "ce-brainstorm", "notes.md"), "utf8", ) expect(notes).toContain("/ce:plan") @@ -194,7 +194,7 @@ Also run bare agents: await writeCodexBundle(tempRoot, bundle) const installedSkill = await fs.readFile( - path.join(tempRoot, ".codex", "skills", "ce:plan", "SKILL.md"), + path.join(tempRoot, ".codex", "skills", "ce-plan", "SKILL.md"), "utf8", ) diff --git a/tests/copilot-converter.test.ts b/tests/copilot-converter.test.ts index 1bc790e..40220f9 100644 --- a/tests/copilot-converter.test.ts +++ b/tests/copilot-converter.test.ts @@ -485,4 +485,35 @@ Task best-practices-researcher(topic)` expect(result).toContain("the dhh-rails-reviewer agent") expect(result).not.toContain("@security-sentinel") }) + + test("generated skill deduplicates against sanitized pass-through skill names", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [ + { + name: "ce:plan", + description: "Planning command", + model: "inherit", + allowedTools: [], + body: "Plan the work.", + sourcePath: "/tmp/plugin/commands/ce-plan.md", + }, + ], + skills: [ + { + name: "ce:plan", + description: "Planning skill", + sourceDir: "/tmp/plugin/skills/ce-plan", + skillPath: "/tmp/plugin/skills/ce-plan/SKILL.md", + }, + ], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + + // The generated skill from the command should get a deduplicated name + // since "ce:plan" and "ce-plan" both map to "ce-plan" on disk + expect(bundle.generatedSkills[0].name).not.toBe("ce-plan") + }) }) diff --git a/tests/copilot-writer.test.ts b/tests/copilot-writer.test.ts index 36777e1..d87a45f 100644 --- a/tests/copilot-writer.test.ts +++ b/tests/copilot-writer.test.ts @@ -193,7 +193,7 @@ Run these research agents: await writeCopilotBundle(tempRoot, bundle) const installedSkill = await fs.readFile( - path.join(tempRoot, ".github", "skills", "ce:plan", "SKILL.md"), + path.join(tempRoot, ".github", "skills", "ce-plan", "SKILL.md"), "utf8", ) diff --git a/tests/droid-writer.test.ts b/tests/droid-writer.test.ts index 19eb7c0..0412da3 100644 --- a/tests/droid-writer.test.ts +++ b/tests/droid-writer.test.ts @@ -75,7 +75,7 @@ Run these research agents: await writeDroidBundle(tempRoot, bundle) const installedSkill = await fs.readFile( - path.join(tempRoot, ".factory", "skills", "ce:plan", "SKILL.md"), + path.join(tempRoot, ".factory", "skills", "ce-plan", "SKILL.md"), "utf8", ) diff --git a/tests/gemini-writer.test.ts b/tests/gemini-writer.test.ts index 25f9bfb..15d2045 100644 --- a/tests/gemini-writer.test.ts +++ b/tests/gemini-writer.test.ts @@ -94,7 +94,7 @@ Run these research agents: await writeGeminiBundle(tempRoot, bundle) const installedSkill = await fs.readFile( - path.join(tempRoot, ".gemini", "skills", "ce:plan", "SKILL.md"), + path.join(tempRoot, ".gemini", "skills", "ce-plan", "SKILL.md"), "utf8", ) diff --git a/tests/kiro-writer.test.ts b/tests/kiro-writer.test.ts index 500d03b..f59c7b2 100644 --- a/tests/kiro-writer.test.ts +++ b/tests/kiro-writer.test.ts @@ -126,7 +126,7 @@ Run these research agents: await writeKiroBundle(tempRoot, bundle) const installedSkill = await fs.readFile( - path.join(tempRoot, ".kiro", "skills", "ce:plan", "SKILL.md"), + path.join(tempRoot, ".kiro", "skills", "ce-plan", "SKILL.md"), "utf8", ) diff --git a/tests/openclaw-converter.test.ts b/tests/openclaw-converter.test.ts index e1648d5..ab2ed5e 100644 --- a/tests/openclaw-converter.test.ts +++ b/tests/openclaw-converter.test.ts @@ -113,7 +113,7 @@ describe("convertClaudeToOpenClaw", () => { properties: {}, }) expect(bundle.manifest.skills).toContain("skills/agent-security-reviewer") - expect(bundle.manifest.skills).toContain("skills/cmd-workflows:plan") + expect(bundle.manifest.skills).toContain("skills/cmd-workflows-plan") expect(bundle.manifest.skills).toContain("skills/existing-skill") }) @@ -201,4 +201,27 @@ describe("convertClaudeToOpenClaw", () => { const bundle = convertClaudeToOpenClaw(plugin, defaultOptions) expect(bundle.openclawConfig).toBeUndefined() }) + + test("manifest skill paths use sanitized names matching filesystem output", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + skills: [ + { + name: "ce:plan", + description: "Planning skill", + sourceDir: "/tmp/plugin/skills/ce-plan", + skillPath: "/tmp/plugin/skills/ce-plan/SKILL.md", + }, + ], + } + + const bundle = convertClaudeToOpenClaw(plugin, defaultOptions) + + // Manifest paths must not contain colons + for (const skillPath of bundle.manifest.skills) { + expect(skillPath).not.toContain(":") + } + expect(bundle.manifest.skills).toContain("skills/ce-plan") + expect(bundle.manifest.skills).toContain("skills/cmd-workflows-plan") + }) }) diff --git a/tests/path-sanitization.test.ts b/tests/path-sanitization.test.ts new file mode 100644 index 0000000..4fb8e57 --- /dev/null +++ b/tests/path-sanitization.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { loadClaudePlugin } from "../src/parsers/claude" +import { sanitizePathName } from "../src/utils/files" + +const pluginRoot = path.join(process.cwd(), "plugins", "compound-engineering") + +describe("sanitizePathName", () => { + test("replaces colons with hyphens", () => { + expect(sanitizePathName("ce:brainstorm")).toBe("ce-brainstorm") + expect(sanitizePathName("ce:plan")).toBe("ce-plan") + }) + + test("passes through names without colons", () => { + expect(sanitizePathName("frontend-design")).toBe("frontend-design") + }) + + test("handles multiple colons", () => { + expect(sanitizePathName("a:b:c")).toBe("a-b-c") + }) +}) + +describe("path sanitization collision detection", () => { + 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) + }) + + test("no two agent names collide after sanitization", async () => { + const plugin = await loadClaudePlugin(pluginRoot) + const sanitized = plugin.agents.map((agent) => sanitizePathName(agent.name)) + const unique = new Set(sanitized) + + expect(unique.size).toBe(sanitized.length) + }) +}) diff --git a/tests/pi-writer.test.ts b/tests/pi-writer.test.ts index eec28d9..ad3e81d 100644 --- a/tests/pi-writer.test.ts +++ b/tests/pi-writer.test.ts @@ -80,7 +80,7 @@ Run these research agents: await writePiBundle(outputRoot, bundle) const installedSkill = await fs.readFile( - path.join(outputRoot, "skills", "ce:plan", "SKILL.md"), + path.join(outputRoot, "skills", "ce-plan", "SKILL.md"), "utf8", ) diff --git a/tests/windsurf-converter.test.ts b/tests/windsurf-converter.test.ts index 5f76a25..901ce59 100644 --- a/tests/windsurf-converter.test.ts +++ b/tests/windsurf-converter.test.ts @@ -592,3 +592,42 @@ describe("normalizeName", () => { expect(normalizeName("123-agent")).toBe("item") }) }) + +describe("convertClaudeToWindsurf dedupe", () => { + test("agent skill deduplicates against sanitized pass-through skill names", () => { + const { convertClaudeToWindsurf } = require("../src/converters/claude-to-windsurf") + const plugin: import("../src/types/claude").ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [ + { + name: "ce:plan", + description: "Planning agent", + body: "Plan things.", + sourcePath: "/tmp/plugin/agents/ce-plan.md", + }, + ], + commands: [], + skills: [ + { + name: "ce:plan", + description: "Planning skill", + sourceDir: "/tmp/plugin/skills/ce-plan", + skillPath: "/tmp/plugin/skills/ce-plan/SKILL.md", + }, + ], + hooks: undefined, + mcpServers: undefined, + } + + const bundle = convertClaudeToWindsurf(plugin, { + agentMode: "subagent" as const, + inferTemperature: false, + permissions: "none" as const, + }) + + // The agent skill should get a deduplicated name since "ce:plan" normalizes + // to "ce-plan" which collides with the pass-through skill on disk + expect(bundle.agentSkills[0].name).not.toBe("ce-plan") + }) +}) diff --git a/tests/windsurf-writer.test.ts b/tests/windsurf-writer.test.ts index fdeb9a7..725c8ef 100644 --- a/tests/windsurf-writer.test.ts +++ b/tests/windsurf-writer.test.ts @@ -112,7 +112,7 @@ Run these research agents: await writeWindsurfBundle(tempRoot, bundle) const installedSkill = await fs.readFile( - path.join(tempRoot, "skills", "ce:plan", "SKILL.md"), + path.join(tempRoot, "skills", "ce-plan", "SKILL.md"), "utf8", )