diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a1b7be9..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" @@ -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.34.0", + "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.1", "author": { "name": "Kieran Klaassen", "url": "https://github.com/kieranklaassen", 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/.gitignore b/.gitignore index f8f7b97..dae7aba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ .codex/ todos/ +.worktrees diff --git a/AGENTS.md b/AGENTS.md index 471b900..9686f21 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,7 @@ 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 + +- **Plans** live in `docs/plans/` and track implementation progress. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e45db5..5c16438 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,69 @@ 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-14 +## [0.12.0] - 2026-03-01 ### Added -- **Auto-detect install targets** — `install --to all` and `convert --to all` auto-detect installed AI coding tools and install to all of them +- **Auto-detect install targets** — `install --to all` and `convert --to all` auto-detect installed AI coding tools and install to all of them in one command - **Gemini sync** — `sync --target gemini` symlinks personal skills to `.gemini/skills/` and merges MCP servers into `.gemini/settings.json` - **Sync all targets** — `sync --target all` syncs personal config to all detected tools - **Tool detection utility** — Checks config directories for OpenCode, Codex, Droid, Cursor, Pi, and Gemini +--- + +## [0.11.0] - 2026-03-01 + +### Added + +- **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 + +### 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)! +- **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)! + +--- + +## [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 + +- **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/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/README.md b/README.md index 5e44e1b..dcccfb2 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,19 @@ 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 ``` -## OpenCode, Codex, Droid, Cursor, Pi & Gemini (experimental) Install +## Cursor Install -This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode, Codex, Factory Droid, Cursor, Pi, and Gemini CLI. +```text +/add-plugin compound-engineering +``` + +## OpenCode, Codex, Droid, Pi, Gemini, Copilot, Kiro, Windsurf, OpenClaw & 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, Kiro CLI, Windsurf, OpenClaw, and Qwen Code. ```bash # convert the compound-engineering plugin into OpenCode format @@ -26,15 +32,30 @@ 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 # convert to Gemini CLI format 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 + +# convert to OpenClaw format +bunx @every-env/compound-plugin install compound-engineering --to openclaw + +# 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 + +# convert to Qwen Code format +bunx @every-env/compound-plugin install compound-engineering --to qwen + # auto-detect installed tools and install to all bunx @every-env/compound-plugin install compound-engineering --to all ``` @@ -45,20 +66,34 @@ 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. -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. -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. +
+Output format details per target + +| Target | Output path | Notes | +|--------|------------|-------| +| `opencode` | `~/.config/opencode/` | Commands as `.md` files; `opencode.json` MCP config deep-merged; backups made before overwriting | +| `codex` | `~/.codex/prompts` + `~/.codex/skills` | Each command becomes a prompt + skill pair; descriptions truncated to 1024 chars | +| `droid` | `~/.factory/` | Tool names mapped (`Bash`→`Execute`, `Write`→`Create`); namespace prefixes stripped | +| `pi` | `~/.pi/agent/` | Prompts, skills, extensions, and `mcporter.json` for MCPorter interoperability | +| `gemini` | `.gemini/` | Skills from agents; commands as `.toml`; namespaced commands become directories (`workflows:plan` → `commands/workflows/plan.toml`) | +| `copilot` | `.github/` | Agents as `.agent.md` with Copilot frontmatter; MCP env vars prefixed with `COPILOT_MCP_` | +| `kiro` | `.kiro/` | Agents as JSON configs + prompt `.md` files; only stdio MCP servers supported | +| `openclaw` | `~/.openclaw/extensions//` | Entry-point TypeScript skill file; `openclaw-extension.json` for MCP servers | +| `windsurf` | `~/.codeium/windsurf/` (global) or `.windsurf/` (workspace) | Agents become skills; commands become flat workflows; `mcp_config.json` merged | +| `qwen` | `~/.qwen/extensions//` | Agents as `.yaml`; env vars with placeholders extracted as settings; colon separator for nested commands | All provider targets are experimental and may change as the formats evolve. +
+ ## Sync Personal Config -Sync your personal Claude Code config (`~/.claude/`) to other AI coding tools: +Sync your personal Claude Code config (`~/.claude/`) to other AI coding tools. Omit `--target` to sync to all detected tools automatically: ```bash +# Sync to all detected tools (default) +bunx @every-env/compound-plugin sync + # Sync skills and MCP servers to OpenCode bunx @every-env/compound-plugin sync --target opencode @@ -71,8 +106,8 @@ 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 +# Sync to GitHub Copilot (skills + MCP servers) +bunx @every-env/compound-plugin sync --target copilot # Sync to Gemini (skills + MCP servers) bunx @every-env/compound-plugin sync --target gemini 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/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/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/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..d90eb6a --- /dev/null +++ b/docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md @@ -0,0 +1,627 @@ +--- +title: Windsurf Global Scope Support +type: feat +status: completed +date: 2026-02-25 +deepened: 2026-02-25 +prior: docs/plans/2026-02-23-feat-add-windsurf-target-provider-plan.md (removed — superseded) +--- + +# Windsurf Global Scope Support + +## Post-Implementation Revisions (2026-02-26) + +After auditing the implementation against `docs/specs/windsurf.md`, two significant changes were made: + +1. **Agents → Skills (not Workflows)**: Claude agents map to Windsurf Skills (`skills/{name}/SKILL.md`), not Workflows. Skills are "complex multi-step tasks with supporting resources" — a better conceptual match for specialized expertise/personas. Workflows are "reusable step-by-step procedures" — a better match for Claude Commands (slash commands). + +2. **Workflows are flat files**: Command workflows are written to `global_workflows/{name}.md` (global scope) or `workflows/{name}.md` (workspace scope). No subdirectories — the spec requires flat files. + +3. **Content transforms updated**: `@agent-name` references are kept as-is (Windsurf skill invocation syntax). `/command` references produce `/{name}` (not `/commands/{name}`). `Task agent(args)` produces `Use the @agent-name skill: args`. + +### Final Component Mapping (per spec) + +| Claude Code | Windsurf | Output Path | Invocation | +|---|---|---|---| +| Agents (`.md`) | Skills | `skills/{name}/SKILL.md` | `@skill-name` or automatic | +| Commands (`.md`) | Workflows (flat) | `global_workflows/{name}.md` (global) / `workflows/{name}.md` (workspace) | `/{workflow-name}` | +| Skills (`SKILL.md`) | Skills (pass-through) | `skills/{name}/SKILL.md` | `@skill-name` | +| MCP servers | `mcp_config.json` | `mcp_config.json` | N/A | +| Hooks | Skipped with warning | N/A | N/A | +| CLAUDE.md | Skipped | N/A | N/A | + +### Files Changed in Revision + +- `src/types/windsurf.ts` — `agentWorkflows` → `agentSkills: WindsurfGeneratedSkill[]` +- `src/converters/claude-to-windsurf.ts` — `convertAgentToSkill()`, updated content transforms +- `src/targets/windsurf.ts` — Skills written as `skills/{name}/SKILL.md`, flat workflows +- Tests updated to match + +--- + +## Enhancement Summary + +**Deepened on:** 2026-02-25 +**Research agents used:** architecture-strategist, kieran-typescript-reviewer, security-sentinel, code-simplicity-reviewer, pattern-recognition-specialist +**External research:** Windsurf MCP docs, Windsurf tutorial docs + +### Key Improvements from Deepening +1. **HTTP/SSE servers should be INCLUDED** — Windsurf supports all 3 transport types (stdio, Streamable HTTP, SSE). Original plan incorrectly skipped them. +2. **File permissions: use `0o600`** — `mcp_config.json` contains secrets and must not be world-readable. Add secure write support. +3. **Extract `resolveTargetOutputRoot` to shared utility** — both commands duplicate this; adding scope makes it worse. Extract first. +4. **Bug fix: missing `result[name] = entry`** — all 5 review agents caught a copy-paste bug in the `buildMcpConfig` sample code. +5. **`hasPotentialSecrets` to shared utility** — currently in sync.ts, would be duplicated. Extract to `src/utils/secrets.ts`. +6. **Windsurf `mcp_config.json` is global-only** — per Windsurf docs, no per-project MCP config support. Workspace scope writes it for forward-compatibility but emit a warning. +7. **Windsurf supports `${env:VAR}` interpolation** — consider writing env var references instead of literal values for secrets. + +### New Considerations Discovered +- Backup files accumulate with secrets and are never cleaned up — cap at 3 backups +- Workspace `mcp_config.json` could be committed to git — warn about `.gitignore` +- `WindsurfMcpServerEntry` type needs `serverUrl` field for HTTP/SSE servers +- Simplicity reviewer recommends handling scope as windsurf-specific in CLI rather than generic `TargetHandler` fields — but brainstorm explicitly chose "generic with windsurf as first adopter". **Decision: keep generic approach** per user's brainstorm decision, with JSDoc documenting the relationship between `defaultScope` and `supportedScopes`. + +--- + +## Overview + +Add a generic `--scope global|workspace` flag to the converter CLI with Windsurf as the first adopter. Global scope writes to `~/.codeium/windsurf/`, making workflows, skills, and MCP servers available across all projects. This also upgrades MCP handling from a human-readable setup doc (`mcp-setup.md`) to a proper machine-readable config (`mcp_config.json`), and removes AGENTS.md generation (the plugin's CLAUDE.md contains development-internal instructions, not user-facing content). + +## Problem Statement / Motivation + +The current Windsurf converter (v0.10.0) writes everything to project-level `.windsurf/`, requiring re-installation per project. Windsurf supports global paths for skills (`~/.codeium/windsurf/skills/`) and MCP config (`~/.codeium/windsurf/mcp_config.json`). Users should install once and get capabilities everywhere. + +Additionally, the v0.10.0 MCP output was a markdown setup guide — not an actual integration. Windsurf reads `mcp_config.json` directly, so we should write to that file. + +## Breaking Changes from v0.10.0 + +This is a **minor version bump** (v0.11.0) with intentional breaking changes to the experimental Windsurf target: + +1. **Default output location changed** — `--to windsurf` now defaults to global scope (`~/.codeium/windsurf/`). Use `--scope workspace` for the old behavior. +2. **AGENTS.md no longer generated** — old files are left in place (not deleted). +3. **`mcp-setup.md` replaced by `mcp_config.json`** — proper machine-readable integration. Old files left in place. +4. **Env var secrets included with warning** — previously redacted, now included (required for the config file to work). +5. **`--output` semantics changed** — `--output` now specifies the direct target directory (not a parent where `.windsurf/` is created). + +## Proposed Solution + +### Phase 0: Extract Shared Utilities (prerequisite) + +**Files:** `src/utils/resolve-output.ts` (new), `src/utils/secrets.ts` (new) + +#### 0a. Extract `resolveTargetOutputRoot` to shared utility + +Both `install.ts` and `convert.ts` have near-identical `resolveTargetOutputRoot` functions that are already diverging (`hasExplicitOutput` exists in install.ts but not convert.ts). Adding scope would make the duplication worse. + +- [x] Create `src/utils/resolve-output.ts` with a unified function: + +```typescript +import os from "os" +import path from "path" +import type { TargetScope } from "../targets" + +export function resolveTargetOutputRoot(options: { + targetName: string + outputRoot: string + codexHome: string + piHome: string + hasExplicitOutput: boolean + scope?: TargetScope +}): string { + const { targetName, outputRoot, codexHome, piHome, hasExplicitOutput, scope } = options + if (targetName === "codex") return codexHome + if (targetName === "pi") return piHome + if (targetName === "droid") return path.join(os.homedir(), ".factory") + if (targetName === "cursor") { + const base = hasExplicitOutput ? outputRoot : process.cwd() + return path.join(base, ".cursor") + } + if (targetName === "gemini") { + const base = hasExplicitOutput ? outputRoot : process.cwd() + return path.join(base, ".gemini") + } + if (targetName === "copilot") { + const base = hasExplicitOutput ? outputRoot : process.cwd() + return path.join(base, ".github") + } + if (targetName === "kiro") { + const base = hasExplicitOutput ? outputRoot : process.cwd() + return path.join(base, ".kiro") + } + if (targetName === "windsurf") { + if (hasExplicitOutput) return outputRoot + if (scope === "global") return path.join(os.homedir(), ".codeium", "windsurf") + return path.join(process.cwd(), ".windsurf") + } + return outputRoot +} +``` + +- [x] Update `install.ts` to import and call `resolveTargetOutputRoot` from shared utility +- [x] Update `convert.ts` to import and call `resolveTargetOutputRoot` from shared utility +- [x] Add `hasExplicitOutput` tracking to `convert.ts` (currently missing) + +### Research Insights (Phase 0) + +**Architecture review:** Both commands will call the same function with the same signature. This eliminates the divergence and ensures scope resolution has a single source of truth. The `--also` loop in both commands also uses this function with `handler.defaultScope`. + +**Pattern review:** This follows the same extraction pattern as `resolveTargetHome` in `src/utils/resolve-home.ts`. + +#### 0b. Extract `hasPotentialSecrets` to shared utility + +Currently in `sync.ts:20-31`. The same regex pattern also appears in `claude-to-windsurf.ts:223` as `redactEnvValue`. Extract to avoid a third copy. + +- [x] Create `src/utils/secrets.ts`: + +```typescript +const SENSITIVE_PATTERN = /key|token|secret|password|credential|api_key/i + +export function hasPotentialSecrets( + servers: Record }>, +): boolean { + for (const server of Object.values(servers)) { + if (server.env) { + for (const key of Object.keys(server.env)) { + if (SENSITIVE_PATTERN.test(key)) return true + } + } + } + return false +} +``` + +- [x] Update `sync.ts` to import from shared utility +- [x] Use in new windsurf converter + +### Phase 1: Types and TargetHandler + +**Files:** `src/types/windsurf.ts`, `src/targets/index.ts` + +#### 1a. Update WindsurfBundle type + +```typescript +// src/types/windsurf.ts +export type WindsurfMcpServerEntry = { + command?: string + args?: string[] + env?: Record + serverUrl?: string + headers?: Record +} + +export type WindsurfMcpConfig = { + mcpServers: Record +} + +export type WindsurfBundle = { + agentWorkflows: WindsurfWorkflow[] + commandWorkflows: WindsurfWorkflow[] + skillDirs: WindsurfSkillDir[] + mcpConfig: WindsurfMcpConfig | null +} +``` + +- [x] Remove `agentsMd: string | null` +- [x] Replace `mcpSetupDoc: string | null` with `mcpConfig: WindsurfMcpConfig | null` +- [x] Add `WindsurfMcpServerEntry` (supports both stdio and HTTP/SSE) and `WindsurfMcpConfig` types + +### Research Insights (Phase 1a) + +**Windsurf docs confirm** three transport types: stdio (`command` + `args`), Streamable HTTP (`serverUrl`), and SSE (`serverUrl` or `url`). The `WindsurfMcpServerEntry` type must support all three — making `command` optional and adding `serverUrl` and `headers` fields. + +**TypeScript reviewer:** Consider making `WindsurfMcpServerEntry` a discriminated union if strict typing is desired. However, since this mirrors JSON config structure, a flat type with optional fields is pragmatically simpler. + +#### 1b. Add TargetScope to TargetHandler + +```typescript +// src/targets/index.ts +export type TargetScope = "global" | "workspace" + +export type TargetHandler = { + name: string + implemented: boolean + /** + * Default scope when --scope is not provided. + * Only meaningful when supportedScopes is defined. + * Falls back to "workspace" if absent. + */ + defaultScope?: TargetScope + /** Valid scope values. If absent, the --scope flag is rejected for this target. */ + supportedScopes?: TargetScope[] + convert: (plugin: ClaudePlugin, options: ClaudeToOpenCodeOptions) => TBundle | null + write: (outputRoot: string, bundle: TBundle) => Promise +} +``` + +- [x] Add `TargetScope` type export +- [x] Add `defaultScope?` and `supportedScopes?` to `TargetHandler` with JSDoc +- [x] Set windsurf target: `defaultScope: "global"`, `supportedScopes: ["global", "workspace"]` +- [x] No changes to other targets (they have no scope fields, flag is ignored) + +### Research Insights (Phase 1b) + +**Simplicity review:** Argued this is premature generalization (only 1 of 8 targets uses scopes). Recommended handling scope as windsurf-specific with `if (targetName !== "windsurf")` guard instead. **Decision: keep generic approach** per brainstorm decision "Generic with windsurf as first adopter", but add JSDoc documenting the invariant. + +**TypeScript review:** Suggested a `ScopeConfig` grouped object to prevent `defaultScope` without `supportedScopes`. The JSDoc approach is simpler and sufficient for now. + +**Architecture review:** Adding optional fields to `TargetHandler` follows Open/Closed Principle — existing targets are unaffected. Clean extension. + +### Phase 2: Converter Changes + +**Files:** `src/converters/claude-to-windsurf.ts` + +#### 2a. Remove AGENTS.md generation + +- [x] Remove `buildAgentsMd()` function +- [x] Remove `agentsMd` from return value + +#### 2b. Replace MCP setup doc with MCP config + +- [x] Remove `buildMcpSetupDoc()` function +- [x] Remove `redactEnvValue()` helper +- [x] Add `buildMcpConfig()` that returns `WindsurfMcpConfig | null` +- [x] Include **all** env vars (including secrets) — no redaction +- [x] Use shared `hasPotentialSecrets()` from `src/utils/secrets.ts` +- [x] Include **both** stdio and HTTP/SSE servers (Windsurf supports all transport types) + +```typescript +function buildMcpConfig( + servers?: Record, +): WindsurfMcpConfig | null { + if (!servers || Object.keys(servers).length === 0) return null + + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + if (server.command) { + // stdio transport + const entry: WindsurfMcpServerEntry = { command: server.command } + if (server.args?.length) entry.args = server.args + if (server.env && Object.keys(server.env).length > 0) entry.env = server.env + result[name] = entry + } else if (server.url) { + // HTTP/SSE transport + const entry: WindsurfMcpServerEntry = { serverUrl: server.url } + if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers + if (server.env && Object.keys(server.env).length > 0) entry.env = server.env + result[name] = entry + } else { + console.warn(`Warning: MCP server "${name}" has no command or URL. Skipping.`) + continue + } + } + + if (Object.keys(result).length === 0) return null + + // Warn about secrets (don't redact — they're needed for the config to work) + if (hasPotentialSecrets(result)) { + console.warn( + "Warning: MCP servers contain env vars that may include secrets (API keys, tokens).\n" + + " These will be written to mcp_config.json. Review before sharing the config file.", + ) + } + + return { mcpServers: result } +} +``` + +### Research Insights (Phase 2) + +**Windsurf docs (critical correction):** Windsurf supports **stdio, Streamable HTTP, and SSE** transports in `mcp_config.json`. HTTP/SSE servers use `serverUrl` (not `url`). The original plan incorrectly planned to skip HTTP/SSE servers. This is now corrected — all transport types are included. + +**All 5 review agents flagged:** The original code sample was missing `result[name] = entry` — the entry was built but never stored. Fixed above. + +**Security review:** The warning message should enumerate which specific env var names triggered detection. Enhanced version: + +```typescript +if (hasPotentialSecrets(result)) { + const flagged = Object.entries(result) + .filter(([, s]) => s.env && Object.keys(s.env).some(k => SENSITIVE_PATTERN.test(k))) + .map(([name]) => name) + console.warn( + `Warning: MCP servers contain env vars that may include secrets: ${flagged.join(", ")}.\n` + + " These will be written to mcp_config.json. Review before sharing the config file.", + ) +} +``` + +**Windsurf env var interpolation:** Windsurf supports `${env:VARIABLE_NAME}` syntax in `mcp_config.json`. Future enhancement: write env var references instead of literal values for secrets. Out of scope for v0.11.0 (requires more research on which fields support interpolation). + +### Phase 3: Writer Changes + +**Files:** `src/targets/windsurf.ts`, `src/utils/files.ts` + +#### 3a. Simplify writer — remove AGENTS.md and double-nesting guard + +The writer always writes directly into `outputRoot`. The CLI resolves the correct output root based on scope. + +- [x] Remove AGENTS.md writing block (lines 10-17) +- [x] Remove `resolveWindsurfPaths()` — no longer needed +- [x] Write workflows, skills, and MCP config directly into `outputRoot` + +### Research Insights (Phase 3a) + +**Pattern review (dissent):** Every other writer (kiro, copilot, gemini, droid) has a `resolve*Paths()` function with a double-nesting guard. Removing it makes Windsurf the only target where the CLI fully owns nesting. This creates an inconsistency in the `write()` contract. + +**Resolution:** Accept the divergence — Windsurf has genuinely different semantics (global vs workspace). Add a JSDoc comment on `TargetHandler.write()` documenting that some writers may apply additional nesting while the Windsurf writer expects the final resolved path. Long-term, other targets could migrate to this pattern in a separate refactor. + +#### 3b. Replace MCP setup doc with JSON config merge + +Follow Kiro pattern (`src/targets/kiro.ts:68-92`) with security hardening: + +- [x] Read existing `mcp_config.json` if present +- [x] Backup before overwrite (`backupFile()`) +- [x] Parse existing JSON (warn and replace if corrupted; add `!Array.isArray()` guard) +- [x] Merge at `mcpServers` key: plugin entries overwrite same-name entries, user entries preserved +- [x] Preserve all other top-level keys in existing file +- [x] Write merged result with **restrictive permissions** (`0o600`) +- [x] Emit warning when writing to workspace scope (Windsurf `mcp_config.json` is global-only per docs) + +```typescript +// MCP config merge with security hardening +if (bundle.mcpConfig) { + const mcpPath = path.join(outputRoot, "mcp_config.json") + const backupPath = await backupFile(mcpPath) + if (backupPath) { + console.log(`Backed up existing mcp_config.json to ${backupPath}`) + } + + let existingConfig: Record = {} + if (await pathExists(mcpPath)) { + try { + const parsed = await readJson(mcpPath) + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + existingConfig = parsed as Record + } + } catch { + console.warn("Warning: existing mcp_config.json could not be parsed and will be replaced.") + } + } + + const existingServers = + existingConfig.mcpServers && + typeof existingConfig.mcpServers === "object" && + !Array.isArray(existingConfig.mcpServers) + ? (existingConfig.mcpServers as Record) + : {} + const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpConfig.mcpServers } } + await writeJsonSecure(mcpPath, merged) // 0o600 permissions +} +``` + +### Research Insights (Phase 3b) + +**Security review (HIGH):** The current `writeJson()` in `src/utils/files.ts` uses default umask (`0o644`) — world-readable. The sync targets all use `{ mode: 0o600 }` for secret-containing files. The Windsurf writer (and Kiro writer) must do the same. + +**Implementation:** Add a `writeJsonSecure()` helper or add a `mode` parameter to `writeJson()`: + +```typescript +// src/utils/files.ts +export async function writeJsonSecure(filePath: string, data: unknown): Promise { + const content = JSON.stringify(data, null, 2) + await ensureDir(path.dirname(filePath)) + await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 }) +} +``` + +**Security review (MEDIUM):** Backup files inherit default permissions. Ensure `backupFile()` also sets `0o600` on the backup copy when the source may contain secrets. + +**Security review (MEDIUM):** Workspace `mcp_config.json` could be committed to git. After writing to workspace scope, emit a warning: + +``` +Warning: .windsurf/mcp_config.json may contain secrets. Ensure it is in .gitignore. +``` + +**TypeScript review:** The `readJson>` assertion is unsafe — a valid JSON array or string passes parsing but fails the type. Added `!Array.isArray()` guard. + +**TypeScript review:** The `bundle.mcpConfig` null check is sufficient — when non-null, `mcpServers` is guaranteed to have entries (the converter returns null for empty servers). Simplified from `bundle.mcpConfig && Object.keys(...)`. + +**Windsurf docs (important):** `mcp_config.json` is a **global configuration only** — Windsurf has no per-project MCP config support. Writing it to `.windsurf/` in workspace scope may not be discovered by Windsurf. Emit a warning for workspace scope but still write the file for forward-compatibility. + +#### 3c. Updated writer structure + +```typescript +export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBundle): Promise { + await ensureDir(outputRoot) + + // Write agent workflows + if (bundle.agentWorkflows.length > 0) { + const agentDir = path.join(outputRoot, "workflows", "agents") + await ensureDir(agentDir) + for (const workflow of bundle.agentWorkflows) { + validatePathSafe(workflow.name, "agent workflow") + const content = formatFrontmatter({ description: workflow.description }, `# ${workflow.name}\n\n${workflow.body}`) + await writeText(path.join(agentDir, `${workflow.name}.md`), content + "\n") + } + } + + // Write command workflows + if (bundle.commandWorkflows.length > 0) { + const cmdDir = path.join(outputRoot, "workflows", "commands") + await ensureDir(cmdDir) + for (const workflow of bundle.commandWorkflows) { + validatePathSafe(workflow.name, "command workflow") + const content = formatFrontmatter({ description: workflow.description }, `# ${workflow.name}\n\n${workflow.body}`) + await writeText(path.join(cmdDir, `${workflow.name}.md`), content + "\n") + } + } + + // Copy skill directories + if (bundle.skillDirs.length > 0) { + const skillsDir = path.join(outputRoot, "skills") + await ensureDir(skillsDir) + for (const skill of bundle.skillDirs) { + validatePathSafe(skill.name, "skill directory") + const destDir = path.join(skillsDir, skill.name) + const resolvedDest = path.resolve(destDir) + if (!resolvedDest.startsWith(path.resolve(skillsDir))) { + console.warn(`Warning: Skill name "${skill.name}" escapes skills/. Skipping.`) + continue + } + await copyDir(skill.sourceDir, destDir) + } + } + + // Merge MCP config (see 3b above) + if (bundle.mcpConfig) { + // ... merge logic from 3b + } +} +``` + +### Phase 4: CLI Wiring + +**Files:** `src/commands/install.ts`, `src/commands/convert.ts` + +#### 4a. Add `--scope` flag to both commands + +```typescript +scope: { + type: "string", + description: "Scope level: global | workspace (default varies by target)", +}, +``` + +- [x] Add `scope` arg to `install.ts` +- [x] Add `scope` arg to `convert.ts` + +#### 4b. Validate scope with type guard + +Use a proper type guard instead of unsafe `as TargetScope` cast: + +```typescript +function isTargetScope(value: string): value is TargetScope { + return value === "global" || value === "workspace" +} + +const scopeValue = args.scope ? String(args.scope) : undefined +if (scopeValue !== undefined) { + if (!target.supportedScopes) { + throw new Error(`Target "${targetName}" does not support the --scope flag.`) + } + if (!isTargetScope(scopeValue) || !target.supportedScopes.includes(scopeValue)) { + throw new Error(`Target "${targetName}" does not support --scope ${scopeValue}. Supported: ${target.supportedScopes.join(", ")}`) + } +} +const resolvedScope = scopeValue ?? target.defaultScope ?? "workspace" +``` + +- [x] Add `isTargetScope` type guard +- [x] Add scope validation in both commands (single block, not two separate checks) + +### Research Insights (Phase 4b) + +**TypeScript review:** The original plan cast `scopeValue as TargetScope` before validation — a type lie. Use a proper type guard function to keep the type system honest. + +**Simplicity review:** The two-step validation (check supported, then check exists) can be a single block with the type guard approach above. + +#### 4c. Update output root resolution + +Both commands now use the shared `resolveTargetOutputRoot` from Phase 0a. + +- [x] Call shared function with `scope: resolvedScope` for primary target +- [x] Default scope: `target.defaultScope ?? "workspace"` (only used when target supports scopes) + +#### 4d. Handle `--also` targets + +`--scope` applies only to the primary `--to` target. Extra `--also` targets use their own `defaultScope`. + +- [x] Pass `handler.defaultScope` for `--also` targets (each uses its own default) +- [x] Update the `--also` loop in both commands to use target-specific scope resolution + +### Research Insights (Phase 4d) + +**Architecture review:** There is no way for users to specify scope for an `--also` target (e.g., `--also windsurf:workspace`). Accept as a known v0.11.0 limitation. If users need workspace scope for windsurf, they can run two separate commands. Add a code comment indicating where per-target scope overrides would be added in the future. + +### Phase 5: Tests + +**Files:** `tests/windsurf-converter.test.ts`, `tests/windsurf-writer.test.ts` + +#### 5a. Update converter tests + +- [x] Remove all AGENTS.md tests (lines 275-303: empty plugin, CLAUDE.md missing) +- [x] Remove all `mcpSetupDoc` tests (lines 305-366: stdio, HTTP/SSE, redaction, null) +- [x] Update `fixturePlugin` default — remove `agentsMd` and `mcpSetupDoc` references +- [x] Add `mcpConfig` tests: + - stdio server produces correct JSON structure with `command`, `args`, `env` + - HTTP/SSE server produces correct JSON structure with `serverUrl`, `headers` + - mixed servers (stdio + HTTP) both included + - env vars included (not redacted) — verify actual values present + - `hasPotentialSecrets()` emits console.warn for sensitive keys + - `hasPotentialSecrets()` does NOT warn when no sensitive keys + - no servers produces null mcpConfig + - empty bundle has null mcpConfig + - server with no command and no URL is skipped with warning + +#### 5b. Update writer tests + +- [x] Remove AGENTS.md tests (backup test, creation test, double-nesting AGENTS.md parent test) +- [x] Remove double-nesting guard test (guard removed) +- [x] Remove `mcp-setup.md` write test +- [x] Update `emptyBundle` fixture — remove `agentsMd`, `mcpSetupDoc`, add `mcpConfig: null` +- [x] Add `mcp_config.json` tests: + - writes mcp_config.json to outputRoot + - merges with existing mcp_config.json (preserves user servers) + - backs up existing mcp_config.json before overwrite + - handles corrupted existing mcp_config.json (warn and replace) + - handles existing mcp_config.json with array (not object) at root + - handles existing mcp_config.json with `mcpServers: null` + - preserves non-mcpServers keys in existing file + - server name collision: plugin entry wins + - file permissions are 0o600 (not world-readable) +- [x] Update full bundle test — writer writes directly into outputRoot (no `.windsurf/` nesting) + +#### 5c. Add scope resolution tests + +Test the shared `resolveTargetOutputRoot` function: + +- [x] Default scope for windsurf is "global" → resolves to `~/.codeium/windsurf/` +- [x] Explicit `--scope workspace` → resolves to `cwd/.windsurf/` +- [x] `--output` overrides scope resolution (both global and workspace) +- [x] Invalid scope value for windsurf → error +- [x] `--scope` on non-scope target (e.g., opencode) → error +- [x] `--also windsurf` uses windsurf's default scope ("global") +- [x] `isTargetScope` type guard correctly identifies valid/invalid values + +### Phase 6: Documentation + +**Files:** `README.md`, `CHANGELOG.md` + +- [x] Update README.md Windsurf section to mention `--scope` flag and global default +- [x] Add CHANGELOG entry for v0.11.0 with breaking changes documented +- [x] Document migration path: `--scope workspace` for old behavior +- [x] Note that Windsurf `mcp_config.json` is global-only (workspace MCP config may not be discovered) + +## Acceptance Criteria + +- [x] `install compound-engineering --to windsurf` writes to `~/.codeium/windsurf/` by default +- [x] `install compound-engineering --to windsurf --scope workspace` writes to `cwd/.windsurf/` +- [x] `--output /custom/path` overrides scope for both commands +- [x] `--scope` on non-supporting target produces clear error +- [x] `mcp_config.json` merges with existing file (backup created, user entries preserved) +- [x] `mcp_config.json` written with `0o600` permissions (not world-readable) +- [x] No AGENTS.md generated for either scope +- [x] Env var secrets included in `mcp_config.json` with `console.warn` listing affected servers +- [x] Both stdio and HTTP/SSE MCP servers included in `mcp_config.json` +- [x] All existing tests updated, all new tests pass +- [x] No regressions in other targets +- [x] `resolveTargetOutputRoot` extracted to shared utility (no duplication) + +## Dependencies & Risks + +**Risk: Global workflow path is undocumented.** Windsurf may not discover workflows from `~/.codeium/windsurf/workflows/`. Mitigation: documented as a known assumption in the brainstorm. Users can `--scope workspace` if global workflows aren't discovered. + +**Risk: Breaking changes for existing v0.10.0 users.** Mitigation: document migration path clearly. `--scope workspace` restores previous behavior. Target is experimental with a small user base. + +**Risk: Workspace `mcp_config.json` not read by Windsurf.** Per Windsurf docs, `mcp_config.json` is global-only configuration. Workspace scope writes the file for forward-compatibility but emits a warning. The primary use case is global scope anyway. + +**Risk: Secrets in `mcp_config.json` committed to git.** Mitigation: `0o600` file permissions, console.warn about sensitive env vars, warning about `.gitignore` for workspace scope. + +## References & Research + +- Spec: `docs/specs/windsurf.md` (authoritative reference for component mapping) +- Kiro MCP merge pattern: [src/targets/kiro.ts:68-92](../../src/targets/kiro.ts) +- Sync secrets warning: [src/commands/sync.ts:20-28](../../src/commands/sync.ts) +- Windsurf MCP docs: https://docs.windsurf.com/windsurf/cascade/mcp +- Windsurf Skills global path: https://docs.windsurf.com/windsurf/cascade/skills +- Windsurf MCP tutorial: https://windsurf.com/university/tutorials/configuring-first-mcp-server +- Adding converter targets (learning): [docs/solutions/adding-converter-target-providers.md](../solutions/adding-converter-target-providers.md) +- Plugin versioning (learning): [docs/solutions/plugin-versioning-requirements.md](../solutions/plugin-versioning-requirements.md) diff --git a/docs/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/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/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/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/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 | diff --git a/docs/specs/windsurf.md b/docs/specs/windsurf.md new file mode 100644 index 0000000..a895b52 --- /dev/null +++ b/docs/specs/windsurf.md @@ -0,0 +1,477 @@ +# Windsurf Editor Global Configuration Guide + +> **Purpose**: Technical reference for programmatically creating and managing Windsurf's global Skills, Workflows, and Rules. +> +> **Source**: Official Windsurf documentation at [docs.windsurf.com](https://docs.windsurf.com) + local file analysis. +> +> **Last Updated**: February 2026 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Base Directory Structure](#base-directory-structure) +3. [Skills](#skills) +4. [Workflows](#workflows) +5. [Rules](#rules) +6. [Memories](#memories) +7. [System-Level Configuration (Enterprise)](#system-level-configuration-enterprise) +8. [Programmatic Creation Reference](#programmatic-creation-reference) +9. [Best Practices](#best-practices) + +--- + +## Overview + +Windsurf provides three main customization mechanisms: + +| Feature | Purpose | Invocation | +|---------|---------|------------| +| **Skills** | Complex multi-step tasks with supporting resources | Automatic (progressive disclosure) or `@skill-name` | +| **Workflows** | Reusable step-by-step procedures | Slash command `/workflow-name` | +| **Rules** | Behavioral guidelines and preferences | Trigger-based (always-on, glob, manual, or model decision) | + +All three support both **workspace-level** (project-specific) and **global** (user-wide) scopes. + +--- + +## Base Directory Structure + +### Global Configuration Root + +| OS | Path | +|----|------| +| **Windows** | `C:\Users\{USERNAME}\.codeium\windsurf\` | +| **macOS** | `~/.codeium/windsurf/` | +| **Linux** | `~/.codeium/windsurf/` | + +### Directory Layout + +``` +~/.codeium/windsurf/ +├── skills/ # Global skills (directories) +│ └── {skill-name}/ +│ └── SKILL.md +├── global_workflows/ # Global workflows (flat .md files) +│ └── {workflow-name}.md +├── rules/ # Global rules (flat .md files) +│ └── {rule-name}.md +├── memories/ +│ ├── global_rules.md # Always-on global rules (plain text) +│ └── *.pb # Auto-generated memories (protobuf) +├── mcp_config.json # MCP server configuration +└── user_settings.pb # User settings (protobuf) +``` + +--- + +## Skills + +Skills bundle instructions with supporting resources for complex, multi-step tasks. Cascade uses **progressive disclosure** to automatically invoke skills when relevant. + +### Storage Locations + +| Scope | Location | +|-------|----------| +| **Global** | `~/.codeium/windsurf/skills/{skill-name}/SKILL.md` | +| **Workspace** | `.windsurf/skills/{skill-name}/SKILL.md` | + +### Directory Structure + +Each skill is a **directory** (not a single file) containing: + +``` +{skill-name}/ +├── SKILL.md # Required: Main skill definition +├── references/ # Optional: Reference documentation +├── assets/ # Optional: Images, diagrams, etc. +├── scripts/ # Optional: Helper scripts +└── {any-other-files} # Optional: Templates, configs, etc. +``` + +### SKILL.md Format + +```markdown +--- +name: skill-name +description: Brief description shown to model to help it decide when to invoke the skill +--- + +# Skill Title + +Instructions for the skill go here in markdown format. + +## Section 1 +Step-by-step guidance... + +## Section 2 +Reference supporting files using relative paths: +- See [deployment-checklist.md](./deployment-checklist.md) +- Run script: [deploy.sh](./scripts/deploy.sh) +``` + +### Required YAML Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | **Yes** | Unique identifier (lowercase letters, numbers, hyphens only). Must match directory name. | +| `description` | **Yes** | Explains what the skill does and when to use it. Critical for automatic invocation. | + +### Naming Convention + +- Use **lowercase-kebab-case**: `deploy-to-staging`, `code-review`, `setup-dev-environment` +- Name must match the directory name exactly + +### Invocation Methods + +1. **Automatic**: Cascade automatically invokes when request matches skill description +2. **Manual**: Type `@skill-name` in Cascade input + +### Example: Complete Skill + +``` +~/.codeium/windsurf/skills/deploy-to-production/ +├── SKILL.md +├── deployment-checklist.md +├── rollback-procedure.md +└── config-template.yaml +``` + +**SKILL.md:** +```markdown +--- +name: deploy-to-production +description: Guides the deployment process to production with safety checks. Use when deploying to prod, releasing, or pushing to production environment. +--- + +## Pre-deployment Checklist +1. Run all tests +2. Check for uncommitted changes +3. Verify environment variables + +## Deployment Steps +Follow these steps to deploy safely... + +See [deployment-checklist.md](./deployment-checklist.md) for full checklist. +See [rollback-procedure.md](./rollback-procedure.md) if issues occur. +``` + +--- + +## Workflows + +Workflows define step-by-step procedures invoked via slash commands. They guide Cascade through repetitive tasks. + +### Storage Locations + +| Scope | Location | +|-------|----------| +| **Global** | `~/.codeium/windsurf/global_workflows/{workflow-name}.md` | +| **Workspace** | `.windsurf/workflows/{workflow-name}.md` | + +### File Format + +Workflows are **single markdown files** (not directories): + +```markdown +--- +description: Short description of what the workflow does +--- + +# Workflow Title + +> Arguments: [optional arguments description] + +Step-by-step instructions in markdown. + +1. First step +2. Second step +3. Third step +``` + +### Required YAML Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `description` | **Yes** | Short title/description shown in UI | + +### Invocation + +- Slash command: `/workflow-name` +- Filename becomes the command (e.g., `deploy.md` → `/deploy`) + +### Constraints + +- **Character limit**: 12,000 characters per workflow file +- Workflows can call other workflows: Include instructions like "Call `/other-workflow`" + +### Example: Complete Workflow + +**File**: `~/.codeium/windsurf/global_workflows/address-pr-comments.md` + +```markdown +--- +description: Address all PR review comments systematically +--- + +# Address PR Comments + +> Arguments: [PR number] + +1. Check out the PR branch: `gh pr checkout [id]` + +2. Get comments on PR: + ```bash + gh api --paginate repos/[owner]/[repo]/pulls/[id]/comments | jq '.[] | {user: .user.login, body, path, line}' + ``` + +3. For EACH comment: + a. Print: "(index). From [user] on [file]:[lines] — [body]" + b. Analyze the file and line range + c. If unclear, ask for clarification + d. Make the change before moving to next comment + +4. Summarize what was done and which comments need attention +``` + +--- + +## Rules + +Rules provide persistent behavioral guidelines that influence how Cascade responds. + +### Storage Locations + +| Scope | Location | +|-------|----------| +| **Global** | `~/.codeium/windsurf/rules/{rule-name}.md` | +| **Workspace** | `.windsurf/rules/{rule-name}.md` | + +### File Format + +Rules are **single markdown files**: + +```markdown +--- +description: When to use this rule +trigger: activation_mode +globs: ["*.py", "src/**/*.ts"] +--- + +Rule instructions in markdown format. + +- Guideline 1 +- Guideline 2 +- Guideline 3 +``` + +### YAML Frontmatter Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `description` | **Yes** | Describes when to use the rule | +| `trigger` | Optional | Activation mode (see below) | +| `globs` | Optional | File patterns for glob trigger | + +### Activation Modes (trigger field) + +| Mode | Value | Description | +|------|-------|-------------| +| **Manual** | `manual` | Activated via `@mention` in Cascade input | +| **Always On** | `always` | Always applied to every conversation | +| **Model Decision** | `model_decision` | Model decides based on description | +| **Glob** | `glob` | Applied when working with files matching pattern | + +### Constraints + +- **Character limit**: 12,000 characters per rule file + +### Example: Complete Rule + +**File**: `~/.codeium/windsurf/rules/python-style.md` + +```markdown +--- +description: Python coding standards and style guidelines. Use when writing or reviewing Python code. +trigger: glob +globs: ["*.py", "**/*.py"] +--- + +# Python Coding Guidelines + +- Use type hints for all function parameters and return values +- Follow PEP 8 style guide +- Use early returns when possible +- Always add docstrings to public functions and classes +- Prefer f-strings over .format() or % formatting +- Use pathlib instead of os.path for file operations +``` + +--- + +## Memories + +### Global Rules (Always-On) + +**Location**: `~/.codeium/windsurf/memories/global_rules.md` + +This is a special file for rules that **always apply** to all conversations. Unlike rules in the `rules/` directory, this file: + +- Does **not** require YAML frontmatter +- Is plain text/markdown +- Is always active (no trigger configuration) + +**Format:** +```markdown +Plain text rules that always apply to all conversations. + +- Rule 1 +- Rule 2 +- Rule 3 +``` + +### Auto-Generated Memories + +Cascade automatically creates memories during conversations, stored as `.pb` (protobuf) files in `~/.codeium/windsurf/memories/`. These are managed by Windsurf and should not be manually edited. + +--- + +## System-Level Configuration (Enterprise) + +Enterprise organizations can deploy system-level configurations that apply globally and cannot be modified by end users. + +### System-Level Paths + +| Type | Windows | macOS | Linux/WSL | +|------|---------|-------|-----------| +| **Rules** | `C:\ProgramData\Windsurf\rules\*.md` | `/Library/Application Support/Windsurf/rules/*.md` | `/etc/windsurf/rules/*.md` | +| **Workflows** | `C:\ProgramData\Windsurf\workflows\*.md` | `/Library/Application Support/Windsurf/workflows/*.md` | `/etc/windsurf/workflows/*.md` | + +### Precedence Order + +When items with the same name exist at multiple levels: + +1. **System** (highest priority) - Organization-wide, deployed by IT +2. **Workspace** - Project-specific in `.windsurf/` +3. **Global** - User-defined in `~/.codeium/windsurf/` +4. **Built-in** - Default items provided by Windsurf + +--- + +## Programmatic Creation Reference + +### Quick Reference Table + +| Type | Path Pattern | Format | Key Fields | +|------|--------------|--------|------------| +| **Skill** | `skills/{name}/SKILL.md` | YAML frontmatter + markdown | `name`, `description` | +| **Workflow** | `global_workflows/{name}.md` (global) or `workflows/{name}.md` (workspace) | YAML frontmatter + markdown | `description` | +| **Rule** | `rules/{name}.md` | YAML frontmatter + markdown | `description`, `trigger`, `globs` | +| **Global Rules** | `memories/global_rules.md` | Plain text/markdown | None | + +### Minimal Templates + +#### Skill (SKILL.md) +```markdown +--- +name: my-skill +description: What this skill does and when to use it +--- + +Instructions here. +``` + +#### Workflow +```markdown +--- +description: What this workflow does +--- + +1. Step one +2. Step two +``` + +#### Rule +```markdown +--- +description: When this rule applies +trigger: model_decision +--- + +- Guideline one +- Guideline two +``` + +### Validation Checklist + +When programmatically creating items: + +- [ ] **Skills**: Directory exists with `SKILL.md` inside +- [ ] **Skills**: `name` field matches directory name exactly +- [ ] **Skills**: Name uses only lowercase letters, numbers, hyphens +- [ ] **Workflows/Rules**: File is `.md` extension +- [ ] **All**: YAML frontmatter uses `---` delimiters +- [ ] **All**: `description` field is present and meaningful +- [ ] **All**: File size under 12,000 characters (workflows/rules) + +--- + +## Best Practices + +### Writing Effective Descriptions + +The `description` field is critical for automatic invocation. Be specific: + +**Good:** +```yaml +description: Guides deployment to staging environment with pre-flight checks. Use when deploying to staging, testing releases, or preparing for production. +``` + +**Bad:** +```yaml +description: Deployment stuff +``` + +### Formatting Guidelines + +- Use bullet points and numbered lists (easier for Cascade to follow) +- Use markdown headers to organize sections +- Keep rules concise and specific +- Avoid generic rules like "write good code" (already built-in) + +### XML Tags for Grouping + +XML tags can effectively group related rules: + +```markdown + +- Use early returns when possible +- Always add documentation for new functions +- Prefer composition over inheritance + + + +- Write unit tests for all public methods +- Maintain 80% code coverage + +``` + +### Skills vs Rules vs Workflows + +| Use Case | Recommended | +|----------|-------------| +| Multi-step procedure with supporting files | **Skill** | +| Repeatable CLI/automation sequence | **Workflow** | +| Coding style preferences | **Rule** | +| Project conventions | **Rule** | +| Deployment procedure | **Skill** or **Workflow** | +| Code review checklist | **Skill** | + +--- + +## Additional Resources + +- **Official Documentation**: [docs.windsurf.com](https://docs.windsurf.com) +- **Skills Specification**: [agentskills.io](https://agentskills.io/home) +- **Rule Templates**: [windsurf.com/editor/directory](https://windsurf.com/editor/directory) diff --git a/package.json b/package.json index 1115dc0..5b378d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@every-env/compound-plugin", - "version": "0.8.0", + "version": "0.12.0", "type": "module", "private": false, "bin": { 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/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/.claude-plugin/plugin.json b/plugins/compound-engineering/.claude-plugin/plugin.json index 9b35c5a..2634cca 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.34.0", - "description": "AI-powered development tools. 29 agents, 22 commands, 19 skills, 1 MCP server for code review, research, design, and workflow automation.", + "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", "email": "kieran@every.to", 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/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index 6819c48..ec452bb 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -5,6 +5,67 @@ 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)! +- **`.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)! + +--- + +## [2.37.0] - 2026-03-01 + +### 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. +- **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. + +--- + +## [2.36.0] - 2026-03-01 + +### Added + +- **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)! + +### 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. ([#202](https://github.com/EveryInc/compound-engineering-plugin/pull/202)) — thanks [@rburnham52](https://github.com/rburnham52)! + +--- + +## [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 + +- **`/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 + +- **`/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 @@ -73,7 +134,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 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/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/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/ ``` 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:** 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 --- 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/brainstorm.md b/plugins/compound-engineering/commands/workflows/brainstorm.md index b4f3a0f..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. diff --git a/plugins/compound-engineering/commands/workflows/plan.md b/plugins/compound-engineering/commands/workflows/plan.md index 631bccc..fd18ff5 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. @@ -150,7 +158,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:** @@ -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] @@ -255,6 +266,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 @@ -269,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] @@ -298,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] @@ -344,6 +365,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 @@ -386,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 @@ -465,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 @@ -475,6 +532,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. @@ -503,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 diff --git a/plugins/compound-engineering/commands/workflows/review.md b/plugins/compound-engineering/commands/workflows/review.md index d0ba78f..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 @@ -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 @@ -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,10 +435,9 @@ After creating all todo files, present comprehensive summary: - Code cleanup - 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. -``` 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) 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 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 7ac3d88..a616c5f 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" import { detectInstalledTools } from "../utils/detect-tools" const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"] @@ -24,7 +25,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | gemini | all)", + description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen | all)", }, output: { type: "string", @@ -41,6 +42,20 @@ 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)", + }, + qwenHome: { + type: "string", + alias: "qwen-home", + description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions)", + }, + scope: { + type: "string", + description: "Scope level: global | workspace (default varies by target)", + }, also: { type: "string", description: "Comma-separated extra targets to generate (ex: codex)", @@ -71,8 +86,11 @@ export default defineCommand({ 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")) + const openclawHome = resolveTargetHome(args.openclawHome, path.join(os.homedir(), ".openclaw", "extensions")) + const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions")) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -105,7 +123,16 @@ export default defineCommand({ console.warn(`Skipping ${tool.name}: no output returned.`) continue } - const root = resolveTargetOutputRoot(tool.name, outputRoot, codexHome, piHome) + const root = resolveTargetOutputRoot({ + targetName: tool.name, + outputRoot, + codexHome, + piHome, + openclawHome, + qwenHome, + pluginName: plugin.manifest.name, + hasExplicitOutput, + }) await handler.write(root, bundle) console.log(`Converted ${plugin.manifest.name} to ${tool.name} at ${root}`) } @@ -125,13 +152,25 @@ export default defineCommand({ throw new Error(`Target ${targetName} is registered but not implemented yet.`) } - const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome) + const resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined) + + const primaryOutputRoot = resolveTargetOutputRoot({ + targetName, + outputRoot, + codexHome, + piHome, + openclawHome, + qwenHome, + pluginName: plugin.manifest.name, + hasExplicitOutput, + scope: resolvedScope, + }) const bundle = target.convert(plugin, options) if (!bundle) { throw new Error(`Target ${targetName} did not return a bundle.`) } - await target.write(primaryOutputRoot, bundle) + await target.write(primaryOutputRoot, bundle, resolvedScope) console.log(`Converted ${plugin.manifest.name} to ${targetName} at ${primaryOutputRoot}`) const extraTargets = parseExtraTargets(args.also) @@ -151,8 +190,18 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome) - await handler.write(extraRoot, extraBundle) + const extraRoot = resolveTargetOutputRoot({ + targetName: extra, + outputRoot: path.join(outputRoot, extra), + codexHome, + piHome, + openclawHome, + qwenHome, + pluginName: plugin.manifest.name, + hasExplicitOutput, + scope: handler.defaultScope, + }) + await handler.write(extraRoot, extraBundle, handler.defaultScope) console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`) } @@ -177,12 +226,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") - return outputRoot -} diff --git a/src/commands/install.ts b/src/commands/install.ts index fb91e4a..a1f2f1c 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" import { detectInstalledTools } from "../utils/detect-tools" const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"] @@ -26,7 +27,7 @@ export default defineCommand({ to: { type: "string", default: "opencode", - description: "Target format (opencode | codex | droid | cursor | pi | gemini | all)", + description: "Target format (opencode | codex | droid | cursor | pi | copilot | gemini | kiro | windsurf | openclaw | qwen | all)", }, output: { type: "string", @@ -43,14 +44,28 @@ 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)", + }, + qwenHome: { + type: "string", + alias: "qwen-home", + description: "Write Qwen output to this Qwen extensions root (ex: ~/.qwen/extensions)", + }, + scope: { + type: "string", + description: "Scope level: global | workspace (default varies by target)", + }, also: { type: "string", description: "Comma-separated extra targets to generate (ex: codex)", }, 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", @@ -79,6 +94,8 @@ export default defineCommand({ const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")) const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) + const openclawHome = resolveTargetHome(args.openclawHome, path.join(os.homedir(), ".openclaw", "extensions")) + const qwenHome = resolveTargetHome(args.qwenHome, path.join(os.homedir(), ".qwen", "extensions")) const options = { agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", @@ -111,7 +128,16 @@ export default defineCommand({ console.warn(`Skipping ${tool.name}: no output returned.`) continue } - const root = resolveTargetOutputRoot(tool.name, outputRoot, codexHome, piHome, hasExplicitOutput) + const root = resolveTargetOutputRoot({ + targetName: tool.name, + outputRoot, + codexHome, + piHome, + openclawHome, + qwenHome, + pluginName: plugin.manifest.name, + hasExplicitOutput, + }) await handler.write(root, bundle) console.log(`Installed ${plugin.manifest.name} to ${tool.name} at ${root}`) } @@ -130,12 +156,24 @@ export default defineCommand({ throw new Error(`Target ${targetName} is registered but not implemented yet.`) } + const resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined) + const bundle = target.convert(plugin, options) if (!bundle) { throw new Error(`Target ${targetName} did not return a bundle.`) } - const primaryOutputRoot = resolveTargetOutputRoot(targetName, outputRoot, codexHome, piHome, hasExplicitOutput) - await target.write(primaryOutputRoot, bundle) + const primaryOutputRoot = resolveTargetOutputRoot({ + targetName, + outputRoot, + codexHome, + piHome, + openclawHome, + qwenHome, + pluginName: plugin.manifest.name, + hasExplicitOutput, + scope: resolvedScope, + }) + await target.write(primaryOutputRoot, bundle, resolvedScope) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) const extraTargets = parseExtraTargets(args.also) @@ -155,8 +193,18 @@ export default defineCommand({ console.warn(`Skipping ${extra}: no output returned.`) continue } - const extraRoot = resolveTargetOutputRoot(extra, path.join(outputRoot, extra), codexHome, piHome, hasExplicitOutput) - await handler.write(extraRoot, extraBundle) + const extraRoot = resolveTargetOutputRoot({ + targetName: extra, + outputRoot: path.join(outputRoot, extra), + codexHome, + piHome, + openclawHome, + qwenHome, + pluginName: plugin.manifest.name, + hasExplicitOutput, + scope: handler.defaultScope, + }) + await handler.write(extraRoot, extraBundle, handler.defaultScope) console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`) } @@ -207,27 +255,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") - } - 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 c860ca3..5ded215 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -6,32 +6,19 @@ 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 { syncToGemini } from "../sync/gemini" import { expandHome } from "../utils/resolve-home" +import { hasPotentialSecrets } from "../utils/secrets" import { detectInstalledTools } from "../utils/detect-tools" -const validTargets = ["opencode", "codex", "pi", "droid", "cursor", "gemini", "all"] as const +const validTargets = ["opencode", "codex", "pi", "droid", "copilot", "gemini", "all"] as const type SyncTarget = (typeof validTargets)[number] 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: string): string { switch (target) { case "opencode": @@ -42,8 +29,8 @@ function resolveOutputRoot(target: string): 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") case "gemini": return path.join(process.cwd(), ".gemini") default: @@ -65,8 +52,8 @@ async function syncTarget(target: string, config: Awaited() - const usedCommandNames = new Set() + _options: ClaudeToCopilotOptions, +): CopilotBundle { + const usedAgentNames = new Set() + const usedSkillNames = 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 agents = plugin.agents.map((agent) => convertAgent(agent, usedAgentNames)) - const mcpServers = convertMcpServers(plugin.mcpServers) + // 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: Cursor does not support hooks. Hooks were skipped during conversion.") + console.warn("Warning: Copilot does not support hooks. Hooks were skipped during conversion.") } - return { rules, commands, skillDirs, mcpServers } + return { agents, generatedSkills, skillDirs, mcpConfig } } -function convertAgentToRule(agent: ClaudeAgent, usedNames: Set): CursorRule { +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, - alwaysApply: false, + tools: ["*"], + infer: true, } - let body = transformContentForCursor(agent.body.trim()) + 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() @@ -46,39 +66,44 @@ function convertAgentToRule(agent: ClaudeAgent, usedNames: Set): CursorR 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 convertCommand(command: ClaudeCommand, usedNames: Set): CursorCommand { +function convertCommandToSkill( + command: ClaudeCommand, + usedNames: Set, +): CopilotGeneratedSkill { const name = uniqueName(flattenCommandName(command.name), usedNames) - const sections: string[] = [] - - if (command.description) { - sections.push(``) + 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 = transformContentForCursor(command.body.trim()) + const transformedBody = transformContentForCopilot(command.body.trim()) sections.push(transformedBody) - const content = sections.filter(Boolean).join("\n\n").trim() + const body = sections.filter(Boolean).join("\n\n").trim() + const content = formatFrontmatter(frontmatter, body) 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 { +export function transformContentForCopilot(body: string): string { let result = body // 1. Transform Task agent calls @@ -88,24 +113,25 @@ export function transformContentForCursor(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 .cursor/ + // 3. Rewrite .claude/ paths to .github/ and ~/.claude/ to ~/.copilot/ result = result - .replace(/~\/\.claude\//g, "~/.cursor/") - .replace(/\.claude\//g, ".cursor/") + .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 + 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 `the ${normalizeName(agentName)} agent` }) return result @@ -113,29 +139,47 @@ export function transformContentForCursor(body: string): string { function convertMcpServers( servers?: Record, -): Record | undefined { +): Record | undefined { if (!servers || Object.keys(servers).length === 0) return undefined - const result: Record = {} + const result: Record = {} for (const [name, server] of Object.entries(servers)) { - const entry: CursorMcpServer = {} + 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 - 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 } + + 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) + return normalizeName(name) } function normalizeName(value: string): string { 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/converters/claude-to-openclaw.ts b/src/converters/claude-to-openclaw.ts new file mode 100644 index 0000000..83a0192 --- /dev/null +++ b/src/converters/claude-to-openclaw.ts @@ -0,0 +1,240 @@ +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 type ClaudeToOpenClawOptions = ClaudeToOpenCodeOptions + +export function convertClaudeToOpenClaw( + plugin: ClaudePlugin, + _options: ClaudeToOpenClawOptions, +): OpenClawBundle { + const enabledCommands = plugin.commands.filter((cmd) => !cmd.disableModelInvocation) + + const agentSkills = plugin.agents.map(convertAgentToSkill) + const commandSkills = enabledCommands.map(convertCommandToSkill) + const commands = enabledCommands.map(convertCommand) + + const skills: OpenClawSkillFile[] = [...agentSkills, ...commandSkills] + + const skillDirCopies = plugin.skills.map((skill) => ({ + sourceDir: skill.sourceDir, + name: skill.name, + })) + + const allSkillDirs = [ + ...agentSkills.map((s) => s.dir), + ...commandSkills.map((s) => s.dir), + ...plugin.skills.map((s) => s.name), + ] + + const manifest = buildManifest(plugin, allSkillDirs) + + const packageJson = buildPackageJson(plugin) + + const openclawConfig = plugin.mcpServers + ? buildOpenClawConfig(plugin.mcpServers) + : undefined + + const entryPoint = generateEntryPoint(commands) + + return { + manifest, + packageJson, + entryPoint, + skills, + skillDirCopies, + commands, + openclawConfig, + } +} + +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}`), + } +} + +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) => { + // 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: ${safeName}, + description: ${safeDesc}, + acceptsArgs: ${cmd.acceptsArgs}, + requireAuth: false, + handler: (ctx) => ({ + text: skills[${safeName}] ?? ${safeNotFound}, + }), + });` + }) + .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: Record = {}; + +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(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.openclaw/") + .replace(/(?<=^|\s|["'`])\.claude\//gm, ".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/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index 5bff059..ff6b31f 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" @@ -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/src/converters/claude-to-qwen.ts b/src/converters/claude-to-qwen.ts new file mode 100644 index 0000000..c07b177 --- /dev/null +++ b/src/converters/claude-to-qwen.ts @@ -0,0 +1,238 @@ +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 +} + +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, + } + continue + } + + if (server.url) { + // Qwen only supports stdio (command-based) MCP servers — skip remote servers + console.warn( + `Warning: Remote MCP server '${name}' (URL: ${server.url}) is not supported in Qwen format. Qwen only supports stdio MCP servers. Skipping.`, + ) + } + } + 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.manifest.name}`) + sections.push("") + if (plugin.manifest.description) { + sections.push(plugin.manifest.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(/(?<=^|\s|["'`])~\/\.claude\//gm, "~/.qwen/") + .replace(/(?<=^|\s|["'`])\.claude\//gm, ".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 undefined +} 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/sync/cursor.ts b/src/sync/copilot.ts similarity index 62% rename from src/sync/cursor.ts rename to src/sync/copilot.ts index 32f3aa4..b4eccdc 100644 --- a/src/sync/cursor.ts +++ b/src/sync/copilot.ts @@ -4,19 +4,21 @@ import type { ClaudeHomeConfig } from "../parsers/claude-home" import type { ClaudeMcpServer } from "../types/claude" import { forceSymlink, isValidSkillName } from "../utils/symlink" -type CursorMcpServer = { +type CopilotMcpServer = { + type: string command?: string args?: string[] url?: string + tools: string[] env?: Record headers?: Record } -type CursorMcpConfig = { - mcpServers: Record +type CopilotMcpConfig = { + mcpServers: Record } -export async function syncToCursor( +export async function syncToCopilot( config: ClaudeHomeConfig, outputRoot: string, ): Promise { @@ -33,10 +35,10 @@ export async function syncToCursor( } if (Object.keys(config.mcpServers).length > 0) { - const mcpPath = path.join(outputRoot, "mcp.json") + const mcpPath = path.join(outputRoot, "copilot-mcp-config.json") const existing = await readJsonSafe(mcpPath) - const converted = convertMcpForCursor(config.mcpServers) - const merged: CursorMcpConfig = { + const converted = convertMcpForCopilot(config.mcpServers) + const merged: CopilotMcpConfig = { mcpServers: { ...(existing.mcpServers ?? {}), ...converted, @@ -46,10 +48,10 @@ export async function syncToCursor( } } -async function readJsonSafe(filePath: string): Promise> { +async function readJsonSafe(filePath: string): Promise> { try { const content = await fs.readFile(filePath, "utf-8") - return JSON.parse(content) as Partial + return JSON.parse(content) as Partial } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { return {} @@ -58,21 +60,41 @@ async function readJsonSafe(filePath: string): Promise> } } -function convertMcpForCursor( +function convertMcpForCopilot( servers: Record, -): Record { - const result: Record = {} +): Record { + const result: Record = {} for (const [name, server] of Object.entries(servers)) { - const entry: CursorMcpServer = {} + 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 - 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 } + + 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/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/targets/index.ts b/src/targets/index.ts index b76dfc1..b1214d0 100644 --- a/src/targets/index.ts +++ b/src/targets/index.ts @@ -2,27 +2,69 @@ 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 type { KiroBundle } from "../types/kiro" +import type { WindsurfBundle } from "../types/windsurf" +import type { OpenClawBundle } from "../types/openclaw" +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" -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 { convertClaudeToKiro } from "../converters/claude-to-kiro" +import { convertClaudeToWindsurf } from "../converters/claude-to-windsurf" +import { convertClaudeToOpenClaw } from "../converters/claude-to-openclaw" +import { convertClaudeToQwen } from "../converters/claude-to-qwen" 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" +import { writeKiroBundle } from "./kiro" +import { writeWindsurfBundle } from "./windsurf" +import { writeOpenClawBundle } from "./openclaw" +import { writeQwenBundle } from "./qwen" + +export type TargetScope = "global" | "workspace" + +export function isTargetScope(value: string): value is TargetScope { + return value === "global" || value === "workspace" +} + +/** + * Validate a --scope flag against a target's supported scopes. + * Returns the resolved scope (explicit or default) or throws on invalid input. + */ +export function validateScope( + targetName: string, + target: TargetHandler, + scopeArg: string | undefined, +): TargetScope | undefined { + if (scopeArg === undefined) return target.defaultScope + + if (!target.supportedScopes) { + throw new Error(`Target "${targetName}" does not support the --scope flag.`) + } + if (!isTargetScope(scopeArg) || !target.supportedScopes.includes(scopeArg)) { + throw new Error(`Target "${targetName}" does not support --scope ${scopeArg}. Supported: ${target.supportedScopes.join(", ")}`) + } + return scopeArg +} export type TargetHandler = { name: string implemented: boolean + /** Default scope when --scope is not provided. Only meaningful when supportedScopes is defined. */ + defaultScope?: TargetScope + /** Valid scope values. If absent, the --scope flag is rejected for this target. */ + supportedScopes?: TargetScope[] convert: (plugin: ClaudePlugin, options: ClaudeToOpenCodeOptions) => TBundle | null - write: (outputRoot: string, bundle: TBundle) => Promise + write: (outputRoot: string, bundle: TBundle, scope?: TargetScope) => Promise } export const targets: Record = { @@ -44,22 +86,48 @@ 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, 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"], + }, gemini: { name: "gemini", implemented: true, 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"], + }, + windsurf: { + name: "windsurf", + implemented: true, + defaultScope: "global", + supportedScopes: ["global", "workspace"], + convert: convertClaudeToWindsurf as TargetHandler["convert"], + write: writeWindsurfBundle as TargetHandler["write"], + }, + openclaw: { + name: "openclaw", + implemented: true, + convert: convertClaudeToOpenClaw as TargetHandler["convert"], + write: writeOpenClawBundle as TargetHandler["write"], + }, + qwen: { + name: "qwen", + implemented: true, + convert: convertClaudeToQwen as TargetHandler["convert"], + write: writeQwenBundle 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/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/targets/opencode.ts b/src/targets/opencode.ts index 24e8faf..e0e89ff 100644 --- a/src/targets/opencode.ts +++ b/src/targets/opencode.ts @@ -1,31 +1,93 @@ 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 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) + const merged = await mergeOpenCodeConfig(openCodePaths.configPath, bundle.config) + await writeJson(openCodePaths.configPath, merged) - 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 +105,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 +117,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/src/targets/qwen.ts b/src/targets/qwen.ts new file mode 100644 index 0000000..a822857 --- /dev/null +++ b/src/targets/qwen.ts @@ -0,0 +1,64 @@ +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) { + 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/targets/windsurf.ts b/src/targets/windsurf.ts new file mode 100644 index 0000000..ee96045 --- /dev/null +++ b/src/targets/windsurf.ts @@ -0,0 +1,104 @@ +import path from "path" +import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJsonSecure, writeText } from "../utils/files" +import { formatFrontmatter } from "../utils/frontmatter" +import type { WindsurfBundle } from "../types/windsurf" +import type { TargetScope } from "./index" + +/** + * Write a WindsurfBundle directly into outputRoot. + * + * Unlike other target writers, this writer expects outputRoot to be the final + * resolved directory — the CLI handles scope-based nesting (global vs workspace). + */ +export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBundle, scope?: TargetScope): Promise { + await ensureDir(outputRoot) + + // Write agent skills (before pass-through copies so pass-through takes precedence on collision) + if (bundle.agentSkills.length > 0) { + const skillsDir = path.join(outputRoot, "skills") + await ensureDir(skillsDir) + for (const skill of bundle.agentSkills) { + validatePathSafe(skill.name, "agent skill") + const destDir = path.join(skillsDir, skill.name) + + const resolvedDest = path.resolve(destDir) + if (!resolvedDest.startsWith(path.resolve(skillsDir))) { + console.warn(`Warning: Agent skill name "${skill.name}" escapes skills/. Skipping.`) + continue + } + + await ensureDir(destDir) + await writeText(path.join(destDir, "SKILL.md"), skill.content) + } + } + + // Write command workflows (flat in global_workflows/ for global scope, workflows/ for workspace) + if (bundle.commandWorkflows.length > 0) { + const workflowsDirName = scope === "global" ? "global_workflows" : "workflows" + const workflowsDir = path.join(outputRoot, workflowsDirName) + await ensureDir(workflowsDir) + for (const workflow of bundle.commandWorkflows) { + validatePathSafe(workflow.name, "command workflow") + const content = formatWorkflowContent(workflow.name, workflow.description, workflow.body) + await writeText(path.join(workflowsDir, `${workflow.name}.md`), content) + } + } + + // Copy pass-through skill directories (after generated skills so copies overwrite on collision) + if (bundle.skillDirs.length > 0) { + const skillsDir = path.join(outputRoot, "skills") + await ensureDir(skillsDir) + for (const skill of bundle.skillDirs) { + validatePathSafe(skill.name, "skill directory") + const destDir = path.join(skillsDir, skill.name) + + const resolvedDest = path.resolve(destDir) + if (!resolvedDest.startsWith(path.resolve(skillsDir))) { + console.warn(`Warning: Skill name "${skill.name}" escapes skills/. Skipping.`) + continue + } + + await copyDir(skill.sourceDir, destDir) + } + } + + // Merge MCP config + if (bundle.mcpConfig) { + const mcpPath = path.join(outputRoot, "mcp_config.json") + const backupPath = await backupFile(mcpPath) + if (backupPath) { + console.log(`Backed up existing mcp_config.json to ${backupPath}`) + } + + let existingConfig: Record = {} + if (await pathExists(mcpPath)) { + try { + const parsed = await readJson(mcpPath) + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + existingConfig = parsed as Record + } + } catch { + console.warn("Warning: existing mcp_config.json could not be parsed and will be replaced.") + } + } + + const existingServers = + existingConfig.mcpServers && + typeof existingConfig.mcpServers === "object" && + !Array.isArray(existingConfig.mcpServers) + ? (existingConfig.mcpServers as Record) + : {} + const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpConfig.mcpServers } } + await writeJsonSecure(mcpPath, merged) + } +} + +function validatePathSafe(name: string, label: string): void { + if (name.includes("..") || name.includes("/") || name.includes("\\")) { + throw new Error(`${label} name contains unsafe path characters: ${name}`) + } +} + +function formatWorkflowContent(name: string, description: string, body: string): string { + return formatFrontmatter({ description }, `# ${name}\n\n${body}`) + "\n" +} diff --git a/src/types/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/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/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 +} 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 +} 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/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 +} 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/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/src/utils/resolve-output.ts b/src/utils/resolve-output.ts new file mode 100644 index 0000000..724f142 --- /dev/null +++ b/src/utils/resolve-output.ts @@ -0,0 +1,50 @@ +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 + openclawHome?: string + qwenHome?: string + pluginName?: string + hasExplicitOutput: boolean + scope?: TargetScope +}): string { + const { targetName, outputRoot, codexHome, piHome, openclawHome, qwenHome, pluginName, 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") + } + if (targetName === "openclaw") { + const home = openclawHome ?? path.join(os.homedir(), ".openclaw", "extensions") + return path.join(home, pluginName ?? "plugin") + } + if (targetName === "qwen") { + const home = qwenHome ?? path.join(os.homedir(), ".qwen", "extensions") + return path.join(home, pluginName ?? "plugin") + } + 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/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() + }) }) diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 3b3053e..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,8 +16,9 @@ describe("convertClaudeToOpenCode", () => { permissions: "from-commands", }) - expect(bundle.config.command?.["workflows:review"]).toBeDefined() - expect(bundle.config.command?.["plan_review"]).toBeDefined() + 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() const permission = bundle.config.permission as Record> expect(Object.keys(permission).sort()).toEqual([ @@ -71,8 +72,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", () => { @@ -199,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", @@ -208,10 +211,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 +243,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", () => { @@ -273,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") + }) }) diff --git a/tests/copilot-converter.test.ts b/tests/copilot-converter.test.ts new file mode 100644 index 0000000..22f7973 --- /dev/null +++ b/tests/copilot-converter.test.ts @@ -0,0 +1,467 @@ +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("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("preserves namespaced command names with hyphens", () => { + const bundle = convertClaudeToCopilot(fixturePlugin, defaultOptions) + expect(bundle.generatedSkills[0].name).toBe("workflows-plan") + }) + + 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: [ + { + 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(["workflows-plan", "plan"]) + }) + + 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("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("/workflows-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/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/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") + }) +}) 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() + }) +}) diff --git a/tests/opencode-writer.test.ts b/tests/opencode-writer.test.ts index 0bafcc0..5c02cc1 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", @@ -85,28 +88,35 @@ 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: [], skillDirs: [], } 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) @@ -116,4 +126,131 @@ describe("writeOpenCodeBundle", () => { const backupContent = JSON.parse(await fs.readFile(path.join(outputRoot, backupFileName!), "utf8")) 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") + 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") + }) }) 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") + }) +}) diff --git a/tests/resolve-output.test.ts b/tests/resolve-output.test.ts new file mode 100644 index 0000000..0d6488d --- /dev/null +++ b/tests/resolve-output.test.ts @@ -0,0 +1,131 @@ +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") + }) + + test("openclaw uses openclawHome + pluginName", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "openclaw", + openclawHome: "/custom/openclaw/extensions", + pluginName: "my-plugin", + }) + expect(result).toBe("/custom/openclaw/extensions/my-plugin") + }) + + test("openclaw falls back to default home when not provided", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "openclaw", + pluginName: "my-plugin", + }) + expect(result).toBe(path.join(os.homedir(), ".openclaw", "extensions", "my-plugin")) + }) + + test("qwen uses qwenHome + pluginName", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "qwen", + qwenHome: "/custom/qwen/extensions", + pluginName: "my-plugin", + }) + expect(result).toBe("/custom/qwen/extensions/my-plugin") + }) + + test("qwen falls back to default home when not provided", () => { + const result = resolveTargetOutputRoot({ + ...baseOptions, + targetName: "qwen", + pluginName: "my-plugin", + }) + expect(result).toBe(path.join(os.homedir(), ".qwen", "extensions", "my-plugin")) + }) +}) 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) + }) +}) 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) - }) -}) 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) + } + }) +})