Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions bin/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -557,15 +558,19 @@ 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) {
const src = path.join(agentSrcDir, f);
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`);
}

Expand Down
42 changes: 42 additions & 0 deletions bin/lib/opencode-agent.js
Original file line number Diff line number Diff line change
@@ -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 };
168 changes: 168 additions & 0 deletions tests/installer/opencode-agent.test.mjs
Original file line number Diff line number Diff line change
@@ -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: <emoji> <severity>: <problem>. <fix>.\`
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 });
}
});