From f0b6ce9689f7cb05f643b7abbfccaac3ba93cdfe Mon Sep 17 00:00:00 2001 From: Adrian Date: Fri, 20 Feb 2026 13:20:48 -0500 Subject: [PATCH] phase 02: convert command to md files --- .../2026-02-20-phase-02-convert-commands.md | 63 +++++++++++++++++++ .../decisions.md | 41 +++++++++++- src/converters/claude-to-opencode.ts | 20 +++--- tests/converter.test.ts | 34 +++++++++- 4 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md new file mode 100644 index 0000000..b2d4f4e --- /dev/null +++ b/docs/reports/2026-02-20-opencode-command-md-merge/2026-02-20-phase-02-convert-commands.md @@ -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`. 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 = { + 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 \ No newline at end of file diff --git a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md index 0befcc6..622c8b3 100644 --- a/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md +++ b/docs/reports/2026-02-20-opencode-command-md-merge/decisions.md @@ -41,4 +41,43 @@ export type OpenCodeBundle = { ## Alternatives Considered 1. Keep inline in config - Rejected: limits flexibility -2. Use separate JSON files - Rejected: YAML frontmatter is more idiomatic for commands \ No newline at end of file +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**: `.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 \ No newline at end of file diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts index d73dbe6..ff6b31f 100644 --- a/src/converters/claude-to-opencode.ts +++ b/src/converters/claude-to-opencode.ts @@ -66,13 +66,12 @@ export function convertClaudeToOpenCode( options: ClaudeToOpenCodeOptions, ): OpenCodeBundle { const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options)) - const commandMap = convertCommands(plugin.commands) + const cmdFiles = convertCommands(plugin.commands) const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : [] const config: OpenCodeConfig = { $schema: "https://opencode.ai/config.json", - command: Object.keys(commandMap).length > 0 ? commandMap : undefined, mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined, } @@ -81,6 +80,7 @@ export function convertClaudeToOpenCode( return { config, agents: agentFiles, + commandFiles: cmdFiles, plugins, skillDirs: plugin.skills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })), } @@ -111,20 +111,22 @@ function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) { } } -function convertCommands(commands: ClaudeCommand[]): Record { - const result: Record = {} +// Commands are written as individual .md files rather than entries in opencode.json. +// Chosen over JSON map because opencode resolves commands by filename at runtime (ADR-001). +function convertCommands(commands: ClaudeCommand[]): OpenCodeCommandFile[] { + const files: OpenCodeCommandFile[] = [] for (const command of commands) { if (command.disableModelInvocation) continue - const entry: OpenCodeCommandConfig = { + const frontmatter: Record = { description: command.description, - template: rewriteClaudePaths(command.body), } if (command.model && command.model !== "inherit") { - entry.model = normalizeModel(command.model) + frontmatter.model = normalizeModel(command.model) } - result[command.name] = entry + const content = formatFrontmatter(frontmatter, rewriteClaudePaths(command.body)) + files.push({ name: command.name, content }) } - return result + return files } function convertMcp(servers: Record): Record { diff --git a/tests/converter.test.ts b/tests/converter.test.ts index 979a702..873ce2b 100644 --- a/tests/converter.test.ts +++ b/tests/converter.test.ts @@ -8,7 +8,7 @@ import type { ClaudePlugin } from "../src/types/claude" const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") describe("convertClaudeToOpenCode", () => { - test("maps commands, permissions, and agents", async () => { + test("from-command mode: map allowedTools to global permission block", async () => { const plugin = await loadClaudePlugin(fixtureRoot) const bundle = convertClaudeToOpenCode(plugin, { agentMode: "subagent", @@ -16,6 +16,7 @@ describe("convertClaudeToOpenCode", () => { permissions: "from-commands", }) + expect(bundle.config.command).toBeUndefined() expect(bundle.commandFiles.find((f) => f.name === "workflows:review")).toBeDefined() expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined() @@ -201,7 +202,7 @@ describe("convertClaudeToOpenCode", () => { expect(parsed.data.mode).toBe("primary") }) - test("excludes commands with disable-model-invocation from command map", async () => { + test("excludes commands with disable-model-invocation from commandFiles", async () => { const plugin = await loadClaudePlugin(fixtureRoot) const bundle = convertClaudeToOpenCode(plugin, { agentMode: "subagent", @@ -276,4 +277,33 @@ Run \`/compound-engineering-setup\` to create a settings file.`, // Tool-agnostic path in project root — no rewriting needed expect(agentFile!.content).toContain("compound-engineering.local.md") }) + + test("command .md files include description in frontmatter", () => { + const plugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [], + commands: [ + { + name: "test-cmd", + description: "Test description", + body: "Do the thing", + sourcePath: "/tmp/plugin/commands/test-cmd.md", + }, + ], + skills: [], + } + + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const commandFile = bundle.commandFiles.find((f) => f.name === "test-cmd") + expect(commandFile).toBeDefined() + const parsed = parseFrontmatter(commandFile!.content) + expect(parsed.data.description).toBe("Test description") + expect(parsed.body).toContain("Do the thing") + }) })