ruvnet-brain 3.4.21-dev β 3.4.22-dev
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/install.mjs +543 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
# π§ RuvNet Brain
|
|
6
6
|
|
|
7
|
-
### π§ RuvNet Brain β [](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
|
|
8
8
|
|
|
9
9
|
**A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack β delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
|
|
10
10
|
|
|
@@ -271,7 +271,7 @@ Plus: the **βtake the wheelβ behavioral pipeline** (below), a **4-level beha
|
|
|
271
271
|
|
|
272
272
|
## How it works
|
|
273
273
|
|
|
274
|
-
The expensive work happens **once, at build time**: every covered repo is deep-walked (whole files, full function bodies, plus a symbol index), embedded into **two** vector variants (MiniLM-384 for edge/portability, bge-768 for depth) stored on-disk in **RVF / HNSW**, and distilled into a concepts + capability layer of per-repo primers and cards. That's **149,
|
|
274
|
+
The expensive work happens **once, at build time**: every covered repo is deep-walked (whole files, full function bodies, plus a symbol index), embedded into **two** vector variants (MiniLM-384 for edge/portability, bge-768 for depth) stored on-disk in **RVF / HNSW**, and distilled into a concepts + capability layer of per-repo primers and cards. That's **149,691 source chunks**. At **query time**, `search_ruvnet` searches every repo's store at once, pools the hits, and runs them through **one cross-encoder rerank** on a common scale β so the truly relevant file wins regardless of which repo it lives in β then returns whole source files, each labeled by repo and path.
|
|
275
275
|
|
|
276
276
|

