phase 02: convert command to md files
This commit is contained in:
@@ -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
|
||||||
@@ -41,4 +41,43 @@ export type OpenCodeBundle = {
|
|||||||
## Alternatives Considered
|
## Alternatives Considered
|
||||||
|
|
||||||
1. Keep inline in config - Rejected: limits flexibility
|
1. Keep inline in config - Rejected: limits flexibility
|
||||||
2. Use separate JSON files - Rejected: YAML frontmatter is more idiomatic for commands
|
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
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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,6 +16,7 @@ describe("convertClaudeToOpenCode", () => {
|
|||||||
permissions: "from-commands",
|
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 === "workflows:review")).toBeDefined()
|
||||||
expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined()
|
expect(bundle.commandFiles.find((f) => f.name === "plan_review")).toBeDefined()
|
||||||
|
|
||||||
@@ -201,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",
|
||||||
@@ -276,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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user