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" } }