|
|
277
277
|
|
|
@@ -381,7 +381,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
|
|
|
381
381
|
|
|
382
382
|
This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) β we don't claim βdone,β βcomplete,β or βzero hallucinations.β Where it stands:
|
|
383
383
|
|
|
384
|
-
- β
**The grounding brain is real and proven** β 54 public stores Β· 149,
|
|
384
|
+
- β
**The grounding brain is real and proven** β 54 public stores Β· 149,691 public source chunks (57 built stores incl. private), dual embeddings, cross-encoder rerank, plugin (MCP tool + enforcement hook + skill), all re-runnable.
|
|
385
385
|
- β
**Code-level depth** β the code-rich repos are indexed to full function bodies; βhow is it implemented?β returns the implementation. Verified in the shipped bundle (clean-room 3/3).
|
|
386
386
|
- β
**Routing holds** β named 47/48, described 26/28, scenario 7/8; behavioral L1βL4 all pass; private stores fenced out of the public bundle (zero-leak verified).
|
|
387
387
|
- β οΈ **Two routing residuals** (above) β surfaced, not hidden.
|
package/bin/install.mjs
CHANGED
|
@@ -73,6 +73,11 @@ const FLAG_ENABLE_NIGHTLY = argv.includes('--enable-nightly'); // schedule that
|
|
|
73
73
|
const FLAG_DISABLE_NIGHTLY = argv.includes('--disable-nightly'); // remove the nightly schedule
|
|
74
74
|
const FLAG_NO_NIGHTLY_PROMPT = argv.includes('--no-nightly-prompt'); // don't offer nightly auto-updates at the end of an install
|
|
75
75
|
const FLAG_NO_TELEMETRY = argv.includes('--no-telemetry'); // decline anonymous usage counts without being asked
|
|
76
|
+
// High-impact, so it needs its OWN flag β `-y` cannot install a launchd job (see ask()'s note).
|
|
77
|
+
const FLAG_ENABLE_SPEND_GUARD = argv.includes('--enable-spend-guard');
|
|
78
|
+
const FLAG_DISABLE_SPEND_GUARD = argv.includes('--disable-spend-guard'); // the missing undo
|
|
79
|
+
const FLAG_UNINSTALL = argv.includes('--uninstall'); // reverse everything, in one command
|
|
80
|
+
const FLAG_WHAT_CHANGED = argv.includes('--what-changed'); // show our footprint on this machine
|
|
76
81
|
// ββ onboarding-experience flags (all optional; every offer is safe to decline) ββ
|
|
77
82
|
const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
|
|
78
83
|
const FLAG_WITH_STACK = argv.includes('--with-stack'); // add missing Ruflo/RuVector without prompting
|
|
@@ -459,6 +464,28 @@ function installReader(cacheDir) {
|
|
|
459
464
|
ok('reader installed');
|
|
460
465
|
}
|
|
461
466
|
|
|
467
|
+
// ββ plugin presence: the ONLY reliable proof the slash commands will exist βββββββββββββββββββββββ
|
|
468
|
+
// Reported by a user on 3.4.21-dev whose install was otherwise healthy: `/rvbc` returned
|
|
469
|
+
// "Unknown command: /rvbc. Did you mean /rvf?". search_ruvnet worked, the KB was current β the
|
|
470
|
+
// plugin had simply never landed, and the installer had said everything was fine.
|
|
471
|
+
//
|
|
472
|
+
// The brain ships as TWO independent artifacts and this is the one people lose:
|
|
473
|
+
// β’ KB + search_ruvnet β installed by this script into ~/.cache/ruvnet-brain
|
|
474
|
+
// β’ the Claude Code plugin β slash commands, the Console, the grounding hook
|
|
475
|
+
// Checking the commands directory on disk is what distinguishes them; a `claude plugin install`
|
|
476
|
+
// exit code does not.
|
|
477
|
+
/** @returns {string|null} the commands dir if the plugin is really installed, else null */
|
|
478
|
+
function pluginCommandsDir() {
|
|
479
|
+
const candidates = [
|
|
480
|
+
path.join(os.homedir(), '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin', 'commands'),
|
|
481
|
+
path.join(os.homedir(), '.claude', 'plugins', 'ruvnet-brain', 'commands'),
|
|
482
|
+
];
|
|
483
|
+
for (const dir of candidates) {
|
|
484
|
+
try { if (fs.existsSync(path.join(dir, 'rvbc.md'))) return dir; } catch { /* unreadable β treat as absent */ }
|
|
485
|
+
}
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
|
|
462
489
|
// ββ step: wire the Claude Code plugin ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
463
490
|
function wirePlugin() {
|
|
464
491
|
step(
|
|
@@ -480,15 +507,30 @@ function wirePlugin() {
|
|
|
480
507
|
}
|
|
481
508
|
|
|
482
509
|
const addedMarket = tryRun('claude', ['plugin', 'marketplace', 'add', 'stuinfla/ruvnet-brain']);
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
510
|
+
// Deliberately NOT reassuring here. This used to say "it may already be added β that's fine",
|
|
511
|
+
// which is a GUESS about someone else's machine, and when it was wrong the user finished the
|
|
512
|
+
// install with a working search_ruvnet, no slash commands, and a message telling them all was
|
|
513
|
+
// well. The real state is checked below; nothing is declared fine until it has been looked at.
|
|
514
|
+
if (!addedMarket) info(`marketplace add didn't report success β checking what actually landedβ¦`);
|
|
515
|
+
|
|
516
|
+
tryRun('claude', ['plugin', 'install', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']);
|
|
517
|
+
|
|
518
|
+
// NEVER take "installed" on faith β same discipline verifyInstall() applies to the KB. An exit
|
|
519
|
+
// code says the command ran, not that the plugin is usable; the commands either exist on disk or
|
|
520
|
+
// they do not. This is the difference between `/rvbc` working and "Unknown command: /rvbc".
|
|
521
|
+
const commandsDir = pluginCommandsDir();
|
|
522
|
+
if (commandsDir) {
|
|
487
523
|
ok('plugin installed at user scope (global, alongside Ruflo / RuVector)');
|
|
524
|
+
info(` commands available after a restart: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
|
|
488
525
|
return { wired: true, manualMarketplace, manualInstall };
|
|
489
526
|
}
|
|
490
527
|
|
|
491
|
-
|
|
528
|
+
// The honest failure. The brain still WORKS β this is the difference between a broken install and
|
|
529
|
+
// a partial one, and the user is told exactly which they have instead of being congratulated.
|
|
530
|
+
warn(`the plugin did NOT land β so slash commands like ${c.bold('/rvbc')} will not exist yet.`);
|
|
531
|
+
info(`${c.green('Your brain still works')}: search_ruvnet is wired and Claude will ground answers with it.`);
|
|
532
|
+
info(`Only the plugin extras (slash commands, the Console, the grounding hook) are missing.`);
|
|
533
|
+
info(`Run these two yourself to finish:`);
|
|
492
534
|
info(` ${c.bold(manualMarketplace)}`);
|
|
493
535
|
info(` ${c.bold(manualInstall)}`);
|
|
494
536
|
return { wired: false, manualMarketplace, manualInstall };
|
|
@@ -532,7 +574,10 @@ function verifyInstall(cacheDir) {
|
|
|
532
574
|
if (mcp) ok('search_ruvnet server present (this is what Claude calls to ground answers)');
|
|
533
575
|
else warn('forge-mcp-all.mjs missing β the brain unpacked incompletely');
|
|
534
576
|
|
|
535
|
-
|
|
577
|
+
// Plugin presence is part of "what is really on disk" β it is the difference between `/rvbc`
|
|
578
|
+
// working and "Unknown command". A user whose plugin never landed had no way to see that.
|
|
579
|
+
const plugin = pluginCommandsDir() !== null;
|
|
580
|
+
return { repos, reader, mcp, plugin };
|
|
536
581
|
}
|
|
537
582
|
|
|
538
583
|
// ββ step: warm the model + prove grounding with one real question (best-effort, never fatal) ββββββ
|
|
@@ -727,6 +772,9 @@ async function doctor() {
|
|
|
727
772
|
have('node') ? ok('node present') : warn('node missing');
|
|
728
773
|
have('npm') ? ok('npm present') : warn('npm missing');
|
|
729
774
|
have('claude') ? ok('claude CLI present') : warn('claude CLI missing (plugin wiring needs it)');
|
|
775
|
+
// Two independent version streams (KB bundle vs plugin wrapper) β see checkVersionDrift()'s
|
|
776
|
+
// header comment for the full story. Silent unless they've genuinely diverged.
|
|
777
|
+
reportVersionDrift(cacheDir);
|
|
730
778
|
have('unzip') || have('pwsh') || have('powershell')
|
|
731
779
|
? ok('zip extraction available (unzip or PowerShell Expand-Archive)')
|
|
732
780
|
: warn('no zip tool found β unzip or PowerShell needed for re-install');
|
|
@@ -813,6 +861,79 @@ function installedBrainVersion(cacheDir) {
|
|
|
813
861
|
return 'unknown';
|
|
814
862
|
}
|
|
815
863
|
|
|
864
|
+
// ββ the OTHER version: the plugin WRAPPER's own plugin.json ββββββββββββββββββββββββββββββββββββββ
|
|
865
|
+
// installedBrainVersion() above answers "what KB is on disk". This answers "what PLUGIN WRAPPER is
|
|
866
|
+
// on disk" β a genuinely different artifact (hooks, skills, slash commands), updated on a genuinely
|
|
867
|
+
// different schedule (see the drift note at checkVersionDrift() below). Reuses pluginCommandsDir()
|
|
868
|
+
// β the ONE locator for "is the plugin really here" β instead of growing a second one: plugin.json
|
|
869
|
+
// always lives one directory above commands/, in both layouts that function checks.
|
|
870
|
+
function wrapperVersion() {
|
|
871
|
+
const commandsDir = pluginCommandsDir();
|
|
872
|
+
if (!commandsDir) return null; // plugin not installed β nothing to read, nothing to compare
|
|
873
|
+
try {
|
|
874
|
+
const p = path.join(path.dirname(commandsDir), '.claude-plugin', 'plugin.json');
|
|
875
|
+
const v = String(JSON.parse(fs.readFileSync(p, 'utf8')).version || '');
|
|
876
|
+
return /^[A-Za-z0-9._-]{1,32}$/.test(v) ? v : null; // present-but-unparsable = "don't know"
|
|
877
|
+
} catch { return null; }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* The brain ships as TWO independently-versioned artifacts: the KB content bundle (self-updates
|
|
882
|
+
* nightly via forge-update.mjs + GitHub Releases) and the Claude Code PLUGIN WRAPPER (hooks,
|
|
883
|
+
* skills, slash commands), which updates ONLY when Claude Code itself pulls the marketplace git
|
|
884
|
+
* clone at ~/.claude/plugins/marketplaces/ruvnet-brain β NOT AT ALL if a user's
|
|
885
|
+
* ~/.claude/settings.json has "autoUpdate": false for that marketplace. They drift silently, and β
|
|
886
|
+
* this is the damaging part β the version a user is SHOWN always comes from the frozen wrapper,
|
|
887
|
+
* never the brain. Verified live on this machine 2026-07-20: KB SOURCE.json built today, wrapper
|
|
888
|
+
* plugin.json still 3.4.18-dev, nine commits behind origin/main, because autoUpdate was false. A
|
|
889
|
+
* user (Dr. Mark Allen) hit exactly this: KB current, wrapper still the June v0.5.0-dev build, and
|
|
890
|
+
* nothing anywhere told him the two had diverged.
|
|
891
|
+
*
|
|
892
|
+
* NEVER invent or guess a version β this project's hardest rule. Either side unresolved β null, and
|
|
893
|
+
* null is NEVER treated as drift: a locally-built or pre-stamping KB legitimately has no releaseTag
|
|
894
|
+
* (see installedBrainVersion's own comment above), and a plugin that simply isn't installed yet is
|
|
895
|
+
* a DIFFERENT, already-reported situation (wirePlugin / verifyInstall), not a version mismatch.
|
|
896
|
+
* Drift is reported ONLY when BOTH sides resolved to a real value AND those values differ.
|
|
897
|
+
*
|
|
898
|
+
* @returns {{wrapper: string|null, kb: string|null, drift: boolean}}
|
|
899
|
+
*/
|
|
900
|
+
function checkVersionDrift(cacheDir) {
|
|
901
|
+
const wrapper = wrapperVersion();
|
|
902
|
+
const kbRaw = installedBrainVersion(cacheDir); // already honest β 'unknown' rather than a guess
|
|
903
|
+
const kb = kbRaw === 'unknown' ? null : kbRaw;
|
|
904
|
+
// COMPARE NUMBERS, NOT NAMESPACES. The two sides are written by different writers in different
|
|
905
|
+
// formats: build-bundle stamps SOURCE.json.releaseTag as a git TAG (v-prefixed) while
|
|
906
|
+
// sync-version writes plugin.json.version as a bare SEMVER (no prefix). A raw !== is therefore
|
|
907
|
+
// ALWAYS true, so the first version of this check told every perfectly healthy user their install
|
|
908
|
+
// had drifted, and handed them a fix command that could never clear it. Caught by adversarial
|
|
909
|
+
// review, not by the tests β the test fixture used a v-prefixed plugin version that sync-version
|
|
910
|
+
// never produces, so an impossible input was green-lighting a false claim.
|
|
911
|
+
// Duplicated deliberately from scripts/version.mjs's stripTag(): this installer ships standalone
|
|
912
|
+
// on npm (package.json `files` excludes scripts/version.mjs) and imports node builtins ONLY, so
|
|
913
|
+
// it cannot import the canonical one. Same one-line rule, kept identical on purpose.
|
|
914
|
+
const stripV = (v) => String(v).replace(/^v/, '');
|
|
915
|
+
const drift = Boolean(wrapper && kb && stripV(wrapper) !== stripV(kb));
|
|
916
|
+
return { wrapper, kb, drift };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Shared narration for --doctor and --what-changed (via printFootprint). Silent whenever there is
|
|
921
|
+
* nothing actionable to say β matched versions, or either side not comparable β so this never adds
|
|
922
|
+
* noise to a healthy machine or a not-yet-fully-installed one. Speaks up only when the two
|
|
923
|
+
* artifacts have genuinely diverged, in plain, warm, non-alarming language (neither artifact is
|
|
924
|
+
* broken β they just update on different schedules), and always hands over the exact command to
|
|
925
|
+
* fix it β verified live against `claude plugin marketplace --help` (2026-07-20) before ever being
|
|
926
|
+
* printed here.
|
|
927
|
+
*/
|
|
928
|
+
function reportVersionDrift(cacheDir) {
|
|
929
|
+
const state = checkVersionDrift(cacheDir);
|
|
930
|
+
if (!state.drift) return state;
|
|
931
|
+
warn(`the brain (${c.bold(state.kb)}) and the Claude Code plugin (${c.bold(state.wrapper)}) have drifted apart β`);
|
|
932
|
+
info(`that's normal (they update on separate schedules) and neither one is broken. To bring the`);
|
|
933
|
+
info(`plugin up to date: ${c.bold('claude plugin marketplace update ruvnet-brain')} ${c.dim('(then restart Claude Code)')}`);
|
|
934
|
+
return state;
|
|
935
|
+
}
|
|
936
|
+
|
|
816
937
|
function installAgeLine(cacheDir) {
|
|
817
938
|
// SOURCE.json's mtime is when the bundle last landed here (install or self-update) β say which.
|
|
818
939
|
for (const f of ['SOURCE.json', 'forge-mcp-all.mjs']) {
|
|
@@ -831,7 +952,7 @@ function feedbackHealthLines(cacheDir) {
|
|
|
831
952
|
const env = detectEnvironment();
|
|
832
953
|
const allGreen = s.repos > 0 && s.reader && s.mcp;
|
|
833
954
|
return [
|
|
834
|
-
`${s.repos} repo stores on disk Β· reader ${s.reader ? 'ok' : 'MISSING'} Β· search_ruvnet ${s.mcp ? 'ok' : 'MISSING'}`,
|
|
955
|
+
`${s.repos} repo stores on disk Β· reader ${s.reader ? 'ok' : 'MISSING'} Β· search_ruvnet ${s.mcp ? 'ok' : 'MISSING'} Β· plugin ${s.plugin ? 'ok' : 'NOT INSTALLED (no /rvbc)'}`,
|
|
835
956
|
`toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} Β· RuVector ${env.ruvector ? 'present' : 'not found'} Β· claude CLI ${env.claude ? 'present' : 'not found'}`,
|
|
836
957
|
allGreen ? 'verdict: Healthy β installed and reachable' : 'verdict: Needs attention β re-run npx ruvnet-brain',
|
|
837
958
|
];
|
|
@@ -1132,6 +1253,247 @@ function enableSpendGuard() {
|
|
|
1132
1253
|
return 'enabled';
|
|
1133
1254
|
}
|
|
1134
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* Remove the spend watchdog. Mirrors disableNightly() exactly.
|
|
1258
|
+
*
|
|
1259
|
+
* This did not exist until 2026-07-20, which meant the watchdog was the one thing this installer
|
|
1260
|
+
* could put on a machine with no supported way to take it back off. "Reversible" has to be a
|
|
1261
|
+
* command someone can run, not a paragraph telling them which files to delete by hand β a user
|
|
1262
|
+
* asking how to undo our changes should never need us to answer.
|
|
1263
|
+
*/
|
|
1264
|
+
function disableSpendGuard() {
|
|
1265
|
+
printBanner('disable spend watchdog');
|
|
1266
|
+
if (process.platform !== 'darwin') {
|
|
1267
|
+
info('The spend-watchdog LaunchAgent is macOS-only, so nothing was scheduled here by this tool.');
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
const plistPath = spendGuardPlistPath();
|
|
1271
|
+
const scriptPath = spendGuardScriptPath();
|
|
1272
|
+
const existed = fs.existsSync(plistPath);
|
|
1273
|
+
if (TEST_MODE) {
|
|
1274
|
+
warn('RUVNET_BRAIN_TEST=1 β skipping launchctl bootout (plist removal only)');
|
|
1275
|
+
} else {
|
|
1276
|
+
// Ignore failure: "not loaded" is the state we want anyway.
|
|
1277
|
+
spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${SPEND_GUARD_LABEL}`], { stdio: 'ignore' });
|
|
1278
|
+
}
|
|
1279
|
+
let failed = false;
|
|
1280
|
+
for (const p of [plistPath, scriptPath]) {
|
|
1281
|
+
if (!fs.existsSync(p)) continue;
|
|
1282
|
+
try { fs.rmSync(p); } catch (e) {
|
|
1283
|
+
failed = true;
|
|
1284
|
+
console.error(`\n${c.red("β couldn't remove:")} ${p} β ${e.message}`);
|
|
1285
|
+
console.error(` Remove it yourself: rm ${p}`);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
if (failed) process.exit(1);
|
|
1289
|
+
if (existed) ok(`spend watchdog disabled β removed ${plistPath}`);
|
|
1290
|
+
else ok('spend watchdog was already off β nothing to remove (safe to run any time)');
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Everything this installer can leave on a machine, DERIVED from disk β never asserted.
|
|
1295
|
+
*
|
|
1296
|
+
* A user who wants out should not have to ask us which files to delete. That was the actual
|
|
1297
|
+
* position the corporate-machine report left someone in: they had to reverse-engineer our
|
|
1298
|
+
* footprint from a bug report. Anything listed here has a real undo next to it.
|
|
1299
|
+
*
|
|
1300
|
+
* @returns {{label:string, path:string, undo:string}[]}
|
|
1301
|
+
*/
|
|
1302
|
+
export function machineFootprint() {
|
|
1303
|
+
const items = [];
|
|
1304
|
+
const add = (label, p, undo) => { try { if (p && fs.existsSync(p)) items.push({ label, path: p, undo }); } catch { /* unreadable β not ours to claim */ } };
|
|
1305
|
+
|
|
1306
|
+
add('Brain bundle (knowledge base)', resolvedKbDir(), 'npx ruvnet-brain --uninstall');
|
|
1307
|
+
if (process.platform === 'darwin') {
|
|
1308
|
+
add('Nightly updater (LaunchAgent)', nightlyPlistPath(), 'npx ruvnet-brain --disable-nightly');
|
|
1309
|
+
add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
|
|
1310
|
+
add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
|
|
1311
|
+
}
|
|
1312
|
+
const cmds = pluginCommandsDir();
|
|
1313
|
+
if (cmds) items.push({
|
|
1314
|
+
label: 'Claude Code plugin',
|
|
1315
|
+
path: path.dirname(cmds),
|
|
1316
|
+
undo: 'claude plugin uninstall ruvnet-brain@ruvnet-brain',
|
|
1317
|
+
});
|
|
1318
|
+
const cmdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
1319
|
+
try {
|
|
1320
|
+
if (fs.existsSync(cmdPath) && fs.readFileSync(cmdPath, 'utf8').includes(CLAUDE_MD_START)) {
|
|
1321
|
+
items.push({ label: 'CLAUDE.md block (6 lines, between markers)', path: cmdPath, undo: 'npx ruvnet-brain --uninstall' });
|
|
1322
|
+
}
|
|
1323
|
+
} catch { /* unreadable */ }
|
|
1324
|
+
add('Usage-counts preference', telemetryConsentPath(), 'delete this file');
|
|
1325
|
+
|
|
1326
|
+
// EVERYTHING ELSE THIS INSTALLER WRITES. The first version of this listed the KB bundle and
|
|
1327
|
+
// little else β one artifact out of six β while the help text promised "exactly what RuvNet Brain
|
|
1328
|
+
// has put on this machine" and uninstallAll() went on to print "Verified clean". That is the same
|
|
1329
|
+
// position the corporate-machine reporter was left in, reproduced by the very feature written to
|
|
1330
|
+
// prevent it. It also explains a user seeing ~5 GB used for a ~2 GB knowledge base: most of the
|
|
1331
|
+
// footprint was never reported. Anything this installer can create belongs here, or the summary
|
|
1332
|
+
// is a comfortable fiction.
|
|
1333
|
+
add('Status-bar version script', path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ruvnet-brain-statusline.cjs'),
|
|
1334
|
+
'remove the statusLine entry in ~/.claude/settings.json, then delete this file');
|
|
1335
|
+
add('Status-bar preference', path.join(telemetryStateDir(), '.statusline-pref'), 'delete this file');
|
|
1336
|
+
add('Model-router files', path.join(os.homedir(), '.claude', 'model-router'),
|
|
1337
|
+
'rm -rf ~/.claude/model-router');
|
|
1338
|
+
// Config entries live INSIDE files the user owns, so they are reported as edits to review rather
|
|
1339
|
+
// than as paths to delete β deleting someone's settings.json over one key would be indefensible.
|
|
1340
|
+
try {
|
|
1341
|
+
const settings = path.join(os.homedir(), '.claude', 'settings.json');
|
|
1342
|
+
if (fs.existsSync(settings) && fs.readFileSync(settings, 'utf8').includes('ruvnet-brain')) {
|
|
1343
|
+
items.push({ label: 'A statusLine entry in your settings.json', path: settings, undo: 'remove the "statusLine" entry that points at ruvnet-brain' });
|
|
1344
|
+
}
|
|
1345
|
+
} catch { /* unreadable β do not claim it */ }
|
|
1346
|
+
try {
|
|
1347
|
+
const claudeJson = path.join(os.homedir(), '.claude.json');
|
|
1348
|
+
if (fs.existsSync(claudeJson) && fs.readFileSync(claudeJson, 'utf8').includes('ruvnet-brain')) {
|
|
1349
|
+
items.push({ label: 'The search_ruvnet MCP server registration', path: claudeJson, undo: 'claude mcp remove ruvnet-brain --scope user' });
|
|
1350
|
+
}
|
|
1351
|
+
} catch { /* unreadable β do not claim it */ }
|
|
1352
|
+
|
|
1353
|
+
return items;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/** Print the footprint. Called at the end of an install so nobody is ever surprised later. */
|
|
1357
|
+
function printFootprint({ heading = 'What this put on your machine' } = {}) {
|
|
1358
|
+
const items = machineFootprint();
|
|
1359
|
+
if (!items.length) { info('Nothing from RuvNet Brain is currently installed.'); return items; }
|
|
1360
|
+
console.log(`\n ${c.bold(heading)}`);
|
|
1361
|
+
for (const it of items) {
|
|
1362
|
+
console.log(` β’ ${it.label}`);
|
|
1363
|
+
console.log(` ${c.dim(it.path.replace(os.homedir(), '~'))}`);
|
|
1364
|
+
console.log(` ${c.dim(`undo: ${it.undo}`)}`);
|
|
1365
|
+
}
|
|
1366
|
+
console.log(`\n ${c.dim('Remove all of it at once:')} ${c.bold('npx ruvnet-brain --uninstall')}`);
|
|
1367
|
+
// Same "two artifacts, one machine" story as --doctor β surfaced here too, since a footprint
|
|
1368
|
+
// listing is exactly where a user would otherwise reasonably assume one version covers both.
|
|
1369
|
+
// Silent unless they've genuinely diverged.
|
|
1370
|
+
reportVersionDrift(resolvedKbDir());
|
|
1371
|
+
return items;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* Surgically remove ONLY our block from CLAUDE.md, leaving every other line exactly as it was.
|
|
1376
|
+
* Backed up and written atomically, same as when it was added β taking something away is at least
|
|
1377
|
+
* as sensitive as putting it there.
|
|
1378
|
+
*/
|
|
1379
|
+
function removeClaudeMdBlock() {
|
|
1380
|
+
const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
1381
|
+
let src = '';
|
|
1382
|
+
try { src = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { return 'unreadable'; }
|
|
1383
|
+
// PAIR THE MARKERS PROPERLY. The first version took the FIRST start and the FIRST end, unpaired.
|
|
1384
|
+
// Adversarial review proved that eats the user's file: a CLAUDE.md that merely MENTIONS the start
|
|
1385
|
+
// marker in prose (entirely plausible β we print both marker strings to the console when adding
|
|
1386
|
+
// the block) above a real installed block causes everything between the prose mention and the real
|
|
1387
|
+
// block's end marker to be deleted. Their rules, silently gone, under a message saying "your
|
|
1388
|
+
// content untouched".
|
|
1389
|
+
//
|
|
1390
|
+
// Take the LAST start that has an end after it, and the FIRST end after that start β the real
|
|
1391
|
+
// block is the innermost well-formed pair. Anything we cannot pair confidently is left alone:
|
|
1392
|
+
// refusing to edit is always better than removing the wrong span from a file we do not own.
|
|
1393
|
+
const end = src.indexOf(CLAUDE_MD_END);
|
|
1394
|
+
if (end === -1) return 'absent';
|
|
1395
|
+
const start = src.lastIndexOf(CLAUDE_MD_START, end);
|
|
1396
|
+
if (start === -1) return 'absent';
|
|
1397
|
+
|
|
1398
|
+
// Splice ONLY the block. The previous version also ran .replace(/\n{3,}/g,'\n\n') and stripped
|
|
1399
|
+
// leading whitespace across the WHOLE document, which silently reformatted unrelated content β
|
|
1400
|
+
// including collapsing blank lines inside fenced code blocks β while the docstring promised
|
|
1401
|
+
// "every other line exactly as it was". Normalize only at the seam we actually cut.
|
|
1402
|
+
const before = src.slice(0, start).replace(/\n{3,}$/, '\n\n');
|
|
1403
|
+
const after = src.slice(end + CLAUDE_MD_END.length).replace(/^\n{3,}/, '\n\n');
|
|
1404
|
+
const next = `${before}${after}`;
|
|
1405
|
+
try {
|
|
1406
|
+
const backup = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
1407
|
+
fs.copyFileSync(p, backup);
|
|
1408
|
+
const tmp = `${p}.ruvnet-tmp`;
|
|
1409
|
+
fs.writeFileSync(tmp, next);
|
|
1410
|
+
fs.renameSync(tmp, p);
|
|
1411
|
+
return 'removed';
|
|
1412
|
+
} catch { return 'failed'; }
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** Reverse everything, and PROVE it rather than claiming it. */
|
|
1416
|
+
function uninstallAll() {
|
|
1417
|
+
printBanner('uninstall RuvNet Brain');
|
|
1418
|
+
const before = machineFootprint();
|
|
1419
|
+
if (!before.length) { ok('Nothing to remove β RuvNet Brain is not installed here.'); return; }
|
|
1420
|
+
|
|
1421
|
+
// SPLIT WHAT WE REMOVE FROM WHAT WE CANNOT. The first version printed one flat "This will remove:"
|
|
1422
|
+
// list built from the whole footprint β including the Claude Code plugin and edits inside files
|
|
1423
|
+
// the user owns, none of which this function touches. It then closed by admitting those same
|
|
1424
|
+
// items still needed a manual step. One command contradicting itself inside a single run is
|
|
1425
|
+
// exactly the sloppiness that makes people stop believing any of our output, so the promise is now
|
|
1426
|
+
// scoped to what actually happens.
|
|
1427
|
+
// Ours-by-construction directories and files are removed; things that live INSIDE a file the user
|
|
1428
|
+
// owns (settings.json entries, the MCP registration) and the Claude Code plugin itself are not
|
|
1429
|
+
// ours to delete, so they are handed over as commands.
|
|
1430
|
+
const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)',
|
|
1431
|
+
'Spend watchdog (LaunchAgent)', 'Spend watchdog script', 'CLAUDE.md block (6 lines, between markers)',
|
|
1432
|
+
'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference']);
|
|
1433
|
+
const willRemove = before.filter((it) => AUTO.has(it.label));
|
|
1434
|
+
const manual = before.filter((it) => !AUTO.has(it.label));
|
|
1435
|
+
|
|
1436
|
+
console.log(` This will remove:`);
|
|
1437
|
+
for (const it of willRemove) console.log(` β’ ${it.label} ${c.dim(it.path.replace(os.homedir(), '~'))}`);
|
|
1438
|
+
if (!willRemove.length) console.log(` ${c.dim('(nothing that this command removes automatically)')}`);
|
|
1439
|
+
if (manual.length) {
|
|
1440
|
+
console.log(`\n ${c.bold('It will NOT remove these')} β they are not ours to delete, so you get the command instead:`);
|
|
1441
|
+
for (const it of manual) console.log(` β’ ${it.label}\n ${c.dim(it.undo)}`);
|
|
1442
|
+
}
|
|
1443
|
+
console.log(`\n ${c.dim('Your own CLAUDE.md content is preserved β only our marked block is taken out,')}`);
|
|
1444
|
+
console.log(` ${c.dim('and the file is backed up first.')}\n`);
|
|
1445
|
+
|
|
1446
|
+
if (process.platform === 'darwin') { disableNightly(); disableSpendGuard(); }
|
|
1447
|
+
|
|
1448
|
+
const claudeMd = removeClaudeMdBlock();
|
|
1449
|
+
if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
|
|
1450
|
+
|
|
1451
|
+
// NEVER rm -rf A PATH WE HAVE NOT PROVEN IS OURS. resolvedKbDir() honours $RUVNET_BRAIN_KB, which
|
|
1452
|
+
// the docs encourage for custom install locations β so `RUVNET_BRAIN_KB=$HOME npx ruvnet-brain
|
|
1453
|
+
// --uninstall` would have recursively deleted the user's home directory. Found by adversarial
|
|
1454
|
+
// review. The asymmetry was already visible in this same file: runUpdate() refuses to act unless
|
|
1455
|
+
// forge-update.mjs is present, precisely so it can never surprise someone. Uninstall skipped it.
|
|
1456
|
+
//
|
|
1457
|
+
// Proof-of-ownership: the directory must actually contain the reader we install. That is cheap,
|
|
1458
|
+
// unspoofable in practice, and fails CLOSED β if we cannot prove it is a brain, we do not touch it
|
|
1459
|
+
// and we say why.
|
|
1460
|
+
const kb = resolvedKbDir();
|
|
1461
|
+
const looksLikeBrain = kb && ['forge-ask.mjs', 'forge-mcp.mjs', 'SOURCE.json']
|
|
1462
|
+
.some((marker) => { try { return fs.existsSync(path.join(kb, marker)); } catch { return false; } });
|
|
1463
|
+
if (kb && fs.existsSync(kb) && !looksLikeBrain) {
|
|
1464
|
+
warn(`refusing to delete ${kb.replace(os.homedir(), '~')} β it does not look like a brain bundle.`);
|
|
1465
|
+
info(` (no forge-ask.mjs / forge-mcp.mjs / SOURCE.json found there). Nothing was removed.`);
|
|
1466
|
+
info(` If that really is your brain, remove it yourself: rm -rf ${kb.replace(os.homedir(), '~')}`);
|
|
1467
|
+
} else if (kb && fs.existsSync(kb)) {
|
|
1468
|
+
try { fs.rmSync(kb, { recursive: true, force: true }); ok(`removed the brain bundle (${kb.replace(os.homedir(), '~')})`); }
|
|
1469
|
+
catch (e) { warn(`couldn't remove ${kb}: ${e.message}`); }
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// The rest of what is ours by construction. Leaving these behind is how an "uninstalled" machine
|
|
1473
|
+
// still shows gigabytes of us β 8 executables under ~/.claude/model-router, a statusline script
|
|
1474
|
+
// that settings.json is still pointing at, and preference files. Each is removed only if it is
|
|
1475
|
+
// inside a directory this installer creates, never a path the user chose.
|
|
1476
|
+
for (const [label, target] of [
|
|
1477
|
+
['model-router files', path.join(os.homedir(), '.claude', 'model-router')],
|
|
1478
|
+
['status-bar script', path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ruvnet-brain-statusline.cjs')],
|
|
1479
|
+
['status-bar preference', path.join(telemetryStateDir(), '.statusline-pref')],
|
|
1480
|
+
['usage-counts preference', telemetryConsentPath()],
|
|
1481
|
+
]) {
|
|
1482
|
+
if (!fs.existsSync(target)) continue;
|
|
1483
|
+
try { fs.rmSync(target, { recursive: true, force: true }); ok(`removed the ${label}`); }
|
|
1484
|
+
catch (e) { warn(`couldn't remove ${target.replace(os.homedir(), '~')}: ${e.message}`); }
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
// PROOF, not a claim β re-derive the footprint and show what (if anything) survived.
|
|
1488
|
+
const after = machineFootprint();
|
|
1489
|
+
console.log('');
|
|
1490
|
+
if (!after.length) { ok('Verified clean β nothing from RuvNet Brain remains.'); }
|
|
1491
|
+
else {
|
|
1492
|
+
warn('These need one more step (they are not ours to remove automatically):');
|
|
1493
|
+
for (const it of after) console.log(` β’ ${it.label} β ${c.bold(it.undo)}`);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1135
1497
|
// Exported (testable under RUVNET_BRAIN_IMPORT_ONLY=1, like offerNightly). Never throws β the caller
|
|
1136
1498
|
// also guards, because a finished install must never be broken by an optional safety offer.
|
|
1137
1499
|
export async function offerSpendGuard() {
|
|
@@ -1143,12 +1505,16 @@ export async function offerSpendGuard() {
|
|
|
1143
1505
|
'One more safety net β a spend watchdog',
|
|
1144
1506
|
'agentic tools can bill the paid API in the background; this alarm catches a runaway before it drains your card',
|
|
1145
1507
|
);
|
|
1146
|
-
info(`${c.
|
|
1147
|
-
info(
|
|
1148
|
-
|
|
1149
|
-
|
|
1508
|
+
info(`${c.green('Recommended')} β an hourly check that alerts you the moment an automated agent fleet`);
|
|
1509
|
+
info(`floods a project. That pattern has quietly burned real money. ${c.bold('Your call, and easy to undo.')}`);
|
|
1510
|
+
info(`${c.dim('What it sets up:')} a small background job (a macOS LaunchAgent) that watches for the burst`);
|
|
1511
|
+
info(`${c.dim(' ')} pattern. ${c.bold('Alert-only β it never spends, and never changes your billing.')}`);
|
|
1512
|
+
info(`${c.dim('If you skip:')} nothing changes; add it later with ${c.bold('npx ruvnet-brain --enable-spend-guard')}`);
|
|
1513
|
+
|
|
1514
|
+
// NOT gated on FLAG_YES β second launchd job, same rule as the nightly updater above.
|
|
1515
|
+
if (!process.stdin.isTTY && !FLAG_ENABLE_SPEND_GUARD) { info(`No terminal to prompt on β install it any time with ${c.bold('npx ruvnet-brain --enable-spend-guard')}`); return 'recommended'; }
|
|
1150
1516
|
let yes = true;
|
|
1151
|
-
if (!
|
|
1517
|
+
if (!FLAG_ENABLE_SPEND_GUARD) {
|
|
1152
1518
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1153
1519
|
const answer = await new Promise((resolve) => rl.question(` ${c.cyan('?')} Install the spend watchdog? ${c.dim('[Y/n]')} `, resolve));
|
|
1154
1520
|
rl.close();
|
|
@@ -1293,15 +1659,28 @@ export async function offerNightly() {
|
|
|
1293
1659
|
|
|
1294
1660
|
info(`${c.bold('Recommended:')} your brain updates itself while you sleep β new repos, new gists, zero effort.`);
|
|
1295
1661
|
|
|
1296
|
-
|
|
1662
|
+
// Recommend it, mean it, and still make declining feel completely fine. The goal is a user who
|
|
1663
|
+
// understands what they're agreeing to β not one who is either scared off by a wall of caveats or
|
|
1664
|
+
// nudged past a decision they'd have made differently. Both failures cost trust; only one is loud.
|
|
1665
|
+
info(`${c.green('Recommended')} β rUv ships constantly, and this is how fixes reach you without you`);
|
|
1666
|
+
info(`thinking about it. ${c.bold('Entirely your call, though')}, and easy to undo.`);
|
|
1667
|
+
info(`${c.dim('What it sets up:')} a small background job (a macOS LaunchAgent) that checks each night`);
|
|
1668
|
+
info(`${c.dim(' ')} and downloads a fresher brain β signature-verified before anything is applied.`);
|
|
1669
|
+
info(`${c.dim('If you skip:')} nothing changes; update whenever you like with ${c.bold('npx ruvnet-brain --update')}`);
|
|
1670
|
+
info(`${c.dim('Turn it off:')} ${c.bold('npx ruvnet-brain --disable-nightly')} ${c.dim('(any time, no reinstall)')}`);
|
|
1671
|
+
|
|
1672
|
+
// NOT gated on FLAG_YES β see the high-impact consent note on ask(). A blanket `-y` means nobody is
|
|
1673
|
+
// present to READ the explanation above, and an explanation nobody read is not consent. It takes the
|
|
1674
|
+
// explicit --enable-nightly, or a human answering in a terminal.
|
|
1675
|
+
if (!process.stdin.isTTY && !FLAG_ENABLE_NIGHTLY) {
|
|
1297
1676
|
// No terminal to ask on (CI / piped install) β recommend clearly instead of prompting.
|
|
1298
1677
|
info(`No interactive terminal here, so I won't prompt. Enable it any time with one command:`);
|
|
1299
1678
|
info(` ${c.bold('npx ruvnet-brain --enable-nightly')}`);
|
|
1300
1679
|
return 'recommended';
|
|
1301
1680
|
}
|
|
1302
1681
|
|
|
1303
|
-
let yes = true;
|
|
1304
|
-
if (!
|
|
1682
|
+
let yes = true;
|
|
1683
|
+
if (!FLAG_ENABLE_NIGHTLY) {
|
|
1305
1684
|
// Not ask(): its parser treats anything but y/yes as no. Here the DEFAULT is yes β only an
|
|
1306
1685
|
// explicit n/no declines (parseNightlyAnswer holds that contract, and the tests hold it there).
|
|
1307
1686
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -1411,8 +1790,28 @@ export async function offerTelemetry(cacheDir) {
|
|
|
1411
1790
|
}
|
|
1412
1791
|
|
|
1413
1792
|
// ββ tiny interactive yes/no β SAFE in non-TTY (returns the default; never blocks a piped install) ββ
|
|
1414
|
-
|
|
1415
|
-
|
|
1793
|
+
/**
|
|
1794
|
+
* @param {string} question
|
|
1795
|
+
* @param {boolean} def answer used when there is no terminal to ask on
|
|
1796
|
+
* @param {{blanketYes?: boolean}} opts blanketYes:false means --yes does NOT answer this one
|
|
1797
|
+
*
|
|
1798
|
+
* HIGH-IMPACT CONSENT (2026-07-20). `--yes` is documented as "accept every optional offer", and it
|
|
1799
|
+
* used to include the two changes nobody would call optional: installing a persistent LaunchAgent
|
|
1800
|
+
* that pulls code from GitHub on a schedule, and editing a global config file. Reported by a user on
|
|
1801
|
+
* a CORPORATE machine whose enterprise policy correctly blocked the plugin/MCP install but had no
|
|
1802
|
+
* rule covering a launchd job β so the one thing that survived was the background daemon.
|
|
1803
|
+
*
|
|
1804
|
+
* It almost certainly arrived via an AI agent: hit an interactive prompt, cannot answer it, re-run
|
|
1805
|
+
* with `-y`. Entirely reasonable behaviour, and with blanket consent it silently authorizes a
|
|
1806
|
+
* daemon. rUv's own ADR-302 already says why this is wrong β "accepting the enrollment screen is
|
|
1807
|
+
* not blanket authorization... four distinct decisions, each with its own consent, its own prompt
|
|
1808
|
+
* moment, and its own record." We were violating his design inside our own installer.
|
|
1809
|
+
*
|
|
1810
|
+
* So: persistent background jobs and global-config edits require their OWN explicit flag. There is
|
|
1811
|
+
* no combination of `-y` alone that installs a daemon.
|
|
1812
|
+
*/
|
|
1813
|
+
function ask(question, def = false, { blanketYes = true } = {}) {
|
|
1814
|
+
if (FLAG_YES && blanketYes) return Promise.resolve(true);
|
|
1416
1815
|
if (!process.stdin.isTTY) return Promise.resolve(def);
|
|
1417
1816
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
1418
1817
|
const suffix = def ? c.dim('[Y/n]') : c.dim('[y/N]');
|
|
@@ -1552,31 +1951,65 @@ Prefer rUv-native primitives (RVF over Pinecone/pgvector, Ruflo over LangChain,
|
|
|
1552
1951
|
Not sure it's active? Run \`npx ruvnet-brain --doctor\`.
|
|
1553
1952
|
${CLAUDE_MD_END}`;
|
|
1554
1953
|
|
|
1555
|
-
async function offerClaudeMd() {
|
|
1954
|
+
export async function offerClaudeMd() {
|
|
1556
1955
|
if (FLAG_NO_ENHANCE) return;
|
|
1557
1956
|
const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
1558
1957
|
let existing = '';
|
|
1559
1958
|
try { existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { /* ignore */ }
|
|
1560
|
-
if (existing.includes(CLAUDE_MD_START)) { return; } // already
|
|
1959
|
+
if (existing.includes(CLAUDE_MD_START)) { return; } // already there β idempotent, stay silent
|
|
1960
|
+
|
|
1961
|
+
// DON'T ASK WHEN IT ADDS NOTHING. If the plugin is installed, its hooks already enforce grounding
|
|
1962
|
+
// on every turn and this block is pure duplication β the old skip-branch message said exactly that
|
|
1963
|
+
// out loud. Asking to edit the most sensitive file we touch, for a benefit the user already has,
|
|
1964
|
+
// is how a helpful tool starts feeling invasive. So the question is only worth someone's attention
|
|
1965
|
+
// when the plugin is absent, which is a real population (see the "Unknown command: /rvbc" report)
|
|
1966
|
+
// but not the common one.
|
|
1967
|
+
if (pluginCommandsDir()) return;
|
|
1561
1968
|
|
|
1562
1969
|
step(
|
|
1563
|
-
'
|
|
1564
|
-
|
|
1970
|
+
'Optional β a note in your global CLAUDE.md',
|
|
1971
|
+
"so Claude leans on the brain in projects where the plugin's hooks aren't running",
|
|
1565
1972
|
);
|
|
1973
|
+
// Say precisely what changes on their disk, in their words, BEFORE asking. "Enhance your CLAUDE.md"
|
|
1974
|
+
// is the kind of phrasing that makes a careful person assume the worst β and on a managed machine
|
|
1975
|
+
// that file may be governed. Six lines at the bottom, markers, reversible, backed up: all of that
|
|
1976
|
+
// is far less alarming than the vague version, and it happens to be the whole truth.
|
|
1977
|
+
info(`Adds ${c.bold('6 lines to the BOTTOM')} of ${c.bold('~/.claude/CLAUDE.md')}, wrapped in`);
|
|
1978
|
+
info(` ${c.dim('<!-- ruvnet-brain:start -->')} β¦ ${c.dim('<!-- ruvnet-brain:end -->')}`);
|
|
1979
|
+
info(`Nothing already in the file is changed or removed. Delete the block any time.`);
|
|
1980
|
+
info(`${c.green("We back the file up first")}, and re-running never adds it twice.`);
|
|
1981
|
+
info(c.dim(`Honestly: if you install the plugin, you don't need this β its hooks already do it.`));
|
|
1982
|
+
|
|
1566
1983
|
const yes =
|
|
1567
1984
|
FLAG_ENHANCE_CLAUDE_MD ||
|
|
1568
|
-
|
|
1985
|
+
// blanketYes:false β editing a file THEY own is not something a blanket `-y` gets to decide.
|
|
1986
|
+
(await ask('Add it?', false, { blanketYes: false }));
|
|
1569
1987
|
if (!yes) {
|
|
1570
|
-
info(
|
|
1988
|
+
info(`No problem, skipped β add it any time with ${c.bold('npx ruvnet-brain --enhance-claude-md')}`);
|
|
1571
1989
|
return;
|
|
1572
1990
|
}
|
|
1573
1991
|
try {
|
|
1574
1992
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1993
|
+
// Back up before touching it β the same courtesy this installer already extends to settings.json,
|
|
1994
|
+
// and this is the more sensitive file of the two.
|
|
1995
|
+
let backup = null;
|
|
1996
|
+
if (existing) {
|
|
1997
|
+
backup = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
1998
|
+
fs.copyFileSync(p, backup);
|
|
1999
|
+
}
|
|
1575
2000
|
const next = existing ? `${existing.replace(/\s*$/, '')}\n\n${CLAUDE_MD_BLOCK}\n` : `${CLAUDE_MD_BLOCK}\n`;
|
|
1576
|
-
|
|
1577
|
-
|
|
2001
|
+
// ATOMIC write β temp sibling then rename. The content was always a pure append, but the old
|
|
2002
|
+
// code rewrote the whole file in place, so an interruption (disk full, power loss) could leave
|
|
2003
|
+
// a TRUNCATED CLAUDE.md. Fine 999 times out of 1000 and unforgivable the other time. This is the
|
|
2004
|
+
// discipline rUv already applies to credential files (cognitum-seed cloud_key.rs: tmp β rename).
|
|
2005
|
+
const tmp = `${p}.ruvnet-tmp`;
|
|
2006
|
+
fs.writeFileSync(tmp, next);
|
|
2007
|
+
fs.renameSync(tmp, p);
|
|
2008
|
+
ok(`added 6 lines to the bottom of ${p}`);
|
|
2009
|
+
if (backup) info(c.dim(` your original is saved at ${backup}`));
|
|
2010
|
+
info(c.dim(` to remove: delete the block between the two ruvnet-brain markers`));
|
|
1578
2011
|
} catch (e) {
|
|
1579
|
-
warn(`couldn't update CLAUDE.md (${e.message}) β
|
|
2012
|
+
warn(`couldn't update CLAUDE.md (${e.message}) β your file is untouched, and this was optional anyway`);
|
|
1580
2013
|
}
|
|
1581
2014
|
}
|
|
1582
2015
|
|
|
@@ -1728,7 +2161,14 @@ export async function offerStatusline() {
|
|
|
1728
2161
|
info(`Adds a small ${c.bold('"RuvNet Brain vX.Y.Z"')} segment, read live from your installed brain β it`);
|
|
1729
2162
|
info(`updates itself the moment the brain updates. ${c.bold('Never overwrites an existing status line.')}`);
|
|
1730
2163
|
|
|
1731
|
-
|
|
2164
|
+
// FLAG_YES deliberately does NOT appear here. This writes ~/.claude/settings.json β a file the
|
|
2165
|
+
// USER owns β and installs a script Claude Code then executes on EVERY PROMPT. An adversarial
|
|
2166
|
+
// review caught that a blanket `-y` on a non-TTY still did both, which made the security fix in
|
|
2167
|
+
// 9ad02f5 ("`-y` can no longer install a daemon or edit a global config file") FALSE as written:
|
|
2168
|
+
// the two functions that commit gated were the two I happened to be thinking about, and this
|
|
2169
|
+
// third one β higher-frequency persistent execution than the LaunchAgent β was never checked.
|
|
2170
|
+
// Same footprint rule as everywhere else: their config, their explicit yes.
|
|
2171
|
+
const interactive = process.stdin.isTTY || FLAG_STATUSLINE;
|
|
1732
2172
|
if (!interactive) {
|
|
1733
2173
|
// No terminal to ask on, and no explicit flag either β skip WITHOUT recording an answer, so a
|
|
1734
2174
|
// future interactive (or flagged) run still gets a real chance to ask.
|
|
@@ -1737,7 +2177,7 @@ export async function offerStatusline() {
|
|
|
1737
2177
|
return 'not-asked';
|
|
1738
2178
|
}
|
|
1739
2179
|
|
|
1740
|
-
const yes = FLAG_STATUSLINE || (await ask('Add a RuvNet Brain version segment to your Claude Code status bar?', false));
|
|
2180
|
+
const yes = FLAG_STATUSLINE || (await ask('Add a RuvNet Brain version segment to your Claude Code status bar?', false, { blanketYes: false }));
|
|
1741
2181
|
|
|
1742
2182
|
try {
|
|
1743
2183
|
fs.mkdirSync(telemetryStateDir(), { recursive: true });
|
|
@@ -1853,6 +2293,14 @@ Usage:
|
|
|
1853
2293
|
npx ruvnet-brain --enable-nightly Schedule that update nightly at 03:47 β macOS LaunchAgent;
|
|
1854
2294
|
other platforms get the documented cron line. OFF by default.
|
|
1855
2295
|
npx ruvnet-brain --disable-nightly Remove the nightly schedule (safe to run any time)
|
|
2296
|
+
npx ruvnet-brain --what-changed Show exactly what RuvNet Brain has put on this machine,
|
|
2297
|
+
with the undo command for each piece
|
|
2298
|
+
npx ruvnet-brain --uninstall Remove all of it (bundle, LaunchAgents, and our CLAUDE.md
|
|
2299
|
+
block only β your own CLAUDE.md content is preserved and backed up)
|
|
2300
|
+
npx ruvnet-brain --enable-spend-guard Install the hourly runaway-agent spend alarm (alert-only)
|
|
2301
|
+
npx ruvnet-brain --disable-spend-guard Remove it (safe to run any time)
|
|
2302
|
+
npx ruvnet-brain --enhance-claude-md Add the 6-line RuvNet-Brain block to ~/.claude/CLAUDE.md
|
|
2303
|
+
(appended at the bottom between markers; your file is backed up first)
|
|
1856
2304
|
(a default install RECOMMENDS nightly and asks, defaulting to yes)
|
|
1857
2305
|
node bin/install.mjs --no-nightly-prompt Don't offer nightly auto-updates at the end of the install
|
|
1858
2306
|
node bin/install.mjs --no-telemetry Decline anonymous usage counts without being asked
|
|
@@ -1891,6 +2339,14 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
|
|
|
1891
2339
|
if (FLAG_UPDATE) return runUpdate();
|
|
1892
2340
|
if (FLAG_ENABLE_NIGHTLY) return enableNightly();
|
|
1893
2341
|
if (FLAG_DISABLE_NIGHTLY) return disableNightly();
|
|
2342
|
+
// Standalone, like the nightly pair above. Without these, the flags existed only as a way to
|
|
2343
|
+
// pre-answer a prompt DURING a full install β so the copy telling someone to "add it later with
|
|
2344
|
+
// npx ruvnet-brain --enable-spend-guard" would have kicked off an entire reinstall instead of the
|
|
2345
|
+
// small targeted action they asked for. Promised in the UI, therefore real here.
|
|
2346
|
+
if (FLAG_ENABLE_SPEND_GUARD) { enableSpendGuard(); return; }
|
|
2347
|
+
if (FLAG_DISABLE_SPEND_GUARD) { disableSpendGuard(); return; }
|
|
2348
|
+
if (FLAG_UNINSTALL) { uninstallAll(); return; }
|
|
2349
|
+
if (FLAG_WHAT_CHANGED) { printBanner('what RuvNet Brain put on this machine'); printFootprint(); return; }
|
|
1894
2350
|
|
|
1895
2351
|
printBanner('installer');
|
|
1896
2352
|
console.log(c.dim("I'll set up the brain and the Claude Code plugin, explaining each step as I go.\n"));
|
|
@@ -1915,19 +2371,66 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
|
|
|
1915
2371
|
|
|
1916
2372
|
const { cacheDir, isCustom } = resolveCacheDir();
|
|
1917
2373
|
|
|
2374
|
+
// ββ "ALREADY PRESENT" IS THE WRONG QUESTION β ask "already CURRENT" ββββββββββββββββββββββββββ
|
|
2375
|
+
//
|
|
2376
|
+
// THE STALE-INSTALL TRAP, and the root cause of "users are still on 0.5". This used to skip the
|
|
2377
|
+
// download whenever forge-mcp-all.mjs merely EXISTED β a pure file-existence check, no version
|
|
2378
|
+
// anywhere in it. So a June v0.5 brain made `alreadyInstalled` true, the download was skipped,
|
|
2379
|
+
// and the installer went on to print its success banner. Re-running the installer β the fix we
|
|
2380
|
+
// ADVERTISE in recovery messages β refreshed the reader and the plugin wiring and left the actual
|
|
2381
|
+
// brain untouched, forever. A closed trap: the advertised escape hatch was the thing that failed.
|
|
2382
|
+
//
|
|
2383
|
+
// The honest question is whether the installed brain is CURRENT, so that is what we now ask.
|
|
2384
|
+
// Fail-safe by design: if the version cannot be resolved (offline, API rate limit) we keep the
|
|
2385
|
+
// old skip behaviour rather than force a 2 GB download on someone with no network β but we SAY
|
|
2386
|
+
// that is what happened, instead of implying everything is up to date.
|
|
1918
2387
|
const alreadyInstalled = fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'));
|
|
2388
|
+
let staleSkip = false;
|
|
2389
|
+
let installedTag = null;
|
|
2390
|
+
let latestTag = null;
|
|
2391
|
+
let resolvedRelease = null; // reused below so the release is resolved at most once
|
|
1919
2392
|
if (alreadyInstalled && !FLAG_FORCE) {
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
2393
|
+
installedTag = installedBrainVersion(cacheDir); // 'unknown' when SOURCE.json has no releaseTag
|
|
2394
|
+
try {
|
|
2395
|
+
resolvedRelease = await resolveRelease();
|
|
2396
|
+
// ONLY a genuine `latest` lookup counts as "what current means". resolveRelease() does NOT
|
|
2397
|
+
// throw when the GitHub API fails β it returns the hardcoded known-good pin with
|
|
2398
|
+
// source:'fallback'. Treating that as latest inverts this whole fix: a rate-limited lookup
|
|
2399
|
+
// would report installed v3.4.21-dev "β latest v2.9.0" and DOWNGRADE a perfectly current
|
|
2400
|
+
// machine. (Caught by exercising the failure path against a 404 repo β the first version of
|
|
2401
|
+
// this fix did exactly that.) A pinned/forced resolution is likewise the operator's explicit
|
|
2402
|
+
// choice, not a staleness verdict, so neither drives this comparison.
|
|
2403
|
+
latestTag = resolvedRelease && resolvedRelease.source === 'latest'
|
|
2404
|
+
? (resolvedRelease.tag_name || resolvedRelease.tag || null)
|
|
2405
|
+
: null;
|
|
2406
|
+
} catch { latestTag = null; }
|
|
2407
|
+
const norm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
|
|
2408
|
+
const a = norm(installedTag), b = norm(latestTag);
|
|
2409
|
+
// Different (or unknowable-installed) => it is NOT current => download. Same => genuinely skip.
|
|
2410
|
+
staleSkip = Boolean(b && (a === null || a !== b));
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
if (alreadyInstalled && !FLAG_FORCE && !staleSkip) {
|
|
2414
|
+
if (latestTag) {
|
|
2415
|
+
step('Brain already current β skipping the download', `installed ${installedTag} matches the latest release`);
|
|
2416
|
+
ok(`found an up-to-date brain at ${cacheDir}`);
|
|
2417
|
+
} else {
|
|
2418
|
+
// Could not check. Say so plainly rather than letting silence imply "current".
|
|
2419
|
+
step('Brain present β could not check for a newer one', 'the release lookup failed (offline or rate-limited)');
|
|
2420
|
+
warn(`skipping the download WITHOUT verifying it is current. Installed: ${installedTag || 'unknown'}.`);
|
|
2421
|
+
info(`When you have a connection: ${c.bold('npx ruvnet-brain --update')} ${c.dim('(or --force to refetch now)')}`);
|
|
2422
|
+
}
|
|
1925
2423
|
} else {
|
|
2424
|
+
if (staleSkip) {
|
|
2425
|
+
step('Brain is out of date β fetching the current release', `installed ${installedTag || 'unknown'} β latest ${latestTag}`);
|
|
2426
|
+
info(`${c.dim('(the old installer skipped this whenever any brain was present, which is why stale installs never moved)')}`);
|
|
2427
|
+
}
|
|
1926
2428
|
// Resolve which Release to fetch BEFORE downloading. Skipped entirely on the --local path
|
|
1927
2429
|
// (obtainBundle short-circuits to the repo's dist/ zip and never touches the network).
|
|
1928
2430
|
const localZipPresent =
|
|
1929
2431
|
FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
|
|
1930
|
-
|
|
2432
|
+
// Reuse the staleness check's resolution when it already ran β one network round-trip, not two.
|
|
2433
|
+
const release = localZipPresent ? null : (resolvedRelease || await resolveRelease());
|
|
1931
2434
|
const { zipPath, tmpDir, downloaded } = await obtainBundle(release);
|
|
1932
2435
|
// Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
|
|
1933
2436
|
// (SEC-0010 #6 β trust root = keys/ruvnet-brain-signing.pub.pem shipped inside this package).
|
|
@@ -1986,6 +2489,12 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
|
|
|
1986
2489
|
try { await offerStatusline(); } catch { /* non-fatal β a status-bar nicety must never break the install */ }
|
|
1987
2490
|
|
|
1988
2491
|
success({ cacheDir, isCustom, plugin, env, nightly });
|
|
2492
|
+
|
|
2493
|
+
// Close every install by stating, in one place, exactly what is now on their machine and how to
|
|
2494
|
+
// take each piece back off. Someone reading this should never have to file a bug report to find
|
|
2495
|
+
// out what we did β which is precisely the position the 2026-07-20 corporate-machine reporter was
|
|
2496
|
+
// left in. Derived from disk, so it can only ever describe what is actually there.
|
|
2497
|
+
try { printFootprint(); } catch { /* a summary must never break a finished install */ }
|
|
1989
2498
|
})().catch((e) => {
|
|
1990
2499
|
die(e && e.message ? e.message : String(e));
|
|
1991
2500
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.22-dev",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain β a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|