diff --git a/CLAUDE.md b/CLAUDE.md index f3ba9ad..210799c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` | 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). diff --git a/INSTALL.md b/INSTALL.md index 32628cc..389f757 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 | diff --git a/README.md b/README.md index 9466411..a1a1038 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/bin/install.js b/bin/install.js index 1575397..cdd9dd1 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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; } } diff --git a/src/plugins/opencode/README.md b/src/plugins/opencode/README.md new file mode 100644 index 0000000..1c68198 --- /dev/null +++ b/src/plugins/opencode/README.md @@ -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[ ]`, + `/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. diff --git a/src/plugins/opencode/commands/caveman-commit.md b/src/plugins/opencode/commands/caveman-commit.md new file mode 100644 index 0000000..7c5025d --- /dev/null +++ b/src/plugins/opencode/commands/caveman-commit.md @@ -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. diff --git a/src/plugins/opencode/commands/caveman-help.md b/src/plugins/opencode/commands/caveman-help.md new file mode 100644 index 0000000..4538f88 --- /dev/null +++ b/src/plugins/opencode/commands/caveman-help.md @@ -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 ` | 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. diff --git a/src/plugins/opencode/commands/caveman-review.md b/src/plugins/opencode/commands/caveman-review.md new file mode 100644 index 0000000..0bfdc57 --- /dev/null +++ b/src/plugins/opencode/commands/caveman-review.md @@ -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: . .` +Severity emoji: 🔴 critical · 🟡 warn · 🟢 nit. Skip non-issues. +Group by file. End with a one-line verdict. diff --git a/src/plugins/opencode/commands/caveman-stats.md b/src/plugins/opencode/commands/caveman-stats.md new file mode 100644 index 0000000..b76ec27 --- /dev/null +++ b/src/plugins/opencode/commands/caveman-stats.md @@ -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. diff --git a/src/plugins/opencode/commands/caveman.md b/src/plugins/opencode/commands/caveman.md new file mode 100644 index 0000000..10f98c7 --- /dev/null +++ b/src/plugins/opencode/commands/caveman.md @@ -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. diff --git a/src/plugins/opencode/package.json b/src/plugins/opencode/package.json new file mode 100644 index 0000000..f0971a1 --- /dev/null +++ b/src/plugins/opencode/package.json @@ -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 +} diff --git a/src/plugins/opencode/plugin.js b/src/plugins/opencode/plugin.js new file mode 100644 index 0000000..7f3a870 --- /dev/null +++ b/src/plugins/opencode/plugin.js @@ -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 . +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; diff --git a/src/tools/caveman-init.js b/src/tools/caveman-init.js index 052ccb2..49b6dce 100644 --- a/src/tools/caveman-init.js +++ b/src/tools/caveman-init.js @@ -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' }, diff --git a/tests/installer/opencode.test.mjs b/tests/installer/opencode.test.mjs new file mode 100644 index 0000000..395ad79 --- /dev/null +++ b/tests/installer/opencode.test.mjs @@ -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 }); + } +});