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

@@ -46,6 +46,13 @@ export async function writeJson(filePath: string, data: unknown): Promise<void>
await writeText(filePath, content + "\n")
}
/** Write JSON with restrictive permissions (0o600) for files containing secrets */
export async function writeJsonSecure(filePath: string, data: unknown): Promise<void> {
const content = JSON.stringify(data, null, 2)
await ensureDir(path.dirname(filePath))
await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
}
export async function walkFiles(root: string): Promise<string[]> {
const entries = await fs.readdir(root, { withFileTypes: true })
const results: string[] = []

View File

@@ -0,0 +1,39 @@
import os from "os"
import path from "path"
import type { TargetScope } from "../targets"
export function resolveTargetOutputRoot(options: {
targetName: string
outputRoot: string
codexHome: string
piHome: string
hasExplicitOutput: boolean
scope?: TargetScope
}): string {
const { targetName, outputRoot, codexHome, piHome, hasExplicitOutput, scope } = options
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")
}
if (targetName === "windsurf") {
if (hasExplicitOutput) return outputRoot
if (scope === "global") return path.join(os.homedir(), ".codeium", "windsurf")
return path.join(process.cwd(), ".windsurf")
}
return outputRoot
}

24
src/utils/secrets.ts Normal file
View File

@@ -0,0 +1,24 @@
export const SENSITIVE_PATTERN = /key|token|secret|password|credential|api_key/i
/** Check if any MCP servers have env vars that might contain secrets */
export function hasPotentialSecrets(
servers: Record<string, { env?: Record<string, string> }>,
): boolean {
for (const server of Object.values(servers)) {
if (server.env) {
for (const key of Object.keys(server.env)) {
if (SENSITIVE_PATTERN.test(key)) return true
}
}
}
return false
}
/** Return names of MCP servers whose env vars may contain secrets */
export function findServersWithPotentialSecrets(
servers: Record<string, { env?: Record<string, string> }>,
): string[] {
return Object.entries(servers)
.filter(([, s]) => s.env && Object.keys(s.env).some((k) => SENSITIVE_PATTERN.test(k)))
.map(([name]) => name)
}