feat(opencode): native plugin replaces npx-skills fallback

Replaces opencode's Tier-4 npx-skills entry with a native in-repo plugin
that mirrors the Claude Code hook architecture (session.created +
tui.prompt.append) using opencode's lifecycle hook system. Reaches Tier-1
parity minus the statusline (opencode TUI exposes no plugin-writable
badge). Skill files drop in unchanged — opencode reads SKILL.md natively.

Plugin reuses src/hooks/caveman-config.js for the symlink-safe flag-write
helpers via createRequire (renamed .cjs post-install to coexist with the
plugin dir's "type":"module"). AGENTS.md provides a Tier-3 always-on
fallback if the plugin runtime breaks.

5 new tests cover fresh install, idempotency, JSONC tolerance of
pre-existing opencode.json, uninstall, and a plugin smoke test that fires
synthetic tui.prompt.append events. All 38 installer tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Julius Brussee
2026-05-10 15:08:17 +02:00
co-authored by Claude Opus 4.7
parent 1ce38a2295
commit 8b8068d8ca
14 changed files with 715 additions and 2 deletions
+5
View File
@@ -92,6 +92,8 @@ caveman/
| `agents/cavecrew-investigator.md` | Read-only locator subagent (haiku). Output contract: `path:line — symbol — note`. |
| `agents/cavecrew-builder.md` | Surgical 1-2 file editor subagent. Refuses 3+ file scope. |
| `agents/cavecrew-reviewer.md` | Diff/file reviewer subagent (haiku). One-line findings with severity emoji. |
| `src/plugins/opencode/plugin.js` | opencode native plugin. ESM Bun module — `session.created` writes flag, `tui.prompt.append` parses slash/natural-language activation and appends per-prompt reinforcement. Reuses `caveman-config.js` via `createRequire`. |
| `src/plugins/opencode/commands/*.md` | Six opencode slash-command prompt templates (`/caveman`, `/caveman-{commit,review,compress,stats,help}`). |
### Auto-generated / auto-synced — do not edit directly
@@ -236,12 +238,15 @@ How caveman reaches each agent type:
| Claude Code | Plugin (hooks + skills) or standalone hooks | Yes — SessionStart hook injects rules |
| Codex | Plugin in `plugins/caveman/` plus repo `.codex/hooks.json` and `.codex/config.toml` | Yes on macOS/Linux — SessionStart hook |
| Gemini CLI | Extension with `GEMINI.md` context file | Yes — context file loads every session |
| opencode | Native plugin (`src/plugins/opencode/`) copied into `~/.config/opencode/plugins/caveman/` + `AGENTS.md` ruleset + skills/agents/commands directories. Plugin uses `session.created` and `tui.prompt.append` lifecycle hooks. No statusline (opencode TUI exposes no plugin-writable badge). | Yes — `session.created` writes flag, `AGENTS.md` carries always-on ruleset |
| Cursor | Per-user `.cursor/rules/caveman.mdc` written by `src/tools/caveman-init.js` | Yes — always-on rule (after `npx caveman --only cursor`) |
| Windsurf | Per-user `.windsurf/rules/caveman.md` written by `src/tools/caveman-init.js` | Yes — always-on rule (after `npx caveman --only windsurf`) |
| Cline | Per-user `.clinerules/caveman.md` written by `src/tools/caveman-init.js` | Yes — Cline auto-discovers `.clinerules/` |
| Copilot | Per-user `.github/copilot-instructions.md` written by `src/tools/caveman-init.js` + `AGENTS.md` | Yes — repo-wide instructions |
| Others (Junie, Trae, Warp, Tabnine, Mistral, Qwen, Devin, Droid, ForgeCode, Bob, Crush, iFlow, OpenHands, Qoder, Rovo Dev, Replit, Antigravity, …) | `npx skills add JuliusBrussee/caveman -a <profile>` | No — user must say `/caveman` each session |
opencode reaches Tier 1 minus the statusline (opencode's TUI has no plugin-writable badge). Mode flag lives at `~/.config/opencode/.caveman-active` for any external tooling that wants to surface it.
For agents without hook systems, the always-on snippet lives in `INSTALL.md`'s "Want it always on?" section — keep current with `src/rules/caveman-activate.md`.
**Adding a new agent.** Edit the `PROVIDERS` array in `bin/install.js` — single source of truth, no more bash/PS1 dual-source drift. Each entry has `id`, `label`, `mech`, `detect` (clause spec like `command:foo||dir:$HOME/x`), optional `profile` (vercel-labs/skills slug), optional `soft: true` (config-dir-only detection).
+1 -1
View File
@@ -39,6 +39,7 @@ If you want to install for one agent (or want to know exactly what command runs
|---|---|:-:|
| **Claude Code** | `claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman` | Yes |
| **Gemini CLI** | `gemini extensions install https://github.com/JuliusBrussee/caveman` | Yes |
| **opencode** | `node bin/install.js --only opencode` *(or `npx -y github:JuliusBrussee/caveman -- --only opencode`)* | Yes (plugin + AGENTS.md) |
| **Codex CLI** | `npx skills add JuliusBrussee/caveman -a codex` | Yes (in this repo via `.codex/`) |
| **Cursor** | `npx skills add JuliusBrussee/caveman -a cursor` | Yes (rule file) |
| **Windsurf** | `npx skills add JuliusBrussee/caveman -a windsurf` | Yes (rule file) |
@@ -60,7 +61,6 @@ If you want to install for one agent (or want to know exactly what command runs
| **Kiro CLI** | `npx skills add JuliusBrussee/caveman -a kiro-cli` | No |
| **Mistral Vibe** | `npx skills add JuliusBrussee/caveman -a mistral-vibe` | No |
| **OpenHands** | `npx skills add JuliusBrussee/caveman -a openhands` | No |
| **opencode** | `npx skills add JuliusBrussee/caveman -a opencode` | No |
| **Qwen Code** | `npx skills add JuliusBrussee/caveman -a qwen-code` | No |
| **Atlassian Rovo Dev** | `npx skills add JuliusBrussee/caveman -a rovodev` | No |
| **Tabnine CLI** | `npx skills add JuliusBrussee/caveman -a tabnine-cli` | No |
+1
View File
@@ -97,6 +97,7 @@ Detects 30+ agents, runs each one's native install, skips what you not have. Saf
| **Claude Code** | `claude plugin marketplace add JuliusBrussee/caveman && claude plugin install caveman@caveman` |
| **Codex** | `npx skills add JuliusBrussee/caveman -a codex` |
| **Gemini CLI** | `gemini extensions install https://github.com/JuliusBrussee/caveman` |
| **opencode** | `npx -y github:JuliusBrussee/caveman -- --only opencode` |
| **Cursor** | `npx skills add JuliusBrussee/caveman -a cursor` |
| **Windsurf** | `npx skills add JuliusBrussee/caveman -a windsurf` |
| **Cline** | `npx skills add JuliusBrussee/caveman -a cline` |
+223 -1
View File
@@ -146,6 +146,7 @@ function checkNodeVersion() {
const PROVIDERS = [
{ id: 'claude', label: 'Claude Code', mech: 'claude plugin install', detect: 'command:claude' },
{ id: 'gemini', label: 'Gemini CLI', mech: 'gemini extensions install', detect: 'command:gemini' },
{ id: 'opencode', label: 'opencode', mech: 'native opencode plugin', detect: 'command:opencode' },
{ id: 'codex', label: 'Codex CLI', mech: 'npx skills add (codex)', detect: 'command:codex', profile: 'codex' },
// IDE / VS Code-family — extension probes are precise. Cursor/Windsurf also
@@ -179,7 +180,6 @@ const PROVIDERS = [
{ id: 'kiro', label: 'Kiro CLI', mech: 'npx skills add (kiro-cli)', detect: 'command:kiro', profile: 'kiro-cli' },
{ id: 'mistral', label: 'Mistral Vibe', mech: 'npx skills add (mistral-vibe)', detect: 'command:mistral', profile: 'mistral-vibe' },
{ id: 'openhands', label: 'OpenHands', mech: 'npx skills add (openhands)', detect: 'command:openhands', profile: 'openhands' },
{ id: 'opencode', label: 'opencode', mech: 'npx skills add (opencode)', detect: 'command:opencode', profile: 'opencode' },
{ id: 'qwen', label: 'Qwen Code', mech: 'npx skills add (qwen-code)', detect: 'command:qwen', profile: 'qwen-code' },
{ id: 'rovodev', label: 'Atlassian Rovo Dev', mech: 'npx skills add (rovodev)', detect: 'command:rovodev', profile: 'rovodev' },
{ id: 'tabnine', label: 'Tabnine CLI', mech: 'npx skills add (tabnine-cli)', detect: 'command:tabnine', profile: 'tabnine-cli' },
@@ -437,6 +437,171 @@ function installViaSkills(ctx, prov) {
process.stdout.write('\n');
}
// ── opencode native install ───────────────────────────────────────────────
// Drops the in-repo plugin (src/plugins/opencode/) plus skills, agents,
// commands, and an AGENTS.md ruleset into ~/.config/opencode/. Patches
// opencode.json with a "plugin" array entry. Mirrors the Claude Code hook
// architecture as closely as opencode allows — only the statusline is missing
// (opencode's TUI exposes no plugin-writable badge).
const OPENCODE_SKILL_DIRS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew'];
const OPENCODE_AGENT_FILES = ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md'];
const OPENCODE_COMMAND_FILES = ['caveman.md', 'caveman-commit.md', 'caveman-review.md', 'caveman-compress.md', 'caveman-stats.md', 'caveman-help.md'];
const OPENCODE_PLUGIN_REL = './plugins/caveman/plugin.js';
const OPENCODE_AGENTS_MD_SENTINEL = 'Respond terse like smart caveman';
function opencodeConfigDir() {
if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
if (IS_WIN) return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'opencode');
return path.join(os.homedir(), '.config', 'opencode');
}
function copyDirRecursive(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
if (entry.isDirectory()) copyDirRecursive(s, d);
else if (entry.isFile()) fs.copyFileSync(s, d);
}
}
function installOpencode(ctx) {
const { say, note, warn, opts, repoRoot, results } = ctx;
results.detected++;
say('→ opencode detected');
if (!repoRoot) {
warn(' opencode native install requires a local clone of the caveman repo.');
note(' Re-run from a clone: git clone https://github.com/' + REPO + ' && cd caveman && node bin/install.js --only opencode');
results.failed.push(['opencode', 'native install requires local repo clone']);
process.stdout.write('\n');
return;
}
const dir = opencodeConfigDir();
const pluginDir = path.join(dir, 'plugins', 'caveman');
const commandsDir = path.join(dir, 'commands');
const agentsDir = path.join(dir, 'agents');
const skillsDir = path.join(dir, 'skills');
const opencodeJson = path.join(dir, 'opencode.json');
const agentsMd = path.join(dir, 'AGENTS.md');
if (opts.dryRun) {
note(` would mkdir ${pluginDir}/, ${commandsDir}/, ${agentsDir}/, ${skillsDir}/`);
note(` would copy plugin.js + package.json + caveman-config.cjs into ${pluginDir}/`);
note(` would copy ${OPENCODE_COMMAND_FILES.length} command files into ${commandsDir}/`);
note(` would copy ${OPENCODE_AGENT_FILES.length} cavecrew agents into ${agentsDir}/`);
note(` would copy ${OPENCODE_SKILL_DIRS.length} skill dirs into ${skillsDir}/`);
note(` would patch ${opencodeJson} with "plugin" entry${opts.withMcpShrink ? ' + caveman-shrink MCP' : ''}`);
note(` would write Tier-3 ruleset to ${agentsMd}`);
results.installed.push('opencode');
process.stdout.write('\n');
return;
}
try {
// 1. Plugin dir — copy plugin.js, package.json, caveman-config.js (sibling).
fs.mkdirSync(pluginDir, { recursive: true });
const pluginSrc = path.join(repoRoot, 'src', 'plugins', 'opencode');
fs.copyFileSync(path.join(pluginSrc, 'plugin.js'), path.join(pluginDir, 'plugin.js'));
fs.copyFileSync(path.join(pluginSrc, 'package.json'), path.join(pluginDir, 'package.json'));
// Renamed to .cjs because the plugin dir is "type": "module" — a bare .js
// sibling would be loaded as ESM and break the plugin's require() bridge.
fs.copyFileSync(
path.join(repoRoot, 'src', 'hooks', 'caveman-config.js'),
path.join(pluginDir, 'caveman-config.cjs'),
);
process.stdout.write(` installed: ${pluginDir}\n`);
// 2. Commands.
fs.mkdirSync(commandsDir, { recursive: true });
const cmdSrcDir = path.join(pluginSrc, 'commands');
for (const f of OPENCODE_COMMAND_FILES) {
const src = path.join(cmdSrcDir, f);
const dest = path.join(commandsDir, f);
if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest} (exists; --force to overwrite)`); continue; }
fs.copyFileSync(src, dest);
process.stdout.write(` installed: ${dest}\n`);
}
// 3. Subagents.
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);
process.stdout.write(` installed: ${dest}\n`);
}
// 4. Skills — opencode auto-discovers SKILL.md from ~/.config/opencode/skills/.
fs.mkdirSync(skillsDir, { recursive: true });
const skillSrcDir = path.join(repoRoot, 'skills');
for (const name of OPENCODE_SKILL_DIRS) {
const src = path.join(skillSrcDir, name);
const dest = path.join(skillsDir, name);
if (!fs.existsSync(src)) continue;
if (fs.existsSync(dest) && !opts.force) { note(` skipped ${dest}/ (exists; --force to overwrite)`); continue; }
copyDirRecursive(src, dest);
process.stdout.write(` installed: ${dest}/\n`);
}
// 5. AGENTS.md — Tier-3 always-on ruleset. Append-with-sentinel so we
// don't clobber a user-authored AGENTS.md.
const ruleBody = fs.readFileSync(path.join(repoRoot, 'src', 'rules', 'caveman-activate.md'), 'utf8').trimEnd() + '\n';
if (fs.existsSync(agentsMd)) {
const existing = fs.readFileSync(agentsMd, 'utf8');
if (!existing.includes(OPENCODE_AGENTS_MD_SENTINEL)) {
const sep = existing.endsWith('\n\n') ? '' : (existing.endsWith('\n') ? '\n' : '\n\n');
fs.writeFileSync(agentsMd, existing + sep + ruleBody, { mode: 0o644 });
process.stdout.write(` appended caveman ruleset to ${agentsMd}\n`);
} else {
note(` ${agentsMd} already contains caveman ruleset`);
}
} else {
fs.writeFileSync(agentsMd, ruleBody, { mode: 0o644 });
process.stdout.write(` installed: ${agentsMd}\n`);
}
// 6. opencode.json — add plugin entry; optional caveman-shrink MCP.
let cfg = SETTINGS.readSettings(opencodeJson);
if (cfg === null) {
warn(` ${opencodeJson} unparseable; will not touch it. Edit manually then re-run.`);
results.failed.push(['opencode', 'opencode.json unparseable']);
process.stdout.write('\n');
return;
}
if (fs.existsSync(opencodeJson)) {
try { fs.copyFileSync(opencodeJson, opencodeJson + '.bak'); } catch (_) {}
}
if (!Array.isArray(cfg.plugin)) cfg.plugin = [];
if (!cfg.plugin.includes(OPENCODE_PLUGIN_REL)) {
cfg.plugin.push(OPENCODE_PLUGIN_REL);
}
if (opts.withMcpShrink) {
if (!cfg.mcp || typeof cfg.mcp !== 'object') cfg.mcp = {};
if (!cfg.mcp['caveman-shrink']) {
cfg.mcp['caveman-shrink'] = {
type: 'local',
command: ['npx', '-y', MCP_SHRINK_PKG],
enabled: true,
};
process.stdout.write(' registered caveman-shrink MCP server\n');
}
}
SETTINGS.writeSettings(opencodeJson, cfg);
process.stdout.write(` patched: ${opencodeJson}\n`);
results.installed.push('opencode');
} catch (e) {
warn(' opencode install failed: ' + (e && e.message || e));
results.failed.push(['opencode', (e && e.message) || 'unknown error']);
}
process.stdout.write('\n');
}
// ── Hooks installer ────────────────────────────────────────────────────────
// Replaces src/hooks/install.sh + src/hooks/install.ps1.
function installHooks(ctx) {
@@ -659,6 +824,62 @@ function uninstall(ctx) {
runSpawn('gemini', ['extensions', 'uninstall', 'caveman'], null, opts.dryRun);
}
// opencode native install — strip plugin entry, MCP entry, and our files.
// Probed by the existence of the plugin dir we own; if absent, skip silently.
const ocDir = opencodeConfigDir();
const ocPluginDir = path.join(ocDir, 'plugins', 'caveman');
if (fs.existsSync(ocPluginDir)) {
const ocJson = path.join(ocDir, 'opencode.json');
if (fs.existsSync(ocJson)) {
const cfg = SETTINGS.readSettings(ocJson);
if (cfg) {
if (Array.isArray(cfg.plugin)) {
cfg.plugin = cfg.plugin.filter(p => p !== OPENCODE_PLUGIN_REL);
if (cfg.plugin.length === 0) delete cfg.plugin;
}
if (cfg.mcp && typeof cfg.mcp === 'object' && cfg.mcp['caveman-shrink']) {
delete cfg.mcp['caveman-shrink'];
if (Object.keys(cfg.mcp).length === 0) delete cfg.mcp;
}
if (!opts.dryRun) SETTINGS.writeSettings(ocJson, cfg);
ok(` pruned caveman entries from ${ocJson}`);
}
}
if (!opts.dryRun) { try { fs.rmSync(ocPluginDir, { recursive: true, force: true }); } catch (_) {} }
note(` removed ${ocPluginDir}`);
// Commands, agents, skills — only files matching our manifest (don't
// sweep the parent dirs; user may have other entries there).
for (const f of OPENCODE_COMMAND_FILES) {
const p = path.join(ocDir, 'commands', f);
if (fs.existsSync(p) && !opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} }
}
for (const f of OPENCODE_AGENT_FILES) {
const p = path.join(ocDir, 'agents', f);
if (fs.existsSync(p) && !opts.dryRun) { try { fs.unlinkSync(p); } catch (_) {} }
}
for (const name of OPENCODE_SKILL_DIRS) {
const p = path.join(ocDir, 'skills', name);
if (fs.existsSync(p) && !opts.dryRun) { try { fs.rmSync(p, { recursive: true, force: true }); } catch (_) {} }
}
// AGENTS.md — only remove if our sentinel is the entire content (we
// wrote it) or strip our block from a mixed file.
const ocAgentsMd = path.join(ocDir, 'AGENTS.md');
if (fs.existsSync(ocAgentsMd)) {
const body = fs.readFileSync(ocAgentsMd, 'utf8');
if (body.includes(OPENCODE_AGENTS_MD_SENTINEL)) {
if (body.trim() === '' || body.trim().startsWith(OPENCODE_AGENTS_MD_SENTINEL)) {
if (!opts.dryRun) { try { fs.unlinkSync(ocAgentsMd); } catch (_) {} }
note(` removed ${ocAgentsMd}`);
} else {
note(` left ${ocAgentsMd} in place (mixed content — strip caveman block manually)`);
}
}
}
// opencode flag file
const ocFlag = path.join(ocDir, '.caveman-active');
if (fs.existsSync(ocFlag) && !opts.dryRun) { try { fs.unlinkSync(ocFlag); } catch (_) {} }
}
// Flag file
const flag = path.join(configDir, '.caveman-active');
if (fs.existsSync(flag) && !opts.dryRun) { try { fs.unlinkSync(flag); } catch (_) {} }
@@ -795,6 +1016,7 @@ async function main() {
if (!detectMatch(prov.detect)) continue;
if (prov.id === 'claude') { installClaude(ctx); continue; }
if (prov.id === 'gemini') { installGemini(ctx); continue; }
if (prov.id === 'opencode') { installOpencode(ctx); continue; }
if (prov.profile) { installViaSkills(ctx, prov); continue; }
}
+46
View File
@@ -0,0 +1,46 @@
# caveman — opencode plugin
Native opencode plugin. Mirrors the Claude Code hook architecture using
opencode's `session.created` + `tui.prompt.append` lifecycle hooks.
## What this ships
| File | Role |
|---|---|
| `plugin.js` | ESM Bun module. Default-exports an opencode `Plugin` factory. |
| `package.json` | Marks the directory as ESM so Bun loads `plugin.js` correctly. |
| `commands/*.md` | Six slash-command prompt templates (`/caveman`, `/caveman-commit`, …). |
The installer (`bin/install.js --only opencode`) copies these alongside
`src/hooks/caveman-config.js` (for the symlink-safe flag-write helpers, renamed
to `caveman-config.cjs` because this directory is `"type": "module"`) into
`~/.config/opencode/plugins/caveman/` and patches `opencode.json` with a
`"plugin"` array entry.
## What it does
- `session.created` → writes the configured default mode to
`~/.config/opencode/.caveman-active` via the same `safeWriteFlag` helper
Claude Code uses (O_NOFOLLOW, atomic temp+rename, 0600 perms, symlink
refusal, ownership check).
- `tui.prompt.append` → flips the flag in response to `/caveman[ <level>]`,
`/caveman-commit`, `/caveman-review`, `/caveman-compress`, and natural
language ("turn on caveman", "stop caveman", "normal mode"). When a
non-independent mode is active, appends a one-line reinforcement to keep
caveman in the model's attention each turn.
## What it does NOT do
- **No statusline badge.** opencode's TUI does not expose a plugin-writable
statusline. The flag file is at `~/.config/opencode/.caveman-active` if
you want to surface mode in your shell prompt.
- **No system-prompt injection from `session.created`.** opencode's docs
don't expose a return shape for that. The always-on caveman ruleset comes
from `~/.config/opencode/AGENTS.md` (also written by the installer) so
the rules load even when the plugin runtime is broken.
## Why no separate npm package
Plugin code reuses `caveman-config.js` from the main repo. Shipping as an
in-repo plugin avoids a second release cadence and a name collision with
the existing third-party `opencode-caveman` npm package.
@@ -0,0 +1,8 @@
---
description: Generate a terse caveman-style commit message for staged changes
---
Generate a commit message for the current staged changes.
Conventional Commits format. Subject line ≤50 chars, imperative, lowercase
after the type. No period on subject. Body only when the "why" isn't obvious
from the subject — explain why over what. Drop filler, hedging, padding.
@@ -0,0 +1,20 @@
---
description: Quick reference card for caveman modes, slash commands, and triggers
---
Show the caveman quick-reference card.
| Command | What |
|---|---|
| `/caveman` | Activate at default level (full) |
| `/caveman lite` | Light compression — ~30% tokens dropped |
| `/caveman ultra` | Maximum compression |
| `/caveman wenyan[-lite\|-ultra]` | Classical Chinese compression |
| `/caveman off` | Deactivate |
| `/caveman-commit` | Terse commit message |
| `/caveman-review` | One-line review findings |
| `/caveman-compress <file>` | Compress a Markdown file |
| `/caveman-stats` | Lifetime token-savings |
Natural language also works: "turn on caveman", "stop caveman", "normal mode".
Code, commits, security warnings: caveman drops out automatically.
@@ -0,0 +1,8 @@
---
description: Caveman-style code review — one-line findings with severity
---
Review the current diff (or files: $ARGUMENTS).
One line per finding. Format: `L<line>: <severity> <problem>. <fix>.`
Severity emoji: 🔴 critical · 🟡 warn · 🟢 nit. Skip non-issues.
Group by file. End with a one-line verdict.
@@ -0,0 +1,8 @@
---
description: Show caveman lifetime token-savings stats
---
Show caveman stats — total tokens saved, sessions, average compression ratio.
Read the lifetime history log at `~/.config/caveman/.caveman-history.jsonl`
(or wherever the caveman-stats script writes it). Output: total saved,
sessions counted, avg ratio. One short table.
+13
View File
@@ -0,0 +1,13 @@
---
description: Activate caveman compression mode (lite | full | ultra | wenyan | off)
---
Activate caveman mode: $ARGUMENTS
If no level given, use full. If "off", deactivate.
Respond terse like smart caveman. Drop articles, filler, pleasantries, hedging.
Fragments OK. Technical terms exact. Code unchanged.
Pattern: [thing] [action] [reason]. [next step].
Behavior persists until session ends or user says "stop caveman" / "normal mode".
Code, commits, security warnings: write normal English.
+8
View File
@@ -0,0 +1,8 @@
{
"name": "caveman-opencode-plugin",
"version": "0.1.0",
"description": "caveman plugin for opencode — terse mode tracking + slash commands",
"type": "module",
"main": "plugin.js",
"private": true
}
+154
View File
@@ -0,0 +1,154 @@
// caveman — opencode plugin
//
// Mirrors the Claude Code SessionStart + UserPromptSubmit hook pair using
// opencode's lifecycle hook system. Bun ESM module; loads the existing
// security-hardened helpers from caveman-config.js via createRequire so the
// symlink-safe flag-write code lives in one place.
//
// Layout once installed:
// ~/.config/opencode/plugins/caveman/
// ├── package.json
// ├── plugin.js ← this file
// └── caveman-config.js ← copied sibling of src/hooks/caveman-config.js
//
// Always-on caveman ruleset is provided separately via
// ~/.config/opencode/AGENTS.md (Tier-3 base) so this plugin only handles
// dynamic state — flag writes, slash-command parsing, natural-language
// activation, and per-prompt reinforcement. opencode's `session.created`
// payload doesn't expose a documented system-prompt-injection return, so we
// don't try to emit ruleset content here.
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { existsSync, unlinkSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));
// When installed: caveman-config.cjs sits next to plugin.js (copied by
// bin/install.js, renamed to .cjs because this directory's package.json
// declares "type": "module" — bare .js would be loaded as ESM and break
// require()). When loaded from the source tree (tests, dev): fall back
// to the canonical src/hooks/caveman-config.js, which lives in a directory
// whose own package.json pins "type": "commonjs". One source of truth
// either way.
function loadConfig() {
try { return require(join(here, 'caveman-config.cjs')); }
catch (_) { return require(join(here, '..', '..', 'hooks', 'caveman-config.js')); }
}
const config = loadConfig();
const { getDefaultMode, safeWriteFlag, readFlag, VALID_MODES } = config;
// Modes handled by independent skills — not selectable via /caveman <arg>.
const INDEPENDENT_MODES = new Set(['commit', 'review', 'compress']);
function opencodeConfigDir() {
if (process.env.XDG_CONFIG_HOME) {
return path.join(process.env.XDG_CONFIG_HOME, 'opencode');
}
if (process.platform === 'win32') {
return path.join(
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
'opencode'
);
}
return path.join(os.homedir(), '.config', 'opencode');
}
const flagPath = path.join(opencodeConfigDir(), '.caveman-active');
function reinforcementLine(mode) {
return 'CAVEMAN MODE ACTIVE (' + mode + '). ' +
'Drop articles/filler/pleasantries/hedging. Fragments OK. ' +
'Code/commits/security: write normal.';
}
// Parse a prompt for slash-command activation or natural-language toggles.
// Returns the new mode to write, the literal string 'off' to deactivate, or
// null when the prompt doesn't change state. Mirrors caveman-mode-tracker.js.
function parseModeChange(promptRaw) {
const prompt = (promptRaw || '').trim().toLowerCase();
if (!prompt) return null;
// Natural-language deactivation — checked before activation so "stop talking
// like caveman" doesn't trip the activation regex.
if (/\b(stop|disable|deactivate|turn off)\b.*\bcaveman\b/i.test(prompt) ||
/\bcaveman\b.*\b(stop|disable|deactivate|turn off)\b/i.test(prompt) ||
/\bnormal mode\b/i.test(prompt)) {
return 'off';
}
// Natural-language activation
if (/\b(activate|enable|turn on|start|talk like)\b.*\bcaveman\b/i.test(prompt) ||
/\bcaveman\b.*\b(mode|activate|enable|turn on|start)\b/i.test(prompt)) {
const mode = getDefaultMode();
return mode === 'off' ? null : mode;
}
// Slash-command parsing — opencode also expands command files, but if the
// user types the literal slash command we still want to flip the flag.
if (prompt.startsWith('/caveman')) {
const parts = prompt.split(/\s+/);
const cmd = parts[0];
const arg = parts[1] || '';
if (cmd === '/caveman-commit') return 'commit';
if (cmd === '/caveman-review') return 'review';
if (cmd === '/caveman-compress') return 'compress';
if (cmd === '/caveman') {
if (!arg) return getDefaultMode();
if (arg === 'off' || arg === 'stop' || arg === 'disable') return 'off';
if (arg === 'wenyan-full') return 'wenyan';
if (VALID_MODES.includes(arg) && !INDEPENDENT_MODES.has(arg)) return arg;
// Unknown arg — leave flag alone. No silent overwrite.
return null;
}
}
return null;
}
function applyModeChange(mode) {
if (!mode) return;
if (mode === 'off') {
try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
return;
}
safeWriteFlag(flagPath, mode);
}
export const CavemanPlugin = async (_ctx) => ({
'session.created': async () => {
const mode = getDefaultMode();
if (mode === 'off') {
try { if (existsSync(flagPath)) unlinkSync(flagPath); } catch (e) {}
return;
}
safeWriteFlag(flagPath, mode);
},
// opencode's TUI prompt-append hook fires before the prompt is sent to the
// model. We use it for two things: react to mode-changing prompts (slash
// commands + natural language), and append a one-line reinforcement when
// caveman is active so the model can't drift mid-session. Returning an
// object with `append` is the documented way to inject prompt content.
'tui.prompt.append': async (input) => {
const promptText = (input && (input.prompt || input.text)) || '';
const change = parseModeChange(promptText);
if (change) applyModeChange(change);
const active = readFlag(flagPath);
if (active && !INDEPENDENT_MODES.has(active)) {
return { append: reinforcementLine(active) };
}
return undefined;
},
});
export default CavemanPlugin;
+3
View File
@@ -47,6 +47,9 @@ const AGENTS = [
{ id: 'copilot', file: '.github/copilot-instructions.md',
frontmatter: '',
mode: 'append' },
{ id: 'opencode', file: '.opencode/AGENTS.md',
frontmatter: '',
mode: 'append' },
{ id: 'agents', file: 'AGENTS.md',
frontmatter: '',
mode: 'append' },
+217
View File
@@ -0,0 +1,217 @@
// opencode native install — fresh install, idempotency, uninstall, plugin smoke.
//
// Detection of opencode is gated behind `command -v opencode`, so to run on a
// CI box without opencode installed we prepend a tmpdir with a no-op `opencode`
// shim to PATH. The installer's per-provider dispatch only checks PATH; it
// never invokes the binary itself.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..', '..');
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
const requireCjs = createRequire(import.meta.url);
const SETTINGS = requireCjs(path.join(REPO_ROOT, 'bin', 'lib', 'settings.js'));
const IS_WIN = process.platform === 'win32';
function freshTmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-opencode-'));
}
// Make a throwaway `opencode` binary on PATH so detectMatch('command:opencode')
// returns true. The shim never executes — installer only checks PATH presence.
function shimOpencode() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cm-shim-'));
if (IS_WIN) {
fs.writeFileSync(path.join(dir, 'opencode.cmd'), '@echo off\r\n');
} else {
const f = path.join(dir, 'opencode');
fs.writeFileSync(f, '#!/bin/sh\nexit 0\n');
fs.chmodSync(f, 0o755);
}
return dir;
}
function runInstaller(args, env) {
return spawnSync('node', [INSTALLER, ...args, '--non-interactive', '--no-mcp-shrink'], {
env, encoding: 'utf8',
});
}
function pathWith(prependDir) {
const sep = IS_WIN ? ';' : ':';
return prependDir + sep + (process.env.PATH || '');
}
// ── 1. Fresh install populates expected files ────────────────────────────
test('opencode fresh install drops plugin, commands, agents, skills, AGENTS.md, opencode.json', () => {
const xdg = freshTmpDir();
const shimDir = shimOpencode();
try {
const r = runInstaller(['--only', 'opencode'], {
...process.env,
XDG_CONFIG_HOME: xdg,
PATH: pathWith(shimDir),
NO_COLOR: '1',
});
assert.notEqual(r.status, 2, `argv error: ${r.stderr}`);
const ocDir = path.join(xdg, 'opencode');
assert.ok(fs.existsSync(path.join(ocDir, 'plugins', 'caveman', 'plugin.js')), 'plugin.js missing');
assert.ok(fs.existsSync(path.join(ocDir, 'plugins', 'caveman', 'package.json')), 'plugin package.json missing');
assert.ok(fs.existsSync(path.join(ocDir, 'plugins', 'caveman', 'caveman-config.cjs')), 'caveman-config.cjs sibling missing');
for (const f of ['caveman.md', 'caveman-commit.md', 'caveman-review.md', 'caveman-compress.md', 'caveman-stats.md', 'caveman-help.md']) {
assert.ok(fs.existsSync(path.join(ocDir, 'commands', f)), `command ${f} missing`);
}
for (const f of ['cavecrew-investigator.md', 'cavecrew-builder.md', 'cavecrew-reviewer.md']) {
assert.ok(fs.existsSync(path.join(ocDir, 'agents', f)), `agent ${f} missing`);
}
for (const name of ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew']) {
assert.ok(fs.existsSync(path.join(ocDir, 'skills', name, 'SKILL.md')), `skill ${name}/SKILL.md missing`);
}
assert.ok(fs.existsSync(path.join(ocDir, 'AGENTS.md')), 'AGENTS.md missing');
assert.match(fs.readFileSync(path.join(ocDir, 'AGENTS.md'), 'utf8'), /Respond terse like smart caveman/);
const cfgPath = path.join(ocDir, 'opencode.json');
assert.ok(fs.existsSync(cfgPath), 'opencode.json missing');
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
assert.ok(Array.isArray(cfg.plugin), 'opencode.json missing plugin array');
assert.ok(cfg.plugin.includes('./plugins/caveman/plugin.js'), 'plugin entry missing');
} finally {
fs.rmSync(xdg, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
}
});
// ── 2. Idempotency: install twice, plugin array stays length 1 ───────────
test('opencode idempotent install does not duplicate plugin entries', () => {
const xdg = freshTmpDir();
const shimDir = shimOpencode();
try {
const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
const r1 = runInstaller(['--only', 'opencode'], env);
assert.notEqual(r1.status, 2);
const r2 = runInstaller(['--only', 'opencode'], env);
assert.notEqual(r2.status, 2);
const cfg = JSON.parse(fs.readFileSync(path.join(xdg, 'opencode', 'opencode.json'), 'utf8'));
const matches = cfg.plugin.filter(p => p === './plugins/caveman/plugin.js');
assert.equal(matches.length, 1, `expected 1 plugin entry, got ${matches.length}`);
// AGENTS.md should not have the ruleset duplicated either.
const agentsMd = fs.readFileSync(path.join(xdg, 'opencode', 'AGENTS.md'), 'utf8');
const sentinelCount = (agentsMd.match(/Respond terse like smart caveman/g) || []).length;
assert.equal(sentinelCount, 1, `expected 1 sentinel, got ${sentinelCount}`);
} finally {
fs.rmSync(xdg, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
}
});
// ── 3. Tolerates JSONC opencode.json (#249-class regression guard) ───────
test('opencode install tolerates JSONC opencode.json (comments + trailing commas)', () => {
const xdg = freshTmpDir();
const shimDir = shimOpencode();
try {
const ocDir = path.join(xdg, 'opencode');
fs.mkdirSync(ocDir, { recursive: true });
fs.writeFileSync(path.join(ocDir, 'opencode.json'),
`// hand-written
{
/* user prefs */
"model": "anthropic/claude-sonnet-4-5",
"theme": "dark",
}
`);
const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
const r = runInstaller(['--only', 'opencode'], env);
assert.notEqual(r.status, 2);
const cfg = JSON.parse(fs.readFileSync(path.join(ocDir, 'opencode.json'), 'utf8'));
assert.equal(cfg.model, 'anthropic/claude-sonnet-4-5', 'user model setting wiped');
assert.equal(cfg.theme, 'dark', 'user theme setting wiped');
assert.ok(cfg.plugin.includes('./plugins/caveman/plugin.js'), 'plugin entry missing');
} finally {
fs.rmSync(xdg, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
}
});
// ── 4. Uninstall removes opencode artifacts and prunes config ────────────
test('opencode uninstall removes plugin dir, command/agent/skill files, prunes opencode.json', () => {
const xdg = freshTmpDir();
const shimDir = shimOpencode();
try {
const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
const r1 = runInstaller(['--only', 'opencode'], env);
assert.notEqual(r1.status, 2);
const r2 = runInstaller(['--uninstall'], env);
assert.notEqual(r2.status, 2);
const ocDir = path.join(xdg, 'opencode');
assert.equal(fs.existsSync(path.join(ocDir, 'plugins', 'caveman')), false, 'plugin dir survived');
assert.equal(fs.existsSync(path.join(ocDir, 'commands', 'caveman.md')), false, 'caveman.md command survived');
assert.equal(fs.existsSync(path.join(ocDir, 'agents', 'cavecrew-builder.md')), false, 'cavecrew agent survived');
assert.equal(fs.existsSync(path.join(ocDir, 'skills', 'caveman')), false, 'caveman skill dir survived');
assert.equal(fs.existsSync(path.join(ocDir, 'AGENTS.md')), false, 'AGENTS.md (we wrote it) survived');
if (fs.existsSync(path.join(ocDir, 'opencode.json'))) {
const cfg = JSON.parse(fs.readFileSync(path.join(ocDir, 'opencode.json'), 'utf8'));
const stillHasPlugin = Array.isArray(cfg.plugin) && cfg.plugin.includes('./plugins/caveman/plugin.js');
assert.equal(stillHasPlugin, false, 'plugin entry survived in opencode.json');
}
} finally {
fs.rmSync(xdg, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
}
});
// ── 5. Plugin smoke: load installed plugin.js, fire fake hooks ────────────
test('opencode plugin handles /caveman ultra and stop caveman via tui.prompt.append', async () => {
const xdg = freshTmpDir();
const shimDir = shimOpencode();
try {
const env = { ...process.env, XDG_CONFIG_HOME: xdg, PATH: pathWith(shimDir), NO_COLOR: '1' };
const r = runInstaller(['--only', 'opencode'], env);
assert.notEqual(r.status, 2);
const pluginPath = path.join(xdg, 'opencode', 'plugins', 'caveman', 'plugin.js');
const flagPath = path.join(xdg, 'opencode', '.caveman-active');
// Set XDG_CONFIG_HOME for the plugin so flagPath resolves to our temp dir.
process.env.XDG_CONFIG_HOME = xdg;
const mod = await import(pathToFileURL(pluginPath).href);
const factory = mod.default || mod.CavemanPlugin;
const handlers = await factory({});
// Slash command activates ultra
const out1 = await handlers['tui.prompt.append']({ prompt: '/caveman ultra' });
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'ultra');
assert.ok(out1 && typeof out1.append === 'string', 'expected reinforcement append');
assert.match(out1.append, /CAVEMAN MODE ACTIVE \(ultra\)/);
// Natural-language deactivation removes flag
const out2 = await handlers['tui.prompt.append']({ prompt: 'stop caveman please' });
assert.equal(fs.existsSync(flagPath), false, 'flag should be deleted after deactivation');
assert.equal(out2, undefined, 'no reinforcement when flag absent');
// session.created writes default mode
await handlers['session.created']();
assert.equal(fs.readFileSync(flagPath, 'utf8'), 'full');
} finally {
fs.rmSync(xdg, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
}
});