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');
+ });
+});