wavebrain 0.2.0 → 0.3.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/targets.mjs DELETED
@@ -1,438 +0,0 @@
1
- // One target per agent config WaveBrain can be added to. Every path comes from ctx
2
- // ({ home, env, platform }) so tests and dry runs can point everything at a sandbox.
3
-
4
- import fs from "node:fs";
5
- import path from "node:path";
6
- import {
7
- Conflict,
8
- getIn,
9
- hasBlock,
10
- jsonArrayAdd,
11
- jsonArrayRemove,
12
- jsonDelete,
13
- jsonSet,
14
- firstMissing,
15
- parseJson,
16
- readText,
17
- removeBlock,
18
- tomlString,
19
- tomlUpsertServer,
20
- yamlUpsertServer,
21
- envUpsert,
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";
34
-
35
- export const SERVER = "wavebrain";
36
- export const KEY_ENV = "WAVEBRAIN_API_KEY";
37
- export const CLAUDE_RULE = `mcp__${SERVER}__*`;
38
-
39
- const exists = (p) => {
40
- try {
41
- fs.statSync(p);
42
- return true;
43
- } catch {
44
- return false;
45
- }
46
- };
47
-
48
- export function onPath(ctx, bin) {
49
- const dirs = (ctx.env.PATH ?? "").split(path.delimiter).filter(Boolean);
50
- const exts = ctx.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
51
- return dirs.some((d) => exts.some((x) => exists(path.join(d, bin + x))));
52
- }
53
-
54
- function appData(ctx) {
55
- return ctx.env.APPDATA || path.join(ctx.home, "AppData", "Roaming");
56
- }
57
-
58
- function appSupport(ctx, ...rest) {
59
- if (ctx.platform === "darwin") return path.join(ctx.home, "Library", "Application Support", ...rest);
60
- if (ctx.platform === "win32") return path.join(appData(ctx), ...rest);
61
- return path.join(ctx.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config"), ...rest);
62
- }
63
-
64
- const bearer = (key) => `Bearer ${key}`;
65
-
66
- /** A target whose config is one JSON file with our server under `keys`. */
67
- function jsonTarget({ id, label, file, keys, entry, detect, next }) {
68
- return {
69
- id,
70
- label,
71
- files: (ctx) => [file(ctx)],
72
- detect,
73
- next,
74
- configured: (ctx) => getIn(safeJson(file(ctx)), keys) !== undefined,
75
- install(ctx, s, opts, owned) {
76
- const f = file(ctx);
77
- const current = getIn(safeJson(f), keys);
78
- if (current !== undefined && !owned.has(`${id}:server`)) {
79
- throw new Conflict(`a "${SERVER}" server is already in ${tilde(ctx, f)} (not added by wavebrain init); left as is`);
80
- }
81
- const before = readText(f);
82
- const prev = owned.get(`${id}:server`);
83
- const kind = s.edit(f, (t) => jsonSet(t, keys, entry(opts), f));
84
- return [
85
- {
86
- ref: `${id}:server`,
87
- file: f,
88
- kind,
89
- createdFile: prev?.createdFile ?? before === null,
90
- pruneFrom: prev?.pruneFrom ?? firstMissing(before, keys, f),
91
- },
92
- ];
93
- },
94
- uninstall(ctx, s, owned) {
95
- const rec = owned.get(`${id}:server`);
96
- if (!rec) return [];
97
- const kind = s.edit(rec.file, (t) =>
98
- jsonDelete(t, keys, rec.file, { deleteIfEmpty: rec.createdFile, pruneFrom: rec.pruneFrom ?? keys.length - 1 }),
99
- );
100
- return [{ ref: `${id}:server`, file: rec.file, kind }];
101
- },
102
- };
103
- }
104
-
105
- function safeJson(file) {
106
- try {
107
- return parseJson(readText(file), file);
108
- } catch {
109
- return {};
110
- }
111
- }
112
-
113
- export function tilde(ctx, p) {
114
- return p.startsWith(ctx.home + path.sep) ? "~" + p.slice(ctx.home.length) : p;
115
- }
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
-
167
- // ---------- Claude Code ----------
168
-
169
- function claudeDir(ctx) {
170
- return ctx.env.CLAUDE_CONFIG_DIR || path.join(ctx.home, ".claude");
171
- }
172
- function claudeJson(ctx) {
173
- return ctx.env.CLAUDE_CONFIG_DIR ? path.join(ctx.env.CLAUDE_CONFIG_DIR, ".claude.json") : path.join(ctx.home, ".claude.json");
174
- }
175
-
176
- const claudeServer = jsonTarget({
177
- id: "claude-code",
178
- label: "Claude Code",
179
- file: claudeJson,
180
- keys: ["mcpServers", SERVER],
181
- entry: ({ url, key }) => ({ type: "http", url, headers: { Authorization: bearer(key) } }),
182
- detect: (ctx) => onPath(ctx, "claude") || exists(claudeJson(ctx)),
183
- next: "Start a new Claude Code session; /mcp should list wavebrain.",
184
- });
185
-
186
- const claudeCode = {
187
- ...claudeServer,
188
- files: (ctx) => [claudeJson(ctx), path.join(claudeDir(ctx), "settings.json")],
189
- install(ctx, s, opts, owned) {
190
- const out = claudeServer.install(ctx, s, opts, owned);
191
- if (opts.claudePermission) {
192
- const f = path.join(claudeDir(ctx), "settings.json");
193
- const had = (getIn(safeJson(f), ["permissions", "allow"]) ?? []).includes(CLAUDE_RULE);
194
- const prev = owned.get("claude-code:permission");
195
- if (!had || prev) {
196
- const before = readText(f);
197
- const kind = s.edit(f, (t) => jsonArrayAdd(t, ["permissions", "allow"], CLAUDE_RULE, f));
198
- out.push({
199
- ref: "claude-code:permission",
200
- file: f,
201
- kind,
202
- createdFile: prev?.createdFile ?? before === null,
203
- pruneFrom: prev?.pruneFrom ?? firstMissing(before, ["permissions", "allow"], f),
204
- });
205
- }
206
- }
207
- return out;
208
- },
209
- uninstall(ctx, s, owned) {
210
- const out = claudeServer.uninstall(ctx, s, owned);
211
- const rec = owned.get("claude-code:permission");
212
- if (rec) {
213
- const kind = s.edit(rec.file, (t) =>
214
- jsonArrayRemove(t, ["permissions", "allow"], CLAUDE_RULE, rec.file, {
215
- deleteIfEmpty: rec.createdFile,
216
- pruneFrom: rec.pruneFrom ?? 2,
217
- }),
218
- );
219
- out.push({ ref: "claude-code:permission", file: rec.file, kind });
220
- }
221
- return out;
222
- },
223
- };
224
-
225
- // ---------- Codex ----------
226
-
227
- function codexToml(ctx) {
228
- return path.join(ctx.env.CODEX_HOME || path.join(ctx.home, ".codex"), "config.toml");
229
- }
230
-
231
- const codex = {
232
- id: "codex",
233
- label: "Codex CLI",
234
- files: (ctx) => [codexToml(ctx)],
235
- detect: (ctx) => exists(codexToml(ctx)) || onPath(ctx, "codex"),
236
- configured: (ctx) => /^\s*\[mcp_servers\.wavebrain\]/m.test(readText(codexToml(ctx)) ?? ""),
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).`,
238
- install(ctx, s, { url, key, keyFromEnv }) {
239
- const f = codexToml(ctx);
240
- // With the key already in the environment, reference it; otherwise Codex needs it in the file.
241
- const auth = keyFromEnv
242
- ? [["bearer_token_env_var", tomlString(KEY_ENV)]]
243
- : [["http_headers", `{ "Authorization" = ${tomlString(bearer(key))} }`]];
244
- const kind = s.edit(f, (t) => tomlUpsertServer(t, SERVER, [["url", tomlString(url)], ...auth]));
245
- return [{ ref: "codex:server", file: f, kind }];
246
- },
247
- uninstall(ctx, s, owned) {
248
- const f = owned.get("codex:server")?.file ?? codexToml(ctx);
249
- if (!hasBlock(readText(f))) return [];
250
- return [{ ref: "codex:server", file: f, kind: s.edit(f, (t) => removeBlock(t, { restore: "delete-if-empty" })) }];
251
- },
252
- };
253
-
254
- // ---------- Cursor, Windsurf, Claude Desktop, VS Code ----------
255
-
256
- const cursor = jsonTarget({
257
- id: "cursor",
258
- label: "Cursor",
259
- file: (ctx) => path.join(ctx.home, ".cursor", "mcp.json"),
260
- keys: ["mcpServers", SERVER],
261
- entry: ({ url, key }) => ({ url, headers: { Authorization: bearer(key) } }),
262
- detect: (ctx) => exists(path.join(ctx.home, ".cursor")),
263
- next: "Open Cursor Settings → MCP to check that wavebrain is enabled.",
264
- });
265
-
266
- const windsurf = jsonTarget({
267
- id: "windsurf",
268
- label: "Windsurf",
269
- file: (ctx) => path.join(ctx.home, ".codeium", "windsurf", "mcp_config.json"),
270
- keys: ["mcpServers", SERVER],
271
- entry: ({ url, key }) => ({ serverUrl: url, headers: { Authorization: bearer(key) } }),
272
- detect: (ctx) => exists(path.join(ctx.home, ".codeium", "windsurf")),
273
- next: "Press Refresh in Cascade's MCP panel.",
274
- });
275
-
276
- const claudeDesktopFile = (ctx) => appSupport(ctx, "Claude", "claude_desktop_config.json");
277
- const claudeDesktop = jsonTarget({
278
- id: "claude-desktop",
279
- label: "Claude Desktop",
280
- file: claudeDesktopFile,
281
- keys: ["mcpServers", SERVER],
282
- // The config only takes local servers, so the remote one goes through mcp-remote (needs Node.js).
283
- entry: ({ url, key }) => ({
284
- command: "npx",
285
- args: ["-y", "mcp-remote", url, "--header", "Authorization:${AUTH_HEADER}"],
286
- env: { AUTH_HEADER: bearer(key) },
287
- }),
288
- detect: (ctx) => exists(path.dirname(claudeDesktopFile(ctx))),
289
- next: "Quit and reopen Claude Desktop.",
290
- });
291
-
292
- const vscodeFile = (ctx) => appSupport(ctx, "Code", "User", "mcp.json");
293
- const vscode = jsonTarget({
294
- id: "vscode",
295
- label: "VS Code (Copilot)",
296
- file: vscodeFile,
297
- keys: ["servers", SERVER],
298
- entry: ({ url, key }) => ({ type: "http", url, headers: { Authorization: bearer(key) } }),
299
- detect: (ctx) => exists(path.dirname(vscodeFile(ctx))) || onPath(ctx, "code"),
300
- next: "In VS Code, run “MCP: List Servers” and start wavebrain if needed.",
301
- });
302
-
303
- // ---------- Hermes ----------
304
-
305
- function hermesHome(ctx) {
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");
310
- }
311
-
312
- function hermesTarget(profile) {
313
- const id = profile ? `hermes:${profile}` : "hermes";
314
- const dir = (ctx) => (profile ? path.join(hermesHome(ctx), "profiles", profile) : hermesHome(ctx));
315
- const yaml = (ctx) => path.join(dir(ctx), "config.yaml");
316
- const env = (ctx) => path.join(dir(ctx), ".env");
317
- return {
318
- id,
319
- label: profile ? `Hermes Agent (profile ${profile})` : "Hermes Agent",
320
- files: (ctx) => [yaml(ctx), env(ctx)],
321
- detect: (ctx) => exists(yaml(ctx)),
322
- configured: (ctx) => hasBlock(readText(yaml(ctx))) || /^\s+wavebrain:/m.test(readText(yaml(ctx)) ?? ""),
323
- next: profile ? `Run: hermes -p ${profile} gateway restart (or /reload-mcp in a chat).` : "Run: hermes gateway restart (or /reload-mcp in a chat).",
324
- install(ctx, s, { url, key }) {
325
- const y = yaml(ctx);
326
- const e = env(ctx);
327
- // Check both files before writing either, so a conflict leaves nothing half done.
328
- envUpsert(readText(e), KEY_ENV, key);
329
- yamlUpsertServer(readText(y), SERVER, (n, name) => serverLines(n, name, url));
330
- const yk = s.edit(y, (t) => yamlUpsertServer(t, SERVER, (n, name) => serverLines(n, name, url)));
331
- const ek = s.edit(e, (t) => envUpsert(t, KEY_ENV, key));
332
- return [
333
- { ref: `${id}:server`, file: y, kind: yk },
334
- { ref: `${id}:env`, file: e, kind: ek },
335
- ];
336
- },
337
- uninstall(ctx, s) {
338
- const out = [];
339
- for (const [ref, f] of [
340
- [`${id}:server`, yaml(ctx)],
341
- [`${id}:env`, env(ctx)],
342
- ]) {
343
- if (hasBlock(readText(f))) out.push({ ref, file: f, kind: s.edit(f, (t) => removeBlock(t, { restore: "delete-if-empty" })) });
344
- }
345
- return out;
346
- },
347
- };
348
- }
349
-
350
- function serverLines(n, name, url) {
351
- const i = " ".repeat(n);
352
- return [`${name}:`, `${i}url: "${url}"`, `${i}headers:`, `${i}${i}Authorization: "Bearer \${${KEY_ENV}}"`];
353
- }
354
-
355
- export function hermesProfiles(ctx) {
356
- try {
357
- return fs
358
- .readdirSync(path.join(hermesHome(ctx), "profiles"), { withFileTypes: true })
359
- .filter((d) => d.isDirectory() && /^[A-Za-z0-9._-]+$/.test(d.name))
360
- .map((d) => d.name)
361
- .sort();
362
- } catch {
363
- return [];
364
- }
365
- }
366
-
367
- // ---------- registry ----------
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
-
417
- export const BASE_TARGET_IDS = ["claude-code", "codex", "cursor", "windsurf", "claude-desktop", "vscode", "hermes"];
418
-
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
- };
428
-
429
- /** Every target, including one per Hermes profile found. */
430
- export function allTargets(ctx) {
431
- return [...BASE_TARGET_IDS.map((id) => BASE[id]), ...hermesProfiles(ctx).map((p) => hermesWithCapture(p))];
432
- }
433
-
434
- export function targetById(ctx, id) {
435
- if (BASE[id]) return BASE[id];
436
- const m = id.match(/^hermes:([A-Za-z0-9._-]+)$/);
437
- return m ? hermesWithCapture(m[1]) : null;
438
- }