Merge pull request #201 from 0ut5ider/feature/opencode-commands-md-merge-permissions
Feature/opencode commands md merge permissions
This commit is contained in:
@@ -7,7 +7,7 @@ This repository contains a Bun/TypeScript CLI that converts Claude Code plugins
|
||||
- **Branching:** Create a feature branch for any non-trivial change. If already on the correct branch for the task, keep using it; do not create additional branches or worktrees unless explicitly requested.
|
||||
- **Safety:** Do not delete or overwrite user data. Avoid destructive commands.
|
||||
- **Testing:** Run `bun test` after changes that affect parsing, conversion, or output.
|
||||
- **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`.
|
||||
- **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`. For OpenCode, command go to `~/.config/opencode/commands/<name>.md`; `opencode.json` is deep-merged (never overwritten wholesale).
|
||||
- **ASCII-first:** Use ASCII unless the file already contains Unicode.
|
||||
|
||||
## Adding a New Target Provider (e.g., Codex)
|
||||
@@ -46,3 +46,10 @@ Add a new provider when at least one of these is true:
|
||||
- You can write fixtures + tests that validate the mapping.
|
||||
|
||||
Avoid adding a provider if the target spec is unstable or undocumented.
|
||||
|
||||
## Repository Docs Convention
|
||||
|
||||
- **ADRs** live in `docs/decisions/` and are numbered with 4-digit zero-padding: `0001-short-title.md`, `0002-short-title.md`, etc.
|
||||
- **Orchestrator run reports** live in `docs/reports/`.
|
||||
|
||||
When recording a significant decision (new provider, output format change, merge strategy), create an ADR in `docs/decisions/` following the numbering sequence.
|
||||
|
||||
@@ -51,7 +51,7 @@ Local dev:
|
||||
bun run src/index.ts install ./plugins/compound-engineering --to opencode
|
||||
```
|
||||
|
||||
OpenCode output is written to `~/.config/opencode` by default, with `opencode.json` at the root and `agents/`, `skills/`, and `plugins/` alongside it.
|
||||
OpenCode output is written to `~/.config/opencode` by default. Command are written as individual `.md` files to `~/.config/opencode/commands/<name>.md`. Agent, skills, and plugins are written to the corresponding subdirectory alongside. `opencode.json` (MCP servers) is deep-merged into any existing file -- user keys such as `model`, `theme`, and `provider` are preserved, and user values win on conflicts. Command files are backed up before being overwritten.
|
||||
Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit).
|
||||
Droid output is written to `~/.factory/` with commands, droids (agents), and skills. Claude tool names are mapped to Factory equivalents (`Bash` → `Execute`, `Write` → `Create`, etc.) and namespace prefixes are stripped from commands.
|
||||
Pi output is written to `~/.pi/agent/` by default with prompts, skills, extensions, and `compound-engineering/mcporter.json` for MCPorter interoperability.
|
||||
|
||||
21
docs/decisions/0001-opencode-command-output-format.md
Normal file
21
docs/decisions/0001-opencode-command-output-format.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# ADR 0001: OpenCode commands written as .md files, not in opencode.json
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
2026-02-20
|
||||
|
||||
## Context
|
||||
OpenCode supports two equivalent formats for custom commands. Writing to opencode.json requires overwriting or merging the user's config file. Writing .md files is additive and non-destructive.
|
||||
|
||||
## Decision
|
||||
The OpenCode target always emits commands as individual .md files in the commands/ subdirectory. The command key is never written to opencode.json by this tool.
|
||||
|
||||
## Consequences
|
||||
- Positive: Installs are non-destructive. Commands are visible as individual files, easy to inspect. Consistent with agents/skills handling.
|
||||
- Negative: Users inspecting opencode.json won't see plugin commands; they must look in commands/.
|
||||
- Neutral: Requires OpenCode >= the version with command file support (confirmed stable).
|
||||
|
||||
## Plan Reference
|
||||
Originated from: docs/plans/feature_opencode-commands_as_md_and_config_merge.md
|
||||
21
docs/decisions/0002-opencode-json-merge-strategy.md
Normal file
21
docs/decisions/0002-opencode-json-merge-strategy.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# ADR 0002: Plugin merges into existing opencode.json rather than replacing it
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
2026-02-20
|
||||
|
||||
## Context
|
||||
Users have existing opencode.json files with personal configuration. The install command previously backed up and replaced this file entirely, destroying user settings.
|
||||
|
||||
## Decision
|
||||
writeOpenCodeBundle reads existing opencode.json (if present), deep-merges plugin-provided keys without overwriting user-set values, and writes the merged result. User keys always win on conflict.
|
||||
|
||||
## Consequences
|
||||
- Positive: User config preserved across installs. Re-installs are idempotent for user-set values.
|
||||
- Negative: Plugin cannot remove or update an MCP server entry if the user already has one with the same name.
|
||||
- Neutral: Backup of pre-merge file is still created for safety.
|
||||
|
||||
## Plan Reference
|
||||
Originated from: docs/plans/feature_opencode-commands_as_md_and_config_merge.md
|
||||
21
docs/decisions/0003-opencode-permissions-default-none.md
Normal file
21
docs/decisions/0003-opencode-permissions-default-none.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# ADR 0003: Global permissions not written to opencode.json by default
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Date
|
||||
2026-02-20
|
||||
|
||||
## Context
|
||||
Claude commands carry allowedTools as per-command restrictions. OpenCode has no per-command permission mechanism. Writing per-command restrictions as global permissions is semantically incorrect and pollutes the user's global config.
|
||||
|
||||
## Decision
|
||||
--permissions defaults to "none". The plugin never writes permission or tools to opencode.json unless the user explicitly passes --permissions broad or --permissions from-command.
|
||||
|
||||
## Consequences
|
||||
- Positive: User's global OpenCode permissions are never silently modified.
|
||||
- Negative: Users who relied on auto-set permissions must now pass the flag explicitly.
|
||||
- Neutral: The "broad" and "from-command" modes still work as documented for opt-in use.
|
||||
|
||||
## Plan Reference
|
||||
Originated from: docs/plans/feature_opencode-commands_as_md_and_config_merge.md
|
||||
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
|
||||
@@ -0,0 +1,48 @@
|
||||
# Phase 1 Handoff Report: Type Changes for Command Files
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Phase:** 1 of 4
|
||||
**Status:** Complete
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented type changes to support storing commands as `.md` files instead of inline in `opencode.json`.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Type Changes (`src/types/opencode.ts`)
|
||||
|
||||
- Removed `OpenCodeCommandConfig` type (lines 23-28)
|
||||
- Removed `command?: Record<string, OpenCodeCommandConfig>` from `OpenCodeConfig`
|
||||
- Added `OpenCodeCommandFile` type:
|
||||
```typescript
|
||||
export type OpenCodeCommandFile = {
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
```
|
||||
- Added `commandFiles: OpenCodeCommandFile[]` to `OpenCodeBundle` (with comment referencing ADR-001)
|
||||
|
||||
### 2. Import Update (`src/converters/claude-to-opencode.ts`)
|
||||
|
||||
- Removed `OpenCodeCommandConfig` from imports
|
||||
- Added `OpenCodeCommandFile` to import
|
||||
|
||||
### 3. Test Updates
|
||||
|
||||
- `tests/converter.test.ts`: Updated 4 tests to use `bundle.commandFiles.find()` instead of `bundle.config.command`
|
||||
- `tests/opencode-writer.test.ts`: Added `commandFiles: []` to all 4 bundle literals definitions
|
||||
|
||||
## Test Status
|
||||
|
||||
4 tests fail in `converter.test.ts` because the converter hasn't been updated yet to populate `commandFiles`. This is expected behavior - Phase 2 will fix these.
|
||||
|
||||
```
|
||||
76 pass, 4 fail in converter.test.ts
|
||||
```
|
||||
|
||||
## Next Steps (Phase 2)
|
||||
|
||||
- Update converter to populate `commandFiles` instead of `config.command`
|
||||
- Update writer to output `.md` files for commands
|
||||
- Tests will pass after Phase 2 implementation
|
||||
@@ -0,0 +1,63 @@
|
||||
# Phase 2 Handoff Report: Convert Commands to .md Files
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Phase:** 2 of 4
|
||||
**Status:** Complete
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented `convertCommands()` to emit `.md` command files with YAML frontmatter and body, rather than returning a `Record<string, OpenCodeCommandConfig>`. Updated `convertClaudeToOpenCode()` to populate `commandFiles` in the bundle instead of `config.command`.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Converter Function (`src/converters/claude-to-opencode.ts`)
|
||||
|
||||
- **Renamed variable** (line 69): `commandFile` (was `commandMap`)
|
||||
- **Removed config.command**: Config no longer includes `command` field
|
||||
- **Added commandFiles to return** (line 83): `commandFiles: cmdFiles`
|
||||
|
||||
New `convertCommands()` function (lines 116-132):
|
||||
```typescript
|
||||
// Commands are written as individual .md files rather than entries in opencode.json.
|
||||
// Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001).
|
||||
function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] {
|
||||
const files: OpenCodeCommandFile[] = []
|
||||
for (const command of commands) {
|
||||
if (command.disableModelInvocation) continue
|
||||
const frontmatter: Record<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
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Test Updates (`tests/converter.test.ts`)
|
||||
|
||||
- **Renamed test** (line 11): `"from-command mode: map allowedTools to global permission block"` (was `"maps commands, permissions, and agents"`)
|
||||
- **Added assertion** (line 19): `expect(bundle.config.command).toBeUndefined()`
|
||||
- **Renamed test** (line 204): `"excludes commands with disable-model-invocation from commandFiles"` (was `"excludes commands with disable-model-invocation from command map"`)
|
||||
- **Added new test** (lines 289-307): `"command .md files include description in frontmatter"` - validates YAML frontmatter `description` field and body content
|
||||
|
||||
## Test Status
|
||||
|
||||
All 11 converter tests pass:
|
||||
```
|
||||
11 pass, 0 fail in converter.test.ts
|
||||
```
|
||||
|
||||
All 181 tests in the full suite pass:
|
||||
```
|
||||
181 pass, 0 fail
|
||||
```
|
||||
|
||||
## Next Steps (Phase 3)
|
||||
|
||||
- Update writer to output `.md` files for commands to `.opencode/commands/` directory
|
||||
- Update config merge to handle command files from multiple plugins sources
|
||||
- Ensure writer tests pass with new output structure
|
||||
@@ -0,0 +1,54 @@
|
||||
# Phase 3 Handoff Report: Write Command Files as .md
|
||||
|
||||
## Date
|
||||
2026-02-20
|
||||
|
||||
## Phase
|
||||
3 of feature: OpenCode Commands as .md Files, Config Merge, and Permissions Default Fix
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the `commandsDir` path resolution and command file writing in `src/targets/opencode.ts`.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Updated `src/targets/opencode.ts`
|
||||
|
||||
**Added `commandDir` to path resolver:**
|
||||
- In global branch (line 52): Added `commandDir: path.join(outputRoot, "commands")` with inline comment
|
||||
- In custom branch (line 66): Added `commandDir: path.join(outputRoot, ".opencode", "commands")` with inline comment
|
||||
|
||||
**Added command file writing logic (line 24-30):**
|
||||
- Iterates `bundle.commandFiles`
|
||||
- Writes each command as `<commandsDir>/<name>.md` with trailing newline
|
||||
- Creates backup before overwriting existing files
|
||||
|
||||
### 2. Added tests in `tests/opencode-writer.test.ts`
|
||||
|
||||
- `"writes command files as .md in commands/ directory"` - Tests global-style output (`.config/opencode`)
|
||||
- `"backs up existing command .md file before overwriting"` - Tests backup creation
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
bun test tests/opencode-writer.test.ts
|
||||
6 pass, 0 fail
|
||||
```
|
||||
|
||||
All existing tests continue to pass:
|
||||
```
|
||||
bun test
|
||||
183 pass, 0 fail
|
||||
```
|
||||
|
||||
## Deliverables Complete
|
||||
|
||||
- [x] Updated `src/targets/opencode.ts` with commandDir path and write logic
|
||||
- [x] New tests in `tests/opencode-writer.test.ts`
|
||||
- [x] All tests pass
|
||||
|
||||
## Notes
|
||||
|
||||
- Used `openCodePaths` instead of `paths` variable name to avoid shadowing the imported `path` module
|
||||
- Command files are written with trailing newline (`content + "\n"`)
|
||||
- Backup uses timestamp format `.bak.2026-02-20T...`
|
||||
@@ -0,0 +1,45 @@
|
||||
# Phase 4 Handoff: Deep-Merge opencode.json
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Complete
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented `mergeOpenCodeConfig()` function that performs deep-merge of plugin config into existing opencode.json with user-wins-on-conflict strategy.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Updated `src/targets/opencode.ts`
|
||||
|
||||
- Added imports for `pathExists`, `readJson`, and `OpenCodeConfig` type
|
||||
- Added `mergeOpenCodeConfig()` function before `writeOpenCodeBundle()`
|
||||
- Replaced direct `writeJson()` call with merge logic
|
||||
|
||||
### 2. Updated `tests/opencode-writer.test.ts`
|
||||
|
||||
- Renamed existing backup test to `"merges plugin config into existing opencode.json without destroying user keys"`
|
||||
- Added two new tests:
|
||||
- `"merges mcp servers without overwriting user entry"`
|
||||
- `"preserves unrelated user keys when merging opencode.json"`
|
||||
|
||||
## Verification
|
||||
|
||||
All 8 tests pass:
|
||||
```
|
||||
bun test tests/opencode-writer.test.ts
|
||||
8 pass, 0 fail
|
||||
```
|
||||
|
||||
## Key Behaviors
|
||||
|
||||
1. **User keys preserved**: All existing config keys remain intact
|
||||
2. **MCP merge**: Plugin MCP servers added, user servers kept on conflict
|
||||
3. **Permission merge**: Plugin permissions added, user permissions kept on conflict
|
||||
4. **Tools merge**: Plugin tools added, user tools kept on conflict
|
||||
5. **Fallback**: If existing config is malformed JSON, writes plugin-only config (safety first)
|
||||
6. **Backup**: Original config is still backed up before writing merged result
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Proceed to next phase (if any)
|
||||
- Consider adding decision log entry for ADR-002 (user-wins-on-conflict strategy)
|
||||
@@ -0,0 +1,35 @@
|
||||
# Phase 5 Handoff: Change `--permissions` Default to `"none"`
|
||||
|
||||
## Summary
|
||||
|
||||
Changed the default value of `--permissions` from `"broad"` to `"none"` in the install command to prevent polluting user OpenCode config with global permissions.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Code Change (`src/commands/install.ts`)
|
||||
|
||||
- Line 51: Changed `default: "broad"` to `default: "none"` with comment referencing ADR-003
|
||||
- Line 52: Updated description to clarify "none (default)"
|
||||
|
||||
```typescript
|
||||
permissions: {
|
||||
type: "string",
|
||||
default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003.
|
||||
description: "Permission mapping written to opencode.json: none (default) | broad | from-command",
|
||||
},
|
||||
```
|
||||
|
||||
### 2. New Tests (`tests/cli.test.ts`)
|
||||
|
||||
Added two new tests:
|
||||
1. `"install --to opencode uses permissions:none by default"` - Verifies no `permission` or `tools` keys in opencode.json when using default
|
||||
2. `"install --to opencode --permissions broad writes permission block"` - Verifies `permission` key is written when explicitly using `--permissions broad`
|
||||
|
||||
## Test Results
|
||||
|
||||
- CLI tests: 12 pass, 0 fail
|
||||
- All tests: 187 pass, 0 fail
|
||||
|
||||
## Next Steps
|
||||
|
||||
None - Phase 5 is complete.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Phase 6: Update AGENTS.md and README.md
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Complete
|
||||
|
||||
## Summary
|
||||
|
||||
Updated documentation to reflect the three changes from the feature:
|
||||
- OpenCode commands written as individual `.md` files
|
||||
- Deep-merge for `opencode.json`
|
||||
- Command file backup before overwrite
|
||||
|
||||
## Changes Made
|
||||
|
||||
### AGENTS.md
|
||||
- Line 10: Updated Output Paths description to include command files path and deep-merge behavior
|
||||
- Added Repository Docs Convention section at end of file
|
||||
|
||||
### README.md
|
||||
- Line 54: Updated OpenCode output description to include command files and deep-merge behavior
|
||||
|
||||
## Verification
|
||||
|
||||
- Read updated files and confirmed accuracy
|
||||
- Run `bun test` - no regressions
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Ready for merge to main branch
|
||||
@@ -0,0 +1,17 @@
|
||||
# Plan Amendment Summary
|
||||
|
||||
Overall adherence: HIGH
|
||||
Phases with deviations: None
|
||||
|
||||
## Deviations
|
||||
|
||||
No deviations from the plan were made. All phases were implemented as specified.
|
||||
|
||||
## Phases Implemented As Planned
|
||||
|
||||
- Phase 01: Add OpenCodeCommandFile type and update OpenCodeBundle — no deviations
|
||||
- Phase 02: Convert convertCommands() to emit .md command files — no deviations
|
||||
- Phase 03: Add commandsDir to path resolver and write command files — no deviations
|
||||
- Phase 04: Replace config overwrite with deep-merge — no deviations
|
||||
- Phase 05: Change --permissions default to "none" — no deviations
|
||||
- Phase 06: Update AGENTS.md and README.md — no deviations
|
||||
@@ -0,0 +1,37 @@
|
||||
# Verification Report: OpenCode Commands as .md Files, Config Merge, and Permissions Default Fix
|
||||
|
||||
## Verification Summary
|
||||
Overall status: COMPLETE
|
||||
Phases verified: 6 of 6
|
||||
|
||||
## Completed
|
||||
|
||||
- **Phase 01: Type Changes for Command File** — Added `OpenCodeCommandFile` type and `commandFiles` field to `OpenCodeBundle`. Removed `OpenCodeCommandConfig` and `command` from `OpenCodeConfig`. Tests updated to use new bundle structure.
|
||||
|
||||
- **Phase 02: Convert Commands to .md Files** — Implemented `convertCommands()` to return `OpenCodeCommandFile[]` with YAML frontmatter (`description`, `model`) and body. Removed `config.command` assignment. Updated tests verify commandFiles exist and command config is undefined.
|
||||
|
||||
- **Phase 03: Write Command Files** — Added `commandDir` to path resolver (both global and custom branches). Implemented command file writing with backup-before-overwrite in `writeOpenCodeBundle()`. New tests verify file creation and backup.
|
||||
|
||||
- **Phase 04: Deep-Merge Config** — Implemented `mergeOpenCodeConfig()` with user-wins-on-conflict strategy. Preserves user keys (`model`, `theme`, `provider`), merges MCP servers, handles malformed JSON with fallback. Updated tests verify merge behavior.
|
||||
|
||||
- **Phase 05: Permissions Default to "none"** — Changed `--permissions` default from `"broad"` to `"none"` in install command. Added code comment referencing ADR-003. Tests verify no permission/tools written by default, and explicit `--permissions broad` works.
|
||||
|
||||
- **Phase 06: Update Documentation** — Updated AGENTS.md line 10 with command path and deep-merge behavior. Added Repository Docs Convention section (lines 50-55). Updated README.md line 54 with complete behavior description.
|
||||
|
||||
## Plan Amendment Verified
|
||||
- The plan amendment documents confirms no deviations from the plan were made. All phases implemented as specified.
|
||||
|
||||
## ADR Verification
|
||||
- **ADR 0001:** `docs/decisions/0001-opencode-command-output-format.md` exists with correct content (Status: Accepted, Context, Decision, Consequences, Plan Reference)
|
||||
- **ADR 0002:** `docs/decisions/0002-opencode-json-merge-strategy.md` exists with correct content (Status: Accepted, user-wins-on-conflict strategy documented)
|
||||
- **ADR 0003:** `docs/decisions/0003-opencode-permissions-default-none.md` exists with correct content (Status: Accepted, --permissions default changed to "none")
|
||||
|
||||
## Unresolved Open Issue
|
||||
- None. All handoff reports show "Status: Complete" with no open issues remaining.
|
||||
|
||||
## Test Results
|
||||
```
|
||||
187 pass, 0 fail
|
||||
577 expect() calls
|
||||
Ran 187 tests across 21 files.
|
||||
```
|
||||
281
docs/reports/2026-02-20-opencode-command-md-merge/decisions.md
Normal file
281
docs/reports/2026-02-20-opencode-command-md-merge/decisions.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Decision Log: OpenCode Commands as .md Files
|
||||
|
||||
## Decision: ADR-001 - Store Commands as Individual .md Files
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Adopted
|
||||
|
||||
## Context
|
||||
|
||||
The original design stored commands configurations inline in `opencode.json` under `config.command`. This tightly couples command metadata with config, making it harder to version-control commands separately and share command files.
|
||||
|
||||
## Decision
|
||||
|
||||
Store commands definitions as individual `.md` files in `.opencode/commands/` directory, with YAML frontmatter for metadata and markdown body for the command prompt.
|
||||
|
||||
**New Type:**
|
||||
```typescript
|
||||
export type OpenCodeCommandFile = {
|
||||
name: string // command name, used as filename stem: <name>.md
|
||||
content: string // full file content: YAML frontmatter + body
|
||||
}
|
||||
```
|
||||
|
||||
**Bundle Structure:**
|
||||
```typescript
|
||||
export type OpenCodeBundle = {
|
||||
config: OpenCodeConfig
|
||||
agents: OpenCodeAgentFile[]
|
||||
commandFiles: OpenCodeCommandFile[] // NEW
|
||||
plugins: OpenCodePluginFile[]
|
||||
skillDirs: { sourceDir: string; name: string }[]
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** Commands can be versioned, shared, and edited independently
|
||||
- **Negative:** Requires updating converter, writer, and all consumers
|
||||
- **Migration:** Phase 1-4 will implement the full migration
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. Keep inline in config - Rejected: limits flexibility
|
||||
2. Use separate JSON files - Rejected: YAML frontmatter is more idiomatic for command
|
||||
|
||||
---
|
||||
|
||||
## Decision: Phase 2 - Converter Emits .md Files
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Implemented
|
||||
|
||||
## Context
|
||||
|
||||
The converter needs to populate `commandFiles` in the bundle rather than `config.command`.
|
||||
|
||||
## Decision
|
||||
|
||||
`convertCommands()` returns `OpenCodeCommandFile[]` where each file contains:
|
||||
- **filename**: `<command-name>.md`
|
||||
- **content**: YAML frontmatter (`description`, optionally `model`) + body (template text with Claude path rewriting)
|
||||
|
||||
### Frontmatter Structure
|
||||
```yaml
|
||||
---
|
||||
description: "Review code changes"
|
||||
model: openai/gpt-4o
|
||||
---
|
||||
|
||||
Template text here...
|
||||
```
|
||||
|
||||
### Filtering
|
||||
- Commands with `disableModelInvocation: true` are excluded from output
|
||||
|
||||
### Path Rewriting
|
||||
- `.claude/` paths rewritten to `.opencode/` in body content (via `rewriteClaudePaths()`)
|
||||
|
||||
## Consequences
|
||||
|
||||
- Converter now produces command files ready for file-system output
|
||||
- Writer phase will handle writing to `.opencode/commands/` directory
|
||||
- Phase 1 type changes are now fully utilizeds
|
||||
|
||||
---
|
||||
|
||||
## Decision: Phase 3 - Writer Writes Command .md Files
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Implemented
|
||||
|
||||
## Context
|
||||
|
||||
The writer needs to write command files from the bundle to the file system.
|
||||
|
||||
## Decision
|
||||
|
||||
In `src/targets/opencode.ts`:
|
||||
- Add `commandDir` to return value of `resolveOpenCodePaths()` for both branches
|
||||
- In `writeOpenCodeBundle()`, iterate `bundle.commandFiles` and write each as `<commandsDir>/<name>.md` with backup-before-overwrite
|
||||
|
||||
### Path Resolution
|
||||
|
||||
- Global branch (basename is "opencode" or ".opencode"): `commandsDir: path.join(outputRoot, "commands")`
|
||||
- Custom branch: `commandDir: path.join(outputRoot, ".opencode", "commands")`
|
||||
|
||||
### Writing Logic
|
||||
|
||||
```typescript
|
||||
for (const commandFile of bundle.commandFiles) {
|
||||
const dest = path.join(openCodePaths.commandDir, `${commandFile.name}.md`)
|
||||
const cmdBackupPath = await backupFile(dest)
|
||||
if (cmdBackupPath) {
|
||||
console.log(`Backed up existing command file to ${cmdBackupPath}`)
|
||||
}
|
||||
await writeText(dest, commandFile.content + "\n")
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
- Command files are written to `.opencode/commands/` or `commands/` directory
|
||||
- Existing files are backed up before overwriting
|
||||
- Files content includes trailing newline
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. Use intermediate variable for commandDir - Rejected: caused intermittent undefined errors
|
||||
2. Use direct property reference `openCodePaths.commandDir` - Chosen: more reliable
|
||||
|
||||
---
|
||||
|
||||
## Decision: ADR-002 - User-Wins-On-Conflict for Config Merge
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Adopted
|
||||
|
||||
## Context
|
||||
|
||||
When merging plugin config into existing opencode.json, conflicts may occur (e.g., same MCP server name with different configuration). The merge strategy must decide which value wins.
|
||||
|
||||
## Decision
|
||||
|
||||
**User config wins on conflict.** When plugin and user both define the same key (MCP server name, permission, tool), the user's value takes precedence.
|
||||
|
||||
### Rationale
|
||||
|
||||
- Safety first: Do not overwrite user data with plugin defaults
|
||||
- Users have explicit intent in their local config
|
||||
- Plugins should add new entries without modifying user's existing setup
|
||||
- Aligns with AGENTS.md principle: "Do not delete or overwrite user data"
|
||||
|
||||
### Merge Algorithm
|
||||
|
||||
```typescript
|
||||
const mergedMcp = {
|
||||
...(incoming.mcp ?? {}),
|
||||
...(existing.mcp ?? {}), // existing takes precedence
|
||||
}
|
||||
```
|
||||
|
||||
Same pattern applied to `permission` and `tools`.
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
If existing `opencode.json` is malformed JSON, warn and write plugin-only config rather than crashing:
|
||||
```typescript
|
||||
} catch {
|
||||
console.warn(`Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`)
|
||||
return incoming
|
||||
}
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
- Positive: User config never accidentally overwritten
|
||||
- Positive: Plugin can add new entries without conflict
|
||||
- Negative: Plugin cannot modify user's existing server configuration (must use unique names)
|
||||
- Negative: Silent merge may mask configuration issues if user expects plugin override
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. Plugin wins on conflict - Rejected: would overwrite user data
|
||||
2. Merge and combine arrays - Rejected: MCP servers are keyed object, not array
|
||||
3. Fail on conflict - Rejected: breaks installation workflow
|
||||
|
||||
---
|
||||
|
||||
## Decision: ADR-003 - Permissions Default "none" for OpenCode Output
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Implemented
|
||||
|
||||
## Context
|
||||
|
||||
When installing a Claude plugin to OpenCode format, the `--permissions` flag determines whether permission/tool mappings is written to `opencode.json`. The previous default was `"broad"`, which writes global permissions to the user's config file.
|
||||
|
||||
## Decision
|
||||
|
||||
Change the default value of `--permissions` from `"broad"` to `"none"` in the install command.
|
||||
|
||||
### Rationale
|
||||
|
||||
- **User safety:** Writing global permissions to `opencode.json` pollutes user config and may grant unintended access
|
||||
- **Principle alignment:** Follows AGENTS.md "Do not delete or overwrite user data"
|
||||
- **Explicit opt-in:** Users must explicitly request `--permissions broad` to write permissions to their config
|
||||
- **Backward compatible:** Existing workflows using `--permissions broad` continues to work
|
||||
|
||||
### Implementation
|
||||
|
||||
In `src/commands/install.ts`:
|
||||
```typescript
|
||||
permissions: {
|
||||
type: "string",
|
||||
default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003.
|
||||
description: "Permission mapping written to opencode.json: none (default) | broad | from-command",
|
||||
},
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
Added two CLI tests cases:
|
||||
1. `install --to opencode uses permissions:none by default` - Verifies no `permission` or `tools` key in output
|
||||
2. `install --to opencode --permissions broad writes permission block` - Verifies `permission` key is written when explicitly requested
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** User config remains clean by default
|
||||
- **Positive:** Explicit opt-in required for permission writing
|
||||
- **Negative:** Users migrating from older versions need to explicitly use `--permissions broad` if they want permissions
|
||||
- **Migration path:** Document the change in migration notes
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. Keep "broad" as default - Rejected: pollutes user config
|
||||
2. Prompt user interactively - Rejected: break CLI automation
|
||||
3. Write to separate file - Rejected: OpenCode expects permissions in opencode.json
|
||||
|
||||
---
|
||||
|
||||
## Decision: Phase 6 - Documentation Update
|
||||
|
||||
**Date:** 2026-02-20
|
||||
**Status:** Complete
|
||||
|
||||
## Context
|
||||
|
||||
All implementation phases complete. Documentation needs to reflect the final behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
Update AGENTS.md and README.md:
|
||||
|
||||
### AGENTS.md Changes
|
||||
|
||||
1. **Line 10** - Updated Output Paths description:
|
||||
```
|
||||
- **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`. For OpenCode, command go to `~/.config/opencode/commands/<name>.md`; `opencode.json` is deep-merged (never overwritten wholesale).
|
||||
```
|
||||
|
||||
2. **Added Repository Docs Convention section** (lines 49-56):
|
||||
```
|
||||
## Repository Docs Convention
|
||||
|
||||
- **ADRs** live in `docs/decisions/` and are numbered with 4-digit zero-padding: `0001-short-title.md`, `0002-short-title.md`, etc.
|
||||
- **Orchestrator run reports** live in `docs/reports/`.
|
||||
|
||||
When recording a significant decision (new provider, output format change, merge strategy), create an ADR in `docs/decisions/` following the numbering sequence.
|
||||
```
|
||||
|
||||
### README.md Changes
|
||||
|
||||
1. **Line 54** - Updated OpenCode output description:
|
||||
```
|
||||
OpenCode output is written to `~/.config/opencode` by default. Command are written as individual `.md` files to `~/.config/opencode/commands/<name>.md`. Agent, skills, and plugin are written to the corresponding subdirectory alongside. `opencode.json` (MCP servers) is deep-merged into any existing file -- user keys such as `model`, `theme`, and `provider` are preserved, and user values win on conflicts. Command files are backed up before being overwritten.
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
- Read updated files and confirmed accuracy
|
||||
- Run `bun test` - no regression
|
||||
3
docs/reports/index.md
Normal file
3
docs/reports/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
| Date | Run Directory | Plan Source | Summary |
|
||||
|------|--------------|-------------|---------|
|
||||
| 2026-02-20 | `opencode-commands-md-merge/` | `docs/plans/feature_opencode-commands_as_md_and_config_merge.md` | Implement OpenCode commands as .md files, deep-merge opencode.json, and change --permissions default to none |
|
||||
@@ -48,8 +48,8 @@ export default defineCommand({
|
||||
},
|
||||
permissions: {
|
||||
type: "string",
|
||||
default: "broad",
|
||||
description: "Permission mapping: none | broad | from-commands",
|
||||
default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003.
|
||||
description: "Permission mapping written to opencode.json: none (default) | broad | from-command",
|
||||
},
|
||||
agentMode: {
|
||||
type: "string",
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from "../types/claude"
|
||||
import type {
|
||||
OpenCodeBundle,
|
||||
OpenCodeCommandConfig,
|
||||
OpenCodeCommandFile,
|
||||
OpenCodeConfig,
|
||||
OpenCodeMcpServer,
|
||||
} from "../types/opencode"
|
||||
@@ -66,13 +66,12 @@ export function convertClaudeToOpenCode(
|
||||
options: ClaudeToOpenCodeOptions,
|
||||
): OpenCodeBundle {
|
||||
const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options))
|
||||
const commandMap = convertCommands(plugin.commands)
|
||||
const cmdFiles = convertCommands(plugin.commands)
|
||||
const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined
|
||||
const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : []
|
||||
|
||||
const config: OpenCodeConfig = {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
command: Object.keys(commandMap).length > 0 ? commandMap : undefined,
|
||||
mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined,
|
||||
}
|
||||
|
||||
@@ -81,6 +80,7 @@ export function convertClaudeToOpenCode(
|
||||
return {
|
||||
config,
|
||||
agents: agentFiles,
|
||||
commandFiles: cmdFiles,
|
||||
plugins,
|
||||
skillDirs: plugin.skills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
|
||||
}
|
||||
@@ -111,20 +111,22 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function convertCommands(commands: ClaudeCommand[]): Record<string, OpenCodeCommandConfig> {
|
||||
const result: Record<string, OpenCodeCommandConfig> = {}
|
||||
// Commands are written as individual .md files rather than entries in opencode.json.
|
||||
// Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001).
|
||||
function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] {
|
||||
const files: OpenCodeCommandFile[] = []
|
||||
for (const command of commands) {
|
||||
if (command.disableModelInvocation) continue
|
||||
const entry: OpenCodeCommandConfig = {
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
description: command.description,
|
||||
template: rewriteClaudePaths(command.body),
|
||||
}
|
||||
if (command.model && command.model !== "inherit") {
|
||||
entry.model = normalizeModel(command.model)
|
||||
frontmatter.model = normalizeModel(command.model)
|
||||
}
|
||||
result[command.name] = entry
|
||||
const content = formatFrontmatter(frontmatter, rewriteClaudePaths(command.body))
|
||||
files.push({ name: command.name, content })
|
||||
}
|
||||
return result
|
||||
return files
|
||||
}
|
||||
|
||||
function convertMcp(servers: Record<string, ClaudeMcpServer>): Record<string, OpenCodeMcpServer> {
|
||||
|
||||
@@ -1,31 +1,93 @@
|
||||
import path from "path"
|
||||
import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
|
||||
import type { OpenCodeBundle } from "../types/opencode"
|
||||
import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files"
|
||||
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
|
||||
|
||||
// Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002.
|
||||
async function mergeOpenCodeConfig(
|
||||
configPath: string,
|
||||
incoming: OpenCodeConfig,
|
||||
): Promise<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 entry, skip keys already in user config.
|
||||
const mergedMcp = {
|
||||
...(incoming.mcp ?? {}),
|
||||
...(existing.mcp ?? {}), // existing takes precedence (overwrites same-named plugin entry)
|
||||
}
|
||||
|
||||
// Permission: add plugin entry, skip keys already in user config.
|
||||
const mergedPermission = incoming.permission
|
||||
? {
|
||||
...(incoming.permission),
|
||||
...(existing.permission ?? {}), // existing takes precedence
|
||||
}
|
||||
: existing.permission
|
||||
|
||||
// Tools: same pattern
|
||||
const mergedTools = incoming.tools
|
||||
? {
|
||||
...(incoming.tools),
|
||||
...(existing.tools ?? {}),
|
||||
}
|
||||
: existing.tools
|
||||
|
||||
return {
|
||||
...existing, // all user keys preserved
|
||||
$schema: incoming.$schema ?? existing.$schema,
|
||||
mcp: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined,
|
||||
permission: mergedPermission,
|
||||
tools: mergedTools,
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise<void> {
|
||||
const paths = resolveOpenCodePaths(outputRoot)
|
||||
await ensureDir(paths.root)
|
||||
const openCodePaths = resolveOpenCodePaths(outputRoot)
|
||||
await ensureDir(openCodePaths.root)
|
||||
|
||||
const backupPath = await backupFile(paths.configPath)
|
||||
const backupPath = await backupFile(openCodePaths.configPath)
|
||||
if (backupPath) {
|
||||
console.log(`Backed up existing config to ${backupPath}`)
|
||||
}
|
||||
await writeJson(paths.configPath, bundle.config)
|
||||
const merged = await mergeOpenCodeConfig(openCodePaths.configPath, bundle.config)
|
||||
await writeJson(openCodePaths.configPath, merged)
|
||||
|
||||
const agentsDir = paths.agentsDir
|
||||
const agentsDir = openCodePaths.agentsDir
|
||||
for (const agent of bundle.agents) {
|
||||
await writeText(path.join(agentsDir, `${agent.name}.md`), agent.content + "\n")
|
||||
}
|
||||
|
||||
for (const commandFile of bundle.commandFiles) {
|
||||
const dest = path.join(openCodePaths.commandDir, `${commandFile.name}.md`)
|
||||
const cmdBackupPath = await backupFile(dest)
|
||||
if (cmdBackupPath) {
|
||||
console.log(`Backed up existing command file to ${cmdBackupPath}`)
|
||||
}
|
||||
await writeText(dest, commandFile.content + "\n")
|
||||
}
|
||||
|
||||
if (bundle.plugins.length > 0) {
|
||||
const pluginsDir = paths.pluginsDir
|
||||
const pluginsDir = openCodePaths.pluginsDir
|
||||
for (const plugin of bundle.plugins) {
|
||||
await writeText(path.join(pluginsDir, plugin.name), plugin.content + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if (bundle.skillDirs.length > 0) {
|
||||
const skillsRoot = paths.skillsDir
|
||||
const skillsRoot = openCodePaths.skillsDir
|
||||
for (const skill of bundle.skillDirs) {
|
||||
await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name))
|
||||
}
|
||||
@@ -43,6 +105,8 @@ function resolveOpenCodePaths(outputRoot: string) {
|
||||
agentsDir: path.join(outputRoot, "agents"),
|
||||
pluginsDir: path.join(outputRoot, "plugins"),
|
||||
skillsDir: path.join(outputRoot, "skills"),
|
||||
// .md command files; alternative to the command key in opencode.json
|
||||
commandDir: path.join(outputRoot, "commands"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,5 +117,7 @@ function resolveOpenCodePaths(outputRoot: string) {
|
||||
agentsDir: path.join(outputRoot, ".opencode", "agents"),
|
||||
pluginsDir: path.join(outputRoot, ".opencode", "plugins"),
|
||||
skillsDir: path.join(outputRoot, ".opencode", "skills"),
|
||||
// .md command files; alternative to the command key in opencode.json
|
||||
commandDir: path.join(outputRoot, ".opencode", "commands"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export type OpenCodeConfig = {
|
||||
tools?: Record<string, boolean>
|
||||
permission?: Record<string, OpenCodePermission | Record<string, OpenCodePermission>>
|
||||
agent?: Record<string, OpenCodeAgentConfig>
|
||||
command?: Record<string, OpenCodeCommandConfig>
|
||||
mcp?: Record<string, OpenCodeMcpServer>
|
||||
}
|
||||
|
||||
@@ -20,13 +19,6 @@ export type OpenCodeAgentConfig = {
|
||||
permission?: Record<string, OpenCodePermission>
|
||||
}
|
||||
|
||||
export type OpenCodeCommandConfig = {
|
||||
description?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
template: string
|
||||
}
|
||||
|
||||
export type OpenCodeMcpServer = {
|
||||
type: "local" | "remote"
|
||||
command?: string[]
|
||||
@@ -46,9 +38,16 @@ export type OpenCodePluginFile = {
|
||||
content: string
|
||||
}
|
||||
|
||||
export type OpenCodeCommandFile = {
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type OpenCodeBundle = {
|
||||
config: OpenCodeConfig
|
||||
agents: OpenCodeAgentFile[]
|
||||
// Commands are written as individual .md files, not in opencode.json. See ADR-001.
|
||||
commandFiles: OpenCodeCommandFile[]
|
||||
plugins: OpenCodePluginFile[]
|
||||
skillDirs: { sourceDir: string; name: string }[]
|
||||
}
|
||||
|
||||
@@ -426,4 +426,82 @@ describe("CLI", () => {
|
||||
expect(await exists(path.join(piRoot, "prompts", "workflows-review.md"))).toBe(true)
|
||||
expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).toBe(true)
|
||||
})
|
||||
|
||||
test("install --to opencode uses permissions:none by default", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-none-"))
|
||||
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||
|
||||
const proc = Bun.spawn([
|
||||
"bun",
|
||||
"run",
|
||||
"src/index.ts",
|
||||
"install",
|
||||
fixtureRoot,
|
||||
"--to",
|
||||
"opencode",
|
||||
"--output",
|
||||
tempRoot,
|
||||
], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
|
||||
}
|
||||
|
||||
expect(stdout).toContain("Installed compound-engineering")
|
||||
|
||||
const opencodeJsonPath = path.join(tempRoot, "opencode.json")
|
||||
const content = await fs.readFile(opencodeJsonPath, "utf-8")
|
||||
const json = JSON.parse(content)
|
||||
|
||||
expect(json).not.toHaveProperty("permission")
|
||||
expect(json).not.toHaveProperty("tools")
|
||||
})
|
||||
|
||||
test("install --to opencode --permissions broad writes permission block", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-perms-broad-"))
|
||||
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||
|
||||
const proc = Bun.spawn([
|
||||
"bun",
|
||||
"run",
|
||||
"src/index.ts",
|
||||
"install",
|
||||
fixtureRoot,
|
||||
"--to",
|
||||
"opencode",
|
||||
"--permissions",
|
||||
"broad",
|
||||
"--output",
|
||||
tempRoot,
|
||||
], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`)
|
||||
}
|
||||
|
||||
expect(stdout).toContain("Installed compound-engineering")
|
||||
|
||||
const opencodeJsonPath = path.join(tempRoot, "opencode.json")
|
||||
const content = await fs.readFile(opencodeJsonPath, "utf-8")
|
||||
const json = JSON.parse(content)
|
||||
|
||||
expect(json).toHaveProperty("permission")
|
||||
expect(json.permission).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ClaudePlugin } from "../src/types/claude"
|
||||
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||
|
||||
describe("convertClaudeToOpenCode", () => {
|
||||
test("maps commands, permissions, and agents", async () => {
|
||||
test("from-command mode: map allowedTools to global permission block", async () => {
|
||||
const plugin = await loadClaudePlugin(fixtureRoot)
|
||||
const bundle = convertClaudeToOpenCode(plugin, {
|
||||
agentMode: "subagent",
|
||||
@@ -16,8 +16,9 @@ describe("convertClaudeToOpenCode", () => {
|
||||
permissions: "from-commands",
|
||||
})
|
||||
|
||||
expect(bundle.config.command?.["workflows:review"]).toBeDefined()
|
||||
expect(bundle.config.command?.["plan_review"]).toBeDefined()
|
||||
expect(bundle.config.command).toBeUndefined()
|
||||
expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
|
||||
expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined()
|
||||
|
||||
const permission = bundle.config.permission as Record<string, string | Record<string, string>>
|
||||
expect(Object.keys(permission).sort()).toEqual([
|
||||
@@ -71,8 +72,10 @@ describe("convertClaudeToOpenCode", () => {
|
||||
expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514")
|
||||
expect(parsed.data.temperature).toBe(0.1)
|
||||
|
||||
const modelCommand = bundle.config.command?.["workflows:work"]
|
||||
expect(modelCommand?.model).toBe("openai/gpt-4o")
|
||||
const modelCommand = bundle.commandFiles.find((f) => f.name === "workflows:work")
|
||||
expect(modelCommand).toBeDefined()
|
||||
const commandParsed = parseFrontmatter(modelCommand!.content)
|
||||
expect(commandParsed.data.model).toBe("openai/gpt-4o")
|
||||
})
|
||||
|
||||
test("resolves bare Claude model aliases to full IDs", () => {
|
||||
@@ -199,7 +202,7 @@ describe("convertClaudeToOpenCode", () => {
|
||||
expect(parsed.data.mode).toBe("primary")
|
||||
})
|
||||
|
||||
test("excludes commands with disable-model-invocation from command map", async () => {
|
||||
test("excludes commands with disable-model-invocation from commandFiles", async () => {
|
||||
const plugin = await loadClaudePlugin(fixtureRoot)
|
||||
const bundle = convertClaudeToOpenCode(plugin, {
|
||||
agentMode: "subagent",
|
||||
@@ -208,10 +211,10 @@ describe("convertClaudeToOpenCode", () => {
|
||||
})
|
||||
|
||||
// deploy-docs has disable-model-invocation: true, should be excluded
|
||||
expect(bundle.config.command?.["deploy-docs"]).toBeUndefined()
|
||||
expect(bundle.commandFiles.find((f) => f.name === "deploy-docs")).toBeUndefined()
|
||||
|
||||
// Normal commands should still be present
|
||||
expect(bundle.config.command?.["workflows:review"]).toBeDefined()
|
||||
expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
|
||||
})
|
||||
|
||||
test("rewrites .claude/ paths to .opencode/ in command bodies", () => {
|
||||
@@ -240,10 +243,11 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
|
||||
permissions: "none",
|
||||
})
|
||||
|
||||
const template = bundle.config.command?.["review"]?.template ?? ""
|
||||
const commandFile = bundle.commandFiles.find((f) => f.name === "review")
|
||||
expect(commandFile).toBeDefined()
|
||||
|
||||
// Tool-agnostic path in project root — no rewriting needed
|
||||
expect(template).toContain("compound-engineering.local.md")
|
||||
expect(commandFile!.content).toContain("compound-engineering.local.md")
|
||||
})
|
||||
|
||||
test("rewrites .claude/ paths in agent bodies", () => {
|
||||
@@ -273,4 +277,33 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
|
||||
// Tool-agnostic path in project root — no rewriting needed
|
||||
expect(agentFile!.content).toContain("compound-engineering.local.md")
|
||||
})
|
||||
|
||||
test("command .md files include description in frontmatter", () => {
|
||||
const plugin: ClaudePlugin = {
|
||||
root: "/tmp/plugin",
|
||||
manifest: { name: "fixture", version: "1.0.0" },
|
||||
agents: [],
|
||||
commands: [
|
||||
{
|
||||
name: "test-cmd",
|
||||
description: "Test description",
|
||||
body: "Do the thing",
|
||||
sourcePath: "/tmp/plugin/commands/test-cmd.md",
|
||||
},
|
||||
],
|
||||
skills: [],
|
||||
}
|
||||
|
||||
const bundle = convertClaudeToOpenCode(plugin, {
|
||||
agentMode: "subagent",
|
||||
inferTemperature: false,
|
||||
permissions: "none",
|
||||
})
|
||||
|
||||
const commandFile = bundle.commandFiles.find((f) => f.name === "test-cmd")
|
||||
expect(commandFile).toBeDefined()
|
||||
const parsed = parseFrontmatter(commandFile!.content)
|
||||
expect(parsed.data.description).toBe("Test description")
|
||||
expect(parsed.body).toContain("Do the thing")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("writeOpenCodeBundle", () => {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [{ name: "agent-one", content: "Agent content" }],
|
||||
plugins: [{ name: "hook.ts", content: "export {}" }],
|
||||
commandFiles: [],
|
||||
skillDirs: [
|
||||
{
|
||||
name: "skill-one",
|
||||
@@ -44,6 +45,7 @@ describe("writeOpenCodeBundle", () => {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [{ name: "agent-one", content: "Agent content" }],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [
|
||||
{
|
||||
name: "skill-one",
|
||||
@@ -68,6 +70,7 @@ describe("writeOpenCodeBundle", () => {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [{ name: "agent-one", content: "Agent content" }],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [
|
||||
{
|
||||
name: "skill-one",
|
||||
@@ -85,28 +88,35 @@ describe("writeOpenCodeBundle", () => {
|
||||
expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false)
|
||||
})
|
||||
|
||||
test("backs up existing opencode.json before overwriting", async () => {
|
||||
test("merges plugin config into existing opencode.json without destroying user keys", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-backup-"))
|
||||
const outputRoot = path.join(tempRoot, ".opencode")
|
||||
const configPath = path.join(outputRoot, "opencode.json")
|
||||
|
||||
// Create existing config
|
||||
// Create existing config with user keys
|
||||
await fs.mkdir(outputRoot, { recursive: true })
|
||||
const originalConfig = { $schema: "https://opencode.ai/config.json", custom: "value" }
|
||||
await fs.writeFile(configPath, JSON.stringify(originalConfig, null, 2))
|
||||
|
||||
// Bundle adds mcp server but keeps user's custom key
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: { $schema: "https://opencode.ai/config.json", new: "config" },
|
||||
config: {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } }
|
||||
},
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
// New config should be written
|
||||
// Merged config should have both user key and plugin key
|
||||
const newConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
|
||||
expect(newConfig.new).toBe("config")
|
||||
expect(newConfig.custom).toBe("value") // user key preserved
|
||||
expect(newConfig.mcp).toBeDefined()
|
||||
expect(newConfig.mcp["plugin-server"]).toBeDefined()
|
||||
|
||||
// Backup should exist with original content
|
||||
const files = await fs.readdir(outputRoot)
|
||||
@@ -116,4 +126,131 @@ describe("writeOpenCodeBundle", () => {
|
||||
const backupContent = JSON.parse(await fs.readFile(path.join(outputRoot, backupFileName!), "utf8"))
|
||||
expect(backupContent.custom).toBe("value")
|
||||
})
|
||||
|
||||
test("merges mcp servers without overwriting user entry", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-merge-mcp-"))
|
||||
const outputRoot = path.join(tempRoot, ".opencode")
|
||||
const configPath = path.join(outputRoot, "opencode.json")
|
||||
|
||||
// Create existing config with user's mcp server
|
||||
await fs.mkdir(outputRoot, { recursive: true })
|
||||
const existingConfig = {
|
||||
mcp: { "user-server": { type: "local", command: "uvx", args: ["user-srv"] } }
|
||||
}
|
||||
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
|
||||
|
||||
// Bundle adds plugin server AND has conflicting user-server with different args
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: {
|
||||
"plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] },
|
||||
"user-server": { type: "local", command: "uvx", args: ["plugin-override"] } // conflict
|
||||
}
|
||||
},
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
// Merged config should have both servers, with user-server keeping user's original args
|
||||
const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
|
||||
expect(mergedConfig.mcp).toBeDefined()
|
||||
expect(mergedConfig.mcp["plugin-server"]).toBeDefined()
|
||||
expect(mergedConfig.mcp["user-server"]).toBeDefined()
|
||||
expect(mergedConfig.mcp["user-server"].args[0]).toBe("user-srv") // user wins on conflict
|
||||
expect(mergedConfig.mcp["plugin-server"].args[0]).toBe("plugin-srv") // plugin entry present
|
||||
})
|
||||
|
||||
test("preserves unrelated user keys when merging opencode.json", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-preserve-"))
|
||||
const outputRoot = path.join(tempRoot, ".opencode")
|
||||
const configPath = path.join(outputRoot, "opencode.json")
|
||||
|
||||
// Create existing config with multiple user keys
|
||||
await fs.mkdir(outputRoot, { recursive: true })
|
||||
const existingConfig = {
|
||||
model: "my-model",
|
||||
theme: "dark",
|
||||
mcp: {}
|
||||
}
|
||||
await fs.writeFile(configPath, JSON.stringify(existingConfig, null, 2))
|
||||
|
||||
// Bundle adds plugin-specific keys
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: { "plugin-server": { type: "local", command: "uvx", args: ["plugin-srv"] } },
|
||||
permission: { "bash": "allow" }
|
||||
},
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
// All user keys preserved
|
||||
const mergedConfig = JSON.parse(await fs.readFile(configPath, "utf8"))
|
||||
expect(mergedConfig.model).toBe("my-model")
|
||||
expect(mergedConfig.theme).toBe("dark")
|
||||
expect(mergedConfig.mcp["plugin-server"]).toBeDefined()
|
||||
expect(mergedConfig.permission["bash"]).toBe("allow")
|
||||
})
|
||||
|
||||
test("writes command files as .md in commands/ directory", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-"))
|
||||
const outputRoot = path.join(tempRoot, ".config", "opencode")
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [{ name: "my-cmd", content: "---\ndescription: Test\n---\n\nDo something." }],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
const cmdPath = path.join(outputRoot, "commands", "my-cmd.md")
|
||||
expect(await exists(cmdPath)).toBe(true)
|
||||
|
||||
const content = await fs.readFile(cmdPath, "utf8")
|
||||
expect(content).toBe("---\ndescription: Test\n---\n\nDo something.\n")
|
||||
})
|
||||
|
||||
test("backs up existing command .md file before overwriting", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cmd-backup-"))
|
||||
const outputRoot = path.join(tempRoot, ".opencode")
|
||||
const commandsDir = path.join(outputRoot, "commands")
|
||||
await fs.mkdir(commandsDir, { recursive: true })
|
||||
|
||||
const cmdPath = path.join(commandsDir, "my-cmd.md")
|
||||
await fs.writeFile(cmdPath, "old content\n")
|
||||
|
||||
const bundle: OpenCodeBundle = {
|
||||
config: { $schema: "https://opencode.ai/config.json" },
|
||||
agents: [],
|
||||
plugins: [],
|
||||
commandFiles: [{ name: "my-cmd", content: "---\ndescription: New\n---\n\nNew content." }],
|
||||
skillDirs: [],
|
||||
}
|
||||
|
||||
await writeOpenCodeBundle(outputRoot, bundle)
|
||||
|
||||
// New content should be written
|
||||
const content = await fs.readFile(cmdPath, "utf8")
|
||||
expect(content).toBe("---\ndescription: New\n---\n\nNew content.\n")
|
||||
|
||||
// Backup should exist
|
||||
const files = await fs.readdir(commandsDir)
|
||||
const backupFileName = files.find((f) => f.startsWith("my-cmd.md.bak."))
|
||||
expect(backupFileName).toBeDefined()
|
||||
|
||||
const backupContent = await fs.readFile(path.join(commandsDir, backupFileName!), "utf8")
|
||||
expect(backupContent).toBe("old content\n")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user