From f09fbd7f2edffe980936ac17bbc1e4150c270f06 Mon Sep 17 00:00:00 2001
From: Tiago Santos <113142031+forbee-dev@users.noreply.github.com>
Date: Fri, 6 Nov 2026 10:53:29 +0000
Subject: [PATCH] feat: add OpenCode installer for skills, agents, commands,
and rules
OpenCode users can now run ForgeBee without Claude Code. The installer
converts the markdown surfaces to OpenCode format. Hooks stay Claude
Code-only, so orchestrator mode, guards, and learning do not run there.
- opencode/install-opencode.js: skills and agents get a forgebee- prefix,
agent tools become a permission map, model aliases are dropped,
CLAUDE_PLUGIN_ROOT points to the installed resource copy.
- A manifest makes reinstall replace only installer-owned files. Existing
user files are kept, never overwritten.
- opencode/test-install-opencode.js runs in npm run check.
---
ARCHITECTURE.md | 3 +-
CHANGELOG.md | 8 ++
README.md | 52 +++++++-
forgebee/README.md | 2 +-
opencode/install-opencode.js | 213 ++++++++++++++++++++++++++++++
opencode/test-install-opencode.js | 77 +++++++++++
package.json | 3 +-
7 files changed, 353 insertions(+), 5 deletions(-)
create mode 100644 opencode/install-opencode.js
create mode 100644 opencode/test-install-opencode.js
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3224d26..4ea4c74 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -83,6 +83,7 @@ the README.
One source, four manifests: `.claude-plugin/marketplace.json` (+ `forgebee/.claude-plugin/plugin.json`),
`.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json`, `gemini-extension.json`,
-plus `openclaw/install-openclaw.js` (converts agents/commands to OpenClaw skills).
+plus `openclaw/install-openclaw.js` (converts agents/commands to OpenClaw skills)
+and `opencode/install-opencode.js` (converts skills, agents, commands to OpenCode format).
All version-bearing files are listed in `.version-bump.json` and kept in sync by
`bump-version.sh`. Surface **counts** in manifest/README prose are maintained by hand.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 905af45..597c65f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,14 @@ The format roughly follows [Keep a Changelog](https://keepachangelog.com/) and t
---
+## [Unreleased]
+
+### Added
+
+- **OpenCode installer.** `node opencode/install-opencode.js [target-dir]` converts skills, agents, commands, and rules to OpenCode format. Hooks do not convert. `npm run check:opencode` tests it.
+
+---
+
## [7.1.1] — 2026-10-20
### Fixed
diff --git a/README.md b/README.md
index 51134f1..657bc32 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@
46 specialist agents. 35 skills. 39 slash commands. 28 lifecycle hooks.
Three execution modes: inline skills, context:fork, subagents.
Adaptive pipeline. Karpathy principles. Adversarial debate. Continuous learning.
- Works with Claude Code, Codex, Cursor, Gemini, and OpenClaw.
+ Works with Claude Code, Codex, Cursor, Gemini, OpenClaw, and OpenCode.
@@ -36,7 +36,8 @@
Learning •
Growth OS •
PM System •
- OpenClaw
+ OpenClaw •
+ OpenCode
---
@@ -421,6 +422,8 @@ The **skills, agents, and commands** are portable — they ship to Claude Code,
| Continuous learning (observe → instincts) | ✅ | ❌ |
| Auto project-triage routing | ✅ | ❌ |
+OpenCode gets skills, agents, commands, and rules through its [own installer](#opencode). It gets no hooks.
+
On non-Claude harnesses you get the full prompt library; the guardrails, governance, and learning loop are Claude Code-only today.
---
@@ -615,6 +618,51 @@ This converts every agent and command into `SKILL.md` files in `~/.openclaw/work
---
+## OpenCode
+
+ForgeBee converts to [OpenCode](https://opencode.ai) format. Skills, agents, commands, and rules work. Hooks do not.
+
+```bash
+# Global install (default target: ~/.config/opencode)
+node ForgeBee/opencode/install-opencode.js
+
+# Project install
+node ForgeBee/opencode/install-opencode.js ./.opencode
+```
+
+
+How the conversion works
+
+- Each skill goes to `skills/forgebee-/` with the whole skill directory. The `name:` field matches the directory.
+- Each agent goes to `agents/forgebee-.md` with `mode: subagent`. The `tools:` list becomes a `permission:` map.
+- `orchestrator` and `direct` are skipped. They need Claude Code hooks.
+- Each command goes to `commands/.md`. Only `description`, `agent`, and `subtask` stay.
+- The `forgebee/` tree (without `eval/`) goes to `/forgebee/`. `${CLAUDE_PLUGIN_ROOT}` becomes that absolute path.
+- `forgebee:` references become `forgebee-`.
+- The installer writes `forgebee/.opencode-install.json`. A reinstall deletes only the paths in that file.
+
+**Not converted:** hooks. Orchestrator mode, guards, learning, and PM sync do not run on OpenCode.
+
+**Existing files.** The installer keeps your own files. If a skill, agent, or command already exists at the target, it skips that file and lists it in the summary. It does not overwrite it.
+
+**Rules.** The installer does not edit `opencode.json`. Add this entry yourself. Use the absolute path the installer prints. Do not use `~`.
+
+```json
+{
+ "instructions": ["/absolute/path/to/opencode/forgebee/rules/common/general.md"]
+}
+```
+
+Add `rules/php/conventions.md`, `rules/python/conventions.md`, or `rules/typescript/conventions.md` for your stack. Do not load `rules/common/hooks.md`. It documents Claude Code hooks.
+
+OpenCode reads `CLAUDE.md` when no `AGENTS.md` exists.
+
+**Models.** The installer drops `model:` from agents. Claude aliases such as `opus` are not valid in OpenCode. Subagents use the model of the calling agent. To pin a model, add `model: provider/model-id` to the agent file.
+
+
+
+---
+
## Contributing
Contributions welcome! ForgeBee is markdown surfaces and Node.js scripts. See **[CONTRIBUTING.md](./CONTRIBUTING.md)** for the surface contracts (agent/skill/command structure, the load-bearing Status + adversarial-hardening contract, the model-tier policy, output modes) and **[ARCHITECTURE.md](./ARCHITECTURE.md)** for how the pieces fit together.
diff --git a/forgebee/README.md b/forgebee/README.md
index 64e695b..8e0aa66 100644
--- a/forgebee/README.md
+++ b/forgebee/README.md
@@ -76,7 +76,7 @@ forgebee/
### Other capabilities
- **Orchestrator by default** — the main thread runs as `forgebee:orchestrator`, delegates real work, keeps tiny edits inline; opt out with `claude --agent forgebee:direct`
-- **Multi-platform** — works with Claude Code, Codex, Cursor, Gemini, OpenClaw (separate manifests)
+- **Multi-platform** — works with Claude Code, Codex, Cursor, Gemini, OpenClaw, OpenCode (separate manifests or installers)
- **Adaptive pipeline** — `/workflow` scrum phase is promptable (full sprint planning OR direct delegation)
- **Decision logs + addenda** — `/workflow` and `/plan` persist decisions across runs
- **Continuous learning** — Stop hook asks Claude to save instincts; SessionStart digest applies them and auto-activates ≥0.7; `/learn` and `/evolve` stay manual
diff --git a/opencode/install-opencode.js b/opencode/install-opencode.js
new file mode 100644
index 0000000..047dde2
--- /dev/null
+++ b/opencode/install-opencode.js
@@ -0,0 +1,213 @@
+#!/usr/bin/env node
+
+/**
+ * ForgeBee → OpenCode installer (skills, agents, commands, rules; no hooks)
+ * Usage: node opencode/install-opencode.js [target-dir]
+ * Default target: ~/.config/opencode. For a project install pass ./.opencode
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const SRC = path.join(path.dirname(path.dirname(path.resolve(__filename))), 'forgebee');
+const TARGET = path.resolve(process.argv[2] || path.join(os.homedir(), '.config', 'opencode'));
+const ROOT = path.join(TARGET, 'forgebee');
+const MANIFEST = path.join(ROOT, '.opencode-install.json');
+
+const SKIP_AGENTS = { orchestrator: 'main-thread agent, needs Claude Code hooks', direct: 'main-thread agent, needs Claude Code hooks' };
+const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
+const COLOR_RE = /^(#[0-9a-fA-F]{6}|primary|secondary|accent|success|warning|error|info)$/;
+const TOOL_MAP = { Read: 'read', Write: 'edit', Edit: 'edit', Glob: 'glob', Grep: 'grep', Bash: 'bash', Task: 'task', WebSearch: 'websearch', WebFetch: 'webfetch' };
+const PERMISSION_KEYS = ['read', 'edit', 'glob', 'grep', 'list', 'bash', 'task', 'webfetch', 'websearch'];
+
+function fail(msg) {
+ throw new Error(msg);
+}
+
+// Split a markdown file into frontmatter lines and body text.
+function splitFrontmatter(text, file) {
+ const lines = text.split('\n');
+ if (lines[0].trim() !== '---') fail(`${file}: missing frontmatter`);
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
+ if (end < 0) fail(`${file}: unterminated frontmatter`);
+ return { fm: lines.slice(1, end), body: lines.slice(end + 1).join('\n') };
+}
+
+function fmFields(fm) {
+ const fields = {};
+ for (const line of fm) {
+ const m = line.match(/^([A-Za-z-]+):\s*(.*)$/);
+ if (m) fields[m[1]] = m[2].trim();
+ }
+ return fields;
+}
+
+const join = (fm, body) => `---\n${fm.join('\n')}\n---\n${body}`;
+
+function rewrite(text) {
+ return text.replaceAll('${CLAUDE_PLUGIN_ROOT}', ROOT).replaceAll('forgebee:', 'forgebee-');
+}
+
+function mdFiles(dir) {
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
+ const p = path.join(dir, e.name);
+ return e.isDirectory() ? mdFiles(p) : e.name.endsWith('.md') ? [p] : [];
+ });
+}
+
+function rewriteTree(dir) {
+ for (const f of mdFiles(dir)) fs.writeFileSync(f, rewrite(fs.readFileSync(f, 'utf-8')));
+}
+
+function removeOldInstall() {
+ if (!fs.existsSync(MANIFEST)) return;
+ const old = JSON.parse(fs.readFileSync(MANIFEST, 'utf-8'));
+ for (const rel of old.created || []) {
+ const abs = path.resolve(TARGET, rel);
+ // Guard: a tampered manifest must not delete outside the target or the resource root.
+ if (path.isAbsolute(rel) || !abs.startsWith(TARGET + path.sep) || abs === ROOT || abs.startsWith(ROOT + path.sep)) {
+ fail(`manifest has unsafe path: ${rel}`);
+ }
+ fs.rmSync(abs, { recursive: true, force: true });
+ }
+}
+
+function copyResources() {
+ fs.mkdirSync(ROOT, { recursive: true });
+ // shortcut: stale files from older versions stay in forgebee/, upgrade when files are renamed often.
+ fs.cpSync(SRC, ROOT, {
+ recursive: true,
+ force: true,
+ filter: src => src !== path.join(SRC, 'eval'),
+ });
+ rewriteTree(ROOT);
+}
+
+// removeOldInstall() ran first, so any path that still exists is user-owned.
+const kept = [];
+function isUserOwned(rel) {
+ if (!fs.existsSync(path.join(TARGET, rel))) return false;
+ kept.push(path.join(TARGET, rel));
+ return true;
+}
+
+function listDir(dir, pred) {
+ return fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).filter(pred).map(e => e.name) : [];
+}
+
+function installSkills(created) {
+ const srcDir = path.join(SRC, 'skills');
+ let count = 0;
+ for (const name of listDir(srcDir, e => e.isDirectory())) {
+ const file = path.join(srcDir, name, 'SKILL.md');
+ if (!fs.existsSync(file)) continue;
+ const full = `forgebee-${name}`;
+ if (full.length > 64 || !NAME_RE.test(full)) fail(`skill ${name}: invalid OpenCode name "${full}"`);
+ const { fm, body } = splitFrontmatter(fs.readFileSync(file, 'utf-8'), file);
+ const desc = fmFields(fm).description || '';
+ if (desc.length < 1 || desc.length > 1024) fail(`skill ${name}: description must be 1-1024 chars (got ${desc.length})`);
+ if (isUserOwned(path.join('skills', full))) continue;
+ const dest = path.join(TARGET, 'skills', full);
+ fs.cpSync(path.join(srcDir, name), dest, { recursive: true });
+ const newFm = fm.map(l => (/^name:/.test(l) ? `name: ${full}` : l));
+ fs.writeFileSync(path.join(dest, 'SKILL.md'), rewrite(join(newFm, body)));
+ rewriteTree(dest);
+ created.push(path.join('skills', full));
+ count++;
+ }
+ return count;
+}
+
+function permissionBlock(toolsLine) {
+ const allowed = new Set(toolsLine.split(',').map(t => TOOL_MAP[t.trim()]).filter(Boolean));
+ if (allowed.has('glob')) allowed.add('list');
+ return ['permission:', ...PERMISSION_KEYS.map(k => ` ${k}: ${allowed.has(k) ? 'allow' : 'deny'}`)];
+}
+
+function installAgents(created) {
+ const srcDir = path.join(SRC, 'agents');
+ const skipped = [];
+ let count = 0;
+ for (const file of listDir(srcDir, e => e.isFile() && e.name.endsWith('.md'))) {
+ const name = path.basename(file, '.md');
+ if (SKIP_AGENTS[name]) {
+ skipped.push(`${name} (${SKIP_AGENTS[name]})`);
+ continue;
+ }
+ const { fm, body } = splitFrontmatter(fs.readFileSync(path.join(srcDir, file), 'utf-8'), file);
+ const f = fmFields(fm);
+ if (!f.description) fail(`agent ${name}: description is required`);
+ // model: is dropped on purpose. Claude aliases like `opus` are invalid in OpenCode,
+ // so the subagent inherits the caller's model. Users can set `model: provider/model-id` by hand.
+ const out = [`description: ${f.description}`, 'mode: subagent'];
+ if (f.color && COLOR_RE.test(f.color.replace(/^["']|["']$/g, ''))) out.push(`color: ${f.color}`);
+ if (f.tools !== undefined) out.push(...permissionBlock(f.tools));
+ const rel = path.join('agents', `forgebee-${file}`);
+ if (isUserOwned(rel)) continue;
+ fs.mkdirSync(path.join(TARGET, 'agents'), { recursive: true });
+ fs.writeFileSync(path.join(TARGET, rel), rewrite(join(out, body)));
+ created.push(rel);
+ count++;
+ }
+ return { count, skipped };
+}
+
+function installCommands(created) {
+ const srcDir = path.join(SRC, 'commands');
+ const keep = new Set(['description', 'agent', 'subtask']);
+ let count = 0;
+ for (const file of listDir(srcDir, e => e.isFile() && e.name.endsWith('.md'))) {
+ const { fm, body } = splitFrontmatter(fs.readFileSync(path.join(srcDir, file), 'utf-8'), file);
+ const kept = fm.filter(l => keep.has((l.match(/^([A-Za-z-]+):/) || [])[1]));
+ const rel = path.join('commands', file);
+ if (isUserOwned(rel)) continue;
+ fs.mkdirSync(path.join(TARGET, 'commands'), { recursive: true });
+ fs.writeFileSync(path.join(TARGET, rel), rewrite(join(kept, body)));
+ created.push(rel);
+ count++;
+ }
+ return count;
+}
+
+function printSummary({ skills, agents, commands }) {
+ const lines = [
+ '',
+ 'ForgeBee installed for OpenCode.',
+ ` Target: ${TARGET}`,
+ ` Skills: ${skills}`,
+ ` Agents: ${agents.count}`,
+ ` Commands: ${commands}`,
+ ` Skipped agents: ${agents.skipped.join('; ') || 'none'}`,
+ ...(kept.length ? [` Kept your existing files: ${kept.join(', ')}`] : []),
+ '',
+ 'Hooks do not run on OpenCode: orchestrator mode, guards, learning, and PM sync are off.',
+ '',
+ 'Rules: add this to your opencode.json (the installer does not edit it):',
+ JSON.stringify({ instructions: [path.join(ROOT, 'rules', 'common', 'general.md')] }, null, 2),
+ `Optional, add for your stack: ${['php', 'python', 'typescript'].map(l => path.join(ROOT, 'rules', l, 'conventions.md')).join(', ')}`,
+ 'OpenCode falls back to CLAUDE.md when no AGENTS.md exists.',
+ 'Model: agents have no model set. They inherit the model of the calling agent.',
+ ];
+ console.log(lines.join('\n'));
+}
+
+function main() {
+ const version = JSON.parse(fs.readFileSync(path.join(SRC, '.claude-plugin', 'plugin.json'), 'utf-8')).version;
+ console.log(`ForgeBee ${version} -> OpenCode\n Source: ${SRC}\n Target: ${TARGET}`);
+ removeOldInstall();
+ copyResources();
+ const created = [];
+ const skills = installSkills(created);
+ const agents = installAgents(created);
+ const commands = installCommands(created);
+ fs.writeFileSync(MANIFEST, JSON.stringify({ version, created }, null, 2) + '\n');
+ printSummary({ skills, agents, commands });
+}
+
+try {
+ main();
+} catch (err) {
+ console.error(`Install failed: ${err.message}`);
+ process.exit(1);
+}
diff --git a/opencode/test-install-opencode.js b/opencode/test-install-opencode.js
new file mode 100644
index 0000000..ab1ca85
--- /dev/null
+++ b/opencode/test-install-opencode.js
@@ -0,0 +1,77 @@
+#!/usr/bin/env node
+// Self-check: install twice into a temp dir, then assert the OpenCode output shape.
+
+const assert = require('node:assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const INSTALLER = path.join(__dirname, 'install-opencode.js');
+const SRC = path.join(__dirname, '..', 'forgebee');
+const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
+
+const frontmatter = f => fs.readFileSync(f, 'utf-8').split('\n---')[0].split('\n').slice(1);
+const field = (fm, key) => (fm.find(l => l.startsWith(`${key}:`)) || '').slice(key.length + 1).trim();
+const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => {
+ const p = path.join(dir, e.name);
+ return e.isDirectory() ? walk(p) : [p];
+});
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forgebee-opencode-'));
+try {
+ for (let i = 0; i < 2; i++) execFileSync('node', [INSTALLER, tmp], { stdio: 'pipe' });
+
+ const skillsDir = path.join(tmp, 'skills');
+ const skills = fs.readdirSync(skillsDir);
+ assert.ok(skills.length > 0, 'no skills installed');
+ for (const dir of skills) {
+ const name = field(frontmatter(path.join(skillsDir, dir, 'SKILL.md')), 'name');
+ assert.strictEqual(name, dir, `skill name != dir: ${dir}`);
+ assert.match(name, NAME_RE);
+ assert.ok(name.length <= 64);
+ }
+
+ const mds = walk(tmp).filter(f => f.endsWith('.md'));
+ for (const f of mds) {
+ const text = fs.readFileSync(f, 'utf-8');
+ assert.ok(!text.includes('${CLAUDE_PLUGIN_ROOT}'), `CLAUDE_PLUGIN_ROOT left in ${f}`);
+ assert.ok(!text.includes('forgebee:'), `forgebee: left in ${f}`);
+ }
+
+ const agentsDir = path.join(tmp, 'agents');
+ const agents = fs.readdirSync(agentsDir);
+ assert.ok(agents.length > 0, 'no agents installed');
+ for (const a of agents) {
+ const fm = frontmatter(path.join(agentsDir, a));
+ assert.ok(field(fm, 'description'), `${a}: no description`);
+ assert.strictEqual(field(fm, 'mode'), 'subagent', `${a}: mode`);
+ assert.ok(!field(fm, 'model') && !field(fm, 'tools'), `${a}: model/tools left`);
+ }
+ for (const skip of ['orchestrator', 'direct']) {
+ assert.ok(!agents.includes(`forgebee-${skip}.md`), `${skip} must be absent`);
+ }
+
+ const srcCmds = fs.readdirSync(path.join(SRC, 'commands')).filter(f => f.endsWith('.md')).length;
+ assert.strictEqual(fs.readdirSync(path.join(tmp, 'commands')).length, srcCmds, 'command count');
+
+ assert.ok(fs.existsSync(path.join(tmp, 'forgebee', '.opencode-install.json')), 'manifest missing');
+ assert.ok(!fs.existsSync(path.join(tmp, 'forgebee', 'eval')), 'eval must not be copied');
+
+ const own = fs.mkdtempSync(path.join(os.tmpdir(), 'forgebee-opencode-own-'));
+ try {
+ fs.mkdirSync(path.join(own, 'commands'));
+ const mine = path.join(own, 'commands', 'review.md');
+ fs.writeFileSync(mine, 'mine');
+ for (let i = 0; i < 2; i++) execFileSync('node', [INSTALLER, own], { stdio: 'pipe' });
+ assert.strictEqual(fs.readFileSync(mine, 'utf-8'), 'mine', 'user file overwritten');
+ const { created } = JSON.parse(fs.readFileSync(path.join(own, 'forgebee', '.opencode-install.json'), 'utf-8'));
+ assert.ok(!created.includes(path.join('commands', 'review.md')), 'user file in manifest');
+ } finally {
+ fs.rmSync(own, { recursive: true, force: true });
+ }
+
+ console.log(`OK: ${skills.length} skills, ${agents.length} agents, ${srcCmds} commands`);
+} finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+}
diff --git a/package.json b/package.json
index bb34b2b..ecc7239 100644
--- a/package.json
+++ b/package.json
@@ -9,8 +9,9 @@
"check:references": "node scripts/check-references.js",
"check:agents": "node scripts/check-agent-contract.js",
"check:version": "bash scripts/bump-version.sh --check",
+ "check:opencode": "node opencode/test-install-opencode.js",
"eval": "node forgebee/eval/harness/run.js",
"eval:golden": "node forgebee/eval/golden/run-golden.js",
- "check": "npm run index:check && npm run check:references && npm run check:agents && npm run check:version && npm run eval"
+ "check": "npm run index:check && npm run check:references && npm run check:agents && npm run check:version && npm run eval && npm run check:opencode"
}
}