feat(windsurf): add Windsurf as converter target with global scope support

Add `--to windsurf` target for the converter CLI with full spec compliance
per docs/specs/windsurf.md:

- Claude agents → Windsurf skills (skills/{name}/SKILL.md)
- Claude commands → Windsurf workflows (workflows/{name}.md, flat)
- Pass-through skills copy unchanged
- MCP servers → mcp_config.json (merged with existing, 0o600 permissions)
- Hooks skipped with warning, CLAUDE.md skipped

Global scope support via generic --scope flag (Windsurf as first adopter):
- --to windsurf defaults to global (~/.codeium/windsurf/)
- --scope workspace for project-level .windsurf/ output
- --output overrides scope-derived paths

Shared utilities extracted (resolveTargetOutputRoot, hasPotentialSecrets)
to eliminate duplication across CLI commands.

68 new tests (converter, writer, scope resolution).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ryan Burnham
2026-02-26 18:36:34 +08:00
parent 63e76cf67f
commit 6fe51a0602
19 changed files with 3361 additions and 62 deletions

View File

@@ -6,6 +6,7 @@ import type { PiBundle } from "../types/pi"
import type { CopilotBundle } from "../types/copilot"
import type { GeminiBundle } from "../types/gemini"
import type { KiroBundle } from "../types/kiro"
import type { WindsurfBundle } from "../types/windsurf"
import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode"
import { convertClaudeToCodex } from "../converters/claude-to-codex"
import { convertClaudeToDroid } from "../converters/claude-to-droid"
@@ -13,6 +14,7 @@ import { convertClaudeToPi } from "../converters/claude-to-pi"
import { convertClaudeToCopilot } from "../converters/claude-to-copilot"
import { convertClaudeToGemini } from "../converters/claude-to-gemini"
import { convertClaudeToKiro } from "../converters/claude-to-kiro"
import { convertClaudeToWindsurf } from "../converters/claude-to-windsurf"
import { writeOpenCodeBundle } from "./opencode"
import { writeCodexBundle } from "./codex"
import { writeDroidBundle } from "./droid"
@@ -20,10 +22,41 @@ import { writePiBundle } from "./pi"
import { writeCopilotBundle } from "./copilot"
import { writeGeminiBundle } from "./gemini"
import { writeKiroBundle } from "./kiro"
import { writeWindsurfBundle } from "./windsurf"
export type TargetScope = "global" | "workspace"
export function isTargetScope(value: string): value is TargetScope {
return value === "global" || value === "workspace"
}
/**
* Validate a --scope flag against a target's supported scopes.
* Returns the resolved scope (explicit or default) or throws on invalid input.
*/
export function validateScope(
targetName: string,
target: TargetHandler,
scopeArg: string | undefined,
): TargetScope | undefined {
if (scopeArg === undefined) return target.defaultScope
if (!target.supportedScopes) {
throw new Error(`Target "${targetName}" does not support the --scope flag.`)
}
if (!isTargetScope(scopeArg) || !target.supportedScopes.includes(scopeArg)) {
throw new Error(`Target "${targetName}" does not support --scope ${scopeArg}. Supported: ${target.supportedScopes.join(", ")}`)
}
return scopeArg
}
export type TargetHandler<TBundle = unknown> = {
name: string
implemented: boolean
/** Default scope when --scope is not provided. Only meaningful when supportedScopes is defined. */
defaultScope?: TargetScope
/** Valid scope values. If absent, the --scope flag is rejected for this target. */
supportedScopes?: TargetScope[]
convert: (plugin: ClaudePlugin, options: ClaudeToOpenCodeOptions) => TBundle | null
write: (outputRoot: string, bundle: TBundle) => Promise<void>
}
@@ -71,4 +104,12 @@ export const targets: Record<string, TargetHandler> = {
convert: convertClaudeToKiro as TargetHandler<KiroBundle>["convert"],
write: writeKiroBundle as TargetHandler<KiroBundle>["write"],
},
windsurf: {
name: "windsurf",
implemented: true,
defaultScope: "global",
supportedScopes: ["global", "workspace"],
convert: convertClaudeToWindsurf as TargetHandler<WindsurfBundle>["convert"],
write: writeWindsurfBundle as TargetHandler<WindsurfBundle>["write"],
},
}

102
src/targets/windsurf.ts Normal file
View File

@@ -0,0 +1,102 @@
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"
/**
* 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): 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 workflows/, per spec)
if (bundle.commandWorkflows.length > 0) {
const workflowsDir = path.join(outputRoot, "workflows")
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"
}