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.
|
- **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.
|
- **Safety:** Do not delete or overwrite user data. Avoid destructive commands.
|
||||||
- **Testing:** Run `bun test` after changes that affect parsing, conversion, or output.
|
- **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.
|
- **ASCII-first:** Use ASCII unless the file already contains Unicode.
|
||||||
|
|
||||||
## Adding a New Target Provider (e.g., Codex)
|
## 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.
|
- You can write fixtures + tests that validate the mapping.
|
||||||
|
|
||||||
Avoid adding a provider if the target spec is unstable or undocumented.
|
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
|
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).
|
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.
|
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.
|
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: {
|
permissions: {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "broad",
|
default: "none", // Default is "none" -- writing global permissions to opencode.json pollutes user config. See ADR-003.
|
||||||
description: "Permission mapping: none | broad | from-commands",
|
description: "Permission mapping written to opencode.json: none (default) | broad | from-command",
|
||||||
},
|
},
|
||||||
agentMode: {
|
agentMode: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
} from "../types/claude"
|
} from "../types/claude"
|
||||||
import type {
|
import type {
|
||||||
OpenCodeBundle,
|
OpenCodeBundle,
|
||||||
OpenCodeCommandConfig,
|
OpenCodeCommandFile,
|
||||||
OpenCodeConfig,
|
OpenCodeConfig,
|
||||||
OpenCodeMcpServer,
|
OpenCodeMcpServer,
|
||||||
} from "../types/opencode"
|
} from "../types/opencode"
|
||||||
@@ -66,13 +66,12 @@ export function convertClaudeToOpenCode(
|
|||||||
options: ClaudeToOpenCodeOptions,
|
options: ClaudeToOpenCodeOptions,
|
||||||
): OpenCodeBundle {
|
): OpenCodeBundle {
|
||||||
const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options))
|
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 mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined
|
||||||
const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : []
|
const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : []
|
||||||
|
|
||||||
const config: OpenCodeConfig = {
|
const config: OpenCodeConfig = {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
command: Object.keys(commandMap).length > 0 ? commandMap : undefined,
|
|
||||||
mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined,
|
mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +80,7 @@ export function convertClaudeToOpenCode(
|
|||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
agents: agentFiles,
|
agents: agentFiles,
|
||||||
|
commandFiles: cmdFiles,
|
||||||
plugins,
|
plugins,
|
||||||
skillDirs: plugin.skills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })),
|
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> {
|
// Commands are written as individual .md files rather than entries in opencode.json.
|
||||||
const result: Record<string, OpenCodeCommandConfig> = {}
|
// 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) {
|
for (const command of commands) {
|
||||||
if (command.disableModelInvocation) continue
|
if (command.disableModelInvocation) continue
|
||||||
const entry: OpenCodeCommandConfig = {
|
const frontmatter: Record<string, unknown> = {
|
||||||
description: command.description,
|
description: command.description,
|
||||||
template: rewriteClaudePaths(command.body),
|
|
||||||
}
|
}
|
||||||
if (command.model && command.model !== "inherit") {
|
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> {
|
function convertMcp(servers: Record<string, ClaudeMcpServer>): Record<string, OpenCodeMcpServer> {
|
||||||
|
|||||||
@@ -1,31 +1,93 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { backupFile, copyDir, ensureDir, writeJson, writeText } from "../utils/files"
|
import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files"
|
||||||
import type { OpenCodeBundle } from "../types/opencode"
|
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> {
|
export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise<void> {
|
||||||
const paths = resolveOpenCodePaths(outputRoot)
|
const openCodePaths = resolveOpenCodePaths(outputRoot)
|
||||||
await ensureDir(paths.root)
|
await ensureDir(openCodePaths.root)
|
||||||
|
|
||||||
const backupPath = await backupFile(paths.configPath)
|
const backupPath = await backupFile(openCodePaths.configPath)
|
||||||
if (backupPath) {
|
if (backupPath) {
|
||||||
console.log(`Backed up existing config to ${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) {
|
for (const agent of bundle.agents) {
|
||||||
await writeText(path.join(agentsDir, `${agent.name}.md`), agent.content + "\n")
|
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) {
|
if (bundle.plugins.length > 0) {
|
||||||
const pluginsDir = paths.pluginsDir
|
const pluginsDir = openCodePaths.pluginsDir
|
||||||
for (const plugin of bundle.plugins) {
|
for (const plugin of bundle.plugins) {
|
||||||
await writeText(path.join(pluginsDir, plugin.name), plugin.content + "\n")
|
await writeText(path.join(pluginsDir, plugin.name), plugin.content + "\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bundle.skillDirs.length > 0) {
|
if (bundle.skillDirs.length > 0) {
|
||||||
const skillsRoot = paths.skillsDir
|
const skillsRoot = openCodePaths.skillsDir
|
||||||
for (const skill of bundle.skillDirs) {
|
for (const skill of bundle.skillDirs) {
|
||||||
await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name))
|
await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name))
|
||||||
}
|
}
|
||||||
@@ -43,6 +105,8 @@ function resolveOpenCodePaths(outputRoot: string) {
|
|||||||
agentsDir: path.join(outputRoot, "agents"),
|
agentsDir: path.join(outputRoot, "agents"),
|
||||||
pluginsDir: path.join(outputRoot, "plugins"),
|
pluginsDir: path.join(outputRoot, "plugins"),
|
||||||
skillsDir: path.join(outputRoot, "skills"),
|
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"),
|
agentsDir: path.join(outputRoot, ".opencode", "agents"),
|
||||||
pluginsDir: path.join(outputRoot, ".opencode", "plugins"),
|
pluginsDir: path.join(outputRoot, ".opencode", "plugins"),
|
||||||
skillsDir: path.join(outputRoot, ".opencode", "skills"),
|
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>
|
tools?: Record<string, boolean>
|
||||||
permission?: Record<string, OpenCodePermission | Record<string, OpenCodePermission>>
|
permission?: Record<string, OpenCodePermission | Record<string, OpenCodePermission>>
|
||||||
agent?: Record<string, OpenCodeAgentConfig>
|
agent?: Record<string, OpenCodeAgentConfig>
|
||||||
command?: Record<string, OpenCodeCommandConfig>
|
|
||||||
mcp?: Record<string, OpenCodeMcpServer>
|
mcp?: Record<string, OpenCodeMcpServer>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,13 +19,6 @@ export type OpenCodeAgentConfig = {
|
|||||||
permission?: Record<string, OpenCodePermission>
|
permission?: Record<string, OpenCodePermission>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenCodeCommandConfig = {
|
|
||||||
description?: string
|
|
||||||
model?: string
|
|
||||||
agent?: string
|
|
||||||
template: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OpenCodeMcpServer = {
|
export type OpenCodeMcpServer = {
|
||||||
type: "local" | "remote"
|
type: "local" | "remote"
|
||||||
command?: string[]
|
command?: string[]
|
||||||
@@ -46,9 +38,16 @@ export type OpenCodePluginFile = {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OpenCodeCommandFile = {
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
export type OpenCodeBundle = {
|
export type OpenCodeBundle = {
|
||||||
config: OpenCodeConfig
|
config: OpenCodeConfig
|
||||||
agents: OpenCodeAgentFile[]
|
agents: OpenCodeAgentFile[]
|
||||||
|
// Commands are written as individual .md files, not in opencode.json. See ADR-001.
|
||||||
|
commandFiles: OpenCodeCommandFile[]
|
||||||
plugins: OpenCodePluginFile[]
|
plugins: OpenCodePluginFile[]
|
||||||
skillDirs: { sourceDir: string; name: string }[]
|
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, "prompts", "workflows-review.md"))).toBe(true)
|
||||||
expect(await exists(path.join(piRoot, "extensions", "compound-engineering-compat.ts"))).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")
|
const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin")
|
||||||
|
|
||||||
describe("convertClaudeToOpenCode", () => {
|
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 plugin = await loadClaudePlugin(fixtureRoot)
|
||||||
const bundle = convertClaudeToOpenCode(plugin, {
|
const bundle = convertClaudeToOpenCode(plugin, {
|
||||||
agentMode: "subagent",
|
agentMode: "subagent",
|
||||||
@@ -16,8 +16,9 @@ describe("convertClaudeToOpenCode", () => {
|
|||||||
permissions: "from-commands",
|
permissions: "from-commands",
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(bundle.config.command?.["workflows:review"]).toBeDefined()
|
expect(bundle.config.command).toBeUndefined()
|
||||||
expect(bundle.config.command?.["plan_review"]).toBeDefined()
|
expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined()
|
||||||
|
expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined()
|
||||||
|
|
||||||
const permission = bundle.config.permission as Record<string, string | Record<string, string>>
|
const permission = bundle.config.permission as Record<string, string | Record<string, string>>
|
||||||
expect(Object.keys(permission).sort()).toEqual([
|
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.model).toBe("anthropic/claude-sonnet-4-20250514")
|
||||||
expect(parsed.data.temperature).toBe(0.1)
|
expect(parsed.data.temperature).toBe(0.1)
|
||||||
|
|
||||||
const modelCommand = bundle.config.command?.["workflows:work"]
|
const modelCommand = bundle.commandFiles.find((f) => f.name === "workflows:work")
|
||||||
expect(modelCommand?.model).toBe("openai/gpt-4o")
|
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", () => {
|
test("resolves bare Claude model aliases to full IDs", () => {
|
||||||
@@ -199,7 +202,7 @@ describe("convertClaudeToOpenCode", () => {
|
|||||||
expect(parsed.data.mode).toBe("primary")
|
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 plugin = await loadClaudePlugin(fixtureRoot)
|
||||||
const bundle = convertClaudeToOpenCode(plugin, {
|
const bundle = convertClaudeToOpenCode(plugin, {
|
||||||
agentMode: "subagent",
|
agentMode: "subagent",
|
||||||
@@ -208,10 +211,10 @@ describe("convertClaudeToOpenCode", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// deploy-docs has disable-model-invocation: true, should be excluded
|
// 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
|
// 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", () => {
|
test("rewrites .claude/ paths to .opencode/ in command bodies", () => {
|
||||||
@@ -240,10 +243,11 @@ Run \`/compound-engineering-setup\` to create a settings file.`,
|
|||||||
permissions: "none",
|
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
|
// 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", () => {
|
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
|
// Tool-agnostic path in project root — no rewriting needed
|
||||||
expect(agentFile!.content).toContain("compound-engineering.local.md")
|
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" },
|
config: { $schema: "https://opencode.ai/config.json" },
|
||||||
agents: [{ name: "agent-one", content: "Agent content" }],
|
agents: [{ name: "agent-one", content: "Agent content" }],
|
||||||
plugins: [{ name: "hook.ts", content: "export {}" }],
|
plugins: [{ name: "hook.ts", content: "export {}" }],
|
||||||
|
commandFiles: [],
|
||||||
skillDirs: [
|
skillDirs: [
|
||||||
{
|
{
|
||||||
name: "skill-one",
|
name: "skill-one",
|
||||||
@@ -44,6 +45,7 @@ describe("writeOpenCodeBundle", () => {
|
|||||||
config: { $schema: "https://opencode.ai/config.json" },
|
config: { $schema: "https://opencode.ai/config.json" },
|
||||||
agents: [{ name: "agent-one", content: "Agent content" }],
|
agents: [{ name: "agent-one", content: "Agent content" }],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
commandFiles: [],
|
||||||
skillDirs: [
|
skillDirs: [
|
||||||
{
|
{
|
||||||
name: "skill-one",
|
name: "skill-one",
|
||||||
@@ -68,6 +70,7 @@ describe("writeOpenCodeBundle", () => {
|
|||||||
config: { $schema: "https://opencode.ai/config.json" },
|
config: { $schema: "https://opencode.ai/config.json" },
|
||||||
agents: [{ name: "agent-one", content: "Agent content" }],
|
agents: [{ name: "agent-one", content: "Agent content" }],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
commandFiles: [],
|
||||||
skillDirs: [
|
skillDirs: [
|
||||||
{
|
{
|
||||||
name: "skill-one",
|
name: "skill-one",
|
||||||
@@ -85,28 +88,35 @@ describe("writeOpenCodeBundle", () => {
|
|||||||
expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false)
|
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 tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-backup-"))
|
||||||
const outputRoot = path.join(tempRoot, ".opencode")
|
const outputRoot = path.join(tempRoot, ".opencode")
|
||||||
const configPath = path.join(outputRoot, "opencode.json")
|
const configPath = path.join(outputRoot, "opencode.json")
|
||||||
|
|
||||||
// Create existing config
|
// Create existing config with user keys
|
||||||
await fs.mkdir(outputRoot, { recursive: true })
|
await fs.mkdir(outputRoot, { recursive: true })
|
||||||
const originalConfig = { $schema: "https://opencode.ai/config.json", custom: "value" }
|
const originalConfig = { $schema: "https://opencode.ai/config.json", custom: "value" }
|
||||||
await fs.writeFile(configPath, JSON.stringify(originalConfig, null, 2))
|
await fs.writeFile(configPath, JSON.stringify(originalConfig, null, 2))
|
||||||
|
|
||||||
|
// Bundle adds mcp server but keeps user's custom key
|
||||||
const bundle: OpenCodeBundle = {
|
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: [],
|
agents: [],
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
commandFiles: [],
|
||||||
skillDirs: [],
|
skillDirs: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeOpenCodeBundle(outputRoot, bundle)
|
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"))
|
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
|
// Backup should exist with original content
|
||||||
const files = await fs.readdir(outputRoot)
|
const files = await fs.readdir(outputRoot)
|
||||||
@@ -116,4 +126,131 @@ describe("writeOpenCodeBundle", () => {
|
|||||||
const backupContent = JSON.parse(await fs.readFile(path.join(outputRoot, backupFileName!), "utf8"))
|
const backupContent = JSON.parse(await fs.readFile(path.join(outputRoot, backupFileName!), "utf8"))
|
||||||
expect(backupContent.custom).toBe("value")
|
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