diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b11bdba5..bca5dc742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed +- OpenCode agent frontmatter conversion — agents now get correct `name:`, `model: inherit`, `mode: subagent` instead of broken command-style conversion that stripped name and kept `color:`/`skills:`/`tools:` + ## [1.23.0] - 2026-03-15 ### Added diff --git a/bin/install.js b/bin/install.js index 7f6fff4af..af7ab4e95 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1078,7 +1078,7 @@ function convertClaudeToGeminiAgent(content) { return `---\n${newFrontmatter}\n---${stripSubTags(escapedBody)}`; } -function convertClaudeToOpencodeFrontmatter(content) { +function convertClaudeToOpencodeFrontmatter(content, { isAgent = false } = {}) { // Replace tool name references in content (applies to all files) let convertedContent = content; convertedContent = convertedContent.replace(/\bAskUserQuestion\b/g, 'question'); @@ -1110,11 +1110,17 @@ function convertClaudeToOpencodeFrontmatter(content) { const lines = frontmatter.split('\n'); const newLines = []; let inAllowedTools = false; + let inSkippedArray = false; const allowedTools = []; for (const line of lines) { const trimmed = line.trim(); + // For agents: skip commented-out lines (e.g. hooks blocks) + if (isAgent && trimmed.startsWith('#')) { + continue; + } + // Detect start of allowed-tools array if (trimmed.startsWith('allowed-tools:')) { inAllowedTools = true; @@ -1123,6 +1129,11 @@ function convertClaudeToOpencodeFrontmatter(content) { // Detect inline tools: field (comma-separated string) if (trimmed.startsWith('tools:')) { + if (isAgent) { + // Agents: strip tools entirely (not supported in OpenCode agent frontmatter) + inSkippedArray = true; + continue; + } const toolsValue = trimmed.substring(6).trim(); if (toolsValue) { // Parse comma-separated tools @@ -1132,12 +1143,27 @@ function convertClaudeToOpencodeFrontmatter(content) { continue; } - // Remove name: field - opencode uses filename for command name - if (trimmed.startsWith('name:')) { + // For agents: strip skills:, color:, memory:, maxTurns:, permissionMode:, disallowedTools: + if (isAgent && /^(skills|color|memory|maxTurns|permissionMode|disallowedTools):/.test(trimmed)) { + inSkippedArray = true; continue; } - // Convert color names to hex for opencode + // Skip continuation lines of a stripped array/object field + if (inSkippedArray) { + if (trimmed.startsWith('- ') || trimmed.startsWith('#') || /^\s/.test(line)) { + continue; + } + inSkippedArray = false; + } + + // For commands: remove name: field (opencode uses filename for command name) + // For agents: keep name: (required by OpenCode agents) + if (!isAgent && trimmed.startsWith('name:')) { + continue; + } + + // Convert color names to hex for opencode (commands only; agents strip color above) if (trimmed.startsWith('color:')) { const colorValue = trimmed.substring(6).trim().toLowerCase(); const hexColor = colorNameToHex[colorValue]; @@ -1166,14 +1192,20 @@ function convertClaudeToOpencodeFrontmatter(content) { } } - // Keep other fields (including name: which opencode ignores) + // Keep other fields if (!inAllowedTools) { newLines.push(line); } } - // Add tools object if we had allowed-tools or tools - if (allowedTools.length > 0) { + // For agents: add required OpenCode agent fields + if (isAgent) { + newLines.push('model: inherit'); + newLines.push('mode: subagent'); + } + + // For commands: add tools object if we had allowed-tools or tools + if (!isAgent && allowedTools.length > 0) { newLines.push('tools:'); for (const tool of allowedTools) { newLines.push(` ${convertToolName(tool)}: true`); @@ -2345,9 +2377,9 @@ function install(isGlobal, runtime = 'claude') { content = content.replace(homeDirRegex, toHomePrefix(pathPrefix)); } content = processAttribution(content, getCommitAttribution(runtime)); - // Convert frontmatter for runtime compatibility + // Convert frontmatter for runtime compatibility (agents need different handling) if (isOpencode) { - content = convertClaudeToOpencodeFrontmatter(content); + content = convertClaudeToOpencodeFrontmatter(content, { isAgent: true }); } else if (isGemini) { content = convertClaudeToGeminiAgent(content); } else if (isCodex) { @@ -2816,6 +2848,7 @@ if (process.env.GSD_TEST_MODE) { mergeCodexConfig, installCodexConfig, convertClaudeCommandToCodexSkill, + convertClaudeToOpencodeFrontmatter, GSD_CODEX_MARKER, CODEX_AGENT_SANDBOX, getDirName, diff --git a/tests/opencode-agent-conversion.test.cjs b/tests/opencode-agent-conversion.test.cjs new file mode 100644 index 000000000..6ba62b54b --- /dev/null +++ b/tests/opencode-agent-conversion.test.cjs @@ -0,0 +1,143 @@ +/** + * OpenCode Agent Frontmatter Conversion Tests + * + * Validates that convertClaudeToOpencodeFrontmatter correctly converts + * agent frontmatter for OpenCode compatibility when isAgent: true. + * + * Bug: Without isAgent flag, the function strips name: (agents need it), + * keeps color:/skills:/tools: record (should strip), and doesn't add + * model: inherit / mode: subagent (required by OpenCode agents). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert'); + +process.env.GSD_TEST_MODE = '1'; +const { convertClaudeToOpencodeFrontmatter } = require('../bin/install.js'); + +// Sample Claude agent frontmatter (matches actual GSD agent format) +const SAMPLE_AGENT = `--- +name: gsd-executor +description: Executes GSD plans with atomic commits +tools: Read, Write, Edit, Bash, Grep, Glob +color: yellow +skills: + - gsd-executor-workflow +# hooks: +# PostToolUse: +# - matcher: "Write|Edit" +# hooks: +# - type: command +# command: "npx eslint --fix $FILE 2>/dev/null || true" +--- + + +You are a GSD plan executor. +`; + +// Sample Claude command frontmatter (for comparison — commands work differently) +const SAMPLE_COMMAND = `--- +name: gsd-execute-phase +description: Execute all plans in a phase +allowed-tools: + - Read + - Write + - Bash +--- + +Execute the phase plan.`; + +describe('OpenCode agent conversion (isAgent: true)', () => { + test('keeps name: field for agents', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); + const frontmatter = result.split('---')[1]; + assert.ok(frontmatter.includes('name: gsd-executor'), 'name: should be preserved for agents'); + }); + + test('adds model: inherit', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); + const frontmatter = result.split('---')[1]; + assert.ok(frontmatter.includes('model: inherit'), 'model: inherit should be added'); + }); + + test('adds mode: subagent', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); + const frontmatter = result.split('---')[1]; + assert.ok(frontmatter.includes('mode: subagent'), 'mode: subagent should be added'); + }); + + test('strips tools: field', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); + const frontmatter = result.split('---')[1]; + assert.ok(!frontmatter.includes('tools:'), 'tools: should be stripped for agents'); + assert.ok(!frontmatter.includes('read: true'), 'tools object should not be generated'); + }); + + test('strips skills: array', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); + const frontmatter = result.split('---')[1]; + assert.ok(!frontmatter.includes('skills:'), 'skills: should be stripped'); + assert.ok(!frontmatter.includes('gsd-executor-workflow'), 'skill entries should be stripped'); + }); + + test('strips color: field', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); + const frontmatter = result.split('---')[1]; + assert.ok(!frontmatter.includes('color:'), 'color: should be stripped for agents'); + }); + + test('strips commented hooks block', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); + const frontmatter = result.split('---')[1]; + assert.ok(!frontmatter.includes('# hooks:'), 'commented hooks should be stripped'); + assert.ok(!frontmatter.includes('PostToolUse'), 'hook content should be stripped'); + }); + + test('keeps description: field', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); + const frontmatter = result.split('---')[1]; + assert.ok(frontmatter.includes('description: Executes GSD plans'), 'description should be kept'); + }); + + test('preserves body content', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_AGENT, { isAgent: true }); + assert.ok(result.includes(''), 'body should be preserved'); + assert.ok(result.includes('You are a GSD plan executor.'), 'body content should be intact'); + }); + + test('applies body text replacements', () => { + const agentWithClaudePaths = `--- +name: test-agent +description: Test +tools: Read +--- + +Read ~/.claude/agent-memory/ for context. +Use $HOME/.claude/skills/ for reference.`; + + const result = convertClaudeToOpencodeFrontmatter(agentWithClaudePaths, { isAgent: true }); + assert.ok(result.includes('~/.config/opencode/agent-memory/'), '~/.claude should be replaced'); + assert.ok(result.includes('$HOME/.config/opencode/skills/'), '$HOME/.claude should be replaced'); + }); +}); + +describe('OpenCode command conversion (isAgent: false, default)', () => { + test('strips name: field for commands', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_COMMAND); + const frontmatter = result.split('---')[1]; + assert.ok(!frontmatter.includes('name:'), 'name: should be stripped for commands'); + }); + + test('does not add model: or mode: for commands', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_COMMAND); + const frontmatter = result.split('---')[1]; + assert.ok(!frontmatter.includes('model:'), 'model: should not be added for commands'); + assert.ok(!frontmatter.includes('mode:'), 'mode: should not be added for commands'); + }); + + test('keeps description: for commands', () => { + const result = convertClaudeToOpencodeFrontmatter(SAMPLE_COMMAND); + const frontmatter = result.split('---')[1]; + assert.ok(frontmatter.includes('description:'), 'description should be kept'); + }); +});