wavebrain-hermes 0.0.0-stage → 0.4.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 +27 -2
- package/assets/SKILL.md +27 -0
- package/assets/hermes/__init__.py +282 -0
- package/assets/hermes/plugin.yaml +8 -0
- package/assets/hermes/rules.json +31 -0
- package/assets/hooks/core.mjs +321 -0
- package/assets/hooks/hook.mjs +28 -0
- package/assets/hooks/rules.json +31 -0
- package/bin/cli.mjs +10 -0
- package/lib/capture.mjs +252 -0
- package/lib/cli.mjs +360 -0
- package/lib/files.mjs +292 -0
- package/lib/harness.mjs +137 -0
- package/lib/state.mjs +36 -0
- package/lib/targets.mjs +445 -0
- package/package.json +31 -4
package/lib/harness.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Installers for the agents with no plugin marketplace that covers WaveBrain: wavebrain-cursor and
|
|
2
|
+
// wavebrain-hermes (Claude Code and Codex install the plugin from their own marketplaces). 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
|
+
cursor: {
|
|
14
|
+
label: "Cursor",
|
|
15
|
+
gets: "the WaveBrain MCP server and auto-capture hooks",
|
|
16
|
+
targets: (ctx) => [targetById(ctx, "cursor")],
|
|
17
|
+
},
|
|
18
|
+
hermes: {
|
|
19
|
+
label: "Hermes Agent",
|
|
20
|
+
gets: "the WaveBrain MCP server and the WaveBrain plugin, for each profile you pick",
|
|
21
|
+
options: ` --profile <name> Profile to set up (repeatable). "default" is ~/.hermes itself.
|
|
22
|
+
Without it you pick from a list (or get "default" when not
|
|
23
|
+
in a terminal).
|
|
24
|
+
--all-profiles Every profile, including default`,
|
|
25
|
+
targets: hermesTargets,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
async function hermesTargets(ctx, o, io, verb) {
|
|
30
|
+
const all = [targetById(ctx, "hermes"), ...hermesProfiles(ctx).map((p) => targetById(ctx, `hermes:${p}`))];
|
|
31
|
+
if (o.allProfiles) return all;
|
|
32
|
+
if (o.profiles.length) {
|
|
33
|
+
const known = hermesProfiles(ctx);
|
|
34
|
+
return o.profiles.map((p) => {
|
|
35
|
+
const t = p === "default" ? all[0] : known.includes(p) ? targetById(ctx, `hermes:${p}`) : null;
|
|
36
|
+
if (!t) throw new UsageError(`No Hermes profile "${p}". Profiles here: default${hermesProfiles(ctx).map((x) => `, ${x}`).join("")}`);
|
|
37
|
+
return t;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (verb !== "install") return all; // uninstall / status: every profile WaveBrain touched
|
|
41
|
+
if (all.length === 1 || !io.interactive) return [all[0]];
|
|
42
|
+
return pick(ctx, io, all);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseHarnessArgs(argv) {
|
|
46
|
+
const o = { command: null, key: null, url: DEFAULT_URL, dryRun: false, yes: false, hooks: true, claudePermission: null, profiles: [], allProfiles: false };
|
|
47
|
+
for (let i = 0; i < argv.length; i++) {
|
|
48
|
+
const a = argv[i];
|
|
49
|
+
const val = () => {
|
|
50
|
+
const v = argv[++i];
|
|
51
|
+
if (v === undefined || v.startsWith("--")) throw new UsageError(`${a} needs a value`);
|
|
52
|
+
return v;
|
|
53
|
+
};
|
|
54
|
+
if (a === "--key") o.key = val();
|
|
55
|
+
else if (a.startsWith("--key=")) o.key = a.slice(6);
|
|
56
|
+
else if (a === "--url") o.url = val();
|
|
57
|
+
else if (a === "--profile" || a === "-p") o.profiles.push(val());
|
|
58
|
+
else if (a.startsWith("--profile=")) o.profiles.push(a.slice(10));
|
|
59
|
+
else if (a === "--all-profiles") o.allProfiles = true;
|
|
60
|
+
else if (a === "--dry-run") o.dryRun = true;
|
|
61
|
+
else if (a === "--yes" || a === "-y") o.yes = true;
|
|
62
|
+
else if (a === "--no-hooks") o.hooks = false;
|
|
63
|
+
else if (a === "--no-claude-permission") o.claudePermission = false;
|
|
64
|
+
else if (a === "--claude-permission") o.claudePermission = true;
|
|
65
|
+
else if (a === "-h" || a === "--help") o.command = "help";
|
|
66
|
+
else if (a === "-v" || a === "--version") o.command = "version";
|
|
67
|
+
else if (!a.startsWith("-") && !o.command) o.command = a;
|
|
68
|
+
else throw new UsageError(`Unknown option: ${a}`);
|
|
69
|
+
}
|
|
70
|
+
o.command ??= "install";
|
|
71
|
+
if (o.command === "remove") o.command = "uninstall";
|
|
72
|
+
if (o.command === "init") o.command = "install";
|
|
73
|
+
return o;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function help(id, h) {
|
|
77
|
+
const bin = `wavebrain-${id}`;
|
|
78
|
+
return `${bin} ${VERSION}: connect ${h.label} to WaveBrain (https://getwavebrain.com)
|
|
79
|
+
|
|
80
|
+
Usage:
|
|
81
|
+
npx ${bin} install Add ${h.gets}
|
|
82
|
+
npx ${bin} uninstall Undo exactly what install added
|
|
83
|
+
npx ${bin} status Show whether ${h.label} is set up
|
|
84
|
+
|
|
85
|
+
Options:
|
|
86
|
+
--key <key> API key (or set ${KEY_ENV}). Create one at
|
|
87
|
+
${SETUP_URL}
|
|
88
|
+
--no-hooks MCP server only, without auto-capture
|
|
89
|
+
${h.options ? h.options + "\n" : ""} --dry-run Show what would change without writing anything
|
|
90
|
+
-y, --yes Don't ask for confirmation
|
|
91
|
+
--url <url> MCP server URL (default ${DEFAULT_URL})
|
|
92
|
+
-h, --help, -v, --version
|
|
93
|
+
|
|
94
|
+
Auto-capture records deploys, pushes, releases, payments, emails and database writes even
|
|
95
|
+
when the model doesn't call log_action. Secrets are redacted before anything is sent.
|
|
96
|
+
Pause it any time with WAVEBRAIN_HOOKS=off. Every file is backed up to
|
|
97
|
+
<file>.bak-wavebrain before it's changed.`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Entry point for one harness installer. */
|
|
101
|
+
export async function runHarness(id, argv, ctx = defaultCtx(), io = defaultIo()) {
|
|
102
|
+
const h = HARNESSES[id];
|
|
103
|
+
if (!h) throw new Error(`unknown harness ${id}`);
|
|
104
|
+
let o;
|
|
105
|
+
try {
|
|
106
|
+
o = parseHarnessArgs(argv);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (!(e instanceof UsageError)) throw e;
|
|
109
|
+
io.out(`${e.message}\n\n${help(id, h)}`);
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
if (id !== "hermes" && (o.profiles.length || o.allProfiles)) {
|
|
113
|
+
io.out("--profile and --all-profiles are for wavebrain-hermes.");
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
if (o.command === "help") return io.out(help(id, h)), 0;
|
|
118
|
+
if (o.command === "version") return io.out(VERSION), 0;
|
|
119
|
+
const targets = await h.targets(ctx, o, io, o.command);
|
|
120
|
+
if (o.command === "status") return status(ctx, io, targets);
|
|
121
|
+
const undo = `npx wavebrain-${id} uninstall`;
|
|
122
|
+
if (o.command === "install") {
|
|
123
|
+
if (!targets.length) return io.out("Nothing selected; nothing changed."), 0;
|
|
124
|
+
const code = await init({ ...o, agents: [], targets, undo }, ctx, io);
|
|
125
|
+
if (h.after && code === 0 && o.hooks && !o.dryRun) io.out(`\n${h.after}`);
|
|
126
|
+
return code;
|
|
127
|
+
}
|
|
128
|
+
if (o.command === "uninstall") return await remove({ ...o, agents: [], targets }, ctx, io);
|
|
129
|
+
throw new UsageError(`Unknown command: ${o.command}. Use install, uninstall or status.`);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (e instanceof UsageError) {
|
|
132
|
+
io.out(e.message);
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
}
|
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
|
+
}
|
package/lib/targets.mjs
ADDED
|
@@ -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,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wavebrain-hermes",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Connect Hermes Agent profiles to WaveBrain: MCP server and the WaveBrain plugin.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"wavebrain-hermes": "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
|
+
"hermes",
|
|
24
|
+
"hermes-agent"
|
|
25
|
+
],
|
|
26
|
+
"homepage": "https://getwavebrain.com",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/tambo-ai-labs/wavebrain.git",
|
|
30
|
+
"directory": "packages/hermes"
|
|
31
|
+
},
|
|
32
|
+
"license": "UNLICENSED"
|
|
33
|
+
}
|