diff --git a/bin/install.js b/bin/install.js index def0d144..e67562c0 100755 --- a/bin/install.js +++ b/bin/install.js @@ -23,6 +23,7 @@ const readline = require('readline'); const SETTINGS = require('./lib/settings'); const OPENCLAW = require('./lib/openclaw'); +const { stripOpencodeAgentTools } = require('./lib/opencode-agent'); const REPO = 'JuliusBrussee/caveman'; const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main`; @@ -557,7 +558,11 @@ function installOpencode(ctx) { process.stdout.write(` installed: ${dest}\n`); } - // 3. Subagents. + // 3. Subagents. Source files target Claude Code's schema (`tools: [...]` + // YAML array); OpenCode rejects that form and refuses to boot until the + // file is removed. Strip the `tools:` line on copy — OpenCode falls back + // to its default tool set, and subagent prompts already self-restrict in + // the body. Issue 386. fs.mkdirSync(agentsDir, { recursive: true }); const agentSrcDir = path.join(repoRoot, 'agents'); for (const f of OPENCODE_AGENT_FILES) { @@ -565,7 +570,7 @@ function installOpencode(ctx) { const dest = path.join(agentsDir, f); if (!fs.existsSync(src)) continue; if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest} (exists; --force to overwrite)`); continue; } - fs.copyFileSync(src, dest); + fs.writeFileSync(dest, stripOpencodeAgentTools(fs.readFileSync(src, 'utf8'))); process.stdout.write(` installed: ${dest}\n`); } diff --git a/bin/lib/opencode-agent.js b/bin/lib/opencode-agent.js new file mode 100644 index 00000000..a97a9320 --- /dev/null +++ b/bin/lib/opencode-agent.js @@ -0,0 +1,42 @@ +'use strict'; + +// Strip the `tools:` field from a Claude-Code-style subagent frontmatter so +// the file is valid for OpenCode, whose schema rejects the YAML array form +// (`tools: [Read, Grep, Bash]`) with: +// +// Configuration is invalid at .../agents/cavecrew-reviewer.md +// ↳ Expected object | undefined, got ["Read","Grep","Bash"] tools +// +// OpenCode allows `tools` to be a map (`{read: true, grep: true}`) or +// omitted entirely. Omitting falls back to OpenCode's default tool set, +// which is what the cavecrew subagent prompts already self-restrict against +// in their body ("Read-only locator", "No `Bash` available", etc.), so +// dropping the array form is safe. + +const TOOLS_FIELD_RE = /^tools[ \t]*:/; +const CONTINUATION_RE = /^[ \t]/; +const FRONTMATTER_FENCE = '---\n'; + +function stripOpencodeAgentTools(content) { + if (typeof content !== 'string' || !content.startsWith(FRONTMATTER_FENCE)) return content; + const fmEnd = content.indexOf('\n---', FRONTMATTER_FENCE.length); + if (fmEnd < 0) return content; + + const fm = content.slice(FRONTMATTER_FENCE.length, fmEnd); + const rest = content.slice(fmEnd); + + const out = []; + let dropping = false; + for (const line of fm.split('\n')) { + if (dropping) { + if (CONTINUATION_RE.test(line)) continue; + dropping = false; + } + if (TOOLS_FIELD_RE.test(line)) { dropping = true; continue; } + out.push(line); + } + + return FRONTMATTER_FENCE + out.join('\n') + rest; +} + +module.exports = { stripOpencodeAgentTools }; diff --git a/tests/installer/opencode-agent.test.mjs b/tests/installer/opencode-agent.test.mjs new file mode 100644 index 00000000..fb1d72c4 --- /dev/null +++ b/tests/installer/opencode-agent.test.mjs @@ -0,0 +1,168 @@ +// Subagent frontmatter sanitizer for OpenCode (issue 386). +// +// OpenCode rejects the YAML array form `tools: [Read, Grep, Bash]` that +// Claude Code accepts. Copying agents/cavecrew-*.md verbatim into +// ~/.config/opencode/agents/ broke OpenCode startup with: +// Configuration is invalid at .../cavecrew-reviewer.md +// ↳ Expected object | undefined, got ["Read","Grep","Bash"] tools +// +// Fix: strip the `tools:` field on copy. These tests prove the helper +// strips the field, preserves every other frontmatter key and the body, +// and handles both the inline array form and the multi-line YAML list form. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..', '..'); +const requireCjs = createRequire(import.meta.url); +const { stripOpencodeAgentTools } = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'opencode-agent.js')); + +const SHIPPED_AGENT_FILES = ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md']; + +function frontmatter(content) { + const m = content.match(/^---\n([\s\S]*?)\n---\n/); + assert.ok(m, 'frontmatter present'); + return m[1]; +} + +// ── Inline array form (the exact bug reported in issue 386) ────────────── +test('strips inline `tools: [...]` array from frontmatter', () => { + const src = `--- +name: test-agent +description: short description +tools: [Read, Grep, Bash] +model: haiku +--- +body line one +body line two +`; + const out = stripOpencodeAgentTools(src); + const fm = frontmatter(out); + assert.doesNotMatch(fm, /^tools:/m, '`tools` field must be absent'); + assert.match(fm, /^name: test-agent$/m, '`name` preserved'); + assert.match(fm, /^description: short description$/m, '`description` preserved'); + assert.match(fm, /^model: haiku$/m, '`model` preserved'); + assert.match(out, /^body line one$/m, 'body preserved'); + assert.match(out, /^body line two$/m, 'body preserved'); +}); + +// ── Multi-line YAML list form (defensive — future-proof for refactors) ─── +test('strips multi-line `tools:` list with indented continuation', () => { + const src = `--- +name: test-agent +tools: + - Read + - Grep + - Bash +model: haiku +--- +body +`; + const out = stripOpencodeAgentTools(src); + const fm = frontmatter(out); + assert.doesNotMatch(fm, /^tools:/m, '`tools` field must be absent'); + assert.doesNotMatch(fm, /^\s+- Read$/m, '`tools` list items must be absent'); + assert.match(fm, /^name: test-agent$/m, '`name` preserved'); + assert.match(fm, /^model: haiku$/m, '`model` preserved'); +}); + +// ── Folded `description: >` block must NOT be eaten ────────────────────── +test('preserves folded `description: >` continuation lines when `tools:` follows', () => { + const src = `--- +name: cavecrew-reviewer +description: > + Diff/branch/file reviewer. One line per finding, severity-tagged, no praise, + no scope creep. Output format \`path:line: : . .\` +tools: [Read, Grep, Bash] +model: haiku +--- +body +`; + const out = stripOpencodeAgentTools(src); + const fm = frontmatter(out); + assert.doesNotMatch(fm, /^tools:/m); + assert.match(fm, /^description: >$/m, 'folded scalar header preserved'); + assert.match(fm, /Diff\/branch\/file reviewer/, 'folded scalar body preserved'); + assert.match(fm, /no scope creep/, 'second folded line preserved'); + assert.match(fm, /^model: haiku$/m); +}); + +// ── No frontmatter: pass content through untouched ─────────────────────── +test('returns input unchanged when no frontmatter fence', () => { + const src = 'just body, no frontmatter\ntools: [Read]\n'; + assert.equal(stripOpencodeAgentTools(src), src); +}); + +// ── No `tools:` field: pass content through untouched ──────────────────── +test('returns input unchanged when frontmatter has no `tools:` field', () => { + const src = `--- +name: x +model: haiku +--- +body +`; + assert.equal(stripOpencodeAgentTools(src), src); +}); + +// ── Non-string input: pass through (defensive) ─────────────────────────── +test('non-string input returns unchanged', () => { + assert.equal(stripOpencodeAgentTools(null), null); + assert.equal(stripOpencodeAgentTools(undefined), undefined); + assert.deepEqual(stripOpencodeAgentTools({ x: 1 }), { x: 1 }); +}); + +// ── Real shipped agent files: every one must transform to OpenCode-safe ── +// This is the RED-state proof: each `agents/cavecrew-*.md` in the repo today +// contains the offending `tools: [...]` form, which is what broke OpenCode +// startup in the reported bug. After transform, the field is gone. +test('all shipped cavecrew agent files contain offending tools array (RED proof)', () => { + for (const f of SHIPPED_AGENT_FILES) { + const src = fs.readFileSync(path.join(REPO_ROOT, 'agents', f), 'utf8'); + const fm = frontmatter(src); + assert.match(fm, /^tools:\s*\[/m, `source ${f} should contain inline array form (this is the bug)`); + } +}); + +test('all shipped cavecrew agent files become OpenCode-safe after transform (GREEN proof)', () => { + for (const f of SHIPPED_AGENT_FILES) { + const src = fs.readFileSync(path.join(REPO_ROOT, 'agents', f), 'utf8'); + const out = stripOpencodeAgentTools(src); + const fm = frontmatter(out); + + assert.doesNotMatch(fm, /^tools:/m, `${f}: tools field still present after transform`); + assert.match(fm, /^name: cavecrew-/m, `${f}: name field preserved`); + assert.match(fm, /^description:/m, `${f}: description field preserved`); + + const bodyOut = out.replace(/^---\n[\s\S]*?\n---\n/, ''); + const bodyIn = src.replace(/^---\n[\s\S]*?\n---\n/, ''); + assert.equal(bodyOut, bodyIn, `${f}: body must be byte-identical`); + } +}); + +// ── End-to-end: installer's agent-copy step writes a sanitized file ────── +// We re-enact section 3 of installOpencode() directly to avoid coupling to +// the rest of the install pipeline (which depends on optional files outside +// this fix's scope). The assertion is the same one OpenCode applies on +// startup: `tools` must be absent (or an object), never an array. +test('installer-equivalent copy writes OpenCode-safe agent file (issue 386 end-to-end)', () => { + const tmpDir = fs.mkdtempSync(path.join(REPO_ROOT, 'tests', '.tmp-opencode-agent-')); + try { + for (const f of SHIPPED_AGENT_FILES) { + const src = path.join(REPO_ROOT, 'agents', f); + const dest = path.join(tmpDir, f); + fs.writeFileSync(dest, stripOpencodeAgentTools(fs.readFileSync(src, 'utf8'))); + + const installed = fs.readFileSync(dest, 'utf8'); + const fm = frontmatter(installed); + assert.doesNotMatch(fm, /^tools:\s*\[/m, `${f}: array form survived in installed file`); + assert.doesNotMatch(fm, /^tools:/m, `${f}: tools field survived in installed file`); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +});