fix: sanitize colons in skill/agent names for Windows path compatibility (#398)

This commit is contained in:
Trevin Chow
2026-03-26 16:15:48 -07:00
committed by GitHub
parent 0877b693ce
commit b25480af9e
31 changed files with 356 additions and 61 deletions

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}

View File

@@ -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",
)
}

View File

@@ -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<void> {
@@ -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)
}

View File

@@ -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<string>()
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)))
}
}
}

View File

@@ -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) {

View File

@@ -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<void> {
@@ -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)))
}
}
}

View File

@@ -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))) {