mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
fix: handle agent frontmatter correctly in OpenCode conversion (#981)
convertClaudeToOpencodeFrontmatter() was designed for commands but is also called for agents. For agents it incorrectly strips name: (needed by OpenCode agents), keeps color:/skills:/tools: (should strip), and doesn't add model: inherit / mode: subagent (required by OpenCode). Add isAgent option to convertClaudeToOpencodeFrontmatter() so agent installs get correct frontmatter: name preserved, Claude-only fields stripped, model/mode injected. Command conversion unchanged (default). Includes 14 test cases covering agent and command conversion paths. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
143
tests/opencode-agent-conversion.test.cjs
Normal file
143
tests/opencode-agent-conversion.test.cjs
Normal file
@@ -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"
|
||||
---
|
||||
|
||||
<role>
|
||||
You are a GSD plan executor.
|
||||
</role>`;
|
||||
|
||||
// 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('<role>'), '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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user