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/README.md +9 -44
- package/bin/wavebrain.mjs +32 -7
- package/package.json +3 -11
- package/assets/SKILL.md +0 -27
- package/assets/hermes/__init__.py +0 -282
- package/assets/hermes/plugin.yaml +0 -8
- package/assets/hermes/rules.json +0 -31
- package/assets/hooks/core.mjs +0 -320
- package/assets/hooks/hook.mjs +0 -28
- package/assets/hooks/rules.json +0 -31
- package/src/capture.mjs +0 -188
- package/src/cli.mjs +0 -353
- package/src/files.mjs +0 -292
- package/src/state.mjs +0 -36
- package/src/targets.mjs +0 -438
package/src/cli.mjs
DELETED
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import readline from "node:readline";
|
|
4
|
-
import { Conflict, Session, hasBlock, readText } from "./files.mjs";
|
|
5
|
-
import { loadState, ownedBy, saveState, statePath } from "./state.mjs";
|
|
6
|
-
import { stageCapture, unstageCapture } from "./capture.mjs";
|
|
7
|
-
import { KEY_ENV, allTargets, targetById, tilde } from "./targets.mjs";
|
|
8
|
-
|
|
9
|
-
export const VERSION = "0.2.0";
|
|
10
|
-
const DEFAULT_URL = "https://getwavebrain.com/api/mcp";
|
|
11
|
-
const SETUP_URL = "https://getwavebrain.com/dashboard/setup";
|
|
12
|
-
|
|
13
|
-
const HELP = `wavebrain ${VERSION}: connect your AI agents to WaveBrain (https://getwavebrain.com)
|
|
14
|
-
|
|
15
|
-
Usage:
|
|
16
|
-
npx wavebrain init Pick agents and connect them to WaveBrain
|
|
17
|
-
npx wavebrain remove Undo exactly what init added
|
|
18
|
-
npx wavebrain status Show which agents are configured
|
|
19
|
-
|
|
20
|
-
Options:
|
|
21
|
-
--agent <name> Agent to set up (repeatable). Skips the picker. Names:
|
|
22
|
-
claude-code, codex, cursor, windsurf, claude-desktop,
|
|
23
|
-
vscode, hermes, hermes:<profile>
|
|
24
|
-
--key <key> API key (or set ${KEY_ENV}). Create one at
|
|
25
|
-
${SETUP_URL}
|
|
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
|
|
29
|
-
--no-claude-permission Don't pre-approve WaveBrain's tools in Claude Code
|
|
30
|
-
-y, --yes Don't ask for confirmation
|
|
31
|
-
--url <url> MCP server URL (default ${DEFAULT_URL})
|
|
32
|
-
-h, --help, -v, --version
|
|
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
|
-
|
|
39
|
-
Every file is backed up to <file>.bak-wavebrain before it's changed.`;
|
|
40
|
-
|
|
41
|
-
export function parseArgs(argv) {
|
|
42
|
-
const o = { command: null, agents: [], key: null, dryRun: false, yes: false, claudePermission: null, url: DEFAULT_URL, hooks: true };
|
|
43
|
-
for (let i = 0; i < argv.length; i++) {
|
|
44
|
-
const a = argv[i];
|
|
45
|
-
const val = () => {
|
|
46
|
-
const v = argv[++i];
|
|
47
|
-
if (v === undefined || v.startsWith("--")) throw new UsageError(`${a} needs a value`);
|
|
48
|
-
return v;
|
|
49
|
-
};
|
|
50
|
-
if (a === "--agent" || a === "-a") o.agents.push(val());
|
|
51
|
-
else if (a.startsWith("--agent=")) o.agents.push(a.slice(8));
|
|
52
|
-
else if (a === "--key") o.key = val();
|
|
53
|
-
else if (a.startsWith("--key=")) o.key = a.slice(6);
|
|
54
|
-
else if (a === "--url") o.url = val();
|
|
55
|
-
else if (a === "--dry-run") o.dryRun = true;
|
|
56
|
-
else if (a === "--yes" || a === "-y") o.yes = true;
|
|
57
|
-
else if (a === "--no-claude-permission") o.claudePermission = false;
|
|
58
|
-
else if (a === "--no-hooks") o.hooks = false;
|
|
59
|
-
else if (a === "--claude-permission") o.claudePermission = true;
|
|
60
|
-
else if (a === "-h" || a === "--help") o.command = "help";
|
|
61
|
-
else if (a === "-v" || a === "--version") o.command = "version";
|
|
62
|
-
else if (!a.startsWith("-") && !o.command) o.command = a;
|
|
63
|
-
else throw new UsageError(`Unknown option: ${a}`);
|
|
64
|
-
}
|
|
65
|
-
o.command ??= "help";
|
|
66
|
-
return o;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
class UsageError extends Error {}
|
|
70
|
-
|
|
71
|
-
export const mask = (key) => (key.length > 12 ? `${key.slice(0, 10)}…` : "al_…");
|
|
72
|
-
const validKey = (k) => /^al_[A-Za-z0-9_-]{20,}$/.test(k);
|
|
73
|
-
|
|
74
|
-
/** io: { out(line), interactive, ask(q), askSecret(q), open(url) } — swapped out in tests. */
|
|
75
|
-
export async function run(argv, ctx = defaultCtx(), io = defaultIo()) {
|
|
76
|
-
let o;
|
|
77
|
-
try {
|
|
78
|
-
o = parseArgs(argv);
|
|
79
|
-
} catch (e) {
|
|
80
|
-
if (!(e instanceof UsageError)) throw e;
|
|
81
|
-
io.out(`${e.message}\n\n${HELP}`);
|
|
82
|
-
return 2;
|
|
83
|
-
}
|
|
84
|
-
try {
|
|
85
|
-
if (o.command === "help") return io.out(HELP), 0;
|
|
86
|
-
if (o.command === "version") return io.out(VERSION), 0;
|
|
87
|
-
if (o.command === "status") return status(ctx, io);
|
|
88
|
-
if (o.command === "init") return await init(o, ctx, io);
|
|
89
|
-
if (o.command === "remove" || o.command === "uninstall") return await remove(o, ctx, io);
|
|
90
|
-
throw new UsageError(`Unknown command: ${o.command}`);
|
|
91
|
-
} catch (e) {
|
|
92
|
-
if (e instanceof UsageError) {
|
|
93
|
-
io.out(e.message);
|
|
94
|
-
return 2;
|
|
95
|
-
}
|
|
96
|
-
throw e;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function resolveTargets(ctx, names) {
|
|
101
|
-
return names.map((n) => {
|
|
102
|
-
const t = targetById(ctx, n.trim().toLowerCase());
|
|
103
|
-
if (!t) throw new UsageError(`Unknown agent "${n}". Run with --help for the list.`);
|
|
104
|
-
return t;
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
async function init(o, ctx, io) {
|
|
109
|
-
let targets;
|
|
110
|
-
if (o.agents.length) {
|
|
111
|
-
targets = resolveTargets(ctx, o.agents);
|
|
112
|
-
} else {
|
|
113
|
-
if (!io.interactive) throw new UsageError("Not a terminal: pass --agent <name> (repeatable) to choose agents.");
|
|
114
|
-
targets = await pick(ctx, io);
|
|
115
|
-
if (!targets.length) return io.out("Nothing selected; nothing changed."), 0;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
let key = o.key ?? null;
|
|
119
|
-
let keyFromEnv = false;
|
|
120
|
-
if (!key && ctx.env[KEY_ENV]) {
|
|
121
|
-
key = ctx.env[KEY_ENV];
|
|
122
|
-
keyFromEnv = true;
|
|
123
|
-
}
|
|
124
|
-
if (!key) {
|
|
125
|
-
if (!io.interactive) throw new UsageError(`No API key: pass --key or set ${KEY_ENV}. Create one at ${SETUP_URL}`);
|
|
126
|
-
key = await askKey(io);
|
|
127
|
-
}
|
|
128
|
-
key = key.trim();
|
|
129
|
-
if (!validKey(key)) throw new UsageError(`That doesn't look like a WaveBrain API key (they start with "al_"). Create one at ${SETUP_URL}`);
|
|
130
|
-
|
|
131
|
-
let claudePermission = o.claudePermission;
|
|
132
|
-
if (claudePermission === null && targets.some((t) => t.id === "claude-code")) {
|
|
133
|
-
if (io.interactive && !o.yes) {
|
|
134
|
-
io.out(
|
|
135
|
-
"\nClaude Code: runs started with a narrow --allowedTools (claude -p, browser automations, Hermes delegations)\n" +
|
|
136
|
-
"can't call log_action unless WaveBrain's tools are pre-approved (mcp__wavebrain__* in ~/.claude/settings.json).",
|
|
137
|
-
);
|
|
138
|
-
claudePermission = !/^n/i.test((await io.ask("Allow logging in non-interactive runs? (Y/n) ")).trim());
|
|
139
|
-
} else claudePermission = true;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
io.out(`\n${o.dryRun ? "Dry run: nothing will be written.\n" : ""}Using key ${mask(key)} and ${o.url}\n`);
|
|
143
|
-
const state = loadState(ctx);
|
|
144
|
-
const session = new Session({ dryRun: o.dryRun });
|
|
145
|
-
const opts = { url: o.url, key, keyFromEnv, claudePermission, hooks: o.hooks };
|
|
146
|
-
let failed = 0;
|
|
147
|
-
const nextSteps = [];
|
|
148
|
-
|
|
149
|
-
for (const t of targets) {
|
|
150
|
-
const owned = ownedBy(state, t.id);
|
|
151
|
-
try {
|
|
152
|
-
const results = t.install(ctx, session, opts, owned);
|
|
153
|
-
for (const r of results) {
|
|
154
|
-
if (r.kind !== "unchanged" || owned.has(r.ref)) {
|
|
155
|
-
state.records = state.records.filter((x) => x.ref !== r.ref);
|
|
156
|
-
state.records.push({
|
|
157
|
-
ref: r.ref,
|
|
158
|
-
target: t.id,
|
|
159
|
-
file: r.file,
|
|
160
|
-
createdFile: Boolean(r.createdFile),
|
|
161
|
-
...(r.pruneFrom !== undefined && { pruneFrom: r.pruneFrom }),
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
const changed = results.filter((r) => r.kind !== "unchanged");
|
|
166
|
-
if (!changed.length) io.out(`• ${t.label}: already set up`);
|
|
167
|
-
else {
|
|
168
|
-
io.out(`✓ ${t.label}`);
|
|
169
|
-
for (const r of changed) io.out(` ${o.dryRun ? "would " + verb(r.kind) : verb(r.kind, true)} ${describe(ctx, r)}`);
|
|
170
|
-
nextSteps.push(`${t.label}: ${t.next}`);
|
|
171
|
-
}
|
|
172
|
-
if (t.id === "codex" && keyFromEnv) io.out(` Codex reads the key from ${KEY_ENV}; keep it exported in your shell.`);
|
|
173
|
-
} catch (e) {
|
|
174
|
-
if (!(e instanceof Conflict)) throw e;
|
|
175
|
-
failed++;
|
|
176
|
-
io.out(`! ${t.label}: ${e.message}`);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
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
|
-
|
|
195
|
-
if (!o.dryRun) saveState(ctx, state);
|
|
196
|
-
if (nextSteps.length) io.out(`\nNext:\n${nextSteps.map((s) => ` ${s}`).join("\n")}`);
|
|
197
|
-
if (!o.dryRun && session.changes.some((c) => c.backup)) io.out(`\nBackups end in .bak-wavebrain. Undo with: npx wavebrain remove`);
|
|
198
|
-
return failed ? 1 : 0;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function verb(kind, past) {
|
|
202
|
-
return { created: past ? "created" : "create", updated: past ? "updated" : "update", deleted: past ? "deleted" : "delete" }[kind] ?? kind;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function describe(ctx, r) {
|
|
206
|
-
const ref = r.ref;
|
|
207
|
-
const what = ref.endsWith(":permission")
|
|
208
|
-
? " (allow mcp__wavebrain__*)"
|
|
209
|
-
: ref.endsWith(":env")
|
|
210
|
-
? ` (${KEY_ENV})`
|
|
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)";
|
|
226
|
-
return tilde(ctx, r.file) + what;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function pick(ctx, io) {
|
|
230
|
-
const targets = allTargets(ctx);
|
|
231
|
-
const found = targets.filter((t) => t.detect(ctx));
|
|
232
|
-
const list = found.length ? found : targets;
|
|
233
|
-
io.out(found.length ? "Agents found on this machine:" : "No agents detected. Available:");
|
|
234
|
-
list.forEach((t, i) => io.out(` ${i + 1}) ${t.label}${t.configured(ctx) ? " (already has wavebrain)" : ""}`));
|
|
235
|
-
const answer = (await io.ask("\nWhich should log to WaveBrain? Numbers separated by spaces, 'all', or Enter for none: ")).trim();
|
|
236
|
-
if (!answer) return [];
|
|
237
|
-
if (answer.toLowerCase() === "all") return list;
|
|
238
|
-
const picked = [];
|
|
239
|
-
for (const part of answer.split(/[\s,]+/)) {
|
|
240
|
-
const t = list[Number(part) - 1];
|
|
241
|
-
if (!t) throw new UsageError(`"${part}" isn't one of the numbers above.`);
|
|
242
|
-
if (!picked.includes(t)) picked.push(t);
|
|
243
|
-
}
|
|
244
|
-
io.out(`Selected: ${picked.map((t) => t.label).join(", ")}`);
|
|
245
|
-
return picked;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
async function askKey(io) {
|
|
249
|
-
io.out(`\nPaste a WaveBrain API key, or press Enter to open ${SETUP_URL} and create one.`);
|
|
250
|
-
let key = (await io.askSecret("API key: ")).trim();
|
|
251
|
-
if (!key) {
|
|
252
|
-
io.open(SETUP_URL);
|
|
253
|
-
io.out(`Opened ${SETUP_URL}. Click "Create a key", copy it, and paste it here.`);
|
|
254
|
-
key = (await io.askSecret("API key: ")).trim();
|
|
255
|
-
}
|
|
256
|
-
return key;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
async function remove(o, ctx, io) {
|
|
260
|
-
const state = loadState(ctx);
|
|
261
|
-
const ids = o.agents.length
|
|
262
|
-
? resolveTargets(ctx, o.agents).map((t) => t.id)
|
|
263
|
-
: [...new Set([...state.records.map((r) => r.target), ...allTargets(ctx).filter((t) => markerOnly(ctx, t)).map((t) => t.id)])];
|
|
264
|
-
if (!ids.length) return io.out("Nothing to remove: wavebrain init hasn't added anything here."), 0;
|
|
265
|
-
const targets = ids.map((id) => targetById(ctx, id)).filter(Boolean);
|
|
266
|
-
|
|
267
|
-
if (io.interactive && !o.yes && !o.dryRun) {
|
|
268
|
-
const ok = (await io.ask(`Remove WaveBrain from ${targets.map((t) => t.label).join(", ")}? (y/N) `)).trim();
|
|
269
|
-
if (!/^y/i.test(ok)) return io.out("Nothing changed."), 0;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
const session = new Session({ dryRun: o.dryRun });
|
|
273
|
-
for (const t of targets) {
|
|
274
|
-
try {
|
|
275
|
-
const results = t.uninstall(ctx, session, ownedBy(state, t.id)).filter((r) => r.kind !== "unchanged");
|
|
276
|
-
if (!results.length) io.out(`• ${t.label}: nothing added by wavebrain init`);
|
|
277
|
-
else {
|
|
278
|
-
io.out(`✓ ${t.label}`);
|
|
279
|
-
for (const r of results) io.out(` ${o.dryRun ? "would " + verb(r.kind) : verb(r.kind, true)} ${describe(ctx, r)}`);
|
|
280
|
-
}
|
|
281
|
-
state.records = state.records.filter((r) => r.target !== t.id);
|
|
282
|
-
} catch (e) {
|
|
283
|
-
if (!(e instanceof Conflict)) throw e;
|
|
284
|
-
io.out(`! ${t.label}: ${e.message}`);
|
|
285
|
-
}
|
|
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
|
-
}
|
|
297
|
-
if (!o.dryRun) saveState(ctx, state);
|
|
298
|
-
return 0;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Codex and Hermes edits are marked in the file itself, so they're found even without the state file.
|
|
302
|
-
function markerOnly(ctx, t) {
|
|
303
|
-
return (t.id === "codex" || t.id.startsWith("hermes")) && t.configured(ctx) && t.files(ctx).some((f) => hasMarker(f));
|
|
304
|
-
}
|
|
305
|
-
const hasMarker = (f) => hasBlock(readText(f));
|
|
306
|
-
|
|
307
|
-
function status(ctx, io) {
|
|
308
|
-
const state = loadState(ctx);
|
|
309
|
-
io.out("Agent Detected WaveBrain");
|
|
310
|
-
for (const t of allTargets(ctx)) {
|
|
311
|
-
const ours = state.records.some((r) => r.target === t.id) || markerOnly(ctx, t);
|
|
312
|
-
const conf = t.configured(ctx) ? (ours ? "configured (by wavebrain init)" : "configured") : "not configured";
|
|
313
|
-
io.out(`${t.label.padEnd(30)}${(t.detect(ctx) ? "yes" : "no").padEnd(10)}${conf}`);
|
|
314
|
-
}
|
|
315
|
-
io.out(`\nState: ${tilde(ctx, statePath(ctx))}`);
|
|
316
|
-
return 0;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
export function defaultCtx() {
|
|
320
|
-
return { home: os.homedir(), env: process.env, platform: process.platform };
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
export function defaultIo() {
|
|
324
|
-
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
325
|
-
const ask = (q, secret = false) =>
|
|
326
|
-
new Promise((resolve) => {
|
|
327
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
328
|
-
if (secret) {
|
|
329
|
-
process.stdout.write(q);
|
|
330
|
-
rl._writeToOutput = (s) => {
|
|
331
|
-
if (s.includes("\n") || s.includes("\r")) process.stdout.write("\n");
|
|
332
|
-
else process.stdout.write("*".repeat(s.length));
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
rl.question(secret ? "" : q, (a) => {
|
|
336
|
-
rl.close();
|
|
337
|
-
resolve(a);
|
|
338
|
-
});
|
|
339
|
-
});
|
|
340
|
-
return {
|
|
341
|
-
interactive,
|
|
342
|
-
out: (line) => console.log(line),
|
|
343
|
-
ask: (q) => ask(q),
|
|
344
|
-
askSecret: (q) => ask(q, true),
|
|
345
|
-
open(url) {
|
|
346
|
-
const [cmd, args] =
|
|
347
|
-
process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
348
|
-
try {
|
|
349
|
-
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
|
|
350
|
-
} catch {}
|
|
351
|
-
},
|
|
352
|
-
};
|
|
353
|
-
}
|
package/src/files.mjs
DELETED
|
@@ -1,292 +0,0 @@
|
|
|
1
|
-
// File editing with backups, atomic writes and dry runs, plus small pure text transforms for the
|
|
2
|
-
// formats agents use (JSON, TOML, YAML, .env). Transforms never see or return anything but text.
|
|
3
|
-
|
|
4
|
-
import fs from "node:fs";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
|
|
7
|
-
export const BACKUP_SUFFIX = ".bak-wavebrain";
|
|
8
|
-
export const MARK_START = ">>> wavebrain (added by npx wavebrain init)";
|
|
9
|
-
export const MARK_END = "<<< wavebrain";
|
|
10
|
-
|
|
11
|
-
export class Conflict extends Error {}
|
|
12
|
-
|
|
13
|
-
export function readText(file) {
|
|
14
|
-
try {
|
|
15
|
-
return fs.readFileSync(file, "utf8");
|
|
16
|
-
} catch (e) {
|
|
17
|
-
if (e.code === "ENOENT") return null;
|
|
18
|
-
throw e;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Applies edits to files; records what changed. In a dry run nothing is written. */
|
|
23
|
-
export class Session {
|
|
24
|
-
constructor({ dryRun = false } = {}) {
|
|
25
|
-
this.dryRun = dryRun;
|
|
26
|
-
this.changes = [];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* fn(text|null) returns the new text, null to delete the file, or the same text for no change.
|
|
31
|
-
* Throws Conflict to refuse. Returns "created" | "updated" | "deleted" | "unchanged".
|
|
32
|
-
*/
|
|
33
|
-
edit(file, fn) {
|
|
34
|
-
const before = readText(file);
|
|
35
|
-
const after = fn(before);
|
|
36
|
-
if (after === before) return "unchanged";
|
|
37
|
-
const kind = before === null ? "created" : after === null ? "deleted" : "updated";
|
|
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;
|
|
42
|
-
if (!this.dryRun) {
|
|
43
|
-
let mode;
|
|
44
|
-
if (before !== null) {
|
|
45
|
-
mode = fs.statSync(file).mode & 0o777;
|
|
46
|
-
if (backup) {
|
|
47
|
-
fs.copyFileSync(file, backup);
|
|
48
|
-
fs.chmodSync(backup, mode);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
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);
|
|
61
|
-
}
|
|
62
|
-
this.changes.push({ file, kind, backup });
|
|
63
|
-
return kind;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function writeAtomic(file, text, mode) {
|
|
68
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
69
|
-
const tmp = `${file}.wavebrain-tmp-${process.pid}`;
|
|
70
|
-
fs.writeFileSync(tmp, text, { mode: mode ?? 0o600 });
|
|
71
|
-
if (mode !== undefined) fs.chmodSync(tmp, mode);
|
|
72
|
-
fs.renameSync(tmp, file);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ---------- JSON ----------
|
|
76
|
-
|
|
77
|
-
export function parseJson(text, file) {
|
|
78
|
-
if (text === null || text.trim() === "") return {};
|
|
79
|
-
try {
|
|
80
|
-
const v = JSON.parse(text);
|
|
81
|
-
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not an object");
|
|
82
|
-
return v;
|
|
83
|
-
} catch {
|
|
84
|
-
throw new Conflict(`couldn't parse ${file} as plain JSON (comments or trailing commas?); add it by hand instead`);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const dump = (obj) => JSON.stringify(obj, null, 2) + "\n";
|
|
89
|
-
|
|
90
|
-
export function getIn(obj, keys) {
|
|
91
|
-
let cur = obj;
|
|
92
|
-
for (const k of keys) {
|
|
93
|
-
if (!cur || typeof cur !== "object") return undefined;
|
|
94
|
-
cur = cur[k];
|
|
95
|
-
}
|
|
96
|
-
return cur;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Sets obj[...keys] = value. */
|
|
100
|
-
export function jsonSet(text, keys, value, file) {
|
|
101
|
-
const obj = parseJson(text, file);
|
|
102
|
-
let cur = obj;
|
|
103
|
-
for (const k of keys.slice(0, -1)) {
|
|
104
|
-
if (cur[k] === undefined) cur[k] = {};
|
|
105
|
-
if (!cur[k] || typeof cur[k] !== "object" || Array.isArray(cur[k])) {
|
|
106
|
-
throw new Conflict(`${file}: "${k}" isn't an object; add it by hand instead`);
|
|
107
|
-
}
|
|
108
|
-
cur = cur[k];
|
|
109
|
-
}
|
|
110
|
-
const last = keys[keys.length - 1];
|
|
111
|
-
if (JSON.stringify(cur[last]) === JSON.stringify(value)) return text;
|
|
112
|
-
cur[last] = value;
|
|
113
|
-
return dump(obj);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Index of the first key in the path that doesn't exist yet (keys.length if all do). */
|
|
117
|
-
export function firstMissing(text, keys, file) {
|
|
118
|
-
let cur = parseJson(text, file);
|
|
119
|
-
for (let i = 0; i < keys.length; i++) {
|
|
120
|
-
if (!cur || typeof cur !== "object" || !(keys[i] in cur)) return i;
|
|
121
|
-
cur = cur[keys[i]];
|
|
122
|
-
}
|
|
123
|
-
return keys.length;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Deletes obj[...keys], then any now-empty parents from index `pruneFrom` down (parents we created).
|
|
128
|
-
* Returns null (delete the file) if that leaves only empty objects and deleteIfEmpty.
|
|
129
|
-
*/
|
|
130
|
-
export function jsonDelete(text, keys, file, { deleteIfEmpty = false, pruneFrom = keys.length } = {}) {
|
|
131
|
-
if (text === null) return null;
|
|
132
|
-
const obj = parseJson(text, file);
|
|
133
|
-
const parent = getIn(obj, keys.slice(0, -1));
|
|
134
|
-
const last = keys[keys.length - 1];
|
|
135
|
-
if (!parent || typeof parent !== "object" || !(last in parent)) return text;
|
|
136
|
-
delete parent[last];
|
|
137
|
-
for (let i = keys.length - 2; i >= pruneFrom; i--) {
|
|
138
|
-
const v = getIn(obj, keys.slice(0, i + 1));
|
|
139
|
-
const empty = Array.isArray(v) ? v.length === 0 : v && typeof v === "object" && Object.keys(v).length === 0;
|
|
140
|
-
if (!empty) break;
|
|
141
|
-
delete getIn(obj, keys.slice(0, i))[keys[i]];
|
|
142
|
-
}
|
|
143
|
-
if (deleteIfEmpty && isEmptyTree(obj)) return null;
|
|
144
|
-
return dump(obj);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const isEmptyTree = (v) => v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every(isEmptyTree);
|
|
148
|
-
|
|
149
|
-
/** Adds a string to the array at keys if missing. */
|
|
150
|
-
export function jsonArrayAdd(text, keys, item, file) {
|
|
151
|
-
const obj = parseJson(text, file);
|
|
152
|
-
const arr = getIn(obj, keys);
|
|
153
|
-
if (arr !== undefined && !Array.isArray(arr)) throw new Conflict(`${file}: ${keys.join(".")} isn't a list`);
|
|
154
|
-
if (arr?.includes(item)) return text;
|
|
155
|
-
return jsonSet(text, keys, [...(arr ?? []), item], file);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
export function jsonArrayRemove(text, keys, item, file, opts) {
|
|
159
|
-
if (text === null) return null;
|
|
160
|
-
const obj = parseJson(text, file);
|
|
161
|
-
const arr = getIn(obj, keys);
|
|
162
|
-
if (!Array.isArray(arr) || !arr.includes(item)) return text;
|
|
163
|
-
const rest = arr.filter((x) => x !== item);
|
|
164
|
-
if (rest.length || (opts?.pruneFrom ?? keys.length) >= keys.length) return jsonSet(text, keys, rest, file);
|
|
165
|
-
return jsonDelete(text, keys, file, opts);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ---------- marked text blocks (TOML, YAML, .env) ----------
|
|
169
|
-
|
|
170
|
-
const markerLine = (indent, mark) => `${indent}# ${mark}`;
|
|
171
|
-
|
|
172
|
-
/** Index range [start, end] of our marked block, or null. */
|
|
173
|
-
export function findBlock(lines) {
|
|
174
|
-
const start = lines.findIndex((l) => l.trim().startsWith(`# ${MARK_START}`));
|
|
175
|
-
if (start < 0) return null;
|
|
176
|
-
const end = lines.findIndex((l, i) => i > start && l.trim() === `# ${MARK_END}`);
|
|
177
|
-
return end < 0 ? null : [start, end];
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export function hasBlock(text) {
|
|
181
|
-
return text !== null && findBlock(text.split("\n")) !== null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** The text with our block removed (and a trailing blank line we added). */
|
|
185
|
-
export function removeBlock(text, { restore } = {}) {
|
|
186
|
-
if (text === null) return null;
|
|
187
|
-
const lines = text.split("\n");
|
|
188
|
-
const range = findBlock(lines);
|
|
189
|
-
if (!range) return text;
|
|
190
|
-
const [s, e] = range;
|
|
191
|
-
const was = lines[s].match(/; was "(.*)"\)?\s*$/);
|
|
192
|
-
const replacement = was ? [lines[s].match(/^\s*/)[0] + was[1].replace(/\\"/g, '"')] : [];
|
|
193
|
-
lines.splice(s, e - s + 1, ...replacement);
|
|
194
|
-
// Drop one blank line left behind where we appended with a separator.
|
|
195
|
-
if (!was && s > 0 && lines[s - 1] === "" && (s === lines.length || lines[s] === "")) lines.splice(s - 1, 1);
|
|
196
|
-
const out = lines.join("\n");
|
|
197
|
-
return restore === "delete-if-empty" && out.trim() === "" ? null : out;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function block(indent, body) {
|
|
201
|
-
return [markerLine(indent, MARK_START), ...body, markerLine(indent, MARK_END)];
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Appends (or replaces) our block at the end of the file. */
|
|
205
|
-
export function upsertAppendedBlock(text, body) {
|
|
206
|
-
const base = removeBlock(text) ?? "";
|
|
207
|
-
const trimmed = base.replace(/\n+$/, "");
|
|
208
|
-
const lines = block("", body);
|
|
209
|
-
return (trimmed ? trimmed + "\n\n" : "") + lines.join("\n") + "\n";
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ---------- TOML (Codex) ----------
|
|
213
|
-
|
|
214
|
-
export function tomlString(s) {
|
|
215
|
-
return JSON.stringify(s);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export function tomlUpsertServer(text, name, entries) {
|
|
219
|
-
const without = removeBlock(text) ?? "";
|
|
220
|
-
if (new RegExp(`^\\s*\\[mcp_servers\\.${name}(\\.|\\])`, "m").test(without)) {
|
|
221
|
-
throw new Conflict(`a [mcp_servers.${name}] server is already configured (not by wavebrain init); left as is`);
|
|
222
|
-
}
|
|
223
|
-
const body = [`[mcp_servers.${name}]`, ...entries.map(([k, v]) => `${k} = ${v}`)];
|
|
224
|
-
const next = upsertAppendedBlock(text, body);
|
|
225
|
-
return next === text ? text : next;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ---------- YAML (Hermes) ----------
|
|
229
|
-
|
|
230
|
-
/** Adds `name` under top-level mcp_servers, keeping the file's own indentation. */
|
|
231
|
-
export function yamlUpsertServer(text, name, bodyLines) {
|
|
232
|
-
const base = removeBlock(text) ?? "";
|
|
233
|
-
const lines = base.split("\n");
|
|
234
|
-
const keyIdx = lines.findIndex((l) => /^mcp_servers:/.test(l));
|
|
235
|
-
const childRe = new RegExp(`^\\s+${name}:`);
|
|
236
|
-
|
|
237
|
-
if (keyIdx < 0) {
|
|
238
|
-
const trimmed = base.replace(/\n+$/, "");
|
|
239
|
-
const out = block("", ["mcp_servers:", ...indentAll(bodyLines(2, name), 2)]);
|
|
240
|
-
const next = (trimmed ? trimmed + "\n\n" : "") + out.join("\n") + "\n";
|
|
241
|
-
return next === text ? text : next;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const keyLine = lines[keyIdx];
|
|
245
|
-
if (/^mcp_servers:\s*(#.*)?$/.test(keyLine)) {
|
|
246
|
-
// Block mapping: find its children and their indent.
|
|
247
|
-
let end = keyIdx + 1;
|
|
248
|
-
let indent = 0;
|
|
249
|
-
while (end < lines.length && (lines[end].trim() === "" || /^\s/.test(lines[end]))) {
|
|
250
|
-
if (!indent && lines[end].trim() && !lines[end].trim().startsWith("#")) indent = lines[end].match(/^\s*/)[0].length;
|
|
251
|
-
if (childRe.test(lines[end]) && lines[end].match(/^\s*/)[0].length === (indent || 2)) {
|
|
252
|
-
throw new Conflict(`mcp_servers already has a "${name}" server (not added by wavebrain init); left as is`);
|
|
253
|
-
}
|
|
254
|
-
end++;
|
|
255
|
-
}
|
|
256
|
-
indent ||= 2;
|
|
257
|
-
lines.splice(keyIdx + 1, 0, ...block(" ".repeat(indent), indentAll(bodyLines(indent, name), indent)));
|
|
258
|
-
const next = lines.join("\n");
|
|
259
|
-
return next === text ? text : next;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
if (/^mcp_servers:\s*(\{\s*\}|null|~)?\s*$/.test(keyLine)) {
|
|
263
|
-
const was = keyLine.trim().replace(/"/g, '\\"');
|
|
264
|
-
const out = [
|
|
265
|
-
`# ${MARK_START}; was "${was}"`,
|
|
266
|
-
"mcp_servers:",
|
|
267
|
-
...indentAll(bodyLines(2, name), 2),
|
|
268
|
-
`# ${MARK_END}`,
|
|
269
|
-
];
|
|
270
|
-
lines.splice(keyIdx, 1, ...out);
|
|
271
|
-
return lines.join("\n");
|
|
272
|
-
}
|
|
273
|
-
throw new Conflict("mcp_servers is written in a form this tool can't edit safely; add it by hand instead");
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function indentAll(lines, n) {
|
|
277
|
-
return lines.map((l) => " ".repeat(n) + l);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ---------- .env ----------
|
|
281
|
-
|
|
282
|
-
export function envUpsert(text, key, value) {
|
|
283
|
-
const without = removeBlock(text) ?? "";
|
|
284
|
-
const existing = without.match(new RegExp(`^\\s*(?:export\\s+)?${key}=(.*)$`, "m"));
|
|
285
|
-
if (existing) {
|
|
286
|
-
const v = existing[1].trim().replace(/^["']|["']$/g, "");
|
|
287
|
-
if (v === value) return text; // Already set to this key by the user: leave it.
|
|
288
|
-
throw new Conflict(`${key} is already set to a different key (not by wavebrain init); left as is`);
|
|
289
|
-
}
|
|
290
|
-
const next = upsertAppendedBlock(text, [`${key}=${value}`]);
|
|
291
|
-
return next === text ? text : next;
|
|
292
|
-
}
|
package/src/state.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// What `wavebrain init` added, so `wavebrain remove` 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
|
-
}
|