wavebrain 0.1.0 → 0.2.0
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 +8 -1
- package/assets/SKILL.md +27 -0
- package/assets/hermes/__init__.py +282 -0
- package/assets/hermes/plugin.yaml +8 -0
- package/assets/hermes/rules.json +31 -0
- package/assets/hooks/core.mjs +320 -0
- package/assets/hooks/hook.mjs +28 -0
- package/assets/hooks/rules.json +31 -0
- package/package.json +12 -3
- package/src/capture.mjs +188 -0
- package/src/cli.mjs +56 -7
- package/src/files.mjs +18 -5
- package/src/targets.mjs +125 -5
package/src/cli.mjs
CHANGED
|
@@ -3,16 +3,17 @@ import os from "node:os";
|
|
|
3
3
|
import readline from "node:readline";
|
|
4
4
|
import { Conflict, Session, hasBlock, readText } from "./files.mjs";
|
|
5
5
|
import { loadState, ownedBy, saveState, statePath } from "./state.mjs";
|
|
6
|
+
import { stageCapture, unstageCapture } from "./capture.mjs";
|
|
6
7
|
import { KEY_ENV, allTargets, targetById, tilde } from "./targets.mjs";
|
|
7
8
|
|
|
8
|
-
export const VERSION = "0.
|
|
9
|
+
export const VERSION = "0.2.0";
|
|
9
10
|
const DEFAULT_URL = "https://getwavebrain.com/api/mcp";
|
|
10
11
|
const SETUP_URL = "https://getwavebrain.com/dashboard/setup";
|
|
11
12
|
|
|
12
13
|
const HELP = `wavebrain ${VERSION}: connect your AI agents to WaveBrain (https://getwavebrain.com)
|
|
13
14
|
|
|
14
15
|
Usage:
|
|
15
|
-
npx wavebrain init Pick agents and
|
|
16
|
+
npx wavebrain init Pick agents and connect them to WaveBrain
|
|
16
17
|
npx wavebrain remove Undo exactly what init added
|
|
17
18
|
npx wavebrain status Show which agents are configured
|
|
18
19
|
|
|
@@ -23,15 +24,22 @@ Options:
|
|
|
23
24
|
--key <key> API key (or set ${KEY_ENV}). Create one at
|
|
24
25
|
${SETUP_URL}
|
|
25
26
|
--dry-run Show what would change without writing anything
|
|
27
|
+
--no-hooks MCP server only: skip the hooks, skill and Hermes plugin
|
|
28
|
+
that record actions the model forgets to log
|
|
26
29
|
--no-claude-permission Don't pre-approve WaveBrain's tools in Claude Code
|
|
27
30
|
-y, --yes Don't ask for confirmation
|
|
28
31
|
--url <url> MCP server URL (default ${DEFAULT_URL})
|
|
29
32
|
-h, --help, -v, --version
|
|
30
33
|
|
|
34
|
+
For Claude Code, Codex, Cursor and Hermes, init also installs auto-capture: hooks that
|
|
35
|
+
record deploys, pushes, releases, payments, emails and database writes even when the model
|
|
36
|
+
doesn't call log_action, plus a short skill telling it when to log. Secrets are redacted
|
|
37
|
+
before anything is sent. Turn capture off any time with WAVEBRAIN_HOOKS=off.
|
|
38
|
+
|
|
31
39
|
Every file is backed up to <file>.bak-wavebrain before it's changed.`;
|
|
32
40
|
|
|
33
41
|
export function parseArgs(argv) {
|
|
34
|
-
const o = { command: null, agents: [], key: null, dryRun: false, yes: false, claudePermission: null, url: DEFAULT_URL };
|
|
42
|
+
const o = { command: null, agents: [], key: null, dryRun: false, yes: false, claudePermission: null, url: DEFAULT_URL, hooks: true };
|
|
35
43
|
for (let i = 0; i < argv.length; i++) {
|
|
36
44
|
const a = argv[i];
|
|
37
45
|
const val = () => {
|
|
@@ -47,6 +55,7 @@ export function parseArgs(argv) {
|
|
|
47
55
|
else if (a === "--dry-run") o.dryRun = true;
|
|
48
56
|
else if (a === "--yes" || a === "-y") o.yes = true;
|
|
49
57
|
else if (a === "--no-claude-permission") o.claudePermission = false;
|
|
58
|
+
else if (a === "--no-hooks") o.hooks = false;
|
|
50
59
|
else if (a === "--claude-permission") o.claudePermission = true;
|
|
51
60
|
else if (a === "-h" || a === "--help") o.command = "help";
|
|
52
61
|
else if (a === "-v" || a === "--version") o.command = "version";
|
|
@@ -133,7 +142,7 @@ async function init(o, ctx, io) {
|
|
|
133
142
|
io.out(`\n${o.dryRun ? "Dry run: nothing will be written.\n" : ""}Using key ${mask(key)} and ${o.url}\n`);
|
|
134
143
|
const state = loadState(ctx);
|
|
135
144
|
const session = new Session({ dryRun: o.dryRun });
|
|
136
|
-
const opts = { url: o.url, key, keyFromEnv, claudePermission };
|
|
145
|
+
const opts = { url: o.url, key, keyFromEnv, claudePermission, hooks: o.hooks };
|
|
137
146
|
let failed = 0;
|
|
138
147
|
const nextSteps = [];
|
|
139
148
|
|
|
@@ -168,6 +177,21 @@ async function init(o, ctx, io) {
|
|
|
168
177
|
}
|
|
169
178
|
}
|
|
170
179
|
|
|
180
|
+
// The hook runner and the key it uses, shared by every agent with auto-capture.
|
|
181
|
+
if (o.hooks && targets.some((t) => t.capture)) {
|
|
182
|
+
const results = stageCapture(ctx, session, opts);
|
|
183
|
+
const changed = results.filter((r) => r.kind !== "unchanged");
|
|
184
|
+
for (const r of results) {
|
|
185
|
+
const prev = state.records.find((x) => x.ref === r.ref);
|
|
186
|
+
state.records = state.records.filter((x) => x.ref !== r.ref);
|
|
187
|
+
state.records.push({ ref: r.ref, target: "capture", file: r.file, createdFile: prev?.createdFile ?? Boolean(r.createdFile) });
|
|
188
|
+
}
|
|
189
|
+
if (changed.length) {
|
|
190
|
+
io.out(`✓ Auto-capture`);
|
|
191
|
+
for (const r of changed) io.out(` ${o.dryRun ? "would " + verb(r.kind) : verb(r.kind, true)} ${describe(ctx, r)}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
171
195
|
if (!o.dryRun) saveState(ctx, state);
|
|
172
196
|
if (nextSteps.length) io.out(`\nNext:\n${nextSteps.map((s) => ` ${s}`).join("\n")}`);
|
|
173
197
|
if (!o.dryRun && session.changes.some((c) => c.backup)) io.out(`\nBackups end in .bak-wavebrain. Undo with: npx wavebrain remove`);
|
|
@@ -179,11 +203,26 @@ function verb(kind, past) {
|
|
|
179
203
|
}
|
|
180
204
|
|
|
181
205
|
function describe(ctx, r) {
|
|
182
|
-
const
|
|
206
|
+
const ref = r.ref;
|
|
207
|
+
const what = ref.endsWith(":permission")
|
|
183
208
|
? " (allow mcp__wavebrain__*)"
|
|
184
|
-
:
|
|
209
|
+
: ref.endsWith(":env")
|
|
185
210
|
? ` (${KEY_ENV})`
|
|
186
|
-
: "
|
|
211
|
+
: ref.endsWith(":hooks")
|
|
212
|
+
? " (auto-capture hooks)"
|
|
213
|
+
: ref.endsWith(":features")
|
|
214
|
+
? " ([features] hooks = true)"
|
|
215
|
+
: ref.endsWith(":skill")
|
|
216
|
+
? " (when-to-log skill)"
|
|
217
|
+
: ref.includes(":plugin:")
|
|
218
|
+
? " (WaveBrain plugin)"
|
|
219
|
+
: ref.endsWith(":plugin-enabled")
|
|
220
|
+
? " (plugins.enabled: wavebrain)"
|
|
221
|
+
: ref === "capture:config"
|
|
222
|
+
? " (API key for the hooks)"
|
|
223
|
+
: ref.startsWith("capture:")
|
|
224
|
+
? " (hook runner)"
|
|
225
|
+
: " (wavebrain server)";
|
|
187
226
|
return tilde(ctx, r.file) + what;
|
|
188
227
|
}
|
|
189
228
|
|
|
@@ -245,6 +284,16 @@ async function remove(o, ctx, io) {
|
|
|
245
284
|
io.out(`! ${t.label}: ${e.message}`);
|
|
246
285
|
}
|
|
247
286
|
}
|
|
287
|
+
// Last agent with auto-capture gone: remove the hook runner and its key too.
|
|
288
|
+
const captureLeft = state.records.some((r) => r.target !== "capture" && targetById(ctx, r.target)?.capture);
|
|
289
|
+
if (!captureLeft && state.records.some((r) => r.target === "capture")) {
|
|
290
|
+
const results = unstageCapture(ctx, session).filter((r) => r.kind !== "unchanged");
|
|
291
|
+
if (results.length) {
|
|
292
|
+
io.out(`✓ Auto-capture`);
|
|
293
|
+
for (const r of results) io.out(` ${o.dryRun ? "would " + verb(r.kind) : verb(r.kind, true)} ${tilde(ctx, r.file)}`);
|
|
294
|
+
}
|
|
295
|
+
state.records = state.records.filter((r) => r.target !== "capture");
|
|
296
|
+
}
|
|
248
297
|
if (!o.dryRun) saveState(ctx, state);
|
|
249
298
|
return 0;
|
|
250
299
|
}
|
package/src/files.mjs
CHANGED
|
@@ -35,16 +35,29 @@ export class Session {
|
|
|
35
35
|
const after = fn(before);
|
|
36
36
|
if (after === before) return "unchanged";
|
|
37
37
|
const kind = before === null ? "created" : after === null ? "deleted" : "updated";
|
|
38
|
-
|
|
38
|
+
// Files that are entirely ours (in a wavebrain folder) aren't backed up: a backup of
|
|
39
|
+
// ~/.wavebrain/config.json would leave the API key behind after `remove`.
|
|
40
|
+
const ownedFile = /[\\/](\.wavebrain|wavebrain)[\\/]/.test(file);
|
|
41
|
+
const backup = before === null || ownedFile ? null : file + BACKUP_SUFFIX;
|
|
39
42
|
if (!this.dryRun) {
|
|
40
43
|
let mode;
|
|
41
44
|
if (before !== null) {
|
|
42
45
|
mode = fs.statSync(file).mode & 0o777;
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
if (backup) {
|
|
47
|
+
fs.copyFileSync(file, backup);
|
|
48
|
+
fs.chmodSync(backup, mode);
|
|
49
|
+
}
|
|
45
50
|
}
|
|
46
|
-
if (after === null)
|
|
47
|
-
|
|
51
|
+
if (after === null) {
|
|
52
|
+
fs.unlinkSync(file);
|
|
53
|
+
// Folders that only ever held our files (skills/wavebrain, plugins/wavebrain, ~/.wavebrain/hooks).
|
|
54
|
+
const dir = path.dirname(file);
|
|
55
|
+
if (/(^|[\\/])(wavebrain|hooks)$/.test(dir) && /wavebrain/.test(dir)) {
|
|
56
|
+
try {
|
|
57
|
+
fs.rmdirSync(dir);
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
} else writeAtomic(file, after, mode);
|
|
48
61
|
}
|
|
49
62
|
this.changes.push({ file, kind, backup });
|
|
50
63
|
return kind;
|
package/src/targets.mjs
CHANGED
|
@@ -20,6 +20,17 @@ import {
|
|
|
20
20
|
yamlUpsertServer,
|
|
21
21
|
envUpsert,
|
|
22
22
|
} from "./files.mjs";
|
|
23
|
+
import {
|
|
24
|
+
addHooks,
|
|
25
|
+
codexDisableHooks,
|
|
26
|
+
codexEnableHooks,
|
|
27
|
+
hasOurHooks,
|
|
28
|
+
hermesPluginFiles,
|
|
29
|
+
hermesPluginToggle,
|
|
30
|
+
hookCommand,
|
|
31
|
+
removeHooks,
|
|
32
|
+
skillText,
|
|
33
|
+
} from "./capture.mjs";
|
|
23
34
|
|
|
24
35
|
export const SERVER = "wavebrain";
|
|
25
36
|
export const KEY_ENV = "WAVEBRAIN_API_KEY";
|
|
@@ -103,6 +114,56 @@ export function tilde(ctx, p) {
|
|
|
103
114
|
return p.startsWith(ctx.home + path.sep) ? "~" + p.slice(ctx.home.length) : p;
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
// ---------- auto-capture parts (hooks, skills, plugins) ----------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A capture part is one file we fully own (skill, plugin file) or one shared file we merge into
|
|
121
|
+
* (hooks.json, settings.json, config.toml). parts(ctx) -> [{ ref, file, apply(text), revert(text, rec) }]
|
|
122
|
+
*/
|
|
123
|
+
function hooksPart(ref, file, format, entries) {
|
|
124
|
+
return {
|
|
125
|
+
ref,
|
|
126
|
+
file,
|
|
127
|
+
apply: (t) => addHooks(t, file, format, entries),
|
|
128
|
+
revert: (t, rec) => removeHooks(t, file, format, { deleteIfEmpty: rec?.createdFile }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const ownedFilePart = (ref, file, text) => ({ ref, file, apply: () => text, revert: () => null });
|
|
132
|
+
|
|
133
|
+
/** Adds capture parts to a target. Skipped entirely with --no-hooks. */
|
|
134
|
+
function withCapture(target, parts, after) {
|
|
135
|
+
return {
|
|
136
|
+
...target,
|
|
137
|
+
capture: true,
|
|
138
|
+
files: (ctx) => [...target.files(ctx), ...parts(ctx).map((p) => p.file)],
|
|
139
|
+
install(ctx, s, opts, owned) {
|
|
140
|
+
const out = target.install(ctx, s, opts, owned);
|
|
141
|
+
if (opts.hooks === false) return out;
|
|
142
|
+
for (const p of parts(ctx)) {
|
|
143
|
+
const before = readText(p.file);
|
|
144
|
+
const prev = owned.get(p.ref);
|
|
145
|
+
out.push({ ref: p.ref, file: p.file, kind: s.edit(p.file, p.apply), createdFile: prev?.createdFile ?? before === null });
|
|
146
|
+
}
|
|
147
|
+
if (after) out.push(...after(ctx, s, true));
|
|
148
|
+
return out;
|
|
149
|
+
},
|
|
150
|
+
uninstall(ctx, s, owned) {
|
|
151
|
+
const out = [];
|
|
152
|
+
if (after) out.push(...after(ctx, s, false));
|
|
153
|
+
for (const p of parts(ctx)) {
|
|
154
|
+
const text = readText(p.file);
|
|
155
|
+
if (text === null) continue;
|
|
156
|
+
const rec = owned.get(p.ref);
|
|
157
|
+
// Our own files go only if we recorded them; merged files only lose our entries.
|
|
158
|
+
if (!rec && !hasOurHooks(text) && !(p.ref.endsWith(":features") && text.includes("added by wavebrain init"))) continue;
|
|
159
|
+
const kind = s.edit(p.file, (t) => p.revert(t, rec));
|
|
160
|
+
if (kind !== "unchanged") out.push({ ref: p.ref, file: p.file, kind });
|
|
161
|
+
}
|
|
162
|
+
return [...target.uninstall(ctx, s, owned), ...out];
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
106
167
|
// ---------- Claude Code ----------
|
|
107
168
|
|
|
108
169
|
function claudeDir(ctx) {
|
|
@@ -173,7 +234,7 @@ const codex = {
|
|
|
173
234
|
files: (ctx) => [codexToml(ctx)],
|
|
174
235
|
detect: (ctx) => exists(codexToml(ctx)) || onPath(ctx, "codex"),
|
|
175
236
|
configured: (ctx) => /^\s*\[mcp_servers\.wavebrain\]/m.test(readText(codexToml(ctx)) ?? ""),
|
|
176
|
-
next: `Start a new Codex session; /mcp should list wavebrain.`,
|
|
237
|
+
next: `Start a new Codex session; /mcp should list wavebrain. Codex asks you to review new hooks once: open /hooks and trust the two WaveBrain hooks (auto-capture stays off until you do).`,
|
|
177
238
|
install(ctx, s, { url, key, keyFromEnv }) {
|
|
178
239
|
const f = codexToml(ctx);
|
|
179
240
|
// With the key already in the environment, reference it; otherwise Codex needs it in the file.
|
|
@@ -242,7 +303,10 @@ const vscode = jsonTarget({
|
|
|
242
303
|
// ---------- Hermes ----------
|
|
243
304
|
|
|
244
305
|
function hermesHome(ctx) {
|
|
245
|
-
|
|
306
|
+
const h = ctx.env.HERMES_HOME;
|
|
307
|
+
// Inside a Hermes profile's terminal, HERMES_HOME is ~/.hermes/profiles/<name>; profiles live one level up.
|
|
308
|
+
if (h && path.basename(path.dirname(h)) === "profiles") return path.dirname(path.dirname(h));
|
|
309
|
+
return h || path.join(ctx.home, ".hermes");
|
|
246
310
|
}
|
|
247
311
|
|
|
248
312
|
function hermesTarget(profile) {
|
|
@@ -302,17 +366,73 @@ export function hermesProfiles(ctx) {
|
|
|
302
366
|
|
|
303
367
|
// ---------- registry ----------
|
|
304
368
|
|
|
369
|
+
const CLAUDE_PARTS = (ctx) => {
|
|
370
|
+
const settings = path.join(claudeDir(ctx), "settings.json");
|
|
371
|
+
return [
|
|
372
|
+
hooksPart("claude-code:hooks", settings, "nested", [
|
|
373
|
+
{ event: "PostToolUse", matcher: "Bash|mcp__.*", command: hookCommand(ctx, "claude-code", "observe"), timeout: 10 },
|
|
374
|
+
// Failed calls arrive here instead of PostToolUse; a failed deploy belongs in the record too.
|
|
375
|
+
{ event: "PostToolUseFailure", matcher: "Bash|mcp__.*", command: hookCommand(ctx, "claude-code", "observe"), timeout: 10 },
|
|
376
|
+
{ event: "Stop", command: hookCommand(ctx, "claude-code", "flush"), timeout: 30 },
|
|
377
|
+
{ event: "SessionEnd", command: hookCommand(ctx, "claude-code", "flush"), timeout: 30 },
|
|
378
|
+
]),
|
|
379
|
+
ownedFilePart("claude-code:skill", path.join(claudeDir(ctx), "skills", "wavebrain", "SKILL.md"), skillText()),
|
|
380
|
+
];
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const codexHome = (ctx) => ctx.env.CODEX_HOME || path.join(ctx.home, ".codex");
|
|
384
|
+
const CODEX_PARTS = (ctx) => [
|
|
385
|
+
hooksPart("codex:hooks", path.join(codexHome(ctx), "hooks.json"), "nested", [
|
|
386
|
+
{ event: "PostToolUse", matcher: "*", command: hookCommand(ctx, "codex", "observe"), timeout: 10 },
|
|
387
|
+
{ event: "Stop", command: hookCommand(ctx, "codex", "flush"), timeout: 30 },
|
|
388
|
+
]),
|
|
389
|
+
{ ref: "codex:features", file: codexToml(ctx), apply: codexEnableHooks, revert: codexDisableHooks },
|
|
390
|
+
ownedFilePart("codex:skill", path.join(codexHome(ctx), "skills", "wavebrain", "SKILL.md"), skillText()),
|
|
391
|
+
];
|
|
392
|
+
|
|
393
|
+
const CURSOR_PARTS = (ctx) => [
|
|
394
|
+
hooksPart("cursor:hooks", path.join(ctx.home, ".cursor", "hooks.json"), "flat", [
|
|
395
|
+
{ event: "postToolUse", matcher: "Shell|MCP", command: hookCommand(ctx, "cursor", "observe") },
|
|
396
|
+
{ event: "stop", command: hookCommand(ctx, "cursor", "flush") },
|
|
397
|
+
]),
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
function hermesDir(ctx, profile) {
|
|
401
|
+
return profile ? path.join(hermesHome(ctx), "profiles", profile) : hermesHome(ctx);
|
|
402
|
+
}
|
|
403
|
+
const hermesParts = (profile) => (ctx) =>
|
|
404
|
+
hermesPluginFiles().map(([f, text]) =>
|
|
405
|
+
ownedFilePart(`${profile ? `hermes:${profile}` : "hermes"}:plugin:${f}`, path.join(hermesDir(ctx, profile), "plugins", "wavebrain", f), text),
|
|
406
|
+
);
|
|
407
|
+
/** Hermes only loads third-party plugins on its allow-list. */
|
|
408
|
+
const hermesToggle = (profile) => (ctx, s, on) => {
|
|
409
|
+
const id = profile ? `hermes:${profile}` : "hermes";
|
|
410
|
+
if (s.dryRun) return [{ ref: `${id}:plugin-enabled`, file: path.join(hermesDir(ctx, profile), "config.yaml"), kind: on ? "enable plugin" : "disable plugin" }];
|
|
411
|
+
if (!onPath(ctx, "hermes")) return [];
|
|
412
|
+
const ok = hermesPluginToggle(ctx, hermesDir(ctx, profile), on);
|
|
413
|
+
return ok ? [{ ref: `${id}:plugin-enabled`, file: path.join(hermesDir(ctx, profile), "config.yaml"), kind: on ? "enabled plugin" : "disabled plugin" }] : [];
|
|
414
|
+
};
|
|
415
|
+
const hermesWithCapture = (profile) => withCapture(hermesTarget(profile), hermesParts(profile), hermesToggle(profile));
|
|
416
|
+
|
|
305
417
|
export const BASE_TARGET_IDS = ["claude-code", "codex", "cursor", "windsurf", "claude-desktop", "vscode", "hermes"];
|
|
306
418
|
|
|
307
|
-
const BASE = {
|
|
419
|
+
const BASE = {
|
|
420
|
+
"claude-code": withCapture(claudeCode, CLAUDE_PARTS),
|
|
421
|
+
codex: withCapture(codex, CODEX_PARTS),
|
|
422
|
+
cursor: withCapture(cursor, CURSOR_PARTS),
|
|
423
|
+
windsurf,
|
|
424
|
+
"claude-desktop": claudeDesktop,
|
|
425
|
+
vscode,
|
|
426
|
+
hermes: hermesWithCapture(null),
|
|
427
|
+
};
|
|
308
428
|
|
|
309
429
|
/** Every target, including one per Hermes profile found. */
|
|
310
430
|
export function allTargets(ctx) {
|
|
311
|
-
return [...BASE_TARGET_IDS.map((id) => BASE[id]), ...hermesProfiles(ctx).map((p) =>
|
|
431
|
+
return [...BASE_TARGET_IDS.map((id) => BASE[id]), ...hermesProfiles(ctx).map((p) => hermesWithCapture(p))];
|
|
312
432
|
}
|
|
313
433
|
|
|
314
434
|
export function targetById(ctx, id) {
|
|
315
435
|
if (BASE[id]) return BASE[id];
|
|
316
436
|
const m = id.match(/^hermes:([A-Za-z0-9._-]+)$/);
|
|
317
|
-
return m ?
|
|
437
|
+
return m ? hermesWithCapture(m[1]) : null;
|
|
318
438
|
}
|