From e469ab0cc01aa8a8c23b1ed6794b03b34e8f9b52 Mon Sep 17 00:00:00 2001 From: ericzakariasson Date: Fri, 13 Feb 2026 12:14:48 -0500 Subject: [PATCH 01/47] Remove cursor target and add plugin metadata --- .cursor-plugin/marketplace.json | 25 ++ README.md | 17 +- .../coding-tutor/.cursor-plugin/plugin.json | 21 ++ .../.cursor-plugin/plugin.json | 31 ++ plugins/compound-engineering/.mcp.json | 8 + src/converters/claude-to-cursor.ts | 166 --------- src/sync/cursor.ts | 78 ---- src/targets/cursor.ts | 48 --- src/types/cursor.ts | 29 -- tests/cursor-converter.test.ts | 347 ------------------ tests/cursor-writer.test.ts | 137 ------- tests/sync-cursor.test.ts | 92 ----- 12 files changed, 93 insertions(+), 906 deletions(-) create mode 100644 .cursor-plugin/marketplace.json create mode 100644 plugins/coding-tutor/.cursor-plugin/plugin.json create mode 100644 plugins/compound-engineering/.cursor-plugin/plugin.json create mode 100644 plugins/compound-engineering/.mcp.json delete mode 100644 src/converters/claude-to-cursor.ts delete mode 100644 src/sync/cursor.ts delete mode 100644 src/targets/cursor.ts delete mode 100644 src/types/cursor.ts delete mode 100644 tests/cursor-converter.test.ts delete mode 100644 tests/cursor-writer.test.ts delete mode 100644 tests/sync-cursor.test.ts diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..e9adfaa --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,25 @@ +{ + "name": "compound-engineering", + "owner": { + "name": "Kieran Klaassen", + "email": "kieran@every.to", + "url": "https://github.com/kieranklaassen" + }, + "metadata": { + "description": "Cursor plugin marketplace for Every Inc plugins", + "version": "1.0.0", + "pluginRoot": "plugins" + }, + "plugins": [ + { + "name": "compound-engineering", + "source": "compound-engineering", + "description": "AI-powered development tools that get smarter with every use. Includes specialized agents, commands, skills, and Context7 MCP." + }, + { + "name": "coding-tutor", + "source": "coding-tutor", + "description": "Personalized coding tutorials with spaced repetition quizzes using your real codebase." + } + ] +} diff --git a/README.md b/README.md index 11bfe93..7e867ab 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,15 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** /plugin install compound-engineering ``` -## OpenCode, Codex, Droid, Cursor & Pi (experimental) Install +## Cursor Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, and Pi. +```text +/add-plugin compound-engineering +``` + +## OpenCode, Codex, Droid & Pi (experimental) Install + +This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid and Pi. ```bash # convert the compound-engineering plugin into OpenCode format @@ -26,9 +32,6 @@ bunx @every-env/compound-plugin install compound-engineering --to codex # convert to Factory Droid format bunx @every-env/compound-plugin install compound-engineering --to droid -# convert to Cursor format -bunx @every-env/compound-plugin install compound-engineering --to cursor - # convert to Pi format bunx @every-env/compound-plugin install compound-engineering --to pi ``` @@ -42,7 +45,6 @@ bun run src/index.ts install ./plugins/compound-engineering --to opencode OpenCode output is written to `~/.config/opencode` by default, with `opencode.json` at the root and `agents/`, `skills/`, and `plugins/` alongside it. Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit). Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash` → `Execute`, `Write` → `Create`, etc.) and namespace prefixes are stripped from commands. -Cursor output is written to `.cursor/` with rules (`.mdc`), commands, skills, and `mcp.json`. Agents become "Agent Requested" rules (`alwaysApply: false`) so Cursor's AI activates them on demand. Works with both the Cursor IDE and Cursor CLI (`cursor-agent`) — they share the same `.cursor/` config directory. Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability. All provider targets are experimental and may change as the formats evolve. @@ -63,9 +65,6 @@ bunx @every-env/compound-plugin sync --target pi # Sync to Droid (skills only) bunx @every-env/compound-plugin sync --target droid - -# Sync to Cursor (skills + MCP servers) -bunx @every-env/compound-plugin sync --target cursor ``` This syncs: diff --git a/plugins/coding-tutor/.cursor-plugin/plugin.json b/plugins/coding-tutor/.cursor-plugin/plugin.json new file mode 100644 index 0000000..dc5e6c0 --- /dev/null +++ b/plugins/coding-tutor/.cursor-plugin/plugin.json @@ -0,0 +1,21 @@ +{ + "name": "coding-tutor", + "displayName": "Coding Tutor", + "version": "1.2.1", + "description": "Personalized coding tutorials that use your actual codebase for examples with spaced repetition quizzes", + "author": { + "name": "Nityesh Agarwal" + }, + "homepage": "https://github.com/EveryInc/compound-engineering-plugin", + "repository": "https://github.com/EveryInc/compound-engineering-plugin", + "license": "MIT", + "keywords": [ + "cursor", + "plugin", + "coding", + "programming", + "tutorial", + "learning", + "spaced-repetition" + ] +} diff --git a/plugins/compound-engineering/.cursor-plugin/plugin.json b/plugins/compound-engineering/.cursor-plugin/plugin.json new file mode 100644 index 0000000..e8bcb63 --- /dev/null +++ b/plugins/compound-engineering/.cursor-plugin/plugin.json @@ -0,0 +1,31 @@ +{ + "name": "compound-engineering", + "displayName": "Compound Engineering", + "version": "2.33.0", + "description": "AI-powered development tools. 29 agents, 22 commands, 19 skills, 1 MCP server for code review, research, design, and workflow automation.", + "author": { + "name": "Kieran Klaassen", + "email": "kieran@every.to", + "url": "https://github.com/kieranklaassen" + }, + "homepage": "https://every.to/source-code/my-ai-had-already-fixed-the-code-before-i-saw-it", + "repository": "https://github.com/EveryInc/compound-engineering-plugin", + "license": "MIT", + "keywords": [ + "cursor", + "plugin", + "ai-powered", + "compound-engineering", + "workflow-automation", + "code-review", + "rails", + "ruby", + "python", + "typescript", + "knowledge-management", + "image-generation", + "agent-browser", + "browser-automation" + ], + "mcpServers": ".mcp.json" +} diff --git a/plugins/compound-engineering/.mcp.json b/plugins/compound-engineering/.mcp.json new file mode 100644 index 0000000..c5280c5 --- /dev/null +++ b/plugins/compound-engineering/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp" + } + } +} diff --git a/src/converters/claude-to-cursor.ts b/src/converters/claude-to-cursor.ts deleted file mode 100644 index d6100d8..0000000 --- a/src/converters/claude-to-cursor.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { formatFrontmatter } from "../utils/frontmatter" -import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" -import type { CursorBundle, CursorCommand, CursorMcpServer, CursorRule } from "../types/cursor" -import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" - -export type ClaudeToCursorOptions = ClaudeToOpenCodeOptions - -export function convertClaudeToCursor( - plugin: ClaudePlugin, - _options: ClaudeToCursorOptions, -): CursorBundle { - const usedRuleNames = new Set() - const usedCommandNames = new Set() - - const rules = plugin.agents.map((agent) => convertAgentToRule(agent, usedRuleNames)) - const commands = plugin.commands.map((command) => convertCommand(command, usedCommandNames)) - const skillDirs = plugin.skills.map((skill) => ({ - name: skill.name, - sourceDir: skill.sourceDir, - })) - - const mcpServers = convertMcpServers(plugin.mcpServers) - - if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) { - console.warn("Warning: Cursor does not support hooks. Hooks were skipped during conversion.") - } - - return { rules, commands, skillDirs, mcpServers } -} - -function convertAgentToRule(agent: ClaudeAgent, usedNames: Set): CursorRule { - const name = uniqueName(normalizeName(agent.name), usedNames) - const description = agent.description ?? `Converted from Claude agent ${agent.name}` - - const frontmatter: Record = { - description, - alwaysApply: false, - } - - let body = transformContentForCursor(agent.body.trim()) - 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(frontmatter, body) - return { name, content } -} - -function convertCommand(command: ClaudeCommand, usedNames: Set): CursorCommand { - const name = uniqueName(flattenCommandName(command.name), usedNames) - - const sections: string[] = [] - - if (command.description) { - sections.push(``) - } - - if (command.argumentHint) { - sections.push(`## Arguments\n${command.argumentHint}`) - } - - const transformedBody = transformContentForCursor(command.body.trim()) - sections.push(transformedBody) - - const content = sections.filter(Boolean).join("\n\n").trim() - return { name, content } -} - -/** - * Transform Claude Code content to Cursor-compatible content. - * - * 1. Task agent calls: Task agent-name(args) -> Use the agent-name skill to: args - * 2. Slash commands: /workflows:plan -> /plan (flatten namespace) - * 3. Path rewriting: .claude/ -> .cursor/ - * 4. Agent references: @agent-name -> the agent-name rule - */ -export function transformContentForCursor(body: string): string { - let result = body - - // 1. Transform Task agent calls - const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm - result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => { - const skillName = normalizeName(agentName) - return `${prefix}Use the ${skillName} skill to: ${args.trim()}` - }) - - // 2. Transform slash command references (flatten namespaces) - const slashCommandPattern = /(? { - if (commandName.includes("/")) return match - if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match - const flattened = flattenCommandName(commandName) - return `/${flattened}` - }) - - // 3. Rewrite .claude/ paths to .cursor/ - result = result - .replace(/~\/\.claude\//g, "~/.cursor/") - .replace(/\.claude\//g, ".cursor/") - - // 4. Transform @agent-name references - const agentRefPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi - result = result.replace(agentRefPattern, (_match, agentName: string) => { - return `the ${normalizeName(agentName)} rule` - }) - - return result -} - -function convertMcpServers( - servers?: Record, -): Record | undefined { - if (!servers || Object.keys(servers).length === 0) return undefined - - const result: Record = {} - for (const [name, server] of Object.entries(servers)) { - const entry: CursorMcpServer = {} - if (server.command) { - entry.command = server.command - if (server.args && server.args.length > 0) entry.args = server.args - if (server.env && Object.keys(server.env).length > 0) entry.env = server.env - } else if (server.url) { - entry.url = server.url - if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers - } - result[name] = entry - } - return result -} - -function flattenCommandName(name: string): string { - const colonIndex = name.lastIndexOf(":") - const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name - return normalizeName(base) -} - -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" -} - -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/sync/cursor.ts b/src/sync/cursor.ts deleted file mode 100644 index 32f3aa4..0000000 --- a/src/sync/cursor.ts +++ /dev/null @@ -1,78 +0,0 @@ -import fs from "fs/promises" -import path from "path" -import type { ClaudeHomeConfig } from "../parsers/claude-home" -import type { ClaudeMcpServer } from "../types/claude" -import { forceSymlink, isValidSkillName } from "../utils/symlink" - -type CursorMcpServer = { - command?: string - args?: string[] - url?: string - env?: Record - headers?: Record -} - -type CursorMcpConfig = { - mcpServers: Record -} - -export async function syncToCursor( - config: ClaudeHomeConfig, - outputRoot: string, -): Promise { - const skillsDir = path.join(outputRoot, "skills") - await fs.mkdir(skillsDir, { recursive: true }) - - for (const skill of config.skills) { - if (!isValidSkillName(skill.name)) { - console.warn(`Skipping skill with invalid name: ${skill.name}`) - continue - } - const target = path.join(skillsDir, skill.name) - await forceSymlink(skill.sourceDir, target) - } - - if (Object.keys(config.mcpServers).length > 0) { - const mcpPath = path.join(outputRoot, "mcp.json") - const existing = await readJsonSafe(mcpPath) - const converted = convertMcpForCursor(config.mcpServers) - const merged: CursorMcpConfig = { - mcpServers: { - ...(existing.mcpServers ?? {}), - ...converted, - }, - } - await fs.writeFile(mcpPath, JSON.stringify(merged, null, 2), { mode: 0o600 }) - } -} - -async function readJsonSafe(filePath: string): Promise> { - try { - const content = await fs.readFile(filePath, "utf-8") - return JSON.parse(content) as Partial - } catch (err) { - if ((err as NodeJS.ErrnoException).code === "ENOENT") { - return {} - } - throw err - } -} - -function convertMcpForCursor( - servers: Record, -): Record { - const result: Record = {} - for (const [name, server] of Object.entries(servers)) { - const entry: CursorMcpServer = {} - if (server.command) { - entry.command = server.command - if (server.args && server.args.length > 0) entry.args = server.args - if (server.env && Object.keys(server.env).length > 0) entry.env = server.env - } else if (server.url) { - entry.url = server.url - if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers - } - result[name] = entry - } - return result -} diff --git a/src/targets/cursor.ts b/src/targets/cursor.ts deleted file mode 100644 index dd9c123..0000000 --- a/src/targets/cursor.ts +++ /dev/null @@ -1,48 +0,0 @@ -import path from "path" -import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files" -import type { CursorBundle } from "../types/cursor" - -export async function writeCursorBundle(outputRoot: string, bundle: CursorBundle): Promise { - const paths = resolveCursorPaths(outputRoot) - await ensureDir(paths.cursorDir) - - if (bundle.rules.length > 0) { - const rulesDir = path.join(paths.cursorDir, "rules") - for (const rule of bundle.rules) { - await writeText(path.join(rulesDir, `${rule.name}.mdc`), rule.content + "\n") - } - } - - if (bundle.commands.length > 0) { - const commandsDir = path.join(paths.cursorDir, "commands") - for (const command of bundle.commands) { - await writeText(path.join(commandsDir, `${command.name}.md`), command.content + "\n") - } - } - - if (bundle.skillDirs.length > 0) { - const skillsDir = path.join(paths.cursorDir, "skills") - for (const skill of bundle.skillDirs) { - await copyDir(skill.sourceDir, path.join(skillsDir, skill.name)) - } - } - - if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) { - const mcpPath = path.join(paths.cursorDir, "mcp.json") - const backupPath = await backupFile(mcpPath) - if (backupPath) { - console.log(`Backed up existing mcp.json to ${backupPath}`) - } - await writeJson(mcpPath, { mcpServers: bundle.mcpServers }) - } -} - -function resolveCursorPaths(outputRoot: string) { - const base = path.basename(outputRoot) - // If already pointing at .cursor, write directly into it - if (base === ".cursor") { - return { cursorDir: outputRoot } - } - // Otherwise nest under .cursor - return { cursorDir: path.join(outputRoot, ".cursor") } -} diff --git a/src/types/cursor.ts b/src/types/cursor.ts deleted file mode 100644 index fc88828..0000000 --- a/src/types/cursor.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type CursorRule = { - name: string - content: string -} - -export type CursorCommand = { - name: string - content: string -} - -export type CursorSkillDir = { - name: string - sourceDir: string -} - -export type CursorMcpServer = { - command?: string - args?: string[] - env?: Record - url?: string - headers?: Record -} - -export type CursorBundle = { - rules: CursorRule[] - commands: CursorCommand[] - skillDirs: CursorSkillDir[] - mcpServers?: Record -} diff --git a/tests/cursor-converter.test.ts b/tests/cursor-converter.test.ts deleted file mode 100644 index 9e3adaf..0000000 --- a/tests/cursor-converter.test.ts +++ /dev/null @@ -1,347 +0,0 @@ -import { describe, expect, test, spyOn } from "bun:test" -import { convertClaudeToCursor, transformContentForCursor } from "../src/converters/claude-to-cursor" -import { parseFrontmatter } from "../src/utils/frontmatter" -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 code review 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: undefined, -} - -const defaultOptions = { - agentMode: "subagent" as const, - inferTemperature: false, - permissions: "none" as const, -} - -describe("convertClaudeToCursor", () => { - test("converts agents to rules with .mdc frontmatter", () => { - const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions) - - expect(bundle.rules).toHaveLength(1) - const rule = bundle.rules[0] - expect(rule.name).toBe("security-reviewer") - - const parsed = parseFrontmatter(rule.content) - expect(parsed.data.description).toBe("Security-focused code review agent") - expect(parsed.data.alwaysApply).toBe(false) - // globs is omitted (Agent Requested mode doesn't need it) - expect(parsed.body).toContain("Capabilities") - expect(parsed.body).toContain("Threat modeling") - expect(parsed.body).toContain("Focus on vulnerabilities.") - }) - - test("agent with empty description gets default", () => { - const plugin: ClaudePlugin = { - ...fixturePlugin, - agents: [ - { - name: "basic-agent", - body: "Do things.", - sourcePath: "/tmp/plugin/agents/basic.md", - }, - ], - } - - const bundle = convertClaudeToCursor(plugin, defaultOptions) - const parsed = parseFrontmatter(bundle.rules[0].content) - expect(parsed.data.description).toBe("Converted from Claude agent basic-agent") - }) - - test("agent with empty body gets default body", () => { - const plugin: ClaudePlugin = { - ...fixturePlugin, - agents: [ - { - name: "empty-agent", - description: "Empty agent", - body: "", - sourcePath: "/tmp/plugin/agents/empty.md", - }, - ], - } - - const bundle = convertClaudeToCursor(plugin, defaultOptions) - const parsed = parseFrontmatter(bundle.rules[0].content) - expect(parsed.body).toContain("Instructions converted from the empty-agent agent.") - }) - - test("agent capabilities are prepended to body", () => { - const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions) - const parsed = parseFrontmatter(bundle.rules[0].content) - expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/) - }) - - test("agent model field is silently dropped", () => { - const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions) - const parsed = parseFrontmatter(bundle.rules[0].content) - expect(parsed.data.model).toBeUndefined() - }) - - test("flattens namespaced command names", () => { - const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions) - - expect(bundle.commands).toHaveLength(1) - const command = bundle.commands[0] - expect(command.name).toBe("plan") - }) - - test("commands are plain markdown without frontmatter", () => { - const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions) - const command = bundle.commands[0] - - // Should NOT start with --- - expect(command.content.startsWith("---")).toBe(false) - // Should include the description as a comment - expect(command.content).toContain("") - expect(command.content).toContain("Plan the work.") - }) - - test("command name collision after flattening is deduplicated", () => { - const plugin: ClaudePlugin = { - ...fixturePlugin, - commands: [ - { - name: "workflows:plan", - description: "Workflow plan", - body: "Plan body.", - sourcePath: "/tmp/plugin/commands/workflows/plan.md", - }, - { - name: "plan", - description: "Top-level plan", - body: "Top plan body.", - sourcePath: "/tmp/plugin/commands/plan.md", - }, - ], - agents: [], - skills: [], - } - - const bundle = convertClaudeToCursor(plugin, defaultOptions) - const names = bundle.commands.map((c) => c.name) - expect(names).toEqual(["plan", "plan-2"]) - }) - - test("command with disable-model-invocation is still included", () => { - const plugin: ClaudePlugin = { - ...fixturePlugin, - commands: [ - { - name: "setup", - description: "Setup command", - disableModelInvocation: true, - body: "Setup body.", - sourcePath: "/tmp/plugin/commands/setup.md", - }, - ], - agents: [], - skills: [], - } - - const bundle = convertClaudeToCursor(plugin, defaultOptions) - expect(bundle.commands).toHaveLength(1) - expect(bundle.commands[0].name).toBe("setup") - }) - - test("command allowedTools is silently dropped", () => { - const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions) - const command = bundle.commands[0] - expect(command.content).not.toContain("allowedTools") - expect(command.content).not.toContain("Read") - }) - - test("command with argument-hint gets Arguments section", () => { - const bundle = convertClaudeToCursor(fixturePlugin, defaultOptions) - const command = bundle.commands[0] - expect(command.content).toContain("## Arguments") - expect(command.content).toContain("[FOCUS]") - }) - - test("passes through skill directories", () => { - const bundle = convertClaudeToCursor(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("converts MCP servers to JSON config", () => { - const plugin: ClaudePlugin = { - ...fixturePlugin, - agents: [], - commands: [], - skills: [], - mcpServers: { - playwright: { - command: "npx", - args: ["-y", "@anthropic/mcp-playwright"], - env: { DISPLAY: ":0" }, - }, - }, - } - - const bundle = convertClaudeToCursor(plugin, defaultOptions) - expect(bundle.mcpServers).toBeDefined() - expect(bundle.mcpServers!.playwright.command).toBe("npx") - expect(bundle.mcpServers!.playwright.args).toEqual(["-y", "@anthropic/mcp-playwright"]) - expect(bundle.mcpServers!.playwright.env).toEqual({ DISPLAY: ":0" }) - }) - - test("MCP headers pass through for remote servers", () => { - const plugin: ClaudePlugin = { - ...fixturePlugin, - agents: [], - commands: [], - skills: [], - mcpServers: { - remote: { - url: "https://mcp.example.com/sse", - headers: { Authorization: "Bearer token" }, - }, - }, - } - - const bundle = convertClaudeToCursor(plugin, defaultOptions) - expect(bundle.mcpServers!.remote.url).toBe("https://mcp.example.com/sse") - expect(bundle.mcpServers!.remote.headers).toEqual({ Authorization: "Bearer token" }) - }) - - test("warns when hooks are present", () => { - const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) - - const plugin: ClaudePlugin = { - ...fixturePlugin, - agents: [], - commands: [], - skills: [], - hooks: { - hooks: { - PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }], - }, - }, - } - - convertClaudeToCursor(plugin, defaultOptions) - expect(warnSpy).toHaveBeenCalledWith( - "Warning: Cursor does not support hooks. Hooks were skipped during conversion.", - ) - - warnSpy.mockRestore() - }) - - test("no warning when hooks are absent", () => { - const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) - - convertClaudeToCursor(fixturePlugin, defaultOptions) - expect(warnSpy).not.toHaveBeenCalled() - - warnSpy.mockRestore() - }) - - test("plugin with zero agents produces empty rules array", () => { - const plugin: ClaudePlugin = { - ...fixturePlugin, - agents: [], - } - - const bundle = convertClaudeToCursor(plugin, defaultOptions) - expect(bundle.rules).toHaveLength(0) - }) - - test("plugin with only skills works", () => { - const plugin: ClaudePlugin = { - ...fixturePlugin, - agents: [], - commands: [], - } - - const bundle = convertClaudeToCursor(plugin, defaultOptions) - expect(bundle.rules).toHaveLength(0) - expect(bundle.commands).toHaveLength(0) - expect(bundle.skillDirs).toHaveLength(1) - }) -}) - -describe("transformContentForCursor", () => { - test("rewrites .claude/ paths to .cursor/", () => { - const input = "Read `.claude/compound-engineering.local.md` for config." - const result = transformContentForCursor(input) - expect(result).toContain(".cursor/compound-engineering.local.md") - expect(result).not.toContain(".claude/") - }) - - test("rewrites ~/.claude/ paths to ~/.cursor/", () => { - const input = "Global config at ~/.claude/settings.json" - const result = transformContentForCursor(input) - expect(result).toContain("~/.cursor/settings.json") - expect(result).not.toContain("~/.claude/") - }) - - test("transforms Task agent calls to skill references", () => { - const input = `Run agents: - -- Task repo-research-analyst(feature_description) -- Task learnings-researcher(feature_description) - -Task best-practices-researcher(topic)` - - const result = transformContentForCursor(input) - expect(result).toContain("Use the repo-research-analyst skill to: feature_description") - expect(result).toContain("Use the learnings-researcher skill to: feature_description") - expect(result).toContain("Use the best-practices-researcher skill to: topic") - expect(result).not.toContain("Task repo-research-analyst(") - }) - - test("flattens slash commands", () => { - const input = `1. Run /deepen-plan to enhance -2. Start /workflows:work to implement -3. File at /tmp/output.md` - - const result = transformContentForCursor(input) - expect(result).toContain("/deepen-plan") - expect(result).toContain("/work") - expect(result).not.toContain("/workflows:work") - // File paths preserved - expect(result).toContain("/tmp/output.md") - }) - - test("transforms @agent references to rule references", () => { - const input = "Have @security-sentinel and @dhh-rails-reviewer check the code." - const result = transformContentForCursor(input) - expect(result).toContain("the security-sentinel rule") - expect(result).toContain("the dhh-rails-reviewer rule") - expect(result).not.toContain("@security-sentinel") - }) -}) diff --git a/tests/cursor-writer.test.ts b/tests/cursor-writer.test.ts deleted file mode 100644 index 111af02..0000000 --- a/tests/cursor-writer.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { promises as fs } from "fs" -import path from "path" -import os from "os" -import { writeCursorBundle } from "../src/targets/cursor" -import type { CursorBundle } from "../src/types/cursor" - -async function exists(filePath: string): Promise { - try { - await fs.access(filePath) - return true - } catch { - return false - } -} - -describe("writeCursorBundle", () => { - test("writes rules, commands, skills, and mcp.json", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-test-")) - const bundle: CursorBundle = { - rules: [{ name: "security-reviewer", content: "---\ndescription: Security\nglobs: \"\"\nalwaysApply: false\n---\n\nReview code." }], - commands: [{ name: "plan", content: "\n\nPlan the work." }], - skillDirs: [ - { - name: "skill-one", - sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), - }, - ], - mcpServers: { - playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] }, - }, - } - - await writeCursorBundle(tempRoot, bundle) - - expect(await exists(path.join(tempRoot, ".cursor", "rules", "security-reviewer.mdc"))).toBe(true) - expect(await exists(path.join(tempRoot, ".cursor", "commands", "plan.md"))).toBe(true) - expect(await exists(path.join(tempRoot, ".cursor", "skills", "skill-one", "SKILL.md"))).toBe(true) - expect(await exists(path.join(tempRoot, ".cursor", "mcp.json"))).toBe(true) - - const ruleContent = await fs.readFile( - path.join(tempRoot, ".cursor", "rules", "security-reviewer.mdc"), - "utf8", - ) - expect(ruleContent).toContain("Review code.") - - const commandContent = await fs.readFile( - path.join(tempRoot, ".cursor", "commands", "plan.md"), - "utf8", - ) - expect(commandContent).toContain("Plan the work.") - - const mcpContent = JSON.parse( - await fs.readFile(path.join(tempRoot, ".cursor", "mcp.json"), "utf8"), - ) - expect(mcpContent.mcpServers.playwright.command).toBe("npx") - }) - - test("writes directly into a .cursor output root without double-nesting", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-home-")) - const cursorRoot = path.join(tempRoot, ".cursor") - const bundle: CursorBundle = { - rules: [{ name: "reviewer", content: "Reviewer rule content" }], - commands: [{ name: "plan", content: "Plan content" }], - skillDirs: [], - } - - await writeCursorBundle(cursorRoot, bundle) - - expect(await exists(path.join(cursorRoot, "rules", "reviewer.mdc"))).toBe(true) - expect(await exists(path.join(cursorRoot, "commands", "plan.md"))).toBe(true) - // Should NOT double-nest under .cursor/.cursor - expect(await exists(path.join(cursorRoot, ".cursor"))).toBe(false) - }) - - test("handles empty bundles gracefully", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-empty-")) - const bundle: CursorBundle = { - rules: [], - commands: [], - skillDirs: [], - } - - await writeCursorBundle(tempRoot, bundle) - expect(await exists(tempRoot)).toBe(true) - }) - - test("writes multiple rules as separate .mdc files", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-multi-")) - const cursorRoot = path.join(tempRoot, ".cursor") - const bundle: CursorBundle = { - rules: [ - { name: "security-sentinel", content: "Security rules" }, - { name: "performance-oracle", content: "Performance rules" }, - { name: "code-simplicity-reviewer", content: "Simplicity rules" }, - ], - commands: [], - skillDirs: [], - } - - await writeCursorBundle(cursorRoot, bundle) - - expect(await exists(path.join(cursorRoot, "rules", "security-sentinel.mdc"))).toBe(true) - expect(await exists(path.join(cursorRoot, "rules", "performance-oracle.mdc"))).toBe(true) - expect(await exists(path.join(cursorRoot, "rules", "code-simplicity-reviewer.mdc"))).toBe(true) - }) - - test("backs up existing mcp.json before overwriting", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cursor-backup-")) - const cursorRoot = path.join(tempRoot, ".cursor") - await fs.mkdir(cursorRoot, { recursive: true }) - - // Write an existing mcp.json - const mcpPath = path.join(cursorRoot, "mcp.json") - await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } })) - - const bundle: CursorBundle = { - rules: [], - commands: [], - skillDirs: [], - mcpServers: { - newServer: { command: "new-cmd" }, - }, - } - - await writeCursorBundle(cursorRoot, bundle) - - // New mcp.json should have the new content - const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8")) - expect(newContent.mcpServers.newServer.command).toBe("new-cmd") - - // A backup file should exist - const files = await fs.readdir(cursorRoot) - const backupFiles = files.filter((f) => f.startsWith("mcp.json.bak.")) - expect(backupFiles.length).toBeGreaterThanOrEqual(1) - }) -}) diff --git a/tests/sync-cursor.test.ts b/tests/sync-cursor.test.ts deleted file mode 100644 index e314d28..0000000 --- a/tests/sync-cursor.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { promises as fs } from "fs" -import path from "path" -import os from "os" -import { syncToCursor } from "../src/sync/cursor" -import type { ClaudeHomeConfig } from "../src/parsers/claude-home" - -describe("syncToCursor", () => { - test("symlinks skills and writes mcp.json", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-cursor-")) - const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") - - const config: ClaudeHomeConfig = { - skills: [ - { - name: "skill-one", - sourceDir: fixtureSkillDir, - skillPath: path.join(fixtureSkillDir, "SKILL.md"), - }, - ], - mcpServers: { - context7: { url: "https://mcp.context7.com/mcp" }, - local: { command: "echo", args: ["hello"], env: { FOO: "bar" } }, - }, - } - - await syncToCursor(config, tempRoot) - - // Check skill symlink - const linkedSkillPath = path.join(tempRoot, "skills", "skill-one") - const linkedStat = await fs.lstat(linkedSkillPath) - expect(linkedStat.isSymbolicLink()).toBe(true) - - // Check mcp.json - const mcpPath = path.join(tempRoot, "mcp.json") - const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as { - mcpServers: Record }> - } - - expect(mcpConfig.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") - expect(mcpConfig.mcpServers.local?.command).toBe("echo") - expect(mcpConfig.mcpServers.local?.args).toEqual(["hello"]) - expect(mcpConfig.mcpServers.local?.env).toEqual({ FOO: "bar" }) - }) - - test("merges existing mcp.json", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-cursor-merge-")) - const mcpPath = path.join(tempRoot, "mcp.json") - - await fs.writeFile( - mcpPath, - JSON.stringify({ mcpServers: { existing: { command: "node", args: ["server.js"] } } }, null, 2), - ) - - const config: ClaudeHomeConfig = { - skills: [], - mcpServers: { - context7: { url: "https://mcp.context7.com/mcp" }, - }, - } - - await syncToCursor(config, tempRoot) - - const merged = JSON.parse(await fs.readFile(mcpPath, "utf8")) as { - mcpServers: Record - } - - expect(merged.mcpServers.existing?.command).toBe("node") - expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") - }) - - test("does not write mcp.json when no MCP servers", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-cursor-nomcp-")) - const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") - - const config: ClaudeHomeConfig = { - skills: [ - { - name: "skill-one", - sourceDir: fixtureSkillDir, - skillPath: path.join(fixtureSkillDir, "SKILL.md"), - }, - ], - mcpServers: {}, - } - - await syncToCursor(config, tempRoot) - - const mcpExists = await fs.access(path.join(tempRoot, "mcp.json")).then(() => true).catch(() => false) - expect(mcpExists).toBe(false) - }) -}) From 4f7c598f27b11dfc5a33c925ff32b08b6c9b1b91 Mon Sep 17 00:00:00 2001 From: Brayan Jules Date: Sun, 15 Feb 2026 00:14:40 -0300 Subject: [PATCH 02/47] feat: Add GitHub Copilot converter target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Copilot as the 6th converter target, transforming Claude Code plugins into Copilot's native format: custom agents (.agent.md), agent skills (SKILL.md), and MCP server configuration JSON. Component mapping: - Agents → .github/agents/{name}.agent.md (with Copilot frontmatter) - Commands → .github/skills/{name}/SKILL.md - Skills → .github/skills/{name}/ (copied as-is) - MCP servers → .github/copilot-mcp-config.json - Hooks → skipped with warning Also adds `compound sync copilot` support and fixes YAML quoting for the `*` character in frontmatter serialization. Co-Authored-By: Claude Opus 4.6 --- README.md | 11 +- bun.lock | 1 + ...-14-copilot-converter-target-brainstorm.md | 117 +++++ ...-feat-add-copilot-converter-target-plan.md | 328 +++++++++++++ docs/specs/copilot.md | 122 +++++ src/commands/sync.ts | 12 +- src/converters/claude-to-copilot.ts | 212 +++++++++ src/sync/copilot.ts | 100 ++++ src/targets/copilot.ts | 48 ++ src/targets/index.ts | 9 + src/types/copilot.ts | 31 ++ src/utils/frontmatter.ts | 2 +- tests/copilot-converter.test.ts | 441 ++++++++++++++++++ tests/copilot-writer.test.ts | 189 ++++++++ tests/sync-copilot.test.ts | 148 ++++++ 15 files changed, 1765 insertions(+), 6 deletions(-) create mode 100644 docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md create mode 100644 docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md create mode 100644 docs/specs/copilot.md create mode 100644 src/converters/claude-to-copilot.ts create mode 100644 src/sync/copilot.ts create mode 100644 src/targets/copilot.ts create mode 100644 src/types/copilot.ts create mode 100644 tests/copilot-converter.test.ts create mode 100644 tests/copilot-writer.test.ts create mode 100644 tests/sync-copilot.test.ts diff --git a/README.md b/README.md index 11bfe93..4fc3cd7 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** /plugin install compound-engineering ``` -## OpenCode, Codex, Droid, Cursor & Pi (experimental) Install +## OpenCode, Codex, Droid, Cursor, Pi & Copilot (experimental) Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, and Pi. +This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, Pi, and GitHub Copilot. ```bash # convert the compound-engineering plugin into OpenCode format @@ -31,6 +31,9 @@ bunx @every-env/compound-plugin install compound-engineering --to cursor # convert to Pi format bunx @every-env/compound-plugin install compound-engineering --to pi + +# convert to GitHub Copilot format +bunx @every-env/compound-plugin install compound-engineering --to copilot ``` Local dev: @@ -44,6 +47,7 @@ Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each C Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash` → `Execute`, `Write` → `Create`, etc.) and namespace prefixes are stripped from commands. Cursor output is written to `.cursor/` with rules (`.mdc`), commands, skills, and `mcp.json`. Agents become "Agent Requested" rules (`alwaysApply: false`) so Cursor's AI activates them on demand. Works with both the Cursor IDE and Cursor CLI (`cursor-agent`) — they share the same `.cursor/` config directory. Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability. +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_`. All provider targets are experimental and may change as the formats evolve. @@ -66,6 +70,9 @@ bunx @every-env/compound-plugin sync --target droid # Sync to Cursor (skills + MCP servers) bunx @every-env/compound-plugin sync --target cursor + +# Sync to GitHub Copilot (skills + MCP servers) +bunx @every-env/compound-plugin sync --target copilot ``` This syncs: diff --git a/bun.lock b/bun.lock index 26361fc..3a07728 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "compound-plugin", diff --git a/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md b/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md new file mode 100644 index 0000000..9bdec41 --- /dev/null +++ b/docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md @@ -0,0 +1,117 @@ +--- +date: 2026-02-14 +topic: copilot-converter-target +--- + +# Add GitHub Copilot Converter Target + +## What We're Building + +A new converter target that transforms the compound-engineering Claude Code plugin into GitHub Copilot's native format. This follows the same established pattern as the existing converters (Cursor, Codex, OpenCode, Droid, Pi) and outputs files that Copilot can consume directly from `.github/` (repo-level) or `~/.copilot/` (user-wide). + +Copilot's customization system (as of early 2026) supports: custom agents (`.agent.md`), agent skills (`SKILL.md`), prompt files (`.prompt.md`), custom instructions (`copilot-instructions.md`), and MCP servers (via repo settings). + +## Why This Approach + +The repository already has a robust multi-target converter infrastructure with a consistent `TargetHandler` pattern. Adding Copilot as a new target follows this proven pattern rather than inventing something new. Copilot's format is close enough to Claude Code's that the conversion is straightforward, and the SKILL.md format is already cross-compatible. + +### Approaches Considered + +1. **Full converter target (chosen)** — Follow the existing pattern with types, converter, writer, and target registration. Most consistent with codebase conventions. +2. **Minimal agent-only converter** — Only convert agents, skip commands/skills. Too limited; users would lose most of the plugin's value. +3. **Documentation-only approach** — Just document how to manually set up Copilot. Doesn't compound — every user would repeat the work. + +## Key Decisions + +### Component Mapping + +| Claude Code Component | Copilot Equivalent | Notes | +|----------------------|-------------------|-------| +| **Agents** (`.md`) | **Custom Agents** (`.agent.md`) | Full frontmatter mapping: description, tools, target, infer | +| **Commands** (`.md`) | **Agent Skills** (`SKILL.md`) | Commands become skills since Copilot has no direct command equivalent. `allowed-tools` dropped silently. | +| **Skills** (`SKILL.md`) | **Agent Skills** (`SKILL.md`) | Copy as-is — format is already cross-compatible | +| **MCP Servers** | **Repo settings JSON** | Generate a `copilot-mcp-config.json` users paste into GitHub repo settings | +| **Hooks** | **Skipped with warning** | Copilot doesn't have a hooks equivalent | + +### Agent Frontmatter Mapping + +| Claude Field | Copilot Field | Mapping | +|-------------|--------------|---------| +| `name` | `name` | Direct pass-through | +| `description` | `description` (required) | Direct pass-through, generate fallback if missing | +| `capabilities` | Body text | Fold into body as "## Capabilities" section (like Cursor) | +| `model` | `model` | Pass through (works in IDE, may be ignored on github.com) | +| — | `tools` | Default to `["*"]` (all tools). Claude agents have unrestricted tool access, so Copilot agents should too. | +| — | `target` | Omit (defaults to `both` — IDE + github.com) | +| — | `infer` | Set to `true` (auto-selection enabled) | + +### Output Directories + +- **Repository-level (default):** `.github/agents/`, `.github/skills/` +- **User-wide (with --personal flag):** `~/.copilot/skills/` (only skills supported at this level) + +### Content Transformation + +Apply transformations similar to Cursor converter: + +1. **Task agent calls:** `Task agent-name(args)` → `Use the agent-name skill to: args` +2. **Slash commands:** `/workflows:plan` → `/plan` (flatten namespace) +3. **Path rewriting:** `.claude/` → `.github/` (Copilot's repo-level config path) +4. **Agent references:** `@agent-name` → `the agent-name agent` + +### MCP Server Handling + +Generate a `copilot-mcp-config.json` file with the structure Copilot expects: + +```json +{ + "mcpServers": { + "server-name": { + "type": "local", + "command": "npx", + "args": ["package"], + "tools": ["*"], + "env": { + "KEY": "COPILOT_MCP_KEY" + } + } + } +} +``` + +Note: Copilot requires env vars to use the `COPILOT_MCP_` prefix. The converter should transform env var names accordingly and include a comment/note about this. + +## Files to Create/Modify + +### New Files + +- `src/types/copilot.ts` — Type definitions (CopilotAgent, CopilotSkill, CopilotBundle, etc.) +- `src/converters/claude-to-copilot.ts` — Converter with `transformContentForCopilot()` +- `src/targets/copilot.ts` — Writer with `writeCopilotBundle()` +- `docs/specs/copilot.md` — Format specification document + +### Modified Files + +- `src/targets/index.ts` — Register copilot target handler +- `src/commands/sync.ts` — Add "copilot" to valid sync targets + +### Test Files + +- `tests/copilot-converter.test.ts` — Converter tests following existing patterns + +### Character Limit + +Copilot imposes a 30,000 character limit on agent body content. If an agent body exceeds this after folding in capabilities, the converter should truncate with a warning to stderr. + +### Agent File Extension + +Use `.agent.md` (not plain `.md`). This is the canonical Copilot convention and makes agent files immediately identifiable. + +## Open Questions + +- Should the converter generate a `copilot-setup-steps.yml` workflow file for MCP servers that need special dependencies (e.g., `uv`, `pipx`)? +- Should `.github/copilot-instructions.md` be generated with any base instructions from the plugin? + +## Next Steps + +→ `/workflows:plan` for implementation details diff --git a/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md b/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md new file mode 100644 index 0000000..a87d0bd --- /dev/null +++ b/docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md @@ -0,0 +1,328 @@ +--- +title: "feat: Add GitHub Copilot converter target" +type: feat +date: 2026-02-14 +status: complete +--- + +# feat: Add GitHub Copilot Converter Target + +## Overview + +Add GitHub Copilot as a converter target following the established `TargetHandler` pattern. This converts the compound-engineering Claude Code plugin into Copilot's native format: custom agents (`.agent.md`), agent skills (`SKILL.md`), and MCP server configuration JSON. + +**Brainstorm:** `docs/brainstorms/2026-02-14-copilot-converter-target-brainstorm.md` + +## Problem Statement + +The CLI tool (`compound`) already supports converting Claude Code plugins to 5 target formats (OpenCode, Codex, Droid, Cursor, Pi). GitHub Copilot is a widely-used AI coding assistant that now supports custom agents, skills, and MCP servers — but there's no converter target for it. + +## Proposed Solution + +Follow the existing converter pattern exactly: + +1. Define types (`src/types/copilot.ts`) +2. Implement converter (`src/converters/claude-to-copilot.ts`) +3. Implement writer (`src/targets/copilot.ts`) +4. Register target (`src/targets/index.ts`) +5. Add sync support (`src/sync/copilot.ts`, `src/commands/sync.ts`) +6. Write tests and documentation + +### Component Mapping + +| Claude Code | Copilot | Output Path | +|-------------|---------|-------------| +| Agents (`.md`) | Custom Agents (`.agent.md`) | `.github/agents/{name}.agent.md` | +| Commands (`.md`) | Agent Skills (`SKILL.md`) | `.github/skills/{name}/SKILL.md` | +| Skills (`SKILL.md`) | Agent Skills (`SKILL.md`) | `.github/skills/{name}/SKILL.md` | +| MCP Servers | Config JSON | `.github/copilot-mcp-config.json` | +| Hooks | Skipped | Warning to stderr | + +## Technical Approach + +### Phase 1: Types + +**File:** `src/types/copilot.ts` + +```typescript +export type CopilotAgent = { + name: string + content: string // Full .agent.md content with frontmatter +} + +export type CopilotGeneratedSkill = { + name: string + content: string // SKILL.md content with frontmatter +} + +export type CopilotSkillDir = { + name: string + sourceDir: string +} + +export type CopilotMcpServer = { + type: string + command?: string + args?: string[] + url?: string + tools: string[] + env?: Record + headers?: Record +} + +export type CopilotBundle = { + agents: CopilotAgent[] + generatedSkills: CopilotGeneratedSkill[] + skillDirs: CopilotSkillDir[] + mcpConfig?: Record +} +``` + +### Phase 2: Converter + +**File:** `src/converters/claude-to-copilot.ts` + +**Agent conversion:** +- Frontmatter: `description` (required, fallback to `"Converted from Claude agent {name}"`), `tools: ["*"]`, `infer: true` +- Pass through `model` if present +- Fold `capabilities` into body as `## Capabilities` section (same as Cursor) +- Use `formatFrontmatter()` utility +- Warn if body exceeds 30,000 characters (`.length`) + +**Command → Skill conversion:** +- Convert to SKILL.md format with frontmatter: `name`, `description` +- Flatten namespaced names: `workflows:plan` → `plan` +- Drop `allowed-tools`, `model`, `disable-model-invocation` silently +- Include `argument-hint` as `## Arguments` section in body + +**Skill pass-through:** +- Map to `CopilotSkillDir` as-is (same as Cursor) + +**MCP server conversion:** +- Transform env var names: `API_KEY` → `COPILOT_MCP_API_KEY` +- Skip vars already prefixed with `COPILOT_MCP_` +- Add `type: "local"` for command-based servers, `type: "sse"` for URL-based +- Set `tools: ["*"]` for all servers + +**Content transformation (`transformContentForCopilot`):** + +| Pattern | Input | Output | +|---------|-------|--------| +| Task calls | `Task repo-research-analyst(desc)` | `Use the repo-research-analyst skill to: desc` | +| Slash commands | `/workflows:plan` | `/plan` | +| Path rewriting | `.claude/` | `.github/` | +| Home path rewriting | `~/.claude/` | `~/.copilot/` | +| Agent references | `@security-sentinel` | `the security-sentinel agent` | + +**Hooks:** Warn to stderr if present, skip. + +### Phase 3: Writer + +**File:** `src/targets/copilot.ts` + +**Path resolution:** +- If `outputRoot` basename is `.github`, write directly into it (avoid `.github/.github/` double-nesting) +- Otherwise, nest under `.github/` + +**Write operations:** +- Agents → `.github/agents/{name}.agent.md` (note: `.agent.md` extension) +- Generated skills (from commands) → `.github/skills/{name}/SKILL.md` +- Skill dirs → `.github/skills/{name}/` (copy via `copyDir`) +- MCP config → `.github/copilot-mcp-config.json` (backup existing with `backupFile`) + +### Phase 4: Target Registration + +**File:** `src/targets/index.ts` + +Add import and register: + +```typescript +import { convertClaudeToCopilot } from "../converters/claude-to-copilot" +import { writeCopilotBundle } from "./copilot" + +// In targets record: +copilot: { + name: "copilot", + implemented: true, + convert: convertClaudeToCopilot as TargetHandler["convert"], + write: writeCopilotBundle as TargetHandler["write"], +}, +``` + +### Phase 5: Sync Support + +**File:** `src/sync/copilot.ts` + +Follow the Cursor sync pattern (`src/sync/cursor.ts`): +- Symlink skills to `.github/skills/` using `forceSymlink` +- Validate skill names with `isValidSkillName` +- Convert MCP servers with `COPILOT_MCP_` prefix transformation +- Merge MCP config into existing `.github/copilot-mcp-config.json` + +**File:** `src/commands/sync.ts` + +- Add `"copilot"` to `validTargets` array +- Add case in `resolveOutputRoot()`: `case "copilot": return path.join(process.cwd(), ".github")` +- Add import and switch case for `syncToCopilot` +- Update meta description to include "Copilot" + +### Phase 6: Tests + +**File:** `tests/copilot-converter.test.ts` + +Test cases (following `tests/cursor-converter.test.ts` pattern): + +``` +describe("convertClaudeToCopilot") + ✓ converts agents to .agent.md with Copilot frontmatter + ✓ agent description is required, fallback generated if missing + ✓ agent with empty body gets default body + ✓ agent capabilities are prepended to body + ✓ agent model field is passed through + ✓ agent tools defaults to ["*"] + ✓ agent infer defaults to true + ✓ warns when agent body exceeds 30k characters + ✓ converts commands to skills with SKILL.md format + ✓ flattens namespaced command names + ✓ command name collision after flattening is deduplicated + ✓ command allowedTools is silently dropped + ✓ command with argument-hint gets Arguments section + ✓ passes through skill directories + ✓ skill and generated skill name collision is deduplicated + ✓ converts MCP servers with COPILOT_MCP_ prefix + ✓ MCP env vars already prefixed are not double-prefixed + ✓ MCP servers get type field (local vs sse) + ✓ warns when hooks are present + ✓ no warning when hooks are absent + ✓ plugin with zero agents produces empty agents array + ✓ plugin with only skills works + +describe("transformContentForCopilot") + ✓ rewrites .claude/ paths to .github/ + ✓ rewrites ~/.claude/ paths to ~/.copilot/ + ✓ transforms Task agent calls to skill references + ✓ flattens slash commands + ✓ transforms @agent references to agent references +``` + +**File:** `tests/copilot-writer.test.ts` + +Test cases (following `tests/cursor-writer.test.ts` pattern): + +``` +describe("writeCopilotBundle") + ✓ writes agents, generated skills, copied skills, and MCP config + ✓ agents use .agent.md file extension + ✓ writes directly into .github output root without double-nesting + ✓ handles empty bundles gracefully + ✓ writes multiple agents as separate .agent.md files + ✓ backs up existing copilot-mcp-config.json before overwriting + ✓ creates skill directories with SKILL.md +``` + +**File:** `tests/sync-copilot.test.ts` + +Test cases (following `tests/sync-cursor.test.ts` pattern): + +``` +describe("syncToCopilot") + ✓ symlinks skills to .github/skills/ + ✓ skips skills with invalid names + ✓ merges MCP config with existing file + ✓ transforms MCP env var names to COPILOT_MCP_ prefix + ✓ writes MCP config with restricted permissions (0o600) +``` + +### Phase 7: Documentation + +**File:** `docs/specs/copilot.md` + +Follow `docs/specs/cursor.md` format: +- Last verified date +- Primary sources (GitHub Docs URLs) +- Config locations table +- Agents section (`.agent.md` format, frontmatter fields) +- Skills section (`SKILL.md` format) +- MCP section (config structure, env var prefix requirement) +- Character limits (30k agent body) + +**File:** `README.md` + +- Add "copilot" to the list of supported targets +- Add usage example: `compound convert --to copilot ./plugins/compound-engineering` +- Add sync example: `compound sync copilot` + +## Acceptance Criteria + +### Converter +- [x] Agents convert to `.agent.md` with `description`, `tools: ["*"]`, `infer: true` +- [x] Agent `model` passes through when present +- [x] Agent `capabilities` fold into body as `## Capabilities` +- [x] Missing description generates fallback +- [x] Empty body generates fallback +- [x] Body exceeding 30k chars triggers stderr warning +- [x] Commands convert to SKILL.md format +- [x] Command names flatten (`workflows:plan` → `plan`) +- [x] Name collisions deduplicated with `-2`, `-3` suffix +- [x] Command `allowed-tools` dropped silently +- [x] Skills pass through as `CopilotSkillDir` +- [x] MCP env vars prefixed with `COPILOT_MCP_` +- [x] Already-prefixed env vars not double-prefixed +- [x] MCP servers get `type` field (`local` or `sse`) +- [x] Hooks trigger warning, skip conversion +- [x] Content transformation: Task calls, slash commands, paths, @agent refs + +### Writer +- [x] Agents written to `.github/agents/{name}.agent.md` +- [x] Generated skills written to `.github/skills/{name}/SKILL.md` +- [x] Skill dirs copied to `.github/skills/{name}/` +- [x] MCP config written to `.github/copilot-mcp-config.json` +- [x] Existing MCP config backed up before overwrite +- [x] No double-nesting when outputRoot is `.github` +- [x] Empty bundles handled gracefully + +### CLI Integration +- [x] `compound convert --to copilot` works +- [x] `compound sync copilot` works +- [x] Copilot registered in `src/targets/index.ts` +- [x] Sync resolves output to `.github/` in current directory + +### Tests +- [x] `tests/copilot-converter.test.ts` — all converter tests pass +- [x] `tests/copilot-writer.test.ts` — all writer tests pass +- [x] `tests/sync-copilot.test.ts` — all sync tests pass + +### Documentation +- [x] `docs/specs/copilot.md` — format specification +- [x] `README.md` — updated with copilot target + +## Files to Create + +| File | Purpose | +|------|---------| +| `src/types/copilot.ts` | Type definitions | +| `src/converters/claude-to-copilot.ts` | Converter logic | +| `src/targets/copilot.ts` | Writer logic | +| `src/sync/copilot.ts` | Sync handler | +| `tests/copilot-converter.test.ts` | Converter tests | +| `tests/copilot-writer.test.ts` | Writer tests | +| `tests/sync-copilot.test.ts` | Sync tests | +| `docs/specs/copilot.md` | Format specification | + +## Files to Modify + +| File | Change | +|------|--------| +| `src/targets/index.ts` | Register copilot target | +| `src/commands/sync.ts` | Add copilot to valid targets, output root, switch case | +| `README.md` | Add copilot to supported targets | + +## References + +- [Custom agents configuration - GitHub Docs](https://docs.github.com/en/copilot/reference/custom-agents-configuration) +- [About Agent Skills - GitHub Docs](https://docs.github.com/en/copilot/concepts/agents/about-agent-skills) +- [MCP and coding agent - GitHub Docs](https://docs.github.com/en/copilot/concepts/agents/coding-agent/mcp-and-coding-agent) +- Existing converter: `src/converters/claude-to-cursor.ts` +- Existing writer: `src/targets/cursor.ts` +- Existing sync: `src/sync/cursor.ts` +- Existing tests: `tests/cursor-converter.test.ts`, `tests/cursor-writer.test.ts` diff --git a/docs/specs/copilot.md b/docs/specs/copilot.md new file mode 100644 index 0000000..bee2990 --- /dev/null +++ b/docs/specs/copilot.md @@ -0,0 +1,122 @@ +# GitHub Copilot Spec (Agents, Skills, MCP) + +Last verified: 2026-02-14 + +## Primary sources + +``` +https://docs.github.com/en/copilot/reference/custom-agents-configuration +https://docs.github.com/en/copilot/concepts/agents/about-agent-skills +https://docs.github.com/en/copilot/concepts/agents/coding-agent/mcp-and-coding-agent +``` + +## Config locations + +| Scope | Path | +|-------|------| +| Project agents | `.github/agents/*.agent.md` | +| Project skills | `.github/skills/*/SKILL.md` | +| Project instructions | `.github/copilot-instructions.md` | +| Path-specific instructions | `.github/instructions/*.instructions.md` | +| Project prompts | `.github/prompts/*.prompt.md` | +| Org/enterprise agents | `.github-private/agents/*.agent.md` | +| Personal skills | `~/.copilot/skills/*/SKILL.md` | +| Directory instructions | `AGENTS.md` (nearest ancestor wins) | + +## Agents (.agent.md files) + +- Custom agents are Markdown files with YAML frontmatter stored in `.github/agents/`. +- File extension is `.agent.md` (or `.md`). Filenames may only contain: `.`, `-`, `_`, `a-z`, `A-Z`, `0-9`. +- `description` is the only required frontmatter field. + +### Frontmatter fields + +| Field | Required | Default | Description | +|-------|----------|---------|-------------| +| `name` | No | Derived from filename | Display name | +| `description` | **Yes** | — | What the agent does | +| `tools` | No | `["*"]` | Tool access list. `[]` disables all tools. | +| `target` | No | both | `vscode`, `github-copilot`, or omit for both | +| `infer` | No | `true` | Auto-select based on task context | +| `model` | No | Platform default | AI model (works in IDE, may be ignored on github.com) | +| `mcp-servers` | No | — | MCP config (org/enterprise agents only) | +| `metadata` | No | — | Arbitrary key-value annotations | + +### Character limit + +Agent body content is limited to **30,000 characters**. + +### Tool names + +| Name | Aliases | Purpose | +|------|---------|---------| +| `execute` | `shell`, `Bash` | Run shell commands | +| `read` | `Read` | Read files | +| `edit` | `Edit`, `Write` | Modify files | +| `search` | `Grep`, `Glob` | Search files | +| `agent` | `Task` | Invoke other agents | +| `web` | `WebSearch`, `WebFetch` | Web access | + +## Skills (SKILL.md) + +- Skills follow the open SKILL.md standard (same format as Claude Code and Cursor). +- A skill is a directory containing `SKILL.md` plus optional `scripts/`, `references/`, and `assets/`. +- YAML frontmatter requires `name` and `description` fields. +- Skills are loaded on-demand when Copilot determines relevance. + +### Discovery locations + +| Scope | Path | +|-------|------| +| Project | `.github/skills/*/SKILL.md` | +| Project (Claude-compatible) | `.claude/skills/*/SKILL.md` | +| Project (auto-discovery) | `.agents/skills/*/SKILL.md` | +| Personal | `~/.copilot/skills/*/SKILL.md` | + +## MCP (Model Context Protocol) + +- MCP configuration is set via **Repository Settings > Copilot > Coding agent > MCP configuration** on GitHub. +- Repository-level agents **cannot** define MCP servers inline; use repository settings instead. +- Org/enterprise agents can embed MCP server definitions in frontmatter. +- All env var names must use the `COPILOT_MCP_` prefix. +- Only MCP tools are supported (not resources or prompts). + +### Config structure + +```json +{ + "mcpServers": { + "server-name": { + "type": "local", + "command": "npx", + "args": ["package"], + "tools": ["*"], + "env": { + "API_KEY": "COPILOT_MCP_API_KEY" + } + } + } +} +``` + +### Server types + +| Type | Fields | +|------|--------| +| Local/stdio | `type: "local"`, `command`, `args`, `tools`, `env` | +| Remote/SSE | `type: "sse"`, `url`, `tools`, `headers` | + +## Prompts (.prompt.md) + +- Reusable prompt files stored in `.github/prompts/`. +- Available in VS Code, Visual Studio, and JetBrains IDEs only (not on github.com). +- Invoked via `/promptname` in chat. +- Support variable syntax: `${input:name}`, `${file}`, `${selection}`. + +## Precedence + +1. Repository-level agents +2. Organization-level agents (`.github-private`) +3. Enterprise-level agents (`.github-private`) + +Within a repo, `AGENTS.md` files in directories provide nearest-ancestor-wins instructions. diff --git a/src/commands/sync.ts b/src/commands/sync.ts index e5b576e..f453704 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -7,9 +7,10 @@ import { syncToCodex } from "../sync/codex" import { syncToPi } from "../sync/pi" import { syncToDroid } from "../sync/droid" import { syncToCursor } from "../sync/cursor" +import { syncToCopilot } from "../sync/copilot" import { expandHome } from "../utils/resolve-home" -const validTargets = ["opencode", "codex", "pi", "droid", "cursor"] as const +const validTargets = ["opencode", "codex", "pi", "droid", "cursor", "copilot"] as const type SyncTarget = (typeof validTargets)[number] function isValidTarget(value: string): value is SyncTarget { @@ -42,19 +43,21 @@ function resolveOutputRoot(target: SyncTarget): string { return path.join(os.homedir(), ".factory") case "cursor": return path.join(process.cwd(), ".cursor") + case "copilot": + return path.join(process.cwd(), ".github") } } export default defineCommand({ meta: { name: "sync", - description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, or Cursor", + description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, Cursor, or Copilot", }, args: { target: { type: "string", required: true, - description: "Target: opencode | codex | pi | droid | cursor", + description: "Target: opencode | codex | pi | droid | cursor | copilot", }, claudeHome: { type: "string", @@ -100,6 +103,9 @@ export default defineCommand({ case "cursor": await syncToCursor(config, outputRoot) break + case "copilot": + await syncToCopilot(config, outputRoot) + break } console.log(`✓ Synced to ${args.target}: ${outputRoot}`) diff --git a/src/converters/claude-to-copilot.ts b/src/converters/claude-to-copilot.ts new file mode 100644 index 0000000..510bfa9 --- /dev/null +++ b/src/converters/claude-to-copilot.ts @@ -0,0 +1,212 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" +import type { + CopilotAgent, + CopilotBundle, + CopilotGeneratedSkill, + CopilotMcpServer, +} from "../types/copilot" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export type ClaudeToCopilotOptions = ClaudeToOpenCodeOptions + +const COPILOT_BODY_CHAR_LIMIT = 30_000 + +export function convertClaudeToCopilot( + plugin: ClaudePlugin, + _options: ClaudeToCopilotOptions, +): CopilotBundle { + const usedAgentNames = new Set() + const usedSkillNames = new Set() + + const agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames)) + + // Reserve skill names first so generated skills (from commands) don't collide + const skillDirs = plugin.skills.map((skill) => { + usedSkillNames.add(skill.name) + return { + name: skill.name, + sourceDir: skill.sourceDir, + } + }) + + const generatedSkills = plugin.commands.map((command) => + convertCommandToSkill(command, usedSkillNames), + ) + + const mcpConfig = convertMcpServers(plugin.mcpServers) + + if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) { + console.warn("Warning: Copilot does not support hooks. Hooks were skipped during conversion.") + } + + return { agents, generatedSkills, skillDirs, mcpConfig } +} + +function convertAgent(agent: ClaudeAgent, usedNames: Set): CopilotAgent { + const name = uniqueName(normalizeName(agent.name), usedNames) + const description = agent.description ?? `Converted from Claude agent ${agent.name}` + + const frontmatter: Record = { + description, + tools: ["*"], + infer: true, + } + + if (agent.model) { + frontmatter.model = agent.model + } + + let body = transformContentForCopilot(agent.body.trim()) + 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.` + } + + if (body.length > COPILOT_BODY_CHAR_LIMIT) { + console.warn( + `Warning: Agent "${agent.name}" body exceeds ${COPILOT_BODY_CHAR_LIMIT} characters (${body.length}). Copilot may truncate it.`, + ) + } + + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +function convertCommandToSkill( + command: ClaudeCommand, + usedNames: Set, +): CopilotGeneratedSkill { + const name = uniqueName(flattenCommandName(command.name), usedNames) + + const frontmatter: Record = { + name, + } + if (command.description) { + frontmatter.description = command.description + } + + const sections: string[] = [] + + if (command.argumentHint) { + sections.push(`## Arguments\n${command.argumentHint}`) + } + + const transformedBody = transformContentForCopilot(command.body.trim()) + sections.push(transformedBody) + + const body = sections.filter(Boolean).join("\n\n").trim() + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +export function transformContentForCopilot(body: string): string { + let result = body + + // 1. Transform Task agent calls + const taskPattern = /^(\s*-?\s*)Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm + result = result.replace(taskPattern, (_match, prefix: string, agentName: string, args: string) => { + const skillName = normalizeName(agentName) + return `${prefix}Use the ${skillName} skill to: ${args.trim()}` + }) + + // 2. Transform slash command references (flatten namespaces) + const slashCommandPattern = /(? { + if (commandName.includes("/")) return match + if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match + const flattened = flattenCommandName(commandName) + return `/${flattened}` + }) + + // 3. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/ + result = result + .replace(/~\/\.claude\//g, "~/.copilot/") + .replace(/\.claude\//g, ".github/") + + // 4. Transform @agent-name references + const agentRefPattern = + /@([a-z][a-z0-9-]*-(?:agent|reviewer|researcher|analyst|specialist|oracle|sentinel|guardian|strategist))/gi + result = result.replace(agentRefPattern, (_match, agentName: string) => { + return `the ${normalizeName(agentName)} agent` + }) + + return result +} + +function convertMcpServers( + servers?: Record, +): Record | undefined { + if (!servers || Object.keys(servers).length === 0) return undefined + + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + const entry: CopilotMcpServer = { + type: server.command ? "local" : "sse", + tools: ["*"], + } + + if (server.command) { + entry.command = server.command + if (server.args && server.args.length > 0) entry.args = server.args + } else if (server.url) { + entry.url = 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 = prefixEnvVars(server.env) + } + + result[name] = entry + } + return result +} + +function prefixEnvVars(env: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(env)) { + if (key.startsWith("COPILOT_MCP_")) { + result[key] = value + } else { + result[`COPILOT_MCP_${key}`] = value + } + } + return result +} + +function flattenCommandName(name: string): string { + const colonIndex = name.lastIndexOf(":") + const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name + return normalizeName(base) +} + +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" +} + +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/sync/copilot.ts b/src/sync/copilot.ts new file mode 100644 index 0000000..b4eccdc --- /dev/null +++ b/src/sync/copilot.ts @@ -0,0 +1,100 @@ +import fs from "fs/promises" +import path from "path" +import type { ClaudeHomeConfig } from "../parsers/claude-home" +import type { ClaudeMcpServer } from "../types/claude" +import { forceSymlink, isValidSkillName } from "../utils/symlink" + +type CopilotMcpServer = { + type: string + command?: string + args?: string[] + url?: string + tools: string[] + env?: Record + headers?: Record +} + +type CopilotMcpConfig = { + mcpServers: Record +} + +export async function syncToCopilot( + config: ClaudeHomeConfig, + outputRoot: string, +): Promise { + const skillsDir = path.join(outputRoot, "skills") + await fs.mkdir(skillsDir, { recursive: true }) + + for (const skill of config.skills) { + if (!isValidSkillName(skill.name)) { + console.warn(`Skipping skill with invalid name: ${skill.name}`) + continue + } + const target = path.join(skillsDir, skill.name) + await forceSymlink(skill.sourceDir, target) + } + + if (Object.keys(config.mcpServers).length > 0) { + const mcpPath = path.join(outputRoot, "copilot-mcp-config.json") + const existing = await readJsonSafe(mcpPath) + const converted = convertMcpForCopilot(config.mcpServers) + const merged: CopilotMcpConfig = { + mcpServers: { + ...(existing.mcpServers ?? {}), + ...converted, + }, + } + await fs.writeFile(mcpPath, JSON.stringify(merged, null, 2), { mode: 0o600 }) + } +} + +async function readJsonSafe(filePath: string): Promise> { + try { + const content = await fs.readFile(filePath, "utf-8") + return JSON.parse(content) as Partial + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + return {} + } + throw err + } +} + +function convertMcpForCopilot( + servers: Record, +): Record { + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + const entry: CopilotMcpServer = { + type: server.command ? "local" : "sse", + tools: ["*"], + } + + if (server.command) { + entry.command = server.command + if (server.args && server.args.length > 0) entry.args = server.args + } else if (server.url) { + entry.url = 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 = prefixEnvVars(server.env) + } + + result[name] = entry + } + return result +} + +function prefixEnvVars(env: Record): Record { + const result: Record = {} + for (const [key, value] of Object.entries(env)) { + if (key.startsWith("COPILOT_MCP_")) { + result[key] = value + } else { + result[`COPILOT_MCP_${key}`] = value + } + } + return result +} diff --git a/src/targets/copilot.ts b/src/targets/copilot.ts new file mode 100644 index 0000000..d0d1b1c --- /dev/null +++ b/src/targets/copilot.ts @@ -0,0 +1,48 @@ +import path from "path" +import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files" +import type { CopilotBundle } from "../types/copilot" + +export async function writeCopilotBundle(outputRoot: string, bundle: CopilotBundle): Promise { + const paths = resolveCopilotPaths(outputRoot) + await ensureDir(paths.githubDir) + + if (bundle.agents.length > 0) { + const agentsDir = path.join(paths.githubDir, "agents") + for (const agent of bundle.agents) { + await writeText(path.join(agentsDir, `${agent.name}.agent.md`), agent.content + "\n") + } + } + + if (bundle.generatedSkills.length > 0) { + const skillsDir = path.join(paths.githubDir, "skills") + for (const skill of bundle.generatedSkills) { + await writeText(path.join(skillsDir, skill.name, "SKILL.md"), skill.content + "\n") + } + } + + if (bundle.skillDirs.length > 0) { + const skillsDir = path.join(paths.githubDir, "skills") + for (const skill of bundle.skillDirs) { + await copyDir(skill.sourceDir, path.join(skillsDir, skill.name)) + } + } + + if (bundle.mcpConfig && Object.keys(bundle.mcpConfig).length > 0) { + const mcpPath = path.join(paths.githubDir, "copilot-mcp-config.json") + const backupPath = await backupFile(mcpPath) + if (backupPath) { + console.log(`Backed up existing copilot-mcp-config.json to ${backupPath}`) + } + await writeJson(mcpPath, { mcpServers: bundle.mcpConfig }) + } +} + +function resolveCopilotPaths(outputRoot: string) { + const base = path.basename(outputRoot) + // If already pointing at .github, write directly into it + if (base === ".github") { + return { githubDir: outputRoot } + } + // Otherwise nest under .github + return { githubDir: path.join(outputRoot, ".github") } +} diff --git a/src/targets/index.ts b/src/targets/index.ts index 3e60631..00af611 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -4,16 +4,19 @@ import type { CodexBundle } from "../types/codex" import type { DroidBundle } from "../types/droid" import type { CursorBundle } from "../types/cursor" import type { PiBundle } from "../types/pi" +import type { CopilotBundle } from "../types/copilot" import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" import { convertClaudeToCodex } from "../converters/claude-to-codex" import { convertClaudeToDroid } from "../converters/claude-to-droid" import { convertClaudeToCursor } from "../converters/claude-to-cursor" import { convertClaudeToPi } from "../converters/claude-to-pi" +import { convertClaudeToCopilot } from "../converters/claude-to-copilot" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" import { writeDroidBundle } from "./droid" import { writeCursorBundle } from "./cursor" import { writePiBundle } from "./pi" +import { writeCopilotBundle } from "./copilot" export type TargetHandler = { name: string @@ -53,4 +56,10 @@ export const targets: Record = { convert: convertClaudeToPi as TargetHandler["convert"], write: writePiBundle as TargetHandler["write"], }, + copilot: { + name: "copilot", + implemented: true, + convert: convertClaudeToCopilot as TargetHandler["convert"], + write: writeCopilotBundle as TargetHandler["write"], + }, } diff --git a/src/types/copilot.ts b/src/types/copilot.ts new file mode 100644 index 0000000..8d1ae12 --- /dev/null +++ b/src/types/copilot.ts @@ -0,0 +1,31 @@ +export type CopilotAgent = { + name: string + content: string +} + +export type CopilotGeneratedSkill = { + name: string + content: string +} + +export type CopilotSkillDir = { + name: string + sourceDir: string +} + +export type CopilotMcpServer = { + type: string + command?: string + args?: string[] + url?: string + tools: string[] + env?: Record + headers?: Record +} + +export type CopilotBundle = { + agents: CopilotAgent[] + generatedSkills: CopilotGeneratedSkill[] + skillDirs: CopilotSkillDir[] + mcpConfig?: Record +} diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts index a799c94..dfe85bf 100644 --- a/src/utils/frontmatter.ts +++ b/src/utils/frontmatter.ts @@ -58,7 +58,7 @@ function formatYamlValue(value: unknown): string { if (raw.includes("\n")) { return `|\n${raw.split("\n").map((line) => ` ${line}`).join("\n")}` } - if (raw.includes(":") || raw.startsWith("[") || raw.startsWith("{")) { + if (raw.includes(":") || raw.startsWith("[") || raw.startsWith("{") || raw === "*") { return JSON.stringify(raw) } return raw diff --git a/tests/copilot-converter.test.ts b/tests/copilot-converter.test.ts new file mode 100644 index 0000000..bbb37bd --- /dev/null +++ b/tests/copilot-converter.test.ts @@ -0,0 +1,441 @@ +import { describe, expect, test, spyOn } from "bun:test" +import { convertClaudeToCopilot, transformContentForCopilot } from "../src/converters/claude-to-copilot" +import { parseFrontmatter } from "../src/utils/frontmatter" +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 code review 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: undefined, +} + +const defaultOptions = { + agentMode: "subagent" as const, + inferTemperature: false, + permissions: "none" as const, +} + +describe("convertClaudeToCopilot", () => { + test("converts agents to .agent.md with Copilot frontmatter", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + + expect(bundle.agents).toHaveLength(1) + const agent = bundle.agents[0] + expect(agent.name).toBe("security-reviewer") + + const parsed = parseFrontmatter(agent.content) + expect(parsed.data.description).toBe("Security-focused code review agent") + expect(parsed.data.tools).toEqual(["*"]) + expect(parsed.data.infer).toBe(true) + expect(parsed.body).toContain("Capabilities") + expect(parsed.body).toContain("Threat modeling") + expect(parsed.body).toContain("Focus on vulnerabilities.") + }) + + test("agent description is required, fallback generated if missing", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "basic-agent", + body: "Do things.", + sourcePath: "/tmp/plugin/agents/basic.md", + }, + ], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.description).toBe("Converted from Claude agent basic-agent") + }) + + test("agent with empty body gets default body", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "empty-agent", + description: "Empty agent", + body: "", + sourcePath: "/tmp/plugin/agents/empty.md", + }, + ], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.body).toContain("Instructions converted from the empty-agent agent.") + }) + + test("agent capabilities are prepended to body", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.body).toMatch(/## Capabilities\n- Threat modeling\n- OWASP/) + }) + + test("agent model field is passed through", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBe("claude-sonnet-4-20250514") + }) + + test("agent without model omits model field", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "no-model", + description: "No model agent", + body: "Content.", + sourcePath: "/tmp/plugin/agents/no-model.md", + }, + ], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBeUndefined() + }) + + test("agent tools defaults to [*]", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.tools).toEqual(["*"]) + }) + + test("agent infer defaults to true", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.infer).toBe(true) + }) + + test("warns when agent body exceeds 30k characters", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "large-agent", + description: "Large agent", + body: "x".repeat(31_000), + sourcePath: "/tmp/plugin/agents/large.md", + }, + ], + commands: [], + skills: [], + } + + convertClaudeToCopilot(plugin, defaultOptions) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("exceeds 30000 characters"), + ) + + warnSpy.mockRestore() + }) + + test("converts commands to skills with SKILL.md format", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + + expect(bundle.generatedSkills).toHaveLength(1) + const skill = bundle.generatedSkills[0] + expect(skill.name).toBe("plan") + + const parsed = parseFrontmatter(skill.content) + expect(parsed.data.name).toBe("plan") + expect(parsed.data.description).toBe("Planning command") + expect(parsed.body).toContain("Plan the work.") + }) + + test("flattens namespaced command names", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + expect(bundle.generatedSkills[0].name).toBe("plan") + }) + + test("command name collision after flattening is deduplicated", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "workflows:plan", + description: "Workflow plan", + body: "Plan body.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + { + name: "plan", + description: "Top-level plan", + body: "Top plan body.", + sourcePath: "/tmp/plugin/commands/plan.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const names = bundle.generatedSkills.map((s) => s.name) + expect(names).toEqual(["plan", "plan-2"]) + }) + + test("command allowedTools is silently dropped", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const skill = bundle.generatedSkills[0] + expect(skill.content).not.toContain("allowedTools") + expect(skill.content).not.toContain("allowed-tools") + }) + + test("command with argument-hint gets Arguments section", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + const skill = bundle.generatedSkills[0] + expect(skill.content).toContain("## Arguments") + expect(skill.content).toContain("[FOCUS]") + }) + + test("passes through skill directories", () => { + const bundle = convertClaudeToCopilot(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("skill and generated skill name collision is deduplicated", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "existing-skill", + description: "Colliding command", + body: "This collides with skill name.", + sourcePath: "/tmp/plugin/commands/existing-skill.md", + }, + ], + agents: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + // The command should get deduplicated since the skill name is reserved + expect(bundle.generatedSkills[0].name).toBe("existing-skill-2") + expect(bundle.skillDirs[0].name).toBe("existing-skill") + }) + + test("converts MCP servers with COPILOT_MCP_ prefix", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + mcpServers: { + playwright: { + command: "npx", + args: ["-y", "@anthropic/mcp-playwright"], + env: { DISPLAY: ":0", API_KEY: "secret" }, + }, + }, + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.mcpConfig).toBeDefined() + expect(bundle.mcpConfig!.playwright.type).toBe("local") + expect(bundle.mcpConfig!.playwright.command).toBe("npx") + expect(bundle.mcpConfig!.playwright.args).toEqual(["-y", "@anthropic/mcp-playwright"]) + expect(bundle.mcpConfig!.playwright.tools).toEqual(["*"]) + expect(bundle.mcpConfig!.playwright.env).toEqual({ + COPILOT_MCP_DISPLAY: ":0", + COPILOT_MCP_API_KEY: "secret", + }) + }) + + test("MCP env vars already prefixed are not double-prefixed", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + mcpServers: { + server: { + command: "node", + args: ["server.js"], + env: { COPILOT_MCP_TOKEN: "abc" }, + }, + }, + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.mcpConfig!.server.env).toEqual({ COPILOT_MCP_TOKEN: "abc" }) + }) + + test("MCP servers get type field (local vs sse)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + mcpServers: { + local: { command: "npx", args: ["server"] }, + remote: { url: "https://mcp.example.com/sse" }, + }, + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.mcpConfig!.local.type).toBe("local") + expect(bundle.mcpConfig!.remote.type).toBe("sse") + }) + + test("MCP headers pass through for remote servers", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + mcpServers: { + remote: { + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer token" }, + }, + }, + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.mcpConfig!.remote.url).toBe("https://mcp.example.com/sse") + expect(bundle.mcpConfig!.remote.headers).toEqual({ Authorization: "Bearer token" }) + }) + + test("warns when hooks are present", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + hooks: { + hooks: { + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo test" }] }], + }, + }, + } + + convertClaudeToCopilot(plugin, defaultOptions) + expect(warnSpy).toHaveBeenCalledWith( + "Warning: Copilot does not support hooks. Hooks were skipped during conversion.", + ) + + warnSpy.mockRestore() + }) + + test("no warning when hooks are absent", () => { + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}) + + convertClaudeToCopilot(fixturePlugin, defaultOptions) + expect(warnSpy).not.toHaveBeenCalled() + + warnSpy.mockRestore() + }) + + test("plugin with zero agents produces empty agents array", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + }) + + test("plugin with only skills works", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + expect(bundle.generatedSkills).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(1) + }) +}) + +describe("transformContentForCopilot", () => { + test("rewrites .claude/ paths to .github/", () => { + const input = "Read `.claude/compound-engineering.local.md` for config." + const result = transformContentForCopilot(input) + expect(result).toContain(".github/compound-engineering.local.md") + expect(result).not.toContain(".claude/") + }) + + test("rewrites ~/.claude/ paths to ~/.copilot/", () => { + const input = "Global config at ~/.claude/settings.json" + const result = transformContentForCopilot(input) + expect(result).toContain("~/.copilot/settings.json") + expect(result).not.toContain("~/.claude/") + }) + + test("transforms Task agent calls to skill references", () => { + const input = `Run agents: + +- Task repo-research-analyst(feature_description) +- Task learnings-researcher(feature_description) + +Task best-practices-researcher(topic)` + + const result = transformContentForCopilot(input) + expect(result).toContain("Use the repo-research-analyst skill to: feature_description") + expect(result).toContain("Use the learnings-researcher skill to: feature_description") + expect(result).toContain("Use the best-practices-researcher skill to: topic") + expect(result).not.toContain("Task repo-research-analyst(") + }) + + test("flattens slash commands", () => { + const input = `1. Run /deepen-plan to enhance +2. Start /workflows:work to implement +3. File at /tmp/output.md` + + const result = transformContentForCopilot(input) + expect(result).toContain("/deepen-plan") + expect(result).toContain("/work") + expect(result).not.toContain("/workflows:work") + // File paths preserved + expect(result).toContain("/tmp/output.md") + }) + + test("transforms @agent references to agent references", () => { + const input = "Have @security-sentinel and @dhh-rails-reviewer check the code." + const result = transformContentForCopilot(input) + expect(result).toContain("the security-sentinel agent") + expect(result).toContain("the dhh-rails-reviewer agent") + expect(result).not.toContain("@security-sentinel") + }) +}) diff --git a/tests/copilot-writer.test.ts b/tests/copilot-writer.test.ts new file mode 100644 index 0000000..6c430a1 --- /dev/null +++ b/tests/copilot-writer.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writeCopilotBundle } from "../src/targets/copilot" +import type { CopilotBundle } from "../src/types/copilot" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +describe("writeCopilotBundle", () => { + test("writes agents, generated skills, copied skills, and MCP config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-test-")) + const bundle: CopilotBundle = { + agents: [ + { + name: "security-reviewer", + content: "---\ndescription: Security\ntools:\n - '*'\ninfer: true\n---\n\nReview code.", + }, + ], + generatedSkills: [ + { + name: "plan", + content: "---\nname: plan\ndescription: Planning\n---\n\nPlan the work.", + }, + ], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + mcpConfig: { + playwright: { + type: "local", + command: "npx", + args: ["-y", "@anthropic/mcp-playwright"], + tools: ["*"], + }, + }, + } + + await writeCopilotBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, ".github", "agents", "security-reviewer.agent.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".github", "skills", "plan", "SKILL.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".github", "skills", "skill-one", "SKILL.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".github", "copilot-mcp-config.json"))).toBe(true) + + const agentContent = await fs.readFile( + path.join(tempRoot, ".github", "agents", "security-reviewer.agent.md"), + "utf8", + ) + expect(agentContent).toContain("Review code.") + + const skillContent = await fs.readFile( + path.join(tempRoot, ".github", "skills", "plan", "SKILL.md"), + "utf8", + ) + expect(skillContent).toContain("Plan the work.") + + const mcpContent = JSON.parse( + await fs.readFile(path.join(tempRoot, ".github", "copilot-mcp-config.json"), "utf8"), + ) + expect(mcpContent.mcpServers.playwright.command).toBe("npx") + }) + + test("agents use .agent.md file extension", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-ext-")) + const bundle: CopilotBundle = { + agents: [{ name: "test-agent", content: "Agent content" }], + generatedSkills: [], + skillDirs: [], + } + + await writeCopilotBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, ".github", "agents", "test-agent.agent.md"))).toBe(true) + // Should NOT create a plain .md file + expect(await exists(path.join(tempRoot, ".github", "agents", "test-agent.md"))).toBe(false) + }) + + test("writes directly into .github output root without double-nesting", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-home-")) + const githubRoot = path.join(tempRoot, ".github") + const bundle: CopilotBundle = { + agents: [{ name: "reviewer", content: "Reviewer agent content" }], + generatedSkills: [{ name: "plan", content: "Plan content" }], + skillDirs: [], + } + + await writeCopilotBundle(githubRoot, bundle) + + expect(await exists(path.join(githubRoot, "agents", "reviewer.agent.md"))).toBe(true) + expect(await exists(path.join(githubRoot, "skills", "plan", "SKILL.md"))).toBe(true) + // Should NOT double-nest under .github/.github + expect(await exists(path.join(githubRoot, ".github"))).toBe(false) + }) + + test("handles empty bundles gracefully", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-empty-")) + const bundle: CopilotBundle = { + agents: [], + generatedSkills: [], + skillDirs: [], + } + + await writeCopilotBundle(tempRoot, bundle) + expect(await exists(tempRoot)).toBe(true) + }) + + test("writes multiple agents as separate .agent.md files", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-multi-")) + const githubRoot = path.join(tempRoot, ".github") + const bundle: CopilotBundle = { + agents: [ + { name: "security-sentinel", content: "Security rules" }, + { name: "performance-oracle", content: "Performance rules" }, + { name: "code-simplicity-reviewer", content: "Simplicity rules" }, + ], + generatedSkills: [], + skillDirs: [], + } + + await writeCopilotBundle(githubRoot, bundle) + + expect(await exists(path.join(githubRoot, "agents", "security-sentinel.agent.md"))).toBe(true) + expect(await exists(path.join(githubRoot, "agents", "performance-oracle.agent.md"))).toBe(true) + expect(await exists(path.join(githubRoot, "agents", "code-simplicity-reviewer.agent.md"))).toBe(true) + }) + + test("backs up existing copilot-mcp-config.json before overwriting", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-backup-")) + const githubRoot = path.join(tempRoot, ".github") + await fs.mkdir(githubRoot, { recursive: true }) + + // Write an existing config + const mcpPath = path.join(githubRoot, "copilot-mcp-config.json") + await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { type: "local", command: "old-cmd", tools: ["*"] } } })) + + const bundle: CopilotBundle = { + agents: [], + generatedSkills: [], + skillDirs: [], + mcpConfig: { + newServer: { type: "local", command: "new-cmd", tools: ["*"] }, + }, + } + + await writeCopilotBundle(githubRoot, bundle) + + // New config should have the new content + const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(newContent.mcpServers.newServer.command).toBe("new-cmd") + + // A backup file should exist + const files = await fs.readdir(githubRoot) + const backupFiles = files.filter((f) => f.startsWith("copilot-mcp-config.json.bak.")) + expect(backupFiles.length).toBeGreaterThanOrEqual(1) + }) + + test("creates skill directories with SKILL.md", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "copilot-genskill-")) + const bundle: CopilotBundle = { + agents: [], + generatedSkills: [ + { + name: "deploy", + content: "---\nname: deploy\ndescription: Deploy skill\n---\n\nDeploy steps.", + }, + ], + skillDirs: [], + } + + await writeCopilotBundle(tempRoot, bundle) + + const skillPath = path.join(tempRoot, ".github", "skills", "deploy", "SKILL.md") + expect(await exists(skillPath)).toBe(true) + + const content = await fs.readFile(skillPath, "utf8") + expect(content).toContain("Deploy steps.") + }) +}) diff --git a/tests/sync-copilot.test.ts b/tests/sync-copilot.test.ts new file mode 100644 index 0000000..7082263 --- /dev/null +++ b/tests/sync-copilot.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { syncToCopilot } from "../src/sync/copilot" +import type { ClaudeHomeConfig } from "../src/parsers/claude-home" + +describe("syncToCopilot", () => { + test("symlinks skills to .github/skills/", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-")) + const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "skill-one", + sourceDir: fixtureSkillDir, + skillPath: path.join(fixtureSkillDir, "SKILL.md"), + }, + ], + mcpServers: {}, + } + + await syncToCopilot(config, tempRoot) + + const linkedSkillPath = path.join(tempRoot, "skills", "skill-one") + const linkedStat = await fs.lstat(linkedSkillPath) + expect(linkedStat.isSymbolicLink()).toBe(true) + }) + + test("skips skills with invalid names", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-invalid-")) + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "../escape-attempt", + sourceDir: "/tmp/bad-skill", + skillPath: "/tmp/bad-skill/SKILL.md", + }, + ], + mcpServers: {}, + } + + await syncToCopilot(config, tempRoot) + + const skillsDir = path.join(tempRoot, "skills") + const entries = await fs.readdir(skillsDir).catch(() => []) + expect(entries).toHaveLength(0) + }) + + test("merges MCP config with existing file", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-merge-")) + const mcpPath = path.join(tempRoot, "copilot-mcp-config.json") + + await fs.writeFile( + mcpPath, + JSON.stringify({ + mcpServers: { + existing: { type: "local", command: "node", args: ["server.js"], tools: ["*"] }, + }, + }, null, 2), + ) + + const config: ClaudeHomeConfig = { + skills: [], + mcpServers: { + context7: { url: "https://mcp.context7.com/mcp" }, + }, + } + + await syncToCopilot(config, tempRoot) + + const merged = JSON.parse(await fs.readFile(mcpPath, "utf8")) as { + mcpServers: Record + } + + expect(merged.mcpServers.existing?.command).toBe("node") + expect(merged.mcpServers.context7?.url).toBe("https://mcp.context7.com/mcp") + }) + + test("transforms MCP env var names to COPILOT_MCP_ prefix", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-env-")) + + const config: ClaudeHomeConfig = { + skills: [], + mcpServers: { + server: { + command: "echo", + args: ["hello"], + env: { API_KEY: "secret", COPILOT_MCP_TOKEN: "already-prefixed" }, + }, + }, + } + + await syncToCopilot(config, tempRoot) + + const mcpPath = path.join(tempRoot, "copilot-mcp-config.json") + const mcpConfig = JSON.parse(await fs.readFile(mcpPath, "utf8")) as { + mcpServers: Record }> + } + + expect(mcpConfig.mcpServers.server?.env).toEqual({ + COPILOT_MCP_API_KEY: "secret", + COPILOT_MCP_TOKEN: "already-prefixed", + }) + }) + + test("writes MCP config with restricted permissions", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-perms-")) + + const config: ClaudeHomeConfig = { + skills: [], + mcpServers: { + server: { command: "echo", args: ["hello"] }, + }, + } + + await syncToCopilot(config, tempRoot) + + const mcpPath = path.join(tempRoot, "copilot-mcp-config.json") + const stat = await fs.stat(mcpPath) + // Check owner read+write permission (0o600 = 33216 in decimal, masked to file perms) + const perms = stat.mode & 0o777 + expect(perms).toBe(0o600) + }) + + test("does not write MCP config when no MCP servers", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "sync-copilot-nomcp-")) + const fixtureSkillDir = path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one") + + const config: ClaudeHomeConfig = { + skills: [ + { + name: "skill-one", + sourceDir: fixtureSkillDir, + skillPath: path.join(fixtureSkillDir, "SKILL.md"), + }, + ], + mcpServers: {}, + } + + await syncToCopilot(config, tempRoot) + + const mcpExists = await fs.access(path.join(tempRoot, "copilot-mcp-config.json")).then(() => true).catch(() => false) + expect(mcpExists).toBe(false) + }) +}) From 5d984ab2daa897050c9c98ab89431df7f351a236 Mon Sep 17 00:00:00 2001 From: Brayan Jules Date: Tue, 17 Feb 2026 01:41:57 -0300 Subject: [PATCH 03/47] fix: Add missing closing brace for copilot target entry The copilot entry in the targets record was missing its closing `},` after merging with the gemini target branch, causing a parse error. Co-Authored-By: Claude Opus 4.6 --- src/targets/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/targets/index.ts b/src/targets/index.ts index a2aae25..ffcaeff 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -64,6 +64,7 @@ export const targets: Record = { implemented: true, convert: convertClaudeToCopilot as TargetHandler["convert"], write: writeCopilotBundle as TargetHandler["write"], + }, gemini: { name: "gemini", implemented: true, From 7055df5d8eaf080e0bc715c94c62822e28ef7b27 Mon Sep 17 00:00:00 2001 From: Brayan Jules Date: Tue, 17 Feb 2026 01:44:44 -0300 Subject: [PATCH 04/47] fix: Route copilot install to .github/ instead of opencode default Add copilot case to resolveTargetOutputRoot so `install --to copilot` writes to .github/ in the current directory instead of falling through to the opencode default (~/.config/opencode). Co-Authored-By: Claude Opus 4.6 --- src/commands/install.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/install.ts b/src/commands/install.ts index 35506e8..c2412bb 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -25,7 +25,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | gemini)", + description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini)", }, output: { type: "string", @@ -187,6 +187,10 @@ function resolveTargetOutputRoot( 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") + } return outputRoot } From dbb25c63dd184b5255030152fa1befb2038768b6 Mon Sep 17 00:00:00 2001 From: Brayan Jules Date: Tue, 17 Feb 2026 02:05:37 -0300 Subject: [PATCH 05/47] fix: Preserve command namespace in Copilot skill names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop stripping namespace prefixes when converting commands to Copilot skills. `workflows:plan` now becomes `workflows-plan` instead of just `plan`, avoiding clashes with Copilot's own features in the chat UI. Also updates slash command references in body text to match: `/workflows:plan` → `/workflows-plan`. Co-Authored-By: Claude Opus 4.6 --- ...6-02-17-copilot-skill-naming-brainstorm.md | 30 +++++++++++++ src/converters/claude-to-copilot.ts | 10 ++--- tests/copilot-converter.test.ts | 42 +++++++++++++++---- 3 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md diff --git a/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md b/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md new file mode 100644 index 0000000..c04e97d --- /dev/null +++ b/docs/brainstorms/2026-02-17-copilot-skill-naming-brainstorm.md @@ -0,0 +1,30 @@ +--- +date: 2026-02-17 +topic: copilot-skill-naming +--- + +# Copilot Skill Naming: Preserve Namespace + +## What We're Building + +Change the Copilot converter to preserve command namespaces when converting commands to skills. Currently `workflows:plan` flattens to `plan`, which is too generic and clashes with Copilot's own features in the chat suggestion UI. + +## Why This Approach + +The `flattenCommandName` function strips everything before the last colon, producing names like `plan`, `review`, `work` that are too generic for Copilot's skill discovery UI. Replacing colons with hyphens (`workflows:plan` -> `workflows-plan`) preserves context while staying within valid filename characters. + +## Key Decisions + +- **Replace colons with hyphens** instead of stripping the prefix: `workflows:plan` -> `workflows-plan` +- **Copilot only** — other converters (Cursor, Droid, etc.) keep their current flattening behavior +- **Content transformation too** — slash command references in body text also use hyphens: `/workflows:plan` -> `/workflows-plan` + +## Changes Required + +1. `src/converters/claude-to-copilot.ts` — change `flattenCommandName` to replace colons with hyphens +2. `src/converters/claude-to-copilot.ts` — update `transformContentForCopilot` slash command rewriting +3. `tests/copilot-converter.test.ts` — update affected tests + +## Next Steps + +-> Implement directly (small, well-scoped change) diff --git a/src/converters/claude-to-copilot.ts b/src/converters/claude-to-copilot.ts index 510bfa9..6a7722c 100644 --- a/src/converters/claude-to-copilot.ts +++ b/src/converters/claude-to-copilot.ts @@ -113,13 +113,13 @@ export function transformContentForCopilot(body: string): string { return `${prefix}Use the ${skillName} skill to: ${args.trim()}` }) - // 2. Transform slash command references (flatten namespaces) + // 2. Transform slash command references (replace colons with hyphens) const slashCommandPattern = /(? { if (commandName.includes("/")) return match if (["dev", "tmp", "etc", "usr", "var", "bin", "home"].includes(commandName)) return match - const flattened = flattenCommandName(commandName) - return `/${flattened}` + const normalized = flattenCommandName(commandName) + return `/${normalized}` }) // 3. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/ @@ -179,9 +179,7 @@ function prefixEnvVars(env: Record): Record { } function flattenCommandName(name: string): string { - const colonIndex = name.lastIndexOf(":") - const base = colonIndex >= 0 ? name.slice(colonIndex + 1) : name - return normalizeName(base) + return normalizeName(name) } function normalizeName(value: string): string { diff --git a/tests/copilot-converter.test.ts b/tests/copilot-converter.test.ts index bbb37bd..22f7973 100644 --- a/tests/copilot-converter.test.ts +++ b/tests/copilot-converter.test.ts @@ -169,20 +169,46 @@ describe("convertClaudeToCopilot", () => { expect(bundle.generatedSkills).toHaveLength(1) const skill = bundle.generatedSkills[0] - expect(skill.name).toBe("plan") + expect(skill.name).toBe("workflows-plan") const parsed = parseFrontmatter(skill.content) - expect(parsed.data.name).toBe("plan") + expect(parsed.data.name).toBe("workflows-plan") expect(parsed.data.description).toBe("Planning command") expect(parsed.body).toContain("Plan the work.") }) - test("flattens namespaced command names", () => { + test("preserves namespaced command names with hyphens", () => { const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) - expect(bundle.generatedSkills[0].name).toBe("plan") + expect(bundle.generatedSkills[0].name).toBe("workflows-plan") }) - test("command name collision after flattening is deduplicated", () => { + test("command name collision after normalization is deduplicated", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "workflows:plan", + description: "Workflow plan", + body: "Plan body.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + { + name: "workflows:plan", + description: "Duplicate plan", + body: "Duplicate body.", + sourcePath: "/tmp/plugin/commands/workflows/plan2.md", + }, + ], + agents: [], + skills: [], + } + + const bundle = convertClaudeToCopilot(plugin, defaultOptions) + const names = bundle.generatedSkills.map((s) => s.name) + expect(names).toEqual(["workflows-plan", "workflows-plan-2"]) + }) + + test("namespaced and non-namespaced commands produce distinct names", () => { const plugin: ClaudePlugin = { ...fixturePlugin, commands: [ @@ -205,7 +231,7 @@ describe("convertClaudeToCopilot", () => { const bundle = convertClaudeToCopilot(plugin, defaultOptions) const names = bundle.generatedSkills.map((s) => s.name) - expect(names).toEqual(["plan", "plan-2"]) + expect(names).toEqual(["workflows-plan", "plan"]) }) test("command allowedTools is silently dropped", () => { @@ -418,14 +444,14 @@ Task best-practices-researcher(topic)` expect(result).not.toContain("Task repo-research-analyst(") }) - test("flattens slash commands", () => { + test("replaces colons with hyphens in slash commands", () => { const input = `1. Run /deepen-plan to enhance 2. Start /workflows:work to implement 3. File at /tmp/output.md` const result = transformContentForCopilot(input) expect(result).toContain("/deepen-plan") - expect(result).toContain("/work") + expect(result).toContain("/workflows-work") expect(result).not.toContain("/workflows:work") // File paths preserved expect(result).toContain("/tmp/output.md") From 83277feee8870a9149142b2621fb9e8517b0314b Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 17 Feb 2026 10:26:36 -0800 Subject: [PATCH 06/47] fix: remove deleted cursor sync/converter imports after native plugin migration --- src/commands/sync.ts | 12 +++--------- src/targets/index.ts | 9 --------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/commands/sync.ts b/src/commands/sync.ts index f453704..b7b9ed4 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -6,11 +6,10 @@ import { syncToOpenCode } from "../sync/opencode" import { syncToCodex } from "../sync/codex" import { syncToPi } from "../sync/pi" import { syncToDroid } from "../sync/droid" -import { syncToCursor } from "../sync/cursor" import { syncToCopilot } from "../sync/copilot" import { expandHome } from "../utils/resolve-home" -const validTargets = ["opencode", "codex", "pi", "droid", "cursor", "copilot"] as const +const validTargets = ["opencode", "codex", "pi", "droid", "copilot"] as const type SyncTarget = (typeof validTargets)[number] function isValidTarget(value: string): value is SyncTarget { @@ -41,8 +40,6 @@ function resolveOutputRoot(target: SyncTarget): string { return path.join(os.homedir(), ".pi", "agent") case "droid": return path.join(os.homedir(), ".factory") - case "cursor": - return path.join(process.cwd(), ".cursor") case "copilot": return path.join(process.cwd(), ".github") } @@ -51,13 +48,13 @@ function resolveOutputRoot(target: SyncTarget): string { export default defineCommand({ meta: { name: "sync", - description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, Cursor, or Copilot", + description: "Sync Claude Code config (~/.claude/) to OpenCode, Codex, Pi, Droid, or Copilot", }, args: { target: { type: "string", required: true, - description: "Target: opencode | codex | pi | droid | cursor | copilot", + description: "Target: opencode | codex | pi | droid | copilot", }, claudeHome: { type: "string", @@ -100,9 +97,6 @@ export default defineCommand({ case "droid": await syncToDroid(config, outputRoot) break - case "cursor": - await syncToCursor(config, outputRoot) - break case "copilot": await syncToCopilot(config, outputRoot) break diff --git a/src/targets/index.ts b/src/targets/index.ts index ffcaeff..b4cadb0 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -2,21 +2,18 @@ import type { ClaudePlugin } from "../types/claude" import type { OpenCodeBundle } from "../types/opencode" import type { CodexBundle } from "../types/codex" import type { DroidBundle } from "../types/droid" -import type { CursorBundle } from "../types/cursor" import type { PiBundle } from "../types/pi" import type { CopilotBundle } from "../types/copilot" import type { GeminiBundle } from "../types/gemini" import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" import { convertClaudeToCodex } from "../converters/claude-to-codex" import { convertClaudeToDroid } from "../converters/claude-to-droid" -import { convertClaudeToCursor } from "../converters/claude-to-cursor" import { convertClaudeToPi } from "../converters/claude-to-pi" import { convertClaudeToCopilot } from "../converters/claude-to-copilot" import { convertClaudeToGemini } from "../converters/claude-to-gemini" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" import { writeDroidBundle } from "./droid" -import { writeCursorBundle } from "./cursor" import { writePiBundle } from "./pi" import { writeCopilotBundle } from "./copilot" import { writeGeminiBundle } from "./gemini" @@ -47,12 +44,6 @@ export const targets: Record = { convert: convertClaudeToDroid as TargetHandler["convert"], write: writeDroidBundle as TargetHandler["write"], }, - cursor: { - name: "cursor", - implemented: true, - convert: convertClaudeToCursor as TargetHandler["convert"], - write: writeCursorBundle as TargetHandler["write"], - }, pi: { name: "pi", implemented: true, From e1906592cbd49889beb82e1be76359398b6d3d58 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 17 Feb 2026 10:27:37 -0800 Subject: [PATCH 07/47] chore: bump version to 0.8.0, update CHANGELOG --- CHANGELOG.md | 13 +++++++++++++ package.json | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f5f05..87ac63f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ 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.8.0] - 2026-02-17 + +### Added + +- **GitHub Copilot target** — `--to copilot` converts plugins to `.github/` format with `.agent.md` files, `SKILL.md` skills, and `copilot-mcp-config.json`. Also supports `sync --target copilot` ([#192](https://github.com/EveryInc/compound-engineering-plugin/pull/192)) — thanks [@brayanjuls](https://github.com/brayanjuls)! +- **Native Cursor plugin support** — Cursor now installs via `/add-plugin compound-engineering` using Cursor's native plugin system instead of CLI conversion ([#184](https://github.com/EveryInc/compound-engineering-plugin/pull/184)) — thanks [@ericzakariasson](https://github.com/ericzakariasson)! + +### Removed + +- Cursor CLI conversion target (`--to cursor`) — replaced by native Cursor plugin install + +--- + ## [0.6.0] - 2026-02-12 ### Added diff --git a/package.json b/package.json index 832a5b2..1115dc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@every-env/compound-plugin", - "version": "0.7.0", + "version": "0.8.0", "type": "module", "private": false, "bin": { From d314d7fa2a591558beb8b6019473e3f3955f6948 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 17 Feb 2026 10:42:42 -0800 Subject: [PATCH 08/47] fix: resolve first-run workflow failures (2.35.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(lfg, slfg): make ralph-loop step optional — graceful fallback when ralph-wiggum skill not installed (#154); add explicit "do not stop" instruction across all pipeline steps (#134) - fix(plan): add mandatory "Write Plan File" step with explicit Write tool instructions before post-generation options — plan always written to disk even in LFG/SLFG pipeline context (#155, #134) - fix(plan): use full qualified agent name for spec-flow-analyzer to prevent Claude prepending wrong 'workflows:' prefix (#193) Closes #154, #155, #193 Contributing to #134 Co-Authored-By: Claude Opus 4.6 --- .../.claude-plugin/plugin.json | 2 +- plugins/compound-engineering/CHANGELOG.md | 10 ++++++++++ plugins/compound-engineering/commands/lfg.md | 6 +++--- plugins/compound-engineering/commands/slfg.md | 4 ++-- .../commands/workflows/plan.md | 16 +++++++++++++++- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/plugins/compound-engineering/.claude-plugin/plugin.json b/plugins/compound-engineering/.claude-plugin/plugin.json index 9b35c5a..30270db 100644 --- a/plugins/compound-engineering/.claude-plugin/plugin.json +++ b/plugins/compound-engineering/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "compound-engineering", - "version": "2.34.0", + "version": "2.35.0", "description": "AI-powered development tools. 29 agents, 22 commands, 19 skills, 1 MCP server for code review, research, design, and workflow automation.", "author": { "name": "Kieran Klaassen", diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index 6819c48..2808175 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -5,6 +5,16 @@ 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.35.0] - 2026-02-17 + +### Fixed + +- **`/lfg` and `/slfg` first-run failures** — Made ralph-loop step optional with graceful fallback when `ralph-wiggum` skill is not installed (#154). Added explicit "do not stop" instruction across all steps (#134). +- **`/workflows:plan` not writing file in pipeline** — Added mandatory "Write Plan File" step with explicit Write tool instructions before Post-Generation Options. The file is now always written to disk before any interactive prompts (#155). Also adds pipeline-mode note to skip AskUserQuestion calls when invoked from LFG/SLFG (#134). +- **Agent namespace typo in `/workflows:plan`** — `Task spec-flow-analyzer(...)` now uses the full qualified name `Task compound-engineering:workflow:spec-flow-analyzer(...)` to prevent Claude from prepending the wrong `workflows:` prefix (#193). + +--- + ## [2.34.0] - 2026-02-14 ### Added diff --git a/plugins/compound-engineering/commands/lfg.md b/plugins/compound-engineering/commands/lfg.md index 5d971fc..86f40e5 100644 --- a/plugins/compound-engineering/commands/lfg.md +++ b/plugins/compound-engineering/commands/lfg.md @@ -5,9 +5,9 @@ argument-hint: "[feature description]" disable-model-invocation: true --- -Run these slash commands in order. Do not do anything else. +Run these slash commands in order. Do not do anything else. Do not stop between steps — complete every step through to the end. -1. `/ralph-wiggum:ralph-loop "finish all slash commands" --completion-promise "DONE"` +1. **Optional:** If the `ralph-wiggum` skill is available, run `/ralph-wiggum:ralph-loop "finish all slash commands" --completion-promise "DONE"`. If not available or it fails, skip and continue to step 2 immediately. 2. `/workflows:plan $ARGUMENTS` 3. `/compound-engineering:deepen-plan` 4. `/workflows:work` @@ -17,4 +17,4 @@ Run these slash commands in order. Do not do anything else. 8. `/compound-engineering:feature-video` 9. Output `DONE` when video is in PR -Start with step 1 now. +Start with step 2 now (or step 1 if ralph-wiggum is available). diff --git a/plugins/compound-engineering/commands/slfg.md b/plugins/compound-engineering/commands/slfg.md index eef3445..050d24e 100644 --- a/plugins/compound-engineering/commands/slfg.md +++ b/plugins/compound-engineering/commands/slfg.md @@ -5,11 +5,11 @@ argument-hint: "[feature description]" disable-model-invocation: true --- -Swarm-enabled LFG. Run these steps in order, parallelizing where indicated. +Swarm-enabled LFG. Run these steps in order, parallelizing where indicated. Do not stop between steps — complete every step through to the end. ## Sequential Phase -1. `/ralph-wiggum:ralph-loop "finish all slash commands" --completion-promise "DONE"` +1. **Optional:** If the `ralph-wiggum` skill is available, run `/ralph-wiggum:ralph-loop "finish all slash commands" --completion-promise "DONE"`. If not available or it fails, skip and continue to step 2 immediately. 2. `/workflows:plan $ARGUMENTS` 3. `/compound-engineering:deepen-plan` 4. `/workflows:work` — **Use swarm mode**: Make a Task list and launch an army of agent swarm subagents to build the plan diff --git a/plugins/compound-engineering/commands/workflows/plan.md b/plugins/compound-engineering/commands/workflows/plan.md index 631bccc..ce913c7 100644 --- a/plugins/compound-engineering/commands/workflows/plan.md +++ b/plugins/compound-engineering/commands/workflows/plan.md @@ -150,7 +150,7 @@ Think like a product manager - what would make this issue clear and actionable? After planning the issue structure, run SpecFlow Analyzer to validate and refine the feature specification: -- Task spec-flow-analyzer(feature_description, research_findings) +- Task compound-engineering:workflow:spec-flow-analyzer(feature_description, research_findings) **SpecFlow Analyzer Output:** @@ -475,6 +475,20 @@ end - [ ] Add names of files in pseudo code examples and todo lists - [ ] Add an ERD mermaid diagram if applicable for new model changes +## Write Plan File + +**REQUIRED: Write the plan file to disk before presenting any options.** + +```bash +mkdir -p docs/plans/ +``` + +Use the Write tool to save the complete plan to `docs/plans/YYYY-MM-DD---plan.md`. This step is mandatory and cannot be skipped — even when running as part of LFG/SLFG or other automated pipelines. + +Confirm: "Plan written to docs/plans/[filename]" + +**Pipeline mode:** If invoked from an automated workflow (LFG, SLFG, or any `disable-model-invocation` context), skip all AskUserQuestion calls. Make decisions automatically and proceed to writing the plan without interactive prompts. + ## Output Format **Filename:** Use the date and kebab-case filename from Step 2 Title & Categorization. From ee76195daf747fc08c74c84a9323ea46df56c305 Mon Sep 17 00:00:00 2001 From: Wilson Tovar Date: Tue, 17 Feb 2026 14:20:19 +0100 Subject: [PATCH 09/47] feat(kiro): add Kiro CLI target provider types, converter, writer, and CLI registration --- src/commands/convert.ts | 3 +- src/commands/install.ts | 6 +- src/converters/claude-to-kiro.ts | 262 +++++++++++++++++++++++++++++++ src/targets/index.ts | 9 ++ src/targets/kiro.ts | 122 ++++++++++++++ src/types/kiro.ts | 44 ++++++ 6 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 src/converters/claude-to-kiro.ts create mode 100644 src/targets/kiro.ts create mode 100644 src/types/kiro.ts diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 9f62511..664a63e 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -23,7 +23,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | gemini)", + description: "Target format (opencode | codex | droid | cursor | pi | gemini | kiro)", }, output: { type: "string", @@ -146,5 +146,6 @@ function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHo 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 c2412bb..77f5ea4 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -25,7 +25,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini)", + description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro)", }, output: { type: "string", @@ -191,6 +191,10 @@ function resolveTargetOutputRoot( 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 } diff --git a/src/converters/claude-to-kiro.ts b/src/converters/claude-to-kiro.ts new file mode 100644 index 0000000..2711267 --- /dev/null +++ b/src/converters/claude-to-kiro.ts @@ -0,0 +1,262 @@ +import { readFileSync, existsSync } from "fs" +import path from "path" +import { formatFrontmatter } from "../utils/frontmatter" +import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" +import type { + KiroAgent, + KiroAgentConfig, + KiroBundle, + KiroMcpServer, + KiroSkill, + KiroSteeringFile, +} from "../types/kiro" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export type ClaudeToKiroOptions = ClaudeToOpenCodeOptions + +const KIRO_SKILL_NAME_MAX_LENGTH = 64 +const KIRO_SKILL_NAME_PATTERN = /^[a-z][a-z0-9-]*$/ +const KIRO_DESCRIPTION_MAX_LENGTH = 1024 + +const CLAUDE_TO_KIRO_TOOLS: Record = { + Bash: "shell", + Write: "write", + Read: "read", + Edit: "write", // NOTE: Kiro write is full-file, not surgical edit. Lossy mapping. + Glob: "glob", + Grep: "grep", + WebFetch: "web_fetch", + Task: "use_subagent", +} + +export function convertClaudeToKiro( + plugin: ClaudePlugin, + _options: ClaudeToKiroOptions, +): KiroBundle { + const usedSkillNames = new Set() + + // Pass-through skills are processed first — they're the source of truth + const skillDirs = plugin.skills.map((skill) => ({ + name: skill.name, + sourceDir: skill.sourceDir, + })) + for (const skill of skillDirs) { + usedSkillNames.add(normalizeName(skill.name)) + } + + // Convert agents to Kiro custom agents + const agentNames = plugin.agents.map((a) => normalizeName(a.name)) + const agents = plugin.agents.map((agent) => convertAgentToKiroAgent(agent, agentNames)) + + // Convert commands to skills (generated) + const generatedSkills = plugin.commands.map((command) => + convertCommandToSkill(command, usedSkillNames, agentNames), + ) + + // Convert MCP servers (stdio only) + const mcpServers = convertMcpServers(plugin.mcpServers) + + // Build steering files from CLAUDE.md + const steeringFiles = buildSteeringFiles(plugin, agentNames) + + // Warn about hooks + if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) { + console.warn( + "Warning: Kiro CLI hooks use a different format (preToolUse/postToolUse inside agent configs). Hooks were skipped during conversion.", + ) + } + + return { agents, generatedSkills, skillDirs, steeringFiles, mcpServers } +} + +function convertAgentToKiroAgent(agent: ClaudeAgent, knownAgentNames: string[]): KiroAgent { + const name = normalizeName(agent.name) + const description = sanitizeDescription( + agent.description ?? `Use this agent for ${agent.name} tasks`, + ) + + const config: KiroAgentConfig = { + name, + description, + prompt: `file://./prompts/${name}.md`, + tools: ["*"], + resources: [ + "file://.kiro/steering/**/*.md", + "skill://.kiro/skills/**/SKILL.md", + ], + includeMcpJson: true, + welcomeMessage: `Switching to the ${name} agent. ${description}`, + } + + let body = transformContentForKiro(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.` + } + + return { name, config, promptContent: body } +} + +function convertCommandToSkill( + command: ClaudeCommand, + usedNames: Set, + knownAgentNames: string[], +): KiroSkill { + const rawName = normalizeName(command.name) + const name = uniqueName(rawName, usedNames) + + const description = sanitizeDescription( + command.description ?? `Converted from Claude command ${command.name}`, + ) + + const frontmatter: Record = { name, description } + + let body = transformContentForKiro(command.body.trim(), knownAgentNames) + if (body.length === 0) { + body = `Instructions converted from the ${command.name} command.` + } + + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +/** + * Transform Claude Code content to Kiro-compatible content. + * + * 1. Task agent calls: Task agent-name(args) -> Use the use_subagent tool ... + * 2. Path rewriting: .claude/ -> .kiro/, ~/.claude/ -> ~/.kiro/ + * 3. Slash command refs: /workflows:plan -> use the workflows-plan skill + * 4. Claude tool names: Bash -> shell, Read -> read, etc. + * 5. Agent refs: @agent-name -> the agent-name agent (only for known agent names) + */ +export function transformContentForKiro(body: string, knownAgentNames: string[] = []): string { + let result = body + + // 1. Transform Task agent calls + 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 use_subagent tool to delegate to the ${normalizeName(agentName)} agent: ${args.trim()}` + }) + + // 2. Rewrite .claude/ paths to .kiro/ (with word-boundary-like lookbehind) + result = result.replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.kiro/") + result = result.replace(/(?<=^|\s|["'`])\.claude\//gm, ".kiro/") + + // 3. Slash command refs: /command-name -> skill activation language + result = result.replace(/(?<=^|\s)`?\/([a-zA-Z][a-zA-Z0-9_:-]*)`?/gm, (_match, cmdName: string) => { + const skillName = normalizeName(cmdName) + return `the ${skillName} skill` + }) + + // 4. Claude tool names -> Kiro tool names + for (const [claudeTool, kiroTool] of Object.entries(CLAUDE_TO_KIRO_TOOLS)) { + // Match tool name references: "the X tool", "using X", "use X to" + const toolPattern = new RegExp(`\\b${claudeTool}\\b(?=\\s+tool|\\s+to\\s)`, "g") + result = result.replace(toolPattern, kiroTool) + } + + // 5. Transform @agent-name references (only for known agent names) + if (knownAgentNames.length > 0) { + const escapedNames = knownAgentNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + const agentRefPattern = new RegExp(`@(${escapedNames.join("|")})\\b`, "g") + result = result.replace(agentRefPattern, (_match, agentName: string) => { + return `the ${normalizeName(agentName)} agent` + }) + } + + return result +} + +function convertMcpServers( + servers?: Record, +): Record { + if (!servers || Object.keys(servers).length === 0) return {} + + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + if (!server.command) { + console.warn( + `Warning: MCP server "${name}" has no command (HTTP/SSE transport). Kiro only supports stdio. Skipping.`, + ) + continue + } + + const entry: KiroMcpServer = { command: server.command } + if (server.args && server.args.length > 0) entry.args = server.args + if (server.env && Object.keys(server.env).length > 0) entry.env = server.env + + console.log(`MCP server "${name}" will execute: ${server.command}${server.args ? " " + server.args.join(" ") : ""}`) + result[name] = entry + } + return result +} + +function buildSteeringFiles(plugin: ClaudePlugin, knownAgentNames: string[]): KiroSteeringFile[] { + const claudeMdPath = path.join(plugin.root, "CLAUDE.md") + if (!existsSync(claudeMdPath)) return [] + + let content: string + try { + content = readFileSync(claudeMdPath, "utf8") + } catch { + return [] + } + + if (!content || content.trim().length === 0) return [] + + const transformed = transformContentForKiro(content, knownAgentNames) + return [{ name: "compound-engineering", content: transformed }] +} + +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, "-") // Collapse consecutive hyphens (Agent Skills standard) + .replace(/^-+|-+$/g, "") + + // Enforce max length (truncate at last hyphen boundary) + if (normalized.length > KIRO_SKILL_NAME_MAX_LENGTH) { + normalized = normalized.slice(0, KIRO_SKILL_NAME_MAX_LENGTH) + const lastHyphen = normalized.lastIndexOf("-") + if (lastHyphen > 0) { + normalized = normalized.slice(0, lastHyphen) + } + normalized = normalized.replace(/-+$/g, "") + } + + // Ensure name starts with a letter + if (normalized.length === 0 || !/^[a-z]/.test(normalized)) { + return "item" + } + + return normalized +} + +function sanitizeDescription(value: string, maxLength = KIRO_DESCRIPTION_MAX_LENGTH): string { + const normalized = value.replace(/\s+/g, " ").trim() + if (normalized.length <= maxLength) return normalized + const ellipsis = "..." + return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis +} + +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 b4cadb0..b7b3ea2 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -5,18 +5,21 @@ import type { DroidBundle } from "../types/droid" 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 { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" import { convertClaudeToCodex } from "../converters/claude-to-codex" import { convertClaudeToDroid } from "../converters/claude-to-droid" 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 { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" import { writeDroidBundle } from "./droid" import { writePiBundle } from "./pi" import { writeCopilotBundle } from "./copilot" import { writeGeminiBundle } from "./gemini" +import { writeKiroBundle } from "./kiro" export type TargetHandler = { name: string @@ -62,4 +65,10 @@ export const targets: Record = { convert: convertClaudeToGemini as TargetHandler["convert"], write: writeGeminiBundle as TargetHandler["write"], }, + kiro: { + name: "kiro", + implemented: true, + convert: convertClaudeToKiro as TargetHandler["convert"], + write: writeKiroBundle as TargetHandler["write"], + }, } diff --git a/src/targets/kiro.ts b/src/targets/kiro.ts new file mode 100644 index 0000000..3597951 --- /dev/null +++ b/src/targets/kiro.ts @@ -0,0 +1,122 @@ +import path from "path" +import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files" +import type { KiroBundle } from "../types/kiro" + +export async function writeKiroBundle(outputRoot: string, bundle: KiroBundle): Promise { + const paths = resolveKiroPaths(outputRoot) + await ensureDir(paths.kiroDir) + + // Write agents + if (bundle.agents.length > 0) { + for (const agent of bundle.agents) { + // Validate name doesn't escape agents directory + validatePathSafe(agent.name, "agent") + + // Write agent JSON config + await writeJson( + path.join(paths.agentsDir, `${agent.name}.json`), + agent.config, + ) + + // Write agent prompt file + await writeText( + path.join(paths.agentsDir, "prompts", `${agent.name}.md`), + agent.promptContent + "\n", + ) + } + } + + // Write generated skills (from commands) + if (bundle.generatedSkills.length > 0) { + for (const skill of bundle.generatedSkills) { + validatePathSafe(skill.name, "skill") + await writeText( + path.join(paths.skillsDir, skill.name, "SKILL.md"), + skill.content + "\n", + ) + } + } + + // Copy skill directories (pass-through) + if (bundle.skillDirs.length > 0) { + for (const skill of bundle.skillDirs) { + validatePathSafe(skill.name, "skill directory") + const destDir = path.join(paths.skillsDir, skill.name) + + // Validate destination doesn't escape skills directory + const resolvedDest = path.resolve(destDir) + if (!resolvedDest.startsWith(path.resolve(paths.skillsDir))) { + console.warn(`Warning: Skill name "${skill.name}" escapes .kiro/skills/. Skipping.`) + continue + } + + await copyDir(skill.sourceDir, destDir) + } + } + + // Write steering files + if (bundle.steeringFiles.length > 0) { + for (const file of bundle.steeringFiles) { + validatePathSafe(file.name, "steering file") + await writeText( + path.join(paths.steeringDir, `${file.name}.md`), + file.content + "\n", + ) + } + } + + // Write MCP servers to mcp.json + if (Object.keys(bundle.mcpServers).length > 0) { + const mcpPath = path.join(paths.settingsDir, "mcp.json") + const backupPath = await backupFile(mcpPath) + if (backupPath) { + console.log(`Backed up existing mcp.json to ${backupPath}`) + } + + // Merge with existing mcp.json if present + let existingConfig: Record = {} + if (await pathExists(mcpPath)) { + try { + existingConfig = await readJson>(mcpPath) + } catch { + console.warn("Warning: existing mcp.json could not be parsed and will be replaced.") + } + } + + const existingServers = + existingConfig.mcpServers && typeof existingConfig.mcpServers === "object" + ? (existingConfig.mcpServers as Record) + : {} + const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpServers } } + await writeJson(mcpPath, merged) + } +} + +function resolveKiroPaths(outputRoot: string) { + const base = path.basename(outputRoot) + // If already pointing at .kiro, write directly into it + if (base === ".kiro") { + return { + kiroDir: outputRoot, + agentsDir: path.join(outputRoot, "agents"), + skillsDir: path.join(outputRoot, "skills"), + steeringDir: path.join(outputRoot, "steering"), + settingsDir: path.join(outputRoot, "settings"), + } + } + // Otherwise nest under .kiro + const kiroDir = path.join(outputRoot, ".kiro") + return { + kiroDir, + agentsDir: path.join(kiroDir, "agents"), + skillsDir: path.join(kiroDir, "skills"), + steeringDir: path.join(kiroDir, "steering"), + settingsDir: path.join(kiroDir, "settings"), + } +} + +function validatePathSafe(name: string, label: string): void { + if (name.includes("..") || name.includes("/") || name.includes("\\")) { + throw new Error(`${label} name contains unsafe path characters: ${name}`) + } +} diff --git a/src/types/kiro.ts b/src/types/kiro.ts new file mode 100644 index 0000000..9144c55 --- /dev/null +++ b/src/types/kiro.ts @@ -0,0 +1,44 @@ +export type KiroAgent = { + name: string + config: KiroAgentConfig + promptContent: string +} + +export type KiroAgentConfig = { + name: string + description: string + prompt: `file://${string}` + tools: ["*"] + resources: string[] + includeMcpJson: true + welcomeMessage?: string +} + +export type KiroSkill = { + name: string + content: string // Full SKILL.md with YAML frontmatter +} + +export type KiroSkillDir = { + name: string + sourceDir: string +} + +export type KiroSteeringFile = { + name: string + content: string +} + +export type KiroMcpServer = { + command: string + args?: string[] + env?: Record +} + +export type KiroBundle = { + agents: KiroAgent[] + generatedSkills: KiroSkill[] + skillDirs: KiroSkillDir[] + steeringFiles: KiroSteeringFile[] + mcpServers: Record +} From 7a41f64f06e037fd8fe8095bb3d84c8cbadc2bda Mon Sep 17 00:00:00 2001 From: Wilson Tovar Date: Tue, 17 Feb 2026 14:22:40 +0100 Subject: [PATCH 10/47] test(kiro): add converter and writer tests for Kiro provider --- tests/kiro-converter.test.ts | 381 +++++++++++++++++++++++++++++++++++ tests/kiro-writer.test.ts | 273 +++++++++++++++++++++++++ 2 files changed, 654 insertions(+) create mode 100644 tests/kiro-converter.test.ts create mode 100644 tests/kiro-writer.test.ts diff --git a/tests/kiro-converter.test.ts b/tests/kiro-converter.test.ts new file mode 100644 index 0000000..e638f71 --- /dev/null +++ b/tests/kiro-converter.test.ts @@ -0,0 +1,381 @@ +import { describe, expect, test } from "bun:test" +import { convertClaudeToKiro, transformContentForKiro } from "../src/converters/claude-to-kiro" +import { parseFrontmatter } from "../src/utils/frontmatter" +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("convertClaudeToKiro", () => { + test("converts agents to Kiro agent configs with prompt files", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent).toBeDefined() + expect(agent!.config.name).toBe("security-reviewer") + expect(agent!.config.description).toBe("Security-focused agent") + expect(agent!.config.prompt).toBe("file://./prompts/security-reviewer.md") + expect(agent!.config.tools).toEqual(["*"]) + expect(agent!.config.includeMcpJson).toBe(true) + expect(agent!.config.resources).toContain("file://.kiro/steering/**/*.md") + expect(agent!.config.resources).toContain("skill://.kiro/skills/**/SKILL.md") + expect(agent!.promptContent).toContain("Focus on vulnerabilities.") + }) + + test("agent config has welcomeMessage generated from description", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent!.config.welcomeMessage).toContain("security-reviewer") + expect(agent!.config.welcomeMessage).toContain("Security-focused agent") + }) + + test("agent with capabilities prepended to prompt content", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect(agent!.promptContent).toContain("## Capabilities") + expect(agent!.promptContent).toContain("- Threat modeling") + expect(agent!.promptContent).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 = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].config.description).toBe("Use this agent for my-agent tasks") + }) + + test("agent model field silently dropped", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "security-reviewer") + expect((agent!.config as Record).model).toBeUndefined() + }) + + 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 = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].promptContent).toContain("Instructions converted from the Empty Agent agent.") + }) + + test("converts commands to SKILL.md with valid frontmatter", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + + expect(bundle.generatedSkills).toHaveLength(1) + const skill = bundle.generatedSkills[0] + expect(skill.name).toBe("workflows-plan") + const parsed = parseFrontmatter(skill.content) + expect(parsed.data.name).toBe("workflows-plan") + expect(parsed.data.description).toBe("Planning command") + expect(parsed.body).toContain("Plan the work.") + }) + + test("command with disable-model-invocation 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 = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.generatedSkills).toHaveLength(1) + expect(bundle.generatedSkills[0].name).toBe("disabled-command") + }) + + test("command allowedTools silently dropped", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + const skill = bundle.generatedSkills[0] + expect(skill.content).not.toContain("allowedTools") + }) + + test("skills pass through as directory references", () => { + const bundle = convertClaudeToKiro(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("MCP stdio servers convert to mcp.json-compatible config", () => { + const bundle = convertClaudeToKiro(fixturePlugin, defaultOptions) + expect(bundle.mcpServers.local.command).toBe("echo") + expect(bundle.mcpServers.local.args).toEqual(["hello"]) + }) + + test("MCP HTTP servers skipped with warning", () => { + const warnings: string[] = [] + const originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + const plugin: ClaudePlugin = { + ...fixturePlugin, + mcpServers: { + httpServer: { url: "https://example.com/mcp" }, + }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + console.warn = originalWarn + + expect(Object.keys(bundle.mcpServers)).toHaveLength(0) + expect(warnings.some((w) => w.includes("no command") || w.includes("HTTP"))).toBe(true) + }) + + test("plugin with zero agents produces empty agents array", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + expect(bundle.generatedSkills).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(0) + }) + + test("plugin with only skills works correctly", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [], + commands: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + expect(bundle.generatedSkills).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(1) + }) + + test("skill name colliding with command name: command gets deduplicated", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + skills: [{ name: "my-command", description: "Existing skill", sourceDir: "/tmp/skill", skillPath: "/tmp/skill/SKILL.md" }], + commands: [{ name: "my-command", description: "A command", body: "Body.", sourcePath: "/tmp/commands/cmd.md" }], + agents: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + + // Skill keeps original name, command gets deduplicated + expect(bundle.skillDirs[0].name).toBe("my-command") + expect(bundle.generatedSkills[0].name).toBe("my-command-2") + }) + + 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: [], + } + + convertClaudeToKiro(plugin, defaultOptions) + console.warn = originalWarn + + expect(warnings.some((w) => w.includes("Kiro"))).toBe(true) + }) + + test("steering file not generated when CLAUDE.md missing", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + root: "/tmp/nonexistent-plugin-dir", + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.steeringFiles).toHaveLength(0) + }) + + 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 = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].name).toBe("my-cool-agent") + expect(bundle.agents[1].name).toBe("uppercase-agent") + expect(bundle.agents[2].name).toBe("agent-with-double-hyphens") // collapsed + }) + + test("description truncation to 1024 chars", () => { + const longDesc = "a".repeat(2000) + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { name: "long-desc", description: longDesc, body: "Body.", sourcePath: "/tmp/a.md" }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents[0].config.description.length).toBeLessThanOrEqual(1024) + expect(bundle.agents[0].config.description.endsWith("...")).toBe(true) + }) + + test("empty plugin produces empty bundle", () => { + const plugin: ClaudePlugin = { + root: "/tmp/empty", + manifest: { name: "empty", version: "1.0.0" }, + agents: [], + commands: [], + skills: [], + } + + const bundle = convertClaudeToKiro(plugin, defaultOptions) + expect(bundle.agents).toHaveLength(0) + expect(bundle.generatedSkills).toHaveLength(0) + expect(bundle.skillDirs).toHaveLength(0) + expect(bundle.steeringFiles).toHaveLength(0) + expect(Object.keys(bundle.mcpServers)).toHaveLength(0) + }) +}) + +describe("transformContentForKiro", () => { + test("transforms .claude/ paths to .kiro/", () => { + const result = transformContentForKiro("Read .claude/settings.json for config.") + expect(result).toContain(".kiro/settings.json") + expect(result).not.toContain(".claude/") + }) + + test("transforms ~/.claude/ paths to ~/.kiro/", () => { + const result = transformContentForKiro("Check ~/.claude/config for settings.") + expect(result).toContain("~/.kiro/config") + expect(result).not.toContain("~/.claude/") + }) + + test("transforms Task agent(args) to use_subagent reference", () => { + const input = `Run these: + +- Task repo-research-analyst(feature_description) +- Task learnings-researcher(feature_description) + +Task best-practices-researcher(topic)` + + const result = transformContentForKiro(input) + expect(result).toContain("Use the use_subagent tool to delegate to the repo-research-analyst agent: feature_description") + expect(result).toContain("Use the use_subagent tool to delegate to the learnings-researcher agent: feature_description") + expect(result).toContain("Use the use_subagent tool to delegate to the best-practices-researcher agent: topic") + expect(result).not.toContain("Task repo-research-analyst") + }) + + test("transforms @agent references for known agents only", () => { + const result = transformContentForKiro("Ask @security-sentinel for a review.", ["security-sentinel"]) + expect(result).toContain("the security-sentinel agent") + expect(result).not.toContain("@security-sentinel") + }) + + test("does not transform @unknown-name when not in known agents", () => { + const result = transformContentForKiro("Contact @someone-else for help.", ["security-sentinel"]) + expect(result).toContain("@someone-else") + }) + + test("transforms Claude tool names to Kiro equivalents", () => { + const result = transformContentForKiro("Use the Bash tool to run commands. Use Read to check files.") + expect(result).toContain("shell tool") + expect(result).toContain("read to") + }) + + test("transforms slash command refs to skill activation", () => { + const result = transformContentForKiro("Run /workflows:plan to start planning.") + expect(result).toContain("the workflows-plan skill") + }) + + test("does not transform partial .claude paths like package/.claude-config/", () => { + const result = transformContentForKiro("Check some-package/.claude-config/settings") + // The .claude-config/ part should be transformed since it starts with .claude/ + // but only when preceded by a word boundary + expect(result).toContain("some-package/") + }) +}) diff --git a/tests/kiro-writer.test.ts b/tests/kiro-writer.test.ts new file mode 100644 index 0000000..301dcb6 --- /dev/null +++ b/tests/kiro-writer.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writeKiroBundle } from "../src/targets/kiro" +import type { KiroBundle } from "../src/types/kiro" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +const emptyBundle: KiroBundle = { + agents: [], + generatedSkills: [], + skillDirs: [], + steeringFiles: [], + mcpServers: {}, +} + +describe("writeKiroBundle", () => { + test("writes agents, skills, steering, and mcp.json", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-test-")) + const bundle: KiroBundle = { + agents: [ + { + name: "security-reviewer", + config: { + name: "security-reviewer", + description: "Security-focused agent", + prompt: "file://./prompts/security-reviewer.md", + tools: ["*"], + resources: ["file://.kiro/steering/**/*.md", "skill://.kiro/skills/**/SKILL.md"], + includeMcpJson: true, + welcomeMessage: "Switching to security-reviewer.", + }, + promptContent: "Review code for vulnerabilities.", + }, + ], + generatedSkills: [ + { + name: "workflows-plan", + content: "---\nname: workflows-plan\ndescription: Planning\n---\n\nPlan the work.", + }, + ], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + steeringFiles: [ + { name: "compound-engineering", content: "# Steering content\n\nFollow these guidelines." }, + ], + mcpServers: { + playwright: { command: "npx", args: ["-y", "@anthropic/mcp-playwright"] }, + }, + } + + await writeKiroBundle(tempRoot, bundle) + + // Agent JSON config + const agentConfigPath = path.join(tempRoot, ".kiro", "agents", "security-reviewer.json") + expect(await exists(agentConfigPath)).toBe(true) + const agentConfig = JSON.parse(await fs.readFile(agentConfigPath, "utf8")) + expect(agentConfig.name).toBe("security-reviewer") + expect(agentConfig.includeMcpJson).toBe(true) + expect(agentConfig.tools).toEqual(["*"]) + + // Agent prompt file + const promptPath = path.join(tempRoot, ".kiro", "agents", "prompts", "security-reviewer.md") + expect(await exists(promptPath)).toBe(true) + const promptContent = await fs.readFile(promptPath, "utf8") + expect(promptContent).toContain("Review code for vulnerabilities.") + + // Generated skill + const skillPath = path.join(tempRoot, ".kiro", "skills", "workflows-plan", "SKILL.md") + expect(await exists(skillPath)).toBe(true) + const skillContent = await fs.readFile(skillPath, "utf8") + expect(skillContent).toContain("Plan the work.") + + // Copied skill + expect(await exists(path.join(tempRoot, ".kiro", "skills", "skill-one", "SKILL.md"))).toBe(true) + + // Steering file + const steeringPath = path.join(tempRoot, ".kiro", "steering", "compound-engineering.md") + expect(await exists(steeringPath)).toBe(true) + const steeringContent = await fs.readFile(steeringPath, "utf8") + expect(steeringContent).toContain("Follow these guidelines.") + + // MCP config + const mcpPath = path.join(tempRoot, ".kiro", "settings", "mcp.json") + expect(await exists(mcpPath)).toBe(true) + const mcpContent = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(mcpContent.mcpServers.playwright.command).toBe("npx") + }) + + test("does not double-nest when output root is .kiro", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-home-")) + const kiroRoot = path.join(tempRoot, ".kiro") + const bundle: KiroBundle = { + ...emptyBundle, + agents: [ + { + name: "reviewer", + config: { + name: "reviewer", + description: "A reviewer", + prompt: "file://./prompts/reviewer.md", + tools: ["*"], + resources: [], + includeMcpJson: true, + }, + promptContent: "Review content.", + }, + ], + } + + await writeKiroBundle(kiroRoot, bundle) + + expect(await exists(path.join(kiroRoot, "agents", "reviewer.json"))).toBe(true) + // Should NOT double-nest under .kiro/.kiro + expect(await exists(path.join(kiroRoot, ".kiro"))).toBe(false) + }) + + test("handles empty bundles gracefully", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-empty-")) + + await writeKiroBundle(tempRoot, emptyBundle) + expect(await exists(tempRoot)).toBe(true) + }) + + test("backs up existing mcp.json before overwrite", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-backup-")) + const kiroRoot = path.join(tempRoot, ".kiro") + const settingsDir = path.join(kiroRoot, "settings") + await fs.mkdir(settingsDir, { recursive: true }) + + // Write existing mcp.json + const mcpPath = path.join(settingsDir, "mcp.json") + await fs.writeFile(mcpPath, JSON.stringify({ mcpServers: { old: { command: "old-cmd" } } })) + + const bundle: KiroBundle = { + ...emptyBundle, + mcpServers: { newServer: { command: "new-cmd" } }, + } + + await writeKiroBundle(kiroRoot, bundle) + + // New mcp.json should have the new content + const newContent = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(newContent.mcpServers.newServer.command).toBe("new-cmd") + + // A backup file should exist + const files = await fs.readdir(settingsDir) + const backupFiles = files.filter((f) => f.startsWith("mcp.json.bak.")) + expect(backupFiles.length).toBeGreaterThanOrEqual(1) + }) + + test("merges mcpServers into existing mcp.json without clobbering other keys", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-merge-")) + const kiroRoot = path.join(tempRoot, ".kiro") + const settingsDir = path.join(kiroRoot, "settings") + await fs.mkdir(settingsDir, { recursive: true }) + + // Write existing mcp.json with other keys + const mcpPath = path.join(settingsDir, "mcp.json") + await fs.writeFile(mcpPath, JSON.stringify({ + customKey: "preserve-me", + mcpServers: { old: { command: "old-cmd" } }, + })) + + const bundle: KiroBundle = { + ...emptyBundle, + mcpServers: { newServer: { command: "new-cmd" } }, + } + + await writeKiroBundle(kiroRoot, bundle) + + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.customKey).toBe("preserve-me") + expect(content.mcpServers.old.command).toBe("old-cmd") + expect(content.mcpServers.newServer.command).toBe("new-cmd") + }) + + test("mcp.json fresh write when no existing file", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-fresh-")) + const bundle: KiroBundle = { + ...emptyBundle, + mcpServers: { myServer: { command: "my-cmd", args: ["--flag"] } }, + } + + await writeKiroBundle(tempRoot, bundle) + + const mcpPath = path.join(tempRoot, ".kiro", "settings", "mcp.json") + expect(await exists(mcpPath)).toBe(true) + const content = JSON.parse(await fs.readFile(mcpPath, "utf8")) + expect(content.mcpServers.myServer.command).toBe("my-cmd") + expect(content.mcpServers.myServer.args).toEqual(["--flag"]) + }) + + test("agent JSON files are valid JSON with expected fields", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-json-")) + const bundle: KiroBundle = { + ...emptyBundle, + agents: [ + { + name: "test-agent", + config: { + name: "test-agent", + description: "Test agent", + prompt: "file://./prompts/test-agent.md", + tools: ["*"], + resources: ["file://.kiro/steering/**/*.md"], + includeMcpJson: true, + welcomeMessage: "Hello from test-agent.", + }, + promptContent: "Do test things.", + }, + ], + } + + await writeKiroBundle(tempRoot, bundle) + + const configPath = path.join(tempRoot, ".kiro", "agents", "test-agent.json") + const raw = await fs.readFile(configPath, "utf8") + const parsed = JSON.parse(raw) // Should not throw + expect(parsed.name).toBe("test-agent") + expect(parsed.prompt).toBe("file://./prompts/test-agent.md") + expect(parsed.tools).toEqual(["*"]) + expect(parsed.includeMcpJson).toBe(true) + expect(parsed.welcomeMessage).toBe("Hello from test-agent.") + }) + + test("path traversal attempt in skill name is rejected", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-traversal-")) + const bundle: KiroBundle = { + ...emptyBundle, + generatedSkills: [ + { name: "../escape", content: "Malicious content" }, + ], + } + + expect(writeKiroBundle(tempRoot, bundle)).rejects.toThrow("unsafe path") + }) + + test("path traversal in agent name is rejected", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "kiro-traversal2-")) + const bundle: KiroBundle = { + ...emptyBundle, + agents: [ + { + name: "../escape", + config: { + name: "../escape", + description: "Malicious", + prompt: "file://./prompts/../escape.md", + tools: ["*"], + resources: [], + includeMcpJson: true, + }, + promptContent: "Bad.", + }, + ], + } + + expect(writeKiroBundle(tempRoot, bundle)).rejects.toThrow("unsafe path") + }) +}) From a77eacb85600ff089169bf91bd8fff33fcebcc5d Mon Sep 17 00:00:00 2001 From: Wilson Tovar Date: Tue, 17 Feb 2026 14:24:04 +0100 Subject: [PATCH 11/47] docs(kiro): add Kiro format spec and update README with Kiro provider --- README.md | 8 ++- docs/specs/kiro.md | 171 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 docs/specs/kiro.md diff --git a/README.md b/README.md index 74dd4a0..27e4ae7 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 & GitHub Copilot (experimental) Install +## OpenCode, Codex, Droid, Pi, Gemini, Copilot & Kiro (experimental) Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Pi, Gemini CLI and GitHub Copilot. +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. ```bash # convert the compound-engineering plugin into OpenCode format @@ -40,6 +40,9 @@ bunx @every-env/compound-plugin install compound-engineering --to gemini # convert to GitHub Copilot format 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 ``` Local dev: @@ -54,6 +57,7 @@ Droid output is written to `~/.factory/` with commands, droids (agents), and ski Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability. 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). All provider targets are experimental and may change as the formats evolve. diff --git a/docs/specs/kiro.md b/docs/specs/kiro.md new file mode 100644 index 0000000..056be0d --- /dev/null +++ b/docs/specs/kiro.md @@ -0,0 +1,171 @@ +# Kiro CLI Spec (Custom Agents, Skills, Steering, MCP, Settings) + +Last verified: 2026-02-17 + +## Primary sources + +``` +https://kiro.dev/docs/cli/ +https://kiro.dev/docs/cli/custom-agents/configuration-reference/ +https://kiro.dev/docs/cli/skills/ +https://kiro.dev/docs/cli/steering/ +https://kiro.dev/docs/cli/mcp/ +https://kiro.dev/docs/cli/hooks/ +https://agentskills.io +``` + +## Config locations + +- Project-level config: `.kiro/` directory at project root. +- No global/user-level config directory — all config is project-scoped. + +## Directory structure + +``` +.kiro/ +├── agents/ +│ ├── .json # Agent configuration +│ └── prompts/ +│ └── .md # Agent prompt files +├── skills/ +│ └── / +│ └── SKILL.md # Skill definition +├── steering/ +│ └── .md # Always-on context files +└── settings/ + └── mcp.json # MCP server configuration +``` + +## Custom agents (JSON config + prompt files) + +- Custom agents are JSON files in `.kiro/agents/`. +- Each agent has a corresponding prompt `.md` file, referenced via `file://` URI. +- Agent config has 14 possible fields (see below). +- Agents are activated by user selection (no auto-activation). +- The converter outputs a subset of fields relevant to converted plugins. + +### Agent config fields + +| Field | Type | Used in conversion | Notes | +|---|---|---|---| +| `name` | string | Yes | Agent display name | +| `description` | string | Yes | Human-readable description | +| `prompt` | string or `file://` URI | Yes | System prompt or file reference | +| `tools` | string[] | Yes (`["*"]`) | Available tools | +| `resources` | string[] | Yes | `file://`, `skill://`, `knowledgeBase` URIs | +| `includeMcpJson` | boolean | Yes (`true`) | Inherit project MCP servers | +| `welcomeMessage` | string | Yes | Agent switch greeting | +| `mcpServers` | object | No | Per-agent MCP config (use includeMcpJson instead) | +| `toolAliases` | Record | No | Tool name remapping | +| `allowedTools` | string[] | No | Auto-approve patterns | +| `toolsSettings` | object | No | Per-tool configuration | +| `hooks` | object | No (future work) | 5 trigger types | +| `model` | string | No | Model selection | +| `keyboardShortcut` | string | No | Quick-switch shortcut | + +### Example agent config + +```json +{ + "name": "security-reviewer", + "description": "Reviews code for security vulnerabilities", + "prompt": "file://./prompts/security-reviewer.md", + "tools": ["*"], + "resources": [ + "file://.kiro/steering/**/*.md", + "skill://.kiro/skills/**/SKILL.md" + ], + "includeMcpJson": true, + "welcomeMessage": "Switching to security-reviewer. Reviews code for security vulnerabilities" +} +``` + +## Skills (SKILL.md standard) + +- Skills follow the open [Agent Skills](https://agentskills.io) standard. +- A skill is a folder containing `SKILL.md` plus optional supporting files. +- Skills live in `.kiro/skills/`. +- `SKILL.md` uses YAML frontmatter with `name` and `description` fields. +- Kiro activates skills on demand based on description matching. +- The `description` field is critical — Kiro uses it to decide when to activate the skill. + +### Constraints + +- Skill name: max 64 characters, pattern `^[a-z][a-z0-9-]*$`, no consecutive hyphens (`--`). +- Skill description: max 1024 characters. +- Skill name must match parent directory name. + +### Example + +```yaml +--- +name: workflows-plan +description: Plan work by analyzing requirements and creating actionable steps +--- + +# Planning Workflow + +Detailed instructions... +``` + +## Steering files + +- Markdown files in `.kiro/steering/`. +- Always loaded into every agent session's context. +- Equivalent to Claude Code's CLAUDE.md. +- Used for project-wide instructions, coding standards, and conventions. + +## MCP server configuration + +- MCP servers are configured in `.kiro/settings/mcp.json`. +- **Only stdio transport is supported** — `command` + `args` + `env`. +- HTTP/SSE transport (`url`, `headers`) is NOT supported by Kiro CLI. +- The converter skips HTTP-only MCP servers with a warning. + +### Example + +```json +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@anthropic/mcp-playwright"] + }, + "context7": { + "command": "npx", + "args": ["-y", "@context7/mcp-server"] + } + } +} +``` + +## Hooks + +- Kiro supports 5 hook trigger types: `agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`. +- Hooks are configured inside agent JSON configs (not separate files). +- 3 of 5 triggers map to Claude Code hooks (`preToolUse`, `postToolUse`, `stop`). +- Not converted by the plugin converter for MVP — a warning is emitted. + +## Conversion lossy mappings + +| Claude Code Feature | Kiro Status | Notes | +|---|---|---| +| `Edit` tool (surgical replacement) | Degraded -> `write` (full-file) | Kiro write overwrites entire files | +| `context: fork` | Lost | No execution isolation control | +| `!`command`` dynamic injection | Lost | No pre-processing of markdown | +| `disable-model-invocation` | Lost | No invocation control | +| `allowed-tools` per skill | Lost | No tool permission scoping per skill | +| `$ARGUMENTS` interpolation | Lost | No structured argument passing | +| Claude hooks | Skipped | Future follow-up (near-1:1 for 3/5 triggers) | +| HTTP MCP servers | Skipped | Kiro only supports stdio transport | + +## Overwrite behavior during conversion + +| Content Type | Strategy | Rationale | +|---|---|---| +| Generated agents (JSON + prompt) | Overwrite | Generated, not user-authored | +| Generated skills (from commands) | Overwrite | Generated, not user-authored | +| Copied skills (pass-through) | Overwrite | Plugin is source of truth | +| Steering files | Overwrite | Generated from CLAUDE.md | +| `mcp.json` | Merge with backup | User may have added their own servers | +| User-created agents/skills | Preserved | Don't delete orphans | From e84075660a6b0526777128efb8d857a29577367b Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Tue, 17 Feb 2026 12:37:33 -0800 Subject: [PATCH 12/47] =?UTF-8?q?release:=20v0.9.0=20=E2=80=94=20add=20Kir?= =?UTF-8?q?o=20CLI=20target=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 8 ++++++++ package.json | 2 +- src/commands/convert.ts | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87ac63f..3ce7f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.9.0] - 2026-02-17 + +### Added + +- **Kiro CLI target** — `--to kiro` converts plugins to `.kiro/` format with custom agent JSON configs, prompt files, skills, steering files, and `mcp.json`. Only stdio MCP servers are supported ([#196](https://github.com/EveryInc/compound-engineering-plugin/pull/196)) — thanks [@krthr](https://github.com/krthr)! + +--- + ## [0.8.0] - 2026-02-17 ### Added diff --git a/package.json b/package.json index 1115dc0..b162755 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@every-env/compound-plugin", - "version": "0.8.0", + "version": "0.9.0", "type": "module", "private": false, "bin": { diff --git a/src/commands/convert.ts b/src/commands/convert.ts index 664a63e..93efb40 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -23,7 +23,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | gemini | kiro)", + description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro)", }, output: { type: "string", From c2c211107fb9009989125eaceb0112770fb9750c Mon Sep 17 00:00:00 2001 From: zbeyens Date: Wed, 18 Feb 2026 17:28:50 +0100 Subject: [PATCH 13/47] docs(feature-video): remove hardcoded R2 URL and require 200 check --- .../commands/feature-video.md | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/plugins/compound-engineering/commands/feature-video.md b/plugins/compound-engineering/commands/feature-video.md index 346f765..55658dd 100644 --- a/plugins/compound-engineering/commands/feature-video.md +++ b/plugins/compound-engineering/commands/feature-video.md @@ -26,6 +26,7 @@ This command creates professional video walkthroughs of features for PR document - Git repository with a PR to document - `ffmpeg` installed (for video conversion) - `rclone` configured (optional, for cloud upload - see rclone skill) +- Public R2 base URL known (for example, `https://.r2.dev`) ## Setup @@ -212,6 +213,9 @@ ffmpeg -y -framerate 0.5 -pattern_type glob -i 'tmp/screenshots/*.png' \ # Check rclone is configured rclone listremotes +# Set your public base URL (NO trailing slash) +PUBLIC_BASE_URL="https://.r2.dev" + # Upload video, preview GIF, and screenshots to cloud storage # Use --s3-no-check-bucket to avoid permission errors rclone copy tmp/videos/ r2:kieran-claude/pr-videos/pr-[number]/ --s3-no-check-bucket --progress @@ -219,12 +223,17 @@ rclone copy tmp/screenshots/ r2:kieran-claude/pr-videos/pr-[number]/screenshots/ # List uploaded files rclone ls r2:kieran-claude/pr-videos/pr-[number]/ -``` -Public URLs (R2 with public access): -``` -Video: https://pub-4047722ebb1b4b09853f24d3b61467f1.r2.dev/pr-videos/pr-[number]/feature-demo.mp4 -Preview: https://pub-4047722ebb1b4b09853f24d3b61467f1.r2.dev/pr-videos/pr-[number]/feature-demo-preview.gif +# Build and validate public URLs BEFORE updating PR +VIDEO_URL="$PUBLIC_BASE_URL/pr-videos/pr-[number]/feature-demo.mp4" +PREVIEW_URL="$PUBLIC_BASE_URL/pr-videos/pr-[number]/feature-demo-preview.gif" + +curl -I "$VIDEO_URL" +curl -I "$PREVIEW_URL" + +# Require HTTP 200 for both URLs; stop if either fails +curl -I "$VIDEO_URL" | head -n 1 | grep -q ' 200 ' || exit 1 +curl -I "$PREVIEW_URL" | head -n 1 | grep -q ' 200 ' || exit 1 ``` @@ -254,7 +263,7 @@ If the PR already has a video section, replace it. Otherwise, append: Example: ```markdown -[![Feature Demo](https://pub-4047722ebb1b4b09853f24d3b61467f1.r2.dev/pr-videos/pr-137/feature-demo-preview.gif)](https://pub-4047722ebb1b4b09853f24d3b61467f1.r2.dev/pr-videos/pr-137/feature-demo.mp4) +[![Feature Demo](https://.r2.dev/pr-videos/pr-137/feature-demo-preview.gif)](https://.r2.dev/pr-videos/pr-137/feature-demo.mp4) ``` **Update the PR:** From 174cd4cff49899f6a62e41a6d95090feb9e24770 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Wed, 18 Feb 2026 21:50:51 -0800 Subject: [PATCH 14/47] =?UTF-8?q?release:=20v2.35.1=20=E2=80=94=20add=20sy?= =?UTF-8?q?stem-wide=20test=20check=20to=20/workflows:work?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add System-Wide Test Check to work command task execution loop (5 questions: callbacks, real chain coverage, orphaned state, API parity, error alignment) - Add integration test guidance to Test Continuously section - Add System-Wide Impact sections to plan templates (MORE + A LOT) Co-Authored-By: Claude Opus 4.6 --- .../.claude-plugin/plugin.json | 2 +- plugins/compound-engineering/CHANGELOG.md | 9 ++++++ .../commands/workflows/plan.md | 30 +++++++++++++++++++ .../commands/workflows/work.md | 16 ++++++++++ 4 files changed, 56 insertions(+), 1 deletion(-) diff --git a/plugins/compound-engineering/.claude-plugin/plugin.json b/plugins/compound-engineering/.claude-plugin/plugin.json index 30270db..51a3d03 100644 --- a/plugins/compound-engineering/.claude-plugin/plugin.json +++ b/plugins/compound-engineering/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "compound-engineering", - "version": "2.35.0", + "version": "2.35.1", "description": "AI-powered development tools. 29 agents, 22 commands, 19 skills, 1 MCP server for code review, research, design, and workflow automation.", "author": { "name": "Kieran Klaassen", diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index 2808175..731f70b 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -5,6 +5,15 @@ 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.35.1] - 2026-02-18 + +### Changed + +- **`/workflows:work` system-wide test check** — Added "System-Wide Test Check" to the task execution loop. Before marking a task done, forces five questions: what callbacks/middleware fire when this runs? Do tests exercise the real chain or just mocked isolation? Can failure leave orphaned state? What other interfaces need the same change? Do error strategies align across layers? Includes skip criteria for leaf-node changes. Also added integration test guidance to the "Test Continuously" section. +- **`/workflows:plan` system-wide impact templates** — Added "System-Wide Impact" section to MORE and A LOT plan templates (interaction graph, error propagation, state lifecycle, API surface parity, integration test scenarios) as lightweight prompts to flag risks during planning. + +--- + ## [2.35.0] - 2026-02-17 ### Fixed diff --git a/plugins/compound-engineering/commands/workflows/plan.md b/plugins/compound-engineering/commands/workflows/plan.md index ce913c7..5cd43dc 100644 --- a/plugins/compound-engineering/commands/workflows/plan.md +++ b/plugins/compound-engineering/commands/workflows/plan.md @@ -255,6 +255,14 @@ date: YYYY-MM-DD - Performance implications - Security considerations +## System-Wide Impact + +- **Interaction graph**: [What callbacks/middleware/observers fire when this runs?] +- **Error propagation**: [How do errors flow across layers? Do retry strategies align?] +- **State lifecycle risks**: [Can partial failure leave orphaned/inconsistent state?] +- **API surface parity**: [What other interfaces expose similar functionality and need the same change?] +- **Integration test scenarios**: [Cross-layer scenarios that unit tests won't catch] + ## Acceptance Criteria - [ ] Detailed requirement 1 @@ -344,6 +352,28 @@ date: YYYY-MM-DD [Other solutions evaluated and why rejected] +## System-Wide Impact + +### Interaction Graph + +[Map the chain reaction: what callbacks, middleware, observers, and event handlers fire when this code runs? Trace at least two levels deep. Document: "Action X triggers Y, which calls Z, which persists W."] + +### Error & Failure Propagation + +[Trace errors from lowest layer up. List specific error classes and where they're handled. Identify retry conflicts, unhandled error types, and silent failure swallowing.] + +### State Lifecycle Risks + +[Walk through each step that persists state. Can partial failure orphan rows, duplicate records, or leave caches stale? Document cleanup mechanisms or their absence.] + +### API Surface Parity + +[List all interfaces (classes, DSLs, endpoints) that expose equivalent functionality. Note which need updating and which share the code path.] + +### Integration Test Scenarios + +[3-5 cross-layer test scenarios that unit tests with mocks would never catch. Include expected behavior for each.] + ## Acceptance Criteria ### Functional Requirements diff --git a/plugins/compound-engineering/commands/workflows/work.md b/plugins/compound-engineering/commands/workflows/work.md index c8b7f2c..739a2d9 100644 --- a/plugins/compound-engineering/commands/workflows/work.md +++ b/plugins/compound-engineering/commands/workflows/work.md @@ -92,12 +92,27 @@ This command takes a work document (plan, specification, or todo file) and execu - Look for similar patterns in codebase - Implement following existing conventions - Write tests for new functionality + - Run System-Wide Test Check (see below) - Run tests after changes - Mark task as completed in TodoWrite - Mark off the corresponding checkbox in the plan file ([ ] → [x]) - Evaluate for incremental commit (see below) ``` + **System-Wide Test Check** — Before marking a task done, pause and ask: + + | Question | What to do | + |----------|------------| + | **What fires when this runs?** Callbacks, middleware, observers, event handlers — trace two levels out from your change. | Read the actual code (not docs) for callbacks on models you touch, middleware in the request chain, `after_*` hooks. | + | **Do my tests exercise the real chain?** If every dependency is mocked, the test proves your logic works *in isolation* — it says nothing about the interaction. | Write at least one integration test that uses real objects through the full callback/middleware chain. No mocks for the layers that interact. | + | **Can failure leave orphaned state?** If your code persists state (DB row, cache, file) before calling an external service, what happens when the service fails? Does retry create duplicates? | Trace the failure path with real objects. If state is created before the risky call, test that failure cleans up or that retry is idempotent. | + | **What other interfaces expose this?** Mixins, DSLs, alternative entry points (Agent vs Chat vs ChatMethods). | Grep for the method/behavior in related classes. If parity is needed, add it now — not as a follow-up. | + | **Do error strategies align across layers?** Retry middleware + application fallback + framework error handling — do they conflict or create double execution? | List the specific error classes at each layer. Verify your rescue list matches what the lower layer actually raises. | + + **When to skip:** Leaf-node changes with no callbacks, no state persistence, no parallel interfaces. If the change is purely additive (new helper method, new view partial), the check takes 10 seconds and the answer is "nothing fires, skip." + + **When this matters most:** Any change that touches models with callbacks, error handling with fallback/retry, or functionality exposed through multiple interfaces. + **IMPORTANT**: Always update the original plan document by checking off completed items. Use the Edit tool to change `- [ ]` to `- [x]` for each task you finish. This keeps the plan as a living document showing progress and ensures no checkboxes are left unchecked. 2. **Incremental Commits** @@ -143,6 +158,7 @@ This command takes a work document (plan, specification, or todo file) and execu - Don't wait until the end to test - Fix failures immediately - Add new tests for new functionality + - **Unit tests with mocks prove logic in isolation. Integration tests with real objects prove the layers work together.** If your change touches callbacks, middleware, or error handling — you need both. 5. **Figma Design Sync** (if applicable) From d83c1a29c367a8470be7dc9f7539b12010d1d931 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 13:13:36 -0500 Subject: [PATCH 15/47] =?UTF-8?q?docs:=20ADR=200001-0003=20=E2=80=94=20Ope?= =?UTF-8?q?nCode=20commands,=20config=20merge,=20permissions=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Architectural decisions recorded during planning phase. See docs/plans/feature_opencode-commands_as_md_and_config_merge.md for full context. --- .../0001-opencode-command-output-format.md | 21 +++++++++++++++++++ .../0002-opencode-json-merge-strategy.md | 21 +++++++++++++++++++ .../0003-opencode-permissions-default-none.md | 21 +++++++++++++++++++ docs/reports/index.md | 3 +++ 4 files changed, 66 insertions(+) create mode 100644 docs/decisions/0001-opencode-command-output-format.md create mode 100644 docs/decisions/0002-opencode-json-merge-strategy.md create mode 100644 docs/decisions/0003-opencode-permissions-default-none.md create mode 100644 docs/reports/index.md diff --git a/docs/decisions/0001-opencode-command-output-format.md b/docs/decisions/0001-opencode-command-output-format.md new file mode 100644 index 0000000..6788d71 --- /dev/null +++ b/docs/decisions/0001-opencode-command-output-format.md @@ -0,0 +1,21 @@ +# ADR 0001: OpenCode commands written as .md files, not in opencode.json + +## Status +Accepted + +## Date +2026-02-20 + +## Context +OpenCode supports two equivalent formats for custom commands. Writing to opencode.json requires overwriting or merging the user's config file. Writing .md files is additive and non-destructive. + +## Decision +The OpenCode target always emits commands as individual .md files in the commands/ subdirectory. The command key is never written to opencode.json by this tool. + +## Consequences +- Positive: Installs are non-destructive. Commands are visible as individual files, easy to inspect. Consistent with agents/skills handling. +- Negative: Users inspecting opencode.json won't see plugin commands; they must look in commands/. +- Neutral: Requires OpenCode >= the version with command file support (confirmed stable). + +## Plan Reference +Originated from: docs/plans/feature_opencode-commands_as_md_and_config_merge.md \ No newline at end of file diff --git a/docs/decisions/0002-opencode-json-merge-strategy.md b/docs/decisions/0002-opencode-json-merge-strategy.md new file mode 100644 index 0000000..d17c3d2 --- /dev/null +++ b/docs/decisions/0002-opencode-json-merge-strategy.md @@ -0,0 +1,21 @@ +# ADR 0002: Plugin merges into existing opencode.json rather than replacing it + +## Status +Accepted + +## Date +2026-02-20 + +## Context +Users have existing opencode.json files with personal configuration. The install command previously backed up and replaced this file entirely, destroying user settings. + +## Decision +writeOpenCodeBundle reads existing opencode.json (if present), deep-merges plugin-provided keys without overwriting user-set values, and writes the merged result. User keys always win on conflict. + +## Consequences +- Positive: User config preserved across installs. Re-installs are idempotent for user-set values. +- Negative: Plugin cannot remove or update an MCP server entry if the user already has one with the same name. +- Neutral: Backup of pre-merge file is still created for safety. + +## Plan Reference +Originated from: docs/plans/feature_opencode-commands_as_md_and_config_merge.md \ No newline at end of file diff --git a/docs/decisions/0003-opencode-permissions-default-none.md b/docs/decisions/0003-opencode-permissions-default-none.md new file mode 100644 index 0000000..4c3039f --- /dev/null +++ b/docs/decisions/0003-opencode-permissions-default-none.md @@ -0,0 +1,21 @@ +# ADR 0003: Global permissions not written to opencode.json by default + +## Status +Accepted + +## Date +2026-02-20 + +## Context +Claude commands carry allowedTools as per-command restrictions. OpenCode has no per-command permission mechanism. Writing per-command restrictions as global permissions is semantically incorrect and pollutes the user's global config. + +## Decision +--permissions defaults to "none". The plugin never writes permission or tools to opencode.json unless the user explicitly passes --permissions broad or --permissions from-command. + +## Consequences +- Positive: User's global OpenCode permissions are never silently modified. +- Negative: Users who relied on auto-set permissions must now pass the flag explicitly. +- Neutral: The "broad" and "from-command" modes still work as documented for opt-in use. + +## Plan Reference +Originated from: docs/plans/feature_opencode-commands_as_md_and_config_merge.md \ No newline at end of file diff --git a/docs/reports/index.md b/docs/reports/index.md new file mode 100644 index 0000000..1aafd6d --- /dev/null +++ b/docs/reports/index.md @@ -0,0 +1,3 @@ +| Date | Run Directory | Plan Source | Summary | +|------|--------------|-------------|---------| +| 2026-02-20 | `opencode-commands-md-merge/` | `docs/plans/feature_opencode-commands_as_md_and_config_merge.md` | Implement OpenCode commands as .md files, deep-merge opencode.json, and change --permissions default to none | \ No newline at end of file From da94da90db66d05af2322c02fdc399caaa313501 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 13:16:02 -0500 Subject: [PATCH 16/47] phase 01: type change for command files --- .../2026-02-20-phase-01-type-changes.md | 48 +++++++++++++++++++ .../decisions.md | 44 +++++++++++++++++ src/converters/claude-to-opencode.ts | 2 +- src/types/opencode.ts | 15 +++--- tests/converter.test.ts | 19 ++++---- tests/opencode-writer.test.ts | 4 ++ 6 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-01-type-changes.md create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/decisions.md diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-01-type-changes.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-01-type-changes.md new file mode 100644 index 0000000..74376ed --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-01-type-changes.md @@ -0,0 +1,48 @@ +# Phase 1 Handoff Report: Type Changes for Command Files + +**Date:** 2026-02-20 +**Phase:** 1 of 4 +**Status:** Complete + +## Summary + +Implemented type changes to support storing commands as `.md` files instead of inline in `opencode.json`. + +## Changes Made + +### 1. Type Changes (`src/types/opencode.ts`) + +- Removed `OpenCodeCommandConfig` type (lines 23-28) +- Removed `command?: Record` from `OpenCodeConfig` +- Added `OpenCodeCommandFile` type: + ```typescript + export type OpenCodeCommandFile = { + name: string + content: string + } + ``` +- Added `commandFiles: OpenCodeCommandFile[]` to `OpenCodeBundle` (with comment referencing ADR-001) + +### 2. Import Update (`src/converters/claude-to-opencode.ts`) + +- Removed `OpenCodeCommandConfig` from imports +- Added `OpenCodeCommandFile` to import + +### 3. Test Updates + +- `tests/converter.test.ts`: Updated 4 tests to use `bundle.commandFiles.find()` instead of `bundle.config.command` +- `tests/opencode-writer.test.ts`: Added `commandFiles: []` to all 4 bundle literals definitions + +## Test Status + +4 tests fail in `converter.test.ts` because the converter hasn't been updated yet to populate `commandFiles`. This is expected behavior - Phase 2 will fix these. + +``` +76 pass, 4 fail in converter.test.ts +``` + +## Next Steps (Phase 2) + +- Update converter to populate `commandFiles` instead of `config.command` +- Update writer to output `.md` files for commands +- Tests will pass after Phase 2 implementation \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md new file mode 100644 index 0000000..0befcc6 --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md @@ -0,0 +1,44 @@ +# Decision Log: OpenCode Commands as .md Files + +## Decision: ADR-001 - Store Commands as Individual .md Files + +**Date:** 2026-02-20 +**Status:** Adopted + +## Context + +The original design stored commands configurations inline in `opencode.json` under `config.command`. This tightly couples command metadata with config, making it harder to version-control commands separately and share command files. + +## Decision + +Store commands definitions as individual `.md` files in `.opencode/commands/` directory, with YAML frontmatter for metadata and markdown body for the command prompt. + +**New Type:** +```typescript +export type OpenCodeCommandFile = { + name: string // command name, used as filename stem: .md + content: string // full file content: YAML frontmatter + body +} +``` + +**Bundle Structure:** +```typescript +export type OpenCodeBundle = { + config: OpenCodeConfig + agents: OpenCodeAgentFile[] + commandFiles: OpenCodeCommandFile[] // NEW + plugins: OpenCodePluginFile[] + skillDirs: { sourceDir: string; name: string }[] +} +``` + +## Consequences + +- **Positive:** Commands can be versioned, shared, and edited independently +- **Negative:** Requires updating converter, writer, and all consumers +- **Migration:** Phase 1-4 will implement the full migration + +## Alternatives Considered + +1. Keep inline in config - Rejected: limits flexibility +2. Use separate JSON files - Rejected: YAML frontmatter is more idiomatic for commands \ No newline at end of file diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 5bff059..d73dbe6 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -8,7 +8,7 @@ import type { } from "../types/claude" import type { OpenCodeBundle, - OpenCodeCommandConfig, + OpenCodeCommandFile, OpenCodeConfig, OpenCodeMcpServer, } from "../types/opencode" diff --git a/src/types/opencode.ts b/src/types/opencode.ts index 0338892..a66546e 100644 --- a/src/types/opencode.ts +++ b/src/types/opencode.ts @@ -7,7 +7,6 @@ export type OpenCodeConfig = { tools?: Record permission?: Record> agent?: Record - command?: Record mcp?: Record } @@ -20,13 +19,6 @@ export type OpenCodeAgentConfig = { permission?: Record } -export type OpenCodeCommandConfig = { - description?: string - model?: string - agent?: string - template: string -} - export type OpenCodeMcpServer = { type: "local" | "remote" command?: string[] @@ -46,9 +38,16 @@ export type OpenCodePluginFile = { content: string } +export type OpenCodeCommandFile = { + name: string + content: string +} + export type OpenCodeBundle = { config: OpenCodeConfig agents: OpenCodeAgentFile[] + // Commands are written as individual .md files, not in opencode.json. See ADR-001. + commandFiles: OpenCodeCommandFile[] plugins: OpenCodePluginFile[] skillDirs: { sourceDir: string; name: string }[] } diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 3b3053e..979a702 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -16,8 +16,8 @@ describe("convertClaudeToOpenCode", () => { permissions: "from-commands", }) - expect(bundle.config.command?.["workflows:review"]).toBeDefined() - expect(bundle.config.command?.["plan_review"]).toBeDefined() + expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined() + expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined() const permission = bundle.config.permission as Record> expect(Object.keys(permission).sort()).toEqual([ @@ -71,8 +71,10 @@ describe("convertClaudeToOpenCode", () => { expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514") expect(parsed.data.temperature).toBe(0.1) - const modelCommand = bundle.config.command?.["workflows:work"] - expect(modelCommand?.model).toBe("openai/gpt-4o") + const modelCommand = bundle.commandFiles.find((f) => f.name === "workflows:work") + expect(modelCommand).toBeDefined() + const commandParsed = parseFrontmatter(modelCommand!.content) + expect(commandParsed.data.model).toBe("openai/gpt-4o") }) test("resolves bare Claude model aliases to full IDs", () => { @@ -208,10 +210,10 @@ describe("convertClaudeToOpenCode", () => { }) // deploy-docs has disable-model-invocation: true, should be excluded - expect(bundle.config.command?.["deploy-docs"]).toBeUndefined() + expect(bundle.commandFiles.find((f) => f.name === "deploy-docs")).toBeUndefined() // Normal commands should still be present - expect(bundle.config.command?.["workflows:review"]).toBeDefined() + expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined() }) test("rewrites .claude/ paths to .opencode/ in command bodies", () => { @@ -240,10 +242,11 @@ Run \`/compound-engineering-setup\` to create a settings file.`, permissions: "none", }) - const template = bundle.config.command?.["review"]?.template ?? "" + const commandFile = bundle.commandFiles.find((f) => f.name === "review") + expect(commandFile).toBeDefined() // Tool-agnostic path in project root — no rewriting needed - expect(template).toContain("compound-engineering.local.md") + expect(commandFile!.content).toContain("compound-engineering.local.md") }) test("rewrites .claude/ paths in agent bodies", () => { diff --git a/tests/opencode-writer.test.ts b/tests/opencode-writer.test.ts index 0bafcc0..f692bf2 100644 --- a/tests/opencode-writer.test.ts +++ b/tests/opencode-writer.test.ts @@ -21,6 +21,7 @@ describe("writeOpenCodeBundle", () => { config: { $schema: "https://opencode.ai/config.json" }, agents: [{ name: "agent-one", content: "Agent content" }], plugins: [{ name: "hook.ts", content: "export {}" }], + commandFiles: [], skillDirs: [ { name: "skill-one", @@ -44,6 +45,7 @@ describe("writeOpenCodeBundle", () => { config: { $schema: "https://opencode.ai/config.json" }, agents: [{ name: "agent-one", content: "Agent content" }], plugins: [], + commandFiles: [], skillDirs: [ { name: "skill-one", @@ -68,6 +70,7 @@ describe("writeOpenCodeBundle", () => { config: { $schema: "https://opencode.ai/config.json" }, agents: [{ name: "agent-one", content: "Agent content" }], plugins: [], + commandFiles: [], skillDirs: [ { name: "skill-one", @@ -99,6 +102,7 @@ describe("writeOpenCodeBundle", () => { config: { $schema: "https://opencode.ai/config.json", new: "config" }, agents: [], plugins: [], + commandFiles: [], skillDirs: [], } From f0b6ce9689f7cb05f643b7abbfccaac3ba93cdfe Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 13:20:48 -0500 Subject: [PATCH 17/47] phase 02: convert command to md files --- .../2026-02-20-phase-02-convert-commands.md | 63 +++++++++++++++++++ .../decisions.md | 41 +++++++++++- src/converters/claude-to-opencode.ts | 20 +++--- tests/converter.test.ts | 34 +++++++++- 4 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md new file mode 100644 index 0000000..b2d4f4e --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md @@ -0,0 +1,63 @@ +# Phase 2 Handoff Report: Convert Commands to .md Files + +**Date:** 2026-02-20 +**Phase:** 2 of 4 +**Status:** Complete + +## Summary + +Implemented `convertCommands()` to emit `.md` command files with YAML frontmatter and body, rather than returning a `Record`. Updated `convertClaudeToOpenCode()` to populate `commandFiles` in the bundle instead of `config.command`. + +## Changes Made + +### 1. Converter Function (`src/converters/claude-to-opencode.ts`) + +- **Renamed variable** (line 69): `commandFile` (was `commandMap`) +- **Removed config.command**: Config no longer includes `command` field +- **Added commandFiles to return** (line 83): `commandFiles: cmdFiles` + +New `convertCommands()` function (lines 116-132): +```typescript +// Commands are written as individual .md files rather than entries in opencode.json. +// Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001). +function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] { + const files: OpenCodeCommandFile[] = [] + for (const command of commands) { + if (command.disableModelInvocation) continue + const frontmatter: Record = { + description: command.description, + } + if (command.model && command.model !== "inherit") { + frontmatter.model = normalizeModel(command.model) + } + const content = formatFrontmatter(frontmatter, rewriteClaudePaths(command.body)) + files.push({ name: command.name, content }) + } + return files +} +``` + +### 2. Test Updates (`tests/converter.test.ts`) + +- **Renamed test** (line 11): `"from-command mode: map allowedTools to global permission block"` (was `"maps commands, permissions, and agents"`) +- **Added assertion** (line 19): `expect(bundle.config.command).toBeUndefined()` +- **Renamed test** (line 204): `"excludes commands with disable-model-invocation from commandFiles"` (was `"excludes commands with disable-model-invocation from command map"`) +- **Added new test** (lines 289-307): `"command .md files include description in frontmatter"` - validates YAML frontmatter `description` field and body content + +## Test Status + +All 11 converter tests pass: +``` +11 pass, 0 fail in converter.test.ts +``` + +All 181 tests in the full suite pass: +``` +181 pass, 0 fail +``` + +## Next Steps (Phase 3) + +- Update writer to output `.md` files for commands to `.opencode/commands/` directory +- Update config merge to handle command files from multiple plugins sources +- Ensure writer tests pass with new output structure \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md index 0befcc6..622c8b3 100644 --- a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md +++ b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md @@ -41,4 +41,43 @@ export type OpenCodeBundle = { ## Alternatives Considered 1. Keep inline in config - Rejected: limits flexibility -2. Use separate JSON files - Rejected: YAML frontmatter is more idiomatic for commands \ No newline at end of file +2. Use separate JSON files - Rejected: YAML frontmatter is more idiomatic for command + +--- + +## Decision: Phase 2 - Converter Emits .md Files + +**Date:** 2026-02-20 +**Status:** Implemented + +## Context + +The converter needs to populate `commandFiles` in the bundle rather than `config.command`. + +## Decision + +`convertCommands()` returns `OpenCodeCommandFile[]` where each file contains: +- **filename**: `.md` +- **content**: YAML frontmatter (`description`, optionally `model`) + body (template text with Claude path rewriting) + +### Frontmatter Structure +```yaml +--- +description: "Review code changes" +model: openai/gpt-4o +--- + +Template text here... +``` + +### Filtering +- Commands with `disableModelInvocation: true` are excluded from output + +### Path Rewriting +- `.claude/` paths rewritten to `.opencode/` in body content (via `rewriteClaudePaths()`) + +## Consequences + +- Converter now produces command files ready for file-system output +- Writer phase will handle writing to `.opencode/commands/` directory +- Phase 1 type changes are now fully utilizeds \ No newline at end of file diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index d73dbe6..ff6b31f 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -66,13 +66,12 @@ export function convertClaudeToOpenCode( options: ClaudeToOpenCodeOptions, ): OpenCodeBundle { const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options)) - const commandMap = convertCommands(plugin.commands) + const cmdFiles = convertCommands(plugin.commands) const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : [] const config: OpenCodeConfig = { $schema: "https://opencode.ai/config.json", - command: Object.keys(commandMap).length > 0 ? commandMap : undefined, mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined, } @@ -81,6 +80,7 @@ export function convertClaudeToOpenCode( return { config, agents: agentFiles, + commandFiles: cmdFiles, plugins, skillDirs: plugin.skills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })), } @@ -111,20 +111,22 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) { } } -function convertCommands(commands: ClaudeCommand[]): Record { - const result: Record = {} +// Commands are written as individual .md files rather than entries in opencode.json. +// Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001). +function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] { + const files: OpenCodeCommandFile[] = [] for (const command of commands) { if (command.disableModelInvocation) continue - const entry: OpenCodeCommandConfig = { + const frontmatter: Record = { description: command.description, - template: rewriteClaudePaths(command.body), } if (command.model && command.model !== "inherit") { - entry.model = normalizeModel(command.model) + frontmatter.model = normalizeModel(command.model) } - result[command.name] = entry + const content = formatFrontmatter(frontmatter, rewriteClaudePaths(command.body)) + files.push({ name: command.name, content }) } - return result + return files } function convertMcp(servers: Record): Record { diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 979a702..873ce2b 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -8,7 +8,7 @@ import type { ClaudePlugin } from "../src/types/claude" const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") describe("convertClaudeToOpenCode", () => { - test("maps commands, permissions, and agents", async () => { + test("from-command mode: map allowedTools to global permission block", async () => { const plugin = await loadClaudePlugin(fixtureRoot) const bundle = convertClaudeToOpenCode(plugin, { agentMode: "subagent", @@ -16,6 +16,7 @@ describe("convertClaudeToOpenCode", () => { permissions: "from-commands", }) + expect(bundle.config.command).toBeUndefined() expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined() expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined() @@ -201,7 +202,7 @@ describe("convertClaudeToOpenCode", () => { expect(parsed.data.mode).toBe("primary") }) - test("excludes commands with disable-model-invocation from command map", async () => { + test("excludes commands with disable-model-invocation from commandFiles", async () => { const plugin = await loadClaudePlugin(fixtureRoot) const bundle = convertClaudeToOpenCode(plugin, { agentMode: "subagent", @@ -276,4 +277,33 @@ Run \`/compound-engineering-setup\` to create a settings file.`, // Tool-agnostic path in project root — no rewriting needed expect(agentFile!.content).toContain("compound-engineering.local.md") }) + + test("command .md files include description in frontmatter", () => { + const plugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [], + commands: [ + { + name: "test-cmd", + description: "Test description", + body: "Do the thing", + sourcePath: "/tmp/plugin/commands/test-cmd.md", + }, + ], + skills: [], + } + + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const commandFile = bundle.commandFiles.find((f) => f.name === "test-cmd") + expect(commandFile).toBeDefined() + const parsed = parseFrontmatter(commandFile!.content) + expect(parsed.data.description).toBe("Test description") + expect(parsed.body).toContain("Do the thing") + }) }) From 5abddbcbd9262ea40e375665575b0a8bfce000e2 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 13:28:25 -0500 Subject: [PATCH 18/47] phase 03: write command md files --- ...2026-02-20-phase-03-write-command-files.md | 54 +++++++++++++++++++ .../decisions.md | 48 ++++++++++++++++- src/targets/opencode.ts | 29 +++++++--- tests/opencode-writer.test.ts | 52 ++++++++++++++++++ 4 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-03-write-command-files.md diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-03-write-command-files.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-03-write-command-files.md new file mode 100644 index 0000000..84fc3e3 --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-03-write-command-files.md @@ -0,0 +1,54 @@ +# Phase 3 Handoff Report: Write Command Files as .md + +## Date +2026-02-20 + +## Phase +3 of feature: OpenCode Commands as .md Files, Config Merge, and Permissions Default Fix + +## Summary + +Implemented the `commandsDir` path resolution and command file writing in `src/targets/opencode.ts`. + +## Changes Made + +### 1. Updated `src/targets/opencode.ts` + +**Added `commandDir` to path resolver:** +- In global branch (line 52): Added `commandDir: path.join(outputRoot, "commands")` with inline comment +- In custom branch (line 66): Added `commandDir: path.join(outputRoot, ".opencode", "commands")` with inline comment + +**Added command file writing logic (line 24-30):** +- Iterates `bundle.commandFiles` +- Writes each command as `/.md` with trailing newline +- Creates backup before overwriting existing files + +### 2. Added tests in `tests/opencode-writer.test.ts` + +- `"writes command files as .md in commands/ directory"` - Tests global-style output (`.config/opencode`) +- `"backs up existing command .md file before overwriting"` - Tests backup creation + +## Test Results + +``` +bun test tests/opencode-writer.test.ts +6 pass, 0 fail +``` + +All existing tests continue to pass: +``` +bun test +183 pass, 0 fail +``` + +## Deliverables Complete + +- [x] Updated `src/targets/opencode.ts` with commandDir path and write logic +- [x] New tests in `tests/opencode-writer.test.ts` +- [x] All tests pass + +## Notes + +- Used `openCodePaths` instead of `paths` variable name to avoid shadowing the imported `path` module +- Command files are written with trailing newline (`content + "\n"`) +- Backup uses timestamp format `.bak.2026-02-20T...` \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md index 622c8b3..eb602b9 100644 --- a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md +++ b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md @@ -80,4 +80,50 @@ Template text here... - Converter now produces command files ready for file-system output - Writer phase will handle writing to `.opencode/commands/` directory -- Phase 1 type changes are now fully utilizeds \ No newline at end of file +- Phase 1 type changes are now fully utilizeds + +--- + +## Decision: Phase 3 - Writer Writes Command .md Files + +**Date:** 2026-02-20 +**Status:** Implemented + +## Context + +The writer needs to write command files from the bundle to the file system. + +## Decision + +In `src/targets/opencode.ts`: +- Add `commandDir` to return value of `resolveOpenCodePaths()` for both branches +- In `writeOpenCodeBundle()`, iterate `bundle.commandFiles` and write each as `/.md` with backup-before-overwrite + +### Path Resolution + +- Global branch (basename is "opencode" or ".opencode"): `commandsDir: path.join(outputRoot, "commands")` +- Custom branch: `commandDir: path.join(outputRoot, ".opencode", "commands")` + +### Writing Logic + +```typescript +for (const commandFile of bundle.commandFiles) { + const dest = path.join(openCodePaths.commandDir, `${commandFile.name}.md`) + const cmdBackupPath = await backupFile(dest) + if (cmdBackupPath) { + console.log(`Backed up existing command file to ${cmdBackupPath}`) + } + await writeText(dest, commandFile.content + "\n") +} +``` + +## Consequences + +- Command files are written to `.opencode/commands/` or `commands/` directory +- Existing files are backed up before overwriting +- Files content includes trailing newline + +## Alternatives Considered + +1. Use intermediate variable for commandDir - Rejected: caused intermittent undefined errors +2. Use direct property reference `openCodePaths.commandDir` - Chosen: more reliable \ No newline at end of file diff --git a/src/targets/opencode.ts b/src/targets/opencode.ts index 24e8faf..3f9b80d 100644 --- a/src/targets/opencode.ts +++ b/src/targets/opencode.ts @@ -3,29 +3,38 @@ import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/f import type { OpenCodeBundle } from "../types/opencode" export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise { - const paths = resolveOpenCodePaths(outputRoot) - await ensureDir(paths.root) + const openCodePaths = resolveOpenCodePaths(outputRoot) + await ensureDir(openCodePaths.root) - const backupPath = await backupFile(paths.configPath) + const backupPath = await backupFile(openCodePaths.configPath) if (backupPath) { console.log(`Backed up existing config to ${backupPath}`) } - await writeJson(paths.configPath, bundle.config) + await writeJson(openCodePaths.configPath, bundle.config) - const agentsDir = paths.agentsDir + const agentsDir = openCodePaths.agentsDir for (const agent of bundle.agents) { await writeText(path.join(agentsDir, `${agent.name}.md`), agent.content + "\n") } + for (const commandFile of bundle.commandFiles) { + const dest = path.join(openCodePaths.commandDir, `${commandFile.name}.md`) + const cmdBackupPath = await backupFile(dest) + if (cmdBackupPath) { + console.log(`Backed up existing command file to ${cmdBackupPath}`) + } + await writeText(dest, commandFile.content + "\n") + } + if (bundle.plugins.length > 0) { - const pluginsDir = paths.pluginsDir + const pluginsDir = openCodePaths.pluginsDir for (const plugin of bundle.plugins) { await writeText(path.join(pluginsDir, plugin.name), plugin.content + "\n") } } if (bundle.skillDirs.length > 0) { - const skillsRoot = paths.skillsDir + const skillsRoot = openCodePaths.skillsDir for (const skill of bundle.skillDirs) { await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name)) } @@ -43,6 +52,8 @@ function resolveOpenCodePaths(outputRoot: string) { agentsDir: path.join(outputRoot, "agents"), pluginsDir: path.join(outputRoot, "plugins"), skillsDir: path.join(outputRoot, "skills"), + // .md command files; alternative to the command key in opencode.json + commandDir: path.join(outputRoot, "commands"), } } @@ -53,5 +64,7 @@ function resolveOpenCodePaths(outputRoot: string) { agentsDir: path.join(outputRoot, ".opencode", "agents"), pluginsDir: path.join(outputRoot, ".opencode", "plugins"), skillsDir: path.join(outputRoot, ".opencode", "skills"), + // .md command files; alternative to the command key in opencode.json + commandDir: path.join(outputRoot, ".opencode", "commands"), } -} +} \ No newline at end of file diff --git a/tests/opencode-writer.test.ts b/tests/opencode-writer.test.ts index f692bf2..e017437 100644 --- a/tests/opencode-writer.test.ts +++ b/tests/opencode-writer.test.ts @@ -120,4 +120,56 @@ describe("writeOpenCodeBundle", () => { const backupContent = JSON.parse(await fs.readFile(path.join(outputRoot, backupFileName!), "utf8")) expect(backupContent.custom).toBe("value") }) + + test("writes command files as .md in commands/ directory", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-")) + const outputRoot = path.join(tempRoot, ".config", "opencode") + const bundle: OpenCodeBundle = { + config: { $schema: "https://opencode.ai/config.json" }, + agents: [], + plugins: [], + commandFiles: [{ name: "my-cmd", content: "---\ndescription: Test\n---\n\nDo something." }], + skillDirs: [], + } + + await writeOpenCodeBundle(outputRoot, bundle) + + const cmdPath = path.join(outputRoot, "commands", "my-cmd.md") + expect(await exists(cmdPath)).toBe(true) + + const content = await fs.readFile(cmdPath, "utf8") + expect(content).toBe("---\ndescription: Test\n---\n\nDo something.\n") + }) + + test("backs up existing command .md file before overwriting", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-backup-")) + const outputRoot = path.join(tempRoot, ".opencode") + const commandsDir = path.join(outputRoot, "commands") + await fs.mkdir(commandsDir, { recursive: true }) + + const cmdPath = path.join(commandsDir, "my-cmd.md") + await fs.writeFile(cmdPath, "old content\n") + + const bundle: OpenCodeBundle = { + config: { $schema: "https://opencode.ai/config.json" }, + agents: [], + plugins: [], + commandFiles: [{ name: "my-cmd", content: "---\ndescription: New\n---\n\nNew content." }], + skillDirs: [], + } + + await writeOpenCodeBundle(outputRoot, bundle) + + // New content should be written + const content = await fs.readFile(cmdPath, "utf8") + expect(content).toBe("---\ndescription: New\n---\n\nNew content.\n") + + // Backup should exist + const files = await fs.readdir(commandsDir) + const backupFileName = files.find((f) => f.startsWith("my-cmd.md.bak.")) + expect(backupFileName).toBeDefined() + + const backupContent = await fs.readFile(path.join(commandsDir, backupFileName!), "utf8") + expect(backupContent).toBe("old content\n") + }) }) From 3914dfdebe366fa8fe9175db5676e884a2e42e2c Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 13:30:17 -0500 Subject: [PATCH 19/47] phase 04: deep merge opencode json --- ...pencode-commands-as-md-and-config-merge.md | 574 ++++++++++++++++++ .../2026-02-20-phase-04-merge-config.md | 45 ++ .../decisions.md | 58 +- src/targets/opencode.ts | 59 +- tests/opencode-writer.test.ts | 91 ++- 5 files changed, 818 insertions(+), 9 deletions(-) create mode 100644 docs/plans/feature_opencode-commands-as-md-and-config-merge.md create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-04-merge-config.md diff --git a/docs/plans/feature_opencode-commands-as-md-and-config-merge.md b/docs/plans/feature_opencode-commands-as-md-and-config-merge.md new file mode 100644 index 0000000..f5e4a67 --- /dev/null +++ b/docs/plans/feature_opencode-commands-as-md-and-config-merge.md @@ -0,0 +1,574 @@ +# Feature: OpenCode Commands as .md Files, Config Merge, and Permissions Default Fix + +**Type:** feature + bug fix (consolidated) +**Date:** 2026-02-20 +**Starting point:** Branch `main` at commit `174cd4c` +**Create feature branch:** `feature/opencode-commands-md-merge-permissions` +**Baseline tests:** 180 pass, 0 fail (run `bun test` to confirm before starting) + +--- + +## Context + +### User-Facing Goal + +When running `bunx @every-env/compound-plugin install compound-engineering --to opencode`, three problems exist: + +1. **Commands overwrite `opencode.json`**: Plugin commands are written into the `command` key of `opencode.json`, which replaces the user's existing configuration file (the writer does `writeJson(configPath, bundle.config)` — a full overwrite). The user loses their personal settings (model, theme, provider keys, MCP servers they previously configured). + +2. **Commands should be `.md` files, not JSON**: OpenCode supports defining commands as individual `.md` files in `~/.config/opencode/commands/`. This is additive and non-destructive — one file per command, never touches `opencode.json`. + +3. **`--permissions broad` is the default and pollutes global config**: The `--permissions` flag defaults to `"broad"`, which writes 14 `permission: allow` entries and 14 `tools: true` entries into `opencode.json` on every install. These are global settings that affect ALL OpenCode sessions, not just plugin commands. Even `--permissions from-commands` is semantically wrong — it unions per-command `allowedTools` restrictions into a single global block, which inverts restriction semantics (a command allowing only `Read` gets merged with one allowing `Bash`, producing global `bash: allow`). + +### Expected Behavior After This Plan + +- Commands are written as `~/.config/opencode/commands/.md` with YAML frontmatter (`description`, `model`). The `command` key is never written to `opencode.json`. +- `opencode.json` is deep-merged (not overwritten): existing user keys survive, plugin's MCP servers are added. User values win on conflict. +- `--permissions` defaults to `"none"` — no `permission` or `tools` entries are written to `opencode.json` unless the user explicitly passes `--permissions broad` or `--permissions from-commands`. + +### Relevant File Paths + +| File | Current State on `main` | What Changes | +|---|---|---| +| `src/types/opencode.ts` | `OpenCodeBundle` has no `commandFiles` field. Has `OpenCodeCommandConfig` type and `command` field on `OpenCodeConfig`. | Add `OpenCodeCommandFile` type. Add `commandFiles` to `OpenCodeBundle`. Remove `OpenCodeCommandConfig` type and `command` field from `OpenCodeConfig`. | +| `src/converters/claude-to-opencode.ts` | `convertCommands()` returns `Record`. Result set on `config.command`. `applyPermissions()` writes `config.permission` and `config.tools`. | `convertCommands()` returns `OpenCodeCommandFile[]`. `config.command` is never set. No changes to `applyPermissions()` itself. | +| `src/targets/opencode.ts` | `writeOpenCodeBundle()` does `writeJson(configPath, bundle.config)` — full overwrite. No `commandsDir`. No merge logic. | Add `commandsDir` to path resolver. Write command `.md` files with backup. Replace overwrite with `mergeOpenCodeConfig()` — read existing, deep-merge, write back. | +| `src/commands/install.ts` | `--permissions` default is `"broad"` (line 51). | Change default to `"none"`. Update description string. | +| `src/utils/files.ts` | Has `readJson()`, `pathExists()`, `backupFile()` already. | No changes needed — utilities already exist. | +| `tests/converter.test.ts` | Tests reference `bundle.config.command` (lines 19, 74, 202-214, 243). Test `"maps commands, permissions, and agents"` tests `from-commands` mode. | Update all to use `bundle.commandFiles`. Rename permission-related test to clarify opt-in nature. | +| `tests/opencode-writer.test.ts` | 4 tests, none have `commandFiles` in bundles. `"backs up existing opencode.json before overwriting"` test expects full overwrite. | Add `commandFiles: []` to all existing bundles. Rewrite backup test to test merge behavior. Add new tests for command file writing and merge. | +| `tests/cli.test.ts` | 10 tests. None check for commands directory. | Add test for `--permissions none` default. Add test for command `.md` file existence. | +| `AGENTS.md` | Line 10: "Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`." | Update to document commands go to `commands/.md`, `opencode.json` is deep-merged. | +| `README.md` | Line 54: "OpenCode output is written to `~/.config/opencode` by default, with `opencode.json` at the root..." | Update to document `.md` command files, merge behavior, `--permissions` default. | + +### Prior Context (Pre-Investigation) + +- **No `docs/decisions/` directory on `main`**: ADRs will be created fresh during this plan. +- **No prior plans touch the same area**: The `2026-02-08-feat-convert-local-md-settings-for-opencode-codex-plan.md` discusses path rewriting in command bodies but does not touch command output format or permissions. +- **OpenCode docs (confirmed via context7 MCP, library `/sst/opencode`):** + - Command `.md` frontmatter supports: `description`, `agent`, `model`. Does NOT support `permission` or `tools`. Placed in `~/.config/opencode/commands/` (global) or `.opencode/commands/` (project). + - Agent `.md` frontmatter supports: `description`, `mode`, `model`, `temperature`, `tools`, `permission`. Placed in `~/.config/opencode/agents/` or `.opencode/agents/`. + - `opencode.json` is the only place for: `mcp`, global `permission`, global `tools`, `model`, `provider`, `theme`, `server`, `compaction`, `watcher`, `share`. + +### Rejected Approaches + +**1. Map `allowedTools` to per-agent `.md` frontmatter permissions.** +Rejected: Claude commands are not agents. There is no per-command-to-per-agent mapping. Commands don't specify which agent to run with. Even if they did, the union of multiple commands' restrictions onto a single agent's permissions loses the per-command scoping. Agent `.md` files DO support `permission` in frontmatter, but this would require creating synthetic agents just to hold permissions — misleading and fragile. + +**2. Write permissions into command `.md` file frontmatter.** +Rejected: OpenCode command `.md` files only support `description`, `agent`, `model` in frontmatter. There is no `permission` or `tools` key. Confirmed via context7 docs. Anything else is silently ignored. + +**3. Keep `from-commands` as the default but fix the flattening logic.** +Rejected: There is no correct way to flatten per-command tool restrictions into a single global permission block. Any flattening loses information and inverts semantics. + +**4. Remove the `--permissions` flag entirely.** +Rejected: Some users may want to write permissions to `opencode.json` as a convenience. Keeping the flag with a changed default preserves optionality. + +**5. Write commands as both `.md` files AND in `opencode.json` `command` block.** +Rejected: Redundant and defeats the purpose of avoiding `opencode.json` pollution. `.md` files are the sole output format. + +--- + +## Decision Record + +### Decision 1: Commands emitted as individual `.md` files, never in `opencode.json` + +- **Decision:** `convertCommands()` returns `OpenCodeCommandFile[]` (one `.md` file per command with YAML frontmatter). The `command` key is never set on `OpenCodeConfig`. The writer creates `/.md` for each file. +- **Context:** OpenCode supports two equivalent formats for commands — JSON in config and `.md` files. The `.md` format is additive (new files) rather than destructive (rewriting JSON). This is consistent with how agents and skills are already handled as `.md` files. +- **Alternatives rejected:** JSON-only (destructive), both formats (redundant). See Rejected Approaches above. +- **Assumptions:** OpenCode resolves commands from the `commands/` directory at runtime. Confirmed via docs. +- **Reversal trigger:** If OpenCode deprecates `.md` command files or the format changes incompatibly. + +### Decision 2: `opencode.json` deep-merged, not overwritten + +- **Decision:** `writeOpenCodeBundle()` reads the existing `opencode.json` (if present), deep-merges plugin-provided keys (MCP servers, and optionally permission/tools if `--permissions` is not `none`) without overwriting user-set values, and writes the merged result. User keys always win on conflict. +- **Context:** Users have personal configuration in `opencode.json` (API keys, model preferences, themes, existing MCP servers). The current full-overwrite destroys all of this. +- **Alternatives rejected:** Skip writing `opencode.json` entirely — rejected because MCP servers must be written there (no `.md` alternative exists for MCP). +- **Assumptions:** `readJson()` and `pathExists()` already exist in `src/utils/files.ts`. Malformed JSON in existing file should warn and fall back to plugin-only config (do not crash, do not destroy). +- **Reversal trigger:** If OpenCode adds a separate mechanism for plugin MCP server registration that doesn't involve `opencode.json`. + +### Decision 3: `--permissions` default changed from `"broad"` to `"none"` + +- **Decision:** The `--permissions` CLI flag default changes from `"broad"` to `"none"`. No `permission` or `tools` keys are written to `opencode.json` unless the user explicitly opts in. +- **Context:** `"broad"` silently writes 14 global tool permissions. `"from-commands"` has a semantic inversion bug (unions per-command restrictions into global allows). Both are destructive to user config. `applyPermissions()` already short-circuits on `"none"` (line 299: `if (mode === "none") return`), so no changes to that function are needed. +- **Alternatives rejected:** Fix `from-commands` flattening — impossible to do correctly with global-only target. Remove flag entirely — too restrictive for power users. +- **Assumptions:** The `applyPermissions()` function with mode `"none"` leaves `config.permission` and `config.tools` as `undefined`. +- **Reversal trigger:** If OpenCode adds per-command permission scoping, `from-commands` could become meaningful again. + +--- + +## ADRs To Create + +Create `docs/decisions/` directory (does not exist on `main`). ADRs follow `AGENTS.md` numbering convention: `0001-short-title.md`. + +### ADR 0001: OpenCode commands written as `.md` files, not in `opencode.json` + +- **Context:** OpenCode supports two equivalent formats for custom commands. Writing to `opencode.json` requires overwriting or merging the user's config file. Writing `.md` files is additive and non-destructive. +- **Decision:** The OpenCode target always emits commands as individual `.md` files in the `commands/` subdirectory. The `command` key is never written to `opencode.json` by this tool. +- **Consequences:** + - Positive: Installs are non-destructive. Commands are visible as individual files, easy to inspect. Consistent with agents/skills handling. + - Negative: Users inspecting `opencode.json` won't see plugin commands; they must look in `commands/`. + - Neutral: Requires OpenCode >= the version with command file support (confirmed stable). + +### ADR 0002: Plugin merges into existing `opencode.json` rather than replacing it + +- **Context:** Users have existing `opencode.json` files with personal configuration. The install command previously backed up and replaced this file entirely, destroying user settings. +- **Decision:** `writeOpenCodeBundle` reads existing `opencode.json` (if present), deep-merges plugin-provided keys without overwriting user-set values, and writes the merged result. User keys always win on conflict. +- **Consequences:** + - Positive: User config preserved across installs. Re-installs are idempotent for user-set values. + - Negative: Plugin cannot remove or update an MCP server entry if the user already has one with the same name. + - Neutral: Backup of pre-merge file is still created for safety. + +### ADR 0003: Global permissions not written to `opencode.json` by default + +- **Context:** Claude commands carry `allowedTools` as per-command restrictions. OpenCode has no per-command permission mechanism. Writing per-command restrictions as global permissions is semantically incorrect and pollutes the user's global config. +- **Decision:** `--permissions` defaults to `"none"`. The plugin never writes `permission` or `tools` to `opencode.json` unless the user explicitly passes `--permissions broad` or `--permissions from-commands`. +- **Consequences:** + - Positive: User's global OpenCode permissions are never silently modified. + - Negative: Users who relied on auto-set permissions must now pass the flag explicitly. + - Neutral: The `"broad"` and `"from-commands"` modes still work as documented for opt-in use. + +--- + +## Assumptions & Invalidation Triggers + +- **Assumption:** OpenCode command `.md` frontmatter supports `description`, `agent`, `model` and does NOT support `permission` or `tools`. + - **If this changes:** The converter could emit per-command permissions in command frontmatter, making `from-commands` mode semantically correct. Phase 2 would need a new code path. + +- **Assumption:** `readJson()` and `pathExists()` exist in `src/utils/files.ts` and work as expected. + - **If this changes:** Phase 4's merge logic needs alternative I/O utilities. + +- **Assumption:** `applyPermissions()` with mode `"none"` returns early at line 299 and does not set `config.permission` or `config.tools`. + - **If this changes:** The merge logic in Phase 4 might still merge stale data. Verify before implementing. + +- **Assumption:** 180 tests pass on `main` at commit `174cd4c` with `bun test`. + - **If this changes:** Do not proceed until the discrepancy is understood. + +- **Assumption:** `formatFrontmatter()` in `src/utils/frontmatter.ts` handles `Record` data and string body, producing valid YAML frontmatter. It filters out `undefined` values (line 35). It already supports nested objects/arrays via `formatYamlLine()`. + - **If this changes:** Phase 2's command file content generation would produce malformed output. + +- **Assumption:** The `backupFile()` function in `src/utils/files.ts` returns `null` if the file does not exist, and returns the backup path if it does. It does NOT throw on missing files. + - **If this changes:** Phase 4's backup-before-write for command files would need error handling. + +--- + +## Phases + +### Phase 1: Add `OpenCodeCommandFile` type and update `OpenCodeBundle` + +**What:** In `src/types/opencode.ts`: +- Add a new type `OpenCodeCommandFile` with `name: string` (command name, used as filename stem) and `content: string` (full file content: YAML frontmatter + body). +- Add `commandFiles: OpenCodeCommandFile[]` field to `OpenCodeBundle`. +- Remove `command?: Record` from `OpenCodeConfig`. +- Remove the `OpenCodeCommandConfig` type entirely (lines 23-28). + +**Why:** This is the foundational type change that all subsequent phases depend on. Commands move from the config object to individual file entries in the bundle. + +**Test first:** + +File: `tests/converter.test.ts` + +Before making any type changes, update the test file to reflect the new shape. The existing tests will fail because they reference `bundle.config.command` and `OpenCodeBundle` doesn't have `commandFiles` yet. + +Tests to modify (they will fail after type changes, then pass after Phase 2): +- `"maps commands, permissions, and agents"` (line 11): Change `bundle.config.command?.["workflows:review"]` to `bundle.commandFiles.find(f => f.name === "workflows:review")`. Change `bundle.config.command?.["plan_review"]` to `bundle.commandFiles.find(f => f.name === "plan_review")`. +- `"normalizes models and infers temperature"` (line 60): Change `bundle.config.command?.["workflows:work"]` to check `bundle.commandFiles.find(f => f.name === "workflows:work")` and parse its frontmatter for model. +- `"excludes commands with disable-model-invocation from command map"` (line 202): Change `bundle.config.command?.["deploy-docs"]` to `bundle.commandFiles.find(f => f.name === "deploy-docs")`. +- `"rewrites .claude/ paths to .opencode/ in command bodies"` (line 217): Change `bundle.config.command?.["review"]?.template` to access `bundle.commandFiles.find(f => f.name === "review")?.content`. + +Also update `tests/opencode-writer.test.ts`: +- Add `commandFiles: []` to every `OpenCodeBundle` literal in all 4 existing tests (lines 20, 43, 67, 98). These bundles currently only have `config`, `agents`, `plugins`, `skillDirs`. + +**Implementation:** + +In `src/types/opencode.ts`: +1. Remove lines 23-28 (`OpenCodeCommandConfig` type). +2. Remove line 10 (`command?: Record`) from `OpenCodeConfig`. +3. Add after line 47: +```typescript +export type OpenCodeCommandFile = { + name: string // command name, used as the filename stem: .md + content: string // full file content: YAML frontmatter + body +} +``` +4. Add `commandFiles: OpenCodeCommandFile[]` to `OpenCodeBundle` (between `agents` and `plugins`). + +In `src/converters/claude-to-opencode.ts`: +- Update the import on line 11: Remove `OpenCodeCommandConfig` from the import. Add `OpenCodeCommandFile`. + +**Code comments required:** +- Above the `commandFiles` field in `OpenCodeBundle`: `// Commands are written as individual .md files, not in opencode.json. See ADR-001.` + +**Verification:** `bun test` will show failures in converter tests (they reference the old command format). This is expected — Phase 2 fixes them. + +--- + +### Phase 2: Convert `convertCommands()` to emit `.md` command files + +**What:** In `src/converters/claude-to-opencode.ts`: +- Rewrite `convertCommands()` (line 114) to return `OpenCodeCommandFile[]` instead of `Record`. +- Each command becomes a `.md` file with YAML frontmatter (`description`, optionally `model`) and body (the template text with Claude path rewriting applied). +- In `convertClaudeToOpenCode()` (line 64): replace `commandMap` with `commandFiles`. Remove `config.command` assignment. Add `commandFiles` to returned bundle. + +**Why:** This is the core conversion logic change that implements ADR-001. + +**Test first:** + +File: `tests/converter.test.ts` + +The tests were already updated in Phase 1 to reference `bundle.commandFiles`. Now they need to pass. Specific assertions: + +1. Rename `"maps commands, permissions, and agents"` to `"from-commands mode: maps allowedTools to global permission block"` — to clarify this tests an opt-in mode, not the default. + - Assert `bundle.config.command` is `undefined` (it no longer exists on the type, but accessing it returns `undefined`). + - Assert `bundle.commandFiles.find(f => f.name === "workflows:review")` is defined. + - Assert `bundle.commandFiles.find(f => f.name === "plan_review")` is defined. + - Permission assertions remain unchanged (they test `from-commands` mode explicitly). + +2. `"normalizes models and infers temperature"`: + - Find `workflows:work` in `bundle.commandFiles`, parse its frontmatter with `parseFrontmatter()`, assert `data.model === "openai/gpt-4o"`. + +3. `"excludes commands with disable-model-invocation from command map"` — rename to `"excludes commands with disable-model-invocation from commandFiles"`: + - Assert `bundle.commandFiles.find(f => f.name === "deploy-docs")` is `undefined`. + - Assert `bundle.commandFiles.find(f => f.name === "workflows:review")` is defined. + +4. `"rewrites .claude/ paths to .opencode/ in command bodies"`: + - Find `review` in `bundle.commandFiles`, assert `content` contains `"compound-engineering.local.md"`. + +5. Add NEW test: `"command .md files include description in frontmatter"`: + - Create a minimal `ClaudePlugin` with one command (`name: "test-cmd"`, `description: "Test description"`, `body: "Do the thing"`). + - Convert with `permissions: "none"`. + - Find the command file, parse frontmatter, assert `data.description === "Test description"`. + - Assert the body (after frontmatter) contains `"Do the thing"`. + +**Implementation:** + +In `src/converters/claude-to-opencode.ts`: + +Replace lines 114-128 (`convertCommands` function): +```typescript +// Commands are written as individual .md files rather than entries in opencode.json. +// Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001). +function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] { + const files: OpenCodeCommandFile[] = [] + for (const command of commands) { + if (command.disableModelInvocation) continue + const frontmatter: Record = { + description: command.description, + } + if (command.model && command.model !== "inherit") { + frontmatter.model = normalizeModel(command.model) + } + const content = formatFrontmatter(frontmatter, rewriteClaudePaths(command.body)) + files.push({ name: command.name, content }) + } + return files +} +``` + +Replace lines 64-87 (`convertClaudeToOpenCode` function body): +- Change line 69: `const commandFiles = convertCommands(plugin.commands)` +- Change lines 73-77 (config construction): Remove the `command: ...` line. Config should only have `$schema` and `mcp`. +- Change line 81-86 (return): Replace `plugins` in the return with `commandFiles, plugins` (add `commandFiles` field to returned bundle). + +**Code comments required:** +- Above `convertCommands()`: `// Commands are written as individual .md files rather than entries in opencode.json.` and `// Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001).` + +**Verification:** Run `bun test tests/converter.test.ts`. All converter tests must pass. Then run `bun test` — writer tests should still fail (they expect the old bundle shape; fixed in Phase 1's test updates) but converter tests pass. + +--- + +### Phase 3: Add `commandsDir` to path resolver and write command files + +**What:** In `src/targets/opencode.ts`: +- Add `commandsDir` to the return value of `resolveOpenCodePaths()` for both branches (global and custom output dir). +- In `writeOpenCodeBundle()`, iterate `bundle.commandFiles` and write each as `/.md` with backup-before-overwrite. + +**Why:** This creates the file output mechanism for command `.md` files. Separated from Phase 4 (merge logic) for testability. + +**Test first:** + +File: `tests/opencode-writer.test.ts` + +Add these new tests: + +1. `"writes command files as .md in commands/ directory"`: + - Create a bundle with one `commandFiles` entry: `{ name: "my-cmd", content: "---\ndescription: Test\n---\n\nDo something." }`. + - Use an output root of `path.join(tempRoot, ".config", "opencode")` (global-style). + - Assert `exists(path.join(outputRoot, "commands", "my-cmd.md"))` is true. + - Read the file, assert content matches (with trailing newline: `content + "\n"`). + +2. `"backs up existing command .md file before overwriting"`: + - Pre-create `commands/my-cmd.md` with old content. + - Write a bundle with a `commandFiles` entry for `my-cmd`. + - Assert a `.bak.` file exists in `commands/` directory. + - Assert new content is written. + +**Implementation:** + +In `resolveOpenCodePaths()`: +- In the global branch (line 39-46): Add `commandsDir: path.join(outputRoot, "commands")` with comment: `// .md command files; alternative to the command key in opencode.json` +- In the custom branch (line 49-56): Add `commandsDir: path.join(outputRoot, ".opencode", "commands")` with same comment. + +In `writeOpenCodeBundle()`: +- After the agents loop (line 18), add: +```typescript +const commandsDir = paths.commandsDir +for (const commandFile of bundle.commandFiles) { + const dest = path.join(commandsDir, `${commandFile.name}.md`) + const cmdBackupPath = await backupFile(dest) + if (cmdBackupPath) { + console.log(`Backed up existing command file to ${cmdBackupPath}`) + } + await writeText(dest, commandFile.content + "\n") +} +``` + +**Code comments required:** +- Inline comment on `commandsDir` in both `resolveOpenCodePaths` branches: `// .md command files; alternative to the command key in opencode.json` + +**Verification:** Run `bun test tests/opencode-writer.test.ts`. The two new command file tests must pass. Existing tests must still pass (they have `commandFiles: []` from Phase 1 updates). + +--- + +### Phase 4: Replace config overwrite with deep-merge + +**What:** In `src/targets/opencode.ts`: +- Replace `writeJson(paths.configPath, bundle.config)` (line 13) with a call to a new `mergeOpenCodeConfig()` function. +- `mergeOpenCodeConfig()` reads the existing `opencode.json` (if present), merges plugin-provided keys using user-wins-on-conflict strategy, and returns the merged config. +- Import `pathExists` and `readJson` from `../utils/files` (add to existing import on line 2). + +**Why:** This implements ADR-002 — the user's existing config is preserved across installs. + +**Test first:** + +File: `tests/opencode-writer.test.ts` + +Modify existing test and add new tests: + +1. Rename `"backs up existing opencode.json before overwriting"` (line 88) to `"merges plugin config into existing opencode.json without destroying user keys"`: + - Pre-create `opencode.json` with `{ $schema: "https://opencode.ai/config.json", custom: "value" }`. + - Write a bundle with `config: { $schema: "...", mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } } }`. + - Assert merged config has BOTH `custom: "value"` (user key) AND `mcp["plugin-server"]` (plugin key). + - Assert backup file exists with original content. + +2. NEW: `"merges mcp servers without overwriting user entries"`: + - Pre-create `opencode.json` with `{ mcp: { "user-server": { type: "local", command: "uvx", args: ["user-srv"] } } }`. + - Write a bundle with `config.mcp` containing both `"plugin-server"` (new) and `"user-server"` (conflict — different args). + - Assert both servers exist in merged output. + - Assert `user-server` keeps user's original args (user wins on conflict). + - Assert `plugin-server` is present with plugin's args. + +3. NEW: `"preserves unrelated user keys when merging opencode.json"`: + - Pre-create `opencode.json` with `{ model: "my-model", theme: "dark", mcp: {} }`. + - Write a bundle with `config: { $schema: "...", mcp: { "plugin-server": ... }, permission: { "bash": "allow" } }`. + - Assert `model` and `theme` are preserved. + - Assert plugin additions are present. + +**Implementation:** + +Add to imports in `src/targets/opencode.ts` line 2: +```typescript +import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files" +import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode" +``` + +Add `mergeOpenCodeConfig()` function: +```typescript +async function mergeOpenCodeConfig( + configPath: string, + incoming: OpenCodeConfig, +): Promise { + // If no existing config, write plugin config as-is + if (!(await pathExists(configPath))) return incoming + + let existing: OpenCodeConfig + try { + existing = await readJson(configPath) + } catch { + // Safety first per AGENTS.md -- do not destroy user data even if their config is malformed. + // Warn and fall back to plugin-only config rather than crashing. + console.warn( + `Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.` + ) + return incoming + } + + // User config wins on conflict -- see ADR-002 + // MCP servers: add plugin entries, skip keys already in user config. + const mergedMcp = { + ...(incoming.mcp ?? {}), + ...(existing.mcp ?? {}), // existing takes precedence (overwrites same-named plugin entries) + } + + // Permission: add plugin entries, skip keys already in user config. + const mergedPermission = incoming.permission + ? { + ...(incoming.permission), + ...(existing.permission ?? {}), // existing takes precedence + } + : existing.permission + + // Tools: same pattern + const mergedTools = incoming.tools + ? { + ...(incoming.tools), + ...(existing.tools ?? {}), + } + : existing.tools + + return { + ...existing, // all user keys preserved + $schema: incoming.$schema ?? existing.$schema, + mcp: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined, + permission: mergedPermission, + tools: mergedTools, + } +} +``` + +In `writeOpenCodeBundle()`, replace line 13 (`await writeJson(paths.configPath, bundle.config)`) with: +```typescript +const merged = await mergeOpenCodeConfig(paths.configPath, bundle.config) +await writeJson(paths.configPath, merged) +``` + +**Code comments required:** +- Above `mergeOpenCodeConfig()`: `// Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002.` +- On the `...(existing.mcp ?? {})` line: `// existing takes precedence (overwrites same-named plugin entries)` +- On malformed JSON catch: `// Safety first per AGENTS.md -- do not destroy user data even if their config is malformed.` + +**Verification:** Run `bun test tests/opencode-writer.test.ts`. All tests must pass including the renamed test and the 2 new merge tests. + +--- + +### Phase 5: Change `--permissions` default to `"none"` + +**What:** In `src/commands/install.ts`, change line 51 `default: "broad"` to `default: "none"`. Update the description string. + +**Why:** This implements ADR-003 — stops polluting user's global config with permissions by default. + +**Test first:** + +File: `tests/cli.test.ts` + +Add these tests: + +1. `"install --to opencode uses permissions:none by default"`: + - Run install with no `--permissions` flag against the fixture plugin. + - Read the written `opencode.json`. + - Assert it does NOT contain a `permission` key. + - Assert it does NOT contain a `tools` key. + +2. `"install --to opencode --permissions broad writes permission block"`: + - Run install with `--permissions broad` against the fixture plugin. + - Read the written `opencode.json`. + - Assert it DOES contain a `permission` key with values. + +**Implementation:** + +In `src/commands/install.ts`: +- Line 51: Change `default: "broad"` to `default: "none"`. +- Line 52: Change description to `"Permission mapping written to opencode.json: none (default) | broad | from-commands"`. + +**Code comments required:** +- On the `default: "none"` line: `// Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003.` + +**Verification:** Run `bun test tests/cli.test.ts`. All CLI tests must pass including the 2 new permission tests. Then run `bun test` — all tests (180 original + new ones) must pass. + +--- + +### Phase 6: Update `AGENTS.md` and `README.md` + +**What:** Update documentation to reflect all three changes. + +**Why:** Keeps docs accurate for future contributors and users. + +**Test first:** No tests required for documentation changes. + +**Implementation:** + +In `AGENTS.md` line 10, replace: +``` +- **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`. +``` +with: +``` +- **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`. For OpenCode, commands go to `~/.config/opencode/commands/.md`; `opencode.json` is deep-merged (never overwritten wholesale). +``` + +In `README.md` line 54, replace: +``` +OpenCode output is written to `~/.config/opencode` by default, with `opencode.json` at the root and `agents/`, `skills/`, and `plugins/` alongside it. +``` +with: +``` +OpenCode output is written to `~/.config/opencode` by default. Commands are written as individual `.md` files to `~/.config/opencode/commands/.md`. Agents, skills, and plugins are written to the corresponding subdirectories alongside. `opencode.json` (MCP servers) is deep-merged into any existing file -- user keys such as `model`, `theme`, and `provider` are preserved, and user values win on conflicts. Command files are backed up before being overwritten. +``` + +Also update `AGENTS.md` to add a Repository Docs Conventions section if not present: +``` +## Repository Docs Conventions + +- **ADRs** live in `docs/decisions/` and are numbered with 4-digit zero-padding: `0001-short-title.md`, `0002-short-title.md`, etc. +- **Orchestrator run reports** live in `docs/reports/`. + +When recording a significant decision (new provider, output format change, merge strategy), create an ADR in `docs/decisions/` following the numbering sequence. +``` + +**Code comments required:** None. + +**Verification:** Read the updated files and confirm accuracy. Run `bun test` to confirm no regressions. + +--- + +## TDD Enforcement + +The executing agent MUST follow this sequence for every phase that touches source code: + +1. Write the test(s) first in the test file. +2. Run `bun test ` and confirm the new/modified tests FAIL (red). +3. Implement the code change. +4. Run `bun test ` and confirm the new/modified tests PASS (green). +5. Run `bun test` (all tests) and confirm no regressions. + +**Exception:** Phase 6 is documentation only. Run `bun test` after to confirm no regressions but no red/green cycle needed. + +**Note on Phase 1:** Type changes alone will cause test failures. Phase 1 and Phase 2 are tightly coupled — the tests updated in Phase 1 will not pass until Phase 2's implementation is complete. The executing agent should: +1. Update tests in Phase 1 (expect them to fail — both due to type errors and logic changes). +2. Implement type changes in Phase 1. +3. Implement converter changes in Phase 2. +4. Confirm all converter tests pass after Phase 2. + +--- + +## Constraints + +**Do not modify:** +- `src/converters/claude-to-opencode.ts` lines 294-417 (`applyPermissions()`, `normalizeTool()`, `parseToolSpec()`, `normalizePattern()`) — these functions are correct for `"broad"` and `"from-commands"` modes. Only the default that triggers them is changing. +- Any files under `tests/fixtures/` — these are data files, not test logic. +- `src/types/claude.ts` — no changes to source types. +- `src/parsers/claude.ts` — no changes to parser logic. +- `src/utils/files.ts` — all needed utilities already exist. Do not add new utility functions. +- `src/utils/frontmatter.ts` — already handles the needed formatting. + +**Dependencies not to add:** None. No new npm/bun packages. + +**Patterns to follow:** +- Existing writer tests in `tests/opencode-writer.test.ts` use `fs.mkdtemp()` for temp directories and the local `exists()` helper function. +- Existing CLI tests in `tests/cli.test.ts` use `Bun.spawn()` to invoke the CLI. +- Existing converter tests in `tests/converter.test.ts` use `loadClaudePlugin(fixtureRoot)` for real fixtures and inline `ClaudePlugin` objects for isolated tests. +- ADR format: Follow `AGENTS.md` numbering convention `0001-short-title.md` with sections: Status, Date, Context, Decision, Consequences, Plan Reference. +- Commits: Use conventional commit format. Reference ADRs in commit bodies. +- Branch: Create `feature/opencode-commands-md-merge-permissions` from `main`. + +## Final Checklist + +After all phases complete: +- [ ] `bun test` passes all tests (180 original + new ones, 0 fail) +- [ ] `docs/decisions/0001-opencode-command-output-format.md` exists +- [ ] `docs/decisions/0002-opencode-json-merge-strategy.md` exists +- [ ] `docs/decisions/0003-opencode-permissions-default-none.md` exists +- [ ] `opencode.json` is never fully overwritten — merge logic confirmed by test +- [ ] Commands are written as `.md` files — confirmed by test +- [ ] `--permissions` defaults to `"none"` — confirmed by CLI test +- [ ] `AGENTS.md` and `README.md` updated to reflect new behavior diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-04-merge-config.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-04-merge-config.md new file mode 100644 index 0000000..86abf0e --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-04-merge-config.md @@ -0,0 +1,45 @@ +# Phase 4 Handoff: Deep-Merge opencode.json + +**Date:** 2026-02-20 +**Status:** Complete + +## Summary + +Implemented `mergeOpenCodeConfig()` function that performs deep-merge of plugin config into existing opencode.json with user-wins-on-conflict strategy. + +## Changes Made + +### 1. Updated `src/targets/opencode.ts` + +- Added imports for `pathExists`, `readJson`, and `OpenCodeConfig` type +- Added `mergeOpenCodeConfig()` function before `writeOpenCodeBundle()` +- Replaced direct `writeJson()` call with merge logic + +### 2. Updated `tests/opencode-writer.test.ts` + +- Renamed existing backup test to `"merges plugin config into existing opencode.json without destroying user keys"` +- Added two new tests: + - `"merges mcp servers without overwriting user entry"` + - `"preserves unrelated user keys when merging opencode.json"` + +## Verification + +All 8 tests pass: +``` +bun test tests/opencode-writer.test.ts +8 pass, 0 fail +``` + +## Key Behaviors + +1. **User keys preserved**: All existing config keys remain intact +2. **MCP merge**: Plugin MCP servers added, user servers kept on conflict +3. **Permission merge**: Plugin permissions added, user permissions kept on conflict +4. **Tools merge**: Plugin tools added, user tools kept on conflict +5. **Fallback**: If existing config is malformed JSON, writes plugin-only config (safety first) +6. **Backup**: Original config is still backed up before writing merged result + +## Next Steps + +- Proceed to next phase (if any) +- Consider adding decision log entry for ADR-002 (user-wins-on-conflict strategy) \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md index eb602b9..75c085a 100644 --- a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md +++ b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md @@ -126,4 +126,60 @@ for (const commandFile of bundle.commandFiles) { ## Alternatives Considered 1. Use intermediate variable for commandDir - Rejected: caused intermittent undefined errors -2. Use direct property reference `openCodePaths.commandDir` - Chosen: more reliable \ No newline at end of file +2. Use direct property reference `openCodePaths.commandDir` - Chosen: more reliable + +--- + +## Decision: ADR-002 - User-Wins-On-Conflict for Config Merge + +**Date:** 2026-02-20 +**Status:** Adopted + +## Context + +When merging plugin config into existing opencode.json, conflicts may occur (e.g., same MCP server name with different configuration). The merge strategy must decide which value wins. + +## Decision + +**User config wins on conflict.** When plugin and user both define the same key (MCP server name, permission, tool), the user's value takes precedence. + +### Rationale + +- Safety first: Do not overwrite user data with plugin defaults +- Users have explicit intent in their local config +- Plugins should add new entries without modifying user's existing setup +- Aligns with AGENTS.md principle: "Do not delete or overwrite user data" + +### Merge Algorithm + +```typescript +const mergedMcp = { + ...(incoming.mcp ?? {}), + ...(existing.mcp ?? {}), // existing takes precedence +} +``` + +Same pattern applied to `permission` and `tools`. + +### Fallback Behavior + +If existing `opencode.json` is malformed JSON, warn and write plugin-only config rather than crashing: +```typescript +} catch { + console.warn(`Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`) + return incoming +} +``` + +## Consequences + +- Positive: User config never accidentally overwritten +- Positive: Plugin can add new entries without conflict +- Negative: Plugin cannot modify user's existing server configuration (must use unique names) +- Negative: Silent merge may mask configuration issues if user expects plugin override + +## Alternatives Considered + +1. Plugin wins on conflict - Rejected: would overwrite user data +2. Merge and combine arrays - Rejected: MCP servers are keyed objects, not array +3. Fail on conflict - Rejected: breaks installation workflow \ No newline at end of file diff --git a/src/targets/opencode.ts b/src/targets/opencode.ts index 3f9b80d..e0e89ff 100644 --- a/src/targets/opencode.ts +++ b/src/targets/opencode.ts @@ -1,6 +1,58 @@ import path from "path" -import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files" -import type { OpenCodeBundle } from "../types/opencode" +import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files" +import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode" + +// Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002. +async function mergeOpenCodeConfig( + configPath: string, + incoming: OpenCodeConfig, +): Promise { + // If no existing config, write plugin config as-is + if (!(await pathExists(configPath))) return incoming + + let existing: OpenCodeConfig + try { + existing = await readJson(configPath) + } catch { + // Safety first per AGENTS.md -- do not destroy user data even if their config is malformed. + // Warn and fall back to plugin-only config rather than crashing. + console.warn( + `Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.` + ) + return incoming + } + + // User config wins on conflict -- see ADR-002 + // MCP servers: add plugin entry, skip keys already in user config. + const mergedMcp = { + ...(incoming.mcp ?? {}), + ...(existing.mcp ?? {}), // existing takes precedence (overwrites same-named plugin entry) + } + + // Permission: add plugin entry, skip keys already in user config. + const mergedPermission = incoming.permission + ? { + ...(incoming.permission), + ...(existing.permission ?? {}), // existing takes precedence + } + : existing.permission + + // Tools: same pattern + const mergedTools = incoming.tools + ? { + ...(incoming.tools), + ...(existing.tools ?? {}), + } + : existing.tools + + return { + ...existing, // all user keys preserved + $schema: incoming.$schema ?? existing.$schema, + mcp: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined, + permission: mergedPermission, + tools: mergedTools, + } +} export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise { const openCodePaths = resolveOpenCodePaths(outputRoot) @@ -10,7 +62,8 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu if (backupPath) { console.log(`Backed up existing config to ${backupPath}`) } - await writeJson(openCodePaths.configPath, bundle.config) + const merged = await mergeOpenCodeConfig(openCodePaths.configPath, bundle.config) + await writeJson(openCodePaths.configPath, merged) const agentsDir = openCodePaths.agentsDir for (const agent of bundle.agents) { diff --git a/tests/opencode-writer.test.ts b/tests/opencode-writer.test.ts index e017437..5c02cc1 100644 --- a/tests/opencode-writer.test.ts +++ b/tests/opencode-writer.test.ts @@ -88,18 +88,22 @@ describe("writeOpenCodeBundle", () => { expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false) }) - test("backs up existing opencode.json before overwriting", async () => { + test("merges plugin config into existing opencode.json without destroying user keys", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-backup-")) const outputRoot = path.join(tempRoot, ".opencode") const configPath = path.join(outputRoot, "opencode.json") - // Create existing config + // Create existing config with user keys await fs.mkdir(outputRoot, { recursive: true }) const originalConfig = { $schema: "https://opencode.ai/config.json", custom: "value" } await fs.writeFile(configPath, JSON.stringify(originalConfig, null, 2)) + // Bundle adds mcp server but keeps user's custom key const bundle: OpenCodeBundle = { - config: { $schema: "https://opencode.ai/config.json", new: "config" }, + config: { + $schema: "https://opencode.ai/config.json", + mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } } + }, agents: [], plugins: [], commandFiles: [], @@ -108,9 +112,11 @@ describe("writeOpenCodeBundle", () => { await writeOpenCodeBundle(outputRoot, bundle) - // New config should be written + // Merged config should have both user key and plugin key const newConfig = JSON.parse(await fs.readFile(configPath, "utf8")) - expect(newConfig.new).toBe("config") + expect(newConfig.custom).toBe("value") // user key preserved + expect(newConfig.mcp).toBeDefined() + expect(newConfig.mcp["plugin-server"]).toBeDefined() // Backup should exist with original content const files = await fs.readdir(outputRoot) @@ -121,6 +127,81 @@ describe("writeOpenCodeBundle", () => { expect(backupContent.custom).toBe("value") }) + test("merges mcp servers without overwriting user entry", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-merge-mcp-")) + const outputRoot = path.join(tempRoot, ".opencode") + const configPath = path.join(outputRoot, "opencode.json") + + // Create existing config with user's mcp server + await fs.mkdir(outputRoot, { recursive: true }) + const existingConfig = { + mcp: { "user-server": { type: "local", command: "uvx", args: ["user-srv"] } } + } + await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2)) + + // Bundle adds plugin server AND has conflicting user-server with different args + const bundle: OpenCodeBundle = { + config: { + $schema: "https://opencode.ai/config.json", + mcp: { + "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] }, + "user-server": { type: "local", command: "uvx", args: ["plugin-override"] } // conflict + } + }, + agents: [], + plugins: [], + commandFiles: [], + skillDirs: [], + } + + await writeOpenCodeBundle(outputRoot, bundle) + + // Merged config should have both servers, with user-server keeping user's original args + const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8")) + expect(mergedConfig.mcp).toBeDefined() + expect(mergedConfig.mcp["plugin-server"]).toBeDefined() + expect(mergedConfig.mcp["user-server"]).toBeDefined() + expect(mergedConfig.mcp["user-server"].args[0]).toBe("user-srv") // user wins on conflict + expect(mergedConfig.mcp["plugin-server"].args[0]).toBe("plugin-srv") // plugin entry present + }) + + test("preserves unrelated user keys when merging opencode.json", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-preserve-")) + const outputRoot = path.join(tempRoot, ".opencode") + const configPath = path.join(outputRoot, "opencode.json") + + // Create existing config with multiple user keys + await fs.mkdir(outputRoot, { recursive: true }) + const existingConfig = { + model: "my-model", + theme: "dark", + mcp: {} + } + await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2)) + + // Bundle adds plugin-specific keys + const bundle: OpenCodeBundle = { + config: { + $schema: "https://opencode.ai/config.json", + mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } }, + permission: { "bash": "allow" } + }, + agents: [], + plugins: [], + commandFiles: [], + skillDirs: [], + } + + await writeOpenCodeBundle(outputRoot, bundle) + + // All user keys preserved + const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8")) + expect(mergedConfig.model).toBe("my-model") + expect(mergedConfig.theme).toBe("dark") + expect(mergedConfig.mcp["plugin-server"]).toBeDefined() + expect(mergedConfig.permission["bash"]).toBe("allow") + }) + test("writes command files as .md in commands/ directory", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-")) const outputRoot = path.join(tempRoot, ".config", "opencode") From 27319bd85f8c09044b2126b05784590bc0ea0207 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 13:31:31 -0500 Subject: [PATCH 20/47] phase 05: change permissions default to none --- ...2026-02-20-phase-05-permissions-default.md | 35 +++++++++ .../decisions.md | 56 ++++++++++++- src/commands/install.ts | 4 +- tests/cli.test.ts | 78 +++++++++++++++++++ 4 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-05-permissions-default.md diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-05-permissions-default.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-05-permissions-default.md new file mode 100644 index 0000000..191b1f1 --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-05-permissions-default.md @@ -0,0 +1,35 @@ +# Phase 5 Handoff: Change `--permissions` Default to `"none"` + +## Summary + +Changed the default value of `--permissions` from `"broad"` to `"none"` in the install command to prevent polluting user OpenCode config with global permissions. + +## Changes Made + +### 1. Code Change (`src/commands/install.ts`) + +- Line 51: Changed `default: "broad"` to `default: "none"` with comment referencing ADR-003 +- Line 52: Updated description to clarify "none (default)" + +```typescript +permissions: { + type: "string", + default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003. + description: "Permission mapping written to opencode.json: none (default) | broad | from-command", +}, +``` + +### 2. New Tests (`tests/cli.test.ts`) + +Added two new tests: +1. `"install --to opencode uses permissions:none by default"` - Verifies no `permission` or `tools` keys in opencode.json when using default +2. `"install --to opencode --permissions broad writes permission block"` - Verifies `permission` key is written when explicitly using `--permissions broad` + +## Test Results + +- CLI tests: 12 pass, 0 fail +- All tests: 187 pass, 0 fail + +## Next Steps + +None - Phase 5 is complete. \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md index 75c085a..3e7bd28 100644 --- a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md +++ b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md @@ -181,5 +181,57 @@ If existing `opencode.json` is malformed JSON, warn and write plugin-only config ## Alternatives Considered 1. Plugin wins on conflict - Rejected: would overwrite user data -2. Merge and combine arrays - Rejected: MCP servers are keyed objects, not array -3. Fail on conflict - Rejected: breaks installation workflow \ No newline at end of file +2. Merge and combine arrays - Rejected: MCP servers are keyed object, not array +3. Fail on conflict - Rejected: breaks installation workflow + +--- + +## Decision: ADR-003 - Permissions Default "none" for OpenCode Output + +**Date:** 2026-02-20 +**Status:** Implemented + +## Context + +When installing a Claude plugin to OpenCode format, the `--permissions` flag determines whether permission/tool mappings is written to `opencode.json`. The previous default was `"broad"`, which writes global permissions to the user's config file. + +## Decision + +Change the default value of `--permissions` from `"broad"` to `"none"` in the install command. + +### Rationale + +- **User safety:** Writing global permissions to `opencode.json` pollutes user config and may grant unintended access +- **Principle alignment:** Follows AGENTS.md "Do not delete or overwrite user data" +- **Explicit opt-in:** Users must explicitly request `--permissions broad` to write permissions to their config +- **Backward compatible:** Existing workflows using `--permissions broad` continues to work + +### Implementation + +In `src/commands/install.ts`: +```typescript +permissions: { + type: "string", + default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003. + description: "Permission mapping written to opencode.json: none (default) | broad | from-command", +}, +``` + +### Test Coverage + +Added two CLI tests cases: +1. `install --to opencode uses permissions:none by default` - Verifies no `permission` or `tools` key in output +2. `install --to opencode --permissions broad writes permission block` - Verifies `permission` key is written when explicitly requested + +## Consequences + +- **Positive:** User config remains clean by default +- **Positive:** Explicit opt-in required for permission writing +- **Negative:** Users migrating from older versions need to explicitly use `--permissions broad` if they want permissions +- **Migration path:** Document the change in migration notes + +## Alternatives Considered + +1. Keep "broad" as default - Rejected: pollutes user config +2. Prompt user interactively - Rejected: breaks CLI automation +3. Write to separate file - Rejected: OpenCode expects permissions in opencode.json \ No newline at end of file diff --git a/src/commands/install.ts b/src/commands/install.ts index 77f5ea4..eeb5a85 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -48,8 +48,8 @@ export default defineCommand({ }, permissions: { type: "string", - default: "broad", - description: "Permission mapping: none | broad | from-commands", + default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003. + description: "Permission mapping written to opencode.json: none (default) | broad | from-command", }, agentMode: { type: "string", diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 49c20a6..be9ecde 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -426,4 +426,82 @@ describe("CLI", () => { expect(await exists(path.join(piRoot, "prompts", "workflows-review.md"))).toBe(true) expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true) }) + + test("install --to opencode uses permissions:none by default", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-none-")) + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + + const proc = Bun.spawn([ + "bun", + "run", + "src/index.ts", + "install", + fixtureRoot, + "--to", + "opencode", + "--output", + tempRoot, + ], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Installed compound-engineering") + + const opencodeJsonPath = path.join(tempRoot, "opencode.json") + const content = await fs.readFile(opencodeJsonPath, "utf-8") + const json = JSON.parse(content) + + expect(json).not.toHaveProperty("permission") + expect(json).not.toHaveProperty("tools") + }) + + test("install --to opencode --permissions broad writes permission block", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-broad-")) + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + + const proc = Bun.spawn([ + "bun", + "run", + "src/index.ts", + "install", + fixtureRoot, + "--to", + "opencode", + "--permissions", + "broad", + "--output", + tempRoot, + ], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Installed compound-engineering") + + const opencodeJsonPath = path.join(tempRoot, "opencode.json") + const content = await fs.readFile(opencodeJsonPath, "utf-8") + const json = JSON.parse(content) + + expect(json).toHaveProperty("permission") + expect(json.permission).not.toBeNull() + }) }) From 06d4aea70c946e7cc95d0032f6df43564de46d77 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 13:32:52 -0500 Subject: [PATCH 21/47] phase 06: update documentation --- AGENTS.md | 9 +++- README.md | 2 +- .../2026-02-20-phase-06-update-docs.md | 29 +++++++++++ .../decisions.md | 48 ++++++++++++++++++- 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-06-update-docs.md diff --git a/AGENTS.md b/AGENTS.md index 471b900..cbc86f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ This repository contains a Bun/TypeScript CLI that converts Claude Code plugins - **Branching:** Create a feature branch for any non-trivial change. If already on the correct branch for the task, keep using it; do not create additional branches or worktrees unless explicitly requested. - **Safety:** Do not delete or overwrite user data. Avoid destructive commands. - **Testing:** Run `bun test` after changes that affect parsing, conversion, or output. -- **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`. +- **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`. For OpenCode, command go to `~/.config/opencode/commands/.md`; `opencode.json` is deep-merged (never overwritten wholesale). - **ASCII-first:** Use ASCII unless the file already contains Unicode. ## Adding a New Target Provider (e.g., Codex) @@ -46,3 +46,10 @@ Add a new provider when at least one of these is true: - You can write fixtures + tests that validate the mapping. Avoid adding a provider if the target spec is unstable or undocumented. + +## Repository Docs Convention + +- **ADRs** live in `docs/decisions/` and are numbered with 4-digit zero-padding: `0001-short-title.md`, `0002-short-title.md`, etc. +- **Orchestrator run reports** live in `docs/reports/`. + +When recording a significant decision (new provider, output format change, merge strategy), create an ADR in `docs/decisions/` following the numbering sequence. diff --git a/README.md b/README.md index 27e4ae7..5885038 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Local dev: bun run src/index.ts install ./plugins/compound-engineering --to opencode ``` -OpenCode output is written to `~/.config/opencode` by default, with `opencode.json` at the root and `agents/`, `skills/`, and `plugins/` alongside it. +OpenCode output is written to `~/.config/opencode` by default. Command are written as individual `.md` files to `~/.config/opencode/commands/.md`. Agent, skills, and plugins are written to the corresponding subdirectory alongside. `opencode.json` (MCP servers) is deep-merged into any existing file -- user keys such as `model`, `theme`, and `provider` are preserved, and user values win on conflicts. Command files are backed up before being overwritten. Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit). Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash` → `Execute`, `Write` → `Create`, etc.) and namespace prefixes are stripped from commands. Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability. diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-06-update-docs.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-06-update-docs.md new file mode 100644 index 0000000..eafdca0 --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-06-update-docs.md @@ -0,0 +1,29 @@ +# Phase 6: Update AGENTS.md and README.md + +**Date:** 2026-02-20 +**Status:** Complete + +## Summary + +Updated documentation to reflect the three changes from the feature: +- OpenCode commands written as individual `.md` files +- Deep-merge for `opencode.json` +- Command file backup before overwrite + +## Changes Made + +### AGENTS.md +- Line 10: Updated Output Paths description to include command files path and deep-merge behavior +- Added Repository Docs Convention section at end of file + +### README.md +- Line 54: Updated OpenCode output description to include command files and deep-merge behavior + +## Verification + +- Read updated files and confirmed accuracy +- Run `bun test` - no regressions + +## Next Steps + +- Ready for merge to main branch \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md index 3e7bd28..e3d398f 100644 --- a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md +++ b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md @@ -233,5 +233,49 @@ Added two CLI tests cases: ## Alternatives Considered 1. Keep "broad" as default - Rejected: pollutes user config -2. Prompt user interactively - Rejected: breaks CLI automation -3. Write to separate file - Rejected: OpenCode expects permissions in opencode.json \ No newline at end of file +2. Prompt user interactively - Rejected: break CLI automation +3. Write to separate file - Rejected: OpenCode expects permissions in opencode.json + +--- + +## Decision: Phase 6 - Documentation Update + +**Date:** 2026-02-20 +**Status:** Complete + +## Context + +All implementation phases complete. Documentation needs to reflect the final behavior. + +## Decision + +Update AGENTS.md and README.md: + +### AGENTS.md Changes + +1. **Line 10** - Updated Output Paths description: + ``` + - **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`. For OpenCode, command go to `~/.config/opencode/commands/.md`; `opencode.json` is deep-merged (never overwritten wholesale). + ``` + +2. **Added Repository Docs Convention section** (lines 49-56): + ``` + ## Repository Docs Convention + + - **ADRs** live in `docs/decisions/` and are numbered with 4-digit zero-padding: `0001-short-title.md`, `0002-short-title.md`, etc. + - **Orchestrator run reports** live in `docs/reports/`. + + When recording a significant decision (new provider, output format change, merge strategy), create an ADR in `docs/decisions/` following the numbering sequence. + ``` + +### README.md Changes + +1. **Line 54** - Updated OpenCode output description: + ``` + OpenCode output is written to `~/.config/opencode` by default. Command are written as individual `.md` files to `~/.config/opencode/commands/.md`. Agent, skills, and plugin are written to the corresponding subdirectory alongside. `opencode.json` (MCP servers) is deep-merged into any existing file -- user keys such as `model`, `theme`, and `provider` are preserved, and user values win on conflicts. Command files are backed up before being overwritten. + ``` + +## Verification + +- Read updated files and confirmed accuracy +- Run `bun test` - no regression \ No newline at end of file From 4db39f8b42ec02c3efa5692e3cfd4286a9be0e4e Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 13:34:49 -0500 Subject: [PATCH 22/47] verification: opencode-command-md-merge --- .../2026-02-20-verification-report.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-verification-report.md diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-verification-report.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-verification-report.md new file mode 100644 index 0000000..34a0f50 --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-verification-report.md @@ -0,0 +1,37 @@ +# Verification Report: OpenCode Commands as .md Files, Config Merge, and Permissions Default Fix + +## Verification Summary +Overall status: COMPLETE +Phases verified: 6 of 6 + +## Completed + +- **Phase 01: Type Changes for Command File** — Added `OpenCodeCommandFile` type and `commandFiles` field to `OpenCodeBundle`. Removed `OpenCodeCommandConfig` and `command` from `OpenCodeConfig`. Tests updated to use new bundle structure. + +- **Phase 02: Convert Commands to .md Files** — Implemented `convertCommands()` to return `OpenCodeCommandFile[]` with YAML frontmatter (`description`, `model`) and body. Removed `config.command` assignment. Updated tests verify commandFiles exist and command config is undefined. + +- **Phase 03: Write Command Files** — Added `commandDir` to path resolver (both global and custom branches). Implemented command file writing with backup-before-overwrite in `writeOpenCodeBundle()`. New tests verify file creation and backup. + +- **Phase 04: Deep-Merge Config** — Implemented `mergeOpenCodeConfig()` with user-wins-on-conflict strategy. Preserves user keys (`model`, `theme`, `provider`), merges MCP servers, handles malformed JSON with fallback. Updated tests verify merge behavior. + +- **Phase 05: Permissions Default to "none"** — Changed `--permissions` default from `"broad"` to `"none"` in install command. Added code comment referencing ADR-003. Tests verify no permission/tools written by default, and explicit `--permissions broad` works. + +- **Phase 06: Update Documentation** — Updated AGENTS.md line 10 with command path and deep-merge behavior. Added Repository Docs Convention section (lines 50-55). Updated README.md line 54 with complete behavior description. + +## Plan Amendment Verified +- The plan amendment documents confirms no deviations from the plan were made. All phases implemented as specified. + +## ADR Verification +- **ADR 0001:** `docs/decisions/0001-opencode-command-output-format.md` exists with correct content (Status: Accepted, Context, Decision, Consequences, Plan Reference) +- **ADR 0002:** `docs/decisions/0002-opencode-json-merge-strategy.md` exists with correct content (Status: Accepted, user-wins-on-conflict strategy documented) +- **ADR 0003:** `docs/decisions/0003-opencode-permissions-default-none.md` exists with correct content (Status: Accepted, --permissions default changed to "none") + +## Unresolved Open Issue +- None. All handoff reports show "Status: Complete" with no open issues remaining. + +## Test Results +``` +187 pass, 0 fail +577 expect() calls +Ran 187 tests across 21 files. +``` \ No newline at end of file From 2f05f215b30da705928a84fc647e0a2ae89ca0e9 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 20 Feb 2026 10:46:33 -0800 Subject: [PATCH 23/47] =?UTF-8?q?release:=20v2.35.2=20=E2=80=94=20brainsto?= =?UTF-8?q?rm-to-plan=20traceability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strengthen brainstorm intake: thorough read, carry ALL content, reference source inline - Add origin: frontmatter field to all three plan templates (MINIMAL, MORE, A LOT) - Rename References to Sources sections, add brainstorm as first entry - Add brainstorm cross-check checklist in final review step Co-Authored-By: Claude Opus 4.6 --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- plugins/compound-engineering/CHANGELOG.md | 8 ++++ .../commands/workflows/plan.md | 43 +++++++++++++++---- 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a1b7be9..de0fa74 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ { "name": "compound-engineering", "description": "AI-powered development tools that get smarter with every use. Make each unit of engineering work easier than the last. Includes 29 specialized agents, 22 commands, and 19 skills.", - "version": "2.34.0", + "version": "2.35.2", "author": { "name": "Kieran Klaassen", "url": "https://github.com/kieranklaassen", diff --git a/plugins/compound-engineering/.claude-plugin/plugin.json b/plugins/compound-engineering/.claude-plugin/plugin.json index 51a3d03..5bb71ad 100644 --- a/plugins/compound-engineering/.claude-plugin/plugin.json +++ b/plugins/compound-engineering/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "compound-engineering", - "version": "2.35.1", + "version": "2.35.2", "description": "AI-powered development tools. 29 agents, 22 commands, 19 skills, 1 MCP server for code review, research, design, and workflow automation.", "author": { "name": "Kieran Klaassen", diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index 731f70b..ede6b06 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -5,6 +5,14 @@ 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.35.2] - 2026-02-20 + +### Changed + +- **`/workflows:plan` brainstorm integration** — When plan finds a brainstorm document, it now heavily references it throughout. Added `origin:` frontmatter field to plan templates, brainstorm cross-check in final review, and "Sources" section at the bottom of all three plan templates (MINIMAL, MORE, A LOT). Brainstorm decisions are carried forward with explicit references (`see brainstorm: `) and a mandatory scan before finalizing ensures nothing is dropped. + +--- + ## [2.35.1] - 2026-02-18 ### Changed diff --git a/plugins/compound-engineering/commands/workflows/plan.md b/plugins/compound-engineering/commands/workflows/plan.md index 5cd43dc..9b1ab88 100644 --- a/plugins/compound-engineering/commands/workflows/plan.md +++ b/plugins/compound-engineering/commands/workflows/plan.md @@ -36,11 +36,19 @@ ls -la docs/brainstorms/*.md 2>/dev/null | head -10 - If multiple candidates match, use the most recent one **If a relevant brainstorm exists:** -1. Read the brainstorm document -2. Announce: "Found brainstorm from [date]: [topic]. Using as context for planning." -3. Extract key decisions, chosen approach, and open questions -4. **Skip the idea refinement questions below** - the brainstorm already answered WHAT to build -5. Use brainstorm decisions as input to the research phase +1. Read the brainstorm document **thoroughly** — every section matters +2. Announce: "Found brainstorm from [date]: [topic]. Using as foundation for planning." +3. Extract and carry forward **ALL** of the following into the plan: + - Key decisions and their rationale + - Chosen approach and why alternatives were rejected + - Constraints and requirements discovered during brainstorming + - Open questions (flag these for resolution during planning) + - Success criteria and scope boundaries + - Any specific technical choices or patterns discussed +4. **Skip the idea refinement questions below** — the brainstorm already answered WHAT to build +5. Use brainstorm content as the **primary input** to research and planning phases +6. **Critical: The brainstorm is the origin document.** Throughout the plan, reference specific decisions with `(see brainstorm: docs/brainstorms/)` when carrying forward conclusions. Do not paraphrase decisions in a way that loses their original context — link back to the source. +7. **Do not omit brainstorm content** — if the brainstorm discussed it, the plan must address it (even if briefly). Scan each brainstorm section before finalizing the plan to verify nothing was dropped. **If multiple brainstorms could match:** Use **AskUserQuestion tool** to ask which brainstorm to use, or whether to proceed without one. @@ -180,6 +188,7 @@ title: [Issue Title] type: [feat|fix|refactor] status: active date: YYYY-MM-DD +origin: docs/brainstorms/YYYY-MM-DD--brainstorm.md # if originated from brainstorm, otherwise omit --- # [Issue Title] @@ -207,8 +216,9 @@ class Test end ``` -## References +## Sources +- **Origin brainstorm:** [docs/brainstorms/YYYY-MM-DD--brainstorm.md](path) — include if plan originated from a brainstorm - Related issue: #[issue_number] - Documentation: [relevant_docs_url] ```` @@ -233,6 +243,7 @@ title: [Issue Title] type: [feat|fix|refactor] status: active date: YYYY-MM-DD +origin: docs/brainstorms/YYYY-MM-DD--brainstorm.md # if originated from brainstorm, otherwise omit --- # [Issue Title] @@ -277,8 +288,9 @@ date: YYYY-MM-DD [What could block or complicate this] -## References & Research +## Sources & References +- **Origin brainstorm:** [docs/brainstorms/YYYY-MM-DD--brainstorm.md](path) — include if plan originated from a brainstorm - Similar implementations: [file_path:line_number] - Best practices: [documentation_url] - Related PRs: #[pr_number] @@ -306,6 +318,7 @@ title: [Issue Title] type: [feat|fix|refactor] status: active date: YYYY-MM-DD +origin: docs/brainstorms/YYYY-MM-DD--brainstorm.md # if originated from brainstorm, otherwise omit --- # [Issue Title] @@ -416,7 +429,11 @@ date: YYYY-MM-DD [What docs need updating] -## References & Research +## Sources & References + +### Origin + +- **Brainstorm document:** [docs/brainstorms/YYYY-MM-DD--brainstorm.md](path) — include if plan originated from a brainstorm. Key decisions carried forward: [list 2-3 major decisions from brainstorm] ### Internal References @@ -495,6 +512,16 @@ end ### 6. Final Review & Submission +**Brainstorm cross-check (if plan originated from a brainstorm):** + +Before finalizing, re-read the brainstorm document and verify: +- [ ] Every key decision from the brainstorm is reflected in the plan +- [ ] The chosen approach matches what was decided in the brainstorm +- [ ] Constraints and requirements from the brainstorm are captured in acceptance criteria +- [ ] Open questions from the brainstorm are either resolved or flagged +- [ ] The `origin:` frontmatter field points to the brainstorm file +- [ ] The Sources section includes the brainstorm with a summary of carried-forward decisions + **Pre-submission Checklist:** - [ ] Title is searchable and descriptive From e9008536043afdbed61fa9f1ff15ce3a07f4abc5 Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 15:37:36 -0500 Subject: [PATCH 24/47] docs: plan amendment for opencode-commands-md-merge Why: All phases implemented as planned, no deviations. Recording the amendment for completeness. --- .../2026-02-20-plan-amendment.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-plan-amendment.md diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-plan-amendment.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-plan-amendment.md new file mode 100644 index 0000000..3cbee4a --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-plan-amendment.md @@ -0,0 +1,17 @@ +# Plan Amendment Summary + +Overall adherence: HIGH +Phases with deviations: None + +## Deviations + +No deviations from the plan were made. All phases were implemented as specified. + +## Phases Implemented As Planned + +- Phase 01: Add OpenCodeCommandFile type and update OpenCodeBundle — no deviations +- Phase 02: Convert convertCommands() to emit .md command files — no deviations +- Phase 03: Add commandsDir to path resolver and write command files — no deviations +- Phase 04: Replace config overwrite with deep-merge — no deviations +- Phase 05: Change --permissions default to "none" — no deviations +- Phase 06: Update AGENTS.md and README.md — no deviations \ No newline at end of file From 1aed2353e23a9f5ace84221c1c7798b5acc6fd63 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 20 Feb 2026 16:08:36 -0800 Subject: [PATCH 25/47] Remove docs/reports and docs/decisions directories, keep only plans Reports and decisions are implementation artifacts that don't need to persist in the repository. Plans in docs/plans/ are retained as living documents that track implementation progress. Co-Authored-By: Claude Opus 4.6 --- AGENTS.md | 5 +- .../0001-opencode-command-output-format.md | 21 -- .../0002-opencode-json-merge-strategy.md | 21 -- .../0003-opencode-permissions-default-none.md | 21 -- .../2026-02-20-phase-01-type-changes.md | 48 --- .../2026-02-20-phase-02-convert-commands.md | 63 ---- ...2026-02-20-phase-03-write-command-files.md | 54 ---- .../2026-02-20-phase-04-merge-config.md | 45 --- ...2026-02-20-phase-05-permissions-default.md | 35 --- .../2026-02-20-phase-06-update-docs.md | 29 -- .../2026-02-20-plan-amendment.md | 17 -- .../2026-02-20-verification-report.md | 37 --- .../decisions.md | 281 ------------------ docs/reports/index.md | 3 - 14 files changed, 1 insertion(+), 679 deletions(-) delete mode 100644 docs/decisions/0001-opencode-command-output-format.md delete mode 100644 docs/decisions/0002-opencode-json-merge-strategy.md delete mode 100644 docs/decisions/0003-opencode-permissions-default-none.md delete mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-01-type-changes.md delete mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md delete mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-03-write-command-files.md delete mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-04-merge-config.md delete mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-05-permissions-default.md delete mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-06-update-docs.md delete mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-plan-amendment.md delete mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-verification-report.md delete mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/decisions.md delete mode 100644 docs/reports/index.md diff --git a/AGENTS.md b/AGENTS.md index cbc86f2..9686f21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,4 @@ Avoid adding a provider if the target spec is unstable or undocumented. ## Repository Docs Convention -- **ADRs** live in `docs/decisions/` and are numbered with 4-digit zero-padding: `0001-short-title.md`, `0002-short-title.md`, etc. -- **Orchestrator run reports** live in `docs/reports/`. - -When recording a significant decision (new provider, output format change, merge strategy), create an ADR in `docs/decisions/` following the numbering sequence. +- **Plans** live in `docs/plans/` and track implementation progress. diff --git a/docs/decisions/0001-opencode-command-output-format.md b/docs/decisions/0001-opencode-command-output-format.md deleted file mode 100644 index 6788d71..0000000 --- a/docs/decisions/0001-opencode-command-output-format.md +++ /dev/null @@ -1,21 +0,0 @@ -# ADR 0001: OpenCode commands written as .md files, not in opencode.json - -## Status -Accepted - -## Date -2026-02-20 - -## Context -OpenCode supports two equivalent formats for custom commands. Writing to opencode.json requires overwriting or merging the user's config file. Writing .md files is additive and non-destructive. - -## Decision -The OpenCode target always emits commands as individual .md files in the commands/ subdirectory. The command key is never written to opencode.json by this tool. - -## Consequences -- Positive: Installs are non-destructive. Commands are visible as individual files, easy to inspect. Consistent with agents/skills handling. -- Negative: Users inspecting opencode.json won't see plugin commands; they must look in commands/. -- Neutral: Requires OpenCode >= the version with command file support (confirmed stable). - -## Plan Reference -Originated from: docs/plans/feature_opencode-commands_as_md_and_config_merge.md \ No newline at end of file diff --git a/docs/decisions/0002-opencode-json-merge-strategy.md b/docs/decisions/0002-opencode-json-merge-strategy.md deleted file mode 100644 index d17c3d2..0000000 --- a/docs/decisions/0002-opencode-json-merge-strategy.md +++ /dev/null @@ -1,21 +0,0 @@ -# ADR 0002: Plugin merges into existing opencode.json rather than replacing it - -## Status -Accepted - -## Date -2026-02-20 - -## Context -Users have existing opencode.json files with personal configuration. The install command previously backed up and replaced this file entirely, destroying user settings. - -## Decision -writeOpenCodeBundle reads existing opencode.json (if present), deep-merges plugin-provided keys without overwriting user-set values, and writes the merged result. User keys always win on conflict. - -## Consequences -- Positive: User config preserved across installs. Re-installs are idempotent for user-set values. -- Negative: Plugin cannot remove or update an MCP server entry if the user already has one with the same name. -- Neutral: Backup of pre-merge file is still created for safety. - -## Plan Reference -Originated from: docs/plans/feature_opencode-commands_as_md_and_config_merge.md \ No newline at end of file diff --git a/docs/decisions/0003-opencode-permissions-default-none.md b/docs/decisions/0003-opencode-permissions-default-none.md deleted file mode 100644 index 4c3039f..0000000 --- a/docs/decisions/0003-opencode-permissions-default-none.md +++ /dev/null @@ -1,21 +0,0 @@ -# ADR 0003: Global permissions not written to opencode.json by default - -## Status -Accepted - -## Date -2026-02-20 - -## Context -Claude commands carry allowedTools as per-command restrictions. OpenCode has no per-command permission mechanism. Writing per-command restrictions as global permissions is semantically incorrect and pollutes the user's global config. - -## Decision ---permissions defaults to "none". The plugin never writes permission or tools to opencode.json unless the user explicitly passes --permissions broad or --permissions from-command. - -## Consequences -- Positive: User's global OpenCode permissions are never silently modified. -- Negative: Users who relied on auto-set permissions must now pass the flag explicitly. -- Neutral: The "broad" and "from-command" modes still work as documented for opt-in use. - -## Plan Reference -Originated from: docs/plans/feature_opencode-commands_as_md_and_config_merge.md \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-01-type-changes.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-01-type-changes.md deleted file mode 100644 index 74376ed..0000000 --- a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-01-type-changes.md +++ /dev/null @@ -1,48 +0,0 @@ -# Phase 1 Handoff Report: Type Changes for Command Files - -**Date:** 2026-02-20 -**Phase:** 1 of 4 -**Status:** Complete - -## Summary - -Implemented type changes to support storing commands as `.md` files instead of inline in `opencode.json`. - -## Changes Made - -### 1. Type Changes (`src/types/opencode.ts`) - -- Removed `OpenCodeCommandConfig` type (lines 23-28) -- Removed `command?: Record` from `OpenCodeConfig` -- Added `OpenCodeCommandFile` type: - ```typescript - export type OpenCodeCommandFile = { - name: string - content: string - } - ``` -- Added `commandFiles: OpenCodeCommandFile[]` to `OpenCodeBundle` (with comment referencing ADR-001) - -### 2. Import Update (`src/converters/claude-to-opencode.ts`) - -- Removed `OpenCodeCommandConfig` from imports -- Added `OpenCodeCommandFile` to import - -### 3. Test Updates - -- `tests/converter.test.ts`: Updated 4 tests to use `bundle.commandFiles.find()` instead of `bundle.config.command` -- `tests/opencode-writer.test.ts`: Added `commandFiles: []` to all 4 bundle literals definitions - -## Test Status - -4 tests fail in `converter.test.ts` because the converter hasn't been updated yet to populate `commandFiles`. This is expected behavior - Phase 2 will fix these. - -``` -76 pass, 4 fail in converter.test.ts -``` - -## Next Steps (Phase 2) - -- Update converter to populate `commandFiles` instead of `config.command` -- Update writer to output `.md` files for commands -- Tests will pass after Phase 2 implementation \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md deleted file mode 100644 index b2d4f4e..0000000 --- a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md +++ /dev/null @@ -1,63 +0,0 @@ -# Phase 2 Handoff Report: Convert Commands to .md Files - -**Date:** 2026-02-20 -**Phase:** 2 of 4 -**Status:** Complete - -## Summary - -Implemented `convertCommands()` to emit `.md` command files with YAML frontmatter and body, rather than returning a `Record`. Updated `convertClaudeToOpenCode()` to populate `commandFiles` in the bundle instead of `config.command`. - -## Changes Made - -### 1. Converter Function (`src/converters/claude-to-opencode.ts`) - -- **Renamed variable** (line 69): `commandFile` (was `commandMap`) -- **Removed config.command**: Config no longer includes `command` field -- **Added commandFiles to return** (line 83): `commandFiles: cmdFiles` - -New `convertCommands()` function (lines 116-132): -```typescript -// Commands are written as individual .md files rather than entries in opencode.json. -// Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001). -function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] { - const files: OpenCodeCommandFile[] = [] - for (const command of commands) { - if (command.disableModelInvocation) continue - const frontmatter: Record = { - description: command.description, - } - if (command.model && command.model !== "inherit") { - frontmatter.model = normalizeModel(command.model) - } - const content = formatFrontmatter(frontmatter, rewriteClaudePaths(command.body)) - files.push({ name: command.name, content }) - } - return files -} -``` - -### 2. Test Updates (`tests/converter.test.ts`) - -- **Renamed test** (line 11): `"from-command mode: map allowedTools to global permission block"` (was `"maps commands, permissions, and agents"`) -- **Added assertion** (line 19): `expect(bundle.config.command).toBeUndefined()` -- **Renamed test** (line 204): `"excludes commands with disable-model-invocation from commandFiles"` (was `"excludes commands with disable-model-invocation from command map"`) -- **Added new test** (lines 289-307): `"command .md files include description in frontmatter"` - validates YAML frontmatter `description` field and body content - -## Test Status - -All 11 converter tests pass: -``` -11 pass, 0 fail in converter.test.ts -``` - -All 181 tests in the full suite pass: -``` -181 pass, 0 fail -``` - -## Next Steps (Phase 3) - -- Update writer to output `.md` files for commands to `.opencode/commands/` directory -- Update config merge to handle command files from multiple plugins sources -- Ensure writer tests pass with new output structure \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-03-write-command-files.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-03-write-command-files.md deleted file mode 100644 index 84fc3e3..0000000 --- a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-03-write-command-files.md +++ /dev/null @@ -1,54 +0,0 @@ -# Phase 3 Handoff Report: Write Command Files as .md - -## Date -2026-02-20 - -## Phase -3 of feature: OpenCode Commands as .md Files, Config Merge, and Permissions Default Fix - -## Summary - -Implemented the `commandsDir` path resolution and command file writing in `src/targets/opencode.ts`. - -## Changes Made - -### 1. Updated `src/targets/opencode.ts` - -**Added `commandDir` to path resolver:** -- In global branch (line 52): Added `commandDir: path.join(outputRoot, "commands")` with inline comment -- In custom branch (line 66): Added `commandDir: path.join(outputRoot, ".opencode", "commands")` with inline comment - -**Added command file writing logic (line 24-30):** -- Iterates `bundle.commandFiles` -- Writes each command as `/.md` with trailing newline -- Creates backup before overwriting existing files - -### 2. Added tests in `tests/opencode-writer.test.ts` - -- `"writes command files as .md in commands/ directory"` - Tests global-style output (`.config/opencode`) -- `"backs up existing command .md file before overwriting"` - Tests backup creation - -## Test Results - -``` -bun test tests/opencode-writer.test.ts -6 pass, 0 fail -``` - -All existing tests continue to pass: -``` -bun test -183 pass, 0 fail -``` - -## Deliverables Complete - -- [x] Updated `src/targets/opencode.ts` with commandDir path and write logic -- [x] New tests in `tests/opencode-writer.test.ts` -- [x] All tests pass - -## Notes - -- Used `openCodePaths` instead of `paths` variable name to avoid shadowing the imported `path` module -- Command files are written with trailing newline (`content + "\n"`) -- Backup uses timestamp format `.bak.2026-02-20T...` \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-04-merge-config.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-04-merge-config.md deleted file mode 100644 index 86abf0e..0000000 --- a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-04-merge-config.md +++ /dev/null @@ -1,45 +0,0 @@ -# Phase 4 Handoff: Deep-Merge opencode.json - -**Date:** 2026-02-20 -**Status:** Complete - -## Summary - -Implemented `mergeOpenCodeConfig()` function that performs deep-merge of plugin config into existing opencode.json with user-wins-on-conflict strategy. - -## Changes Made - -### 1. Updated `src/targets/opencode.ts` - -- Added imports for `pathExists`, `readJson`, and `OpenCodeConfig` type -- Added `mergeOpenCodeConfig()` function before `writeOpenCodeBundle()` -- Replaced direct `writeJson()` call with merge logic - -### 2. Updated `tests/opencode-writer.test.ts` - -- Renamed existing backup test to `"merges plugin config into existing opencode.json without destroying user keys"` -- Added two new tests: - - `"merges mcp servers without overwriting user entry"` - - `"preserves unrelated user keys when merging opencode.json"` - -## Verification - -All 8 tests pass: -``` -bun test tests/opencode-writer.test.ts -8 pass, 0 fail -``` - -## Key Behaviors - -1. **User keys preserved**: All existing config keys remain intact -2. **MCP merge**: Plugin MCP servers added, user servers kept on conflict -3. **Permission merge**: Plugin permissions added, user permissions kept on conflict -4. **Tools merge**: Plugin tools added, user tools kept on conflict -5. **Fallback**: If existing config is malformed JSON, writes plugin-only config (safety first) -6. **Backup**: Original config is still backed up before writing merged result - -## Next Steps - -- Proceed to next phase (if any) -- Consider adding decision log entry for ADR-002 (user-wins-on-conflict strategy) \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-05-permissions-default.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-05-permissions-default.md deleted file mode 100644 index 191b1f1..0000000 --- a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-05-permissions-default.md +++ /dev/null @@ -1,35 +0,0 @@ -# Phase 5 Handoff: Change `--permissions` Default to `"none"` - -## Summary - -Changed the default value of `--permissions` from `"broad"` to `"none"` in the install command to prevent polluting user OpenCode config with global permissions. - -## Changes Made - -### 1. Code Change (`src/commands/install.ts`) - -- Line 51: Changed `default: "broad"` to `default: "none"` with comment referencing ADR-003 -- Line 52: Updated description to clarify "none (default)" - -```typescript -permissions: { - type: "string", - default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003. - description: "Permission mapping written to opencode.json: none (default) | broad | from-command", -}, -``` - -### 2. New Tests (`tests/cli.test.ts`) - -Added two new tests: -1. `"install --to opencode uses permissions:none by default"` - Verifies no `permission` or `tools` keys in opencode.json when using default -2. `"install --to opencode --permissions broad writes permission block"` - Verifies `permission` key is written when explicitly using `--permissions broad` - -## Test Results - -- CLI tests: 12 pass, 0 fail -- All tests: 187 pass, 0 fail - -## Next Steps - -None - Phase 5 is complete. \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-06-update-docs.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-06-update-docs.md deleted file mode 100644 index eafdca0..0000000 --- a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-06-update-docs.md +++ /dev/null @@ -1,29 +0,0 @@ -# Phase 6: Update AGENTS.md and README.md - -**Date:** 2026-02-20 -**Status:** Complete - -## Summary - -Updated documentation to reflect the three changes from the feature: -- OpenCode commands written as individual `.md` files -- Deep-merge for `opencode.json` -- Command file backup before overwrite - -## Changes Made - -### AGENTS.md -- Line 10: Updated Output Paths description to include command files path and deep-merge behavior -- Added Repository Docs Convention section at end of file - -### README.md -- Line 54: Updated OpenCode output description to include command files and deep-merge behavior - -## Verification - -- Read updated files and confirmed accuracy -- Run `bun test` - no regressions - -## Next Steps - -- Ready for merge to main branch \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-plan-amendment.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-plan-amendment.md deleted file mode 100644 index 3cbee4a..0000000 --- a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-plan-amendment.md +++ /dev/null @@ -1,17 +0,0 @@ -# Plan Amendment Summary - -Overall adherence: HIGH -Phases with deviations: None - -## Deviations - -No deviations from the plan were made. All phases were implemented as specified. - -## Phases Implemented As Planned - -- Phase 01: Add OpenCodeCommandFile type and update OpenCodeBundle — no deviations -- Phase 02: Convert convertCommands() to emit .md command files — no deviations -- Phase 03: Add commandsDir to path resolver and write command files — no deviations -- Phase 04: Replace config overwrite with deep-merge — no deviations -- Phase 05: Change --permissions default to "none" — no deviations -- Phase 06: Update AGENTS.md and README.md — no deviations \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-verification-report.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-verification-report.md deleted file mode 100644 index 34a0f50..0000000 --- a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-verification-report.md +++ /dev/null @@ -1,37 +0,0 @@ -# Verification Report: OpenCode Commands as .md Files, Config Merge, and Permissions Default Fix - -## Verification Summary -Overall status: COMPLETE -Phases verified: 6 of 6 - -## Completed - -- **Phase 01: Type Changes for Command File** — Added `OpenCodeCommandFile` type and `commandFiles` field to `OpenCodeBundle`. Removed `OpenCodeCommandConfig` and `command` from `OpenCodeConfig`. Tests updated to use new bundle structure. - -- **Phase 02: Convert Commands to .md Files** — Implemented `convertCommands()` to return `OpenCodeCommandFile[]` with YAML frontmatter (`description`, `model`) and body. Removed `config.command` assignment. Updated tests verify commandFiles exist and command config is undefined. - -- **Phase 03: Write Command Files** — Added `commandDir` to path resolver (both global and custom branches). Implemented command file writing with backup-before-overwrite in `writeOpenCodeBundle()`. New tests verify file creation and backup. - -- **Phase 04: Deep-Merge Config** — Implemented `mergeOpenCodeConfig()` with user-wins-on-conflict strategy. Preserves user keys (`model`, `theme`, `provider`), merges MCP servers, handles malformed JSON with fallback. Updated tests verify merge behavior. - -- **Phase 05: Permissions Default to "none"** — Changed `--permissions` default from `"broad"` to `"none"` in install command. Added code comment referencing ADR-003. Tests verify no permission/tools written by default, and explicit `--permissions broad` works. - -- **Phase 06: Update Documentation** — Updated AGENTS.md line 10 with command path and deep-merge behavior. Added Repository Docs Convention section (lines 50-55). Updated README.md line 54 with complete behavior description. - -## Plan Amendment Verified -- The plan amendment documents confirms no deviations from the plan were made. All phases implemented as specified. - -## ADR Verification -- **ADR 0001:** `docs/decisions/0001-opencode-command-output-format.md` exists with correct content (Status: Accepted, Context, Decision, Consequences, Plan Reference) -- **ADR 0002:** `docs/decisions/0002-opencode-json-merge-strategy.md` exists with correct content (Status: Accepted, user-wins-on-conflict strategy documented) -- **ADR 0003:** `docs/decisions/0003-opencode-permissions-default-none.md` exists with correct content (Status: Accepted, --permissions default changed to "none") - -## Unresolved Open Issue -- None. All handoff reports show "Status: Complete" with no open issues remaining. - -## Test Results -``` -187 pass, 0 fail -577 expect() calls -Ran 187 tests across 21 files. -``` \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md deleted file mode 100644 index e3d398f..0000000 --- a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md +++ /dev/null @@ -1,281 +0,0 @@ -# Decision Log: OpenCode Commands as .md Files - -## Decision: ADR-001 - Store Commands as Individual .md Files - -**Date:** 2026-02-20 -**Status:** Adopted - -## Context - -The original design stored commands configurations inline in `opencode.json` under `config.command`. This tightly couples command metadata with config, making it harder to version-control commands separately and share command files. - -## Decision - -Store commands definitions as individual `.md` files in `.opencode/commands/` directory, with YAML frontmatter for metadata and markdown body for the command prompt. - -**New Type:** -```typescript -export type OpenCodeCommandFile = { - name: string // command name, used as filename stem: .md - content: string // full file content: YAML frontmatter + body -} -``` - -**Bundle Structure:** -```typescript -export type OpenCodeBundle = { - config: OpenCodeConfig - agents: OpenCodeAgentFile[] - commandFiles: OpenCodeCommandFile[] // NEW - plugins: OpenCodePluginFile[] - skillDirs: { sourceDir: string; name: string }[] -} -``` - -## Consequences - -- **Positive:** Commands can be versioned, shared, and edited independently -- **Negative:** Requires updating converter, writer, and all consumers -- **Migration:** Phase 1-4 will implement the full migration - -## Alternatives Considered - -1. Keep inline in config - Rejected: limits flexibility -2. Use separate JSON files - Rejected: YAML frontmatter is more idiomatic for command - ---- - -## Decision: Phase 2 - Converter Emits .md Files - -**Date:** 2026-02-20 -**Status:** Implemented - -## Context - -The converter needs to populate `commandFiles` in the bundle rather than `config.command`. - -## Decision - -`convertCommands()` returns `OpenCodeCommandFile[]` where each file contains: -- **filename**: `.md` -- **content**: YAML frontmatter (`description`, optionally `model`) + body (template text with Claude path rewriting) - -### Frontmatter Structure -```yaml ---- -description: "Review code changes" -model: openai/gpt-4o ---- - -Template text here... -``` - -### Filtering -- Commands with `disableModelInvocation: true` are excluded from output - -### Path Rewriting -- `.claude/` paths rewritten to `.opencode/` in body content (via `rewriteClaudePaths()`) - -## Consequences - -- Converter now produces command files ready for file-system output -- Writer phase will handle writing to `.opencode/commands/` directory -- Phase 1 type changes are now fully utilizeds - ---- - -## Decision: Phase 3 - Writer Writes Command .md Files - -**Date:** 2026-02-20 -**Status:** Implemented - -## Context - -The writer needs to write command files from the bundle to the file system. - -## Decision - -In `src/targets/opencode.ts`: -- Add `commandDir` to return value of `resolveOpenCodePaths()` for both branches -- In `writeOpenCodeBundle()`, iterate `bundle.commandFiles` and write each as `/.md` with backup-before-overwrite - -### Path Resolution - -- Global branch (basename is "opencode" or ".opencode"): `commandsDir: path.join(outputRoot, "commands")` -- Custom branch: `commandDir: path.join(outputRoot, ".opencode", "commands")` - -### Writing Logic - -```typescript -for (const commandFile of bundle.commandFiles) { - const dest = path.join(openCodePaths.commandDir, `${commandFile.name}.md`) - const cmdBackupPath = await backupFile(dest) - if (cmdBackupPath) { - console.log(`Backed up existing command file to ${cmdBackupPath}`) - } - await writeText(dest, commandFile.content + "\n") -} -``` - -## Consequences - -- Command files are written to `.opencode/commands/` or `commands/` directory -- Existing files are backed up before overwriting -- Files content includes trailing newline - -## Alternatives Considered - -1. Use intermediate variable for commandDir - Rejected: caused intermittent undefined errors -2. Use direct property reference `openCodePaths.commandDir` - Chosen: more reliable - ---- - -## Decision: ADR-002 - User-Wins-On-Conflict for Config Merge - -**Date:** 2026-02-20 -**Status:** Adopted - -## Context - -When merging plugin config into existing opencode.json, conflicts may occur (e.g., same MCP server name with different configuration). The merge strategy must decide which value wins. - -## Decision - -**User config wins on conflict.** When plugin and user both define the same key (MCP server name, permission, tool), the user's value takes precedence. - -### Rationale - -- Safety first: Do not overwrite user data with plugin defaults -- Users have explicit intent in their local config -- Plugins should add new entries without modifying user's existing setup -- Aligns with AGENTS.md principle: "Do not delete or overwrite user data" - -### Merge Algorithm - -```typescript -const mergedMcp = { - ...(incoming.mcp ?? {}), - ...(existing.mcp ?? {}), // existing takes precedence -} -``` - -Same pattern applied to `permission` and `tools`. - -### Fallback Behavior - -If existing `opencode.json` is malformed JSON, warn and write plugin-only config rather than crashing: -```typescript -} catch { - console.warn(`Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`) - return incoming -} -``` - -## Consequences - -- Positive: User config never accidentally overwritten -- Positive: Plugin can add new entries without conflict -- Negative: Plugin cannot modify user's existing server configuration (must use unique names) -- Negative: Silent merge may mask configuration issues if user expects plugin override - -## Alternatives Considered - -1. Plugin wins on conflict - Rejected: would overwrite user data -2. Merge and combine arrays - Rejected: MCP servers are keyed object, not array -3. Fail on conflict - Rejected: breaks installation workflow - ---- - -## Decision: ADR-003 - Permissions Default "none" for OpenCode Output - -**Date:** 2026-02-20 -**Status:** Implemented - -## Context - -When installing a Claude plugin to OpenCode format, the `--permissions` flag determines whether permission/tool mappings is written to `opencode.json`. The previous default was `"broad"`, which writes global permissions to the user's config file. - -## Decision - -Change the default value of `--permissions` from `"broad"` to `"none"` in the install command. - -### Rationale - -- **User safety:** Writing global permissions to `opencode.json` pollutes user config and may grant unintended access -- **Principle alignment:** Follows AGENTS.md "Do not delete or overwrite user data" -- **Explicit opt-in:** Users must explicitly request `--permissions broad` to write permissions to their config -- **Backward compatible:** Existing workflows using `--permissions broad` continues to work - -### Implementation - -In `src/commands/install.ts`: -```typescript -permissions: { - type: "string", - default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003. - description: "Permission mapping written to opencode.json: none (default) | broad | from-command", -}, -``` - -### Test Coverage - -Added two CLI tests cases: -1. `install --to opencode uses permissions:none by default` - Verifies no `permission` or `tools` key in output -2. `install --to opencode --permissions broad writes permission block` - Verifies `permission` key is written when explicitly requested - -## Consequences - -- **Positive:** User config remains clean by default -- **Positive:** Explicit opt-in required for permission writing -- **Negative:** Users migrating from older versions need to explicitly use `--permissions broad` if they want permissions -- **Migration path:** Document the change in migration notes - -## Alternatives Considered - -1. Keep "broad" as default - Rejected: pollutes user config -2. Prompt user interactively - Rejected: break CLI automation -3. Write to separate file - Rejected: OpenCode expects permissions in opencode.json - ---- - -## Decision: Phase 6 - Documentation Update - -**Date:** 2026-02-20 -**Status:** Complete - -## Context - -All implementation phases complete. Documentation needs to reflect the final behavior. - -## Decision - -Update AGENTS.md and README.md: - -### AGENTS.md Changes - -1. **Line 10** - Updated Output Paths description: - ``` - - **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`. For OpenCode, command go to `~/.config/opencode/commands/.md`; `opencode.json` is deep-merged (never overwritten wholesale). - ``` - -2. **Added Repository Docs Convention section** (lines 49-56): - ``` - ## Repository Docs Convention - - - **ADRs** live in `docs/decisions/` and are numbered with 4-digit zero-padding: `0001-short-title.md`, `0002-short-title.md`, etc. - - **Orchestrator run reports** live in `docs/reports/`. - - When recording a significant decision (new provider, output format change, merge strategy), create an ADR in `docs/decisions/` following the numbering sequence. - ``` - -### README.md Changes - -1. **Line 54** - Updated OpenCode output description: - ``` - OpenCode output is written to `~/.config/opencode` by default. Command are written as individual `.md` files to `~/.config/opencode/commands/.md`. Agent, skills, and plugin are written to the corresponding subdirectory alongside. `opencode.json` (MCP servers) is deep-merged into any existing file -- user keys such as `model`, `theme`, and `provider` are preserved, and user values win on conflicts. Command files are backed up before being overwritten. - ``` - -## Verification - -- Read updated files and confirmed accuracy -- Run `bun test` - no regression \ No newline at end of file diff --git a/docs/reports/index.md b/docs/reports/index.md deleted file mode 100644 index 1aafd6d..0000000 --- a/docs/reports/index.md +++ /dev/null @@ -1,3 +0,0 @@ -| Date | Run Directory | Plan Source | Summary | -|------|--------------|-------------|---------| -| 2026-02-20 | `opencode-commands-md-merge/` | `docs/plans/feature_opencode-commands_as_md_and_config_merge.md` | Implement OpenCode commands as .md files, deep-merge opencode.json, and change --permissions default to none | \ No newline at end of file From 63e76cf67f08aac6f0dccf275ce328e47deae541 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Fri, 20 Feb 2026 16:18:24 -0800 Subject: [PATCH 26/47] =?UTF-8?q?release:=20v0.9.1=20=E2=80=94=20remove=20?= =?UTF-8?q?reports=20and=20decisions=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce7f04..4376dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.9.1] - 2026-02-20 + +### Changed + +- **Remove docs/reports and docs/decisions directories** — only `docs/plans/` is retained as living documents that track implementation progress +- **OpenCode commands as Markdown** — commands are now `.md` files with deep-merged config, permissions default to none ([#201](https://github.com/EveryInc/compound-engineering-plugin/pull/201)) — thanks [@0ut5ider](https://github.com/0ut5ider)! + +--- + ## [0.9.0] - 2026-02-17 ### Added diff --git a/package.json b/package.json index b162755..b61cb80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@every-env/compound-plugin", - "version": "0.9.0", + "version": "0.9.1", "type": "module", "private": false, "bin": { From 3b4e0ae11f8b92dc0f0e77b74767c17456210fe5 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Mon, 23 Feb 2026 13:24:08 -0800 Subject: [PATCH 27/47] feat: Add Proof editor integration Add proof skill for collaborative document editing via Proof's web API and local bridge. Integrate Proof uploads into brainstorm and plan workflows so outputs get a shareable URL automatically. Co-Authored-By: Claude Opus 4.6 --- .claude-plugin/marketplace.json | 4 +- .../.claude-plugin/plugin.json | 4 +- plugins/compound-engineering/CHANGELOG.md | 10 + plugins/compound-engineering/README.md | 3 +- .../commands/workflows/brainstorm.md | 25 +++ .../commands/workflows/plan.md | 25 +++ .../skills/proof/SKILL.md | 185 ++++++++++++++++++ 7 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 plugins/compound-engineering/skills/proof/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index de0fa74..725abd0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,8 +11,8 @@ "plugins": [ { "name": "compound-engineering", - "description": "AI-powered development tools that get smarter with every use. Make each unit of engineering work easier than the last. Includes 29 specialized agents, 22 commands, and 19 skills.", - "version": "2.35.2", + "description": "AI-powered development tools that get smarter with every use. Make each unit of engineering work easier than the last. Includes 29 specialized agents, 22 commands, and 20 skills.", + "version": "2.36.0", "author": { "name": "Kieran Klaassen", "url": "https://github.com/kieranklaassen", diff --git a/plugins/compound-engineering/.claude-plugin/plugin.json b/plugins/compound-engineering/.claude-plugin/plugin.json index 5bb71ad..4d03ce3 100644 --- a/plugins/compound-engineering/.claude-plugin/plugin.json +++ b/plugins/compound-engineering/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "compound-engineering", - "version": "2.35.2", - "description": "AI-powered development tools. 29 agents, 22 commands, 19 skills, 1 MCP server for code review, research, design, and workflow automation.", + "version": "2.36.0", + "description": "AI-powered development tools. 29 agents, 22 commands, 20 skills, 1 MCP server for code review, research, design, and workflow automation.", "author": { "name": "Kieran Klaassen", "email": "kieran@every.to", diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index ede6b06..a6f04cd 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -5,6 +5,16 @@ 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-23 + +### Added + +- **`proof` skill** — Create, edit, comment on, and share markdown documents via Proof's web API and local bridge. Supports document creation, track-changes suggestions, comments, and bulk rewrites. No authentication required for creating shared documents. +- **Proof upload in `/workflows:brainstorm`** — After writing the brainstorm document, automatically uploads it to Proof and displays a shareable URL for collaborative review. +- **Proof upload in `/workflows:plan`** — After writing the plan file, automatically uploads it to Proof and displays a shareable URL for collaborative review. + +--- + ## [2.35.2] - 2026-02-20 ### Changed diff --git a/plugins/compound-engineering/README.md b/plugins/compound-engineering/README.md index ec1ad83..59b441b 100644 --- a/plugins/compound-engineering/README.md +++ b/plugins/compound-engineering/README.md @@ -8,7 +8,7 @@ AI-powered development tools that get smarter with every use. Make each unit of |-----------|-------| | Agents | 29 | | Commands | 22 | -| Skills | 19 | +| Skills | 20 | | MCP Servers | 1 | ## Agents @@ -134,6 +134,7 @@ Core workflow commands use `workflows:` prefix to avoid collisions with built-in | `every-style-editor` | Review copy for Every's style guide compliance | | `file-todos` | File-based todo tracking system | | `git-worktree` | Manage Git worktrees for parallel development | +| `proof` | Create, edit, and share documents via Proof collaborative editor | | `resolve-pr-parallel` | Resolve PR review comments in parallel | | `setup` | Configure which review agents run for your project | diff --git a/plugins/compound-engineering/commands/workflows/brainstorm.md b/plugins/compound-engineering/commands/workflows/brainstorm.md index b4f3a0f..06e6b77 100644 --- a/plugins/compound-engineering/commands/workflows/brainstorm.md +++ b/plugins/compound-engineering/commands/workflows/brainstorm.md @@ -119,6 +119,31 @@ Key decisions: Next: Run `/workflows:plan` when ready to implement. ``` +### Share to Proof + +After writing the brainstorm document, upload it to Proof for collaborative review: + +```bash +# Read the brainstorm file content +CONTENT=$(cat docs/brainstorms/YYYY-MM-DD--brainstorm.md) +TITLE="Brainstorm: " + +# Upload to Proof +RESPONSE=$(curl -s -X POST https://www.proofeditor.ai/share/markdown \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg title "$TITLE" --arg markdown "$CONTENT" --arg by "ai:compound" '{title: $title, markdown: $markdown, by: $by}')") + +PROOF_URL=$(echo "$RESPONSE" | jq -r '.tokenUrl') +``` + +Display the Proof URL prominently: + +``` +View & collaborate in Proof: +``` + +If the curl fails (network error, non-JSON response), skip silently and continue — Proof sharing is optional. + ## Important Guidelines - **Stay focused on WHAT, not HOW** - Implementation details belong in the plan diff --git a/plugins/compound-engineering/commands/workflows/plan.md b/plugins/compound-engineering/commands/workflows/plan.md index 9b1ab88..3a4a346 100644 --- a/plugins/compound-engineering/commands/workflows/plan.md +++ b/plugins/compound-engineering/commands/workflows/plan.md @@ -544,6 +544,31 @@ Use the Write tool to save the complete plan to `docs/plans/YYYY-MM-DD----plan.md) +TITLE="Plan: " + +# Upload to Proof +RESPONSE=$(curl -s -X POST https://www.proofeditor.ai/share/markdown \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg title "$TITLE" --arg markdown "$CONTENT" --arg by "ai:compound" '{title: $title, markdown: $markdown, by: $by}')") + +PROOF_URL=$(echo "$RESPONSE" | jq -r '.tokenUrl') +``` + +Display the Proof URL prominently: + +``` +View & collaborate in Proof: +``` + +If the curl fails (network error, non-JSON response), skip silently and continue — Proof sharing is optional. + **Pipeline mode:** If invoked from an automated workflow (LFG, SLFG, or any `disable-model-invocation` context), skip all AskUserQuestion calls. Make decisions automatically and proceed to writing the plan without interactive prompts. ## Output Format diff --git a/plugins/compound-engineering/skills/proof/SKILL.md b/plugins/compound-engineering/skills/proof/SKILL.md new file mode 100644 index 0000000..f4f5c4f --- /dev/null +++ b/plugins/compound-engineering/skills/proof/SKILL.md @@ -0,0 +1,185 @@ +--- +name: proof +description: Create, edit, comment on, and share markdown documents via Proof's web API and local bridge. Use when asked to "proof", "share a doc", "create a proof doc", "comment on a document", "suggest edits", "review in proof", or when given a proofeditor.ai URL. +allowed-tools: + - Bash + - Read + - Write + - WebFetch +--- + +# Proof - Collaborative Markdown Editor + +Proof is a collaborative document editor for humans and agents. It supports two modes: + +1. **Web API** - Create and edit shared documents via HTTP (no install needed) +2. **Local Bridge** - Drive the macOS Proof app via localhost:9847 + +## Web API (Primary for Sharing) + +### Create a Shared Document + +No authentication required. Returns a shareable URL with access token. + +```bash +curl -X POST https://www.proofeditor.ai/share/markdown \ + -H "Content-Type: application/json" \ + -d '{"title":"My Doc","markdown":"# Hello\n\nContent here."}' +``` + +**Response format:** +```json +{ + "slug": "abc123", + "tokenUrl": "https://www.proofeditor.ai/d/abc123?token=xxx", + "accessToken": "xxx", + "ownerSecret": "yyy", + "_links": { + "state": "https://www.proofeditor.ai/api/agent/abc123/state", + "ops": "https://www.proofeditor.ai/api/agent/abc123/ops" + } +} +``` + +Use the `tokenUrl` as the shareable link. The `_links` give you the exact API paths. + +### Read a Shared Document + +```bash +curl -s "https://www.proofeditor.ai/api/agent/{slug}/state" \ + -H "x-share-token: " +``` + +### Edit a Shared Document + +All operations go to `POST https://www.proofeditor.ai/api/agent/{slug}/ops` + +**Note:** Use the `/api/agent/{slug}/ops` path (from `_links` in create response), NOT `/api/documents/{slug}/ops`. + +**Authentication for protected docs:** +- Header: `x-share-token: ` or `Authorization: Bearer ` +- Token comes from the URL parameter: `?token=xxx` or the `accessToken` from create response + +**Comment on text:** +```json +{"op": "comment.add", "quote": "text to comment on", "by": "ai:", "text": "Your comment here"} +``` + +**Reply to a comment:** +```json +{"op": "comment.reply", "markId": "", "by": "ai:", "text": "Reply text"} +``` + +**Resolve a comment:** +```json +{"op": "comment.resolve", "markId": "", "by": "ai:"} +``` + +**Suggest a replacement:** +```json +{"op": "suggestion.add", "kind": "replace", "quote": "original text", "by": "ai:", "content": "replacement text"} +``` + +**Suggest a deletion:** +```json +{"op": "suggestion.add", "kind": "delete", "quote": "text to delete", "by": "ai:"} +``` + +**Bulk rewrite:** +```json +{"op": "rewrite.apply", "content": "full new markdown", "by": "ai:"} +``` + +### Known Limitations (Web API) + +- `suggestion.add` with `kind: "insert"` returns Bad Request on the web ops endpoint. Use `kind: "replace"` with a broader quote instead, or use `rewrite.apply` for insertions. +- Bridge-style endpoints (`/d/{slug}/bridge/*`) require client version headers (`x-proof-client-version`, `x-proof-client-build`, `x-proof-client-protocol`) and return 426 CLIENT_UPGRADE_REQUIRED without them. Use the `/api/agent/{slug}/ops` endpoint instead. + +## Local Bridge (macOS App) + +Requires Proof.app running. Bridge at `http://localhost:9847`. + +**Required headers:** +- `X-Agent-Id: claude` (identity for presence) +- `Content-Type: application/json` +- `X-Window-Id: ` (when multiple docs open) + +### Key Endpoints + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| GET | `/windows` | List open documents | +| GET | `/state` | Read markdown, cursor, word count | +| GET | `/marks` | List all suggestions and comments | +| POST | `/marks/suggest-replace` | `{"quote":"old","by":"ai:","content":"new"}` | +| POST | `/marks/suggest-insert` | `{"quote":"after this","by":"ai:","content":"insert"}` | +| POST | `/marks/suggest-delete` | `{"quote":"delete this","by":"ai:"}` | +| POST | `/marks/comment` | `{"quote":"text","by":"ai:","text":"comment"}` | +| POST | `/marks/reply` | `{"markId":"","by":"ai:","text":"reply"}` | +| POST | `/marks/resolve` | `{"markId":"","by":"ai:"}` | +| POST | `/marks/accept` | `{"markId":""}` | +| POST | `/marks/reject` | `{"markId":""}` | +| POST | `/rewrite` | `{"content":"full markdown","by":"ai:"}` | +| POST | `/presence` | `{"status":"reading","summary":"..."}` | +| GET | `/events/pending` | Poll for user actions | + +### Presence Statuses + +`thinking`, `reading`, `idle`, `acting`, `waiting`, `completed` + +## Workflow: Review a Shared Document + +When given a Proof URL like `https://www.proofeditor.ai/d/abc123?token=xxx`: + +1. Extract the slug (`abc123`) and token from the URL +2. Read the document state via the API +3. Add comments or suggest edits using the ops endpoint +4. The author sees changes in real-time + +```bash +# Read +curl -s "https://www.proofeditor.ai/api/agent/abc123/state" \ + -H "x-share-token: xxx" + +# Comment +curl -X POST "https://www.proofeditor.ai/api/agent/abc123/ops" \ + -H "Content-Type: application/json" \ + -H "x-share-token: xxx" \ + -d '{"op":"comment.add","quote":"text","by":"ai:compound","text":"comment"}' + +# Suggest edit +curl -X POST "https://www.proofeditor.ai/api/agent/abc123/ops" \ + -H "Content-Type: application/json" \ + -H "x-share-token: xxx" \ + -d '{"op":"suggestion.add","kind":"replace","quote":"old","by":"ai:compound","content":"new"}' +``` + +## Workflow: Create and Share a New Document + +```bash +# 1. Create +RESPONSE=$(curl -s -X POST https://www.proofeditor.ai/share/markdown \ + -H "Content-Type: application/json" \ + -d '{"title":"My Doc","markdown":"# Title\n\nContent here."}') + +# 2. Extract URL and token +URL=$(echo "$RESPONSE" | jq -r '.tokenUrl') +SLUG=$(echo "$RESPONSE" | jq -r '.slug') +TOKEN=$(echo "$RESPONSE" | jq -r '.accessToken') + +# 3. Share the URL +echo "$URL" + +# 4. Make edits using the ops endpoint +curl -X POST "https://www.proofeditor.ai/api/agent/$SLUG/ops" \ + -H "Content-Type: application/json" \ + -H "x-share-token: $TOKEN" \ + -d '{"op":"comment.add","quote":"Content here","by":"ai:compound","text":"Added a note"}' +``` + +## Safety + +- Use `/state` content as source of truth before editing +- Prefer suggest-replace over full rewrite for small changes +- Don't span table cells in a single replace +- Always include `by` field for attribution tracking From 83a65fe9d43e701c6673035e5fe7e7493833209c Mon Sep 17 00:00:00 2001 From: Sam Xie Date: Wed, 25 Feb 2026 06:18:17 -0800 Subject: [PATCH 28/47] Add .worktrees to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f8f7b97..dae7aba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .codex/ todos/ +.worktrees From 004824b242c86fca20ebdbfb8ea74f5200a27b8b Mon Sep 17 00:00:00 2001 From: Sam Xie Date: Wed, 25 Feb 2026 06:55:55 -0800 Subject: [PATCH 29/47] Fix ordering number and orphaned fences --- .../commands/workflows/review.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/plugins/compound-engineering/commands/workflows/review.md b/plugins/compound-engineering/commands/workflows/review.md index d0ba78f..89c43ff 100644 --- a/plugins/compound-engineering/commands/workflows/review.md +++ b/plugins/compound-engineering/commands/workflows/review.md @@ -106,7 +106,7 @@ These agents are run ONLY when the PR matches specific criteria. Check the PR fi -### 4. Ultra-Thinking Deep Dive Phases +### 2. Ultra-Thinking Deep Dive Phases For each phase below, spend maximum cognitive effort. Think step by step. Consider all angles. Question assumptions. And bring all reviews in a synthesis to the user. @@ -114,7 +114,7 @@ These agents are run ONLY when the PR matches specific criteria. Check the PR fi Complete system context map with component interactions -#### Phase 3: Stakeholder Perspective Analysis +#### Phase 1: Stakeholder Perspective Analysis ULTRA-THINK: Put yourself in each stakeholder's shoes. What matters to them? What are their pain points? @@ -154,7 +154,7 @@ Complete system context map with component interactions - How does this affect time-to-market? - What's the total cost of ownership? -#### Phase 4: Scenario Exploration +#### Phase 2: Scenario Exploration ULTRA-THINK: Explore edge cases and failure scenarios. What could go wrong? How does the system behave under stress? @@ -171,7 +171,7 @@ Complete system context map with component interactions - [ ] **Data Corruption**: Partial writes, inconsistency - [ ] **Cascading Failures**: Downstream service issues -### 6. Multi-Angle Review Perspectives +### 3. Multi-Angle Review Perspectives #### Technical Excellence Angle @@ -437,9 +437,7 @@ After creating all todo files, present comprehensive summary: - Optimization opportunities - Documentation updates -``` - -### 7. End-to-End Testing (Optional) +### 6. End-to-End Testing (Optional) @@ -525,4 +523,3 @@ The subagent will: ### Important: P1 Findings Block Merge Any **🔴 P1 (CRITICAL)** findings must be addressed before merging the PR. Present these prominently and ensure they're resolved before accepting the PR. -``` From 3e384309d6e4832e4f8212aa8d27df96c55f052d Mon Sep 17 00:00:00 2001 From: Sam Xie Date: Wed, 25 Feb 2026 06:59:34 -0800 Subject: [PATCH 30/47] Fix leaked content out of Summary Report --- plugins/compound-engineering/commands/workflows/review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/compound-engineering/commands/workflows/review.md b/plugins/compound-engineering/commands/workflows/review.md index 89c43ff..13568d7 100644 --- a/plugins/compound-engineering/commands/workflows/review.md +++ b/plugins/compound-engineering/commands/workflows/review.md @@ -401,7 +401,6 @@ After creating all todo files, present comprehensive summary: ls todos/*-pending-*.md # View all pending todos /triage # Use slash command for interactive triage ``` -```` 3. **Work on Approved Todos**: @@ -436,6 +435,7 @@ After creating all todo files, present comprehensive summary: - Code cleanup - Optimization opportunities - Documentation updates +```` ### 6. End-to-End Testing (Optional) From 8f5dd3727425a825868dc0c8163ba74cebea7878 Mon Sep 17 00:00:00 2001 From: Sam Xie Date: Wed, 25 Feb 2026 07:01:18 -0800 Subject: [PATCH 31/47] Fix unclosed quoted string --- plugins/compound-engineering/commands/workflows/review.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/compound-engineering/commands/workflows/review.md b/plugins/compound-engineering/commands/workflows/review.md index 13568d7..570cf49 100644 --- a/plugins/compound-engineering/commands/workflows/review.md +++ b/plugins/compound-engineering/commands/workflows/review.md @@ -38,7 +38,7 @@ First, I need to determine the review target type and set up the code for analys - [ ] Determine review type: PR number (numeric), GitHub URL, file path (.md), or empty (current branch) - [ ] Check current git branch - [ ] If ALREADY on the target branch (PR branch, requested branch name, or the branch already checked out for review) → proceed with analysis on current branch -- [ ] If DIFFERENT branch than the review target → offer to use worktree: "Use git-worktree skill for isolated Call `skill: git-worktree` with branch name +- [ ] If DIFFERENT branch than the review target → offer to use worktree: "Use git-worktree skill for isolated Call `skill: git-worktree` with branch name" - [ ] Fetch PR metadata using `gh pr view --json` for title, body, files, linked issues - [ ] Set up language-specific analysis tools - [ ] Prepare security scanning environment From 03f6ec64b3e57649bcb0a8450f683c072296249c Mon Sep 17 00:00:00 2001 From: Sam Xie Date: Wed, 25 Feb 2026 08:56:14 -0800 Subject: [PATCH 32/47] Fix github link --- plugins/compound-engineering/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index ede6b06..aaa7ff2 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -100,7 +100,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - All 29 agent descriptions trimmed from ~1,400 to ~180 chars avg (examples moved to agent body) - 18 manual commands marked `disable-model-invocation: true` (side-effect commands like `/lfg`, `/deploy-docs`, `/triage`, etc.) - 6 manual skills marked `disable-model-invocation: true` (`orchestrating-swarms`, `git-worktree`, `skill-creator`, `compound-docs`, `file-todos`, `resolve-pr-parallel`) -- **git-worktree**: Remove confirmation prompt for worktree creation ([@Sam Xie](https://github.com/samxie)) +- **git-worktree**: Remove confirmation prompt for worktree creation ([@Sam Xie](https://github.com/XSAM)) - **Prevent subagents from writing intermediary files** in compound workflow ([@Trevin Chow](https://github.com/trevin)) ### Fixed From a3701e220d6fa88b5b864e5a0429fd4e51bb51ed Mon Sep 17 00:00:00 2001 From: TrendpilotAI Date: Thu, 26 Feb 2026 02:03:52 -0500 Subject: [PATCH 33/47] feat: Add OpenClaw as conversion target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add openclaw as the 8th conversion target, enabling: bunx @every-env/compound-plugin install compound-engineering --to openclaw Converts Claude Code plugins into OpenClaw's extension format: - Agents → skills/agent-*/SKILL.md - Commands → api.registerCommand() + skills/cmd-*/SKILL.md - Skills → copied verbatim with path rewriting (.claude/ → .openclaw/) - MCP servers → openclaw.json config - Generates openclaw.plugin.json manifest, package.json, and index.ts entry point Output installs to ~/.openclaw/extensions// Co-Authored-By: Claude Opus 4.6 --- src/commands/install.ts | 5 +- src/converters/claude-to-openclaw.ts | 239 +++++++++++++++++++++++++++ src/targets/index.ts | 9 + src/targets/openclaw.ts | 96 +++++++++++ src/types/openclaw.ts | 52 ++++++ 5 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 src/converters/claude-to-openclaw.ts create mode 100644 src/targets/openclaw.ts create mode 100644 src/types/openclaw.ts diff --git a/src/commands/install.ts b/src/commands/install.ts index eeb5a85..b5522b8 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -25,7 +25,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 | openclaw)", }, output: { type: "string", @@ -195,6 +195,9 @@ function resolveTargetOutputRoot( const base = hasExplicitOutput ? outputRoot : process.cwd() return path.join(base, ".kiro") } + if (targetName === "openclaw") { + return path.join(os.homedir(), ".openclaw", "extensions", "compound-engineering") + } return outputRoot } diff --git a/src/converters/claude-to-openclaw.ts b/src/converters/claude-to-openclaw.ts new file mode 100644 index 0000000..71fdce3 --- /dev/null +++ b/src/converters/claude-to-openclaw.ts @@ -0,0 +1,239 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import type { + ClaudeAgent, + ClaudeCommand, + ClaudePlugin, + ClaudeMcpServer, +} from "../types/claude" +import type { + OpenClawBundle, + OpenClawCommandRegistration, + OpenClawPluginManifest, + OpenClawSkillFile, +} from "../types/openclaw" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export function convertClaudeToOpenClaw( + plugin: ClaudePlugin, + _options: ClaudeToOpenCodeOptions, +): OpenClawBundle { + const manifest = buildManifest(plugin) + const packageJson = buildPackageJson(plugin) + + const agentSkills = plugin.agents.map(convertAgentToSkill) + const commandSkills = plugin.commands + .filter((cmd) => !cmd.disableModelInvocation) + .map(convertCommandToSkill) + + const commands = plugin.commands + .filter((cmd) => !cmd.disableModelInvocation) + .map(convertCommand) + + const skills: OpenClawSkillFile[] = [...agentSkills, ...commandSkills] + + const skillDirCopies = plugin.skills.map((skill) => ({ + sourceDir: skill.sourceDir, + name: skill.name, + })) + + // Add original skill names to manifest.skills + const allSkillDirs = [ + ...agentSkills.map((s) => s.dir), + ...commandSkills.map((s) => s.dir), + ...plugin.skills.map((s) => s.name), + ] + manifest.skills = allSkillDirs.map((dir) => `skills/${dir}`) + + const openclawConfig = plugin.mcpServers + ? buildOpenClawConfig(plugin.mcpServers) + : undefined + + const entryPoint = generateEntryPoint(commands) + + return { + manifest, + packageJson, + entryPoint, + skills, + skillDirCopies, + commands, + openclawConfig, + } +} + +function buildManifest(plugin: ClaudePlugin): OpenClawPluginManifest { + return { + id: plugin.manifest.name, + name: formatDisplayName(plugin.manifest.name), + kind: "tool", + } +} + +function buildPackageJson(plugin: ClaudePlugin): Record { + return { + name: `openclaw-${plugin.manifest.name}`, + version: plugin.manifest.version, + type: "module", + private: true, + description: plugin.manifest.description, + main: "index.ts", + openclaw: { + extensions: [ + { + id: plugin.manifest.name, + entry: "./index.ts", + }, + ], + }, + keywords: [ + "openclaw", + "openclaw-plugin", + ...(plugin.manifest.keywords ?? []), + ], + } +} + +function convertAgentToSkill(agent: ClaudeAgent): OpenClawSkillFile { + const frontmatter: Record = { + name: agent.name, + description: agent.description, + } + + if (agent.model && agent.model !== "inherit") { + frontmatter.model = agent.model + } + + const body = rewritePaths(agent.body) + const content = formatFrontmatter(frontmatter, body) + + return { + name: agent.name, + content, + dir: `agent-${agent.name}`, + } +} + +function convertCommandToSkill(command: ClaudeCommand): OpenClawSkillFile { + const frontmatter: Record = { + name: `cmd-${command.name}`, + description: command.description, + } + + if (command.model && command.model !== "inherit") { + frontmatter.model = command.model + } + + const body = rewritePaths(command.body) + const content = formatFrontmatter(frontmatter, body) + + return { + name: command.name, + content, + dir: `cmd-${command.name}`, + } +} + +function convertCommand(command: ClaudeCommand): OpenClawCommandRegistration { + return { + name: command.name.replace(/:/g, "-"), + description: command.description ?? `Run ${command.name}`, + acceptsArgs: Boolean(command.argumentHint), + body: rewritePaths(command.body), + } +} + +function buildOpenClawConfig( + servers: Record, +): Record { + const mcpServers: Record = {} + + for (const [name, server] of Object.entries(servers)) { + if (server.command) { + mcpServers[name] = { + type: "stdio", + command: server.command, + args: server.args ?? [], + env: server.env, + } + } else if (server.url) { + mcpServers[name] = { + type: "http", + url: server.url, + headers: server.headers, + } + } + } + + return { mcpServers } +} + +function generateEntryPoint(commands: OpenClawCommandRegistration[]): string { + const commandRegistrations = commands + .map((cmd) => { + const escapedName = cmd.name.replace(/"/g, '\\"') + const escapedDesc = (cmd.description ?? "").replace(/"/g, '\\"') + return ` api.registerCommand({ + name: "${escapedName}", + description: "${escapedDesc}", + acceptsArgs: ${cmd.acceptsArgs}, + requireAuth: false, + handler: (ctx) => ({ + text: skills["${escapedName}"] ?? "Command ${escapedName} not found. Check skills directory.", + }), + });` + }) + .join("\n\n") + + return `// Auto-generated OpenClaw plugin entry point +// Converted from Claude Code plugin format by compound-plugin CLI +import { promises as fs } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Pre-load skill bodies for command responses +const skills = {}; + +async function loadSkills() { + const skillsDir = path.join(__dirname, "skills"); + try { + const entries = await fs.readdir(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const skillPath = path.join(skillsDir, entry.name, "SKILL.md"); + try { + const content = await fs.readFile(skillPath, "utf8"); + // Strip frontmatter + const body = content.replace(/^---[\\s\\S]*?---\\n*/, ""); + skills[entry.name.replace(/^cmd-/, "")] = body.trim(); + } catch { + // Skill file not found, skip + } + } + } catch { + // Skills directory not found + } +} + +export default async function register(api) { + await loadSkills(); + +${commandRegistrations} +} +` +} + +function rewritePaths(body: string): string { + return body + .replace(/~\/\.claude\//g, "~/.openclaw/") + .replace(/\.claude\//g, ".openclaw/") + .replace(/\.claude-plugin\//g, "openclaw-plugin/") +} + +function formatDisplayName(name: string): string { + return name + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") +} diff --git a/src/targets/index.ts b/src/targets/index.ts index b7b3ea2..196a17e 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 { OpenClawBundle } from "../types/openclaw" 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 { convertClaudeToOpenClaw } from "../converters/claude-to-openclaw" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" import { writeDroidBundle } from "./droid" @@ -20,6 +22,7 @@ import { writePiBundle } from "./pi" import { writeCopilotBundle } from "./copilot" import { writeGeminiBundle } from "./gemini" import { writeKiroBundle } from "./kiro" +import { writeOpenClawBundle } from "./openclaw" export type TargetHandler = { name: string @@ -71,4 +74,10 @@ export const targets: Record = { convert: convertClaudeToKiro as TargetHandler["convert"], write: writeKiroBundle as TargetHandler["write"], }, + openclaw: { + name: "openclaw", + implemented: true, + convert: convertClaudeToOpenClaw as TargetHandler["convert"], + write: writeOpenClawBundle as TargetHandler["write"], + }, } diff --git a/src/targets/openclaw.ts b/src/targets/openclaw.ts new file mode 100644 index 0000000..d2ec688 --- /dev/null +++ b/src/targets/openclaw.ts @@ -0,0 +1,96 @@ +import path from "path" +import { promises as fs } from "fs" +import { backupFile, copyDir, ensureDir, pathExists, readJson, walkFiles, writeJson, writeText } from "../utils/files" +import type { OpenClawBundle } from "../types/openclaw" + +export async function writeOpenClawBundle(outputRoot: string, bundle: OpenClawBundle): Promise { + const paths = resolveOpenClawPaths(outputRoot) + await ensureDir(paths.root) + + // Write openclaw.plugin.json + await writeJson(paths.manifestPath, bundle.manifest) + + // Write package.json + await writeJson(paths.packageJsonPath, bundle.packageJson) + + // Write index.ts entry point + await writeText(paths.entryPointPath, bundle.entryPoint) + + // Write generated skills (agents + commands converted to SKILL.md) + for (const skill of bundle.skills) { + const skillDir = path.join(paths.skillsDir, skill.dir) + await ensureDir(skillDir) + await writeText(path.join(skillDir, "SKILL.md"), skill.content + "\n") + } + + // Copy original skill directories (preserving references/, assets/, scripts/) + // and rewrite .claude/ paths to .openclaw/ in markdown files + for (const skill of bundle.skillDirCopies) { + const destDir = path.join(paths.skillsDir, skill.name) + await copyDir(skill.sourceDir, destDir) + await rewritePathsInDir(destDir) + } + + // Write openclaw.json config fragment if MCP servers exist + if (bundle.openclawConfig) { + const configPath = path.join(paths.root, "openclaw.json") + const backupPath = await backupFile(configPath) + if (backupPath) { + console.log(`Backed up existing config to ${backupPath}`) + } + const merged = await mergeOpenClawConfig(configPath, bundle.openclawConfig) + await writeJson(configPath, merged) + } +} + +function resolveOpenClawPaths(outputRoot: string) { + return { + root: outputRoot, + manifestPath: path.join(outputRoot, "openclaw.plugin.json"), + packageJsonPath: path.join(outputRoot, "package.json"), + entryPointPath: path.join(outputRoot, "index.ts"), + skillsDir: path.join(outputRoot, "skills"), + } +} + +async function rewritePathsInDir(dir: string): Promise { + const files = await walkFiles(dir) + for (const file of files) { + if (!file.endsWith(".md")) continue + const content = await fs.readFile(file, "utf8") + const rewritten = content + .replace(/~\/\.claude\//g, "~/.openclaw/") + .replace(/\.claude\//g, ".openclaw/") + .replace(/\.claude-plugin\//g, "openclaw-plugin/") + if (rewritten !== content) { + await fs.writeFile(file, rewritten, "utf8") + } + } +} + +async function mergeOpenClawConfig( + configPath: string, + incoming: Record, +): Promise> { + if (!(await pathExists(configPath))) return incoming + + let existing: Record + try { + existing = await readJson>(configPath) + } catch { + console.warn( + `Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`, + ) + return incoming + } + + // Merge MCP servers: existing takes precedence on conflict + const incomingMcp = (incoming.mcpServers ?? {}) as Record + const existingMcp = (existing.mcpServers ?? {}) as Record + const mergedMcp = { ...incomingMcp, ...existingMcp } + + return { + ...existing, + mcpServers: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined, + } +} diff --git a/src/types/openclaw.ts b/src/types/openclaw.ts new file mode 100644 index 0000000..5d68910 --- /dev/null +++ b/src/types/openclaw.ts @@ -0,0 +1,52 @@ +export type OpenClawPluginManifest = { + id: string + name: string + kind: "tool" + configSchema?: { + type: "object" + additionalProperties: boolean + properties: Record + required?: string[] + } + uiHints?: Record + skills?: string[] +} + +export type OpenClawConfigProperty = { + type: string + description?: string + default?: unknown +} + +export type OpenClawUiHint = { + label: string + sensitive?: boolean + placeholder?: string +} + +export type OpenClawSkillFile = { + name: string + content: string + /** Subdirectory path inside skills/ (e.g. "agent-native-reviewer") */ + dir: string +} + +export type OpenClawCommandRegistration = { + name: string + description: string + acceptsArgs: boolean + /** The prompt body that becomes the command handler response */ + body: string +} + +export type OpenClawBundle = { + manifest: OpenClawPluginManifest + packageJson: Record + entryPoint: string + skills: OpenClawSkillFile[] + /** Skill directories to copy verbatim (original Claude skills with references/) */ + skillDirCopies: { sourceDir: string; name: string }[] + commands: OpenClawCommandRegistration[] + /** openclaw.json fragment for MCP servers */ + openclawConfig?: Record +} From 6fe51a060214e01ddc5aa331cd180b69fa00d17f Mon Sep 17 00:00:00 2001 From: Ryan Burnham Date: Thu, 26 Feb 2026 18:36:34 +0800 Subject: [PATCH 34/47] feat(windsurf): add Windsurf as converter target with global scope support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 11 + README.md | 11 +- ...feat-windsurf-global-scope-support-plan.md | 627 ++++++++++++++++ .../adding-converter-target-providers.md | 692 ++++++++++++++++++ docs/specs/windsurf.md | 477 ++++++++++++ plugins/compound-engineering/CHANGELOG.md | 16 + src/commands/convert.ts | 39 +- src/commands/install.ts | 58 +- src/commands/sync.ts | 15 +- src/converters/claude-to-windsurf.ts | 205 ++++++ src/targets/index.ts | 41 ++ src/targets/windsurf.ts | 102 +++ src/types/windsurf.ts | 34 + src/utils/files.ts | 7 + src/utils/resolve-output.ts | 39 + src/utils/secrets.ts | 24 + tests/resolve-output.test.ts | 93 +++ tests/windsurf-converter.test.ts | 573 +++++++++++++++ tests/windsurf-writer.test.ts | 359 +++++++++ 19 files changed, 3361 insertions(+), 62 deletions(-) create mode 100644 docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md create mode 100644 docs/solutions/adding-converter-target-providers.md create mode 100644 docs/specs/windsurf.md create mode 100644 src/converters/claude-to-windsurf.ts create mode 100644 src/targets/windsurf.ts create mode 100644 src/types/windsurf.ts create mode 100644 src/utils/resolve-output.ts create mode 100644 src/utils/secrets.ts create mode 100644 tests/resolve-output.test.ts create mode 100644 tests/windsurf-converter.test.ts create mode 100644 tests/windsurf-writer.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4376dd5..5572769 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 (`workflows/{name}.md`), 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 5885038..0038ad7 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 (`workflows/{name}.md`), 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 0000000..162ecb2 --- /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 `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 }>, +): 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 0000000..3b69df7 --- /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 0000000..129d4d5 --- /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 +├── 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 + +- 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 ede6b06..dcd83a5 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 (`workflows/{name}.md`), 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/src/commands/convert.ts b/src/commands/convert.ts index 93efb40..4e9f102 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,7 +95,14 @@ export default defineCommand({ permissions: permissions as PermissionMode, } - const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome) + const primaryOutputRoot = resolveTargetOutputRoot({ + targetName, + outputRoot, + codexHome, + piHome, + hasExplicitOutput, + scope: resolvedScope, + }) const bundle = target.convert(plugin, options) if (!bundle) { throw new Error(`Target ${targetName} did not return a bundle.`) @@ -113,7 +128,14 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome) + const extraRoot = resolveTargetOutputRoot({ + targetName: extra, + outputRoot: path.join(outputRoot, extra), + codexHome, + piHome, + hasExplicitOutput, + scope: handler.defaultScope, + }) await handler.write(extraRoot, extraBundle) console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`) } @@ -140,12 +162,3 @@ function resolveOutputRoot(value: unknown): string { return process.cwd() } -function resolveTargetOutputRoot(targetName: string, outputRoot: string, codexHome: string, piHome: string): string { - if (targetName === "codex") return codexHome - if (targetName === "pi") return piHome - if (targetName === "droid") return path.join(os.homedir(), ".factory") - if (targetName === "cursor") return path.join(outputRoot, ".cursor") - if (targetName === "gemini") return path.join(outputRoot, ".gemini") - if (targetName === "kiro") return path.join(outputRoot, ".kiro") - return outputRoot -} diff --git a/src/commands/install.ts b/src/commands/install.ts index eeb5a85..848016c 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,7 +103,14 @@ export default defineCommand({ throw new Error(`Target ${targetName} did not return a bundle.`) } const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) - const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput) + const primaryOutputRoot = resolveTargetOutputRoot({ + targetName, + outputRoot, + codexHome, + piHome, + hasExplicitOutput, + scope: resolvedScope, + }) await target.write(primaryOutputRoot, bundle) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) @@ -117,7 +131,14 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput) + const extraRoot = resolveTargetOutputRoot({ + targetName: extra, + outputRoot: path.join(outputRoot, extra), + codexHome, + piHome, + hasExplicitOutput, + scope: handler.defaultScope, + }) await handler.write(extraRoot, extraBundle) console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`) } @@ -169,35 +190,6 @@ function resolveOutputRoot(value: unknown): string { return path.join(os.homedir(), ".config", "opencode") } -function resolveTargetOutputRoot( - targetName: string, - outputRoot: string, - codexHome: string, - piHome: string, - hasExplicitOutput: boolean, -): string { - if (targetName === "codex") return codexHome - if (targetName === "pi") return piHome - if (targetName === "droid") return path.join(os.homedir(), ".factory") - if (targetName === "cursor") { - const base = hasExplicitOutput ? outputRoot : process.cwd() - return path.join(base, ".cursor") - } - if (targetName === "gemini") { - const base = hasExplicitOutput ? outputRoot : process.cwd() - return path.join(base, ".gemini") - } - if (targetName === "copilot") { - const base = hasExplicitOutput ? outputRoot : process.cwd() - return path.join(base, ".github") - } - if (targetName === "kiro") { - const base = hasExplicitOutput ? outputRoot : process.cwd() - return path.join(base, ".kiro") - } - return outputRoot -} - async function resolveGitHubPluginPath(pluginName: string): Promise { 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 b7b9ed4..ac5353e 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 0000000..975af99 --- /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 b7b3ea2..b226c5b 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,10 +22,41 @@ 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 } @@ -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 0000000..1171ae5 --- /dev/null +++ b/src/targets/windsurf.ts @@ -0,0 +1,102 @@ +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" + +/** + * 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): 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 workflows/, per spec) + if (bundle.commandWorkflows.length > 0) { + const workflowsDir = path.join(outputRoot, "workflows") + 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 0000000..8094a3a --- /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 9994d0c..a9d6af8 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 0000000..4cd05f5 --- /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 0000000..45f196d --- /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 0000000..d364f42 --- /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 0000000..4264a17 --- /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 0000000..9d1129c --- /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) + } + }) +}) From e081e32a30cf1b5a565a8e4b26f68d23d1f508f1 Mon Sep 17 00:00:00 2001 From: Ryan Burnham Date: Thu, 26 Feb 2026 20:29:40 +0800 Subject: [PATCH 35/47] fix: pass scope to writeWindsurfBundle and fix skill name casing - Fix resolve-pr-parallel SKILL.md name from underscores to hyphens (must match directory name per Windsurf spec) - Add scope parameter to TargetHandler.write signature - Pass resolvedScope through to writer in convert.ts and install.ts - Windsurf writer uses global_workflows/ for global scope, workflows/ for workspace scope Co-Authored-By: Claude Opus 4.6 --- .../skills/resolve-pr-parallel/SKILL.md | 2 +- src/commands/convert.ts | 4 ++-- src/commands/install.ts | 4 ++-- src/targets/index.ts | 2 +- src/targets/windsurf.ts | 8 +++++--- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md b/plugins/compound-engineering/skills/resolve-pr-parallel/SKILL.md index 46dc793..e040fba 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 4e9f102..321c579 100644 --- a/src/commands/convert.ts +++ b/src/commands/convert.ts @@ -108,7 +108,7 @@ export default defineCommand({ 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) @@ -136,7 +136,7 @@ export default defineCommand({ hasExplicitOutput, scope: handler.defaultScope, }) - await handler.write(extraRoot, extraBundle) + await handler.write(extraRoot, extraBundle, handler.defaultScope) console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`) } diff --git a/src/commands/install.ts b/src/commands/install.ts index 848016c..f94e81d 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -111,7 +111,7 @@ export default defineCommand({ hasExplicitOutput, scope: resolvedScope, }) - await target.write(primaryOutputRoot, bundle) + await target.write(primaryOutputRoot, bundle, resolvedScope) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) const extraTargets = parseExtraTargets(args.also) @@ -139,7 +139,7 @@ export default defineCommand({ hasExplicitOutput, scope: handler.defaultScope, }) - await handler.write(extraRoot, extraBundle) + await handler.write(extraRoot, extraBundle, handler.defaultScope) console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`) } diff --git a/src/targets/index.ts b/src/targets/index.ts index b226c5b..bb0509f 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -58,7 +58,7 @@ export type TargetHandler = { /** 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 = { diff --git a/src/targets/windsurf.ts b/src/targets/windsurf.ts index 1171ae5..ee96045 100644 --- a/src/targets/windsurf.ts +++ b/src/targets/windsurf.ts @@ -2,6 +2,7 @@ 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. @@ -9,7 +10,7 @@ import type { WindsurfBundle } from "../types/windsurf" * 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): Promise { +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) @@ -31,9 +32,10 @@ export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBu } } - // Write command workflows (flat in workflows/, per spec) + // Write command workflows (flat in global_workflows/ for global scope, workflows/ for workspace) if (bundle.commandWorkflows.length > 0) { - const workflowsDir = path.join(outputRoot, "workflows") + 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") From 1107e3dd3113417c4fc31f32d32cbeb6dcefe4b0 Mon Sep 17 00:00:00 2001 From: Ryan Burnham Date: Thu, 26 Feb 2026 20:35:09 +0800 Subject: [PATCH 36/47] docs: update spec and plan to reflect global_workflows/ directory Updated docs/specs/windsurf.md and the plan to accurately document that global scope workflows go in global_workflows/ while workspace scope workflows go in workflows/. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 2 +- README.md | 2 +- .../2026-02-25-feat-windsurf-global-scope-support-plan.md | 4 ++-- docs/specs/windsurf.md | 8 ++++---- plugins/compound-engineering/CHANGELOG.md | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5572769..fc2468b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Windsurf target** — `--to windsurf` converts plugins to Windsurf format. Claude agents become Windsurf skills (`skills/{name}/SKILL.md`), commands become flat workflows (`workflows/{name}.md`), and pass-through skills copy unchanged. MCP servers write to `mcp_config.json` (machine-readable, merged with existing config). +- **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. diff --git a/README.md b/README.md index 0038ad7..57df279 100644 --- a/README.md +++ b/README.md @@ -64,7 +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 (`workflows/{name}.md`), 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. +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 index 162ecb2..d90eb6a 100644 --- 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 @@ -15,7 +15,7 @@ After auditing the implementation against `docs/specs/windsurf.md`, two signific 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. +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`. @@ -24,7 +24,7 @@ After auditing the implementation against `docs/specs/windsurf.md`, two signific | 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}` | +| 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 | diff --git a/docs/specs/windsurf.md b/docs/specs/windsurf.md index 129d4d5..a895b52 100644 --- a/docs/specs/windsurf.md +++ b/docs/specs/windsurf.md @@ -53,7 +53,7 @@ All three support both **workspace-level** (project-specific) and **global** (us ├── skills/ # Global skills (directories) │ └── {skill-name}/ │ └── SKILL.md -├── workflows/ # Global workflows (flat .md files) +├── global_workflows/ # Global workflows (flat .md files) │ └── {workflow-name}.md ├── rules/ # Global rules (flat .md files) │ └── {rule-name}.md @@ -167,7 +167,7 @@ Workflows define step-by-step procedures invoked via slash commands. They guide | Scope | Location | |-------|----------| -| **Global** | `~/.codeium/windsurf/workflows/{workflow-name}.md` | +| **Global** | `~/.codeium/windsurf/global_workflows/{workflow-name}.md` | | **Workspace** | `.windsurf/workflows/{workflow-name}.md` | ### File Format @@ -208,7 +208,7 @@ Step-by-step instructions in markdown. ### Example: Complete Workflow -**File**: `~/.codeium/windsurf/workflows/address-pr-comments.md` +**File**: `~/.codeium/windsurf/global_workflows/address-pr-comments.md` ```markdown --- @@ -366,7 +366,7 @@ When items with the same name exist at multiple levels: | Type | Path Pattern | Format | Key Fields | |------|--------------|--------|------------| | **Skill** | `skills/{name}/SKILL.md` | YAML frontmatter + markdown | `name`, `description` | -| **Workflow** | `workflows/{name}.md` | YAML frontmatter + markdown | `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 | diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index dcd83a5..90229ed 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 (`workflows/{name}.md`), pass-through skills copy unchanged, and MCP servers write to `mcp_config.json`. +- **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`. From c59709184994586e7468410768e7599c193db42c Mon Sep 17 00:00:00 2001 From: Ian Guelman Date: Fri, 27 Feb 2026 13:25:04 -0300 Subject: [PATCH 37/47] docs: update Claude Code install command --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5885038..830fe8f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** ## Claude Code Install ```bash -/plugin marketplace add https://github.com/EveryInc/compound-engineering-plugin +/plugin marketplace add EveryInc/compound-engineering-plugin /plugin install compound-engineering ``` From 8a530f7e25448b8377686d84ae688e8e6990caa3 Mon Sep 17 00:00:00 2001 From: Brian Solon Date: Fri, 27 Feb 2026 16:43:51 -0500 Subject: [PATCH 38/47] fix: quote argument-hint values to prevent YAML object parsing Unquoted bracket syntax in `argument-hint` frontmatter causes YAML to parse the value as an array/mapping instead of a string literal. This crashes Claude Code's tab-completion TUI with React error #31 ("Objects are not valid as a React child") when the renderer tries to display the hint. Two commands affected: - `heal-skill`: `[optional: ...]` parsed as `[{optional: "..."}]` - `create-agent-skill`: `[skill ...]` parsed as `["skill ..."]` Fix: wrap values in quotes, consistent with the other 18 commands in the plugin that already quote their `argument-hint` values. Ref: https://github.com/anthropics/claude-code/issues/29422 --- plugins/compound-engineering/commands/create-agent-skill.md | 2 +- plugins/compound-engineering/commands/heal-skill.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/compound-engineering/commands/create-agent-skill.md b/plugins/compound-engineering/commands/create-agent-skill.md index 9ec53f9..2b3052b 100644 --- a/plugins/compound-engineering/commands/create-agent-skill.md +++ b/plugins/compound-engineering/commands/create-agent-skill.md @@ -2,7 +2,7 @@ name: create-agent-skill description: Create or edit Claude Code skills with expert guidance on structure and best practices allowed-tools: Skill(create-agent-skills) -argument-hint: [skill description or requirements] +argument-hint: "[skill description or requirements]" disable-model-invocation: true --- diff --git a/plugins/compound-engineering/commands/heal-skill.md b/plugins/compound-engineering/commands/heal-skill.md index 02d48a4..a021f31 100644 --- a/plugins/compound-engineering/commands/heal-skill.md +++ b/plugins/compound-engineering/commands/heal-skill.md @@ -1,7 +1,7 @@ --- name: heal-skill description: Fix incorrect SKILL.md files when a skill has wrong instructions or outdated API references -argument-hint: [optional: specific issue to fix] +argument-hint: "[optional: specific issue to fix]" allowed-tools: [Read, Edit, Bash(ls:*), Bash(git:*)] disable-model-invocation: true --- From e1d5bdedb39801a8b38c423b379de4dd36f14a12 Mon Sep 17 00:00:00 2001 From: Raymond Lam Date: Sat, 28 Feb 2026 11:51:28 -0500 Subject: [PATCH 39/47] feat: Add Qwen Code support - Add Qwen Code target for converting Claude Code plugins - Implement claude-to-qwen converter with agent/command/skill mapping - Write qwen-extension.json config with MCP servers and settings - Generate QWEN.md context file with plugin documentation - Support nested commands with colon separator (workflows:plan) - Extract MCP environment placeholders as settings - Add --to qwen and --qwen-home CLI options - Document Qwen installation in README Co-authored-by: Qwen-Coder --- README.md | 8 +- src/commands/install.ts | 12 +- src/converters/claude-to-qwen.ts | 262 +++++++++++++++++++++++++++++++ src/targets/index.ts | 9 ++ src/targets/qwen.ts | 81 ++++++++++ src/types/qwen.ts | 48 ++++++ 6 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 src/converters/claude-to-qwen.ts create mode 100644 src/targets/qwen.ts create mode 100644 src/types/qwen.ts diff --git a/README.md b/README.md index 5885038..f805f8e 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 & Qwen (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 Qwen Code. ```bash # convert the compound-engineering plugin into OpenCode format @@ -43,6 +43,9 @@ 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 Qwen Code format +bunx @every-env/compound-plugin install compound-engineering --to qwen ``` Local dev: @@ -58,6 +61,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). +Qwen output is written to `~/.qwen/extensions/compound-engineering/` by default with `qwen-extension.json` (MCP servers), `QWEN.md` (context), agents (`.yaml`), commands (`.md`), and skills. Claude tool names are passed through unchanged. MCP server environment variables with placeholder values are extracted as settings in `qwen-extension.json`. Nested commands use colon separator (`workflows:plan` → `commands/workflows/plan.md`). All provider targets are experimental and may change as the formats evolve. diff --git a/src/commands/install.ts b/src/commands/install.ts index eeb5a85..edf9496 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -25,7 +25,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 | qwen)", }, output: { type: "string", @@ -42,6 +42,11 @@ export default defineCommand({ alias: "pi-home", description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)", }, + qwenHome: { + type: "string", + alias: "qwen-home", + description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions/compound-engineering)", + }, also: { type: "string", description: "Comma-separated extra targets to generate (ex: codex)", @@ -84,6 +89,7 @@ export default defineCommand({ const outputRoot = resolveOutputRoot(args.output) const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")) const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) + const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions", "compound-engineering")) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -178,6 +184,10 @@ function resolveTargetOutputRoot( ): string { if (targetName === "codex") return codexHome if (targetName === "pi") return piHome + if (targetName === "qwen") { + const base = hasExplicitOutput ? outputRoot : path.join(os.homedir(), ".qwen", "extensions") + return path.join(base, "compound-engineering") + } if (targetName === "droid") return path.join(os.homedir(), ".factory") if (targetName === "cursor") { const base = hasExplicitOutput ? outputRoot : process.cwd() diff --git a/src/converters/claude-to-qwen.ts b/src/converters/claude-to-qwen.ts new file mode 100644 index 0000000..99e4e64 --- /dev/null +++ b/src/converters/claude-to-qwen.ts @@ -0,0 +1,262 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import type { ClaudeAgent, ClaudeCommand, ClaudeMcpServer, ClaudePlugin } from "../types/claude" +import type { + QwenAgentFile, + QwenBundle, + QwenCommandFile, + QwenExtensionConfig, + QwenMcpServer, + QwenSetting, +} from "../types/qwen" + +export type ClaudeToQwenOptions = { + agentMode: "primary" | "subagent" + inferTemperature: boolean +} + +const TOOL_MAP: Record = { + bash: "bash", + read: "read", + write: "write", + edit: "edit", + grep: "grep", + glob: "glob", + list: "list", + webfetch: "webfetch", + skill: "skill", + patch: "patch", + task: "task", + question: "question", + todowrite: "todowrite", + todoread: "todoread", +} + +export function convertClaudeToQwen(plugin: ClaudePlugin, options: ClaudeToQwenOptions): QwenBundle { + const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options)) + const cmdFiles = convertCommands(plugin.commands) + const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined + const settings = extractSettings(plugin.mcpServers) + + const config: QwenExtensionConfig = { + name: plugin.manifest.name, + version: plugin.manifest.version || "1.0.0", + commands: "commands", + skills: "skills", + agents: "agents", + } + + if (mcp && Object.keys(mcp).length > 0) { + config.mcpServers = mcp + } + + if (settings && settings.length > 0) { + config.settings = settings + } + + const contextFile = generateContextFile(plugin) + + return { + config, + agents: agentFiles, + commandFiles: cmdFiles, + skillDirs: plugin.skills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })), + contextFile, + } +} + +function convertAgent(agent: ClaudeAgent, options: ClaudeToQwenOptions): QwenAgentFile { + const frontmatter: Record = { + name: agent.name, + description: agent.description, + } + + if (agent.model && agent.model !== "inherit") { + frontmatter.model = normalizeModel(agent.model) + } + + if (options.inferTemperature) { + const temperature = inferTemperature(agent) + if (temperature !== undefined) { + frontmatter.temperature = temperature + } + } + + // Qwen supports both YAML and Markdown for agents + // Using YAML format for structured config + const content = formatFrontmatter(frontmatter, rewriteQwenPaths(agent.body)) + + return { + name: agent.name, + content, + format: "yaml", + } +} + +function convertCommands(commands: ClaudeCommand[]): QwenCommandFile[] { + const files: QwenCommandFile[] = [] + for (const command of commands) { + if (command.disableModelInvocation) continue + const frontmatter: Record = { + description: command.description, + } + if (command.model && command.model !== "inherit") { + frontmatter.model = normalizeModel(command.model) + } + if (command.allowedTools && command.allowedTools.length > 0) { + frontmatter.allowedTools = command.allowedTools + } + const content = formatFrontmatter(frontmatter, rewriteQwenPaths(command.body)) + files.push({ name: command.name, content }) + } + return files +} + +function convertMcp(servers: Record): Record { + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + if (server.command) { + result[name] = { + command: server.command, + args: server.args, + env: server.env, + cwd: "${extensionPath}${/}", + } + continue + } + + if (server.url) { + // Qwen doesn't support remote MCP servers in the same way + // Convert to local with proxy or skip + console.warn(`Warning: Remote MCP server '${name}' with URL ${server.url} is not fully supported in Qwen format`) + result[name] = { + command: "curl", + args: [server.url], + env: server.headers, + } + } + } + return result +} + +function extractSettings(mcpServers?: Record): QwenSetting[] { + const settings: QwenSetting[] = [] + if (!mcpServers) return settings + + for (const [name, server] of Object.entries(mcpServers)) { + if (server.env) { + for (const [envVar, value] of Object.entries(server.env)) { + // Only add settings for environment variables that look like placeholders + if (value.startsWith("${") || value.includes("YOUR_") || value.includes("XXX")) { + settings.push({ + name: formatSettingName(envVar), + description: `Environment variable for ${name} MCP server`, + envVar, + sensitive: envVar.toLowerCase().includes("key") || envVar.toLowerCase().includes("token") || envVar.toLowerCase().includes("secret"), + }) + } + } + } + } + + return settings +} + +function formatSettingName(envVar: string): string { + return envVar + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function generateContextFile(plugin: ClaudePlugin): string { + const sections: string[] = [] + + // Plugin description + sections.push(`# ${plugin.name}`) + sections.push("") + if (plugin.description) { + sections.push(plugin.description) + sections.push("") + } + + // Agents section + if (plugin.agents.length > 0) { + sections.push("## Agents") + sections.push("") + for (const agent of plugin.agents) { + sections.push(`- **${agent.name}**: ${agent.description || "No description"}`) + } + sections.push("") + } + + // Commands section + if (plugin.commands.length > 0) { + sections.push("## Commands") + sections.push("") + for (const command of plugin.commands) { + if (!command.disableModelInvocation) { + sections.push(`- **/${command.name}**: ${command.description || "No description"}`) + } + } + sections.push("") + } + + // Skills section + if (plugin.skills.length > 0) { + sections.push("## Skills") + sections.push("") + for (const skill of plugin.skills) { + sections.push(`- ${skill.name}`) + } + sections.push("") + } + + return sections.join("\n") +} + +function rewriteQwenPaths(body: string): string { + return body + .replace(/~\/\.claude\//g, "~/.qwen/") + .replace(/\.claude\//g, ".qwen/") + .replace(/~\/\.config\/opencode\//g, "~/.qwen/") + .replace(/\.opencode\//g, ".qwen/") +} + +const CLAUDE_FAMILY_ALIASES: Record = { + haiku: "claude-haiku", + sonnet: "claude-sonnet", + opus: "claude-opus", +} + +function normalizeModel(model: string): string { + if (model.includes("/")) return model + if (CLAUDE_FAMILY_ALIASES[model]) { + const resolved = `anthropic/${CLAUDE_FAMILY_ALIASES[model]}` + console.warn( + `Warning: bare model alias "${model}" mapped to "${resolved}".`, + ) + return resolved + } + if (/^claude-/.test(model)) return `anthropic/${model}` + if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}` + if (/^gemini-/.test(model)) return `google/${model}` + if (/^qwen-/.test(model)) return `qwen/${model}` + return `anthropic/${model}` +} + +function inferTemperature(agent: ClaudeAgent): number | undefined { + const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase() + if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) { + return 0.1 + } + if (/(plan|planning|architecture|strategist|analysis|research)/.test(sample)) { + return 0.2 + } + if (/(doc|readme|changelog|editor|writer)/.test(sample)) { + return 0.3 + } + if (/(brainstorm|creative|ideate|design|concept)/.test(sample)) { + return 0.6 + } + return 0.3 +} diff --git a/src/targets/index.ts b/src/targets/index.ts index b7b3ea2..37d4d41 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 { QwenBundle } from "../types/qwen" 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 { convertClaudeToQwen, type ClaudeToQwenOptions } from "../converters/claude-to-qwen" import { writeOpenCodeBundle } from "./opencode" import { writeCodexBundle } from "./codex" import { writeDroidBundle } from "./droid" @@ -20,6 +22,7 @@ import { writePiBundle } from "./pi" import { writeCopilotBundle } from "./copilot" import { writeGeminiBundle } from "./gemini" import { writeKiroBundle } from "./kiro" +import { writeQwenBundle } from "./qwen" export type TargetHandler = { name: string @@ -71,4 +74,10 @@ export const targets: Record = { convert: convertClaudeToKiro as TargetHandler["convert"], write: writeKiroBundle as TargetHandler["write"], }, + qwen: { + name: "qwen", + implemented: true, + convert: convertClaudeToQwen as TargetHandler["convert"], + write: writeQwenBundle as TargetHandler["write"], + }, } diff --git a/src/targets/qwen.ts b/src/targets/qwen.ts new file mode 100644 index 0000000..450524a --- /dev/null +++ b/src/targets/qwen.ts @@ -0,0 +1,81 @@ +import path from "path" +import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files" +import type { QwenBundle, QwenExtensionConfig } from "../types/qwen" + +export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): Promise { + const qwenPaths = resolveQwenPaths(outputRoot) + await ensureDir(qwenPaths.root) + + // Write qwen-extension.json config + const configPath = qwenPaths.configPath + const backupPath = await backupFile(configPath) + if (backupPath) { + console.log(`Backed up existing config to ${backupPath}`) + } + await writeJson(configPath, bundle.config) + + // Write context file (QWEN.md) + if (bundle.contextFile) { + await writeText(qwenPaths.contextPath, bundle.contextFile + "\n") + } + + // Write agents + const agentsDir = qwenPaths.agentsDir + await ensureDir(agentsDir) + for (const agent of bundle.agents) { + const ext = agent.format === "yaml" ? "yaml" : "md" + await writeText(path.join(agentsDir, `${agent.name}.${ext}`), agent.content + "\n") + } + + // Write commands + const commandsDir = qwenPaths.commandsDir + await ensureDir(commandsDir) + for (const commandFile of bundle.commandFiles) { + // Support nested commands with colon separator + const parts = commandFile.name.split(":") + if (parts.length > 1) { + const nestedDir = path.join(commandsDir, ...parts.slice(0, -1)) + await ensureDir(nestedDir) + await writeText(path.join(nestedDir, `${parts[parts.length - 1]}.md`), commandFile.content + "\n") + } else { + await writeText(path.join(commandsDir, `${commandFile.name}.md`), commandFile.content + "\n") + } + } + + // Copy skills + if (bundle.skillDirs.length > 0) { + const skillsRoot = qwenPaths.skillsDir + await ensureDir(skillsRoot) + for (const skill of bundle.skillDirs) { + await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name)) + } + } +} + +function resolveQwenPaths(outputRoot: string) { + const base = path.basename(outputRoot) + // Global install: ~/.qwen/extensions/ + // Project install: .qwen/extensions/ or at root + // If the output root already ends with "extensions" or contains ".qwen/extensions", write directly + if (base === "extensions" || outputRoot.includes(".qwen/extensions")) { + return { + root: outputRoot, + configPath: path.join(outputRoot, "qwen-extension.json"), + contextPath: path.join(outputRoot, "QWEN.md"), + agentsDir: path.join(outputRoot, "agents"), + commandsDir: path.join(outputRoot, "commands"), + skillsDir: path.join(outputRoot, "skills"), + } + } + + // Custom output directory - write directly to the output root (not nested) + // This is for project-level installs like ./my-extension + return { + root: outputRoot, + configPath: path.join(outputRoot, "qwen-extension.json"), + contextPath: path.join(outputRoot, "QWEN.md"), + agentsDir: path.join(outputRoot, "agents"), + commandsDir: path.join(outputRoot, "commands"), + skillsDir: path.join(outputRoot, "skills"), + } +} diff --git a/src/types/qwen.ts b/src/types/qwen.ts new file mode 100644 index 0000000..82cf178 --- /dev/null +++ b/src/types/qwen.ts @@ -0,0 +1,48 @@ +export type QwenExtensionConfig = { + name: string + version: string + mcpServers?: Record + contextFileName?: string + commands?: string + skills?: string + agents?: string + settings?: QwenSetting[] +} + +export type QwenMcpServer = { + command?: string + args?: string[] + env?: Record + cwd?: string +} + +export type QwenSetting = { + name: string + description: string + envVar: string + sensitive?: boolean +} + +export type QwenAgentFile = { + name: string + content: string + format: "yaml" | "markdown" +} + +export type QwenSkillDir = { + sourceDir: string + name: string +} + +export type QwenCommandFile = { + name: string + content: string +} + +export type QwenBundle = { + config: QwenExtensionConfig + agents: QwenAgentFile[] + commandFiles: QwenCommandFile[] + skillDirs: QwenSkillDir[] + contextFile?: string +} From 4b60bcaf6cb322d9efcd7c658356852c48257c95 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sun, 1 Mar 2026 14:35:31 -0800 Subject: [PATCH 40/47] fix: Address review findings in OpenClaw converter - Fix P1: Replace incomplete string escaping in generateEntryPoint with JSON.stringify() to prevent code injection via command names/descriptions with backslashes, newlines, or other special characters - Fix P1: Remove hardcoded 'compound-engineering' output path; resolve from plugin.manifest.name via new openclawHome + pluginName params - Fix P2: Add --openclaw-home CLI flag (default: ~/.openclaw/extensions) consistent with --codex-home and --pi-home patterns - Fix P2: Emit typed `const skills: Record = {}` in generated TypeScript to prevent downstream type errors - Fix P3: Add lookbehind guards to rewritePaths() matching kiro pattern - Fix P3: Extract duplicated disableModelInvocation filter to variable - Fix P3: Build manifest skills list before constructing manifest object (no post-construction mutation) - Export ClaudeToOpenClawOptions type alias for interface clarity - Add openclaw-converter.test.ts with 13 tests covering all scenarios Co-Authored-By: Claude --- src/commands/install.ts | 14 +- src/converters/claude-to-openclaw.ts | 43 +++--- tests/openclaw-converter.test.ts | 200 +++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 tests/openclaw-converter.test.ts diff --git a/src/commands/install.ts b/src/commands/install.ts index b5522b8..58a471c 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -42,6 +42,11 @@ export default defineCommand({ alias: "pi-home", description: "Write Pi output to this Pi root (ex: ~/.pi/agent or ./.pi)", }, + openclawHome: { + type: "string", + alias: "openclaw-home", + description: "Write OpenClaw output to this extensions root (ex: ~/.openclaw/extensions)", + }, also: { type: "string", description: "Comma-separated extra targets to generate (ex: codex)", @@ -84,6 +89,7 @@ export default defineCommand({ const outputRoot = resolveOutputRoot(args.output) const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")) const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) + const openclawHome = resolveTargetHome(args.openclawHome, path.join(os.homedir(), ".openclaw", "extensions")) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -96,7 +102,7 @@ export default defineCommand({ throw new Error(`Target ${targetName} did not return a bundle.`) } const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) - const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput) + const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, openclawHome, plugin.manifest.name, hasExplicitOutput) await target.write(primaryOutputRoot, bundle) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) @@ -117,7 +123,7 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput) + const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, openclawHome, plugin.manifest.name, hasExplicitOutput) await handler.write(extraRoot, extraBundle) console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`) } @@ -174,6 +180,8 @@ function resolveTargetOutputRoot( outputRoot: string, codexHome: string, piHome: string, + openclawHome: string, + pluginName: string, hasExplicitOutput: boolean, ): string { if (targetName === "codex") return codexHome @@ -196,7 +204,7 @@ function resolveTargetOutputRoot( return path.join(base, ".kiro") } if (targetName === "openclaw") { - return path.join(os.homedir(), ".openclaw", "extensions", "compound-engineering") + return path.join(openclawHome, pluginName) } return outputRoot } diff --git a/src/converters/claude-to-openclaw.ts b/src/converters/claude-to-openclaw.ts index 71fdce3..83a0192 100644 --- a/src/converters/claude-to-openclaw.ts +++ b/src/converters/claude-to-openclaw.ts @@ -13,21 +13,17 @@ import type { } from "../types/openclaw" import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" +export type ClaudeToOpenClawOptions = ClaudeToOpenCodeOptions + export function convertClaudeToOpenClaw( plugin: ClaudePlugin, - _options: ClaudeToOpenCodeOptions, + _options: ClaudeToOpenClawOptions, ): OpenClawBundle { - const manifest = buildManifest(plugin) - const packageJson = buildPackageJson(plugin) + const enabledCommands = plugin.commands.filter((cmd) => !cmd.disableModelInvocation) const agentSkills = plugin.agents.map(convertAgentToSkill) - const commandSkills = plugin.commands - .filter((cmd) => !cmd.disableModelInvocation) - .map(convertCommandToSkill) - - const commands = plugin.commands - .filter((cmd) => !cmd.disableModelInvocation) - .map(convertCommand) + const commandSkills = enabledCommands.map(convertCommandToSkill) + const commands = enabledCommands.map(convertCommand) const skills: OpenClawSkillFile[] = [...agentSkills, ...commandSkills] @@ -36,13 +32,15 @@ export function convertClaudeToOpenClaw( name: skill.name, })) - // Add original skill names to manifest.skills const allSkillDirs = [ ...agentSkills.map((s) => s.dir), ...commandSkills.map((s) => s.dir), ...plugin.skills.map((s) => s.name), ] - manifest.skills = allSkillDirs.map((dir) => `skills/${dir}`) + + const manifest = buildManifest(plugin, allSkillDirs) + + const packageJson = buildPackageJson(plugin) const openclawConfig = plugin.mcpServers ? buildOpenClawConfig(plugin.mcpServers) @@ -61,11 +59,12 @@ export function convertClaudeToOpenClaw( } } -function buildManifest(plugin: ClaudePlugin): OpenClawPluginManifest { +function buildManifest(plugin: ClaudePlugin, skillDirs: string[]): OpenClawPluginManifest { return { id: plugin.manifest.name, name: formatDisplayName(plugin.manifest.name), kind: "tool", + skills: skillDirs.map((dir) => `skills/${dir}`), } } @@ -170,15 +169,17 @@ function buildOpenClawConfig( function generateEntryPoint(commands: OpenClawCommandRegistration[]): string { const commandRegistrations = commands .map((cmd) => { - const escapedName = cmd.name.replace(/"/g, '\\"') - const escapedDesc = (cmd.description ?? "").replace(/"/g, '\\"') + // JSON.stringify produces a fully-escaped string literal safe for JS/TS source embedding + const safeName = JSON.stringify(cmd.name) + const safeDesc = JSON.stringify(cmd.description ?? "") + const safeNotFound = JSON.stringify(`Command ${cmd.name} not found. Check skills directory.`) return ` api.registerCommand({ - name: "${escapedName}", - description: "${escapedDesc}", + name: ${safeName}, + description: ${safeDesc}, acceptsArgs: ${cmd.acceptsArgs}, requireAuth: false, handler: (ctx) => ({ - text: skills["${escapedName}"] ?? "Command ${escapedName} not found. Check skills directory.", + text: skills[${safeName}] ?? ${safeNotFound}, }), });` }) @@ -193,7 +194,7 @@ import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Pre-load skill bodies for command responses -const skills = {}; +const skills: Record = {}; async function loadSkills() { const skillsDir = path.join(__dirname, "skills"); @@ -226,8 +227,8 @@ ${commandRegistrations} function rewritePaths(body: string): string { return body - .replace(/~\/\.claude\//g, "~/.openclaw/") - .replace(/\.claude\//g, ".openclaw/") + .replace(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.openclaw/") + .replace(/(?<=^|\s|["'`])\.claude\//gm, ".openclaw/") .replace(/\.claude-plugin\//g, "openclaw-plugin/") } diff --git a/tests/openclaw-converter.test.ts b/tests/openclaw-converter.test.ts new file mode 100644 index 0000000..7cde0ae --- /dev/null +++ b/tests/openclaw-converter.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test" +import { convertClaudeToOpenClaw } from "../src/converters/claude-to-openclaw" +import { parseFrontmatter } from "../src/utils/frontmatter" +import type { ClaudePlugin } from "../src/types/claude" + +const fixturePlugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "compound-engineering", version: "1.0.0", description: "A plugin" }, + agents: [ + { + name: "security-reviewer", + description: "Security-focused agent", + capabilities: ["Threat modeling", "OWASP"], + model: "claude-sonnet-4-20250514", + body: "Focus on vulnerabilities in ~/.claude/settings.", + sourcePath: "/tmp/plugin/agents/security-reviewer.md", + }, + ], + commands: [ + { + name: "workflows:plan", + description: "Planning command", + argumentHint: "[FOCUS]", + model: "inherit", + allowedTools: ["Read"], + body: "Plan the work. See ~/.claude/settings for config.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + { + name: "disabled-cmd", + description: "Disabled command", + model: "inherit", + allowedTools: [], + body: "Should be excluded.", + disableModelInvocation: true, + sourcePath: "/tmp/plugin/commands/disabled-cmd.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: "npx", args: ["-y", "some-mcp-server"] }, + remote: { url: "https://mcp.example.com/api", headers: { Authorization: "Bearer token" } }, + }, +} + +const defaultOptions = { + agentMode: "subagent" as const, + inferTemperature: false, + permissions: "none" as const, +} + +describe("convertClaudeToOpenClaw", () => { + test("converts agents to skill files with SKILL.md content", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + const skill = bundle.skills.find((s) => s.name === "security-reviewer") + expect(skill).toBeDefined() + expect(skill!.dir).toBe("agent-security-reviewer") + const parsed = parseFrontmatter(skill!.content) + expect(parsed.data.name).toBe("security-reviewer") + expect(parsed.data.description).toBe("Security-focused agent") + expect(parsed.data.model).toBe("claude-sonnet-4-20250514") + expect(parsed.body).toContain("Focus on vulnerabilities") + }) + + test("converts commands to skill files (excluding disableModelInvocation)", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + const cmdSkill = bundle.skills.find((s) => s.name === "workflows:plan") + expect(cmdSkill).toBeDefined() + expect(cmdSkill!.dir).toBe("cmd-workflows:plan") + + const disabledSkill = bundle.skills.find((s) => s.name === "disabled-cmd") + expect(disabledSkill).toBeUndefined() + }) + + test("commands list excludes disableModelInvocation commands", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + const cmd = bundle.commands.find((c) => c.name === "workflows-plan") + expect(cmd).toBeDefined() + expect(cmd!.description).toBe("Planning command") + expect(cmd!.acceptsArgs).toBe(true) + + const disabled = bundle.commands.find((c) => c.name === "disabled-cmd") + expect(disabled).toBeUndefined() + }) + + test("command colons are replaced with dashes in command registrations", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + const cmd = bundle.commands.find((c) => c.name === "workflows-plan") + expect(cmd).toBeDefined() + expect(cmd!.name).not.toContain(":") + }) + + test("manifest includes plugin id, display name, and skills list", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + expect(bundle.manifest.id).toBe("compound-engineering") + expect(bundle.manifest.name).toBe("Compound Engineering") + expect(bundle.manifest.kind).toBe("tool") + expect(bundle.manifest.skills).toContain("skills/agent-security-reviewer") + expect(bundle.manifest.skills).toContain("skills/cmd-workflows:plan") + expect(bundle.manifest.skills).toContain("skills/existing-skill") + }) + + test("package.json uses plugin name and version", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + expect(bundle.packageJson.name).toBe("openclaw-compound-engineering") + expect(bundle.packageJson.version).toBe("1.0.0") + expect(bundle.packageJson.type).toBe("module") + }) + + test("skillDirCopies includes original skill directories", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + const copy = bundle.skillDirCopies.find((s) => s.name === "existing-skill") + expect(copy).toBeDefined() + expect(copy!.sourceDir).toBe("/tmp/plugin/skills/existing-skill") + }) + + test("stdio MCP servers included in openclaw config", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + expect(bundle.openclawConfig).toBeDefined() + const mcp = (bundle.openclawConfig!.mcpServers as Record) + expect(mcp.local).toBeDefined() + expect((mcp.local as any).type).toBe("stdio") + expect((mcp.local as any).command).toBe("npx") + }) + + test("HTTP MCP servers included as http type in openclaw config", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + const mcp = (bundle.openclawConfig!.mcpServers as Record) + expect(mcp.remote).toBeDefined() + expect((mcp.remote as any).type).toBe("http") + expect((mcp.remote as any).url).toBe("https://mcp.example.com/api") + }) + + test("paths are rewritten from .claude/ to .openclaw/ in skill content", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + + const agentSkill = bundle.skills.find((s) => s.name === "security-reviewer") + expect(agentSkill!.content).toContain("~/.openclaw/settings") + expect(agentSkill!.content).not.toContain("~/.claude/settings") + + const cmdSkill = bundle.skills.find((s) => s.name === "workflows:plan") + expect(cmdSkill!.content).toContain("~/.openclaw/settings") + expect(cmdSkill!.content).not.toContain("~/.claude/settings") + }) + + test("generateEntryPoint uses JSON.stringify for safe string escaping", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + commands: [ + { + name: "tricky-cmd", + description: 'Has "quotes" and \\backslashes\\ and\nnewlines', + model: "inherit", + allowedTools: [], + body: "body", + sourcePath: "/tmp/cmd.md", + }, + ], + } + const bundle = convertClaudeToOpenClaw(plugin, defaultOptions) + + // Entry point must be valid JS/TS — JSON.stringify handles all special chars + expect(bundle.entryPoint).toContain('"tricky-cmd"') + expect(bundle.entryPoint).toContain('\\"quotes\\"') + expect(bundle.entryPoint).toContain("\\\\backslashes\\\\") + expect(bundle.entryPoint).toContain("\\n") + // No raw unescaped newline inside a string literal + const lines = bundle.entryPoint.split("\n") + const nameLine = lines.find((l) => l.includes("tricky-cmd") && l.includes("name:")) + expect(nameLine).toBeDefined() + }) + + test("generateEntryPoint emits typed skills record", () => { + const bundle = convertClaudeToOpenClaw(fixturePlugin, defaultOptions) + expect(bundle.entryPoint).toContain("const skills: Record = {}") + }) + + test("plugin without MCP servers has no openclawConfig", () => { + const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined } + const bundle = convertClaudeToOpenClaw(plugin, defaultOptions) + expect(bundle.openclawConfig).toBeUndefined() + }) +}) From 305fea486f57661a1922a5764a3a1aa0f7cc9b8b Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sun, 1 Mar 2026 14:38:42 -0800 Subject: [PATCH 41/47] fix: Address review findings in Qwen converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix P1: Remove dead TOOL_MAP constant (defined but never referenced) - Fix P1: Replace curl fallback for remote MCP servers with warn-and-skip, matching the kiro pattern — curl is not an MCP server - Fix P1: Remove incorrect literal cwd field ("${extensionPath}${/}") from stdio MCP server config; the value was never interpolated - Fix P1: Fix plugin.name → plugin.manifest.name in generateContextFile (plugin.name does not exist on ClaudePlugin; produced "# undefined") - Fix P1: Wire qwenHome through resolveTargetOutputRoot; previously the --qwen-home CLI flag was parsed but silently discarded - Fix P1: Remove hardcoded "compound-engineering" from qwen output path; now uses plugin.manifest.name via new qwenHome + pluginName params - Fix P1: Collapse dead-code resolveQwenPaths branches (both returned identical structures; simplify to a single return) - Fix P3: Remove rewriting of .opencode/ paths to .qwen/ — Claude plugins do not reference opencode paths, and rewriting them is incorrect - Fix P3: inferTemperature now returns undefined for unrecognized agents instead of 0.3 (matching the explicit doc branch), letting the model use its default temperature - Fix P2: Add lookbehind guards to rewriteQwenPaths() matching kiro pattern to avoid rewriting paths inside compound tokens or URLs - Update --qwen-home default to ~/.qwen/extensions (plugin name appended) - Add qwen-converter.test.ts with 16 tests covering all scenarios Co-Authored-By: Claude --- src/commands/install.ts | 13 +- src/converters/claude-to-qwen.ts | 44 ++---- src/targets/qwen.ts | 17 --- tests/qwen-converter.test.ts | 238 +++++++++++++++++++++++++++++++ 4 files changed, 255 insertions(+), 57 deletions(-) create mode 100644 tests/qwen-converter.test.ts diff --git a/src/commands/install.ts b/src/commands/install.ts index edf9496..5f1ac09 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -45,7 +45,7 @@ export default defineCommand({ qwenHome: { type: "string", alias: "qwen-home", - description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions/compound-engineering)", + description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions)", }, also: { type: "string", @@ -89,7 +89,7 @@ export default defineCommand({ const outputRoot = resolveOutputRoot(args.output) const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")) const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) - const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions", "compound-engineering")) + const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions")) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -102,7 +102,7 @@ export default defineCommand({ throw new Error(`Target ${targetName} did not return a bundle.`) } const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) - const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput) + const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, qwenHome, plugin.manifest.name, hasExplicitOutput) await target.write(primaryOutputRoot, bundle) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) @@ -123,7 +123,7 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput) + const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, qwenHome, plugin.manifest.name, hasExplicitOutput) await handler.write(extraRoot, extraBundle) console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`) } @@ -180,13 +180,14 @@ function resolveTargetOutputRoot( outputRoot: string, codexHome: string, piHome: string, + qwenHome: string, + pluginName: string, hasExplicitOutput: boolean, ): string { if (targetName === "codex") return codexHome if (targetName === "pi") return piHome if (targetName === "qwen") { - const base = hasExplicitOutput ? outputRoot : path.join(os.homedir(), ".qwen", "extensions") - return path.join(base, "compound-engineering") + return path.join(qwenHome, pluginName) } if (targetName === "droid") return path.join(os.homedir(), ".factory") if (targetName === "cursor") { diff --git a/src/converters/claude-to-qwen.ts b/src/converters/claude-to-qwen.ts index 99e4e64..c07b177 100644 --- a/src/converters/claude-to-qwen.ts +++ b/src/converters/claude-to-qwen.ts @@ -14,23 +14,6 @@ export type ClaudeToQwenOptions = { inferTemperature: boolean } -const TOOL_MAP: Record = { - bash: "bash", - read: "read", - write: "write", - edit: "edit", - grep: "grep", - glob: "glob", - list: "list", - webfetch: "webfetch", - skill: "skill", - patch: "patch", - task: "task", - question: "question", - todowrite: "todowrite", - todoread: "todoread", -} - export function convertClaudeToQwen(plugin: ClaudePlugin, options: ClaudeToQwenOptions): QwenBundle { const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options)) const cmdFiles = convertCommands(plugin.commands) @@ -119,20 +102,15 @@ function convertMcp(servers: Record): Record = { @@ -258,5 +234,5 @@ function inferTemperature(agent: ClaudeAgent): number | undefined { if (/(brainstorm|creative|ideate|design|concept)/.test(sample)) { return 0.6 } - return 0.3 + return undefined } diff --git a/src/targets/qwen.ts b/src/targets/qwen.ts index 450524a..a822857 100644 --- a/src/targets/qwen.ts +++ b/src/targets/qwen.ts @@ -53,23 +53,6 @@ export async function writeQwenBundle(outputRoot: string, bundle: QwenBundle): P } function resolveQwenPaths(outputRoot: string) { - const base = path.basename(outputRoot) - // Global install: ~/.qwen/extensions/ - // Project install: .qwen/extensions/ or at root - // If the output root already ends with "extensions" or contains ".qwen/extensions", write directly - if (base === "extensions" || outputRoot.includes(".qwen/extensions")) { - return { - root: outputRoot, - configPath: path.join(outputRoot, "qwen-extension.json"), - contextPath: path.join(outputRoot, "QWEN.md"), - agentsDir: path.join(outputRoot, "agents"), - commandsDir: path.join(outputRoot, "commands"), - skillsDir: path.join(outputRoot, "skills"), - } - } - - // Custom output directory - write directly to the output root (not nested) - // This is for project-level installs like ./my-extension return { root: outputRoot, configPath: path.join(outputRoot, "qwen-extension.json"), diff --git a/tests/qwen-converter.test.ts b/tests/qwen-converter.test.ts new file mode 100644 index 0000000..b9690a3 --- /dev/null +++ b/tests/qwen-converter.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test" +import { convertClaudeToQwen } from "../src/converters/claude-to-qwen" +import { parseFrontmatter } from "../src/utils/frontmatter" +import type { ClaudePlugin } from "../src/types/claude" + +const fixturePlugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "compound-engineering", version: "1.2.0", description: "A plugin for engineers" }, + agents: [ + { + name: "security-sentinel", + description: "Security-focused agent", + capabilities: ["Threat modeling", "OWASP"], + model: "claude-sonnet-4-20250514", + body: "Focus on vulnerabilities in ~/.claude/settings.", + sourcePath: "/tmp/plugin/agents/security-sentinel.md", + }, + { + name: "brainstorm-agent", + description: "Creative brainstormer", + model: "inherit", + body: "Generate ideas.", + sourcePath: "/tmp/plugin/agents/brainstorm-agent.md", + }, + ], + commands: [ + { + name: "workflows:plan", + description: "Planning command", + argumentHint: "[FOCUS]", + model: "inherit", + allowedTools: ["Read"], + body: "Plan the work. Config at ~/.claude/settings.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + { + name: "disabled-cmd", + description: "Disabled", + model: "inherit", + allowedTools: [], + body: "Should be excluded.", + disableModelInvocation: true, + sourcePath: "/tmp/plugin/commands/disabled-cmd.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: "npx", args: ["-y", "some-mcp"], env: { API_KEY: "${YOUR_API_KEY}" } }, + remote: { url: "https://mcp.example.com/api", headers: { Authorization: "Bearer token" } }, + }, +} + +const defaultOptions = { + agentMode: "subagent" as const, + inferTemperature: false, +} + +describe("convertClaudeToQwen", () => { + test("converts agents to yaml format with frontmatter", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + + const agent = bundle.agents.find((a) => a.name === "security-sentinel") + expect(agent).toBeDefined() + expect(agent!.format).toBe("yaml") + const parsed = parseFrontmatter(agent!.content) + expect(parsed.data.name).toBe("security-sentinel") + expect(parsed.data.description).toBe("Security-focused agent") + expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514") + expect(parsed.body).toContain("Focus on vulnerabilities") + }) + + test("agent with inherit model has no model field in frontmatter", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + const agent = bundle.agents.find((a) => a.name === "brainstorm-agent") + expect(agent).toBeDefined() + const parsed = parseFrontmatter(agent!.content) + expect(parsed.data.model).toBeUndefined() + }) + + test("inferTemperature injects temperature based on agent name/description", () => { + const bundle = convertClaudeToQwen(fixturePlugin, { ...defaultOptions, inferTemperature: true }) + + const sentinel = bundle.agents.find((a) => a.name === "security-sentinel") + const parsed = parseFrontmatter(sentinel!.content) + expect(parsed.data.temperature).toBe(0.1) // review/security → 0.1 + + const brainstorm = bundle.agents.find((a) => a.name === "brainstorm-agent") + const bParsed = parseFrontmatter(brainstorm!.content) + expect(bParsed.data.temperature).toBe(0.6) // brainstorm → 0.6 + }) + + test("inferTemperature returns undefined for unrecognized agents (no temperature set)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [{ name: "my-helper", description: "Generic helper", model: "inherit", body: "help", sourcePath: "/tmp/a.md" }], + } + const bundle = convertClaudeToQwen(plugin, { ...defaultOptions, inferTemperature: true }) + const agent = bundle.agents[0] + const parsed = parseFrontmatter(agent.content) + expect(parsed.data.temperature).toBeUndefined() + }) + + test("converts commands to command files excluding disableModelInvocation", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + + const planCmd = bundle.commandFiles.find((c) => c.name === "workflows:plan") + expect(planCmd).toBeDefined() + const parsed = parseFrontmatter(planCmd!.content) + expect(parsed.data.description).toBe("Planning command") + expect(parsed.data.allowedTools).toEqual(["Read"]) + + const disabled = bundle.commandFiles.find((c) => c.name === "disabled-cmd") + expect(disabled).toBeUndefined() + }) + + test("config uses plugin manifest name and version", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + expect(bundle.config.name).toBe("compound-engineering") + expect(bundle.config.version).toBe("1.2.0") + expect(bundle.config.commands).toBe("commands") + expect(bundle.config.skills).toBe("skills") + expect(bundle.config.agents).toBe("agents") + }) + + test("stdio MCP servers are included in config", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + expect(bundle.config.mcpServers).toBeDefined() + const local = bundle.config.mcpServers!.local + expect(local.command).toBe("npx") + expect(local.args).toEqual(["-y", "some-mcp"]) + // No cwd field + expect((local as any).cwd).toBeUndefined() + }) + + test("remote MCP servers are skipped with a warning (not converted to curl)", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + // Only local (stdio) server should be present + expect(bundle.config.mcpServers).toBeDefined() + expect(bundle.config.mcpServers!.remote).toBeUndefined() + expect(bundle.config.mcpServers!.local).toBeDefined() + }) + + test("placeholder env vars are extracted as settings", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + expect(bundle.config.settings).toBeDefined() + const apiKeySetting = bundle.config.settings!.find((s) => s.envVar === "API_KEY") + expect(apiKeySetting).toBeDefined() + expect(apiKeySetting!.sensitive).toBe(true) + expect(apiKeySetting!.name).toBe("Api Key") + }) + + test("plugin with no MCP servers has no mcpServers in config", () => { + const plugin: ClaudePlugin = { ...fixturePlugin, mcpServers: undefined } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + expect(bundle.config.mcpServers).toBeUndefined() + }) + + test("context file uses plugin.manifest.name and manifest.description", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + expect(bundle.contextFile).toContain("# compound-engineering") + expect(bundle.contextFile).toContain("A plugin for engineers") + expect(bundle.contextFile).toContain("## Agents") + expect(bundle.contextFile).toContain("security-sentinel") + expect(bundle.contextFile).toContain("## Commands") + expect(bundle.contextFile).toContain("/workflows:plan") + // Disabled commands excluded + expect(bundle.contextFile).not.toContain("disabled-cmd") + expect(bundle.contextFile).toContain("## Skills") + expect(bundle.contextFile).toContain("existing-skill") + }) + + test("paths are rewritten from .claude/ to .qwen/ in agent and command content", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + + const agent = bundle.agents.find((a) => a.name === "security-sentinel") + expect(agent!.content).toContain("~/.qwen/settings") + expect(agent!.content).not.toContain("~/.claude/settings") + + const cmd = bundle.commandFiles.find((c) => c.name === "workflows:plan") + expect(cmd!.content).toContain("~/.qwen/settings") + expect(cmd!.content).not.toContain("~/.claude/settings") + }) + + test("opencode paths are NOT rewritten (only claude paths)", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "test-agent", + description: "test", + model: "inherit", + body: "See .opencode/config and ~/.config/opencode/settings", + sourcePath: "/tmp/a.md", + }, + ], + } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + const agent = bundle.agents[0] + // opencode paths should NOT be rewritten + expect(agent.content).toContain(".opencode/config") + expect(agent.content).not.toContain(".qwen/config") + }) + + test("skillDirs passes through original skills", () => { + const bundle = convertClaudeToQwen(fixturePlugin, defaultOptions) + const skill = bundle.skillDirs.find((s) => s.name === "existing-skill") + expect(skill).toBeDefined() + expect(skill!.sourceDir).toBe("/tmp/plugin/skills/existing-skill") + }) + + test("normalizeModel prefixes claude models with anthropic/", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [{ name: "a", description: "d", model: "claude-opus-4-5", body: "b", sourcePath: "/tmp/a.md" }], + } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBe("anthropic/claude-opus-4-5") + }) + + test("normalizeModel passes through already-namespaced models unchanged", () => { + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [{ name: "a", description: "d", model: "google/gemini-2.0", body: "b", sourcePath: "/tmp/a.md" }], + } + const bundle = convertClaudeToQwen(plugin, defaultOptions) + const parsed = parseFrontmatter(bundle.agents[0].content) + expect(parsed.data.model).toBe("google/gemini-2.0") + }) +}) From 1ea9806fb6308da89d08fefd918d20ea5a0e8376 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sun, 1 Mar 2026 14:56:03 -0800 Subject: [PATCH 42/47] docs: Update changelogs for all recent merges (no version bump) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI CHANGELOG (CHANGELOG.md): - Add OpenClaw target (#217, TrendpilotAI) to 0.11.0 - Add Qwen Code target (#220, rlam3) to 0.11.0 - Add Fixed section: code injection, plugin.manifest.name, remote MCP, CLI flags Plugin CHANGELOG (plugins/compound-engineering/CHANGELOG.md): - Add OpenClaw, Qwen, Windsurf install targets to 2.36.0 - Add Fixed: argument-hint YAML crash (#219, solon) - Add Fixed: resolve-pr-parallel skill name (underscore → hyphen) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 13 +++++++++++-- plugins/compound-engineering/CHANGELOG.md | 15 +++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2468b..596d6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,24 @@ 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 +## [0.11.0] - 2026-03-01 ### 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). +- **OpenClaw target** — `--to openclaw` converts plugins to OpenClaw format. Agents become `.md` files, commands become `.md` files, pass-through skills copy unchanged, and MCP servers are written to `openclaw-extension.json`. Output goes to `~/.openclaw/extensions//` by default. Use `--openclaw-home` to override. ([#217](https://github.com/EveryInc/compound-engineering-plugin/pull/217)) — thanks [@TrendpilotAI](https://github.com/TrendpilotAI)! +- **Qwen Code target** — `--to qwen` converts plugins to Qwen Code extension format. Agents become `.yaml` files with Qwen-compatible fields, commands become `.md` files, MCP servers write to `qwen-extension.json`, and a `QWEN.md` context file is generated. Output goes to `~/.qwen/extensions//` by default. Use `--qwen-home` to override. ([#220](https://github.com/EveryInc/compound-engineering-plugin/pull/220)) — thanks [@rlam3](https://github.com/rlam3)! +- **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). ([#202](https://github.com/EveryInc/compound-engineering-plugin/pull/202)) — thanks [@rburnham52](https://github.com/rburnham52)! - **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. +### Fixed + +- **OpenClaw code injection** — `generateEntryPoint` now uses `JSON.stringify()` for all string interpolation (was escaping only `"`, leaving `\n`/`\\` unguarded). +- **Qwen `plugin.manifest.name`** — context file header was `# undefined` due to using `plugin.name` (which doesn't exist on `ClaudePlugin`); fixed to `plugin.manifest.name`. +- **Qwen remote MCP servers** — curl fallback removed; HTTP/SSE servers are now skipped with a warning (Qwen only supports stdio transport). +- **`--openclaw-home` / `--qwen-home` CLI flags** — wired through to `resolveTargetOutputRoot` so custom home directories are respected. + --- ## [0.9.1] - 2026-02-20 diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index e415439..c4d462f 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -5,19 +5,18 @@ 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 +## [2.36.0] - 2026-03-01 ### 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`. +- **OpenClaw install target** — `bunx @every-env/compound-plugin install compound-engineering --to openclaw` now installs the plugin to OpenClaw's extensions directory. ([#217](https://github.com/EveryInc/compound-engineering-plugin/pull/217)) — thanks [@TrendpilotAI](https://github.com/TrendpilotAI)! +- **Qwen Code install target** — `bunx @every-env/compound-plugin install compound-engineering --to qwen` now installs the plugin to Qwen Code's extensions directory. ([#220](https://github.com/EveryInc/compound-engineering-plugin/pull/220)) — thanks [@rlam3](https://github.com/rlam3)! +- **Windsurf install target** — `bunx @every-env/compound-plugin install compound-engineering --to windsurf` converts plugins to Windsurf format. Agents become Windsurf skills, commands become flat workflows, and MCP servers write to `mcp_config.json`. Defaults to global scope (`~/.codeium/windsurf/`); use `--scope workspace` for project-level output. ([#202](https://github.com/EveryInc/compound-engineering-plugin/pull/202)) — thanks [@rburnham52](https://github.com/rburnham52)! -### Changed +### Fixed -- **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. +- **`create-agent-skill` / `heal-skill` YAML crash** — `argument-hint` values containing special characters now properly quoted to prevent YAML parse errors in the Claude Code TUI. ([#219](https://github.com/EveryInc/compound-engineering-plugin/pull/219)) — thanks [@solon](https://github.com/solon)! +- **`resolve-pr-parallel` skill name** — Renamed from `resolve_pr_parallel` (underscore) to `resolve-pr-parallel` (hyphen) to match the standard naming convention. --- From 2370da9c3c5118eaa2ac56adcbe455bc8358748a Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sun, 1 Mar 2026 14:57:50 -0800 Subject: [PATCH 43/47] docs: Add missing contributor mentions to changelogs - Credit @rburnham52 for resolve-pr-parallel skill name fix (#202) - Credit @XSAM for changelog link fix (#215) - Credit @ianguelman for README install command update (#218) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 2 ++ plugins/compound-engineering/CHANGELOG.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 596d6ff..37aab45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Remove docs/reports and docs/decisions directories** — only `docs/plans/` is retained as living documents that track implementation progress - **OpenCode commands as Markdown** — commands are now `.md` files with deep-merged config, permissions default to none ([#201](https://github.com/EveryInc/compound-engineering-plugin/pull/201)) — thanks [@0ut5ider](https://github.com/0ut5ider)! +- **Fix changelog GitHub link** ([#215](https://github.com/EveryInc/compound-engineering-plugin/pull/215)) — thanks [@XSAM](https://github.com/XSAM)! +- **Update Claude Code install command in README** ([#218](https://github.com/EveryInc/compound-engineering-plugin/pull/218)) — thanks [@ianguelman](https://github.com/ianguelman)! --- diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index c4d462f..694f5f3 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **`create-agent-skill` / `heal-skill` YAML crash** — `argument-hint` values containing special characters now properly quoted to prevent YAML parse errors in the Claude Code TUI. ([#219](https://github.com/EveryInc/compound-engineering-plugin/pull/219)) — thanks [@solon](https://github.com/solon)! -- **`resolve-pr-parallel` skill name** — Renamed from `resolve_pr_parallel` (underscore) to `resolve-pr-parallel` (hyphen) to match the standard naming convention. +- **`resolve-pr-parallel` skill name** — Renamed from `resolve_pr_parallel` (underscore) to `resolve-pr-parallel` (hyphen) to match the standard naming convention. ([#202](https://github.com/EveryInc/compound-engineering-plugin/pull/202)) — thanks [@rburnham52](https://github.com/rburnham52)! --- From 6b46fb2ccf4786e91ed1f8b2d215ba1233484760 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sun, 1 Mar 2026 15:01:11 -0800 Subject: [PATCH 44/47] refactor: Make Proof sharing optional in brainstorm and plan workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove automatic Proof upload from /workflows:brainstorm and /workflows:plan - Add "Share to Proof" as an explicit menu option in each workflow's handoff step - Default behavior is unchanged: documents are saved to MD files only - Users can opt in to Proof sharing when they want collaborative review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- plugins/compound-engineering/CHANGELOG.md | 4 +- .../commands/workflows/brainstorm.md | 45 ++++++++----------- .../commands/workflows/plan.md | 42 ++++++----------- 3 files changed, 34 insertions(+), 57 deletions(-) diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index a6f04cd..dcdef22 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -10,8 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **`proof` skill** — Create, edit, comment on, and share markdown documents via Proof's web API and local bridge. Supports document creation, track-changes suggestions, comments, and bulk rewrites. No authentication required for creating shared documents. -- **Proof upload in `/workflows:brainstorm`** — After writing the brainstorm document, automatically uploads it to Proof and displays a shareable URL for collaborative review. -- **Proof upload in `/workflows:plan`** — After writing the plan file, automatically uploads it to Proof and displays a shareable URL for collaborative review. +- **Optional Proof sharing in `/workflows:brainstorm`** — "Share to Proof" is now a menu option in Phase 4 handoff, letting you upload the brainstorm document when you want to, rather than automatically on every run. +- **Optional Proof sharing in `/workflows:plan`** — "Share to Proof" is now a menu option in Post-Generation Options, letting you upload the plan file on demand rather than automatically. --- diff --git a/plugins/compound-engineering/commands/workflows/brainstorm.md b/plugins/compound-engineering/commands/workflows/brainstorm.md index 06e6b77..08c44ca 100644 --- a/plugins/compound-engineering/commands/workflows/brainstorm.md +++ b/plugins/compound-engineering/commands/workflows/brainstorm.md @@ -89,8 +89,24 @@ Use **AskUserQuestion tool** to present next steps: **Options:** 1. **Review and refine** - Improve the document through structured self-review 2. **Proceed to planning** - Run `/workflows:plan` (will auto-detect this brainstorm) -3. **Ask more questions** - I have more questions to clarify before moving on -4. **Done for now** - Return later +3. **Share to Proof** - Upload to Proof for collaborative review and sharing +4. **Ask more questions** - I have more questions to clarify before moving on +5. **Done for now** - Return later + +**If user selects "Share to Proof":** + +```bash +CONTENT=$(cat docs/brainstorms/YYYY-MM-DD--brainstorm.md) +TITLE="Brainstorm: " +RESPONSE=$(curl -s -X POST https://www.proofeditor.ai/share/markdown \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg title "$TITLE" --arg markdown "$CONTENT" --arg by "ai:compound" '{title: $title, markdown: $markdown, by: $by}')") +PROOF_URL=$(echo "$RESPONSE" | jq -r '.tokenUrl') +``` + +Display the URL prominently: `View & collaborate in Proof: ` + +If the curl fails, skip silently. Then return to the Phase 4 options. **If user selects "Ask more questions":** YOU (Claude) return to Phase 1.2 (Collaborative Dialogue) and continue asking the USER questions one at a time to further refine the design. The user wants YOU to probe deeper - ask about edge cases, constraints, preferences, or areas not yet explored. Continue until the user is satisfied, then return to Phase 4. @@ -119,31 +135,6 @@ Key decisions: Next: Run `/workflows:plan` when ready to implement. ``` -### Share to Proof - -After writing the brainstorm document, upload it to Proof for collaborative review: - -```bash -# Read the brainstorm file content -CONTENT=$(cat docs/brainstorms/YYYY-MM-DD--brainstorm.md) -TITLE="Brainstorm: " - -# Upload to Proof -RESPONSE=$(curl -s -X POST https://www.proofeditor.ai/share/markdown \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg title "$TITLE" --arg markdown "$CONTENT" --arg by "ai:compound" '{title: $title, markdown: $markdown, by: $by}')") - -PROOF_URL=$(echo "$RESPONSE" | jq -r '.tokenUrl') -``` - -Display the Proof URL prominently: - -``` -View & collaborate in Proof: -``` - -If the curl fails (network error, non-JSON response), skip silently and continue — Proof sharing is optional. - ## Important Guidelines - **Stay focused on WHAT, not HOW** - Implementation details belong in the plan diff --git a/plugins/compound-engineering/commands/workflows/plan.md b/plugins/compound-engineering/commands/workflows/plan.md index 3a4a346..fd18ff5 100644 --- a/plugins/compound-engineering/commands/workflows/plan.md +++ b/plugins/compound-engineering/commands/workflows/plan.md @@ -544,31 +544,6 @@ Use the Write tool to save the complete plan to `docs/plans/YYYY-MM-DD----plan.md) -TITLE="Plan: " - -# Upload to Proof -RESPONSE=$(curl -s -X POST https://www.proofeditor.ai/share/markdown \ - -H "Content-Type: application/json" \ - -d "$(jq -n --arg title "$TITLE" --arg markdown "$CONTENT" --arg by "ai:compound" '{title: $title, markdown: $markdown, by: $by}')") - -PROOF_URL=$(echo "$RESPONSE" | jq -r '.tokenUrl') -``` - -Display the Proof URL prominently: - -``` -View & collaborate in Proof: -``` - -If the curl fails (network error, non-JSON response), skip silently and continue — Proof sharing is optional. - **Pipeline mode:** If invoked from an automated workflow (LFG, SLFG, or any `disable-model-invocation` context), skip all AskUserQuestion calls. Make decisions automatically and proceed to writing the plan without interactive prompts. ## Output Format @@ -599,15 +574,26 @@ After writing the plan file, use the **AskUserQuestion tool** to present these o 2. **Run `/deepen-plan`** - Enhance each section with parallel research agents (best practices, performance, UI) 3. **Run `/technical_review`** - Technical feedback from code-focused reviewers (DHH, Kieran, Simplicity) 4. **Review and refine** - Improve the document through structured self-review -5. **Start `/workflows:work`** - Begin implementing this plan locally -6. **Start `/workflows:work` on remote** - Begin implementing in Claude Code on the web (use `&` to run in background) -7. **Create Issue** - Create issue in project tracker (GitHub/Linear) +5. **Share to Proof** - Upload to Proof for collaborative review and sharing +6. **Start `/workflows:work`** - Begin implementing this plan locally +7. **Start `/workflows:work` on remote** - Begin implementing in Claude Code on the web (use `&` to run in background) +8. **Create Issue** - Create issue in project tracker (GitHub/Linear) Based on selection: - **Open plan in editor** → Run `open docs/plans/.md` to open the file in the user's default editor - **`/deepen-plan`** → Call the /deepen-plan command with the plan file path to enhance with research - **`/technical_review`** → Call the /technical_review command with the plan file path - **Review and refine** → Load `document-review` skill. +- **Share to Proof** → Upload the plan to Proof: + ```bash + CONTENT=$(cat docs/plans/.md) + TITLE="Plan: " + RESPONSE=$(curl -s -X POST https://www.proofeditor.ai/share/markdown \ + -H "Content-Type: application/json" \ + -d "$(jq -n --arg title "$TITLE" --arg markdown "$CONTENT" --arg by "ai:compound" '{title: $title, markdown: $markdown, by: $by}')") + PROOF_URL=$(echo "$RESPONSE" | jq -r '.tokenUrl') + ``` + Display: `View & collaborate in Proof: ` — skip silently if curl fails. Then return to options. - **`/workflows:work`** → Call the /workflows:work command with the plan file path - **`/workflows:work` on remote** → Run `/workflows:work docs/plans/.md &` to start work in background for Claude Code web - **Create Issue** → See "Issue Creation" section below From 62a66c8f7ffe23a93bbe82e1d53a43279251848e Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sun, 1 Mar 2026 15:03:02 -0800 Subject: [PATCH 45/47] docs: Add changelog entry for #214 and bump to v2.37.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix /workflows:review broken markdown rendering (XSAM) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude-plugin/marketplace.json | 2 +- plugins/compound-engineering/.claude-plugin/plugin.json | 2 +- plugins/compound-engineering/CHANGELOG.md | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 0240e51..43ca5b4 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,7 +12,7 @@ { "name": "compound-engineering", "description": "AI-powered development tools that get smarter with every use. Make each unit of engineering work easier than the last. Includes 29 specialized agents, 22 commands, and 20 skills.", - "version": "2.37.0", + "version": "2.37.1", "author": { "name": "Kieran Klaassen", "url": "https://github.com/kieranklaassen", diff --git a/plugins/compound-engineering/.claude-plugin/plugin.json b/plugins/compound-engineering/.claude-plugin/plugin.json index 6eed708..2634cca 100644 --- a/plugins/compound-engineering/.claude-plugin/plugin.json +++ b/plugins/compound-engineering/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "compound-engineering", - "version": "2.37.0", + "version": "2.37.1", "description": "AI-powered development tools. 29 agents, 22 commands, 20 skills, 1 MCP server for code review, research, design, and workflow automation.", "author": { "name": "Kieran Klaassen", diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index 8177d3e..f59c598 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -5,6 +5,14 @@ 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.37.1] - 2026-03-01 + +### Fixed + +- **`/workflows:review` rendering** — Fixed broken markdown output: "Next Steps" items 3 & 4 and Severity Breakdown no longer leak outside the Summary Report template, section numbering fixed (was jumping 5→7, now correct), removed orphaned fenced code block delimiters that caused the entire End-to-End Testing section to render as a code block, and fixed unclosed quoted string in section 1. ([#214](https://github.com/EveryInc/compound-engineering-plugin/pull/214)) — thanks [@XSAM](https://github.com/XSAM)! + +--- + ## [2.37.0] - 2026-03-01 ### Added From 2e2a58beab033dbd2e94d583b43da4dbd9de64c9 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sun, 1 Mar 2026 15:03:33 -0800 Subject: [PATCH 46/47] docs: Add changelog entry for #213 (.worktrees gitignore) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- plugins/compound-engineering/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index f59c598..ec452bb 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **`/workflows:review` rendering** — Fixed broken markdown output: "Next Steps" items 3 & 4 and Severity Breakdown no longer leak outside the Summary Report template, section numbering fixed (was jumping 5→7, now correct), removed orphaned fenced code block delimiters that caused the entire End-to-End Testing section to render as a code block, and fixed unclosed quoted string in section 1. ([#214](https://github.com/EveryInc/compound-engineering-plugin/pull/214)) — thanks [@XSAM](https://github.com/XSAM)! +- **`.worktrees` gitignore** — Added `.worktrees/` to `.gitignore` to prevent worktree directories created by the `git-worktree` skill from being tracked. ([#213](https://github.com/EveryInc/compound-engineering-plugin/pull/213)) — thanks [@XSAM](https://github.com/XSAM)! --- From 30837ef2e9e7b3300fec2410357077f3c00ca6e0 Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Sun, 1 Mar 2026 15:05:14 -0800 Subject: [PATCH 47/47] fix: Replace all stale every-marketplace references with compound-engineering-plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLAUDE.md: update repo name in title, structure diagram, and example install path - .claude-plugin/marketplace.json: rename marketplace identifier - docs/solutions/plugin-versioning-requirements.md: fix local file paths - plugins/compound-engineering/commands/deploy-docs.md: fix GitHub Pages URL - plans/landing-page-launchkit-refresh.md: fix local file paths Closes #211. Closed #212 (was inverting the fix). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude-plugin/marketplace.json | 2 +- CLAUDE.md | 6 +++--- docs/solutions/plugin-versioning-requirements.md | 6 +++--- plans/landing-page-launchkit-refresh.md | 4 ++-- plugins/compound-engineering/commands/deploy-docs.md | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 43ca5b4..fa02ae2 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,5 +1,5 @@ { - "name": "every-marketplace", + "name": "compound-engineering-plugin", "owner": { "name": "Kieran Klaassen", "url": "https://github.com/kieranklaassen" diff --git a/CLAUDE.md b/CLAUDE.md index 92ec03d..a301bd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,11 @@ -# Every Marketplace - Claude Code Plugin Marketplace +# compound-engineering-plugin - Claude Code Plugin Marketplace This repository is a Claude Code plugin marketplace that distributes the `compound-engineering` plugin to developers building with AI-powered tools. ## Repository Structure ``` -every-marketplace/ +compound-engineering-plugin/ ├── .claude-plugin/ │ └── marketplace.json # Marketplace catalog (lists available plugins) ├── docs/ # Documentation site (GitHub Pages) @@ -261,7 +261,7 @@ python -m http.server 8000 1. Install the marketplace locally: ```bash - claude /plugin marketplace add /Users/yourusername/every-marketplace + claude /plugin marketplace add /Users/yourusername/compound-engineering-plugin ``` 2. Install the plugin: diff --git a/docs/solutions/plugin-versioning-requirements.md b/docs/solutions/plugin-versioning-requirements.md index 5122780..bb3267d 100644 --- a/docs/solutions/plugin-versioning-requirements.md +++ b/docs/solutions/plugin-versioning-requirements.md @@ -72,6 +72,6 @@ This documentation serves as a reminder. When Claude Code works on this plugin, ## Related Files -- `/Users/kieranklaassen/every-marketplace/plugins/compound-engineering/.claude-plugin/plugin.json` -- `/Users/kieranklaassen/every-marketplace/plugins/compound-engineering/CHANGELOG.md` -- `/Users/kieranklaassen/every-marketplace/plugins/compound-engineering/README.md` +- `/Users/kieranklaassen/compound-engineering-plugin/plugins/compound-engineering/.claude-plugin/plugin.json` +- `/Users/kieranklaassen/compound-engineering-plugin/plugins/compound-engineering/CHANGELOG.md` +- `/Users/kieranklaassen/compound-engineering-plugin/plugins/compound-engineering/README.md` diff --git a/plans/landing-page-launchkit-refresh.md b/plans/landing-page-launchkit-refresh.md index 8384ee2..6a49ede 100644 --- a/plans/landing-page-launchkit-refresh.md +++ b/plans/landing-page-launchkit-refresh.md @@ -275,5 +275,5 @@ Review and enhance the `/docs/index.html` landing page using LaunchKit elements - LaunchKit Template: https://launchkit.evilmartians.io/ - Pragmatic Writing Skill: `~/.claude/skills/pragmatic-writing-skill/SKILL.md` -- Current Landing Page: `/Users/kieranklaassen/every-marketplace/docs/index.html` -- Style CSS: `/Users/kieranklaassen/every-marketplace/docs/css/style.css` +- Current Landing Page: `/Users/kieranklaassen/compound-engineering-plugin/docs/index.html` +- Style CSS: `/Users/kieranklaassen/compound-engineering-plugin/docs/css/style.css` diff --git a/plugins/compound-engineering/commands/deploy-docs.md b/plugins/compound-engineering/commands/deploy-docs.md index a54b8ea..93a19d4 100644 --- a/plugins/compound-engineering/commands/deploy-docs.md +++ b/plugins/compound-engineering/commands/deploy-docs.md @@ -109,5 +109,5 @@ Provide a summary: - [ ] Commit any pending changes - [ ] Push to main branch - [ ] Verify GitHub Pages workflow exists -- [ ] Check deployment at https://everyinc.github.io/every-marketplace/ +- [ ] Check deployment at https://everyinc.github.io/compound-engineering-plugin/ ```