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/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.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 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,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
- return ctx.env.HERMES_HOME || path.join(ctx.home, ".hermes");
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 = { "claude-code": claudeCode, codex, cursor, windsurf, "claude-desktop": claudeDesktop, vscode, hermes: hermesTarget(null) };
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) => hermesTarget(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 ? hermesTarget(m[1]) : null;
437
+ return m ? hermesWithCapture(m[1]) : null;
318
438
  }