wavebrain 0.1.0 → 0.2.1

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/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.1.0";
9
+ export const VERSION = "0.2.1";
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 add WaveBrain to their config
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 what = r.ref.endsWith(":permission")
206
+ const ref = r.ref;
207
+ const what = ref.endsWith(":permission")
183
208
  ? " (allow mcp__wavebrain__*)"
184
- : r.ref.endsWith(":env")
209
+ : ref.endsWith(":env")
185
210
  ? ` (${KEY_ENV})`
186
- : " (wavebrain server)";
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
- const backup = before === null ? null : file + BACKUP_SUFFIX;
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
- fs.copyFileSync(file, backup);
44
- fs.chmodSync(backup, mode);
46
+ if (backup) {
47
+ fs.copyFileSync(file, backup);
48
+ fs.chmodSync(backup, mode);
49
+ }
45
50
  }
46
- if (after === null) fs.unlinkSync(file);
47
- else writeAtomic(file, after, mode);
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,18 @@ 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
+ hermesUnlistPlugin,
31
+ hookCommand,
32
+ removeHooks,
33
+ skillText,
34
+ } from "./capture.mjs";
23
35
 
24
36
  export const SERVER = "wavebrain";
25
37
  export const KEY_ENV = "WAVEBRAIN_API_KEY";
@@ -103,6 +115,56 @@ export function tilde(ctx, p) {
103
115
  return p.startsWith(ctx.home + path.sep) ? "~" + p.slice(ctx.home.length) : p;
104
116
  }
105
117
 
118
+ // ---------- auto-capture parts (hooks, skills, plugins) ----------
119
+
120
+ /**
121
+ * A capture part is one file we fully own (skill, plugin file) or one shared file we merge into
122
+ * (hooks.json, settings.json, config.toml). parts(ctx) -> [{ ref, file, apply(text), revert(text, rec) }]
123
+ */
124
+ function hooksPart(ref, file, format, entries) {
125
+ return {
126
+ ref,
127
+ file,
128
+ apply: (t) => addHooks(t, file, format, entries),
129
+ revert: (t, rec) => removeHooks(t, file, format, { deleteIfEmpty: rec?.createdFile }),
130
+ };
131
+ }
132
+ const ownedFilePart = (ref, file, text) => ({ ref, file, apply: () => text, revert: () => null });
133
+
134
+ /** Adds capture parts to a target. Skipped entirely with --no-hooks. */
135
+ function withCapture(target, parts, after) {
136
+ return {
137
+ ...target,
138
+ capture: true,
139
+ files: (ctx) => [...target.files(ctx), ...parts(ctx).map((p) => p.file)],
140
+ install(ctx, s, opts, owned) {
141
+ const out = target.install(ctx, s, opts, owned);
142
+ if (opts.hooks === false) return out;
143
+ for (const p of parts(ctx)) {
144
+ const before = readText(p.file);
145
+ const prev = owned.get(p.ref);
146
+ out.push({ ref: p.ref, file: p.file, kind: s.edit(p.file, p.apply), createdFile: prev?.createdFile ?? before === null });
147
+ }
148
+ if (after) out.push(...after(ctx, s, true));
149
+ return out;
150
+ },
151
+ uninstall(ctx, s, owned) {
152
+ const out = [];
153
+ if (after) out.push(...after(ctx, s, false));
154
+ for (const p of parts(ctx)) {
155
+ const text = readText(p.file);
156
+ if (text === null) continue;
157
+ const rec = owned.get(p.ref);
158
+ // Our own files go only if we recorded them; merged files only lose our entries.
159
+ if (!rec && !hasOurHooks(text) && !(p.ref.endsWith(":features") && text.includes("added by wavebrain init"))) continue;
160
+ const kind = s.edit(p.file, (t) => p.revert(t, rec));
161
+ if (kind !== "unchanged") out.push({ ref: p.ref, file: p.file, kind });
162
+ }
163
+ return [...target.uninstall(ctx, s, owned), ...out];
164
+ },
165
+ };
166
+ }
167
+
106
168
  // ---------- Claude Code ----------
107
169
 
