feat(windsurf): add Windsurf as converter target with global scope support
Add `--to windsurf` target for the converter CLI with full spec compliance
per docs/specs/windsurf.md:
- Claude agents → Windsurf skills (skills/{name}/SKILL.md)
- Claude commands → Windsurf workflows (workflows/{name}.md, flat)
- Pass-through skills copy unchanged
- MCP servers → mcp_config.json (merged with existing, 0o600 permissions)
- Hooks skipped with warning, CLAUDE.md skipped
Global scope support via generic --scope flag (Windsurf as first adopter):
- --to windsurf defaults to global (~/.codeium/windsurf/)
- --scope workspace for project-level .windsurf/ output
- --output overrides scope-derived paths
Shared utilities extracted (resolveTargetOutputRoot, hasPotentialSecrets)
to eliminate duplication across CLI commands.
68 new tests (converter, writer, scope resolution).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
627
docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md
Normal file
627
docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md
Normal file
@@ -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 `workflows/{name}.md` (no subdirectories). The spec requires flat files, not `workflows/agents/` or `workflows/commands/` subdirectories.
|
||||
|
||||
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) | `workflows/{name}.md` | `/{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<string, { env?: Record<string, string> }>,
|
||||
): boolean {
|
||||
for (const server of Object.values(servers)) {
|
||||
if (server.env) {
|
||||
for (const key of Object.keys(server.env)) {
|
||||
if (SENSITIVE_PATTERN.test(key)) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
- [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<string, string>
|
||||
serverUrl?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export type WindsurfMcpConfig = {
|
||||
mcpServers: Record<string, WindsurfMcpServerEntry>
|
||||
}
|
||||
|
||||
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<TBundle = unknown> = {
|
||||
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<void>
|
||||
}
|
||||
```
|
||||
|
||||
- [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<string, ClaudeMcpServer>,
|
||||
): WindsurfMcpConfig | null {
|
||||
if (!servers || Object.keys(servers).length === 0) return null
|
||||
|
||||
const result: Record<string, WindsurfMcpServerEntry> = {}
|
||||
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<string, unknown> = {}
|
||||
if (await pathExists(mcpPath)) {
|
||||
try {
|
||||
const parsed = await readJson<unknown>(mcpPath)
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
existingConfig = parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
console.warn("Warning: existing mcp_config.json could not be parsed and will be replaced.")
|
||||
}
|
||||
}
|
||||
|
||||
const existingServers =
|
||||
existingConfig.mcpServers &&
|
||||
typeof existingConfig.mcpServers === "object" &&
|
||||
!Array.isArray(existingConfig.mcpServers)
|
||||
? (existingConfig.mcpServers as Record<string, unknown>)
|
||||
: {}
|
||||
const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpConfig.mcpServers } }
|
||||
await writeJsonSecure(mcpPath, merged) // 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<void> {
|
||||
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<Record<string, unknown>>` 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<void> {
|
||||
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)
|
||||
692
docs/solutions/adding-converter-target-providers.md
Normal file
692
docs/solutions/adding-converter-target-providers.md
Normal file
@@ -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<string, {TargetName}McpServer>
|
||||
// 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<string, unknown> // 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<string, string> = {}
|
||||
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 = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
|
||||
result = result.replace(slashPattern, (match, commandName: string) => {
|
||||
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(/<examples>[\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>): 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<void> {
|
||||
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<string | null> {
|
||||
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<string, TargetHandler<any>> = {
|
||||
// ... 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<void> {
|
||||
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
|
||||
477
docs/specs/windsurf.md
Normal file
477
docs/specs/windsurf.md
Normal file
@@ -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
|
||||
├── 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/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/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** | `workflows/{name}.md` | 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
|
||||
<coding_guidelines>
|
||||
- Use early returns when possible
|
||||
- Always add documentation for new functions
|
||||
- Prefer composition over inheritance
|
||||
</coding_guidelines>
|
||||
|
||||
<testing_requirements>
|
||||
- Write unit tests for all public methods
|
||||
- Maintain 80% code coverage
|
||||
</testing_requirements>
|
||||
```
|
||||
|
||||
### 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)
|
||||
Reference in New Issue
Block a user