wavebrain-claude-code 0.0.0-stage → 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.
@@ -0,0 +1,149 @@
1
+ // One installer per agent harness: wavebrain-claude-code, wavebrain-codex, wavebrain-cursor and
2
+ // wavebrain-hermes. Each is a thin front end over the shared engine in cli.mjs, limited to its
3
+ // own agent's targets, with install / uninstall / status.
4
+
5
+ import { UsageError, VERSION, defaultCtx, defaultIo, init, pick, remove, status } from "./cli.mjs";
6
+ import { KEY_ENV, hermesProfiles, targetById } from "./targets.mjs";
7
+
8
+ const DEFAULT_URL = "https://getwavebrain.com/api/mcp";
9
+ const SETUP_URL = "https://getwavebrain.com/dashboard/setup";
10
+
11
+ /** The harness installers. `targets(ctx, o, io)` returns the engine targets to act on. */
12
+ export const HARNESSES = {
13
+ "claude-code": {
14
+ label: "Claude Code",
15
+ gets: "the WaveBrain MCP server, auto-capture hooks and a when-to-log skill",
16
+ options: ` --no-claude-permission Don't pre-approve WaveBrain's tools (mcp__wavebrain__*), so
17
+ narrow --allowedTools runs (claude -p) can't log`,
18
+ targets: (ctx) => [targetById(ctx, "claude-code")],
19
+ },
20
+ codex: {
21
+ label: "Codex CLI",
22
+ gets: "the WaveBrain MCP server, auto-capture hooks and a when-to-log skill",
23
+ targets: (ctx) => [targetById(ctx, "codex")],
24
+ },
25
+ cursor: {
26
+ label: "Cursor",
27
+ gets: "the WaveBrain MCP server and auto-capture hooks",
28
+ targets: (ctx) => [targetById(ctx, "cursor")],
29
+ },
30
+ hermes: {
31
+ label: "Hermes Agent",
32
+ gets: "the WaveBrain MCP server and the WaveBrain plugin, for each profile you pick",
33
+ options: ` --profile <name> Profile to set up (repeatable). "default" is ~/.hermes itself.
34
+ Without it you pick from a list (or get "default" when not
35
+ in a terminal).
36
+ --all-profiles Every profile, including default`,
37
+ targets: hermesTargets,
38
+ },
39
+ };
40
+
41
+ async function hermesTargets(ctx, o, io, verb) {
42
+ const all = [targetById(ctx, "hermes"), ...hermesProfiles(ctx).map((p) => targetById(ctx, `hermes:${p}`))];
43
+ if (o.allProfiles) return all;
44
+ if (o.profiles.length) {
45
+ const known = hermesProfiles(ctx);
46
+ return o.profiles.map((p) => {
47
+ const t = p === "default" ? all[0] : known.includes(p) ? targetById(ctx, `hermes:${p}`) : null;
48
+ if (!t) throw new UsageError(`No Hermes profile "${p}". Profiles here: default${hermesProfiles(ctx).map((x) => `, ${x}`).join("")}`);
49
+ return t;
50
+ });
51
+ }
52
+ if (verb !== "install") return all; // uninstall / status: every profile WaveBrain touched
53
+ if (all.length === 1 || !io.interactive) return [all[0]];
54
+ return pick(ctx, io, all);
55
+ }
56
+
57
+ export function parseHarnessArgs(argv) {
58
+ const o = { command: null, key: null, url: DEFAULT_URL, dryRun: false, yes: false, hooks: true, claudePermission: null, profiles: [], allProfiles: false };
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const a = argv[i];
61
+ const val = () => {
62
+ const v = argv[++i];
63
+ if (v === undefined || v.startsWith("--")) throw new UsageError(`${a} needs a value`);
64
+ return v;
65
+ };
66
+ if (a === "--key") o.key = val();
67
+ else if (a.startsWith("--key=")) o.key = a.slice(6);
68
+ else if (a === "--url") o.url = val();
69
+ else if (a === "--profile" || a === "-p") o.profiles.push(val());
70
+ else if (a.startsWith("--profile=")) o.profiles.push(a.slice(10));
71
+ else if (a === "--all-profiles") o.allProfiles = true;
72
+ else if (a === "--dry-run") o.dryRun = true;
73
+ else if (a === "--yes" || a === "-y") o.yes = true;
74
+ else if (a === "--no-hooks") o.hooks = false;
75
+ else if (a === "--no-claude-permission") o.claudePermission = false;
76
+ else if (a === "--claude-permission") o.claudePermission = true;
77
+ else if (a === "-h" || a === "--help") o.command = "help";
78
+ else if (a === "-v" || a === "--version") o.command = "version";
79
+ else if (!a.startsWith("-") && !o.command) o.command = a;
80
+ else throw new UsageError(`Unknown option: ${a}`);
81
+ }
82
+ o.command ??= "install";
83
+ if (o.command === "remove") o.command = "uninstall";
84
+ if (o.command === "init") o.command = "install";
85
+ return o;
86
+ }
87
+
88
+ function help(id, h) {
89
+ const bin = `wavebrain-${id}`;
90
+ return `${bin} ${VERSION}: connect ${h.label} to WaveBrain (https://getwavebrain.com)
91
+
92
+ Usage:
93
+ npx ${bin} install Add ${h.gets}
94
+ npx ${bin} uninstall Undo exactly what install added
95
+ npx ${bin} status Show whether ${h.label} is set up
96
+
97
+ Options:
98
+ --key <key> API key (or set ${KEY_ENV}). Create one at
99
+ ${SETUP_URL}
100
+ --no-hooks MCP server only, without auto-capture
101
+ ${h.options ? h.options + "\n" : ""} --dry-run Show what would change without writing anything
102
+ -y, --yes Don't ask for confirmation
103
+ --url <url> MCP server URL (default ${DEFAULT_URL})
104
+ -h, --help, -v, --version
105
+
106
+ Auto-capture records deploys, pushes, releases, payments, emails and database writes even
107
+ when the model doesn't call log_action. Secrets are redacted before anything is sent.
108
+ Pause it any time with WAVEBRAIN_HOOKS=off. Every file is backed up to
109
+ <file>.bak-wavebrain before it's changed.`;
110
+ }
111
+
112
+ /** Entry point for one harness installer. */
113
+ export async function runHarness(id, argv, ctx = defaultCtx(), io = defaultIo()) {
114
+ const h = HARNESSES[id];
115
+ if (!h) throw new Error(`unknown harness ${id}`);
116
+ let o;
117
+ try {
118
+ o = parseHarnessArgs(argv);
119
+ } catch (e) {
120
+ if (!(e instanceof UsageError)) throw e;
121
+ io.out(`${e.message}\n\n${help(id, h)}`);
122
+ return 2;
123
+ }
124
+ if (id !== "hermes" && (o.profiles.length || o.allProfiles)) {
125
+ io.out("--profile and --all-profiles are for wavebrain-hermes.");
126
+ return 2;
127
+ }
128
+ try {
129
+ if (o.command === "help") return io.out(help(id, h)), 0;
130
+ if (o.command === "version") return io.out(VERSION), 0;
131
+ const targets = await h.targets(ctx, o, io, o.command);
132
+ if (o.command === "status") return status(ctx, io, targets);
133
+ const undo = `npx wavebrain-${id} uninstall`;
134
+ if (o.command === "install") {
135
+ if (!targets.length) return io.out("Nothing selected; nothing changed."), 0;
136
+ const code = await init({ ...o, agents: [], targets, undo }, ctx, io);
137
+ if (h.after && code === 0 && o.hooks && !o.dryRun) io.out(`\n${h.after}`);
138
+ return code;
139
+ }
140
+ if (o.command === "uninstall") return await remove({ ...o, agents: [], targets }, ctx, io);
141
+ throw new UsageError(`Unknown command: ${o.command}. Use install, uninstall or status.`);
142
+ } catch (e) {
143
+ if (e instanceof UsageError) {
144
+ io.out(e.message);
145
+ return 2;
146
+ }
147
+ throw e;
148
+ }
149
+ }
package/lib/state.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // What the WaveBrain installers added, so uninstall undoes exactly that and nothing the user
2
+ // configured themselves. Stores file paths and entry names only, never keys.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ export function statePath(ctx) {
8
+ return path.join(ctx.home, ".wavebrain", "installed.json");
9
+ }
10
+
11
+ export function loadState(ctx) {
12
+ try {
13
+ const s = JSON.parse(fs.readFileSync(statePath(ctx), "utf8"));
14
+ return Array.isArray(s.records) ? s : { version: 1, records: [] };
15
+ } catch {
16
+ return { version: 1, records: [] };
17
+ }
18
+ }
19
+
20
+ export function saveState(ctx, state) {
21
+ const p = statePath(ctx);
22
+ if (!state.records.length) {
23
+ fs.rmSync(p, { force: true });
24
+ try {
25
+ fs.rmdirSync(path.dirname(p));
26
+ } catch {}
27
+ return;
28
+ }
29
+ fs.mkdirSync(path.dirname(p), { recursive: true });
30
+ fs.writeFileSync(p, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 });
31
+ }
32
+
33
+ /** Records owned by one target, keyed by ref. */
34
+ export function ownedBy(state, targetId) {
35
+ return new Map(state.records.filter((r) => r.target === targetId).map((r) => [r.ref, r]));
36
+ }
@@ -0,0 +1,445 @@
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
+ hermesUnlistPlugin,
31
+ hookCommand,
32
+ removeHooks,
33
+ skillText,
34
+ } from "./capture.mjs";
35
+
36
+ export const SERVER = "wavebrain";
37
+ export const KEY_ENV = "WAVEBRAIN_API_KEY";
38
+ export const CLAUDE_RULE = `mcp__${SERVER}__*`;
39
+
40
+ const exists = (p) => {
41
+ try {
42
+ fs.statSync(p);
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ };
48
+
49
+ export function onPath(ctx, bin) {
50
+ const dirs = (ctx.env.PATH ?? "").split(path.delimiter).filter(Boolean);
51
+ const exts = ctx.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
52
+ return dirs.some((d) => exts.some((x) => exists(path.join(d, bin + x))));
53
+ }
54
+
55
+ function appData(ctx) {
56
+ return ctx.env.APPDATA || path.join(ctx.home, "AppData", "Roaming");
57
+ }
58
+
59
+ function appSupport(ctx, ...rest) {
60
+ if (ctx.platform === "darwin") return path.join(ctx.home, "Library", "Application Support", ...rest);
61
+ if (ctx.platform === "win32") return path.join(appData(ctx), ...rest);
62
+ return path.join(ctx.env.XDG_CONFIG_HOME || path.join(ctx.home, ".config"), ...rest);
63
+ }
64
+
65
+ const bearer = (key) => `Bearer ${key}`;
66
+
67
+ /** A target whose config is one JSON file with our server under `keys`. */
68
+ function jsonTarget({ id, label, file, keys, entry, detect, next }) {
69
+ return {
70
+ id,
71
+ label,
72
+ files: (ctx) => [file(ctx)],
73
+ detect,
74
+ next,
75
+ configured: (ctx) => getIn(safeJson(file(ctx)), keys) !== undefined,
76
+ install(ctx, s, opts, owned) {
77
+ const f = file(ctx);
78
+ const current = getIn(safeJson(f), keys);
79
+ if (current !== undefined && !owned.has(`${id}:server`)) {
80
+ throw new Conflict(`a "${SERVER}" server is already in ${tilde(ctx, f)} (not added by WaveBrain); left as is`);
81
+ }
82
+ const before = readText(f);
83
+ const prev = owned.get(`${id}:server`);
84
+ const kind = s.edit(f, (t) => jsonSet(t, keys, entry(opts), f));
85
+ return [
86
+ {
87
+ ref: `${id}:server`,
88
+ file: f,
89
+ kind,
90
+ createdFile: prev?.createdFile ?? before === null,
91
+ pruneFrom: prev?.pruneFrom ?? firstMissing(before, keys, f),
92
+ },
93
+ ];
94
+ },
95
+ uninstall(ctx, s, owned) {
96
+ const rec = owned.get(`${id}:server`);
97
+ if (!rec) return [];
98
+ const kind = s.edit(rec.file, (t) =>
99
+ jsonDelete(t, keys, rec.file, { deleteIfEmpty: rec.createdFile, pruneFrom: rec.pruneFrom ?? keys.length - 1 }),
100
+ );
101
+ return [{ ref: `${id}:server`, file: rec.file, kind }];
102
+ },
103
+ };
104
+ }
105
+
106
+ function safeJson(file) {
107
+ try {
108
+ return parseJson(readText(file), file);
109
+ } catch {
110
+ return {};
111
+ }
112
+ }
113
+
114
+ export function tilde(ctx, p) {
115
+ return p.startsWith(ctx.home + path.sep) ? "~" + p.slice(ctx.home.length) : p;
116
+ }
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") && /# added by (WaveBrain|wavebrain init)/.test(text))) 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
+
168
+ // ---------- Claude Code ----------
169
+
170
+ function claudeDir(ctx) {
171
+ return ctx.env.CLAUDE_CONFIG_DIR || path.join(ctx.home, ".claude");
172
+ }
173
+ function claudeJson(ctx) {
174
+ return ctx.env.CLAUDE_CONFIG_DIR ? path.join(ctx.env.CLAUDE_CONFIG_DIR, ".claude.json") : path.join(ctx.home, ".claude.json");
175
+ }
176
+
177
+ const claudeServer = jsonTarget({
178
+ id: "claude-code",
179
+ label: "Claude Code",
180
+ file: claudeJson,
181
+ keys: ["mcpServers", SERVER],
182
+ entry: ({ url, key }) => ({ type: "http", url, headers: { Authorization: bearer(key) } }),
183
+ detect: (ctx) => onPath(ctx, "claude") || exists(claudeJson(ctx)),
184
+ next: "Start a new Claude Code session; /mcp should list wavebrain.",
185
+ });
186
+
187
+ const claudeCode = {
188
+ ...claudeServer,
189
+ files: (ctx) => [claudeJson(ctx), path.join(claudeDir(ctx), "settings.json")],
190
+ install(ctx, s, opts, owned) {
191
+ const out = claudeServer.install(ctx, s, opts, owned);
192
+ if (opts.claudePermission) {
193
+ const f = path.join(claudeDir(ctx), "settings.json");
194
+ const had = (getIn(safeJson(f), ["permissions", "allow"]) ?? []).includes(CLAUDE_RULE);
195
+ const prev = owned.get("claude-code:permission");
196
+ if (!had || prev) {
197
+ const before = readText(f);
198
+ const kind = s.edit(f, (t) => jsonArrayAdd(t, ["permissions", "allow"], CLAUDE_RULE, f));
199
+ out.push({
200
+ ref: "claude-code:permission",
201
+ file: f,
202
+ kind,
203
+ createdFile: prev?.createdFile ?? before === null,
204
+ pruneFrom: prev?.pruneFrom ?? firstMissing(before, ["permissions", "allow"], f),
205
+ });
206
+ }
207
+ }
208
+ return out;
209
+ },
210
+ uninstall(ctx, s, owned) {
211
+ const out = claudeServer.uninstall(ctx, s, owned);
212
+ const rec = owned.get("claude-code:permission");
213
+ if (rec) {
214
+ const kind = s.edit(rec.file, (t) =>
215
+ jsonArrayRemove(t, ["permissions", "allow"], CLAUDE_RULE, rec.file, {
216
+ deleteIfEmpty: rec.createdFile,
217
+ pruneFrom: rec.pruneFrom ?? 2,
218
+ }),
219
+ );
220
+ out.push({ ref: "claude-code:permission", file: rec.file, kind });
221
+ }
222
+ return out;
223
+ },
224
+ };
225
+
226
+ // ---------- Codex ----------
227
+
228
+ function codexToml(ctx) {
229
+ return path.join(ctx.env.CODEX_HOME || path.join(ctx.home, ".codex"), "config.toml");
230
+ }
231
+
232
+ const codex = {
233
+ id: "codex",
234
+ label: "Codex CLI",
235
+ files: (ctx) => [codexToml(ctx)],
236
+ detect: (ctx) => exists(codexToml(ctx)) || onPath(ctx, "codex"),
237
+ configured: (ctx) => /^\s*\[mcp_servers\.wavebrain\]/m.test(readText(codexToml(ctx)) ?? ""),
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).`,
239
+ install(ctx, s, { url, key, keyFromEnv }) {
240
+ const f = codexToml(ctx);
241
+ // With the key already in the environment, reference it; otherwise Codex needs it in the file.
242
+ const auth = keyFromEnv
243
+ ? [["bearer_token_env_var", tomlString(KEY_ENV)]]
244
+ : [["http_headers", `{ "Authorization" = ${tomlString(bearer(key))} }`]];
245
+ const kind = s.edit(f, (t) => tomlUpsertServer(t, SERVER, [["url", tomlString(url)], ...auth]));
246
+ return [{ ref: "codex:server", file: f, kind }];
247
+ },
248
+ uninstall(ctx, s, owned) {
249
+ const f = owned.get("codex:server")?.file ?? codexToml(ctx);
250
+ if (!hasBlock(readText(f))) return [];
251
+ return [{ ref: "codex:server", file: f, kind: s.edit(f, (t) => removeBlock(t, { restore: "delete-if-empty" })) }];
252
+ },
253
+ };
254
+
255
+ // ---------- Cursor, Windsurf, Claude Desktop, VS Code ----------
256
+
257
+ const cursor = jsonTarget({
258
+ id: "cursor",
259
+ label: "Cursor",
260
+ file: (ctx) => path.join(ctx.home, ".cursor", "mcp.json"),
261
+ keys: ["mcpServers", SERVER],
262
+ entry: ({ url, key }) => ({ url, headers: { Authorization: bearer(key) } }),
263
+ detect: (ctx) => exists(path.join(ctx.home, ".cursor")),
264
+ next: "Open Cursor Settings → MCP to check that wavebrain is enabled.",
265
+ });
266
+
267
+ const windsurf = jsonTarget({
268
+ id: "windsurf",
269
+ label: "Windsurf",
270
+ file: (ctx) => path.join(ctx.home, ".codeium", "windsurf", "mcp_config.json"),
271
+ keys: ["mcpServers", SERVER],
272
+ entry: ({ url, key }) => ({ serverUrl: url, headers: { Authorization: bearer(key) } }),
273
+ detect: (ctx) => exists(path.join(ctx.home, ".codeium", "windsurf")),
274
+ next: "Press Refresh in Cascade's MCP panel.",
275
+ });
276
+
277
+ const claudeDesktopFile = (ctx) => appSupport(ctx, "Claude", "claude_desktop_config.json");
278
+ const claudeDesktop = jsonTarget({
279
+ id: "claude-desktop",
280
+ label: "Claude Desktop",
281
+ file: claudeDesktopFile,
282
+ keys: ["mcpServers", SERVER],
283
+ // The config only takes local servers, so the remote one goes through mcp-remote (needs Node.js).
284
+ entry: ({ url, key }) => ({
285
+ command: "npx",
286
+ args: ["-y", "mcp-remote", url, "--header", "Authorization:${AUTH_HEADER}"],
287
+ env: { AUTH_HEADER: bearer(key) },
288
+ }),
289
+ detect: (ctx) => exists(path.dirname(claudeDesktopFile(ctx))),
290
+ next: "Quit and reopen Claude Desktop.",
291
+ });
292
+
293
+ const vscodeFile = (ctx) => appSupport(ctx, "Code", "User", "mcp.json");
294
+ const vscode = jsonTarget({
295
+ id: "vscode",
296
+ label: "VS Code (Copilot)",
297
+ file: vscodeFile,
298
+ keys: ["servers", SERVER],
299
+ entry: ({ url, key }) => ({ type: "http", url, headers: { Authorization: bearer(key) } }),
300
+ detect: (ctx) => exists(path.dirname(vscodeFile(ctx))) || onPath(ctx, "code"),
301
+ next: "In VS Code, run “MCP: List Servers” and start wavebrain if needed.",
302
+ });
303
+
304
+ // ---------- Hermes ----------
305
+
306
+ function hermesHome(ctx) {
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");
311
+ }
312
+
313
+ function hermesTarget(profile) {
314
+ const id = profile ? `hermes:${profile}` : "hermes";
315
+ const dir = (ctx) => (profile ? path.join(hermesHome(ctx), "profiles", profile) : hermesHome(ctx));
316
+ const yaml = (ctx) => path.join(dir(ctx), "config.yaml");
317
+ const env = (ctx) => path.join(dir(ctx), ".env");
318
+ return {
319
+ id,
320
+ label: profile ? `Hermes Agent (profile ${profile})` : "Hermes Agent",
321
+ files: (ctx) => [yaml(ctx), env(ctx)],
322
+ detect: (ctx) => exists(yaml(ctx)),
323
+ configured: (ctx) => hasBlock(readText(yaml(ctx))) || /^\s+wavebrain:/m.test(readText(yaml(ctx)) ?? ""),
324
+ next: profile ? `Run: hermes -p ${profile} gateway restart (or /reload-mcp in a chat).` : "Run: hermes gateway restart (or /reload-mcp in a chat).",
325
+ install(ctx, s, { url, key }) {
326
+ const y = yaml(ctx);
327
+ const e = env(ctx);
328
+ // Check both files before writing either, so a conflict leaves nothing half done.
329
+ envUpsert(readText(e), KEY_ENV, key);
330
+ yamlUpsertServer(readText(y), SERVER, (n, name) => serverLines(n, name, url));
331
+ const yk = s.edit(y, (t) => yamlUpsertServer(t, SERVER, (n, name) => serverLines(n, name, url)));
332
+ const ek = s.edit(e, (t) => envUpsert(t, KEY_ENV, key));
333
+ return [
334
+ { ref: `${id}:server`, file: y, kind: yk },
335
+ { ref: `${id}:env`, file: e, kind: ek },
336
+ ];
337
+ },
338
+ uninstall(ctx, s) {
339
+ const out = [];
340
+ for (const [ref, f] of [
341
+ [`${id}:server`, yaml(ctx)],
342
+ [`${id}:env`, env(ctx)],
343
+ ]) {
344
+ if (hasBlock(readText(f))) out.push({ ref, file: f, kind: s.edit(f, (t) => removeBlock(t, { restore: "delete-if-empty" })) });
345
+ }
346
+ return out;
347
+ },
348
+ };
349
+ }
350
+
351
+ function serverLines(n, name, url) {
352
+ const i = " ".repeat(n);
353
+ return [`${name}:`, `${i}url: "${url}"`, `${i}headers:`, `${i}${i}Authorization: "Bearer \${${KEY_ENV}}"`];
354
+ }
355
+
356
+ export function hermesProfiles(ctx) {
357
+ try {
358
+ return fs
359
+ .readdirSync(path.join(hermesHome(ctx), "profiles"), { withFileTypes: true })
360
+ .filter((d) => d.isDirectory() && /^[A-Za-z0-9._-]+$/.test(d.name))
361
+ .map((d) => d.name)
362
+ .sort();
363
+ } catch {
364
+ return [];
365
+ }
366
+ }
367
+
368
+ // ---------- registry ----------
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
+
424
+ export const BASE_TARGET_IDS = ["claude-code", "codex", "cursor", "windsurf", "claude-desktop", "vscode", "hermes"];
425
+
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
+ };
435
+
436
+ /** Every target, including one per Hermes profile found. */
437
+ export function allTargets(ctx) {
438
+ return [...BASE_TARGET_IDS.map((id) => BASE[id]), ...hermesProfiles(ctx).map((p) => hermesWithCapture(p))];
439
+ }
440
+
441
+ export function targetById(ctx, id) {
442
+ if (BASE[id]) return BASE[id];
443
+ const m = id.match(/^hermes:([A-Za-z0-9._-]+)$/);
444
+ return m ? hermesWithCapture(m[1]) : null;
445
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "wavebrain-claude-code",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "Connect Claude Code to WaveBrain: MCP server, auto-capture hooks and a when-to-log skill.",
5
+ "type": "module",
6
+ "bin": {
7
+ "wavebrain-claude-code": "bin/cli.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "lib",
12
+ "assets",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.17"
17
+ },
18
+ "keywords": [
19
+ "mcp",
20
+ "ai-agents",
21
+ "audit-log",
22
+ "wavebrain",
23
+ "claude-code"
24
+ ],
25
+ "homepage": "https://getwavebrain.com",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/GuillermoCoding/wavebrain.git",
29
+ "directory": "packages/claude-code"
30
+ },
31
+ "license": "UNLICENSED"
32
+ }