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

@@ -2,10 +2,11 @@ import { defineCommand } from "citty"
import os from "os"
import path from "path"
import { loadClaudePlugin } from "../parsers/claude"
import { targets } from "../targets"
import { targets, validateScope } from "../targets"
import type { PermissionMode } from "../converters/claude-to-opencode"
import { ensureCodexAgentsFile } from "../utils/codex-agents"
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
import { resolveTargetOutputRoot } from "../utils/resolve-output"
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
@@ -23,7 +24,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro)",
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf)",
},
output: {
type: "string",
@@ -40,6 +41,10 @@ export default defineCommand({
alias: "pi-home",
description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
},
scope: {
type: "string",
description: "Scope level: global | workspace (default varies by target)",
},
also: {
type: "string",
description: "Comma-separated extra targets to generate (ex: codex)",
@@ -76,8 +81,11 @@ export default defineCommand({
throw new Error(`Unknown permissions mode: ${permissions}`)
}
const resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined)
const plugin = await loadClaudePlugin(String(args.source))
const outputRoot = resolveOutputRoot(args.output)
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex"))
const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent"))
@@ -87,7 +95,14 @@ export default defineCommand({
permissions: permissions as PermissionMode,
}
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome)
const primaryOutputRoot = resolveTargetOutputRoot({
targetName,
outputRoot,
codexHome,
piHome,
hasExplicitOutput,
scope: resolvedScope,
})
const bundle = target.convert(plugin, options)
if (!bundle) {
throw new Error(`Target ${targetName} did not return a bundle.`)
@@ -113,7 +128,14 @@ export default defineCommand({
console.warn(`Skipping ${extra}: no output returned.`)
continue
}
const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome)
const extraRoot = resolveTargetOutputRoot({
targetName: extra,
outputRoot: path.join(outputRoot, extra),
codexHome,
piHome,
hasExplicitOutput,
scope: handler.defaultScope,
})
await handler.write(extraRoot, extraBundle)
console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`)
}
@@ -140,12 +162,3 @@ function resolveOutputRoot(value: unknown): string {
return process.cwd()
}
function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string, piHome: string): string {
if (targetName === "codex") return codexHome
if (targetName === "pi") return piHome
if (targetName === "droid") return path.join(os.homedir(), ".factory")
if (targetName === "cursor") return path.join(outputRoot, ".cursor")
if (targetName === "gemini") return path.join(outputRoot, ".gemini")
if (targetName === "kiro") return path.join(outputRoot, ".kiro")
return outputRoot
}

View File

@@ -3,11 +3,12 @@ import { promises as fs } from "fs"
import os from "os"
import path from "path"
import { loadClaudePlugin } from "../parsers/claude"
import { targets } from "../targets"
import { targets, validateScope } from "../targets"
import { pathExists } from "../utils/files"
import type { PermissionMode } from "../converters/claude-to-opencode"
import { ensureCodexAgentsFile } from "../utils/codex-agents"
import { expandHome, resolveTargetHome } from "../utils/resolve-home"
import { resolveTargetOutputRoot } from "../utils/resolve-output"
const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"]
@@ -25,7 +26,7 @@ export default defineCommand({
to: {
type: "string",
default: "opencode",
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro)",
description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf)",
},
output: {
type: "string",
@@ -42,6 +43,10 @@ export default defineCommand({
alias: "pi-home",
description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)",
},
scope: {
type: "string",
description: "Scope level: global | workspace (default varies by target)",
},
also: {
type: "string",
description: "Comma-separated extra targets to generate (ex: codex)",
@@ -77,6 +82,8 @@ export default defineCommand({
throw new Error(`Unknown permissions mode: ${permissions}`)
}
const resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined)
const resolvedPlugin = await resolvePluginPath(String(args.plugin))
try {
@@ -96,7 +103,14 @@ export default defineCommand({
throw new Error(`Target ${targetName} did not return a bundle.`)
}
const hasExplicitOutput = Boolean(args.output && String(args.output).trim())
const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput)
const primaryOutputRoot = resolveTargetOutputRoot({
targetName,
outputRoot,
codexHome,
piHome,
hasExplicitOutput,
scope: resolvedScope,
})
await target.write(primaryOutputRoot, bundle)
console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`)
@@ -117,7 +131,14 @@ export default defineCommand({
console.warn(`Skipping ${extra}: no output returned.`)
continue
}
const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput)
const extraRoot = resolveTargetOutputRoot({
targetName: extra,
outputRoot: path.join(outputRoot, extra),
codexHome,
piHome,
hasExplicitOutput,
scope: handler.defaultScope,
})
await handler.write(extraRoot, extraBundle)
console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`)
}
@@ -169,35 +190,6 @@ function resolveOutputRoot(value: unknown): string {
return path.join(os.homedir(), ".config", "opencode")
}
function resolveTargetOutputRoot(
targetName: string,
outputRoot: string,
codexHome: string,
piHome: string,
hasExplicitOutput: boolean,
): string {
if (targetName === "codex") return codexHome
if (targetName === "pi") return piHome
if (targetName === "droid") return path.join(os.homedir(), ".factory")
if (targetName === "cursor") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".cursor")
}
if (targetName === "gemini") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".gemini")
}
if (targetName === "copilot") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".github")
}
if (targetName === "kiro") {
const base = hasExplicitOutput ? outputRoot : process.cwd()
return path.join(base, ".kiro")
}
return outputRoot
}
async function resolveGitHubPluginPath(pluginName: string): Promise<ResolvedPluginPath> {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-"))
const source = resolveGitHubSource()

View File

@@ -8,6 +8,7 @@ import { syncToPi } from "../sync/pi"
import { syncToDroid } from "../sync/droid"
import { syncToCopilot } from "../sync/copilot"
import { expandHome } from "../utils/resolve-home"
import { hasPotentialSecrets } from "../utils/secrets"
const validTargets = ["opencode", "codex", "pi", "droid", "copilot"] as const
type SyncTarget = (typeof validTargets)[number]
@@ -16,20 +17,6 @@ function isValidTarget(value: string): value is SyncTarget {
return (validTargets as readonly string[]).includes(value)
}
/** Check if any MCP servers have env vars that might contain secrets */
function hasPotentialSecrets(mcpServers: Record<string, unknown>): boolean {
const sensitivePatterns = /key|token|secret|password|credential|api_key/i
for (const server of Object.values(mcpServers)) {
const env = (server as { env?: Record<string, string> }).env
if (env) {
for (const key of Object.keys(env)) {
if (sensitivePatterns.test(key)) return true
}
}
}
return false
}
function resolveOutputRoot(target: SyncTarget): string {
switch (target) {
case "opencode":