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:
Kieran Klaassen
2026-03-01 15:12:21 -08:00
83 changed files with 9076 additions and 913 deletions

View 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`

View 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)

View 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