mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
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:
co-authored by
Claude Opus 4.7
parent
1ce38a2295
commit
8b8068d8ca
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user