fix: sanitize colons in skill/agent names for Windows path compatibility (#398)
This commit is contained in:
10
AGENTS.md
10
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.
|
- **Plans** live in `docs/plans/` — implementation plans and progress tracking.
|
||||||
- **Solutions** live in `docs/solutions/` — documented decisions and patterns.
|
- **Solutions** live in `docs/solutions/` — documented decisions and patterns.
|
||||||
- **Specs** live in `docs/specs/` — target platform format specifications.
|
- **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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatFrontmatter } from "../utils/frontmatter"
|
import { formatFrontmatter } from "../utils/frontmatter"
|
||||||
|
import { sanitizePathName } from "../utils/files"
|
||||||
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
|
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
|
||||||
import type {
|
import type {
|
||||||
CopilotAgent,
|
CopilotAgent,
|
||||||
@@ -21,9 +22,9 @@ export function convertClaudeToCopilot(
|
|||||||
|
|
||||||
const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames))
|
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) => {
|
const skillDirs = plugin.skills.map((skill) => {
|
||||||
usedSkillNames.add(skill.name)
|
usedSkillNames.add(sanitizePathName(skill.name))
|
||||||
return {
|
return {
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
sourceDir: skill.sourceDir,
|
sourceDir: skill.sourceDir,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatFrontmatter } from "../utils/frontmatter"
|
import { formatFrontmatter } from "../utils/frontmatter"
|
||||||
|
import { sanitizePathName } from "../utils/files"
|
||||||
import type {
|
import type {
|
||||||
ClaudeAgent,
|
ClaudeAgent,
|
||||||
ClaudeCommand,
|
ClaudeCommand,
|
||||||
@@ -33,9 +34,9 @@ export function convertClaudeToOpenClaw(
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const allSkillDirs = [
|
const allSkillDirs = [
|
||||||
...agentSkills.map((s) => s.dir),
|
...agentSkills.map((s) => sanitizePathName(s.dir)),
|
||||||
...commandSkills.map((s) => s.dir),
|
...commandSkills.map((s) => sanitizePathName(s.dir)),
|
||||||
...plugin.skills.map((s) => s.name),
|
...plugin.skills.map((s) => sanitizePathName(s.name)),
|
||||||
]
|
]
|
||||||
|
|
||||||
const manifest = buildManifest(plugin, allSkillDirs)
|
const manifest = buildManifest(plugin, allSkillDirs)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { formatFrontmatter } from "../utils/frontmatter"
|
import { formatFrontmatter } from "../utils/frontmatter"
|
||||||
|
import { sanitizePathName } from "../utils/files"
|
||||||
import { findServersWithPotentialSecrets } from "../utils/secrets"
|
import { findServersWithPotentialSecrets } from "../utils/secrets"
|
||||||
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
|
import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude"
|
||||||
import type { WindsurfBundle, WindsurfGeneratedSkill, WindsurfMcpConfig, WindsurfMcpServerEntry, WindsurfWorkflow } from "../types/windsurf"
|
import type { WindsurfBundle, WindsurfGeneratedSkill, WindsurfMcpConfig, WindsurfMcpServerEntry, WindsurfWorkflow } from "../types/windsurf"
|
||||||
@@ -20,8 +21,9 @@ export function convertClaudeToWindsurf(
|
|||||||
sourceDir: skill.sourceDir,
|
sourceDir: skill.sourceDir,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Convert agents to skills (seed usedNames with pass-through skill names)
|
// Convert agents to skills (seed usedNames with sanitized pass-through skill names
|
||||||
const usedSkillNames = new Set<string>(skillDirs.map((s) => s.name))
|
// 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) =>
|
const agentSkills = plugin.agents.map((agent) =>
|
||||||
convertAgentToSkill(agent, knownAgentNames, usedSkillNames),
|
convertAgentToSkill(agent, knownAgentNames, usedSkillNames),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import type { ClaudeHomeConfig } from "../parsers/claude-home"
|
import type { ClaudeHomeConfig } from "../parsers/claude-home"
|
||||||
import type { ClaudePlugin } from "../types/claude"
|
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 { convertClaudeToCodex } from "../converters/claude-to-codex"
|
||||||
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
|
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
|
||||||
import { convertClaudeToDroid } from "../converters/claude-to-droid"
|
import { convertClaudeToDroid } from "../converters/claude-to-droid"
|
||||||
@@ -57,7 +57,7 @@ export async function syncOpenCodeCommands(
|
|||||||
const bundle = convertClaudeToOpenCode(plugin, DEFAULT_SYNC_OPTIONS)
|
const bundle = convertClaudeToOpenCode(plugin, DEFAULT_SYNC_OPTIONS)
|
||||||
|
|
||||||
for (const commandFile of bundle.commandFiles) {
|
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)
|
const backupPath = await backupFile(commandPath)
|
||||||
if (backupPath) {
|
if (backupPath) {
|
||||||
console.log(`Backed up existing command file to ${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")
|
await writeText(path.join(outputRoot, "prompts", `${prompt.name}.md`), prompt.content + "\n")
|
||||||
}
|
}
|
||||||
for (const skill of bundle.generatedSkills) {
|
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)
|
const bundle = convertClaudeToCopilot(plugin, DEFAULT_SYNC_OPTIONS)
|
||||||
|
|
||||||
for (const skill of bundle.generatedSkills) {
|
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 plugin = buildClaudeHomePlugin(config)
|
||||||
const bundle = convertClaudeToKiro(plugin, DEFAULT_SYNC_OPTIONS)
|
const bundle = convertClaudeToKiro(plugin, DEFAULT_SYNC_OPTIONS)
|
||||||
for (const skill of bundle.generatedSkills) {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "fs/promises"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import type { ClaudeHomeConfig } from "../parsers/claude-home"
|
import type { ClaudeHomeConfig } from "../parsers/claude-home"
|
||||||
import type { ClaudeMcpServer } from "../types/claude"
|
import type { ClaudeMcpServer } from "../types/claude"
|
||||||
|
import { sanitizePathName } from "../utils/files"
|
||||||
import { syncGeminiCommands } from "./commands"
|
import { syncGeminiCommands } from "./commands"
|
||||||
import { mergeJsonConfigAtKey } from "./json-config"
|
import { mergeJsonConfigAtKey } from "./json-config"
|
||||||
import { syncSkills } from "./skills"
|
import { syncSkills } from "./skills"
|
||||||
@@ -85,7 +86,7 @@ async function removeGeminiMirrorConflicts(
|
|||||||
sharedSkillsDir: string,
|
sharedSkillsDir: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const skill of skills) {
|
for (const skill of skills) {
|
||||||
const duplicatePath = path.join(skillsDir, skill.name)
|
const duplicatePath = path.join(skillsDir, sanitizePathName(skill.name))
|
||||||
|
|
||||||
let stat
|
let stat
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import type { ClaudeSkill } from "../types/claude"
|
import type { ClaudeSkill } from "../types/claude"
|
||||||
import { ensureDir } from "../utils/files"
|
import { ensureDir, sanitizePathName } from "../utils/files"
|
||||||
import { forceSymlink, isValidSkillName } from "../utils/symlink"
|
import { forceSymlink, isValidSkillName } from "../utils/symlink"
|
||||||
|
|
||||||
export async function syncSkills(
|
export async function syncSkills(
|
||||||
@@ -9,13 +9,21 @@ export async function syncSkills(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await ensureDir(skillsDir)
|
await ensureDir(skillsDir)
|
||||||
|
|
||||||
|
const seen = new Set<string>()
|
||||||
for (const skill of skills) {
|
for (const skill of skills) {
|
||||||
if (!isValidSkillName(skill.name)) {
|
if (!isValidSkillName(skill.name)) {
|
||||||
console.warn(`Skipping skill with invalid name: ${skill.name}`)
|
console.warn(`Skipping skill with invalid name: ${skill.name}`)
|
||||||
continue
|
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)
|
await forceSymlink(skill.sourceDir, target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
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 { CodexBundle } from "../types/codex"
|
||||||
import type { ClaudeMcpServer } from "../types/claude"
|
import type { ClaudeMcpServer } from "../types/claude"
|
||||||
import { transformContentForCodex } from "../utils/codex-content"
|
import { transformContentForCodex } from "../utils/codex-content"
|
||||||
@@ -20,7 +20,7 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
|
|||||||
for (const skill of bundle.skillDirs) {
|
for (const skill of bundle.skillDirs) {
|
||||||
await copySkillDir(
|
await copySkillDir(
|
||||||
skill.sourceDir,
|
skill.sourceDir,
|
||||||
path.join(skillsRoot, skill.name),
|
path.join(skillsRoot, sanitizePathName(skill.name)),
|
||||||
(content) => transformContentForCodex(content, bundle.invocationTargets, {
|
(content) => transformContentForCodex(content, bundle.invocationTargets, {
|
||||||
unknownSlashBehavior: "preserve",
|
unknownSlashBehavior: "preserve",
|
||||||
}),
|
}),
|
||||||
@@ -31,7 +31,7 @@ export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle):
|
|||||||
if (bundle.generatedSkills.length > 0) {
|
if (bundle.generatedSkills.length > 0) {
|
||||||
const skillsRoot = path.join(codexRoot, "skills")
|
const skillsRoot = path.join(codexRoot, "skills")
|
||||||
for (const skill of bundle.generatedSkills) {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
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 { transformContentForCopilot } from "../converters/claude-to-copilot"
|
||||||
import type { CopilotBundle } from "../types/copilot"
|
import type { CopilotBundle } from "../types/copilot"
|
||||||
|
|
||||||
@@ -10,21 +10,21 @@ export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBund
|
|||||||
if (bundle.agents.length > 0) {
|
if (bundle.agents.length > 0) {
|
||||||
const agentsDir = path.join(paths.githubDir, "agents")
|
const agentsDir = path.join(paths.githubDir, "agents")
|
||||||
for (const agent of bundle.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) {
|
if (bundle.generatedSkills.length > 0) {
|
||||||
const skillsDir = path.join(paths.githubDir, "skills")
|
const skillsDir = path.join(paths.githubDir, "skills")
|
||||||
for (const skill of bundle.generatedSkills) {
|
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) {
|
if (bundle.skillDirs.length > 0) {
|
||||||
const skillsDir = path.join(paths.githubDir, "skills")
|
const skillsDir = path.join(paths.githubDir, "skills")
|
||||||
for (const skill of bundle.skillDirs) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
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 { transformContentForDroid } from "../converters/claude-to-droid"
|
||||||
import type { DroidBundle } from "../types/droid"
|
import type { DroidBundle } from "../types/droid"
|
||||||
|
|
||||||
@@ -18,14 +18,14 @@ export async function writeDroidBundle(outputRoot: string, bundle: DroidBundle):
|
|||||||
if (bundle.droids.length > 0) {
|
if (bundle.droids.length > 0) {
|
||||||
await ensureDir(paths.droidsDir)
|
await ensureDir(paths.droidsDir)
|
||||||
for (const droid of bundle.droids) {
|
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) {
|
if (bundle.skillDirs.length > 0) {
|
||||||
await ensureDir(paths.skillsDir)
|
await ensureDir(paths.skillsDir)
|
||||||
for (const skill of bundle.skillDirs) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
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 { transformContentForGemini } from "../converters/claude-to-gemini"
|
||||||
import type { GeminiBundle } from "../types/gemini"
|
import type { GeminiBundle } from "../types/gemini"
|
||||||
|
|
||||||
@@ -9,13 +9,13 @@ export async function writeGeminiBundle(outputRoot: string, bundle: GeminiBundle
|
|||||||
|
|
||||||
if (bundle.generatedSkills.length > 0) {
|
if (bundle.generatedSkills.length > 0) {
|
||||||
for (const skill of bundle.generatedSkills) {
|
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) {
|
if (bundle.skillDirs.length > 0) {
|
||||||
for (const skill of bundle.skillDirs) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
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 { transformContentForKiro } from "../converters/claude-to-kiro"
|
||||||
import type { KiroBundle } from "../types/kiro"
|
import type { KiroBundle } from "../types/kiro"
|
||||||
|
|
||||||
@@ -15,13 +15,13 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P
|
|||||||
|
|
||||||
// Write agent JSON config
|
// Write agent JSON config
|
||||||
await writeJson(
|
await writeJson(
|
||||||
path.join(paths.agentsDir, `${agent.name}.json`),
|
path.join(paths.agentsDir, `${sanitizePathName(agent.name)}.json`),
|
||||||
agent.config,
|
agent.config,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Write agent prompt file
|
// Write agent prompt file
|
||||||
await writeText(
|
await writeText(
|
||||||
path.join(paths.agentsDir, "prompts", `${agent.name}.md`),
|
path.join(paths.agentsDir, "prompts", `${sanitizePathName(agent.name)}.md`),
|
||||||
agent.promptContent + "\n",
|
agent.promptContent + "\n",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -32,7 +32,7 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P
|
|||||||
for (const skill of bundle.generatedSkills) {
|
for (const skill of bundle.generatedSkills) {
|
||||||
validatePathSafe(skill.name, "skill")
|
validatePathSafe(skill.name, "skill")
|
||||||
await writeText(
|
await writeText(
|
||||||
path.join(paths.skillsDir, skill.name, "SKILL.md"),
|
path.join(paths.skillsDir, sanitizePathName(skill.name), "SKILL.md"),
|
||||||
skill.content + "\n",
|
skill.content + "\n",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P
|
|||||||
if (bundle.skillDirs.length > 0) {
|
if (bundle.skillDirs.length > 0) {
|
||||||
for (const skill of bundle.skillDirs) {
|
for (const skill of bundle.skillDirs) {
|
||||||
validatePathSafe(skill.name, "skill directory")
|
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
|
// Validate destination doesn't escape skills directory
|
||||||
const resolvedDest = path.resolve(destDir)
|
const resolvedDest = path.resolve(destDir)
|
||||||
@@ -63,7 +63,7 @@ export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): P
|
|||||||
for (const file of bundle.steeringFiles) {
|
for (const file of bundle.steeringFiles) {
|
||||||
validatePathSafe(file.name, "steering file")
|
validatePathSafe(file.name, "steering file")
|
||||||
await writeText(
|
await writeText(
|
||||||
path.join(paths.steeringDir, `${file.name}.md`),
|
path.join(paths.steeringDir, `${sanitizePathName(file.name)}.md`),
|
||||||
file.content + "\n",
|
file.content + "\n",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { promises as fs } from "fs"
|
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"
|
import type { OpenClawBundle } from "../types/openclaw"
|
||||||
|
|
||||||
export async function writeOpenClawBundle(outputRoot: string, bundle: OpenClawBundle): Promise<void> {
|
export async function writeOpenClawBundle(outputRoot: string, bundle: OpenClawBundle): Promise<void> {
|
||||||
@@ -18,7 +18,7 @@ export async function writeOpenClawBundle(outputRoot: string, bundle: OpenClawBu
|
|||||||
|
|
||||||
// Write generated skills (agents + commands converted to SKILL.md)
|
// Write generated skills (agents + commands converted to SKILL.md)
|
||||||
for (const skill of bundle.skills) {
|
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 ensureDir(skillDir)
|
||||||
await writeText(path.join(skillDir, "SKILL.md"), skill.content + "\n")
|
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/)
|
// Copy original skill directories (preserving references/, assets/, scripts/)
|
||||||
// and rewrite .claude/ paths to .openclaw/ in markdown files
|
// and rewrite .claude/ paths to .openclaw/ in markdown files
|
||||||
for (const skill of bundle.skillDirCopies) {
|
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 copyDir(skill.sourceDir, destDir)
|
||||||
await rewritePathsInDir(destDir)
|
await rewritePathsInDir(destDir)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
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"
|
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
|
||||||
|
|
||||||
// Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002.
|
// 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 agentsDir = openCodePaths.agentsDir
|
||||||
|
const seenAgents = new Set<string>()
|
||||||
for (const agent of bundle.agents) {
|
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) {
|
for (const commandFile of bundle.commandFiles) {
|
||||||
@@ -93,7 +100,7 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu
|
|||||||
if (bundle.skillDirs.length > 0) {
|
if (bundle.skillDirs.length > 0) {
|
||||||
const skillsRoot = openCodePaths.skillsDir
|
const skillsRoot = openCodePaths.skillsDir
|
||||||
for (const skill of bundle.skillDirs) {
|
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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ensureDir,
|
ensureDir,
|
||||||
pathExists,
|
pathExists,
|
||||||
readText,
|
readText,
|
||||||
|
sanitizePathName,
|
||||||
writeJson,
|
writeJson,
|
||||||
writeText,
|
writeText,
|
||||||
} from "../utils/files"
|
} from "../utils/files"
|
||||||
@@ -34,15 +35,15 @@ export async function writePiBundle(outputRoot: string, bundle: PiBundle): Promi
|
|||||||
await ensureDir(paths.extensionsDir)
|
await ensureDir(paths.extensionsDir)
|
||||||
|
|
||||||
for (const prompt of bundle.prompts) {
|
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) {
|
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) {
|
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) {
|
for (const extension of bundle.extensions) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
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"
|
import type { QwenBundle, QwenExtensionConfig } from "../types/qwen"
|
||||||
|
|
||||||
export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): Promise<void> {
|
export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): Promise<void> {
|
||||||
@@ -24,7 +24,7 @@ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): P
|
|||||||
await ensureDir(agentsDir)
|
await ensureDir(agentsDir)
|
||||||
for (const agent of bundle.agents) {
|
for (const agent of bundle.agents) {
|
||||||
const ext = agent.format === "yaml" ? "yaml" : "md"
|
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
|
// Write commands
|
||||||
@@ -40,7 +40,7 @@ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): P
|
|||||||
const skillsRoot = qwenPaths.skillsDir
|
const skillsRoot = qwenPaths.skillsDir
|
||||||
await ensureDir(skillsRoot)
|
await ensureDir(skillsRoot)
|
||||||
for (const skill of bundle.skillDirs) {
|
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)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import path from "path"
|
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 { formatFrontmatter } from "../utils/frontmatter"
|
||||||
import { transformContentForWindsurf } from "../converters/claude-to-windsurf"
|
import { transformContentForWindsurf } from "../converters/claude-to-windsurf"
|
||||||
import type { WindsurfBundle } from "../types/windsurf"
|
import type { WindsurfBundle } from "../types/windsurf"
|
||||||
@@ -20,7 +20,7 @@ export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBu
|
|||||||
await ensureDir(skillsDir)
|
await ensureDir(skillsDir)
|
||||||
for (const skill of bundle.agentSkills) {
|
for (const skill of bundle.agentSkills) {
|
||||||
validatePathSafe(skill.name, "agent skill")
|
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)
|
const resolvedDest = path.resolve(destDir)
|
||||||
if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
|
if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
|
||||||
@@ -51,7 +51,7 @@ export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBu
|
|||||||
await ensureDir(skillsDir)
|
await ensureDir(skillsDir)
|
||||||
for (const skill of bundle.skillDirs) {
|
for (const skill of bundle.skillDirs) {
|
||||||
validatePathSafe(skill.name, "skill directory")
|
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)
|
const resolvedDest = path.resolve(destDir)
|
||||||
if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
|
if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
|
||||||
|
|||||||
@@ -75,6 +75,16 @@ export async function walkFiles(root: string): Promise<string[]> {
|
|||||||
return results
|
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.
|
* Resolve a colon-separated command name into a filesystem path.
|
||||||
* e.g. resolveCommandPath("/commands", "ce:plan", ".md") -> "/commands/ce/plan.md"
|
* e.g. resolveCommandPath("/commands", "ce:plan", ".md") -> "/commands/ce/plan.md"
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ describe("CLI", () => {
|
|||||||
expect(stdout).toContain("Installed compound-engineering")
|
expect(stdout).toContain("Installed compound-engineering")
|
||||||
expect(stdout).toContain(codexRoot)
|
expect(stdout).toContain(codexRoot)
|
||||||
expect(await exists(path.join(codexRoot, "prompts", "ce-plan.md"))).toBe(true)
|
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)
|
expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -690,7 +690,7 @@ describe("CLI", () => {
|
|||||||
expect(stdout).toContain("Synced to gemini")
|
expect(stdout).toContain("Synced to gemini")
|
||||||
expect(stdout).not.toContain("cursor")
|
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", "config.toml"))).toBe(true)
|
||||||
expect(await exists(path.join(tempHome, ".codex", "prompts", "workflows-plan.md"))).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)
|
expect(await exists(path.join(tempHome, ".codex", "skills", "workflows-plan", "SKILL.md"))).toBe(true)
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ Use /deepen-plan for deeper research.
|
|||||||
await writeCodexBundle(tempRoot, bundle)
|
await writeCodexBundle(tempRoot, bundle)
|
||||||
|
|
||||||
const installedSkill = await fs.readFile(
|
const installedSkill = await fs.readFile(
|
||||||
path.join(tempRoot, ".codex", "skills", "ce:brainstorm", "SKILL.md"),
|
path.join(tempRoot, ".codex", "skills", "ce-brainstorm", "SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
)
|
)
|
||||||
expect(installedSkill).toContain("/prompts:ce-plan")
|
expect(installedSkill).toContain("/prompts:ce-plan")
|
||||||
@@ -152,7 +152,7 @@ Use /deepen-plan for deeper research.
|
|||||||
expect(installedSkill).toContain("/prompts:deepen-plan")
|
expect(installedSkill).toContain("/prompts:deepen-plan")
|
||||||
|
|
||||||
const notes = await fs.readFile(
|
const notes = await fs.readFile(
|
||||||
path.join(tempRoot, ".codex", "skills", "ce:brainstorm", "notes.md"),
|
path.join(tempRoot, ".codex", "skills", "ce-brainstorm", "notes.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
)
|
)
|
||||||
expect(notes).toContain("/ce:plan")
|
expect(notes).toContain("/ce:plan")
|
||||||
@@ -194,7 +194,7 @@ Also run bare agents:
|
|||||||
await writeCodexBundle(tempRoot, bundle)
|
await writeCodexBundle(tempRoot, bundle)
|
||||||
|
|
||||||
const installedSkill = await fs.readFile(
|
const installedSkill = await fs.readFile(
|
||||||
path.join(tempRoot, ".codex", "skills", "ce:plan", "SKILL.md"),
|
path.join(tempRoot, ".codex", "skills", "ce-plan", "SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -485,4 +485,35 @@ Task best-practices-researcher(topic)`
|
|||||||
expect(result).toContain("the dhh-rails-reviewer agent")
|
expect(result).toContain("the dhh-rails-reviewer agent")
|
||||||
expect(result).not.toContain("@security-sentinel")
|
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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ Run these research agents:
|
|||||||
await writeCopilotBundle(tempRoot, bundle)
|
await writeCopilotBundle(tempRoot, bundle)
|
||||||
|
|
||||||
const installedSkill = await fs.readFile(
|
const installedSkill = await fs.readFile(
|
||||||
path.join(tempRoot, ".github", "skills", "ce:plan", "SKILL.md"),
|
path.join(tempRoot, ".github", "skills", "ce-plan", "SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ Run these research agents:
|
|||||||
await writeDroidBundle(tempRoot, bundle)
|
await writeDroidBundle(tempRoot, bundle)
|
||||||
|
|
||||||
const installedSkill = await fs.readFile(
|
const installedSkill = await fs.readFile(
|
||||||
path.join(tempRoot, ".factory", "skills", "ce:plan", "SKILL.md"),
|
path.join(tempRoot, ".factory", "skills", "ce-plan", "SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ Run these research agents:
|
|||||||
await writeGeminiBundle(tempRoot, bundle)
|
await writeGeminiBundle(tempRoot, bundle)
|
||||||
|
|
||||||
const installedSkill = await fs.readFile(
|
const installedSkill = await fs.readFile(
|
||||||
path.join(tempRoot, ".gemini", "skills", "ce:plan", "SKILL.md"),
|
path.join(tempRoot, ".gemini", "skills", "ce-plan", "SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ Run these research agents:
|
|||||||
await writeKiroBundle(tempRoot, bundle)
|
await writeKiroBundle(tempRoot, bundle)
|
||||||
|
|
||||||
const installedSkill = await fs.readFile(
|
const installedSkill = await fs.readFile(
|
||||||
path.join(tempRoot, ".kiro", "skills", "ce:plan", "SKILL.md"),
|
path.join(tempRoot, ".kiro", "skills", "ce-plan", "SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ describe("convertClaudeToOpenClaw", () => {
|
|||||||
properties: {},
|
properties: {},
|
||||||
})
|
})
|
||||||
expect(bundle.manifest.skills).toContain("skills/agent-security-reviewer")
|
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")
|
expect(bundle.manifest.skills).toContain("skills/existing-skill")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -201,4 +201,27 @@ describe("convertClaudeToOpenClaw", () => {
|
|||||||
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
|
const bundle = convertClaudeToOpenClaw(plugin, defaultOptions)
|
||||||
expect(bundle.openclawConfig).toBeUndefined()
|
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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
39
tests/path-sanitization.test.ts
Normal file
39
tests/path-sanitization.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -80,7 +80,7 @@ Run these research agents:
|
|||||||
await writePiBundle(outputRoot, bundle)
|
await writePiBundle(outputRoot, bundle)
|
||||||
|
|
||||||
const installedSkill = await fs.readFile(
|
const installedSkill = await fs.readFile(
|
||||||
path.join(outputRoot, "skills", "ce:plan", "SKILL.md"),
|
path.join(outputRoot, "skills", "ce-plan", "SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -592,3 +592,42 @@ describe("normalizeName", () => {
|
|||||||
expect(normalizeName("123-agent")).toBe("item")
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ Run these research agents:
|
|||||||
await writeWindsurfBundle(tempRoot, bundle)
|
await writeWindsurfBundle(tempRoot, bundle)
|
||||||
|
|
||||||
const installedSkill = await fs.readFile(
|
const installedSkill = await fs.readFile(
|
||||||
path.join(tempRoot, "skills", "ce:plan", "SKILL.md"),
|
path.join(tempRoot, "skills", "ce-plan", "SKILL.md"),
|
||||||
"utf8",
|
"utf8",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user