chore: Resolve conflicts with main, update to v0.12.0
- sync.ts: add gemini + all targets, keep copilot, remove cursor (native), use shared hasPotentialSecrets - install.ts + convert.ts: import both detectInstalledTools and resolveTargetOutputRoot; update --to all block to use new object API; fix resolvedScope ordering (was referencing target before definition) - CHANGELOG.md: add v0.12.0 entry (auto-detect + Gemini sync) - README.md: merge all install targets, collapsible output format table, sync defaults to --target all - package.json: bump to 0.12.0 - sync --target now defaults to "all" when omitted 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
328
docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md
Normal file
328
docs/plans/2026-02-14-feat-add-copilot-converter-target-plan.md
Normal file
@@ -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<string, string>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export type CopilotBundle = {
|
||||
agents: CopilotAgent[]
|
||||
generatedSkills: CopilotGeneratedSkill[]
|
||||
skillDirs: CopilotSkillDir[]
|
||||
mcpConfig?: Record<string, CopilotMcpServer>
|
||||
}
|
||||
```
|
||||
|
||||
### 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<CopilotBundle>["convert"],
|
||||
write: writeCopilotBundle as TargetHandler<CopilotBundle>["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`
|
||||
627
docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md
Normal file
627
docs/plans/2026-02-25-feat-windsurf-global-scope-support-plan.md
Normal file
@@ -0,0 +1,627 @@
|
||||
---
|
||||
title: Windsurf Global Scope Support
|
||||
type: feat
|
||||
status: completed
|
||||
date: 2026-02-25
|
||||
deepened: 2026-02-25
|
||||
prior: docs/plans/2026-02-23-feat-add-windsurf-target-provider-plan.md (removed — superseded)
|
||||
---
|
||||
|
||||
# Windsurf Global Scope Support
|
||||
|
||||
## Post-Implementation Revisions (2026-02-26)
|
||||
|
||||
After auditing the implementation against `docs/specs/windsurf.md`, two significant changes were made:
|
||||
|
||||
1. **Agents → Skills (not Workflows)**: Claude agents map to Windsurf Skills (`skills/{name}/SKILL.md`), not Workflows. Skills are "complex multi-step tasks with supporting resources" — a better conceptual match for specialized expertise/personas. Workflows are "reusable step-by-step procedures" — a better match for Claude Commands (slash commands).
|
||||
|
||||
2. **Workflows are flat files**: Command workflows are written to `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<string, { env?: Record<string, string> }>,
|
||||
): boolean {
|
||||
for (const server of Object.values(servers)) {
|
||||
if (server.env) {
|
||||
for (const key of Object.keys(server.env)) {
|
||||
if (SENSITIVE_PATTERN.test(key)) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Update `sync.ts` to import from shared utility
|
||||
- [x] Use in new windsurf converter
|
||||
|
||||
### Phase 1: Types and TargetHandler
|
||||
|
||||
**Files:** `src/types/windsurf.ts`, `src/targets/index.ts`
|
||||
|
||||
#### 1a. Update WindsurfBundle type
|
||||
|
||||
```typescript
|
||||
// src/types/windsurf.ts
|
||||
export type WindsurfMcpServerEntry = {
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
serverUrl?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export type WindsurfMcpConfig = {
|
||||
mcpServers: Record<string, WindsurfMcpServerEntry>
|
||||
}
|
||||
|
||||
export type WindsurfBundle = {
|
||||
agentWorkflows: WindsurfWorkflow[]
|
||||
commandWorkflows: WindsurfWorkflow[]
|
||||
skillDirs: WindsurfSkillDir[]
|
||||
mcpConfig: WindsurfMcpConfig | null
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Remove `agentsMd: string | null`
|
||||
- [x] Replace `mcpSetupDoc: string | null` with `mcpConfig: WindsurfMcpConfig | null`
|
||||
- [x] Add `WindsurfMcpServerEntry` (supports both stdio and HTTP/SSE) and `WindsurfMcpConfig` types
|
||||
|
||||
### Research Insights (Phase 1a)
|
||||
|
||||
**Windsurf docs confirm** three transport types: stdio (`command` + `args`), Streamable HTTP (`serverUrl`), and SSE (`serverUrl` or `url`). The `WindsurfMcpServerEntry` type must support all three — making `command` optional and adding `serverUrl` and `headers` fields.
|
||||
|
||||
**TypeScript reviewer:** Consider making `WindsurfMcpServerEntry` a discriminated union if strict typing is desired. However, since this mirrors JSON config structure, a flat type with optional fields is pragmatically simpler.
|
||||
|
||||
#### 1b. Add TargetScope to TargetHandler
|
||||
|
||||
```typescript
|
||||
// src/targets/index.ts
|
||||
export type TargetScope = "global" | "workspace"
|
||||
|
||||
export type TargetHandler<TBundle = unknown> = {
|
||||
name: string
|
||||
implemented: boolean
|
||||
/**
|
||||
* Default scope when --scope is not provided.
|
||||
* Only meaningful when supportedScopes is defined.
|
||||
* Falls back to "workspace" if absent.
|
||||
*/
|
||||
defaultScope?: TargetScope
|
||||
/** Valid scope values. If absent, the --scope flag is rejected for this target. */
|
||||
supportedScopes?: TargetScope[]
|
||||
convert: (plugin: ClaudePlugin, options: ClaudeToOpenCodeOptions) => TBundle | null
|
||||
write: (outputRoot: string, bundle: TBundle) => Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- [x] Add `TargetScope` type export
|
||||
- [x] Add `defaultScope?` and `supportedScopes?` to `TargetHandler` with JSDoc
|
||||
- [x] Set windsurf target: `defaultScope: "global"`, `supportedScopes: ["global", "workspace"]`
|
||||
- [x] No changes to other targets (they have no scope fields, flag is ignored)
|
||||
|
||||
### Research Insights (Phase 1b)
|
||||
|
||||
**Simplicity review:** Argued this is premature generalization (only 1 of 8 targets uses scopes). Recommended handling scope as windsurf-specific with `if (targetName !== "windsurf")` guard instead. **Decision: keep generic approach** per brainstorm decision "Generic with windsurf as first adopter", but add JSDoc documenting the invariant.
|
||||
|
||||
**TypeScript review:** Suggested a `ScopeConfig` grouped object to prevent `defaultScope` without `supportedScopes`. The JSDoc approach is simpler and sufficient for now.
|
||||
|
||||
**Architecture review:** Adding optional fields to `TargetHandler` follows Open/Closed Principle — existing targets are unaffected. Clean extension.
|
||||
|
||||
### Phase 2: Converter Changes
|
||||
|
||||
**Files:** `src/converters/claude-to-windsurf.ts`
|
||||
|
||||
#### 2a. Remove AGENTS.md generation
|
||||
|
||||
- [x] Remove `buildAgentsMd()` function
|
||||
- [x] Remove `agentsMd` from return value
|
||||
|
||||
#### 2b. Replace MCP setup doc with MCP config
|
||||
|
||||
- [x] Remove `buildMcpSetupDoc()` function
|
||||
- [x] Remove `redactEnvValue()` helper
|
||||
- [x] Add `buildMcpConfig()` that returns `WindsurfMcpConfig | null`
|
||||
- [x] Include **all** env vars (including secrets) — no redaction
|
||||
- [x] Use shared `hasPotentialSecrets()` from `src/utils/secrets.ts`
|
||||
- [x] Include **both** stdio and HTTP/SSE servers (Windsurf supports all transport types)
|
||||
|
||||
```typescript
|
||||
function buildMcpConfig(
|
||||
servers?: Record<string, ClaudeMcpServer>,
|
||||
): WindsurfMcpConfig | null {
|
||||
if (!servers || Object.keys(servers).length === 0) return null
|
||||
|
||||
const result: Record<string, WindsurfMcpServerEntry> = {}
|
||||
for (const [name, server] of Object.entries(servers)) {
|
||||
if (server.command) {
|
||||
// stdio transport
|
||||
const entry: WindsurfMcpServerEntry = { command: server.command }
|
||||
if (server.args?.length) entry.args = server.args
|
||||
if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
|
||||
result[name] = entry
|
||||
} else if (server.url) {
|
||||
// HTTP/SSE transport
|
||||
const entry: WindsurfMcpServerEntry = { serverUrl: server.url }
|
||||
if (server.headers && Object.keys(server.headers).length > 0) entry.headers = server.headers
|
||||
if (server.env && Object.keys(server.env).length > 0) entry.env = server.env
|
||||
result[name] = entry
|
||||
} else {
|
||||
console.warn(`Warning: MCP server "${name}" has no command or URL. Skipping.`)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(result).length === 0) return null
|
||||
|
||||
// Warn about secrets (don't redact — they're needed for the config to work)
|
||||
if (hasPotentialSecrets(result)) {
|
||||
console.warn(
|
||||
"Warning: MCP servers contain env vars that may include secrets (API keys, tokens).\n" +
|
||||
" These will be written to mcp_config.json. Review before sharing the config file.",
|
||||
)
|
||||
}
|
||||
|
||||
return { mcpServers: result }
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insights (Phase 2)
|
||||
|
||||
**Windsurf docs (critical correction):** Windsurf supports **stdio, Streamable HTTP, and SSE** transports in `mcp_config.json`. HTTP/SSE servers use `serverUrl` (not `url`). The original plan incorrectly planned to skip HTTP/SSE servers. This is now corrected — all transport types are included.
|
||||
|
||||
**All 5 review agents flagged:** The original code sample was missing `result[name] = entry` — the entry was built but never stored. Fixed above.
|
||||
|
||||
**Security review:** The warning message should enumerate which specific env var names triggered detection. Enhanced version:
|
||||
|
||||
```typescript
|
||||
if (hasPotentialSecrets(result)) {
|
||||
const flagged = Object.entries(result)
|
||||
.filter(([, s]) => s.env && Object.keys(s.env).some(k => SENSITIVE_PATTERN.test(k)))
|
||||
.map(([name]) => name)
|
||||
console.warn(
|
||||
`Warning: MCP servers contain env vars that may include secrets: ${flagged.join(", ")}.\n` +
|
||||
" These will be written to mcp_config.json. Review before sharing the config file.",
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Windsurf env var interpolation:** Windsurf supports `${env:VARIABLE_NAME}` syntax in `mcp_config.json`. Future enhancement: write env var references instead of literal values for secrets. Out of scope for v0.11.0 (requires more research on which fields support interpolation).
|
||||
|
||||
### Phase 3: Writer Changes
|
||||
|
||||
**Files:** `src/targets/windsurf.ts`, `src/utils/files.ts`
|
||||
|
||||
#### 3a. Simplify writer — remove AGENTS.md and double-nesting guard
|
||||
|
||||
The writer always writes directly into `outputRoot`. The CLI resolves the correct output root based on scope.
|
||||
|
||||
- [x] Remove AGENTS.md writing block (lines 10-17)
|
||||
- [x] Remove `resolveWindsurfPaths()` — no longer needed
|
||||
- [x] Write workflows, skills, and MCP config directly into `outputRoot`
|
||||
|
||||
### Research Insights (Phase 3a)
|
||||
|
||||
**Pattern review (dissent):** Every other writer (kiro, copilot, gemini, droid) has a `resolve*Paths()` function with a double-nesting guard. Removing it makes Windsurf the only target where the CLI fully owns nesting. This creates an inconsistency in the `write()` contract.
|
||||
|
||||
**Resolution:** Accept the divergence — Windsurf has genuinely different semantics (global vs workspace). Add a JSDoc comment on `TargetHandler.write()` documenting that some writers may apply additional nesting while the Windsurf writer expects the final resolved path. Long-term, other targets could migrate to this pattern in a separate refactor.
|
||||
|
||||
#### 3b. Replace MCP setup doc with JSON config merge
|
||||
|
||||
Follow Kiro pattern (`src/targets/kiro.ts:68-92`) with security hardening:
|
||||
|
||||
- [x] Read existing `mcp_config.json` if present
|
||||
- [x] Backup before overwrite (`backupFile()`)
|
||||
- [x] Parse existing JSON (warn and replace if corrupted; add `!Array.isArray()` guard)
|
||||
- [x] Merge at `mcpServers` key: plugin entries overwrite same-name entries, user entries preserved
|
||||
- [x] Preserve all other top-level keys in existing file
|
||||
- [x] Write merged result with **restrictive permissions** (`0o600`)
|
||||
- [x] Emit warning when writing to workspace scope (Windsurf `mcp_config.json` is global-only per docs)
|
||||
|
||||
```typescript
|
||||
// MCP config merge with security hardening
|
||||
if (bundle.mcpConfig) {
|
||||
const mcpPath = path.join(outputRoot, "mcp_config.json")
|
||||
const backupPath = await backupFile(mcpPath)
|
||||
if (backupPath) {
|
||||
console.log(`Backed up existing mcp_config.json to ${backupPath}`)
|
||||
}
|
||||
|
||||
let existingConfig: Record<string, unknown> = {}
|
||||
if (await pathExists(mcpPath)) {
|
||||
try {
|
||||
const parsed = await readJson<unknown>(mcpPath)
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
existingConfig = parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
console.warn("Warning: existing mcp_config.json could not be parsed and will be replaced.")
|
||||
}
|
||||
}
|
||||
|
||||
const existingServers =
|
||||
existingConfig.mcpServers &&
|
||||
typeof existingConfig.mcpServers === "object" &&
|
||||
!Array.isArray(existingConfig.mcpServers)
|
||||
? (existingConfig.mcpServers as Record<string, unknown>)
|
||||
: {}
|
||||
const merged = { ...existingConfig, mcpServers: { ...existingServers, ...bundle.mcpConfig.mcpServers } }
|
||||
await writeJsonSecure(mcpPath, merged) // 0o600 permissions
|
||||
}
|
||||
```
|
||||
|
||||
### Research Insights (Phase 3b)
|
||||
|
||||
**Security review (HIGH):** The current `writeJson()` in `src/utils/files.ts` uses default umask (`0o644`) — world-readable. The sync targets all use `{ mode: 0o600 }` for secret-containing files. The Windsurf writer (and Kiro writer) must do the same.
|
||||
|
||||
**Implementation:** Add a `writeJsonSecure()` helper or add a `mode` parameter to `writeJson()`:
|
||||
|
||||
```typescript
|
||||
// src/utils/files.ts
|
||||
export async function writeJsonSecure(filePath: string, data: unknown): Promise<void> {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
await ensureDir(path.dirname(filePath))
|
||||
await fs.writeFile(filePath, content + "\n", { encoding: "utf8", mode: 0o600 })
|
||||
}
|
||||
```
|
||||
|
||||
**Security review (MEDIUM):** Backup files inherit default permissions. Ensure `backupFile()` also sets `0o600` on the backup copy when the source may contain secrets.
|
||||
|
||||
**Security review (MEDIUM):** Workspace `mcp_config.json` could be committed to git. After writing to workspace scope, emit a warning:
|
||||
|
||||
```
|
||||
Warning: .windsurf/mcp_config.json may contain secrets. Ensure it is in .gitignore.
|
||||
```
|
||||
|
||||
**TypeScript review:** The `readJson<Record<string, unknown>>` assertion is unsafe — a valid JSON array or string passes parsing but fails the type. Added `!Array.isArray()` guard.
|
||||
|
||||
**TypeScript review:** The `bundle.mcpConfig` null check is sufficient — when non-null, `mcpServers` is guaranteed to have entries (the converter returns null for empty servers). Simplified from `bundle.mcpConfig && Object.keys(...)`.
|
||||
|
||||
**Windsurf docs (important):** `mcp_config.json` is a **global configuration only** — Windsurf has no per-project MCP config support. Writing it to `.windsurf/` in workspace scope may not be discovered by Windsurf. Emit a warning for workspace scope but still write the file for forward-compatibility.
|
||||
|
||||
#### 3c. Updated writer structure
|
||||
|
||||
```typescript
|
||||
export async function writeWindsurfBundle(outputRoot: string, bundle: WindsurfBundle): Promise<void> {
|
||||
await ensureDir(outputRoot)
|
||||
|
||||
// Write agent workflows
|
||||
if (bundle.agentWorkflows.length > 0) {
|
||||
const agentDir = path.join(outputRoot, "workflows", "agents")
|
||||
await ensureDir(agentDir)
|
||||
for (const workflow of bundle.agentWorkflows) {
|
||||
validatePathSafe(workflow.name, "agent workflow")
|
||||
const content = formatFrontmatter({ description: workflow.description }, `# ${workflow.name}\n\n${workflow.body}`)
|
||||
await writeText(path.join(agentDir, `${workflow.name}.md`), content + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Write command workflows
|
||||
if (bundle.commandWorkflows.length > 0) {
|
||||
const cmdDir = path.join(outputRoot, "workflows", "commands")
|
||||
await ensureDir(cmdDir)
|
||||
for (const workflow of bundle.commandWorkflows) {
|
||||
validatePathSafe(workflow.name, "command workflow")
|
||||
const content = formatFrontmatter({ description: workflow.description }, `# ${workflow.name}\n\n${workflow.body}`)
|
||||
await writeText(path.join(cmdDir, `${workflow.name}.md`), content + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Copy skill directories
|
||||
if (bundle.skillDirs.length > 0) {
|
||||
const skillsDir = path.join(outputRoot, "skills")
|
||||
await ensureDir(skillsDir)
|
||||
for (const skill of bundle.skillDirs) {
|
||||
validatePathSafe(skill.name, "skill directory")
|
||||
const destDir = path.join(skillsDir, skill.name)
|
||||
const resolvedDest = path.resolve(destDir)
|
||||
if (!resolvedDest.startsWith(path.resolve(skillsDir))) {
|
||||
console.warn(`Warning: Skill name "${skill.name}" escapes skills/. Skipping.`)
|
||||
continue
|
||||
}
|
||||
await copyDir(skill.sourceDir, destDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge MCP config (see 3b above)
|
||||
if (bundle.mcpConfig) {
|
||||
// ... merge logic from 3b
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: CLI Wiring
|
||||
|
||||
**Files:** `src/commands/install.ts`, `src/commands/convert.ts`
|
||||
|
||||
#### 4a. Add `--scope` flag to both commands
|
||||
|
||||
```typescript
|
||||
scope: {
|
||||
type: "string",
|
||||
description: "Scope level: global | workspace (default varies by target)",
|
||||
},
|
||||
```
|
||||
|
||||
- [x] Add `scope` arg to `install.ts`
|
||||
- [x] Add `scope` arg to `convert.ts`
|
||||
|
||||
#### 4b. Validate scope with type guard
|
||||
|
||||
Use a proper type guard instead of unsafe `as TargetScope` cast:
|
||||
|
||||
```typescript
|
||||
function isTargetScope(value: string): value is TargetScope {
|
||||
return value === "global" || value === "workspace"
|
||||
}
|
||||
|
||||
const scopeValue = args.scope ? String(args.scope) : undefined
|
||||
if (scopeValue !== undefined) {
|
||||
if (!target.supportedScopes) {
|
||||
throw new Error(`Target "${targetName}" does not support the --scope flag.`)
|
||||
}
|
||||
if (!isTargetScope(scopeValue) || !target.supportedScopes.includes(scopeValue)) {
|
||||
throw new Error(`Target "${targetName}" does not support --scope ${scopeValue}. Supported: ${target.supportedScopes.join(", ")}`)
|
||||
}
|
||||
}
|
||||
const resolvedScope = scopeValue ?? target.defaultScope ?? "workspace"
|
||||
```
|
||||
|
||||
- [x] Add `isTargetScope` type guard
|
||||
- [x] Add scope validation in both commands (single block, not two separate checks)
|
||||
|
||||
### Research Insights (Phase 4b)
|
||||
|
||||
**TypeScript review:** The original plan cast `scopeValue as TargetScope` before validation — a type lie. Use a proper type guard function to keep the type system honest.
|
||||
|
||||
**Simplicity review:** The two-step validation (check supported, then check exists) can be a single block with the type guard approach above.
|
||||
|
||||
#### 4c. Update output root resolution
|
||||
|
||||
Both commands now use the shared `resolveTargetOutputRoot` from Phase 0a.
|
||||
|
||||
- [x] Call shared function with `scope: resolvedScope` for primary target
|
||||
- [x] Default scope: `target.defaultScope ?? "workspace"` (only used when target supports scopes)
|
||||
|
||||
#### 4d. Handle `--also` targets
|
||||
|
||||
`--scope` applies only to the primary `--to` target. Extra `--also` targets use their own `defaultScope`.
|
||||
|
||||
- [x] Pass `handler.defaultScope` for `--also` targets (each uses its own default)
|
||||
- [x] Update the `--also` loop in both commands to use target-specific scope resolution
|
||||
|
||||
### Research Insights (Phase 4d)
|
||||
|
||||
**Architecture review:** There is no way for users to specify scope for an `--also` target (e.g., `--also windsurf:workspace`). Accept as a known v0.11.0 limitation. If users need workspace scope for windsurf, they can run two separate commands. Add a code comment indicating where per-target scope overrides would be added in the future.
|
||||
|
||||
### Phase 5: Tests
|
||||
|
||||
**Files:** `tests/windsurf-converter.test.ts`, `tests/windsurf-writer.test.ts`
|
||||
|
||||
#### 5a. Update converter tests
|
||||
|
||||
- [x] Remove all AGENTS.md tests (lines 275-303: empty plugin, CLAUDE.md missing)
|
||||
- [x] Remove all `mcpSetupDoc` tests (lines 305-366: stdio, HTTP/SSE, redaction, null)
|
||||
- [x] Update `fixturePlugin` default — remove `agentsMd` and `mcpSetupDoc` references
|
||||
- [x] Add `mcpConfig` tests:
|
||||
- stdio server produces correct JSON structure with `command`, `args`, `env`
|
||||
- HTTP/SSE server produces correct JSON structure with `serverUrl`, `headers`
|
||||
- mixed servers (stdio + HTTP) both included
|
||||
- env vars included (not redacted) — verify actual values present
|
||||
- `hasPotentialSecrets()` emits console.warn for sensitive keys
|
||||
- `hasPotentialSecrets()` does NOT warn when no sensitive keys
|
||||
- no servers produces null mcpConfig
|
||||
- empty bundle has null mcpConfig
|
||||
- server with no command and no URL is skipped with warning
|
||||
|
||||
#### 5b. Update writer tests
|
||||
|
||||
- [x] Remove AGENTS.md tests (backup test, creation test, double-nesting AGENTS.md parent test)
|
||||
- [x] Remove double-nesting guard test (guard removed)
|
||||
- [x] Remove `mcp-setup.md` write test
|
||||
- [x] Update `emptyBundle` fixture — remove `agentsMd`, `mcpSetupDoc`, add `mcpConfig: null`
|
||||
- [x] Add `mcp_config.json` tests:
|
||||
- writes mcp_config.json to outputRoot
|
||||
- merges with existing mcp_config.json (preserves user servers)
|
||||
- backs up existing mcp_config.json before overwrite
|
||||
- handles corrupted existing mcp_config.json (warn and replace)
|
||||
- handles existing mcp_config.json with array (not object) at root
|
||||
- handles existing mcp_config.json with `mcpServers: null`
|
||||
- preserves non-mcpServers keys in existing file
|
||||
- server name collision: plugin entry wins
|
||||
- file permissions are 0o600 (not world-readable)
|
||||
- [x] Update full bundle test — writer writes directly into outputRoot (no `.windsurf/` nesting)
|
||||
|
||||
#### 5c. Add scope resolution tests
|
||||
|
||||
Test the shared `resolveTargetOutputRoot` function:
|
||||
|
||||
- [x] Default scope for windsurf is "global" → resolves to `~/.codeium/windsurf/`
|
||||
- [x] Explicit `--scope workspace` → resolves to `cwd/.windsurf/`
|
||||
- [x] `--output` overrides scope resolution (both global and workspace)
|
||||
- [x] Invalid scope value for windsurf → error
|
||||
- [x] `--scope` on non-scope target (e.g., opencode) → error
|
||||
- [x] `--also windsurf` uses windsurf's default scope ("global")
|
||||
- [x] `isTargetScope` type guard correctly identifies valid/invalid values
|
||||
|
||||
### Phase 6: Documentation
|
||||
|
||||
**Files:** `README.md`, `CHANGELOG.md`
|
||||
|
||||
- [x] Update README.md Windsurf section to mention `--scope` flag and global default
|
||||
- [x] Add CHANGELOG entry for v0.11.0 with breaking changes documented
|
||||
- [x] Document migration path: `--scope workspace` for old behavior
|
||||
- [x] Note that Windsurf `mcp_config.json` is global-only (workspace MCP config may not be discovered)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] `install compound-engineering --to windsurf` writes to `~/.codeium/windsurf/` by default
|
||||
- [x] `install compound-engineering --to windsurf --scope workspace` writes to `cwd/.windsurf/`
|
||||
- [x] `--output /custom/path` overrides scope for both commands
|
||||
- [x] `--scope` on non-supporting target produces clear error
|
||||
- [x] `mcp_config.json` merges with existing file (backup created, user entries preserved)
|
||||
- [x] `mcp_config.json` written with `0o600` permissions (not world-readable)
|
||||
- [x] No AGENTS.md generated for either scope
|
||||
- [x] Env var secrets included in `mcp_config.json` with `console.warn` listing affected servers
|
||||
- [x] Both stdio and HTTP/SSE MCP servers included in `mcp_config.json`
|
||||
- [x] All existing tests updated, all new tests pass
|
||||
- [x] No regressions in other targets
|
||||
- [x] `resolveTargetOutputRoot` extracted to shared utility (no duplication)
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
**Risk: Global workflow path is undocumented.** Windsurf may not discover workflows from `~/.codeium/windsurf/workflows/`. Mitigation: documented as a known assumption in the brainstorm. Users can `--scope workspace` if global workflows aren't discovered.
|
||||
|
||||
**Risk: Breaking changes for existing v0.10.0 users.** Mitigation: document migration path clearly. `--scope workspace` restores previous behavior. Target is experimental with a small user base.
|
||||
|
||||
**Risk: Workspace `mcp_config.json` not read by Windsurf.** Per Windsurf docs, `mcp_config.json` is global-only configuration. Workspace scope writes the file for forward-compatibility but emits a warning. The primary use case is global scope anyway.
|
||||
|
||||
**Risk: Secrets in `mcp_config.json` committed to git.** Mitigation: `0o600` file permissions, console.warn about sensitive env vars, warning about `.gitignore` for workspace scope.
|
||||
|
||||
## References & Research
|
||||
|
||||
- Spec: `docs/specs/windsurf.md` (authoritative reference for component mapping)
|
||||
- Kiro MCP merge pattern: [src/targets/kiro.ts:68-92](../../src/targets/kiro.ts)
|
||||
- Sync secrets warning: [src/commands/sync.ts:20-28](../../src/commands/sync.ts)
|
||||
- Windsurf MCP docs: https://docs.windsurf.com/windsurf/cascade/mcp
|
||||
- Windsurf Skills global path: https://docs.windsurf.com/windsurf/cascade/skills
|
||||
- Windsurf MCP tutorial: https://windsurf.com/university/tutorials/configuring-first-mcp-server
|
||||
- Adding converter targets (learning): [docs/solutions/adding-converter-target-providers.md](../solutions/adding-converter-target-providers.md)
|
||||
- Plugin versioning (learning): [docs/solutions/plugin-versioning-requirements.md](../solutions/plugin-versioning-requirements.md)
|
||||
574
docs/plans/feature_opencode-commands-as-md-and-config-merge.md
Normal file
574
docs/plans/feature_opencode-commands-as-md-and-config-merge.md
Normal file
@@ -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/<name>.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<string, OpenCodeCommandConfig>`. 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/<name>.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 `<commandsDir>/<name>.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<string, unknown>` 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<string, OpenCodeCommandConfig>` 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<string, OpenCodeCommandConfig>`) from `OpenCodeConfig`.
|
||||
3. Add after line 47:
|
||||
```typescript
|
||||
export type OpenCodeCommandFile = {
|
||||
name: string // command name, used as the filename stem: <name>.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<string, OpenCodeCommandConfig>`.
|
||||
- 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<string, unknown> = {
|
||||
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 `<commandsDir>/<name>.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<OpenCodeConfig> {
|
||||
// If no existing config, write plugin config as-is
|
||||
if (!(await pathExists(configPath))) return incoming
|
||||
|
||||
let existing: OpenCodeConfig
|
||||
try {
|
||||
existing = await readJson<OpenCodeConfig>(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/<name>.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/<name>.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 <test-file>` and confirm the new/modified tests FAIL (red).
|
||||
3. Implement the code change.
|
||||
4. Run `bun test <test-file>` 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
|
||||
692
docs/solutions/adding-converter-target-providers.md
Normal file
692
docs/solutions/adding-converter-target-providers.md
Normal file
@@ -0,0 +1,692 @@
|
||||
---
|
||||
title: Adding New Converter Target Providers
|
||||
category: architecture
|
||||
tags: [converter, target-provider, plugin-conversion, multi-platform, pattern]
|
||||
created: 2026-02-23
|
||||
severity: medium
|
||||
component: converter-cli
|
||||
problem_type: best_practice
|
||||
root_cause: architectural_pattern
|
||||
---
|
||||
|
||||
# Adding New Converter Target Providers
|
||||
|
||||
## Problem
|
||||
|
||||
When adding support for a new AI platform (e.g., Devin, Cursor, Copilot), the converter CLI architecture requires consistent implementation across types, converters, writers, CLI integration, and tests. Without documented patterns and learnings, new targets take longer to implement and risk architectural inconsistency.
|
||||
|
||||
## Solution
|
||||
|
||||
The compound-engineering-plugin uses a proven **6-phase target provider pattern** that has been successfully applied to 8 targets:
|
||||
|
||||
1. **OpenCode** (primary target, reference implementation)
|
||||
2. **Codex** (second target, established pattern)
|
||||
3. **Droid/Factory** (workflow/agent conversion)
|
||||
4. **Pi** (MCPorter ecosystem)
|
||||
5. **Gemini CLI** (content transformation patterns)
|
||||
6. **Cursor** (command flattening, rule formats)
|
||||
7. **Copilot** (GitHub native, MCP prefixing)
|
||||
8. **Kiro** (limited MCP support)
|
||||
9. **Devin** (playbook conversion, knowledge entries)
|
||||
|
||||
Each implementation follows this architecture precisely, ensuring consistency and maintainability.
|
||||
|
||||
## Architecture: The 6-Phase Pattern
|
||||
|
||||
### Phase 1: Type Definitions (`src/types/{target}.ts`)
|
||||
|
||||
**Purpose:** Define TypeScript types for the intermediate bundle format
|
||||
|
||||
**Key Pattern:**
|
||||
|
||||
```typescript
|
||||
// Exported bundle type used by converter and writer
|
||||
export type {TargetName}Bundle = {
|
||||
// Component arrays matching the target format
|
||||
agents?: {TargetName}Agent[]
|
||||
commands?: {TargetName}Command[]
|
||||
skillDirs?: {TargetName}SkillDir[]
|
||||
mcpServers?: Record<string, {TargetName}McpServer>
|
||||
// Target-specific fields
|
||||
setup?: string // Instructions file content
|
||||
}
|
||||
|
||||
// Individual component types
|
||||
export type {TargetName}Agent = {
|
||||
name: string
|
||||
content: string // Full file content (with frontmatter if applicable)
|
||||
category?: string // e.g., "agent", "rule", "playbook"
|
||||
meta?: Record<string, unknown> // Target-specific metadata
|
||||
}
|
||||
```
|
||||
|
||||
**Key Learnings:**
|
||||
|
||||
- Always include a `content` field (full file text) rather than decomposed fields — it's simpler and matches how files are written
|
||||
- Use intermediate types for complex sections (e.g., `DevinPlaybookSections` in Devin converter) to make section building independently testable
|
||||
- Avoid target-specific fields in the base bundle unless essential — aim for shared structure across targets
|
||||
- Include a `category` field if the target has file-type variants (agents vs. commands vs. rules)
|
||||
|
||||
**Reference Implementations:**
|
||||
- OpenCode: `src/types/opencode.ts` (command + agent split)
|
||||
- Devin: `src/types/devin.ts` (playbooks + knowledge entries)
|
||||
- Copilot: `src/types/copilot.ts` (agents + skills + MCP)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Converter (`src/converters/claude-to-{target}.ts`)
|
||||
|
||||
**Purpose:** Transform Claude Code plugin format → target-specific bundle format
|
||||
|
||||
**Key Pattern:**
|
||||
|
||||
```typescript
|
||||
export type ClaudeTo{Target}Options = ClaudeToOpenCodeOptions // Reuse common options
|
||||
|
||||
export function convertClaudeTo{Target}(
|
||||
plugin: ClaudePlugin,
|
||||
_options: ClaudeTo{Target}Options,
|
||||
): {Target}Bundle {
|
||||
// Pre-scan: build maps for cross-reference resolution (agents, commands)
|
||||
// Needed if target requires deduplication or reference tracking
|
||||
const refMap: Record<string, string> = {}
|
||||
for (const agent of plugin.agents) {
|
||||
refMap[normalize(agent.name)] = macroName(agent.name)
|
||||
}
|
||||
|
||||
// Phase 1: Convert agents
|
||||
const agents = plugin.agents.map(a => convert{Target}Agent(a, usedNames, refMap))
|
||||
|
||||
// Phase 2: Convert commands (may depend on agent names for dedup)
|
||||
const commands = plugin.commands.map(c => convert{Target}Command(c, usedNames, refMap))
|
||||
|
||||
// Phase 3: Handle skills (usually pass-through, sometimes conversion)
|
||||
const skillDirs = plugin.skills.map(s => ({ name: s.name, sourceDir: s.sourceDir }))
|
||||
|
||||
// Phase 4: Convert MCP servers (target-specific prefixing/type mapping)
|
||||
const mcpConfig = convertMcpServers(plugin.mcpServers)
|
||||
|
||||
// Phase 5: Warn on unsupported features
|
||||
if (plugin.hooks && Object.keys(plugin.hooks.hooks).length > 0) {
|
||||
console.warn("Warning: {Target} does not support hooks. Hooks were skipped.")
|
||||
}
|
||||
|
||||
return { agents, commands, skillDirs, mcpConfig }
|
||||
}
|
||||
```
|
||||
|
||||
**Content Transformation (`transformContentFor{Target}`):**
|
||||
|
||||
Applied to both agent bodies and command bodies to rewrite paths, command references, and agent mentions:
|
||||
|
||||
```typescript
|
||||
export function transformContentFor{Target}(body: string): string {
|
||||
let result = body
|
||||
|
||||
// 1. Rewrite paths (.claude/ → .github/, ~/.claude/ → ~/.{target}/)
|
||||
result = result
|
||||
.replace(/~\/\.claude\//g, `~/.${targetDir}/`)
|
||||
.replace(/\.claude\//g, `.${targetDir}/`)
|
||||
|
||||
// 2. Transform Task agent calls (to natural language)
|
||||
const taskPattern = /Task\s+([a-z][a-z0-9-]*)\(([^)]+)\)/gm
|
||||
result = result.replace(taskPattern, (_match, agentName: string, args: string) => {
|
||||
const skillName = normalize(agentName)
|
||||
return `Use the ${skillName} skill to: ${args.trim()}`
|
||||
})
|
||||
|
||||
// 3. Flatten slash commands (/workflows:plan → /plan)
|
||||
const slashPattern = /(?<![:\w])\/([a-z][a-z0-9_:-]*?)(?=[\s,."')\]}`]|$)/gi
|
||||
result = result.replace(slashPattern, (match, commandName: string) => {
|
||||
if (commandName.includes("/")) return match // Skip file paths
|
||||
const normalized = normalize(commandName)
|
||||
return `/${normalized}`
|
||||
})
|
||||
|
||||
// 4. Transform @agent-name references
|
||||
const agentPattern = /@([a-z][a-z0-9-]*-(?:agent|reviewer|analyst|...))/gi
|
||||
result = result.replace(agentPattern, (_match, agentName: string) => {
|
||||
return `the ${normalize(agentName)} agent` // or "rule", "playbook", etc.
|
||||
})
|
||||
|
||||
// 5. Remove examples (if target doesn't support them)
|
||||
result = result.replace(/<examples>[\s\S]*?<\/examples>/g, "")
|
||||
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
**Deduplication Pattern (`uniqueName`):**
|
||||
|
||||
Used when target has flat namespaces (Cursor, Copilot, Devin) or when name collisions occur:
|
||||
|
||||
```typescript
|
||||
function uniqueName(base: string, used: Set<string>): string {
|
||||
if (!used.has(base)) {
|
||||
used.add(base)
|
||||
return base
|
||||
}
|
||||
let index = 2
|
||||
while (used.has(`${base}-${index}`)) {
|
||||
index += 1
|
||||
}
|
||||
const name = `${base}-${index}`
|
||||
used.add(name)
|
||||
return name
|
||||
}
|
||||
|
||||
function normalizeName(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return "item"
|
||||
const normalized = trimmed
|
||||
.toLowerCase()
|
||||
.replace(/[\\/]+/g, "-")
|
||||
.replace(/[:\s]+/g, "-")
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return normalized || "item"
|
||||
}
|
||||
|
||||
// Flatten: drops namespace prefix (workflows:plan → plan)
|
||||
function flattenCommandName(name: string): string {
|
||||
const normalized = normalizeName(name)
|
||||
return normalized.replace(/^[a-z]+-/, "") // Drop prefix before first dash
|
||||
}
|
||||
```
|
||||
|
||||
**Key Learnings:**
|
||||
|
||||
1. **Pre-scan for cross-references** — If target requires reference names (macros, URIs, IDs), build a map before conversion. Example: Devin needs macro names like `agent_kieran_rails_reviewer`, so pre-scan builds the map.
|
||||
|
||||
2. **Content transformation is fragile** — Test extensively. Patterns that work for slash commands might false-match on file paths. Use negative lookahead to skip `/etc`, `/usr`, `/var`, etc.
|
||||
|
||||
3. **Simplify heuristics, trust structural mapping** — Don't try to parse agent body for "You are..." or "NEVER do..." patterns. Instead, map agent.description → Overview, agent.body → Procedure, agent.capabilities → Specifications. Heuristics fail on edge cases and are hard to test.
|
||||
|
||||
4. **Normalize early and consistently** — Use the same `normalizeName()` function throughout. Inconsistent normalization causes deduplication bugs.
|
||||
|
||||
5. **MCP servers need target-specific handling:**
|
||||
- **OpenCode:** Merge into `opencode.json` (preserve user keys)
|
||||
- **Copilot:** Prefix env vars with `COPILOT_MCP_`, emit JSON
|
||||
- **Devin:** Write setup instructions file (config is via web UI)
|
||||
- **Cursor:** Pass through as-is
|
||||
|
||||
6. **Warn on unsupported features** — Hooks, Gemini extensions, Kiro-incompatible MCP types. Emit to stderr and continue conversion.
|
||||
|
||||
**Reference Implementations:**
|
||||
- OpenCode: `src/converters/claude-to-opencode.ts` (most comprehensive)
|
||||
- Devin: `src/converters/claude-to-devin.ts` (content transformation + cross-references)
|
||||
- Copilot: `src/converters/claude-to-copilot.ts` (MCP prefixing pattern)
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Writer (`src/targets/{target}.ts`)
|
||||
|
||||
**Purpose:** Write converted bundle to disk in target-specific directory structure
|
||||
|
||||
**Key Pattern:**
|
||||
|
||||
```typescript
|
||||
export async function write{Target}Bundle(outputRoot: string, bundle: {Target}Bundle): Promise<void> {
|
||||
const paths = resolve{Target}Paths(outputRoot)
|
||||
await ensureDir(paths.root)
|
||||
|
||||
// Write each component type
|
||||
if (bundle.agents?.length > 0) {
|
||||
const agentsDir = path.join(paths.root, "agents")
|
||||
for (const agent of bundle.agents) {
|
||||
await writeText(path.join(agentsDir, `${agent.name}.ext`), agent.content + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (bundle.commands?.length > 0) {
|
||||
const commandsDir = path.join(paths.root, "commands")
|
||||
for (const command of bundle.commands) {
|
||||
await writeText(path.join(commandsDir, `${command.name}.ext`), command.content + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Copy skills (pass-through case)
|
||||
if (bundle.skillDirs?.length > 0) {
|
||||
const skillsDir = path.join(paths.root, "skills")
|
||||
for (const skill of bundle.skillDirs) {
|
||||
await copyDir(skill.sourceDir, path.join(skillsDir, skill.name))
|
||||
}
|
||||
}
|
||||
|
||||
// Write generated skills (converted from commands)
|
||||
if (bundle.generatedSkills?.length > 0) {
|
||||
const skillsDir = path.join(paths.root, "skills")
|
||||
for (const skill of bundle.generatedSkills) {
|
||||
await writeText(path.join(skillsDir, skill.name, "SKILL.md"), skill.content + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Write MCP config (target-specific location and format)
|
||||
if (bundle.mcpServers && Object.keys(bundle.mcpServers).length > 0) {
|
||||
const mcpPath = path.join(paths.root, "mcp.json") // or copilot-mcp-config.json, etc.
|
||||
const backupPath = await backupFile(mcpPath)
|
||||
if (backupPath) {
|
||||
console.log(`Backed up existing MCP config to ${backupPath}`)
|
||||
}
|
||||
await writeJson(mcpPath, { mcpServers: bundle.mcpServers })
|
||||
}
|
||||
|
||||
// Write instructions or setup guides
|
||||
if (bundle.setupInstructions) {
|
||||
const setupPath = path.join(paths.root, "setup-instructions.md")
|
||||
await writeText(setupPath, bundle.setupInstructions + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid double-nesting (.target/.target/)
|
||||
function resolve{Target}Paths(outputRoot: string) {
|
||||
const base = path.basename(outputRoot)
|
||||
// If already pointing at .target, write directly into it
|
||||
if (base === ".target") {
|
||||
return { root: outputRoot }
|
||||
}
|
||||
// Otherwise nest under .target
|
||||
return { root: path.join(outputRoot, ".target") }
|
||||
}
|
||||
```
|
||||
|
||||
**Backup Pattern (MCP configs only):**
|
||||
|
||||
MCP configs are often pre-existing and user-edited. Backup before overwrite:
|
||||
|
||||
```typescript
|
||||
// From src/utils/files.ts
|
||||
export async function backupFile(filePath: string): Promise<string | null> {
|
||||
if (!existsSync(filePath)) return null
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
|
||||
const dirname = path.dirname(filePath)
|
||||
const basename = path.basename(filePath)
|
||||
const ext = path.extname(basename)
|
||||
const name = basename.slice(0, -ext.length)
|
||||
const backupPath = path.join(dirname, `${name}.${timestamp}${ext}`)
|
||||
await copyFile(filePath, backupPath)
|
||||
return backupPath
|
||||
}
|
||||
```
|
||||
|
||||
**Key Learnings:**
|
||||
|
||||
1. **Always check for double-nesting** — If output root is already `.target`, don't nest again. Pattern:
|
||||
```typescript
|
||||
if (path.basename(outputRoot) === ".target") {
|
||||
return { root: outputRoot } // Write directly
|
||||
}
|
||||
return { root: path.join(outputRoot, ".target") } // Nest
|
||||
```
|
||||
|
||||
2. **Use `writeText` and `writeJson` helpers** — These handle directory creation and line endings consistently
|
||||
|
||||
3. **Backup MCP configs before overwriting** — MCP JSON files are often hand-edited. Always backup with timestamp.
|
||||
|
||||
4. **Empty bundles should succeed gracefully** — Don't fail if a component array is empty. Many plugins may have no commands or no skills.
|
||||
|
||||
5. **File extensions matter** — Match target conventions exactly:
|
||||
- Copilot: `.agent.md` (note the dot)
|
||||
- Cursor: `.mdc` for rules
|
||||
- Devin: `.devin.md` for playbooks
|
||||
- OpenCode: `.md` for commands
|
||||
|
||||
6. **Permissions for sensitive files** — MCP config with API keys should use `0o600`:
|
||||
```typescript
|
||||
await writeJson(mcpPath, config, { mode: 0o600 })
|
||||
```
|
||||
|
||||
**Reference Implementations:**
|
||||
- Droid: `src/targets/droid.ts` (simpler pattern, good for learning)
|
||||
- Copilot: `src/targets/copilot.ts` (double-nesting pattern)
|
||||
- Devin: `src/targets/devin.ts` (setup instructions file)
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: CLI Wiring
|
||||
|
||||
**File: `src/targets/index.ts`**
|
||||
|
||||
Register the new target in the global target registry:
|
||||
|
||||
```typescript
|
||||
import { convertClaudeTo{Target} } from "../converters/claude-to-{target}"
|
||||
import { write{Target}Bundle } from "./{target}"
|
||||
import type { {Target}Bundle } from "../types/{target}"
|
||||
|
||||
export const targets: Record<string, TargetHandler<any>> = {
|
||||
// ... existing targets ...
|
||||
{target}: {
|
||||
name: "{target}",
|
||||
implemented: true,
|
||||
convert: convertClaudeTo{Target} as TargetHandler<{Target}Bundle>["convert"],
|
||||
write: write{Target}Bundle as TargetHandler<{Target}Bundle>["write"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**File: `src/commands/convert.ts` and `src/commands/install.ts`**
|
||||
|
||||
Add output root resolution:
|
||||
|
||||
```typescript
|
||||
// In resolveTargetOutputRoot()
|
||||
if (targetName === "{target}") {
|
||||
return path.join(outputRoot, ".{target}")
|
||||
}
|
||||
|
||||
// Update --to flag description
|
||||
const toDescription = "Target format (opencode | codex | droid | cursor | copilot | kiro | {target})"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Sync Support (Optional)
|
||||
|
||||
**File: `src/sync/{target}.ts`**
|
||||
|
||||
If the target supports syncing personal skills and MCP servers:
|
||||
|
||||
```typescript
|
||||
export async function syncTo{Target}(outputRoot: string): Promise<void> {
|
||||
const personalSkillsDir = path.join(expandHome("~/.claude/skills"))
|
||||
const personalSettings = loadSettings(expandHome("~/.claude/settings.json"))
|
||||
|
||||
const skillsDest = path.join(outputRoot, ".{target}", "skills")
|
||||
await ensureDir(skillsDest)
|
||||
|
||||
// Symlink personal skills
|
||||
if (existsSync(personalSkillsDir)) {
|
||||
const skills = readdirSync(personalSkillsDir)
|
||||
for (const skill of skills) {
|
||||
if (!isValidSkillName(skill)) continue
|
||||
const source = path.join(personalSkillsDir, skill)
|
||||
const dest = path.join(skillsDest, skill)
|
||||
await forceSymlink(source, dest)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge MCP servers if applicable
|
||||
if (personalSettings.mcpServers) {
|
||||
const mcpPath = path.join(outputRoot, ".{target}", "mcp.json")
|
||||
const existing = readJson(mcpPath) || {}
|
||||
const merged = {
|
||||
...existing,
|
||||
mcpServers: {
|
||||
...existing.mcpServers,
|
||||
...personalSettings.mcpServers,
|
||||
},
|
||||
}
|
||||
await writeJson(mcpPath, merged, { mode: 0o600 })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File: `src/commands/sync.ts`**
|
||||
|
||||
```typescript
|
||||
// Add to validTargets array
|
||||
const validTargets = ["opencode", "codex", "droid", "cursor", "pi", "{target}"] as const
|
||||
|
||||
// In resolveOutputRoot()
|
||||
case "{target}":
|
||||
return path.join(process.cwd(), ".{target}")
|
||||
|
||||
// In main switch
|
||||
case "{target}":
|
||||
await syncTo{Target}(outputRoot)
|
||||
break
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Tests
|
||||
|
||||
**File: `tests/{target}-converter.test.ts`**
|
||||
|
||||
Test converter using inline `ClaudePlugin` fixtures:
|
||||
|
||||
```typescript
|
||||
describe("convertClaudeTo{Target}", () => {
|
||||
it("converts agents to {target} format", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
name: "test",
|
||||
agents: [
|
||||
{
|
||||
name: "test-agent",
|
||||
description: "Test description",
|
||||
body: "Test body",
|
||||
capabilities: ["Cap 1", "Cap 2"],
|
||||
},
|
||||
],
|
||||
commands: [],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeTo{Target}(plugin, {})
|
||||
|
||||
expect(bundle.agents).toHaveLength(1)
|
||||
expect(bundle.agents[0].name).toBe("test-agent")
|
||||
expect(bundle.agents[0].content).toContain("Test description")
|
||||
})
|
||||
|
||||
it("normalizes agent names", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
name: "test",
|
||||
agents: [
|
||||
{ name: "Test Agent", description: "", body: "", capabilities: [] },
|
||||
],
|
||||
commands: [],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeTo{Target}(plugin, {})
|
||||
expect(bundle.agents[0].name).toBe("test-agent")
|
||||
})
|
||||
|
||||
it("deduplicates colliding names", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
name: "test",
|
||||
agents: [
|
||||
{ name: "Agent Name", description: "", body: "", capabilities: [] },
|
||||
{ name: "Agent Name", description: "", body: "", capabilities: [] },
|
||||
],
|
||||
commands: [],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeTo{Target}(plugin, {})
|
||||
expect(bundle.agents.map(a => a.name)).toEqual(["agent-name", "agent-name-2"])
|
||||
})
|
||||
|
||||
it("transforms content paths (.claude → .{target})", () => {
|
||||
const result = transformContentFor{Target}("See ~/.claude/config")
|
||||
expect(result).toContain("~/.{target}/config")
|
||||
})
|
||||
|
||||
it("warns when hooks are present", () => {
|
||||
const spy = jest.spyOn(console, "warn")
|
||||
const plugin: ClaudePlugin = {
|
||||
name: "test",
|
||||
agents: [],
|
||||
commands: [],
|
||||
skills: [],
|
||||
hooks: { hooks: { "file:save": "test" } },
|
||||
}
|
||||
|
||||
convertClaudeTo{Target}(plugin, {})
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining("hooks"))
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**File: `tests/{target}-writer.test.ts`**
|
||||
|
||||
Test writer using temp directories (from `tmp` package):
|
||||
|
||||
```typescript
|
||||
describe("write{Target}Bundle", () => {
|
||||
it("writes agents to {target} format", async () => {
|
||||
const tmpDir = await tmp.dir()
|
||||
const bundle: {Target}Bundle = {
|
||||
agents: [{ name: "test", content: "# Test\nBody" }],
|
||||
commands: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await write{Target}Bundle(tmpDir.path, bundle)
|
||||
|
||||
const written = readFileSync(path.join(tmpDir.path, ".{target}", "agents", "test.ext"), "utf-8")
|
||||
expect(written).toContain("# Test")
|
||||
})
|
||||
|
||||
it("does not double-nest when output root is .{target}", async () => {
|
||||
const tmpDir = await tmp.dir()
|
||||
const targetDir = path.join(tmpDir.path, ".{target}")
|
||||
await ensureDir(targetDir)
|
||||
|
||||
const bundle: {Target}Bundle = {
|
||||
agents: [{ name: "test", content: "# Test" }],
|
||||
commands: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await write{Target}Bundle(targetDir, bundle)
|
||||
|
||||
// Should write to targetDir directly, not targetDir/.{target}
|
||||
const written = path.join(targetDir, "agents", "test.ext")
|
||||
expect(existsSync(written)).toBe(true)
|
||||
})
|
||||
|
||||
it("backs up existing MCP config", async () => {
|
||||
const tmpDir = await tmp.dir()
|
||||
const mcpPath = path.join(tmpDir.path, ".{target}", "mcp.json")
|
||||
await ensureDir(path.dirname(mcpPath))
|
||||
await writeJson(mcpPath, { existing: true })
|
||||
|
||||
const bundle: {Target}Bundle = {
|
||||
agents: [],
|
||||
commands: [],
|
||||
skillDirs: [],
|
||||
mcpServers: { "test": { command: "test" } },
|
||||
}
|
||||
|
||||
await write{Target}Bundle(tmpDir.path, bundle)
|
||||
|
||||
// Backup should exist
|
||||
const backups = readdirSync(path.dirname(mcpPath)).filter(f => f.includes("mcp") && f.includes("-"))
|
||||
expect(backups.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Key Testing Patterns:**
|
||||
|
||||
- Test normalization, deduplication, content transformation separately
|
||||
- Use inline plugin fixtures (not file-based)
|
||||
- For writer tests, use temp directories and verify file existence
|
||||
- Test edge cases: empty names, empty bodies, special characters
|
||||
- Test error handling: missing files, permission issues
|
||||
|
||||
---
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
**File: `docs/specs/{target}.md`**
|
||||
|
||||
Document the target format specification:
|
||||
|
||||
- Last verified date (link to official docs)
|
||||
- Config file locations (project-level vs. user-level)
|
||||
- Agent/command/skill format with field descriptions
|
||||
- MCP configuration structure
|
||||
- Character limits (if any)
|
||||
- Example file
|
||||
|
||||
**File: `README.md`**
|
||||
|
||||
Add to supported targets list and include usage examples.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls and Solutions
|
||||
|
||||
| Pitfall | Solution |
|
||||
|---------|----------|
|
||||
| **Double-nesting** (`.cursor/.cursor/`) | Check `path.basename(outputRoot)` before nesting |
|
||||
| **Inconsistent name normalization** | Use single `normalizeName()` function everywhere |
|
||||
| **Fragile content transformation** | Test regex patterns against edge cases (file paths, URLs) |
|
||||
| **Heuristic section extraction fails** | Use structural mapping (description → Overview, body → Procedure) instead |
|
||||
| **MCP config overwrites user edits** | Always backup with timestamp before overwriting |
|
||||
| **Skill body not loaded** | Verify `ClaudeSkill` has `skillPath` field for file reading |
|
||||
| **Missing deduplication** | Build `usedNames` set before conversion, pass to each converter |
|
||||
| **Unsupported features cause silent loss** | Always warn to stderr (hooks, incompatible MCP types, etc.) |
|
||||
| **Test isolation failures** | Use unique temp directories per test, clean up afterward |
|
||||
| **Command namespace collisions after flattening** | Use `uniqueName()` with deduplication, test multiple collisions |
|
||||
|
||||
---
|
||||
|
||||
## Checklist for Adding a New Target
|
||||
|
||||
Use this checklist when adding a new target provider:
|
||||
|
||||
### Implementation
|
||||
- [ ] Create `src/types/{target}.ts` with bundle and component types
|
||||
- [ ] Implement `src/converters/claude-to-{target}.ts` with converter and content transformer
|
||||
- [ ] Implement `src/targets/{target}.ts` with writer
|
||||
- [ ] Register target in `src/targets/index.ts`
|
||||
- [ ] Update `src/commands/convert.ts` (add output root resolution, update help text)
|
||||
- [ ] Update `src/commands/install.ts` (same as convert.ts)
|
||||
- [ ] (Optional) Implement `src/sync/{target}.ts` and update `src/commands/sync.ts`
|
||||
|
||||
### Testing
|
||||
- [ ] Create `tests/{target}-converter.test.ts` with converter tests
|
||||
- [ ] Create `tests/{target}-writer.test.ts` with writer tests
|
||||
- [ ] (Optional) Create `tests/sync-{target}.test.ts` with sync tests
|
||||
- [ ] Run full test suite: `bun test`
|
||||
- [ ] Manual test: `bun run src/index.ts convert --to {target} ./plugins/compound-engineering`
|
||||
|
||||
### Documentation
|
||||
- [ ] Create `docs/specs/{target}.md` with format specification
|
||||
- [ ] Update `README.md` with target in list and usage examples
|
||||
- [ ] Update `CHANGELOG.md` with new target
|
||||
|
||||
### Version Bumping
|
||||
- [ ] Bump version in `package.json` (minor for new target)
|
||||
- [ ] Update plugin.json description if component counts changed
|
||||
- [ ] Verify CHANGELOG entry is clear
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Implementation Examples
|
||||
|
||||
**Reference implementations by priority (easiest to hardest):**
|
||||
|
||||
1. **Droid** (`src/targets/droid.ts`, `src/converters/claude-to-droid.ts`) — Simplest pattern, good learning baseline
|
||||
2. **Copilot** (`src/targets/copilot.ts`, `src/converters/claude-to-copilot.ts`) — MCP prefixing, double-nesting guard
|
||||
3. **Devin** (`src/converters/claude-to-devin.ts`) — Content transformation, cross-references, intermediate types
|
||||
4. **OpenCode** (`src/converters/claude-to-opencode.ts`) — Most comprehensive, handles command structure and config merging
|
||||
|
||||
### Key Utilities
|
||||
|
||||
- `src/utils/frontmatter.ts` — `formatFrontmatter()` and `parseFrontmatter()`
|
||||
- `src/utils/files.ts` — `writeText()`, `writeJson()`, `copyDir()`, `backupFile()`, `ensureDir()`
|
||||
- `src/utils/resolve-home.ts` — `expandHome()` for `~/.{target}` path resolution
|
||||
|
||||
### Existing Tests
|
||||
|
||||
- `tests/cursor-converter.test.ts` — Comprehensive converter tests
|
||||
- `tests/copilot-writer.test.ts` — Writer tests with temp directories
|
||||
- `tests/sync-copilot.test.ts` — Sync pattern with symlinks and config merge
|
||||
|
||||
---
|
||||
|
||||
## Related Files
|
||||
|
||||
- `/C:/Source/compound-engineering-plugin/.claude-plugin/plugin.json` — Version and component counts
|
||||
- `/C:/Source/compound-engineering-plugin/CHANGELOG.md` — Recent additions and patterns
|
||||
- `/C:/Source/compound-engineering-plugin/README.md` — Usage examples for all targets
|
||||
- `/C:/Source/compound-engineering-plugin/docs/solutions/plugin-versioning-requirements.md` — Checklist for releases
|
||||
@@ -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`
|
||||
|
||||
122
docs/specs/copilot.md
Normal file
122
docs/specs/copilot.md
Normal file
@@ -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.
|
||||
171
docs/specs/kiro.md
Normal file
171
docs/specs/kiro.md
Normal file
@@ -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/
|
||||
│ ├── <name>.json # Agent configuration
|
||||
│ └── prompts/
|
||||
│ └── <name>.md # Agent prompt files
|
||||
├── skills/
|
||||
│ └── <name>/
|
||||
│ └── SKILL.md # Skill definition
|
||||
├── steering/
|
||||
│ └── <name>.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 |
|
||||
477
docs/specs/windsurf.md
Normal file
477
docs/specs/windsurf.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# Windsurf Editor Global Configuration Guide
|
||||
|
||||
> **Purpose**: Technical reference for programmatically creating and managing Windsurf's global Skills, Workflows, and Rules.
|
||||
>
|
||||
> **Source**: Official Windsurf documentation at [docs.windsurf.com](https://docs.windsurf.com) + local file analysis.
|
||||
>
|
||||
> **Last Updated**: February 2026
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Overview](#overview)
|
||||
2. [Base Directory Structure](#base-directory-structure)
|
||||
3. [Skills](#skills)
|
||||
4. [Workflows](#workflows)
|
||||
5. [Rules](#rules)
|
||||
6. [Memories](#memories)
|
||||
7. [System-Level Configuration (Enterprise)](#system-level-configuration-enterprise)
|
||||
8. [Programmatic Creation Reference](#programmatic-creation-reference)
|
||||
9. [Best Practices](#best-practices)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Windsurf provides three main customization mechanisms:
|
||||
|
||||
| Feature | Purpose | Invocation |
|
||||
|---------|---------|------------|
|
||||
| **Skills** | Complex multi-step tasks with supporting resources | Automatic (progressive disclosure) or `@skill-name` |
|
||||
| **Workflows** | Reusable step-by-step procedures | Slash command `/workflow-name` |
|
||||
| **Rules** | Behavioral guidelines and preferences | Trigger-based (always-on, glob, manual, or model decision) |
|
||||
|
||||
All three support both **workspace-level** (project-specific) and **global** (user-wide) scopes.
|
||||
|
||||
---
|
||||
|
||||
## Base Directory Structure
|
||||
|
||||
### Global Configuration Root
|
||||
|
||||
| OS | Path |
|
||||
|----|------|
|
||||
| **Windows** | `C:\Users\{USERNAME}\.codeium\windsurf\` |
|
||||
| **macOS** | `~/.codeium/windsurf/` |
|
||||
| **Linux** | `~/.codeium/windsurf/` |
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
~/.codeium/windsurf/
|
||||
├── skills/ # Global skills (directories)
|
||||
│ └── {skill-name}/
|
||||
│ └── SKILL.md
|
||||
├── 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
|
||||
<coding_guidelines>
|
||||
- Use early returns when possible
|
||||
- Always add documentation for new functions
|
||||
- Prefer composition over inheritance
|
||||
</coding_guidelines>
|
||||
|
||||
<testing_requirements>
|
||||
- Write unit tests for all public methods
|
||||
- Maintain 80% code coverage
|
||||
</testing_requirements>
|
||||
```
|
||||
|
||||
### Skills vs Rules vs Workflows
|
||||
|
||||
| Use Case | Recommended |
|
||||
|----------|-------------|
|
||||
| Multi-step procedure with supporting files | **Skill** |
|
||||
| Repeatable CLI/automation sequence | **Workflow** |
|
||||
| Coding style preferences | **Rule** |
|
||||
| Project conventions | **Rule** |
|
||||
| Deployment procedure | **Skill** or **Workflow** |
|
||||
| Code review checklist | **Skill** |
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **Official Documentation**: [docs.windsurf.com](https://docs.windsurf.com)
|
||||
- **Skills Specification**: [agentskills.io](https://agentskills.io/home)
|
||||
- **Rule Templates**: [windsurf.com/editor/directory](https://windsurf.com/editor/directory)
|
||||
Reference in New Issue
Block a user