diff --git a/CHANGELOG.md b/CHANGELOG.md index 4376dd56..fc2468b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to the `@every-env/compound-plugin` CLI tool will be documen The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.11.0] - 2026-02-26 + +### Added + +- **Windsurf target** — `--to windsurf` converts plugins to Windsurf format. Claude agents become Windsurf skills (`skills/{name}/SKILL.md`), commands become flat workflows (`global_workflows/{name}.md` for global scope, `workflows/{name}.md` for workspace), and pass-through skills copy unchanged. MCP servers write to `mcp_config.json` (machine-readable, merged with existing config). +- **Global scope support** — New `--scope global|workspace` flag (generic, Windsurf as first adopter). `--to windsurf` defaults to global scope (`~/.codeium/windsurf/`), making installed skills, workflows, and MCP servers available across all projects. Use `--scope workspace` for project-level `.windsurf/` output. +- **`mcp_config.json` integration** — Windsurf converter writes proper machine-readable MCP config supporting stdio, Streamable HTTP, and SSE transports. Merges with existing config (user entries preserved, plugin entries take precedence). Written with `0o600` permissions. +- **Shared utilities** — Extracted `resolveTargetOutputRoot` to `src/utils/resolve-output.ts` and `hasPotentialSecrets` to `src/utils/secrets.ts` to eliminate duplication. + +--- + ## [0.9.1] - 2026-02-20 ### Changed diff --git a/README.md b/README.md index 58850384..57df279a 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,9 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** /add-plugin compound-engineering ``` -## OpenCode, Codex, Droid, Pi, Gemini, Copilot & Kiro (experimental) Install +## OpenCode, Codex, Droid, Pi, Gemini, Copilot, Kiro & Windsurf (experimental) Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI, GitHub Copilot, and Kiro CLI. +This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI, GitHub Copilot, Kiro CLI, and Windsurf. ```bash # convert the compound-engineering plugin into OpenCode format @@ -43,6 +43,12 @@ bunx @every-env/compound-plugin install compound-engineering --to copilot # convert to Kiro CLI format bunx @every-env/compound-plugin install compound-engineering --to kiro + +# convert to Windsurf format (global scope by default) +bunx @every-env/compound-plugin install compound-engineering --to windsurf + +# convert to Windsurf workspace scope +bunx @every-env/compound-plugin install compound-engineering --to windsurf --scope workspace ``` Local dev: @@ -58,6 +64,7 @@ Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensio Gemini output is written to `.gemini/` with skills (from agents), commands (`.toml`), and `settings.json` (MCP servers). Namespaced commands create directory structure (`workflows:plan` → `commands/workflows/plan.toml`). Skills use the identical SKILL.md standard and pass through unchanged. Copilot output is written to `.github/` with agents (`.agent.md`), skills (`SKILL.md`), and `copilot-mcp-config.json`. Agents get Copilot frontmatter (`description`, `tools: ["*"]`, `infer: true`), commands are converted to agent skills, and MCP server env vars are prefixed with `COPILOT_MCP_`. Kiro output is written to `.kiro/` with custom agents (`.json` configs + prompt `.md` files), skills (from commands), pass-through skills, steering files (from CLAUDE.md), and `mcp.json`. Agents get `includeMcpJson: true` for MCP server access. Only stdio MCP servers are supported (HTTP servers are skipped with a warning). +Windsurf output defaults to global scope (`~/.codeium/windsurf/`). Claude agents become Windsurf skills (`skills/{name}/SKILL.md`), commands become flat workflows (`global_workflows/{name}.md` for global scope, `workflows/{name}.md` for workspace), and pass-through skills copy unchanged. MCP servers write to `mcp_config.json` (machine-readable, merged with existing config). Use `--scope workspace` for project-level output (`.windsurf/`). Env vars including secrets are included in `mcp_config.json` with a console warning for sensitive keys. The `--scope` flag is generic — Windsurf is the first target to support it. All provider targets are experimental and may change as the formats evolve. diff --git a/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md b/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md new file mode 100644 index 00000000..d90eb6a2 --- /dev/null +++ b/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md @@ -0,0 +1,627 @@ +--- +title: Windsurf Global Scope Support +type: feat +status: completed +date: 2026-02-25 +deepened: 2026-02-25 +prior: docs/plans/2026-02-23-feat-add-windsurf-target-provider-plan.md (removed — superseded) +--- + +# Windsurf Global Scope Support + +## Post-Implementation Revisions (2026-02-26) + +After auditing the implementation against `docs/specs/windsurf.md`, two significant changes were made: + +1. **Agents → Skills (not Workflows)**: Claude agents map to Windsurf Skills (`skills/{name}/SKILL.md`), not Workflows. Skills are "complex multi-step tasks with supporting resources" — a better conceptual match for specialized expertise/personas. Workflows are "reusable step-by-step procedures" — a better match for Claude Commands (slash commands). + +2. **Workflows are flat files**: Command workflows are written to `global_workflows/{name}.md` (global scope) or `workflows/{name}.md` (workspace scope). No subdirectories — the spec requires flat files. + +3. **Content transforms updated**: `@agent-name` references are kept as-is (Windsurf skill invocation syntax). `/command` references produce `/{name}` (not `/commands/{name}`). `Task agent(args)` produces `Use the @agent-name skill: args`. + +### Final Component Mapping (per spec) + +| Claude Code | Windsurf | Output Path | Invocation | +|---|---|---|---| +| Agents (`.md`) | Skills | `skills/{name}/SKILL.md` | `@skill-name` or automatic | +| Commands (`.md`) | Workflows (flat) | `global_workflows/{name}.md` (global) / `workflows/{name}.md` (workspace) | `/{workflow-name}` | +| Skills (`SKILL.md`) | Skills (pass-through) | `skills/{name}/SKILL.md` | `@skill-name` | +| MCP servers | `mcp_config.json` | `mcp_config.json` | N/A | +| Hooks | Skipped with warning | N/A | N/A | +| CLAUDE.md | Skipped | N/A | N/A | + +### Files Changed in Revision + +- `src/types/windsurf.ts` — `agentWorkflows` → `agentSkills: WindsurfGeneratedSkill[]` +- `src/converters/claude-to-windsurf.ts` — `convertAgentToSkill()`, updated content transforms +- `src/targets/windsurf.ts` — Skills written as `skills/{name}/SKILL.md`, flat workflows +- Tests updated to match + +--- + +## Enhancement Summary + +**Deepened on:** 2026-02-25 +**Research agents used:** architecture-strategist, kieran-typescript-reviewer, security-sentinel, code-simplicity-reviewer, pattern-recognition-specialist +**External research:** Windsurf MCP docs, Windsurf tutorial docs + +### Key Improvements from Deepening +1. **HTTP/SSE servers should be INCLUDED** — Windsurf supports all 3 transport types (stdio, Streamable HTTP, SSE). Original plan incorrectly skipped them. +2. **File permissions: use `0o600`** — `mcp_config.json` contains secrets and must not be world-readable. Add secure write support. +3. **Extract `resolveTargetOutputRoot` to shared utility** — both commands duplicate this; adding scope makes it worse. Extract first. +4. **Bug fix: missing `result[name] = entry`** — all 5 review agents caught a copy-paste bug in the `buildMcpConfig` sample code. +5. **`hasPotentialSecrets` to shared utility** — currently in sync.ts, would be duplicated. Extract to `src/utils/secrets.ts`. +6. **Windsurf `mcp_config.json` is global-only** — per Windsurf docs, no per-project MCP config support. Workspace scope writes it for forward-compatibility but emit a warning. +7. **Windsurf supports `${env:VAR}` interpolation** — consider writing env var references instead of literal values for secrets. + +### New Considerations Discovered +- Backup files accumulate with secrets and are never cleaned up — cap at 3 backups +- Workspace `mcp_config.json` could be committed to git — warn about `.gitignore` +- `WindsurfMcpServerEntry` type needs `serverUrl` field for HTTP/SSE servers +- Simplicity reviewer recommends handling scope as windsurf-specific in CLI rather than generic `TargetHandler` fields — but brainstorm explicitly chose "generic with windsurf as first adopter". **Decision: keep generic approach** per user's brainstorm decision, with JSDoc documenting the relationship between `defaultScope` and `supportedScopes`. + +--- + +## Overview + +Add a generic `--scope global|workspace` flag to the converter CLI with Windsurf as the first adopter. Global scope writes to `~/.codeium/windsurf/`, making workflows, skills, and MCP servers available across all projects. This also upgrades MCP handling from a human-readable setup doc (`mcp-setup.md`) to a proper machine-readable config (`mcp_config.json`), and removes AGENTS.md generation (the plugin's CLAUDE.md contains development-internal instructions, not user-facing content). + +## Problem Statement / Motivation + +The current Windsurf converter (v0.10.0) writes everything to project-level `.windsurf/`, requiring re-installation per project. Windsurf supports global paths for skills (`~/.codeium/windsurf/skills/`) and MCP config (`~/.codeium/windsurf/mcp_config.json`). Users should install once and get capabilities everywhere. + +Additionally, the v0.10.0 MCP output was a markdown setup guide — not an actual integration. Windsurf reads `mcp_config.json` directly, so we should write to that file. + +## Breaking Changes from v0.10.0 + +This is a **minor version bump** (v0.11.0) with intentional breaking changes to the experimental Windsurf target: + +1. **Default output location changed** — `--to windsurf` now defaults to global scope (`~/.codeium/windsurf/`). Use `--scope workspace` for the old behavior. +2. **AGENTS.md no longer generated** — old files are left in place (not deleted). +3. **`mcp-setup.md` replaced by `mcp_config.json`** — proper machine-readable integration. Old files left in place. +4. **Env var secrets included with warning** — previously redacted, now included (required for the config file to work). +5. **`--output` semantics changed** — `--output` now specifies the direct target directory (not a parent where `.windsurf/` is created). + +## Proposed Solution + +### Phase 0: Extract Shared Utilities (prerequisite) + +**Files:** `src/utils/resolve-output.ts` (new), `src/utils/secrets.ts` (new) + +#### 0a. Extract `resolveTargetOutputRoot` to shared utility + +Both `install.ts` and `convert.ts` have near-identical `resolveTargetOutputRoot` functions that are already diverging (`hasExplicitOutput` exists in install.ts but not convert.ts). Adding scope would make the duplication worse. + +- [x] Create `src/utils/resolve-output.ts` with a unified function: + +```typescript +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 +} +``` + +- [x] Update `install.ts` to import and call `resolveTargetOutputRoot` from shared utility +- [x] Update `convert.ts` to import and call `resolveTargetOutputRoot` from shared utility +- [x] Add `hasExplicitOutput` tracking to `convert.ts` (currently missing) + +### Research Insights (Phase 0) + +**Architecture review:** Both commands will call the same function with the same signature. This eliminates the divergence and ensures scope resolution has a single source of truth. The `--also` loop in both commands also uses this function with `handler.defaultScope`. + +**Pattern review:** This follows the same extraction pattern as `resolveTargetHome` in `src/utils/resolve-home.ts`. + +#### 0b. Extract `hasPotentialSecrets` to shared utility + +Currently in `sync.ts:20-31`. The same regex pattern also appears in `claude-to-windsurf.ts:223` as `redactEnvValue`. Extract to avoid a third copy. + +- [x] Create `src/utils/secrets.ts`: + +```typescript +const SENSITIVE_PATTERN = /key|token|secret|password|credential|api_key/i + +export function hasPotentialSecrets( + servers: Record }>, +): 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 +} +``` + +- [x] Update `sync.ts` to import from shared utility +- [x] Use in new windsurf converter + +### Phase 1: Types and TargetHandler + +**Files:** `src/types/windsurf.ts`, `src/targets/index.ts` + +#### 1a. Update WindsurfBundle type + +```typescript +// src/types/windsurf.ts +export type WindsurfMcpServerEntry = { + command?: string + args?: string[] + env?: Record + serverUrl?: string + headers?: Record +} + +export type WindsurfMcpConfig = { + mcpServers: Record +} + +export type WindsurfBundle = { + agentWorkflows: WindsurfWorkflow[] + commandWorkflows: WindsurfWorkflow[] + skillDirs: WindsurfSkillDir[] + mcpConfig: WindsurfMcpConfig | null +} +``` + +- [x] Remove `agentsMd: string | null` +- [x] Replace `mcpSetupDoc: string | null` with `mcpConfig: WindsurfMcpConfig | null` +- [x] Add `WindsurfMcpServerEntry` (supports both stdio and HTTP/SSE) and `WindsurfMcpConfig` types + +### Research Insights (Phase 1a) + +**Windsurf docs confirm** three transport types: stdio (`command` + `args`), Streamable HTTP (`serverUrl`), and SSE (`serverUrl` or `url`). The `WindsurfMcpServerEntry` type must support all three — making `command` optional and adding `serverUrl` and `headers` fields. + +**TypeScript reviewer:** Consider making `WindsurfMcpServerEntry` a discriminated union if strict typing is desired. However, since this mirrors JSON config structure, a flat type with optional fields is pragmatically simpler. + +#### 1b. Add TargetScope to TargetHandler + +```typescript +// src/targets/index.ts +export type TargetScope = "global" | "workspace" + +export type TargetHandler = { + name: string + implemented: boolean + /** + * Default scope when --scope is not provided. + * Only meaningful when supportedScopes is defined. + * Falls back to "workspace" if absent. + */ + 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 +} +``` + +- [x] Add `TargetScope` type export +- [x] Add `defaultScope?` and `supportedScopes?` to `TargetHandler` with JSDoc +- [x] Set windsurf target: `defaultScope: "global"`, `supportedScopes: ["global", "workspace"]` +- [x] No changes to other targets (they have no scope fields, flag is ignored) + +### Research Insights (Phase 1b) + +**Simplicity review:** Argued this is premature generalization (only 1 of 8 targets uses scopes). Recommended handling scope as windsurf-specific with `if (targetName !== "windsurf")` guard instead. **Decision: keep generic approach** per brainstorm decision "Generic with windsurf as first adopter", but add JSDoc documenting the invariant. + +**TypeScript review:** Suggested a `ScopeConfig` grouped object to prevent `defaultScope` without `supportedScopes`. The JSDoc approach is simpler and sufficient for now. + +**Architecture review:** Adding optional fields to `TargetHandler` follows Open/Closed Principle — existing targets are unaffected. Clean extension. + +### Phase 2: Converter Changes + +**Files:** `src/converters/claude-to-windsurf.ts` + +#### 2a. Remove AGENTS.md generation + +- [x] Remove `buildAgentsMd()` function +- [x] Remove `agentsMd` from return value + +#### 2b. Replace MCP setup doc with MCP config + +- [x] Remove `buildMcpSetupDoc()` function +- [x] Remove `redactEnvValue()` helper +- [x] Add `buildMcpConfig()` that returns `WindsurfMcpConfig | null` +- [x] Include **all** env vars (including secrets) — no redaction +- [x] Use shared `hasPotentialSecrets()` from `src/utils/secrets.ts` +- [x] Include **both** stdio and HTTP/SSE servers (Windsurf supports all transport types) + +```typescript +function buildMcpConfig( + servers?: Record, +): WindsurfMcpConfig | null { + if (!servers || Object.keys(servers).length === 0) return null + + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + if (server.command) { + // stdio transport + const entry: WindsurfMcpServerEntry = { command: server.command } + if (server.args?.length) entry.args = server.args + if (server.env && Object.keys(server.env).length > 0) entry.env = server.env + result[name] = entry + } else if (server.url) { + // HTTP/SSE transport + const entry: WindsurfMcpServerEntry = { serverUrl: server.url } + if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers + if (server.env && Object.keys(server.env).length > 0) entry.env = server.env + result[name] = entry + } else { + console.warn(`Warning: MCP server "${name}" has no command or URL. Skipping.`) + continue + } + } + + if (Object.keys(result).length === 0) return null + + // Warn about secrets (don't redact — they're needed for the config to work) + if (hasPotentialSecrets(result)) { + console.warn( + "Warning: MCP servers contain env vars that may include secrets (API keys, tokens).\n" + + " These will be written to mcp_config.json. Review before sharing the config file.", + ) + } + + return { mcpServers: result } +} +``` + +### Research Insights (Phase 2) + +**Windsurf docs (critical correction):** Windsurf supports **stdio, Streamable HTTP, and SSE** transports in `mcp_config.json`. HTTP/SSE servers use `serverUrl` (not `url`). The original plan incorrectly planned to skip HTTP/SSE servers. This is now corrected — all transport types are included. + +**All 5 review agents flagged:** The original code sample was missing `result[name] = entry` — the entry was built but never stored. Fixed above. + +**Security review:** The warning message should enumerate which specific env var names triggered detection. Enhanced version: + +```typescript +if (hasPotentialSecrets(result)) { + const flagged = Object.entries(result) + .filter(([, s]) => s.env && Object.keys(s.env).some(k => SENSITIVE_PATTERN.test(k))) + .map(([name]) => name) + console.warn( + `Warning: MCP servers contain env vars that may include secrets: ${flagged.join(", ")}.\n` + + " These will be written to mcp_config.json. Review before sharing the config file.", + ) +} +``` + +**Windsurf env var interpolation:** Windsurf supports `${env:VARIABLE_NAME}` syntax in `mcp_config.json`. Future enhancement: write env var references instead of literal values for secrets. Out of scope for v0.11.0 (requires more research on which fields support interpolation). + +### Phase 3: Writer Changes + +**Files:** `src/targets/windsurf.ts`, `src/utils/files.ts` + +#### 3a. Simplify writer — remove AGENTS.md and double-nesting guard + +The writer always writes directly into `outputRoot`. The CLI resolves the correct output root based on scope. + +- [x] Remove AGENTS.md writing block (lines 10-17) +- [x] Remove `resolveWindsurfPaths()` — no longer needed +- [x] Write workflows, skills, and MCP config directly into `outputRoot` + +### Research Insights (Phase 3a) + +**Pattern review (dissent):** Every other writer (kiro, copilot, gemini, droid) has a `resolve*Paths()` function with a double-nesting guard. Removing it makes Windsurf the only target where the CLI fully owns nesting. This creates an inconsistency in the `write()` contract. + +**Resolution:** Accept the divergence — Windsurf has genuinely different semantics (global vs workspace). Add a JSDoc comment on `TargetHandler.write()` documenting that some writers may apply additional nesting while the Windsurf writer expects the final resolved path. Long-term, other targets could migrate to this pattern in a separate refactor. + +#### 3b. Replace MCP setup doc with JSON config merge + +Follow Kiro pattern (`src/targets/kiro.ts:68-92`) with security hardening: + +- [x] Read existing `mcp_config.json` if present +- [x] Backup before overwrite (`backupFile()`) +- [x] Parse existing JSON (warn and replace if corrupted; add `!Array.isArray()` guard) +- [x] Merge at `mcpServers` key: plugin entries overwrite same-name entries, user entries preserved +- [x] Preserve all other top-level keys in existing file +- [x] Write merged result with **restrictive permissions** (`0o600`) +- [x] Emit warning when writing to workspace scope (Windsurf `mcp_config.json` is global-only per docs) + +```typescript +// MCP config merge with security hardening +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 = {} + if (await pathExists(mcpPath)) { + try { + const parsed = await readJson(mcpPath) + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + existingConfig = parsed as Record + } + } 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) + : {} + const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpConfig.mcpServers } } + await writeJsonSecure(mcpPath, merged) // 0o600 permissions +} +``` + +### Research Insights (Phase 3b) + +**Security review (HIGH):** The current `writeJson()` in `src/utils/files.ts` uses default umask (`0o644`) — world-readable. The sync targets all use `{ mode: 0o600 }` for secret-containing files. The Windsurf writer (and Kiro writer) must do the same. + +**Implementation:** Add a `writeJsonSecure()` helper or add a `mode` parameter to `writeJson()`: + +```typescript +// src/utils/files.ts +export async function writeJsonSecure(filePath: string, data: unknown): Promise { + const content = JSON.stringify(data, null, 2) + await ensureDir(path.dirname(filePath)) + await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 }) +} +``` + +**Security review (MEDIUM):** Backup files inherit default permissions. Ensure `backupFile()` also sets `0o600` on the backup copy when the source may contain secrets. + +**Security review (MEDIUM):** Workspace `mcp_config.json` could be committed to git. After writing to workspace scope, emit a warning: + +``` +Warning: .windsurf/mcp_config.json may contain secrets. Ensure it is in .gitignore. +``` + +**TypeScript review:** The `readJson>` assertion is unsafe — a valid JSON array or string passes parsing but fails the type. Added `!Array.isArray()` guard. + +**TypeScript review:** The `bundle.mcpConfig` null check is sufficient — when non-null, `mcpServers` is guaranteed to have entries (the converter returns null for empty servers). Simplified from `bundle.mcpConfig && Object.keys(...)`. + +**Windsurf docs (important):** `mcp_config.json` is a **global configuration only** — Windsurf has no per-project MCP config support. Writing it to `.windsurf/` in workspace scope may not be discovered by Windsurf. Emit a warning for workspace scope but still write the file for forward-compatibility. + +#### 3c. Updated writer structure + +```typescript +export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBundle): Promise { + await ensureDir(outputRoot) + + // Write agent workflows + if (bundle.agentWorkflows.length > 0) { + const agentDir = path.join(outputRoot, "workflows", "agents") + await ensureDir(agentDir) + for (const workflow of bundle.agentWorkflows) { + validatePathSafe(workflow.name, "agent workflow") + const content = formatFrontmatter({ description: workflow.description }, `# ${workflow.name}\n\n${workflow.body}`) + await writeText(path.join(agentDir, `${workflow.name}.md`), content + "\n") + } + } + + // Write command workflows + if (bundle.commandWorkflows.length > 0) { + const cmdDir = path.join(outputRoot, "workflows", "commands") + await ensureDir(cmdDir) + for (const workflow of bundle.commandWorkflows) { + validatePathSafe(workflow.name, "command workflow") + const content = formatFrontmatter({ description: workflow.description }, `# ${workflow.name}\n\n${workflow.body}`) + await writeText(path.join(cmdDir, `${workflow.name}.md`), content + "\n") + } + } + + // Copy skill directories + 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 (see 3b above) + if (bundle.mcpConfig) { + // ... merge logic from 3b + } +} +``` + +### Phase 4: CLI Wiring + +**Files:** `src/commands/install.ts`, `src/commands/convert.ts` + +#### 4a. Add `--scope` flag to both commands + +```typescript +scope: { + type: "string", + description: "Scope level: global | workspace (default varies by target)", +}, +``` + +- [x] Add `scope` arg to `install.ts` +- [x] Add `scope` arg to `convert.ts` + +#### 4b. Validate scope with type guard + +Use a proper type guard instead of unsafe `as TargetScope` cast: + +```typescript +function isTargetScope(value: string): value is TargetScope { + return value === "global" || value === "workspace" +} + +const scopeValue = args.scope ? String(args.scope) : undefined +if (scopeValue !== undefined) { + if (!target.supportedScopes) { + throw new Error(`Target "${targetName}" does not support the --scope flag.`) + } + if (!isTargetScope(scopeValue) || !target.supportedScopes.includes(scopeValue)) { + throw new Error(`Target "${targetName}" does not support --scope ${scopeValue}. Supported: ${target.supportedScopes.join(", ")}`) + } +} +const resolvedScope = scopeValue ?? target.defaultScope ?? "workspace" +``` + +- [x] Add `isTargetScope` type guard +- [x] Add scope validation in both commands (single block, not two separate checks) + +### Research Insights (Phase 4b) + +**TypeScript review:** The original plan cast `scopeValue as TargetScope` before validation — a type lie. Use a proper type guard function to keep the type system honest. + +**Simplicity review:** The two-step validation (check supported, then check exists) can be a single block with the type guard approach above. + +#### 4c. Update output root resolution + +Both commands now use the shared `resolveTargetOutputRoot` from Phase 0a. + +- [x] Call shared function with `scope: resolvedScope` for primary target +- [x] Default scope: `target.defaultScope ?? "workspace"` (only used when target supports scopes) + +#### 4d. Handle `--also` targets + +`--scope` applies only to the primary `--to` target. Extra `--also` targets use their own `defaultScope`. + +- [x] Pass `handler.defaultScope` for `--also` targets (each uses its own default) +- [x] Update the `--also` loop in both commands to use target-specific scope resolution + +### Research Insights (Phase 4d) + +**Architecture review:** There is no way for users to specify scope for an `--also` target (e.g., `--also windsurf:workspace`). Accept as a known v0.11.0 limitation. If users need workspace scope for windsurf, they can run two separate commands. Add a code comment indicating where per-target scope overrides would be added in the future. + +### Phase 5: Tests + +**Files:** `tests/windsurf-converter.test.ts`, `tests/windsurf-writer.test.ts` + +#### 5a. Update converter tests + +- [x] Remove all AGENTS.md tests (lines 275-303: empty plugin, CLAUDE.md missing) +- [x] Remove all `mcpSetupDoc` tests (lines 305-366: stdio, HTTP/SSE, redaction, null) +- [x] Update `fixturePlugin` default — remove `agentsMd` and `mcpSetupDoc` references +- [x] Add `mcpConfig` tests: + - stdio server produces correct JSON structure with `command`, `args`, `env` + - HTTP/SSE server produces correct JSON structure with `serverUrl`, `headers` + - mixed servers (stdio + HTTP) both included + - env vars included (not redacted) — verify actual values present + - `hasPotentialSecrets()` emits console.warn for sensitive keys + - `hasPotentialSecrets()` does NOT warn when no sensitive keys + - no servers produces null mcpConfig + - empty bundle has null mcpConfig + - server with no command and no URL is skipped with warning + +#### 5b. Update writer tests + +- [x] Remove AGENTS.md tests (backup test, creation test, double-nesting AGENTS.md parent test) +- [x] Remove double-nesting guard test (guard removed) +- [x] Remove `mcp-setup.md` write test +- [x] Update `emptyBundle` fixture — remove `agentsMd`, `mcpSetupDoc`, add `mcpConfig: null` +- [x] Add `mcp_config.json` tests: + - writes mcp_config.json to outputRoot + - merges with existing mcp_config.json (preserves user servers) + - backs up existing mcp_config.json before overwrite + - handles corrupted existing mcp_config.json (warn and replace) + - handles existing mcp_config.json with array (not object) at root + - handles existing mcp_config.json with `mcpServers: null` + - preserves non-mcpServers keys in existing file + - server name collision: plugin entry wins + - file permissions are 0o600 (not world-readable) +- [x] Update full bundle test — writer writes directly into outputRoot (no `.windsurf/` nesting) + +#### 5c. Add scope resolution tests + +Test the shared `resolveTargetOutputRoot` function: + +- [x] Default scope for windsurf is "global" → resolves to `~/.codeium/windsurf/` +- [x] Explicit `--scope workspace` → resolves to `cwd/.windsurf/` +- [x] `--output` overrides scope resolution (both global and workspace) +- [x] Invalid scope value for windsurf → error +- [x] `--scope` on non-scope target (e.g., opencode) → error +- [x] `--also windsurf` uses windsurf's default scope ("global") +- [x] `isTargetScope` type guard correctly identifies valid/invalid values + +### Phase 6: Documentation + +**Files:** `README.md`, `CHANGELOG.md` + +- [x] Update README.md Windsurf section to mention `--scope` flag and global default +- [x] Add CHANGELOG entry for v0.11.0 with breaking changes documented +- [x] Document migration path: `--scope workspace` for old behavior +- [x] Note that Windsurf `mcp_config.json` is global-only (workspace MCP config may not be discovered) + +## Acceptance Criteria + +- [x] `install compound-engineering --to windsurf` writes to `~/.codeium/windsurf/` by default +- [x] `install compound-engineering --to windsurf --scope workspace` writes to `cwd/.windsurf/` +- [x] `--output /custom/path` overrides scope for both commands +- [x] `--scope` on non-supporting target produces clear error +- [x] `mcp_config.json` merges with existing file (backup created, user entries preserved) +- [x] `mcp_config.json` written with `0o600` permissions (not world-readable) +- [x] No AGENTS.md generated for either scope +- [x] Env var secrets included in `mcp_config.json` with `console.warn` listing affected servers +- [x] Both stdio and HTTP/SSE MCP servers included in `mcp_config.json` +- [x] All existing tests updated, all new tests pass +- [x] No regressions in other targets +- [x] `resolveTargetOutputRoot` extracted to shared utility (no duplication) + +## Dependencies & Risks + +**Risk: Global workflow path is undocumented.** Windsurf may not discover workflows from `~/.codeium/windsurf/workflows/`. Mitigation: documented as a known assumption in the brainstorm. Users can `--scope workspace` if global workflows aren't discovered. + +**Risk: Breaking changes for existing v0.10.0 users.** Mitigation: document migration path clearly. `--scope workspace` restores previous behavior. Target is experimental with a small user base. + +**Risk: Workspace `mcp_config.json` not read by Windsurf.** Per Windsurf docs, `mcp_config.json` is global-only configuration. Workspace scope writes the file for forward-compatibility but emits a warning. The primary use case is global scope anyway. + +**Risk: Secrets in `mcp_config.json` committed to git.** Mitigation: `0o600` file permissions, console.warn about sensitive env vars, warning about `.gitignore` for workspace scope. + +## References & Research + +- Spec: `docs/specs/windsurf.md` (authoritative reference for component mapping) +- Kiro MCP merge pattern: [src/targets/kiro.ts:68-92](../../src/targets/kiro.ts) +- Sync secrets warning: [src/commands/sync.ts:20-28](../../src/commands/sync.ts) +- Windsurf MCP docs: https://docs.windsurf.com/windsurf/cascade/mcp +- Windsurf Skills global path: https://docs.windsurf.com/windsurf/cascade/skills +- Windsurf MCP tutorial: https://windsurf.com/university/tutorials/configuring-first-mcp-server +- Adding converter targets (learning): [docs/solutions/adding-converter-target-providers.md](../solutions/adding-converter-target-providers.md) +- Plugin versioning (learning): [docs/solutions/plugin-versioning-requirements.md](../solutions/plugin-versioning-requirements.md) diff --git a/docs/solutions/adding-converter-target-providers.md b/docs/solutions/adding-converter-target-providers.md new file mode 100644 index 00000000..3b69df7e --- /dev/null +++ b/docs/solutions/adding-converter-target-providers.md @@ -0,0 +1,692 @@ +--- +title: Adding New Converter Target Providers +category: architecture +tags: [converter, target-provider, plugin-conversion, multi-platform, pattern] +created: 2026-02-23 +severity: medium +component: converter-cli +problem_type: best_practice +root_cause: architectural_pattern +--- + +# Adding New Converter Target Providers + +## Problem + +When adding support for a new AI platform (e.g., Devin, Cursor, Copilot), the converter CLI architecture requires consistent implementation across types, converters, writers, CLI integration, and tests. Without documented patterns and learnings, new targets take longer to implement and risk architectural inconsistency. + +## Solution + +The compound-engineering-plugin uses a proven **6-phase target provider pattern** that has been successfully applied to 8 targets: + +1. **OpenCode** (primary target, reference implementation) +2. **Codex** (second target, established pattern) +3. **Droid/Factory** (workflow/agent conversion) +4. **Pi** (MCPorter ecosystem) +5. **Gemini CLI** (content transformation patterns) +6. **Cursor** (command flattening, rule formats) +7. **Copilot** (GitHub native, MCP prefixing) +8. **Kiro** (limited MCP support) +9. **Devin** (playbook conversion, knowledge entries) + +Each implementation follows this architecture precisely, ensuring consistency and maintainability. + +## Architecture: The 6-Phase Pattern + +### Phase 1: Type Definitions (`src/types/{target}.ts`) + +**Purpose:** Define TypeScript types for the intermediate bundle format + +**Key Pattern:** + +```typescript +// Exported bundle type used by converter and writer +export type {TargetName}Bundle = { + // Component arrays matching the target format + agents?: {TargetName}Agent[] + commands?: {TargetName}Command[] + skillDirs?: {TargetName}SkillDir[] + mcpServers?: Record + // Target-specific fields + setup?: string // Instructions file content +} + +// Individual component types +export type {TargetName}Agent = { + name: string + content: string // Full file content (with frontmatter if applicable) + category?: string // e.g., "agent", "rule", "playbook" + meta?: Record // Target-specific metadata +} +``` + +**Key Learnings:** + +- Always include a `content` field (full file text) rather than decomposed fields — it's simpler and matches how files are written +- Use intermediate types for complex sections (e.g., `DevinPlaybookSections` in Devin converter) to make section building independently testable +- Avoid target-specific fields in the base bundle unless essential — aim for shared structure across targets +- Include a `category` field if the target has file-type variants (agents vs. commands vs. rules) + +**Reference Implementations:** +- OpenCode: `src/types/opencode.ts` (command + agent split) +- Devin: `src/types/devin.ts` (playbooks + knowledge entries) +- Copilot: `src/types/copilot.ts` (agents + skills + MCP) + +--- + +### Phase 2: Converter (`src/converters/claude-to-{target}.ts`) + +**Purpose:** Transform Claude Code plugin format → target-specific bundle format + +**Key Pattern:** + +```typescript +export type ClaudeTo{Target}Options = ClaudeToOpenCodeOptions // Reuse common options + +export function convertClaudeTo{Target}( + plugin: ClaudePlugin, + _options: ClaudeTo{Target}Options, +): {Target}Bundle { + // Pre-scan: build maps for cross-reference resolution (agents, commands) + // Needed if target requires deduplication or reference tracking + const refMap: Record = {} + for (const agent of plugin.agents) { + refMap[normalize(agent.name)] = macroName(agent.name) + } + + // Phase 1: Convert agents + const agents = plugin.agents.map(a => convert{Target}Agent(a, usedNames, refMap)) + + // Phase 2: Convert commands (may depend on agent names for dedup) + const commands = plugin.commands.map(c => convert{Target}Command(c, usedNames, refMap)) + + // Phase 3: Handle skills (usually pass-through, sometimes conversion) + const skillDirs = plugin.skills.map(s => ({ name: s.name, sourceDir: s.sourceDir })) + + // Phase 4: Convert MCP servers (target-specific prefixing/type mapping) + const mcpConfig = convertMcpServers(plugin.mcpServers) + + // Phase 5: Warn on unsupported features + if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) { + console.warn("Warning: {Target} does not support hooks. Hooks were skipped.") + } + + return { agents, commands, skillDirs, mcpConfig } +} +``` + +**Content Transformation (`transformContentFor{Target}`):** + +Applied to both agent bodies and command bodies to rewrite paths, command references, and agent mentions: + +```typescript +export function transformContentFor{Target}(body: string): string { + let result = body + + // 1. Rewrite paths (.claude/ → .github/, ~/.claude/ → ~/.{target}/) + result = result + .replace(/~\/\.claude\//g, `~/.${targetDir}/`) + .replace(/\.claude\//g, `.${targetDir}/`) + + // 2. Transform Task agent calls (to natural language) + const taskPattern = /Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm + result = result.replace(taskPattern, (_match, agentName: string, args: string) => { + const skillName = normalize(agentName) + return `Use the ${skillName} skill to: ${args.trim()}` + }) + + // 3. Flatten slash commands (/workflows:plan → /plan) + const slashPattern = /(? { + if (commandName.includes("/")) return match // Skip file paths + const normalized = normalize(commandName) + return `/${normalized}` + }) + + // 4. Transform @agent-name references + const agentPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|analyst|...))/gi + result = result.replace(agentPattern, (_match, agentName: string) => { + return `the ${normalize(agentName)} agent` // or "rule", "playbook", etc. + }) + + // 5. Remove examples (if target doesn't support them) + result = result.replace(/[\s\S]*?<\/examples>/g, "") + + return result +} +``` + +**Deduplication Pattern (`uniqueName`):** + +Used when target has flat namespaces (Cursor, Copilot, Devin) or when name collisions occur: + +```typescript +function uniqueName(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base) + return base + } + let index = 2 + while (used.has(`${base}-${index}`)) { + index += 1 + } + const name = `${base}-${index}` + used.add(name) + return name +} + +function normalizeName(value: string): string { + const trimmed = value.trim() + if (!trimmed) return "item" + const normalized = trimmed + .toLowerCase() + .replace(/[\\/]+/g, "-") + .replace(/[:\s]+/g, "-") + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + return normalized || "item" +} + +// Flatten: drops namespace prefix (workflows:plan → plan) +function flattenCommandName(name: string): string { + const normalized = normalizeName(name) + return normalized.replace(/^[a-z]+-/, "") // Drop prefix before first dash +} +``` + +**Key Learnings:** + +1. **Pre-scan for cross-references** — If target requires reference names (macros, URIs, IDs), build a map before conversion. Example: Devin needs macro names like `agent_kieran_rails_reviewer`, so pre-scan builds the map. + +2. **Content transformation is fragile** — Test extensively. Patterns that work for slash commands might false-match on file paths. Use negative lookahead to skip `/etc`, `/usr`, `/var`, etc. + +3. **Simplify heuristics, trust structural mapping** — Don't try to parse agent body for "You are..." or "NEVER do..." patterns. Instead, map agent.description → Overview, agent.body → Procedure, agent.capabilities → Specifications. Heuristics fail on edge cases and are hard to test. + +4. **Normalize early and consistently** — Use the same `normalizeName()` function throughout. Inconsistent normalization causes deduplication bugs. + +5. **MCP servers need target-specific handling:** + - **OpenCode:** Merge into `opencode.json` (preserve user keys) + - **Copilot:** Prefix env vars with `COPILOT_MCP_`, emit JSON + - **Devin:** Write setup instructions file (config is via web UI) + - **Cursor:** Pass through as-is + +6. **Warn on unsupported features** — Hooks, Gemini extensions, Kiro-incompatible MCP types. Emit to stderr and continue conversion. + +**Reference Implementations:** +- OpenCode: `src/converters/claude-to-opencode.ts` (most comprehensive) +- Devin: `src/converters/claude-to-devin.ts` (content transformation + cross-references) +- Copilot: `src/converters/claude-to-copilot.ts` (MCP prefixing pattern) + +--- + +### Phase 3: Writer (`src/targets/{target}.ts`) + +**Purpose:** Write converted bundle to disk in target-specific directory structure + +**Key Pattern:** + +```typescript +export async function write{Target}Bundle(outputRoot: string, bundle: {Target}Bundle): Promise { + const paths = resolve{Target}Paths(outputRoot) + await ensureDir(paths.root) + + // Write each component type + if (bundle.agents?.length > 0) { + const agentsDir = path.join(paths.root, "agents") + for (const agent of bundle.agents) { + await writeText(path.join(agentsDir, `${agent.name}.ext`), agent.content + "\n") + } + } + + if (bundle.commands?.length > 0) { + const commandsDir = path.join(paths.root, "commands") + for (const command of bundle.commands) { + await writeText(path.join(commandsDir, `${command.name}.ext`), command.content + "\n") + } + } + + // Copy skills (pass-through case) + if (bundle.skillDirs?.length > 0) { + const skillsDir = path.join(paths.root, "skills") + for (const skill of bundle.skillDirs) { + await copyDir(skill.sourceDir, path.join(skillsDir, skill.name)) + } + } + + // Write generated skills (converted from commands) + if (bundle.generatedSkills?.length > 0) { + const skillsDir = path.join(paths.root, "skills") + for (const skill of bundle.generatedSkills) { + await writeText(path.join(skillsDir, skill.name, "SKILL.md"), skill.content + "\n") + } + } + + // Write MCP config (target-specific location and format) + if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) { + const mcpPath = path.join(paths.root, "mcp.json") // or copilot-mcp-config.json, etc. + const backupPath = await backupFile(mcpPath) + if (backupPath) { + console.log(`Backed up existing MCP config to ${backupPath}`) + } + await writeJson(mcpPath, { mcpServers: bundle.mcpServers }) + } + + // Write instructions or setup guides + if (bundle.setupInstructions) { + const setupPath = path.join(paths.root, "setup-instructions.md") + await writeText(setupPath, bundle.setupInstructions + "\n") + } +} + +// Avoid double-nesting (.target/.target/) +function resolve{Target}Paths(outputRoot: string) { + const base = path.basename(outputRoot) + // If already pointing at .target, write directly into it + if (base === ".target") { + return { root: outputRoot } + } + // Otherwise nest under .target + return { root: path.join(outputRoot, ".target") } +} +``` + +**Backup Pattern (MCP configs only):** + +MCP configs are often pre-existing and user-edited. Backup before overwrite: + +```typescript +// From src/utils/files.ts +export async function backupFile(filePath: string): Promise { + if (!existsSync(filePath)) return null + const timestamp = new Date().toISOString().replace(/[:.]/g, "-") + const dirname = path.dirname(filePath) + const basename = path.basename(filePath) + const ext = path.extname(basename) + const name = basename.slice(0, -ext.length) + const backupPath = path.join(dirname, `${name}.${timestamp}${ext}`) + await copyFile(filePath, backupPath) + return backupPath +} +``` + +**Key Learnings:** + +1. **Always check for double-nesting** — If output root is already `.target`, don't nest again. Pattern: + ```typescript + if (path.basename(outputRoot) === ".target") { + return { root: outputRoot } // Write directly + } + return { root: path.join(outputRoot, ".target") } // Nest + ``` + +2. **Use `writeText` and `writeJson` helpers** — These handle directory creation and line endings consistently + +3. **Backup MCP configs before overwriting** — MCP JSON files are often hand-edited. Always backup with timestamp. + +4. **Empty bundles should succeed gracefully** — Don't fail if a component array is empty. Many plugins may have no commands or no skills. + +5. **File extensions matter** — Match target conventions exactly: + - Copilot: `.agent.md` (note the dot) + - Cursor: `.mdc` for rules + - Devin: `.devin.md` for playbooks + - OpenCode: `.md` for commands + +6. **Permissions for sensitive files** — MCP config with API keys should use `0o600`: + ```typescript + await writeJson(mcpPath, config, { mode: 0o600 }) + ``` + +**Reference Implementations:** +- Droid: `src/targets/droid.ts` (simpler pattern, good for learning) +- Copilot: `src/targets/copilot.ts` (double-nesting pattern) +- Devin: `src/targets/devin.ts` (setup instructions file) + +--- + +### Phase 4: CLI Wiring + +**File: `src/targets/index.ts`** + +Register the new target in the global target registry: + +```typescript +import { convertClaudeTo{Target} } from "../converters/claude-to-{target}" +import { write{Target}Bundle } from "./{target}" +import type { {Target}Bundle } from "../types/{target}" + +export const targets: Record> = { + // ... existing targets ... + {target}: { + name: "{target}", + implemented: true, + convert: convertClaudeTo{Target} as TargetHandler<{Target}Bundle>["convert"], + write: write{Target}Bundle as TargetHandler<{Target}Bundle>["write"], + }, +} +``` + +**File: `src/commands/convert.ts` and `src/commands/install.ts`** + +Add output root resolution: + +```typescript +// In resolveTargetOutputRoot() +if (targetName === "{target}") { + return path.join(outputRoot, ".{target}") +} + +// Update --to flag description +const toDescription = "Target format (opencode | codex | droid | cursor | copilot | kiro | {target})" +``` + +--- + +### Phase 5: Sync Support (Optional) + +**File: `src/sync/{target}.ts`** + +If the target supports syncing personal skills and MCP servers: + +```typescript +export async function syncTo{Target}(outputRoot: string): Promise { + const personalSkillsDir = path.join(expandHome("~/.claude/skills")) + const personalSettings = loadSettings(expandHome("~/.claude/settings.json")) + + const skillsDest = path.join(outputRoot, ".{target}", "skills") + await ensureDir(skillsDest) + + // Symlink personal skills + if (existsSync(personalSkillsDir)) { + const skills = readdirSync(personalSkillsDir) + for (const skill of skills) { + if (!isValidSkillName(skill)) continue + const source = path.join(personalSkillsDir, skill) + const dest = path.join(skillsDest, skill) + await forceSymlink(source, dest) + } + } + + // Merge MCP servers if applicable + if (personalSettings.mcpServers) { + const mcpPath = path.join(outputRoot, ".{target}", "mcp.json") + const existing = readJson(mcpPath) || {} + const merged = { + ...existing, + mcpServers: { + ...existing.mcpServers, + ...personalSettings.mcpServers, + }, + } + await writeJson(mcpPath, merged, { mode: 0o600 }) + } +} +``` + +**File: `src/commands/sync.ts`** + +```typescript +// Add to validTargets array +const validTargets = ["opencode", "codex", "droid", "cursor", "pi", "{target}"] as const + +// In resolveOutputRoot() +case "{target}": + return path.join(process.cwd(), ".{target}") + +// In main switch +case "{target}": + await syncTo{Target}(outputRoot) + break +``` + +--- + +### Phase 6: Tests + +**File: `tests/{target}-converter.test.ts`** + +Test converter using inline `ClaudePlugin` fixtures: + +```typescript +describe("convertClaudeTo{Target}", () => { + it("converts agents to {target} format", () => { + const plugin: ClaudePlugin = { + name: "test", + agents: [ + { + name: "test-agent", + description: "Test description", + body: "Test body", + capabilities: ["Cap 1", "Cap 2"], + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeTo{Target}(plugin, {}) + + expect(bundle.agents).toHaveLength(1) + expect(bundle.agents[0].name).toBe("test-agent") + expect(bundle.agents[0].content).toContain("Test description") + }) + + it("normalizes agent names", () => { + const plugin: ClaudePlugin = { + name: "test", + agents: [ + { name: "Test Agent", description: "", body: "", capabilities: [] }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeTo{Target}(plugin, {}) + expect(bundle.agents[0].name).toBe("test-agent") + }) + + it("deduplicates colliding names", () => { + const plugin: ClaudePlugin = { + name: "test", + agents: [ + { name: "Agent Name", description: "", body: "", capabilities: [] }, + { name: "Agent Name", description: "", body: "", capabilities: [] }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeTo{Target}(plugin, {}) + expect(bundle.agents.map(a => a.name)).toEqual(["agent-name", "agent-name-2"]) + }) + + it("transforms content paths (.claude → .{target})", () => { + const result = transformContentFor{Target}("See ~/.claude/config") + expect(result).toContain("~/.{target}/config") + }) + + it("warns when hooks are present", () => { + const spy = jest.spyOn(console, "warn") + const plugin: ClaudePlugin = { + name: "test", + agents: [], + commands: [], + skills: [], + hooks: { hooks: { "file:save": "test" } }, + } + + convertClaudeTo{Target}(plugin, {}) + expect(spy).toHaveBeenCalledWith(expect.stringContaining("hooks")) + }) +}) +``` + +**File: `tests/{target}-writer.test.ts`** + +Test writer using temp directories (from `tmp` package): + +```typescript +describe("write{Target}Bundle", () => { + it("writes agents to {target} format", async () => { + const tmpDir = await tmp.dir() + const bundle: {Target}Bundle = { + agents: [{ name: "test", content: "# Test\nBody" }], + commands: [], + skillDirs: [], + } + + await write{Target}Bundle(tmpDir.path, bundle) + + const written = readFileSync(path.join(tmpDir.path, ".{target}", "agents", "test.ext"), "utf-8") + expect(written).toContain("# Test") + }) + + it("does not double-nest when output root is .{target}", async () => { + const tmpDir = await tmp.dir() + const targetDir = path.join(tmpDir.path, ".{target}") + await ensureDir(targetDir) + + const bundle: {Target}Bundle = { + agents: [{ name: "test", content: "# Test" }], + commands: [], + skillDirs: [], + } + + await write{Target}Bundle(targetDir, bundle) + + // Should write to targetDir directly, not targetDir/.{target} + const written = path.join(targetDir, "agents", "test.ext") + expect(existsSync(written)).toBe(true) + }) + + it("backs up existing MCP config", async () => { + const tmpDir = await tmp.dir() + const mcpPath = path.join(tmpDir.path, ".{target}", "mcp.json") + await ensureDir(path.dirname(mcpPath)) + await writeJson(mcpPath, { existing: true }) + + const bundle: {Target}Bundle = { + agents: [], + commands: [], + skillDirs: [], + mcpServers: { "test": { command: "test" } }, + } + + await write{Target}Bundle(tmpDir.path, bundle) + + // Backup should exist + const backups = readdirSync(path.dirname(mcpPath)).filter(f => f.includes("mcp") && f.includes("-")) + expect(backups.length).toBeGreaterThan(0) + }) +}) +``` + +**Key Testing Patterns:** + +- Test normalization, deduplication, content transformation separately +- Use inline plugin fixtures (not file-based) +- For writer tests, use temp directories and verify file existence +- Test edge cases: empty names, empty bodies, special characters +- Test error handling: missing files, permission issues + +--- + +## Documentation Requirements + +**File: `docs/specs/{target}.md`** + +Document the target format specification: + +- Last verified date (link to official docs) +- Config file locations (project-level vs. user-level) +- Agent/command/skill format with field descriptions +- MCP configuration structure +- Character limits (if any) +- Example file + +**File: `README.md`** + +Add to supported targets list and include usage examples. + +--- + +## Common Pitfalls and Solutions + +| Pitfall | Solution | +|---------|----------| +| **Double-nesting** (`.cursor/.cursor/`) | Check `path.basename(outputRoot)` before nesting | +| **Inconsistent name normalization** | Use single `normalizeName()` function everywhere | +| **Fragile content transformation** | Test regex patterns against edge cases (file paths, URLs) | +| **Heuristic section extraction fails** | Use structural mapping (description → Overview, body → Procedure) instead | +| **MCP config overwrites user edits** | Always backup with timestamp before overwriting | +| **Skill body not loaded** | Verify `ClaudeSkill` has `skillPath` field for file reading | +| **Missing deduplication** | Build `usedNames` set before conversion, pass to each converter | +| **Unsupported features cause silent loss** | Always warn to stderr (hooks, incompatible MCP types, etc.) | +| **Test isolation failures** | Use unique temp directories per test, clean up afterward | +| **Command namespace collisions after flattening** | Use `uniqueName()` with deduplication, test multiple collisions | + +--- + +## Checklist for Adding a New Target + +Use this checklist when adding a new target provider: + +### Implementation +- [ ] Create `src/types/{target}.ts` with bundle and component types +- [ ] Implement `src/converters/claude-to-{target}.ts` with converter and content transformer +- [ ] Implement `src/targets/{target}.ts` with writer +- [ ] Register target in `src/targets/index.ts` +- [ ] Update `src/commands/convert.ts` (add output root resolution, update help text) +- [ ] Update `src/commands/install.ts` (same as convert.ts) +- [ ] (Optional) Implement `src/sync/{target}.ts` and update `src/commands/sync.ts` + +### Testing +- [ ] Create `tests/{target}-converter.test.ts` with converter tests +- [ ] Create `tests/{target}-writer.test.ts` with writer tests +- [ ] (Optional) Create `tests/sync-{target}.test.ts` with sync tests +- [ ] Run full test suite: `bun test` +- [ ] Manual test: `bun run src/index.ts convert --to {target} ./plugins/compound-engineering` + +### Documentation +- [ ] Create `docs/specs/{target}.md` with format specification +- [ ] Update `README.md` with target in list and usage examples +- [ ] Update `CHANGELOG.md` with new target + +### Version Bumping +- [ ] Bump version in `package.json` (minor for new target) +- [ ] Update plugin.json description if component counts changed +- [ ] Verify CHANGELOG entry is clear + +--- + +## References + +### Implementation Examples + +**Reference implementations by priority (easiest to hardest):** + +1. **Droid** (`src/targets/droid.ts`, `src/converters/claude-to-droid.ts`) — Simplest pattern, good learning baseline +2. **Copilot** (`src/targets/copilot.ts`, `src/converters/claude-to-copilot.ts`) — MCP prefixing, double-nesting guard +3. **Devin** (`src/converters/claude-to-devin.ts`) — Content transformation, cross-references, intermediate types +4. **OpenCode** (`src/converters/claude-to-opencode.ts`) — Most comprehensive, handles command structure and config merging + +### Key Utilities + +- `src/utils/frontmatter.ts` — `formatFrontmatter()` and `parseFrontmatter()` +- `src/utils/files.ts` — `writeText()`, `writeJson()`, `copyDir()`, `backupFile()`, `ensureDir()` +- `src/utils/resolve-home.ts` — `expandHome()` for `~/.{target}` path resolution + +### Existing Tests + +- `tests/cursor-converter.test.ts` — Comprehensive converter tests +- `tests/copilot-writer.test.ts` — Writer tests with temp directories +- `tests/sync-copilot.test.ts` — Sync pattern with symlinks and config merge + +--- + +## Related Files + +- `/C:/Source/compound-engineering-plugin/.claude-plugin/plugin.json` — Version and component counts +- `/C:/Source/compound-engineering-plugin/CHANGELOG.md` — Recent additions and patterns +- `/C:/Source/compound-engineering-plugin/README.md` — Usage examples for all targets +- `/C:/Source/compound-engineering-plugin/docs/solutions/plugin-versioning-requirements.md` — Checklist for releases diff --git a/docs/specs/windsurf.md b/docs/specs/windsurf.md new file mode 100644 index 00000000..a895b52a --- /dev/null +++ b/docs/specs/windsurf.md @@ -0,0 +1,477 @@ +# Windsurf Editor Global Configuration Guide + +> **Purpose**: Technical reference for programmatically creating and managing Windsurf's global Skills, Workflows, and Rules. +> +> **Source**: Official Windsurf documentation at [docs.windsurf.com](https://docs.windsurf.com) + local file analysis. +> +> **Last Updated**: February 2026 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Base Directory Structure](#base-directory-structure) +3. [Skills](#skills) +4. [Workflows](#workflows) +5. [Rules](#rules) +6. [Memories](#memories) +7. [System-Level Configuration (Enterprise)](#system-level-configuration-enterprise) +8. [Programmatic Creation Reference](#programmatic-creation-reference) +9. [Best Practices](#best-practices) + +--- + +## Overview + +Windsurf provides three main customization mechanisms: + +| Feature | Purpose | Invocation | +|---------|---------|------------| +| **Skills** | Complex multi-step tasks with supporting resources | Automatic (progressive disclosure) or `@skill-name` | +| **Workflows** | Reusable step-by-step procedures | Slash command `/workflow-name` | +| **Rules** | Behavioral guidelines and preferences | Trigger-based (always-on, glob, manual, or model decision) | + +All three support both **workspace-level** (project-specific) and **global** (user-wide) scopes. + +--- + +## Base Directory Structure + +### Global Configuration Root + +| OS | Path | +|----|------| +| **Windows** | `C:\Users\{USERNAME}\.codeium\windsurf\` | +| **macOS** | `~/.codeium/windsurf/` | +| **Linux** | `~/.codeium/windsurf/` | + +### Directory Layout + +``` +~/.codeium/windsurf/ +├── skills/ # Global skills (directories) +│ └── {skill-name}/ +│ └── SKILL.md +├── global_workflows/ # Global workflows (flat .md files) +│ └── {workflow-name}.md +├── rules/ # Global rules (flat .md files) +│ └── {rule-name}.md +├── memories/ +│ ├── global_rules.md # Always-on global rules (plain text) +│ └── *.pb # Auto-generated memories (protobuf) +├── mcp_config.json # MCP server configuration +└── user_settings.pb # User settings (protobuf) +``` + +--- + +## Skills + +Skills bundle instructions with supporting resources for complex, multi-step tasks. Cascade uses **progressive disclosure** to automatically invoke skills when relevant. + +### Storage Locations + +| Scope | Location | +|-------|----------| +| **Global** | `~/.codeium/windsurf/skills/{skill-name}/SKILL.md` | +| **Workspace** | `.windsurf/skills/{skill-name}/SKILL.md` | + +### Directory Structure + +Each skill is a **directory** (not a single file) containing: + +``` +{skill-name}/ +├── SKILL.md # Required: Main skill definition +├── references/ # Optional: Reference documentation +├── assets/ # Optional: Images, diagrams, etc. +├── scripts/ # Optional: Helper scripts +└── {any-other-files} # Optional: Templates, configs, etc. +``` + +### SKILL.md Format + +```markdown +--- +name: skill-name +description: Brief description shown to model to help it decide when to invoke the skill +--- + +# Skill Title + +Instructions for the skill go here in markdown format. + +## Section 1 +Step-by-step guidance... + +## Section 2 +Reference supporting files using relative paths: +- See [deployment-checklist.md](./deployment-checklist.md) +- Run script: [deploy.sh](./scripts/deploy.sh) +``` + +### Required YAML Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | **Yes** | Unique identifier (lowercase letters, numbers, hyphens only). Must match directory name. | +| `description` | **Yes** | Explains what the skill does and when to use it. Critical for automatic invocation. | + +### Naming Convention + +- Use **lowercase-kebab-case**: `deploy-to-staging`, `code-review`, `setup-dev-environment` +- Name must match the directory name exactly + +### Invocation Methods + +1. **Automatic**: Cascade automatically invokes when request matches skill description +2. **Manual**: Type `@skill-name` in Cascade input + +### Example: Complete Skill + +``` +~/.codeium/windsurf/skills/deploy-to-production/ +├── SKILL.md +├── deployment-checklist.md +├── rollback-procedure.md +└── config-template.yaml +``` + +**SKILL.md:** +```markdown +--- +name: deploy-to-production +description: Guides the deployment process to production with safety checks. Use when deploying to prod, releasing, or pushing to production environment. +--- + +## Pre-deployment Checklist +1. Run all tests +2. Check for uncommitted changes +3. Verify environment variables + +## Deployment Steps +Follow these steps to deploy safely... + +See [deployment-checklist.md](./deployment-checklist.md) for full checklist. +See [rollback-procedure.md](./rollback-procedure.md) if issues occur. +``` + +--- + +## Workflows + +Workflows define step-by-step procedures invoked via slash commands. They guide Cascade through repetitive tasks. + +### Storage Locations + +| Scope | Location | +|-------|----------| +| **Global** | `~/.codeium/windsurf/global_workflows/{workflow-name}.md` | +| **Workspace** | `.windsurf/workflows/{workflow-name}.md` | + +### File Format + +Workflows are **single markdown files** (not directories): + +```markdown +--- +description: Short description of what the workflow does +--- + +# Workflow Title + +> Arguments: [optional arguments description] + +Step-by-step instructions in markdown. + +1. First step +2. Second step +3. Third step +``` + +### Required YAML Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `description` | **Yes** | Short title/description shown in UI | + +### Invocation + +- Slash command: `/workflow-name` +- Filename becomes the command (e.g., `deploy.md` → `/deploy`) + +### Constraints + +- **Character limit**: 12,000 characters per workflow file +- Workflows can call other workflows: Include instructions like "Call `/other-workflow`" + +### Example: Complete Workflow + +**File**: `~/.codeium/windsurf/global_workflows/address-pr-comments.md` + +```markdown +--- +description: Address all PR review comments systematically +--- + +# Address PR Comments + +> Arguments: [PR number] + +1. Check out the PR branch: `gh pr checkout [id]` + +2. Get comments on PR: + ```bash + gh api --paginate repos/[owner]/[repo]/pulls/[id]/comments | jq '.[] | {user: .user.login, body, path, line}' + ``` + +3. For EACH comment: + a. Print: "(index). From [user] on [file]:[lines] — [body]" + b. Analyze the file and line range + c. If unclear, ask for clarification + d. Make the change before moving to next comment + +4. Summarize what was done and which comments need attention +``` + +--- + +## Rules + +Rules provide persistent behavioral guidelines that influence how Cascade responds. + +### Storage Locations + +| Scope | Location | +|-------|----------| +| **Global** | `~/.codeium/windsurf/rules/{rule-name}.md` | +| **Workspace** | `.windsurf/rules/{rule-name}.md` | + +### File Format + +Rules are **single markdown files**: + +```markdown +--- +description: When to use this rule +trigger: activation_mode +globs: ["*.py", "src/**/*.ts"] +--- + +Rule instructions in markdown format. + +- Guideline 1 +- Guideline 2 +- Guideline 3 +``` + +### YAML Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `description` | **Yes** | Describes when to use the rule | +| `trigger` | Optional | Activation mode (see below) | +| `globs` | Optional | File patterns for glob trigger | + +### Activation Modes (trigger field) + +| Mode | Value | Description | +|------|-------|-------------| +| **Manual** | `manual` | Activated via `@mention` in Cascade input | +| **Always On** | `always` | Always applied to every conversation | +| **Model Decision** | `model_decision` | Model decides based on description | +| **Glob** | `glob` | Applied when working with files matching pattern | + +### Constraints + +- **Character limit**: 12,000 characters per rule file + +### Example: Complete Rule + +**File**: `~/.codeium/windsurf/rules/python-style.md` + +```markdown +--- +description: Python coding standards and style guidelines. Use when writing or reviewing Python code. +trigger: glob +globs: ["*.py", "**/*.py"] +--- + +# Python Coding Guidelines + +- Use type hints for all function parameters and return values +- Follow PEP 8 style guide +- Use early returns when possible +- Always add docstrings to public functions and classes +- Prefer f-strings over .format() or % formatting +- Use pathlib instead of os.path for file operations +``` + +--- + +## Memories + +### Global Rules (Always-On) + +**Location**: `~/.codeium/windsurf/memories/global_rules.md` + +This is a special file for rules that **always apply** to all conversations. Unlike rules in the `rules/` directory, this file: + +- Does **not** require YAML frontmatter +- Is plain text/markdown +- Is always active (no trigger configuration) + +**Format:** +```markdown +Plain text rules that always apply to all conversations. + +- Rule 1 +- Rule 2 +- Rule 3 +``` + +### Auto-Generated Memories + +Cascade automatically creates memories during conversations, stored as `.pb` (protobuf) files in `~/.codeium/windsurf/memories/`. These are managed by Windsurf and should not be manually edited. + +--- + +## System-Level Configuration (Enterprise) + +Enterprise organizations can deploy system-level configurations that apply globally and cannot be modified by end users. + +### System-Level Paths + +| Type | Windows | macOS | Linux/WSL | +|------|---------|-------|-----------| +| **Rules** | `C:\ProgramData\Windsurf\rules\*.md` | `/Library/Application Support/Windsurf/rules/*.md` | `/etc/windsurf/rules/*.md` | +| **Workflows** | `C:\ProgramData\Windsurf\workflows\*.md` | `/Library/Application Support/Windsurf/workflows/*.md` | `/etc/windsurf/workflows/*.md` | + +### Precedence Order + +When items with the same name exist at multiple levels: + +1. **System** (highest priority) - Organization-wide, deployed by IT +2. **Workspace** - Project-specific in `.windsurf/` +3. **Global** - User-defined in `~/.codeium/windsurf/` +4. **Built-in** - Default items provided by Windsurf + +--- + +## Programmatic Creation Reference + +### Quick Reference Table + +| Type | Path Pattern | Format | Key Fields | +|------|--------------|--------|------------| +| **Skill** | `skills/{name}/SKILL.md` | YAML frontmatter + markdown | `name`, `description` | +| **Workflow** | `global_workflows/{name}.md` (global) or `workflows/{name}.md` (workspace) | YAML frontmatter + markdown | `description` | +| **Rule** | `rules/{name}.md` | YAML frontmatter + markdown | `description`, `trigger`, `globs` | +| **Global Rules** | `memories/global_rules.md` | Plain text/markdown | None | + +### Minimal Templates + +#### Skill (SKILL.md) +```markdown +--- +name: my-skill +description: What this skill does and when to use it +--- + +Instructions here. +``` + +#### Workflow +```markdown +--- +description: What this workflow does +--- + +1. Step one +2. Step two +``` + +#### Rule +```markdown +--- +description: When this rule applies +trigger: model_decision +--- + +- Guideline one +- Guideline two +``` + +### Validation Checklist + +When programmatically creating items: + +- [ ] **Skills**: Directory exists with `SKILL.md` inside +- [ ] **Skills**: `name` field matches directory name exactly +- [ ] **Skills**: Name uses only lowercase letters, numbers, hyphens +- [ ] **Workflows/Rules**: File is `.md` extension +- [ ] **All**: YAML frontmatter uses `---` delimiters +- [ ] **All**: `description` field is present and meaningful +- [ ] **All**: File size under 12,000 characters (workflows/rules) + +--- + +## Best Practices + +### Writing Effective Descriptions + +The `description` field is critical for automatic invocation. Be specific: + +**Good:** +```yaml +description: Guides deployment to staging environment with pre-flight checks. Use when deploying to staging, testing releases, or preparing for production. +``` + +**Bad:** +```yaml +description: Deployment stuff +``` + +### Formatting Guidelines + +- Use bullet points and numbered lists (easier for Cascade to follow) +- Use markdown headers to organize sections +- Keep rules concise and specific +- Avoid generic rules like "write good code" (already built-in) + +### XML Tags for Grouping + +XML tags can effectively group related rules: + +```markdown + +- Use early returns when possible +- Always add documentation for new functions +- Prefer composition over inheritance + + + +- Write unit tests for all public methods +- Maintain 80% code coverage + +``` + +### Skills vs Rules vs Workflows + +| Use Case | Recommended | +|----------|-------------| +| Multi-step procedure with supporting files | **Skill** | +| Repeatable CLI/automation sequence | **Workflow** | +| Coding style preferences | **Rule** | +| Project conventions | **Rule** | +| Deployment procedure | **Skill** or **Workflow** | +| Code review checklist | **Skill** | + +--- + +## Additional Resources + +- **Official Documentation**: [docs.windsurf.com](https://docs.windsurf.com) +- **Skills Specification**: [agentskills.io](https://agentskills.io/home) +- **Rule Templates**: [windsurf.com/editor/directory](https://windsurf.com/editor/directory) diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index ede6b06d..90229edb 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the compound-engineering plugin will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.36.0] - 2026-02-26 + +### Added + +- **Windsurf target provider** — `--to windsurf` converts plugins to Windsurf format per the [Windsurf spec](docs/specs/windsurf.md). Claude agents become Windsurf skills (`skills/{name}/SKILL.md`), commands become flat workflows (`global_workflows/{name}.md` for global scope, `workflows/{name}.md` for workspace), pass-through skills copy unchanged, and MCP servers write to `mcp_config.json`. +- **Global scope support** — New `--scope global|workspace` flag for the converter CLI (generic, Windsurf as first adopter). `--to windsurf` defaults to global scope (`~/.codeium/windsurf/`), making installed skills, workflows, and MCP servers available across all projects. Use `--scope workspace` for project-level `.windsurf/` output. +- **`mcp_config.json` integration** — Machine-readable MCP config supporting stdio, Streamable HTTP, and SSE transports. Merges with existing config (user entries preserved, plugin entries take precedence). Written with `0o600` permissions for security. +- **Shared utilities** — Extracted `resolveTargetOutputRoot` to `src/utils/resolve-output.ts` and `hasPotentialSecrets` to `src/utils/secrets.ts`. + +### Changed + +- **AGENTS.md not generated** — The plugin's CLAUDE.md contains development-internal instructions, not end-user content. +- **Env var secrets included with warning** — Included in `mcp_config.json` (required for config to work) with console warning for sensitive keys. + +--- + ## [2.35.2] - 2026-02-20 ### Changed diff --git a/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md b/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md index 46dc793b..e040fba2 100644 --- a/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md +++ b/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md @@ -1,5 +1,5 @@ --- -name: resolve_pr_parallel +name: resolve-pr-parallel description: Resolve all PR comments using parallel processing. Use when addressing PR review feedback, resolving review threads, or batch-fixing PR comments. argument-hint: "[optional: PR number or current PR]" disable-model-invocation: true diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 93efb405..321c579e 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -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,13 +95,20 @@ 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.`) } - await target.write(primaryOutputRoot, bundle) + await target.write(primaryOutputRoot, bundle, resolvedScope) console.log(`Converted ${plugin.manifest.name} to ${targetName} at ${primaryOutputRoot}`) const extraTargets = parseExtraTargets(args.also) @@ -113,8 +128,15 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome) - await handler.write(extraRoot, extraBundle) + const extraRoot = resolveTargetOutputRoot({ + targetName: extra, + outputRoot: path.join(outputRoot, extra), + codexHome, + piHome, + hasExplicitOutput, + scope: handler.defaultScope, + }) + await handler.write(extraRoot, extraBundle, handler.defaultScope) 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 -} diff --git a/src/commands/install.ts b/src/commands/install.ts index eeb5a85e..f94e81da 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -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,8 +103,15 @@ 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) - await target.write(primaryOutputRoot, bundle) + const primaryOutputRoot = resolveTargetOutputRoot({ + targetName, + outputRoot, + codexHome, + piHome, + hasExplicitOutput, + scope: resolvedScope, + }) + await target.write(primaryOutputRoot, bundle, resolvedScope) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) const extraTargets = parseExtraTargets(args.also) @@ -117,8 +131,15 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput) - await handler.write(extraRoot, extraBundle) + const extraRoot = resolveTargetOutputRoot({ + targetName: extra, + outputRoot: path.join(outputRoot, extra), + codexHome, + piHome, + hasExplicitOutput, + scope: handler.defaultScope, + }) + await handler.write(extraRoot, extraBundle, handler.defaultScope) 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 { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-")) const source = resolveGitHubSource() diff --git a/src/commands/sync.ts b/src/commands/sync.ts index b7b9ed46..ac5353ec 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -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): boolean { - const sensitivePatterns = /key|token|secret|password|credential|api_key/i - for (const server of Object.values(mcpServers)) { - const env = (server as { env?: Record }).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": diff --git a/src/converters/claude-to-windsurf.ts b/src/converters/claude-to-windsurf.ts new file mode 100644 index 00000000..975af99c --- /dev/null +++ b/src/converters/claude-to-windsurf.ts @@ -0,0 +1,205 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import { findServersWithPotentialSecrets } from "../utils/secrets" +import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" +import type { WindsurfBundle, WindsurfGeneratedSkill, WindsurfMcpConfig, WindsurfMcpServerEntry, WindsurfWorkflow } from "../types/windsurf" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export type ClaudeToWindsurfOptions = ClaudeToOpenCodeOptions + +const WINDSURF_WORKFLOW_CHAR_LIMIT = 12_000 + +export function convertClaudeToWindsurf( + plugin: ClaudePlugin, + _options: ClaudeToWindsurfOptions, +): WindsurfBundle { + const knownAgentNames = plugin.agents.map((a) => normalizeName(a.name)) + + // Pass-through skills (collected first so agent skill names can deduplicate against them) + const skillDirs = plugin.skills.map((skill) => ({ + name: skill.name, + sourceDir: skill.sourceDir, + })) + + // Convert agents to skills (seed usedNames with pass-through skill names) + const usedSkillNames = new Set(skillDirs.map((s) => s.name)) + const agentSkills = plugin.agents.map((agent) => + convertAgentToSkill(agent, knownAgentNames, usedSkillNames), + ) + + // Convert commands to workflows + const usedCommandNames = new Set() + const commandWorkflows = plugin.commands.map((command) => + convertCommandToWorkflow(command, knownAgentNames, usedCommandNames), + ) + + // Build MCP config + const mcpConfig = buildMcpConfig(plugin.mcpServers) + + // Warn about hooks + if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) { + console.warn( + "Warning: Windsurf has no hooks equivalent. Hooks were skipped during conversion.", + ) + } + + return { agentSkills, commandWorkflows, skillDirs, mcpConfig } +} + +function convertAgentToSkill( + agent: ClaudeAgent, + knownAgentNames: string[], + usedNames: Set, +): WindsurfGeneratedSkill { + const name = uniqueName(normalizeName(agent.name), usedNames) + const description = sanitizeDescription( + agent.description ?? `Converted from Claude agent ${agent.name}`, + ) + + let body = transformContentForWindsurf(agent.body.trim(), knownAgentNames) + if (agent.capabilities && agent.capabilities.length > 0) { + const capabilities = agent.capabilities.map((c) => `- ${c}`).join("\n") + body = `## Capabilities\n${capabilities}\n\n${body}`.trim() + } + if (body.length === 0) { + body = `Instructions converted from the ${agent.name} agent.` + } + + const content = formatFrontmatter({ name, description }, `# ${name}\n\n${body}`) + "\n" + return { name, content } +} + +function convertCommandToWorkflow( + command: ClaudeCommand, + knownAgentNames: string[], + usedNames: Set, +): WindsurfWorkflow { + const name = uniqueName(normalizeName(command.name), usedNames) + const description = sanitizeDescription( + command.description ?? `Converted from Claude command ${command.name}`, + ) + + let body = transformContentForWindsurf(command.body.trim(), knownAgentNames) + if (command.argumentHint) { + body = `> Arguments: ${command.argumentHint}\n\n${body}` + } + if (body.length === 0) { + body = `Instructions converted from the ${command.name} command.` + } + + const frontmatter: Record = { description } + const fullContent = formatFrontmatter(frontmatter, `# ${name}\n\n${body}`) + if (fullContent.length > WINDSURF_WORKFLOW_CHAR_LIMIT) { + console.warn( + `Warning: Workflow "${name}" is ${fullContent.length} characters (limit: ${WINDSURF_WORKFLOW_CHAR_LIMIT}). It may be truncated by Windsurf.`, + ) + } + + return { name, description, body } +} + +/** + * Transform Claude Code content to Windsurf-compatible content. + * + * 1. Path rewriting: .claude/ -> .windsurf/, ~/.claude/ -> ~/.codeium/windsurf/ + * 2. Slash command refs: /workflows:plan -> /workflows-plan (Windsurf invokes workflows as /{name}) + * 3. @agent-name refs: kept as @agent-name (already Windsurf skill invocation syntax) + * 4. Task agent calls: Task agent-name(args) -> Use the @agent-name skill: args + */ +export function transformContentForWindsurf(body: string, knownAgentNames: string[] = []): string { + let result = body + + // 1. Rewrite paths + result = result.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.codeium/windsurf/") + result = result.replace(/(?<=^|\s|["'`])\.claude\//gm, ".windsurf/") + + // 2. Slash command refs: /workflows:plan -> /workflows-plan (Windsurf invokes as /{name}) + result = result.replace(/(?<=^|\s)`?\/([a-zA-Z][a-zA-Z0-9_:-]*)`?/gm, (_match, cmdName: string) => { + const workflowName = normalizeName(cmdName) + return `/${workflowName}` + }) + + // 3. @agent-name references: no transformation needed. + // In Windsurf, @skill-name is the native invocation syntax for skills. + // Since agents are now mapped to skills, @agent-name already works correctly. + + // 4. Transform Task agent calls to skill references + const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm + result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => { + return `${prefix}Use the @${normalizeName(agentName)} skill: ${args.trim()}` + }) + + return result +} + +function buildMcpConfig(servers?: Record): WindsurfMcpConfig | null { + if (!servers || Object.keys(servers).length === 0) return null + + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + if (server.command) { + // stdio transport + const entry: WindsurfMcpServerEntry = { command: server.command } + if (server.args?.length) entry.args = server.args + if (server.env && Object.keys(server.env).length > 0) entry.env = server.env + result[name] = entry + } else if (server.url) { + // HTTP/SSE transport + const entry: WindsurfMcpServerEntry = { serverUrl: server.url } + if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers + if (server.env && Object.keys(server.env).length > 0) entry.env = server.env + result[name] = entry + } else { + console.warn(`Warning: MCP server "${name}" has no command or URL. Skipping.`) + continue + } + } + + if (Object.keys(result).length === 0) return null + + // Warn about secrets (don't redact — they're needed for the config to work) + const flagged = findServersWithPotentialSecrets(result) + if (flagged.length > 0) { + console.warn( + `Warning: MCP servers contain env vars that may include secrets: ${flagged.join(", ")}.\n` + + " These will be written to mcp_config.json. Review before sharing the config file.", + ) + } + + return { mcpServers: result } +} + +export function normalizeName(value: string): string { + const trimmed = value.trim() + if (!trimmed) return "item" + let normalized = trimmed + .toLowerCase() + .replace(/[\\/]+/g, "-") + .replace(/[:\s]+/g, "-") + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + + if (normalized.length === 0 || !/^[a-z]/.test(normalized)) { + return "item" + } + + return normalized +} + +function sanitizeDescription(value: string): string { + return value.replace(/\s+/g, " ").trim() +} + +function uniqueName(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base) + return base + } + let index = 2 + while (used.has(`${base}-${index}`)) { + index += 1 + } + const name = `${base}-${index}` + used.add(name) + return name +} diff --git a/src/targets/index.ts b/src/targets/index.ts index b7b3ea2b..bb0509fe 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -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,12 +22,43 @@ 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 = { 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 + write: (outputRoot: string, bundle: TBundle, scope?: TargetScope) => Promise } export const targets: Record = { @@ -71,4 +104,12 @@ export const targets: Record = { convert: convertClaudeToKiro as TargetHandler["convert"], write: writeKiroBundle as TargetHandler["write"], }, + windsurf: { + name: "windsurf", + implemented: true, + defaultScope: "global", + supportedScopes: ["global", "workspace"], + convert: convertClaudeToWindsurf as TargetHandler["convert"], + write: writeWindsurfBundle as TargetHandler["write"], + }, } diff --git a/src/targets/windsurf.ts b/src/targets/windsurf.ts new file mode 100644 index 00000000..ee960451 --- /dev/null +++ b/src/targets/windsurf.ts @@ -0,0 +1,104 @@ +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 { + 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 = {} + if (await pathExists(mcpPath)) { + try { + const parsed = await readJson(mcpPath) + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + existingConfig = parsed as Record + } + } 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) + : {} + 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" +} diff --git a/src/types/windsurf.ts b/src/types/windsurf.ts new file mode 100644 index 00000000..8094a3a0 --- /dev/null +++ b/src/types/windsurf.ts @@ -0,0 +1,34 @@ +export type WindsurfWorkflow = { + name: string + description: string + body: string +} + +export type WindsurfGeneratedSkill = { + name: string + content: string +} + +export type WindsurfSkillDir = { + name: string + sourceDir: string +} + +export type WindsurfMcpServerEntry = { + command?: string + args?: string[] + env?: Record + serverUrl?: string + headers?: Record +} + +export type WindsurfMcpConfig = { + mcpServers: Record +} + +export type WindsurfBundle = { + agentSkills: WindsurfGeneratedSkill[] + commandWorkflows: WindsurfWorkflow[] + skillDirs: WindsurfSkillDir[] + mcpConfig: WindsurfMcpConfig | null +} diff --git a/src/utils/files.ts b/src/utils/files.ts index 9994d0c9..a9d6af87 100644 --- a/src/utils/files.ts +++ b/src/utils/files.ts @@ -46,6 +46,13 @@ export async function writeJson(filePath: string, data: unknown): Promise await writeText(filePath, content + "\n") } +/** Write JSON with restrictive permissions (0o600) for files containing secrets */ +export async function writeJsonSecure(filePath: string, data: unknown): Promise { + 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 { const entries = await fs.readdir(root, { withFileTypes: true }) const results: string[] = [] diff --git a/src/utils/resolve-output.ts b/src/utils/resolve-output.ts new file mode 100644 index 00000000..4cd05f5e --- /dev/null +++ b/src/utils/resolve-output.ts @@ -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 +} diff --git a/src/utils/secrets.ts b/src/utils/secrets.ts new file mode 100644 index 00000000..45f196dc --- /dev/null +++ b/src/utils/secrets.ts @@ -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 }>, +): 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[] { + return Object.entries(servers) + .filter(([, s]) => s.env && Object.keys(s.env).some((k) => SENSITIVE_PATTERN.test(k))) + .map(([name]) => name) +} diff --git a/tests/resolve-output.test.ts b/tests/resolve-output.test.ts new file mode 100644 index 00000000..d364f424 --- /dev/null +++ b/tests/resolve-output.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test" +import os from "os" +import path from "path" +import { resolveTargetOutputRoot } from "../src/utils/resolve-output" + +const baseOptions = { + outputRoot: "/tmp/output", + codexHome: path.join(os.homedir(), ".codex"), + piHome: path.join(os.homedir(), ".pi", "agent"), + hasExplicitOutput: false, +} + +describe("resolveTargetOutputRoot", () => { + test("codex returns codexHome", () => { + const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "codex" }) + expect(result).toBe(baseOptions.codexHome) + }) + + test("pi returns piHome", () => { + const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "pi" }) + expect(result).toBe(baseOptions.piHome) + }) + + test("droid returns ~/.factory", () => { + const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "droid" }) + expect(result).toBe(path.join(os.homedir(), ".factory")) + }) + + test("cursor with no explicit output uses cwd", () => { + const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "cursor" }) + expect(result).toBe(path.join(process.cwd(), ".cursor")) + }) + + test("cursor with explicit output uses outputRoot", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "cursor", + hasExplicitOutput: true, + }) + expect(result).toBe(path.join("/tmp/output", ".cursor")) + }) + + test("windsurf default scope (global) resolves to ~/.codeium/windsurf/", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "windsurf", + scope: "global", + }) + expect(result).toBe(path.join(os.homedir(), ".codeium", "windsurf")) + }) + + test("windsurf workspace scope resolves to cwd/.windsurf/", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "windsurf", + scope: "workspace", + }) + expect(result).toBe(path.join(process.cwd(), ".windsurf")) + }) + + test("windsurf with explicit output overrides global scope", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "windsurf", + hasExplicitOutput: true, + scope: "global", + }) + expect(result).toBe("/tmp/output") + }) + + test("windsurf with explicit output overrides workspace scope", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "windsurf", + hasExplicitOutput: true, + scope: "workspace", + }) + expect(result).toBe("/tmp/output") + }) + + test("windsurf with no scope and no explicit output uses cwd/.windsurf/", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "windsurf", + }) + expect(result).toBe(path.join(process.cwd(), ".windsurf")) + }) + + test("opencode returns outputRoot as-is", () => { + const result = resolveTargetOutputRoot({ ...baseOptions, targetName: "opencode" }) + expect(result).toBe("/tmp/output") + }) +}) diff --git a/tests/windsurf-converter.test.ts b/tests/windsurf-converter.test.ts new file mode 100644 index 00000000..4264a174 --- /dev/null +++ b/tests/windsurf-converter.test.ts @@ -0,0 +1,573 @@ +import { describe, expect, test } from "bun:test" +import { convertClaudeToWindsurf, transformContentForWindsurf, normalizeName } from "../src/converters/claude-to-windsurf" +import type { ClaudePlugin } from "../src/types/claude" + +const fixturePlugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [ + { + name: "Security Reviewer", + description: "Security-focused agent", + capabilities: ["Threat modeling", "OWASP"], + model: "claude-sonnet-4-20250514", + body: "Focus on vulnerabilities.", + sourcePath: "/tmp/plugin/agents/security-reviewer.md", + }, + ], + commands: [ + { + name: "workflows:plan", + description: "Planning command", + argumentHint: "[FOCUS]", + model: "inherit", + allowedTools: ["Read"], + body: "Plan the work.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + ], + skills: [ + { + name: "existing-skill", + description: "Existing skill", + sourceDir: "/tmp/plugin/skills/existing-skill", + skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md", + }, + ], + hooks: undefined, + mcpServers: { + local: { command: "echo", args: ["hello"] }, + }, +} + +const defaultOptions = { + agentMode: "subagent" as const, + inferTemperature: false, + permissions: "none" as const, +} + +describe("convertClaudeToWindsurf", () => { + test("converts agents to skills with correct name and description in SKILL.md", () => { + const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions) + + const skill = bundle.agentSkills.find((s) => s.name === "security-reviewer") + expect(skill).toBeDefined() + expect(skill!.content).toContain("name: security-reviewer") + expect(skill!.content).toContain("description: Security-focused agent") + expect(skill!.content).toContain("Focus on vulnerabilities.") + }) + + test("agent capabilities included in skill content", () => { + const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions) + const skill = bundle.agentSkills.find((s) => s.name === "security-reviewer") + expect(skill!.content).toContain("## Capabilities") + expect(skill!.content).toContain("- Threat modeling") + expect(skill!.content).toContain("- OWASP") + }) + + test("agent with empty description gets default description", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "my-agent", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/my-agent.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.agentSkills[0].content).toContain("description: Converted from Claude agent my-agent") + }) + + test("agent model field silently dropped", () => { + const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions) + const skill = bundle.agentSkills.find((s) => s.name === "security-reviewer") + expect(skill!.content).not.toContain("model:") + }) + + test("agent with empty body gets default body text", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "Empty Agent", + description: "An empty agent", + body: "", + sourcePath: "/tmp/plugin/agents/empty.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.agentSkills[0].content).toContain("Instructions converted from the Empty Agent agent.") + }) + + test("converts commands to workflows with description", () => { + const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions) + + expect(bundle.commandWorkflows).toHaveLength(1) + const workflow = bundle.commandWorkflows[0] + expect(workflow.name).toBe("workflows-plan") + expect(workflow.description).toBe("Planning command") + expect(workflow.body).toContain("Plan the work.") + }) + + test("command argumentHint preserved as note in body", () => { + const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions) + const workflow = bundle.commandWorkflows[0] + expect(workflow.body).toContain("> Arguments: [FOCUS]") + }) + + test("command with no description gets fallback", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "my-command", + body: "Do things.", + sourcePath: "/tmp/plugin/commands/my-command.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.commandWorkflows[0].description).toBe("Converted from Claude command my-command") + }) + + test("command with disableModelInvocation is still included", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "disabled-command", + description: "Disabled command", + disableModelInvocation: true, + body: "Disabled body.", + sourcePath: "/tmp/plugin/commands/disabled.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.commandWorkflows).toHaveLength(1) + expect(bundle.commandWorkflows[0].name).toBe("disabled-command") + }) + + test("command allowedTools silently dropped", () => { + const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions) + const workflow = bundle.commandWorkflows[0] + expect(workflow.body).not.toContain("allowedTools") + }) + + test("skills pass through as directory references", () => { + const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions) + + expect(bundle.skillDirs).toHaveLength(1) + expect(bundle.skillDirs[0].name).toBe("existing-skill") + expect(bundle.skillDirs[0].sourceDir).toBe("/tmp/plugin/skills/existing-skill") + }) + + test("name normalization handles various inputs", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { name: "My Cool Agent!!!", description: "Cool", body: "Body.", sourcePath: "/tmp/a.md" }, + { name: "UPPERCASE-AGENT", description: "Upper", body: "Body.", sourcePath: "/tmp/b.md" }, + { name: "agent--with--double-hyphens", description: "Hyphens", body: "Body.", sourcePath: "/tmp/c.md" }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.agentSkills[0].name).toBe("my-cool-agent") + expect(bundle.agentSkills[1].name).toBe("uppercase-agent") + expect(bundle.agentSkills[2].name).toBe("agent-with-double-hyphens") + }) + + test("name deduplication within agent skills", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { name: "reviewer", description: "First", body: "Body.", sourcePath: "/tmp/a.md" }, + { name: "Reviewer", description: "Second", body: "Body.", sourcePath: "/tmp/b.md" }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.agentSkills[0].name).toBe("reviewer") + expect(bundle.agentSkills[1].name).toBe("reviewer-2") + }) + + test("agent skill name deduplicates against pass-through skill names", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { name: "existing-skill", description: "Agent with same name as skill", body: "Body.", sourcePath: "/tmp/a.md" }, + ], + commands: [], + skills: [ + { + name: "existing-skill", + description: "Pass-through skill", + sourceDir: "/tmp/plugin/skills/existing-skill", + skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md", + }, + ], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.agentSkills[0].name).toBe("existing-skill-2") + }) + + test("agent skill and command with same normalized name are NOT deduplicated (separate sets)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { name: "review", description: "Agent", body: "Body.", sourcePath: "/tmp/a.md" }, + ], + commands: [ + { name: "review", description: "Command", body: "Body.", sourcePath: "/tmp/b.md" }, + ], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.agentSkills[0].name).toBe("review") + expect(bundle.commandWorkflows[0].name).toBe("review") + }) + + test("large agent skill does not emit 12K character limit warning (skills have no limit)", () => { + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "large-agent", + description: "Large agent", + body: "x".repeat(12_000), + sourcePath: "/tmp/a.md", + }, + ], + commands: [], + skills: [], + } + + convertClaudeToWindsurf(plugin, defaultOptions) + console.warn = originalWarn + + expect(warnings.some((w) => w.includes("12000") || w.includes("limit"))).toBe(false) + }) + + test("hooks present emits console.warn", () => { + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + hooks: { hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: "echo test" }] }] } }, + agents: [], + commands: [], + skills: [], + } + + convertClaudeToWindsurf(plugin, defaultOptions) + console.warn = originalWarn + + expect(warnings.some((w) => w.includes("Windsurf"))).toBe(true) + }) + + test("empty plugin produces empty bundle with null mcpConfig", () => { + const plugin: ClaudePlugin = { + root: "/tmp/empty", + manifest: { name: "empty", version: "1.0.0" }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.agentSkills).toHaveLength(0) + expect(bundle.commandWorkflows).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(0) + expect(bundle.mcpConfig).toBeNull() + }) + + // MCP config tests + + test("stdio server produces correct mcpConfig JSON structure", () => { + const bundle = convertClaudeToWindsurf(fixturePlugin, defaultOptions) + expect(bundle.mcpConfig).not.toBeNull() + expect(bundle.mcpConfig!.mcpServers.local).toEqual({ + command: "echo", + args: ["hello"], + }) + }) + + test("stdio server with env vars includes actual values (not redacted)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + myserver: { + command: "serve", + env: { + API_KEY: "secret123", + PORT: "3000", + }, + }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.mcpConfig!.mcpServers.myserver.env).toEqual({ + API_KEY: "secret123", + PORT: "3000", + }) + }) + + test("HTTP/SSE server produces correct mcpConfig with serverUrl", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + remote: { url: "https://example.com/mcp", headers: { Authorization: "Bearer abc" } }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.mcpConfig!.mcpServers.remote).toEqual({ + serverUrl: "https://example.com/mcp", + headers: { Authorization: "Bearer abc" }, + }) + }) + + test("mixed stdio and HTTP servers both included", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + local: { command: "echo", args: ["hello"] }, + remote: { url: "https://example.com/mcp" }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(Object.keys(bundle.mcpConfig!.mcpServers)).toHaveLength(2) + expect(bundle.mcpConfig!.mcpServers.local.command).toBe("echo") + expect(bundle.mcpConfig!.mcpServers.remote.serverUrl).toBe("https://example.com/mcp") + }) + + test("hasPotentialSecrets emits console.warn for sensitive env keys", () => { + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (...msgs: unknown[]) => warnings.push(msgs.map(String).join(" ")) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + myserver: { + command: "serve", + env: { API_KEY: "secret123", PORT: "3000" }, + }, + }, + agents: [], + commands: [], + skills: [], + } + + convertClaudeToWindsurf(plugin, defaultOptions) + console.warn = originalWarn + + expect(warnings.some((w) => w.includes("secrets") && w.includes("myserver"))).toBe(true) + }) + + test("no secrets warning when env vars are safe", () => { + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (...msgs: unknown[]) => warnings.push(msgs.map(String).join(" ")) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + myserver: { + command: "serve", + env: { PORT: "3000", HOST: "localhost" }, + }, + }, + agents: [], + commands: [], + skills: [], + } + + convertClaudeToWindsurf(plugin, defaultOptions) + console.warn = originalWarn + + expect(warnings.some((w) => w.includes("secrets"))).toBe(false) + }) + + test("no MCP servers produces null mcpConfig", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: undefined, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.mcpConfig).toBeNull() + }) + + test("server with no command and no URL is skipped with warning", () => { + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (...msgs: unknown[]) => warnings.push(msgs.map(String).join(" ")) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + broken: {} as { command: string }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + console.warn = originalWarn + + expect(bundle.mcpConfig).toBeNull() + expect(warnings.some((w) => w.includes("broken") && w.includes("no command or URL"))).toBe(true) + }) + + test("server command without args omits args field", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + simple: { command: "myserver" }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToWindsurf(plugin, defaultOptions) + expect(bundle.mcpConfig!.mcpServers.simple).toEqual({ command: "myserver" }) + expect(bundle.mcpConfig!.mcpServers.simple.args).toBeUndefined() + }) +}) + +describe("transformContentForWindsurf", () => { + test("transforms .claude/ paths to .windsurf/", () => { + const result = transformContentForWindsurf("Read .claude/settings.json for config.") + expect(result).toContain(".windsurf/settings.json") + expect(result).not.toContain(".claude/") + }) + + test("transforms ~/.claude/ paths to ~/.codeium/windsurf/", () => { + const result = transformContentForWindsurf("Check ~/.claude/config for settings.") + expect(result).toContain("~/.codeium/windsurf/config") + expect(result).not.toContain("~/.claude/") + }) + + test("transforms Task agent(args) to skill reference", () => { + const input = `Run these: + +- Task repo-research-analyst(feature_description) +- Task learnings-researcher(feature_description) + +Task best-practices-researcher(topic)` + + const result = transformContentForWindsurf(input) + expect(result).toContain("Use the @repo-research-analyst skill: feature_description") + expect(result).toContain("Use the @learnings-researcher skill: feature_description") + expect(result).toContain("Use the @best-practices-researcher skill: topic") + expect(result).not.toContain("Task repo-research-analyst") + }) + + test("keeps @agent references as-is for known agents (Windsurf skill invocation syntax)", () => { + const result = transformContentForWindsurf("Ask @security-sentinel for a review.", ["security-sentinel"]) + expect(result).toContain("@security-sentinel") + expect(result).not.toContain("/agents/") + }) + + test("does not transform @unknown-name when not in known agents", () => { + const result = transformContentForWindsurf("Contact @someone-else for help.", ["security-sentinel"]) + expect(result).toContain("@someone-else") + }) + + test("transforms slash command refs to /{workflow-name} (per spec)", () => { + const result = transformContentForWindsurf("Run /workflows:plan to start planning.") + expect(result).toContain("/workflows-plan") + expect(result).not.toContain("/commands/") + }) + + test("does not transform partial .claude paths in middle of word", () => { + const result = transformContentForWindsurf("Check some-package/.claude-config/settings") + expect(result).toContain("some-package/") + }) + + test("handles case sensitivity in @agent-name matching", () => { + const result = transformContentForWindsurf("Delegate to @My-Agent for help.", ["my-agent"]) + // @My-Agent won't match my-agent since regex is case-sensitive on the known names + expect(result).toContain("@My-Agent") + }) + + test("handles multiple occurrences of same transform", () => { + const result = transformContentForWindsurf( + "Use .claude/foo and .claude/bar for config.", + ) + expect(result).toContain(".windsurf/foo") + expect(result).toContain(".windsurf/bar") + expect(result).not.toContain(".claude/") + }) +}) + +describe("normalizeName", () => { + test("lowercases and hyphenates spaces", () => { + expect(normalizeName("Security Reviewer")).toBe("security-reviewer") + }) + + test("replaces colons with hyphens", () => { + expect(normalizeName("workflows:plan")).toBe("workflows-plan") + }) + + test("collapses consecutive hyphens", () => { + expect(normalizeName("agent--with--double-hyphens")).toBe("agent-with-double-hyphens") + }) + + test("strips leading/trailing hyphens", () => { + expect(normalizeName("-leading-and-trailing-")).toBe("leading-and-trailing") + }) + + test("empty string returns item", () => { + expect(normalizeName("")).toBe("item") + }) + + test("non-letter start returns item", () => { + expect(normalizeName("123-agent")).toBe("item") + }) +}) diff --git a/tests/windsurf-writer.test.ts b/tests/windsurf-writer.test.ts new file mode 100644 index 00000000..9d1129c3 --- /dev/null +++ b/tests/windsurf-writer.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writeWindsurfBundle } from "../src/targets/windsurf" +import type { WindsurfBundle } from "../src/types/windsurf" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +const emptyBundle: WindsurfBundle = { + agentSkills: [], + commandWorkflows: [], + skillDirs: [], + mcpConfig: null, +} + +describe("writeWindsurfBundle", () => { + test("creates correct directory structure with all components", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-test-")) + const bundle: WindsurfBundle = { + agentSkills: [ + { + name: "security-reviewer", + content: "---\nname: security-reviewer\ndescription: Security-focused agent\n---\n\n# security-reviewer\n\nReview code for vulnerabilities.\n", + }, + ], + commandWorkflows: [ + { + name: "workflows-plan", + description: "Planning command", + body: "> Arguments: [FOCUS]\n\nPlan the work.", + }, + ], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + mcpConfig: { + mcpServers: { + local: { command: "echo", args: ["hello"] }, + }, + }, + } + + await writeWindsurfBundle(tempRoot, bundle) + + // No AGENTS.md — removed in v0.11.0 + expect(await exists(path.join(tempRoot, "AGENTS.md"))).toBe(false) + + // Agent skill written as skills//SKILL.md + const agentSkillPath = path.join(tempRoot, "skills", "security-reviewer", "SKILL.md") + expect(await exists(agentSkillPath)).toBe(true) + const agentContent = await fs.readFile(agentSkillPath, "utf8") + expect(agentContent).toContain("name: security-reviewer") + expect(agentContent).toContain("description: Security-focused agent") + expect(agentContent).toContain("Review code for vulnerabilities.") + + // No workflows/agents/ or workflows/commands/ subdirectories (flat per spec) + expect(await exists(path.join(tempRoot, "workflows", "agents"))).toBe(false) + expect(await exists(path.join(tempRoot, "workflows", "commands"))).toBe(false) + + // Command workflow flat in outputRoot/workflows/ (per spec) + const cmdWorkflowPath = path.join(tempRoot, "workflows", "workflows-plan.md") + expect(await exists(cmdWorkflowPath)).toBe(true) + const cmdContent = await fs.readFile(cmdWorkflowPath, "utf8") + expect(cmdContent).toContain("description: Planning command") + expect(cmdContent).toContain("Plan the work.") + + // Copied skill directly in outputRoot/skills/ + expect(await exists(path.join(tempRoot, "skills", "skill-one", "SKILL.md"))).toBe(true) + + // MCP config directly in outputRoot/ + const mcpPath = path.join(tempRoot, "mcp_config.json") + expect(await exists(mcpPath)).toBe(true) + const mcpContent = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(mcpContent.mcpServers.local).toEqual({ command: "echo", args: ["hello"] }) + }) + + test("writes directly into outputRoot without nesting", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-direct-")) + const bundle: WindsurfBundle = { + ...emptyBundle, + agentSkills: [ + { + name: "reviewer", + content: "---\nname: reviewer\ndescription: A reviewer\n---\n\n# reviewer\n\nReview content.\n", + }, + ], + } + + await writeWindsurfBundle(tempRoot, bundle) + + // Skill should be directly in outputRoot/skills/reviewer/SKILL.md + expect(await exists(path.join(tempRoot, "skills", "reviewer", "SKILL.md"))).toBe(true) + // Should NOT create a .windsurf subdirectory + expect(await exists(path.join(tempRoot, ".windsurf"))).toBe(false) + }) + + test("handles empty bundle gracefully", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-empty-")) + + await writeWindsurfBundle(tempRoot, emptyBundle) + expect(await exists(tempRoot)).toBe(true) + // No mcp_config.json for null mcpConfig + expect(await exists(path.join(tempRoot, "mcp_config.json"))).toBe(false) + }) + + test("path traversal in agent skill name is rejected", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-traversal-")) + const bundle: WindsurfBundle = { + ...emptyBundle, + agentSkills: [ + { name: "../escape", content: "Bad content." }, + ], + } + + expect(writeWindsurfBundle(tempRoot, bundle)).rejects.toThrow("unsafe path") + }) + + test("path traversal in command workflow name is rejected", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-traversal2-")) + const bundle: WindsurfBundle = { + ...emptyBundle, + commandWorkflows: [ + { name: "../escape", description: "Malicious", body: "Bad content." }, + ], + } + + expect(writeWindsurfBundle(tempRoot, bundle)).rejects.toThrow("unsafe path") + }) + + test("skill directory containment check prevents escape", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-skill-escape-")) + const bundle: WindsurfBundle = { + ...emptyBundle, + skillDirs: [ + { name: "../escape", sourceDir: "/tmp/fake-skill" }, + ], + } + + expect(writeWindsurfBundle(tempRoot, bundle)).rejects.toThrow("unsafe path") + }) + + test("agent skill files have YAML frontmatter with name and description", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-fm-")) + const bundle: WindsurfBundle = { + ...emptyBundle, + agentSkills: [ + { + name: "test-agent", + content: "---\nname: test-agent\ndescription: Test agent description\n---\n\n# test-agent\n\nDo test things.\n", + }, + ], + } + + await writeWindsurfBundle(tempRoot, bundle) + + const skillPath = path.join(tempRoot, "skills", "test-agent", "SKILL.md") + const content = await fs.readFile(skillPath, "utf8") + expect(content).toContain("---") + expect(content).toContain("name: test-agent") + expect(content).toContain("description: Test agent description") + expect(content).toContain("# test-agent") + expect(content).toContain("Do test things.") + }) + + // MCP config merge tests + + test("writes mcp_config.json to outputRoot", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-mcp-")) + const bundle: WindsurfBundle = { + ...emptyBundle, + mcpConfig: { + mcpServers: { + myserver: { command: "serve", args: ["--port", "3000"] }, + }, + }, + } + + await writeWindsurfBundle(tempRoot, bundle) + + const mcpPath = path.join(tempRoot, "mcp_config.json") + expect(await exists(mcpPath)).toBe(true) + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcpServers.myserver.command).toBe("serve") + expect(content.mcpServers.myserver.args).toEqual(["--port", "3000"]) + }) + + test("merges with existing mcp_config.json preserving user servers", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-merge-")) + const mcpPath = path.join(tempRoot, "mcp_config.json") + + // Write existing config with a user server + await fs.writeFile(mcpPath, JSON.stringify({ + mcpServers: { + "user-server": { command: "my-tool", args: ["--flag"] }, + }, + }, null, 2)) + + const bundle: WindsurfBundle = { + ...emptyBundle, + mcpConfig: { + mcpServers: { + "plugin-server": { command: "plugin-tool" }, + }, + }, + } + + await writeWindsurfBundle(tempRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + // Both servers should be present + expect(content.mcpServers["user-server"].command).toBe("my-tool") + expect(content.mcpServers["plugin-server"].command).toBe("plugin-tool") + }) + + test("backs up existing mcp_config.json before overwrite", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-backup-")) + const mcpPath = path.join(tempRoot, "mcp_config.json") + + await fs.writeFile(mcpPath, '{"mcpServers":{}}') + + const bundle: WindsurfBundle = { + ...emptyBundle, + mcpConfig: { + mcpServers: { new: { command: "new-tool" } }, + }, + } + + await writeWindsurfBundle(tempRoot, bundle) + + // A backup file should exist + const files = await fs.readdir(tempRoot) + const backupFiles = files.filter((f) => f.startsWith("mcp_config.json.bak.")) + expect(backupFiles.length).toBeGreaterThanOrEqual(1) + }) + + test("handles corrupted existing mcp_config.json with warning", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-corrupt-")) + const mcpPath = path.join(tempRoot, "mcp_config.json") + + await fs.writeFile(mcpPath, "not valid json{{{") + + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (...msgs: unknown[]) => warnings.push(msgs.map(String).join(" ")) + + const bundle: WindsurfBundle = { + ...emptyBundle, + mcpConfig: { + mcpServers: { new: { command: "new-tool" } }, + }, + } + + await writeWindsurfBundle(tempRoot, bundle) + console.warn = originalWarn + + expect(warnings.some((w) => w.includes("could not be parsed"))).toBe(true) + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcpServers.new.command).toBe("new-tool") + }) + + test("handles existing mcp_config.json with array at root", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-array-")) + const mcpPath = path.join(tempRoot, "mcp_config.json") + + await fs.writeFile(mcpPath, "[1,2,3]") + + const bundle: WindsurfBundle = { + ...emptyBundle, + mcpConfig: { + mcpServers: { new: { command: "new-tool" } }, + }, + } + + await writeWindsurfBundle(tempRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcpServers.new.command).toBe("new-tool") + // Array root should be replaced with object + expect(Array.isArray(content)).toBe(false) + }) + + test("preserves non-mcpServers keys in existing file", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-preserve-")) + const mcpPath = path.join(tempRoot, "mcp_config.json") + + await fs.writeFile(mcpPath, JSON.stringify({ + customSetting: true, + version: 2, + mcpServers: { old: { command: "old-tool" } }, + }, null, 2)) + + const bundle: WindsurfBundle = { + ...emptyBundle, + mcpConfig: { + mcpServers: { new: { command: "new-tool" } }, + }, + } + + await writeWindsurfBundle(tempRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.customSetting).toBe(true) + expect(content.version).toBe(2) + expect(content.mcpServers.new.command).toBe("new-tool") + expect(content.mcpServers.old.command).toBe("old-tool") + }) + + test("server name collision: plugin entry wins", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-collision-")) + const mcpPath = path.join(tempRoot, "mcp_config.json") + + await fs.writeFile(mcpPath, JSON.stringify({ + mcpServers: { shared: { command: "old-version" } }, + }, null, 2)) + + const bundle: WindsurfBundle = { + ...emptyBundle, + mcpConfig: { + mcpServers: { shared: { command: "new-version" } }, + }, + } + + await writeWindsurfBundle(tempRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcpServers.shared.command).toBe("new-version") + }) + + test("mcp_config.json written with restrictive permissions", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "windsurf-perms-")) + const bundle: WindsurfBundle = { + ...emptyBundle, + mcpConfig: { + mcpServers: { server: { command: "tool" } }, + }, + } + + await writeWindsurfBundle(tempRoot, bundle) + + const mcpPath = path.join(tempRoot, "mcp_config.json") + const stat = await fs.stat(mcpPath) + // On Unix: 0o600 = owner read+write only. On Windows, permissions work differently. + if (process.platform !== "win32") { + const mode = stat.mode & 0o777 + expect(mode).toBe(0o600) + } + }) +})