108
170
  function claudeDir(ctx) {
@@ -173,7 +235,7 @@ const codex = {
173
235
  files: (ctx) => [codexToml(ctx)],
174
236
  detect: (ctx) => exists(codexToml(ctx)) || onPath(ctx, "codex"),
175
237
  configured: (ctx) => /^\s*\[mcp_servers\.wavebrain\]/m.test(readText(codexToml(ctx)) ?? ""),
176
- next: `Start a new Codex session; /mcp should list wavebrain.`,
238
+ 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
239
  install(ctx, s, { url, key, keyFromEnv }) {
178
240
  const f = codexToml(ctx);
179
241
  // With the key already in the environment, reference it; otherwise Codex needs it in the file.
@@ -242,7 +304,10 @@ const vscode = jsonTarget({
242
304
  // ---------- Hermes ----------
243
305
 
244
306
  function hermesHome(ctx) {
245
- return ctx.env.HERMES_HOME || path.join(ctx.home, ".hermes");
307
+ const h = ctx.env.HERMES_HOME;
308
+ // Inside a Hermes profile's terminal, HERMES_HOME is ~/.hermes/profiles/<name>; profiles live one level up.
309
+ if (h && path.basename(path.dirname(h)) === "profiles") return path.dirname(path.dirname(h));
310
+ return h || path.join(ctx.home, ".hermes");
246
311
  }
247
312
 
248
313
  function hermesTarget(profile) {
@@ -302,17 +367,79 @@ export function hermesProfiles(ctx) {
302
367
 
303
368
  // ---------- registry ----------
304
369
 
370
+ const CLAUDE_PARTS = (ctx) => {
371
+ const settings = path.join(claudeDir(ctx), "settings.json");
372
+ return [
373
+ hooksPart("claude-code:hooks", settings, "nested", [
374
+ { event: "PostToolUse", matcher: "Bash|mcp__.*", command: hookCommand(ctx, "claude-code", "observe"), timeout: 10 },
375
+ // Failed calls arrive here instead of PostToolUse; a failed deploy belongs in the record too.
376
+ { event: "PostToolUseFailure", matcher: "Bash|mcp__.*", command: hookCommand(ctx, "claude-code", "observe"), timeout: 10 },
377
+ { event: "Stop", command: hookCommand(ctx, "claude-code", "flush"), timeout: 30 },
378
+ { event: "SessionEnd", command: hookCommand(ctx, "claude-code", "flush"), timeout: 30 },
379
+ ]),
380
+ ownedFilePart("claude-code:skill", path.join(claudeDir(ctx), "skills", "wavebrain", "SKILL.md"), skillText()),
381
+ ];
382
+ };
383
+
384
+ const codexHome = (ctx) => ctx.env.CODEX_HOME || path.join(ctx.home, ".codex");
385
+ const CODEX_PARTS = (ctx) => [
386
+ hooksPart("codex:hooks", path.join(codexHome(ctx), "hooks.json"), "nested", [
387
+ { event: "PostToolUse", matcher: "*", command: hookCommand(ctx, "codex", "observe"), timeout: 10 },
388
+ { event: "Stop", command: hookCommand(ctx, "codex", "flush"), timeout: 30 },
389
+ ]),
390
+ { ref: "codex:features", file: codexToml(ctx), apply: codexEnableHooks, revert: codexDisableHooks },
391
+ ownedFilePart("codex:skill", path.join(codexHome(ctx), "skills", "wavebrain", "SKILL.md"), skillText()),
392
+ ];
393
+
394
+ const CURSOR_PARTS = (ctx) => [
395
+ hooksPart("cursor:hooks", path.join(ctx.home, ".cursor", "hooks.json"), "flat", [
396
+ { event: "postToolUse", matcher: "Shell|MCP", command: hookCommand(ctx, "cursor", "observe") },
397
+ { event: "stop", command: hookCommand(ctx, "cursor", "flush") },
398
+ ]),
399
+ ];
400
+
401
+ function hermesDir(ctx, profile) {
402
+ return profile ? path.join(hermesHome(ctx), "profiles", profile) : hermesHome(ctx);
403
+ }
404
+ const hermesParts = (profile) => (ctx) =>
405
+ hermesPluginFiles().map(([f, text]) =>
406
+ ownedFilePart(`${profile ? `hermes:${profile}` : "hermes"}:plugin:${f}`, path.join(hermesDir(ctx, profile), "plugins", "wavebrain", f), text),
407
+ );
408
+ /** Hermes only loads third-party plugins on its allow-list. */
409
+ const hermesToggle = (profile) => (ctx, s, on) => {
410
+ const id = profile ? `hermes:${profile}` : "hermes";
411
+ if (s.dryRun) return [{ ref: `${id}:plugin-enabled`, file: path.join(hermesDir(ctx, profile), "config.yaml"), kind: on ? "enable plugin" : "disable plugin" }];
412
+ const file = path.join(hermesDir(ctx, profile), "config.yaml");
413
+ if (!on) {
414
+ // The plugin files are being deleted; take wavebrain off the plugin lists rather than marking it disabled.
415
+ const kind = s.edit(file, hermesUnlistPlugin);
416
+ return kind === "unchanged" ? [] : [{ ref: `${id}:plugin-enabled`, file, kind: "disabled plugin" }];
417
+ }
418
+ if (!onPath(ctx, "hermes")) return [];
419
+ const ok = hermesPluginToggle(ctx, hermesDir(ctx, profile), on);
420
+ return ok ? [{ ref: `${id}:plugin-enabled`, file: path.join(hermesDir(ctx, profile), "config.yaml"), kind: on ? "enabled plugin" : "disabled plugin" }] : [];
421
+ };
422
+ const hermesWithCapture = (profile) => withCapture(hermesTarget(profile), hermesParts(profile), hermesToggle(profile));
423
+
305
424
  export const BASE_TARGET_IDS = ["claude-code", "codex", "cursor", "windsurf", "claude-desktop", "vscode", "hermes"];
306
425
 
307
- const BASE = { "claude-code": claudeCode, codex, cursor, windsurf, "claude-desktop": claudeDesktop, vscode, hermes: hermesTarget(null) };
426
+ const BASE = {
427
+ "claude-code": withCapture(claudeCode, CLAUDE_PARTS),
428
+ codex: withCapture(codex, CODEX_PARTS),
429
+ cursor: withCapture(cursor, CURSOR_PARTS),
430
+ windsurf,
431
+ "claude-desktop": claudeDesktop,
432
+ vscode,
433
+ hermes: hermesWithCapture(null),
434
+ };
308
435
 
309
436
  /** Every target, including one per Hermes profile found. */
310
437
  export function allTargets(ctx) {
311
- return [...BASE_TARGET_IDS.map((id) => BASE[id]), ...hermesProfiles(ctx).map((p) => hermesTarget(p))];
438
+ return [...BASE_TARGET_IDS.map((id) => BASE[id]), ...hermesProfiles(ctx).map((p) => hermesWithCapture(p))];
312
439
  }
313
440
 
314
441
  export function targetById(ctx, id) {
315
442
  if (BASE[id]) return BASE[id];
316
443
  const m = id.match(/^hermes:([A-Za-z0-9._-]+)$/);
317
- return m ? hermesTarget(m[1]) : null;
444
+ return m ? hermesWithCapture(m[1]) : null;
318
445
  }