Files
claude-engineering-plugin/src/targets/windsurf.ts
Ryan Burnham e081e32a30 fix: pass scope to writeWindsurfBundle and fix skill name casing
- Fix resolve-pr-parallel SKILL.md name from underscores to hyphens
  (must match directory name per Windsurf spec)
- Add scope parameter to TargetHandler.write signature
- Pass resolvedScope through to writer in convert.ts and install.ts
- Windsurf writer uses global_workflows/ for global scope, workflows/
  for workspace scope

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:29:40 +08:00

105 lines
4.1 KiB
TypeScript

import path from "path"
import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJsonSecure, writeText } from "../utils/files"
import { formatFrontmatter } from "../utils/frontmatter"
import type { WindsurfBundle } from "../types/windsurf"
import type { TargetScope } from "./index"
/**
* Write a WindsurfBundle directly into outputRoot.
*
* Unlike other target writers, this writer expects outputRoot to be the final
* resolved directory — the CLI handles scope-based nesting (global vs workspace).
*/
export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBundle, scope?: TargetScope): Promise<void> {
await ensureDir(outputRoot)
// Write agent skills (before pass-through copies so pass-through takes precedence on collision)
if (bundle.agentSkills.length > 0) {
const skillsDir = path.join(outputRoot, "skills")
await ensureDir(skillsDir)
for (const skill of bundle.agentSkills) {
validatePathSafe(skill.name, "agent skill")
const destDir = path.join(skillsDir, skill.name)
const resolvedDest = path.resolve(destDir)
if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
console.warn(`Warning: Agent skill name "${skill.name}" escapes skills/. Skipping.`)
continue
}
await ensureDir(destDir)
await writeText(path.join(destDir, "SKILL.md"), skill.content)
}
}
// Write command workflows (flat in global_workflows/ for global scope, workflows/ for workspace)
if (bundle.commandWorkflows.length > 0) {
const workflowsDirName = scope === "global" ? "global_workflows" : "workflows"
const workflowsDir = path.join(outputRoot, workflowsDirName)
await ensureDir(workflowsDir)
for (const workflow of bundle.commandWorkflows) {
validatePathSafe(workflow.name, "command workflow")
const content = formatWorkflowContent(workflow.name, workflow.description, workflow.body)
await writeText(path.join(workflowsDir, `${workflow.name}.md`), content)
}
}
// Copy pass-through skill directories (after generated skills so copies overwrite on collision)
if (bundle.skillDirs.length > 0) {
const skillsDir = path.join(outputRoot, "skills")
await ensureDir(skillsDir)
for (const skill of bundle.skillDirs) {
validatePathSafe(skill.name, "skill directory")
const destDir = path.join(skillsDir, skill.name)
const resolvedDest = path.resolve(destDir)
if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
console.warn(`Warning: Skill name "${skill.name}" escapes skills/. Skipping.`)
continue
}
await copyDir(skill.sourceDir, destDir)
}
}
// Merge MCP config
if (bundle.mcpConfig) {
const mcpPath = path.join(outputRoot, "mcp_config.json")
const backupPath = await backupFile(mcpPath)
if (backupPath) {
console.log(`Backed up existing mcp_config.json to ${backupPath}`)
}
let existingConfig: Record<string, unknown> = {}
if (await pathExists(mcpPath)) {
try {
const parsed = await readJson<unknown>(mcpPath)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
existingConfig = parsed as Record<string, unknown>
}
} catch {
console.warn("Warning: existing mcp_config.json could not be parsed and will be replaced.")
}
}
const existingServers =
existingConfig.mcpServers &&
typeof existingConfig.mcpServers === "object" &&
!Array.isArray(existingConfig.mcpServers)
? (existingConfig.mcpServers as Record<string, unknown>)
: {}
const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpConfig.mcpServers } }
await writeJsonSecure(mcpPath, merged)
}
}
function validatePathSafe(name: string, label: string): void {
if (name.includes("..") || name.includes("/") || name.includes("\\")) {
throw new Error(`${label} name contains unsafe path characters: ${name}`)
}
}
function formatWorkflowContent(name: string, description: string, body: string): string {
return formatFrontmatter({ description }, `# ${name}\n\n${body}`) + "\n"
}