mirror of
https://github.com/JuliusBrussee/caveman.git
synced 2026-08-11 13:21:09 +02:00
fix(#524): make Hermes install/uninstall symmetric + verify against real Hermes
Adversarial review against a live Hermes Agent install (v0.11.0) found the forward install was correct (skills land in the real ~/.hermes/skills/ productivity/<skill>/ layout, all 7 load as 'enabled' via 'hermes skills list' — verified empirically, no version: field required), but uninstall had NO Hermes handling: --uninstall silently orphaned all 7 skill folders forever. - add Hermes block to uninstall() honoring HERMES_HOME (mirrors opencode/openclaw) - tests/installer/hermes.test.mjs: install lands 7 skills, uninstall removes them (regression guard for the asymmetry), dry-run uninstall is a no-op - INSTALL.md: add Hermes Agent row to the per-agent install table (CLAUDE.md mandates the install table stay complete) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
6ebdb375c4
commit
e8139f86e3
@@ -43,6 +43,7 @@ If you want to install for one agent (or want to know exactly what command runs
|
||||
| **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) |
|
||||
| **OpenClaw** | `npx -y github:JuliusBrussee/caveman -- --only openclaw` | Yes (workspace skill + SOUL.md) |
|
||||
| **Hermes Agent** | `npx -y github:JuliusBrussee/caveman -- --only hermes` *(or `node bin/install.js --only hermes` from a clone)* | Yes (native skills, enabled on load) |
|
||||
| **Codex CLI** | `npx skills add JuliusBrussee/caveman -a codex` | Per-session: `/caveman` |
|
||||
| **Cursor** | `npx skills add JuliusBrussee/caveman -a cursor` | Per-session by default; `--with-init` for an always-on rule file |
|
||||
| **Windsurf** | `npx skills add JuliusBrussee/caveman -a windsurf` | Per-session by default; `--with-init` for an always-on rule file |
|
||||
|
||||
@@ -1247,6 +1247,22 @@ function uninstall(ctx) {
|
||||
if (r.touched) ok(' pruned caveman entries from OpenClaw workspace');
|
||||
}
|
||||
|
||||
// Hermes native install — remove the skill folders installHermes copied.
|
||||
// Honors HERMES_HOME via hermesConfigDir(); probed by the dirs we own.
|
||||
const hermesRoot = path.join(hermesConfigDir(), 'productivity');
|
||||
if (fs.existsSync(hermesRoot)) {
|
||||
let prunedHermes = false;
|
||||
for (const name of HERMES_SKILL_DIRS) {
|
||||
const p = path.join(hermesRoot, name);
|
||||
if (fs.existsSync(p)) {
|
||||
if (!opts.dryRun) { try { fs.rmSync(p, { recursive: true, force: true }); } catch (_) {} }
|
||||
note(` removed ${p}`);
|
||||
prunedHermes = true;
|
||||
}
|
||||
}
|
||||
if (prunedHermes) ok(' pruned caveman skills from Hermes');
|
||||
}
|
||||
|
||||
// Flag file
|
||||
const flag = path.join(configDir, '.caveman-active');
|
||||
if (fs.existsSync(flag) && !opts.dryRun) { try { fs.unlinkSync(flag); } catch (_) {} }
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Hermes Agent native install — fresh install lands skills, uninstall removes them.
|
||||
//
|
||||
// Hermes loads skills from <HERMES_HOME>/skills/<category>/<skill>/SKILL.md
|
||||
// (verified against a live `hermes skills list`). The installer copies the 7
|
||||
// caveman skill dirs into the `productivity/` category. `--only hermes` makes
|
||||
// the provider explicit, so no `hermes` binary needs to be on PATH for the
|
||||
// dispatch to run — we drive it purely through a throwaway HERMES_HOME.
|
||||
//
|
||||
// The uninstall test is the important one: PR #524 shipped installHermes with
|
||||
// NO matching uninstall block, so `--uninstall` silently orphaned all 7 skill
|
||||
// folders forever. This pins the symmetry so it cannot regress.
|
||||
|
||||
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 } from 'node:url';
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(HERE, '..', '..');
|
||||
const INSTALLER = path.join(REPO_ROOT, 'bin', 'install.js');
|
||||
|
||||
const SKILLS = ['caveman', 'caveman-commit', 'caveman-review', 'caveman-help', 'caveman-stats', 'caveman-compress', 'cavecrew'];
|
||||
|
||||
function freshHome() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'caveman-hermes-'));
|
||||
}
|
||||
|
||||
function runInstaller(args, hermesHome) {
|
||||
return spawnSync('node', [INSTALLER, ...args, '--non-interactive', '--no-mcp-shrink'], {
|
||||
env: { ...process.env, HERMES_HOME: hermesHome, NO_COLOR: '1' },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function productivityDir(hermesHome) {
|
||||
return path.join(hermesHome, 'skills', 'productivity');
|
||||
}
|
||||
|
||||
// ── 1. Fresh install drops all 7 skills with SKILL.md in the productivity category ──
|
||||
test('hermes fresh install lands 7 skill dirs with SKILL.md under skills/productivity/', () => {
|
||||
const home = freshHome();
|
||||
try {
|
||||
const r = runInstaller(['--only', 'hermes'], home);
|
||||
assert.notEqual(r.status, 2, `argv error: ${r.stderr}`);
|
||||
|
||||
const prod = productivityDir(home);
|
||||
for (const name of SKILLS) {
|
||||
assert.ok(fs.existsSync(path.join(prod, name, 'SKILL.md')), `skill ${name}/SKILL.md missing`);
|
||||
}
|
||||
// caveman-compress ships executable scripts — ensure the recursive copy kept them.
|
||||
assert.ok(fs.existsSync(path.join(prod, 'caveman-compress', 'scripts')), 'caveman-compress/scripts/ not copied');
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2. Uninstall removes every skill we installed (regression guard for #524) ──
|
||||
test('hermes uninstall removes all installed caveman skills (no orphans)', () => {
|
||||
const home = freshHome();
|
||||
try {
|
||||
const r1 = runInstaller(['--only', 'hermes'], home);
|
||||
assert.notEqual(r1.status, 2);
|
||||
const prod = productivityDir(home);
|
||||
for (const name of SKILLS) {
|
||||
assert.ok(fs.existsSync(path.join(prod, name)), `precondition: ${name} should be installed`);
|
||||
}
|
||||
|
||||
const r2 = runInstaller(['--uninstall'], home);
|
||||
assert.notEqual(r2.status, 2);
|
||||
|
||||
for (const name of SKILLS) {
|
||||
assert.equal(fs.existsSync(path.join(prod, name)), false, `${name} survived uninstall (orphaned skill)`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ── 3. Dry-run uninstall must NOT delete anything ──
|
||||
test('hermes dry-run uninstall leaves skills in place', () => {
|
||||
const home = freshHome();
|
||||
try {
|
||||
runInstaller(['--only', 'hermes'], home);
|
||||
const r = runInstaller(['--uninstall', '--dry-run'], home);
|
||||
assert.notEqual(r.status, 2);
|
||||
|
||||
const prod = productivityDir(home);
|
||||
for (const name of SKILLS) {
|
||||
assert.ok(fs.existsSync(path.join(prod, name)), `${name} was deleted by a dry-run uninstall`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user