35 KiB
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:
-
Commands overwrite
opencode.json: Plugin commands are written into thecommandkey ofopencode.json, which replaces the user's existing configuration file (the writer doeswriteJson(configPath, bundle.config)— a full overwrite). The user loses their personal settings (model, theme, provider keys, MCP servers they previously configured). -
Commands should be
.mdfiles, not JSON: OpenCode supports defining commands as individual.mdfiles in~/.config/opencode/commands/. This is additive and non-destructive — one file per command, never touchesopencode.json. -
--permissions broadis the default and pollutes global config: The--permissionsflag defaults to"broad", which writes 14permission: allowentries and 14tools: trueentries intoopencode.jsonon every install. These are global settings that affect ALL OpenCode sessions, not just plugin commands. Even--permissions from-commandsis semantically wrong — it unions per-commandallowedToolsrestrictions into a single global block, which inverts restriction semantics (a command allowing onlyReadgets merged with one allowingBash, producing globalbash: allow).
Expected Behavior After This Plan
- Commands are written as
~/.config/opencode/commands/<name>.mdwith YAML frontmatter (description,model). Thecommandkey is never written toopencode.json. opencode.jsonis deep-merged (not overwritten): existing user keys survive, plugin's MCP servers are added. User values win on conflict.--permissionsdefaults to"none"— nopermissionortoolsentries are written toopencode.jsonunless the user explicitly passes--permissions broador--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 onmain: 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.mddiscusses path rewriting in command bodies but does not touch command output format or permissions. - OpenCode docs (confirmed via context7 MCP, library
/sst/opencode):- Command
.mdfrontmatter supports:description,agent,model. Does NOT supportpermissionortools. Placed in~/.config/opencode/commands/(global) or.opencode/commands/(project). - Agent
.mdfrontmatter supports:description,mode,model,temperature,tools,permission. Placed in~/.config/opencode/agents/or.opencode/agents/. opencode.jsonis the only place for:mcp, globalpermission, globaltools,model,provider,theme,server,compaction,watcher,share.
- Command
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()returnsOpenCodeCommandFile[](one.mdfile per command with YAML frontmatter). Thecommandkey is never set onOpenCodeConfig. The writer creates<commandsDir>/<name>.mdfor each file. - Context: OpenCode supports two equivalent formats for commands — JSON in config and
.mdfiles. The.mdformat is additive (new files) rather than destructive (rewriting JSON). This is consistent with how agents and skills are already handled as.mdfiles. - 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
.mdcommand files or the format changes incompatibly.
Decision 2: opencode.json deep-merged, not overwritten
- Decision:
writeOpenCodeBundle()reads the existingopencode.json(if present), deep-merges plugin-provided keys (MCP servers, and optionally permission/tools if--permissionsis notnone) 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.jsonentirely — rejected because MCP servers must be written there (no.mdalternative exists for MCP). - Assumptions:
readJson()andpathExists()already exist insrc/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
--permissionsCLI flag default changes from"broad"to"none". Nopermissionortoolskeys are written toopencode.jsonunless 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-commandsflattening — impossible to do correctly with global-only target. Remove flag entirely — too restrictive for power users. - Assumptions: The
applyPermissions()function with mode"none"leavesconfig.permissionandconfig.toolsasundefined. - Reversal trigger: If OpenCode adds per-command permission scoping,
from-commandscould 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.jsonrequires overwriting or merging the user's config file. Writing.mdfiles is additive and non-destructive. - Decision: The OpenCode target always emits commands as individual
.mdfiles in thecommands/subdirectory. Thecommandkey is never written toopencode.jsonby 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.jsonwon't see plugin commands; they must look incommands/. - 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.jsonfiles with personal configuration. The install command previously backed up and replaced this file entirely, destroying user settings. - Decision:
writeOpenCodeBundlereads existingopencode.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
allowedToolsas 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:
--permissionsdefaults to"none". The plugin never writespermissionortoolstoopencode.jsonunless the user explicitly passes--permissions broador--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
.mdfrontmatter supportsdescription,agent,modeland does NOT supportpermissionortools.- If this changes: The converter could emit per-command permissions in command frontmatter, making
from-commandsmode semantically correct. Phase 2 would need a new code path.
- If this changes: The converter could emit per-command permissions in command frontmatter, making
-
Assumption:
readJson()andpathExists()exist insrc/utils/files.tsand 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 setconfig.permissionorconfig.tools.- If this changes: The merge logic in Phase 4 might still merge stale data. Verify before implementing.
-
Assumption: 180 tests pass on
mainat commit174cd4cwithbun test.- If this changes: Do not proceed until the discrepancy is understood.
-
Assumption:
formatFrontmatter()insrc/utils/frontmatter.tshandlesRecord<string, unknown>data and string body, producing valid YAML frontmatter. It filters outundefinedvalues (line 35). It already supports nested objects/arrays viaformatYamlLine().- If this changes: Phase 2's command file content generation would produce malformed output.
-
Assumption: The
backupFile()function insrc/utils/files.tsreturnsnullif 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
OpenCodeCommandFilewithname: string(command name, used as filename stem) andcontent: string(full file content: YAML frontmatter + body). - Add
commandFiles: OpenCodeCommandFile[]field toOpenCodeBundle. - Remove
command?: Record<string, OpenCodeCommandConfig>fromOpenCodeConfig. - Remove the
OpenCodeCommandConfigtype 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): Changebundle.config.command?.["workflows:review"]tobundle.commandFiles.find(f => f.name === "workflows:review"). Changebundle.config.command?.["plan_review"]tobundle.commandFiles.find(f => f.name === "plan_review")."normalizes models and infers temperature"(line 60): Changebundle.config.command?.["workflows:work"]to checkbundle.commandFiles.find(f => f.name === "workflows:work")and parse its frontmatter for model."excludes commands with disable-model-invocation from command map"(line 202): Changebundle.config.command?.["deploy-docs"]tobundle.commandFiles.find(f => f.name === "deploy-docs")."rewrites .claude/ paths to .opencode/ in command bodies"(line 217): Changebundle.config.command?.["review"]?.templateto accessbundle.commandFiles.find(f => f.name === "review")?.content.
Also update tests/opencode-writer.test.ts:
- Add
commandFiles: []to everyOpenCodeBundleliteral in all 4 existing tests (lines 20, 43, 67, 98). These bundles currently only haveconfig,agents,plugins,skillDirs.
Implementation:
In src/types/opencode.ts:
- Remove lines 23-28 (
OpenCodeCommandConfigtype). - Remove line 10 (
command?: Record<string, OpenCodeCommandConfig>) fromOpenCodeConfig. - Add after line 47:
export type OpenCodeCommandFile = {
name: string // command name, used as the filename stem: <name>.md
content: string // full file content: YAML frontmatter + body
}
- Add
commandFiles: OpenCodeCommandFile[]toOpenCodeBundle(betweenagentsandplugins).
In src/converters/claude-to-opencode.ts:
- Update the import on line 11: Remove
OpenCodeCommandConfigfrom the import. AddOpenCodeCommandFile.
Code comments required:
- Above the
commandFilesfield inOpenCodeBundle:// 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 returnOpenCodeCommandFile[]instead ofRecord<string, OpenCodeCommandConfig>. - Each command becomes a
.mdfile with YAML frontmatter (description, optionallymodel) and body (the template text with Claude path rewriting applied). - In
convertClaudeToOpenCode()(line 64): replacecommandMapwithcommandFiles. Removeconfig.commandassignment. AddcommandFilesto 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:
-
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.commandisundefined(it no longer exists on the type, but accessing it returnsundefined). - 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-commandsmode explicitly).
- Assert
-
"normalizes models and infers temperature":- Find
workflows:workinbundle.commandFiles, parse its frontmatter withparseFrontmatter(), assertdata.model === "openai/gpt-4o".
- Find
-
"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")isundefined. - Assert
bundle.commandFiles.find(f => f.name === "workflows:review")is defined.
- Assert
-
"rewrites .claude/ paths to .opencode/ in command bodies":- Find
reviewinbundle.commandFiles, assertcontentcontains"compound-engineering.local.md".
- Find
-
Add NEW test:
"command .md files include description in frontmatter":- Create a minimal
ClaudePluginwith 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".
- Create a minimal
Implementation:
In src/converters/claude-to-opencode.ts:
Replace lines 114-128 (convertCommands function):
// 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$schemaandmcp. - Change line 81-86 (return): Replace
pluginsin the return withcommandFiles, plugins(addcommandFilesfield 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
commandsDirto the return value ofresolveOpenCodePaths()for both branches (global and custom output dir). - In
writeOpenCodeBundle(), iteratebundle.commandFilesand write each as<commandsDir>/<name>.mdwith 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:
-
"writes command files as .md in commands/ directory":- Create a bundle with one
commandFilesentry:{ 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").
- Create a bundle with one
-
"backs up existing command .md file before overwriting":- Pre-create
commands/my-cmd.mdwith old content. - Write a bundle with a
commandFilesentry formy-cmd. - Assert a
.bak.file exists incommands/directory. - Assert new content is written.
- Pre-create
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:
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
commandsDirin bothresolveOpenCodePathsbranches:// .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 newmergeOpenCodeConfig()function. mergeOpenCodeConfig()reads the existingopencode.json(if present), merges plugin-provided keys using user-wins-on-conflict strategy, and returns the merged config.- Import
pathExistsandreadJsonfrom../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:
-
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.jsonwith{ $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) ANDmcp["plugin-server"](plugin key). - Assert backup file exists with original content.
- Pre-create
-
NEW:
"merges mcp servers without overwriting user entries":- Pre-create
opencode.jsonwith{ mcp: { "user-server": { type: "local", command: "uvx", args: ["user-srv"] } } }. - Write a bundle with
config.mcpcontaining both"plugin-server"(new) and"user-server"(conflict — different args). - Assert both servers exist in merged output.
- Assert
user-serverkeeps user's original args (user wins on conflict). - Assert
plugin-serveris present with plugin's args.
- Pre-create
-
NEW:
"preserves unrelated user keys when merging opencode.json":- Pre-create
opencode.jsonwith{ model: "my-model", theme: "dark", mcp: {} }. - Write a bundle with
config: { $schema: "...", mcp: { "plugin-server": ... }, permission: { "bash": "allow" } }. - Assert
modelandthemeare preserved. - Assert plugin additions are present.
- Pre-create
Implementation:
Add to imports in src/targets/opencode.ts line 2:
import { backupFile, copyDir, ensureDir, pathExists, readJson, writeJson, writeText } from "../utils/files"
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
Add mergeOpenCodeConfig() function:
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:
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:
-
"install --to opencode uses permissions:none by default":- Run install with no
--permissionsflag against the fixture plugin. - Read the written
opencode.json. - Assert it does NOT contain a
permissionkey. - Assert it does NOT contain a
toolskey.
- Run install with no
-
"install --to opencode --permissions broad writes permission block":- Run install with
--permissions broadagainst the fixture plugin. - Read the written
opencode.json. - Assert it DOES contain a
permissionkey with values.
- Run install with
Implementation:
In src/commands/install.ts:
- Line 51: Change
default: "broad"todefault: "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:
- Write the test(s) first in the test file.
- Run
bun test <test-file>and confirm the new/modified tests FAIL (red). - Implement the code change.
- Run
bun test <test-file>and confirm the new/modified tests PASS (green). - 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:
- Update tests in Phase 1 (expect them to fail — both due to type errors and logic changes).
- Implement type changes in Phase 1.
- Implement converter changes in Phase 2.
- Confirm all converter tests pass after Phase 2.
Constraints
Do not modify:
src/converters/claude-to-opencode.tslines 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.tsusefs.mkdtemp()for temp directories and the localexists()helper function. - Existing CLI tests in
tests/cli.test.tsuseBun.spawn()to invoke the CLI. - Existing converter tests in
tests/converter.test.tsuseloadClaudePlugin(fixtureRoot)for real fixtures and inlineClaudePluginobjects for isolated tests. - ADR format: Follow
AGENTS.mdnumbering convention0001-short-title.mdwith 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-permissionsfrommain.
Final Checklist
After all phases complete:
bun testpasses all tests (180 original + new ones, 0 fail)docs/decisions/0001-opencode-command-output-format.mdexistsdocs/decisions/0002-opencode-json-merge-strategy.mdexistsdocs/decisions/0003-opencode-permissions-default-none.mdexistsopencode.jsonis never fully overwritten — merge logic confirmed by test- Commands are written as
.mdfiles — confirmed by test --permissionsdefaults to"none"— confirmed by CLI testAGENTS.mdandREADME.mdupdated to reflect new behavior