Adding New Converter Target Providers
When adding support for a new AI platform (e.g., Copilot, Windsurf, Qwen), 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.
Overview
Adding New Converter Target Providers
Problem
When adding support for a new AI platform (e.g., Copilot, Windsurf, Qwen), 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 10 targets:
- OpenCode (primary target, reference implementation)
- Codex (second target, established pattern)
- Droid/Factory (workflow/agent conversion)
- Pi (MCPorter ecosystem)
- Gemini CLI (content transformation patterns)
- Copilot (GitHub native, MCP prefixing)
- Kiro (limited MCP support)
- Windsurf (rules-based format)
- OpenClaw (open agent format)
- Qwen (Qwen agent format)
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:
// Exported bundle type used by converter and writer
// 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
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
contentfield (full file text) rather than decomposed fields — it's simpler and matches how files are written - Use intermediate types for complex sections to make section building independently testable
- Avoid target-specific fields in the base bundle unless essential — aim for shared structure across targets
- Include a
categoryfield if the target has file-type variants (agents vs. commands vs. rules)
Reference Implementations:
- OpenCode:
src/types/opencode.ts(command + agent split) - Copilot:
src/types/copilot.ts(agents + skills + MCP) - Windsurf:
src/types/windsurf.ts(rules-based format)
Phase 2: Converter (src/converters/claude-to-{target}.ts)
Purpose: Transform Claude Code plugin format → target-specific bundle format
Key Pattern:
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:
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 (Copilot, Windsurf) or when name collisions occur:
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:
-
Pre-scan for cross-references — If target requires reference names (macros, URIs, IDs), build a map before conversion to avoid name collisions and enable deduplication.
-
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. -
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.
-
Normalize early and consistently — Use the same
normalizeName()function throughout. Inconsistent normalization causes deduplication bugs. -
MCP servers need target-specific handling:
- OpenCode: Merge into
opencode.json(preserve user keys) - Copilot: Prefix env vars with
COPILOT_MCP_, emit JSON - Windsurf: Write MCP config in target-specific format
- Kiro: Limited MCP support, check compatibility
- OpenCode: Merge into
-
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) - Copilot:
src/converters/claude-to-copilot.ts(MCP prefixing pattern) - Windsurf:
src/converters/claude-to-windsurf.ts(rules-based conversion)
Phase 3: Writer (src/targets/{target}.ts)
Purpose: Write converted bundle to disk in target-specific directory structure
Key Pattern:
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 + "\
")
}
}
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 + "\
")
}
}
// 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 + "\
")
}
}
// 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 + "\
")
}
}
// 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:
// From src/utils/files.ts
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:
-
Always check for double-nesting — If output root is already
.target, don't nest again. Pattern:if (path.basename(outputRoot) === ".target") { return { root: outputRoot } // Write directly } return { root: path.join(outputRoot, ".target") } // Nest -
Use
writeTextandwriteJsonhelpers — These handle directory creation and line endings consistently -
Backup MCP configs before overwriting — MCP JSON files are often hand-edited. Always backup with timestamp.
-
Empty bundles should succeed gracefully — Don't fail if a component array is empty. Many plugins may have no commands or no skills.
-
File extensions matter — Match target conventions exactly:
- Copilot:
.agent.md(note the dot) - Windsurf:
.mdfor rules - OpenCode:
.mdfor commands
- Copilot:
-
Permissions for sensitive files — MCP config with API keys should use
0o600: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) - Windsurf:
src/targets/windsurf.ts(rules-based output)
Phase 4: CLI Wiring
File: src/targets/index.ts
Register the new target in the global target registry:
// ... 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:
// In resolveTargetOutputRoot()
if (targetName === "{target}") {
return path.join(outputRoot, ".{target}")
}
// Update --to flag description
const toDescription = "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen | all)"
Phase 5: Sync Support (Optional)
File: src/sync/{target}.ts
If the target supports syncing personal skills and MCP servers:
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