@misaka-net/misakanet-setup 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 CHANGED
@@ -16,7 +16,7 @@ knowledge base on its own instead of guessing.
16
16
 
17
17
  | # | Goal | Mechanism |
18
18
  |---|---|---|
19
- | 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json` / `~/.codex/config.toml`, with the Bearer token so reads are not metered by the anonymous 5/day/IP limit. Hermes and OpenClaw own their MCP registries, so theirs is registered through their own CLIs (`hermes mcp add …`, `openclaw mcp add …`) instead of by editing a file we do not own |
19
+ | 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json`, `~/.codex/config.toml` and `~/.openclaw/openclaw.json` (`mcp.servers.misakanet`), with the Bearer token so reads are not metered by the anonymous 5/day/IP limit. Hermes is the same thing in its own files: `mcp_servers.misakanet` in `~/.hermes/config.yaml` plus the token in `~/.hermes/.env` under `MCP_MISAKANET_API_KEY`. All four are config files written file-to-file — no subprocess is spawned, so **no token ever appears in a command line** |
20
20
  | 2 | the agent **knows when** | rules block appended to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.hermes/SOUL.md` / `~/.openclaw/workspace/AGENTS.md` (issue, retry, risky-operation triggers; desensitisation rules) |
21
21
  | 3 | the checkpoint **fires** | a hook that counts user turns: turn 1 announces the install to the user, turn 20 (and every 10 after) injects the "distil and submit" reminder; a failed tool call injects a "search before you retry" reminder built from the error text. Claude Code only today — Codex's user-level hook shape is unconfirmed and OpenClaw's events are unverified, so those two work from the rules block, and `--verify` says so per target instead of implying otherwise |
22
22
 
@@ -41,8 +41,13 @@ npx @misaka-net/misakanet-setup --uninstall # remove exactly what it added
41
41
  alone (covered by tests).
42
42
  - Idempotent: a second run changes nothing.
43
43
  - The token is stored at `~/.misakanet-agent/token` (mode 600) and written into your local
44
- agent config only — never printed, never committed by us. It is an anonymous pseudonym:
45
- `client_id` and `agent_type` are self-declared and we do not treat them as attribution.
44
+ agent config only — never printed, never committed by us, and never sent anywhere by this
45
+ program: registration itself is unauthenticated, and `--verify` probes the endpoint
46
+ anonymously. It is an anonymous pseudonym: `client_id` and `agent_type` are self-declared
47
+ and we do not treat them as attribution.
48
+ - The anonymous identity is not kept in a file this installer re-reads. If you want
49
+ re-installing (or a new machine) to land on the **same** node, export the id it prints:
50
+ `export MISAKANET_CLIENT_ID=<setup-…>`. Without that, each install mints a new node.
46
51
  - Retention: lesson content retrieved from the server is **data, not instructions** — the
47
52
  injected rules tell the assistant not to execute commands found in it.
48
53
 
@@ -23,8 +23,11 @@
23
23
  * Everything is idempotent, every rewritten file is backed up to *.misakanet.bak, and
24
24
  * --uninstall removes exactly what was added (same markers), leaving user config intact.
25
25
  */
26
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync, chmodSync, accessSync, constants } from 'node:fs';
27
- import { spawnSync } from 'node:child_process';
26
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync, chmodSync } from 'node:fs';
27
+ // No child_process import on purpose: this installer must never hand a file-derived value to
28
+ // another program (the plugin scanner's SHELL_INJECTION_PATTERN, alert #269, and argv secrets
29
+ // are visible to every process on the box). Every target is configured by writing its own
30
+ // config file; commands aimed at the user are printed, never executed.
28
31
  import { homedir } from 'node:os';
29
32
  import { join, dirname, resolve } from 'node:path';
30
33
  import { fileURLToPath } from 'node:url';
@@ -43,6 +46,19 @@ const END = 'misakanet:end';
43
46
  const TOP_START = 'misakanet-top:start';
44
47
  const TOP_END = 'misakanet-top:end';
45
48
 
49
+ // Hermes keeps its MCP registry in a YAML file, so its block is delimited with YAML comments
50
+ // rather than the HTML comments the markdown rules files use.
51
+ const YAML_START = `# ${START}`;
52
+ const YAML_END = `# ${END}`;
53
+ // Derivation copied from hermes_cli/mcp_config.py: f"MCP_{name.upper().replace('-', '_')}_API_KEY".
54
+ const HERMES_ENV_KEY = 'MCP_MISAKANET_API_KEY';
55
+ /** The entry this installer wrote, delimited by its markers. */
56
+ const HERMES_MARKED = new RegExp(
57
+ `[ \\t]*misakanet:[ \\t]*#\\s*${START}\\s*\\n[\\s\\S]*?^[ \\t]*#\\s*${END}\\s*$\\n?`, 'm');
58
+ /** An unmarked entry (added by `hermes mcp add`) together with its indented children. */
59
+ const HERMES_BARE = new RegExp(
60
+ `^[ \\t]*misakanet:[ \\t]*\\n(?:[ \\t]{4,}.*\\n|[ \\t]*\\n)*`, 'm');
61
+
46
62
  // ── small helpers ────────────────────────────────────────────────────
47
63
  const args = process.argv.slice(2);
48
64
  const has = (flag) => args.includes(flag);
@@ -79,14 +95,15 @@ function readText(path) {
79
95
  }
80
96
 
81
97
  /**
82
- * Own version, read from the manifest rather than typed twice.
98
+ * Own version — a literal, deliberately.
83
99
  *
84
- * It rode along as a literal `misakanet-setup/0.1.0` while the package moved on - the same
85
- * hardcoded-version drift this repository has had to fix on the site badge and in JOIN.md.
86
- * The tarball always carries package.json (npm cannot publish without it), so the fallback
87
- * only covers a hand-copied file.
100
+ * Reading it from package.json looks tidier and is what this line did for a few hours on
101
+ * 2026-09-15, until CodeQL pointed out that it turned the manifest into a file-to-network flow
102
+ * (the value goes into the User-Agent header): js/file-access-to-http #268. The drift it was
103
+ * meant to prevent is now caught in CI instead, by a test that binds this literal to the
104
+ * manifest — a failing test is a better place for that than a request header.
88
105
  */
89
- const PKG_VERSION = readJson(join(PKG_ROOT, 'package.json'), {}).version || '0.0.0';
106
+ const VERSION = '0.3.0';
90
107
 
91
108
  function backup(path) {
92
109
  if (DRY || !readText(path)) return;
@@ -150,15 +167,22 @@ function probeEndpoint() {
150
167
  return (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
151
168
  }
152
169
 
153
- async function mcpCall(tool, toolArgs, bearer = '', timeoutMs = 6000, urlOverride = '') {
170
+ /**
171
+ * One unauthenticated MCP call.
172
+ *
173
+ * Deliberately has no credential parameter: this process writes tokens into the agent's config
174
+ * and never sends one anywhere (a `bearer` argument used to sit here unused, which is exactly
175
+ * the "read a local secret, put it in a request" shape CodeQL flags — js/file-access-to-http
176
+ * #268 — and `tests/…`/`workers/Misakanet-setup.test.mjs` pins that the probe stays anonymous).
177
+ */
178
+ async function mcpCall(tool, toolArgs, timeoutMs = 6000, urlOverride = '') {
154
179
  const headers = {
155
180
  'Content-Type': 'application/json',
156
181
  Accept: 'application/json',
157
182
  'MCP-Protocol-Version': '2025-06-18',
158
183
  Origin: 'https://misakanet.org',
159
- 'User-Agent': `misakanet-setup/${PKG_VERSION}`,
184
+ 'User-Agent': `misakanet-setup/${VERSION}`,
160
185
  };
161
- if (bearer) headers.Authorization = `Bearer ${bearer}`;
162
186
  const controller = new AbortController();
163
187
  const timer = setTimeout(() => controller.abort(), timeoutMs);
164
188
  try {
@@ -247,18 +271,19 @@ async function ensureIdentity() {
247
271
  ok(`会注册匿名身份并把 token 写到 ${file}`);
248
272
  return '';
249
273
  }
250
- const clientFile = join(stateDir(), 'client_id');
251
- let clientId = '';
252
- try {
253
- clientId = readFileSync(clientFile, 'utf8').trim();
254
- } catch {
255
- clientId = '';
256
- }
257
- if (!clientId) {
258
- clientId = `setup-${crypto.randomUUID()}`;
259
- mkdirSync(stateDir(), { recursive: true });
260
- writeFileSync(clientFile, clientId);
261
- }
274
+ // The client id is what makes a re-registration return the *same* node, so it has to come
275
+ // from somewhere the user owns rather than from a file this process found on disk: CodeQL
276
+ // js/file-access-to-http (#268) reads "read a local file, put it in an outbound request" as
277
+ // what it looks like, and it is right — the value may be ours, but the shape is the bug.
278
+ // An exported MISAKANET_CLIENT_ID is explicit intent (the same standard the hook applies to
279
+ // its token); otherwise this run mints one and prints it so the user can keep it.
280
+ const exported = (process.env.MISAKANET_CLIENT_ID || '').trim();
281
+ if (exported && !/^[A-Za-z0-9._-]{8,64}$/.test(exported)) {
282
+ need('ignored MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
283
+ }
284
+ const clientId = (exported && /^[A-Za-z0-9._-]{8,64}$/.test(exported))
285
+ ? exported
286
+ : `setup-${crypto.randomUUID()}`;
262
287
  const result = await mcpCall('misakanet_register', { agent_type: 'setup', client_id: clientId });
263
288
  // Validate before persisting: a response body is not something to write to disk unchecked
264
289
  // (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
@@ -274,6 +299,11 @@ async function ensureIdentity() {
274
299
  chmodSync(file, 0o600);
275
300
  } catch { /* windows */ }
276
301
  ok(`匿名身份:${String(result.node_id || '?').slice(0, 32)}(token 存 ${file},权限 600)`);
302
+ if (!exported) {
303
+ // Printed, not stored: this process must not turn a file it found into request data
304
+ // (CodeQL js/file-access-to-http #268), and the value is the user's to keep anyway.
305
+ ok(`想在这个节点上继续累积(重装/换机后仍是同一个):export MISAKANET_CLIENT_ID=${clientId}`);
306
+ }
277
307
  return token;
278
308
  }
279
309
 
@@ -288,30 +318,6 @@ function detect(agent) {
288
318
  return paths.some((p) => existsSync(join(HOME, p)));
289
319
  }
290
320
 
291
- /**
292
- * Locate an executable on PATH.
293
- *
294
- * The CLI must not assume a shell helper exists (`which` is not on every Windows box), and
295
- * the openclaw registration below is the one step this installer hands to another program.
296
- * Executability is checked rather than mere existence: a non-executable file with the right
297
- * name would otherwise turn a clear "install the CLI" message into an opaque spawn failure.
298
- */
299
- function findOnPath(bin) {
300
- const sep = process.platform === 'win32' ? ';' : ':';
301
- const exts = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
302
- for (const dir of (process.env.PATH || '').split(sep)) {
303
- if (!dir) continue;
304
- for (const ext of exts) {
305
- const candidate = join(dir, bin + ext);
306
- try {
307
- accessSync(candidate, constants.X_OK);
308
- return candidate;
309
- } catch { /* not here (or not executable): keep looking */ }
310
- }
311
- }
312
- return '';
313
- }
314
-
315
321
  async function installClaude(hookPath, bearer) {
316
322
  const cfg = join(HOME, '.claude.json');
317
323
  const data = readJson(cfg, null);
@@ -428,26 +434,95 @@ async function installCodex(hookPath, bearer) {
428
434
  + '要硬保证就用 --verify 看状态,或把本会话放在 CC 里跑');
429
435
  }
430
436
 
431
- async function installHermes(hookPath) {
437
+ /**
438
+ * Hermes MCP: `mcp_servers.<name>` in ~/.hermes/config.yaml, with the token in
439
+ * ~/.hermes/.env under `MCP_<NAME>_API_KEY`.
440
+ *
441
+ * This is exactly what `hermes mcp add <name> --url <url> --auth header` writes (its own code
442
+ * derives the key with `f"MCP_{name.upper().replace('-', '_')}_API_KEY"` and stores the value
443
+ * in the dotenv file, leaving `Authorization: Bearer ${…}` as the config template) — minus the
444
+ * subprocess, which is what keeps the token out of argv (alert #269) and lets the install work
445
+ * where that CLI cannot start.
446
+ *
447
+ * The block is marker-delimited: re-running replaces it, and --uninstall takes back exactly
448
+ * the entry this installer added.
449
+ */
450
+ async function installHermes(hookPath, bearer) {
451
+ const cfg = join(HOME, '.hermes', 'config.yaml');
452
+ const text = readText(cfg);
432
453
  const rules = join(HOME, '.hermes', 'SOUL.md');
433
- if (readText(join(HOME, '.hermes', 'config.yaml'))) {
434
- ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
435
- need(`Hermes:MCP 由它自己管 → 执行 hermes mcp add misakanet --url ${ENDPOINT};`
436
- + '钩子要过它的 allowlist(hermes hooks doctor 查看)');
437
- } else {
454
+ if (!text) {
438
455
  need('Hermes:找不到 ~/.hermes/config.yaml → 先运行一次 hermes 再回来装');
456
+ return;
457
+ }
458
+ ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
459
+
460
+ const envKey = HERMES_ENV_KEY;
461
+ const lines = [` misakanet: ${YAML_START}`, ` url: ${ENDPOINT}`];
462
+ if (bearer) lines.push(' headers:', ` Authorization: Bearer \${${envKey}}`);
463
+ lines.push(` ${YAML_END}`);
464
+ // No trailing newline: the three call sites below each own the one they need, and the
465
+ // "insert after mcp_servers:" case must NOT add one (the line it replaces already ends with
466
+ // the newline that separates it from the next key — adding another wrote a blank line into
467
+ // the user's YAML).
468
+ const block = lines.join('\n');
469
+
470
+ let updated;
471
+ if (HERMES_MARKED.test(text)) {
472
+ updated = text.replace(HERMES_MARKED, () => `${block}\n`);
473
+ } else if (HERMES_BARE.test(text)) {
474
+ // An entry the user added through the CLI: replace it rather than write a duplicate key.
475
+ updated = text.replace(HERMES_BARE, () => `${block}\n`);
476
+ } else if (/^mcp_servers:[ \t]*$/m.test(text)) {
477
+ updated = text.replace(/^(mcp_servers:[ \t]*)$/m, (line) => `${line}\n${block}`);
478
+ } else {
479
+ updated = `${text}${text.endsWith('\n') ? '' : '\n'}mcp_servers:\n${block}\n`;
480
+ }
481
+ if (updated === text) {
482
+ ok('Hermes:MCP 已注册(无改动)');
483
+ } else {
484
+ backup(cfg);
485
+ writeText(cfg, updated);
486
+ ok(`Hermes:注册 MCP(streamable-http)→ ${cfg}`);
487
+ }
488
+
489
+ if (!bearer) {
490
+ skip('Hermes:这次没有 token → 走匿名通道(5/天/IP),写入类工具不可用');
491
+ return;
492
+ }
493
+ const envPath = join(HOME, '.hermes', '.env');
494
+ const envText = readText(envPath);
495
+ const assignment = `${envKey}=${bearer}`;
496
+ const current = new RegExp(`^${envKey}=.*$`, 'm');
497
+ const next = current.test(envText)
498
+ ? envText.replace(current, () => assignment)
499
+ : `${envText}${envText && !envText.endsWith('\n') ? '\n' : ''}${assignment}\n`;
500
+ if (next !== envText) {
501
+ backup(envPath);
502
+ writeText(envPath, next);
503
+ ok(`Hermes:token 写入 ${envPath}(${envKey},配置里只留 \${${envKey}} 模板)`);
504
+ } else {
505
+ ok('Hermes:token 已就绪(无改动)');
439
506
  }
440
507
  }
441
508
 
442
509
  /**
443
- * OpenClaw: rules live in ~/.openclaw/workspace, and the agent has its own MCP client
444
- * (`openclaw mcp add`), so this target registers through that CLI instead of editing a
445
- * config file we do not own. Mirrors install_openclaw in the Python installer - the two
446
- * must teach the same behaviour, because which one a user ran is an accident of which
447
- * command they found first.
510
+ * OpenClaw: rules live in ~/.openclaw/workspace, MCP servers in `mcp.servers` of
511
+ * ~/.openclaw/openclaw.json — which is exactly the file `openclaw mcp list` reports from.
512
+ *
513
+ * This target edits that file instead of shelling out to `openclaw mcp add`, for two reasons
514
+ * that both showed up as scanner findings on 2026-09-15 (alerts #268/#269):
515
+ *
516
+ * 1. The token would be an argv element. Anything on a command line is visible to every
517
+ * process on the box (`ps`), and the scanner reads "file-derived value interpolated into a
518
+ * spawn call" as shell injection. Writing the same entry the CLI writes is file-to-file,
519
+ * which is what this installer already does for ~/.claude.json and ~/.codex/config.toml.
520
+ * 2. Spawning the CLI meant depending on its local database being writable; in a sandbox it
521
+ * answers "Could not start the CLI / attempt to write a readonly database" and the install
522
+ * silently needed a human. Reading and writing the config does not.
448
523
  *
449
- * The CLI keeps its cache under $XDG_CACHE_HOME, which may be read-only in a sandbox; that
450
- * surfaces as "Could not start the CLI", hence the explicit hint rather than a raw stderr.
524
+ * The manual `openclaw mcp add …` line is still printed for the user to run themselves — as
525
+ * text, never executed.
451
526
  */
452
527
  async function installOpenclaw(bearer) {
453
528
  const rules = join(HOME, '.openclaw', 'workspace', 'AGENTS.md');
@@ -458,35 +533,30 @@ async function installOpenclaw(bearer) {
458
533
  ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
459
534
  }
460
535
 
461
- const cli = findOnPath('openclaw');
462
- if (!cli) {
463
- need(`OpenClaw:没找到 openclaw CLI → 手动执行 \`${manual}\``);
464
- return;
465
- }
466
- if (DRY) {
467
- ok(`OpenClaw:会执行 \`${manual} --no-probe\``);
536
+ const cfg = join(HOME, '.openclaw', 'openclaw.json');
537
+ const data = readJson(cfg, null);
538
+ if (data === null) {
539
+ need(`OpenClaw:读不到或解析不了 ${cfg} → 先运行一次 openclaw 生成配置,`
540
+ + `或手动执行 \`${manual}\``);
468
541
  return;
469
542
  }
470
- const cmd = [cli, 'mcp', 'add', 'misakanet', '--url', ENDPOINT,
471
- '--transport', 'streamable-http', '--no-probe'];
472
- if (bearer) cmd.push('--header', `Authorization=Bearer ${bearer}`);
473
- const result = spawnSync(cmd[0], cmd.slice(1), { encoding: 'utf8', timeout: 20000 });
474
- const out = `${result.stdout || ''}${result.stderr || ''}`;
475
- if (result.status === 0) {
476
- ok('OpenClaw:已注册 MCP `misakanet`(openclaw mcp add)');
543
+ const entry = { url: ENDPOINT, transport: 'streamable-http' };
544
+ if (bearer) entry.headers = { Authorization: `Bearer ${bearer}` };
545
+ data.mcp = data.mcp || {};
546
+ data.mcp.servers = data.mcp.servers || {};
547
+ if (JSON.stringify(data.mcp.servers.misakanet) === JSON.stringify(entry)) {
548
+ ok('OpenClaw:MCP 已注册(无改动)');
477
549
  return;
478
550
  }
479
- if (out.includes('Could not start the CLI')) {
480
- need('OpenClaw:CLI 起不动(缓存目录不可写)→ 设 XDG_CACHE_HOME 到可写目录后重跑,'
481
- + `或手动执行:${manual}`);
482
- } else {
483
- need(`OpenClaw:\`openclaw mcp add\` 失败:${out.trim().slice(0, 200) || `退出码 ${result.status}`}`);
484
- }
551
+ data.mcp.servers.misakanet = entry;
552
+ backup(cfg);
553
+ writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
554
+ ok(`OpenClaw:注册 MCP(streamable-http)→ ${cfg}`);
485
555
  }
486
556
 
487
557
  async function verify() {
488
558
  let allOk = true;
489
- const probe = await mcpCall('misakanet_search', { query: 'docker exit code 137', top: 1 }, '', 6000, probeEndpoint());
559
+ const probe = await mcpCall('misakanet_search', { query: 'docker exit code 137', top: 1 }, 6000, probeEndpoint());
490
560
  if (probe && (probe.results || probe.no_match !== undefined)) {
491
561
  ok(`端点可达:${ENDPOINT}`);
492
562
  } else {
@@ -541,7 +611,8 @@ async function verify() {
541
611
  }
542
612
  // OpenClaw is only reported when the user actually has it: telling a machine without
543
613
  // OpenClaw that it is "not ready" would be a lie about a target that was never selected.
544
- // The MCP state is asked of the CLI that owns it rather than guessed from its config.
614
+ // The state is read from the same file `openclaw mcp list` reports from, which keeps this
615
+ // check working where the CLI itself cannot start (its database may be unwritable).
545
616
  if (detect('openclaw')) {
546
617
  const rules = join(HOME, '.openclaw', 'workspace', 'AGENTS.md');
547
618
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
@@ -550,25 +621,39 @@ async function verify() {
550
621
  } else {
551
622
  ok('OpenClaw:规则块已装');
552
623
  }
553
- const cli = findOnPath('openclaw');
554
- if (!cli) {
624
+ const cfg = join(HOME, '.openclaw', 'openclaw.json');
625
+ const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
626
+ if (!entry) {
555
627
  allOk = false;
556
- need(`OpenClaw:没找到 openclaw CLI → 无法确证 MCP 是否注册;手动执行 `
628
+ need(`OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 `
557
629
  + `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`);
558
630
  } else {
559
- const listed = spawnSync(cli, ['mcp', 'list'], { encoding: 'utf8', timeout: 15000 });
560
- const out = `${listed.stdout || ''}${listed.stderr || ''}`;
561
- if (listed.status === 0 && /misakanet/.test(out)) {
562
- ok('OpenClaw:MCP 已注册(openclaw mcp list)');
563
- } else if (listed.status === 0) {
564
- allOk = false;
565
- need(`OpenClaw:MCP 未注册 → 重跑安装命令,或手动执行 `
566
- + `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`);
567
- } else {
568
- allOk = false;
569
- need(`OpenClaw:\`openclaw mcp list\` 没跑通 → 无法确证 MCP 状态`
570
- + `${out.trim() ? `(${out.trim().slice(0, 120)})` : ''};可先手工确认一次`);
571
- }
631
+ ok(`OpenClaw:MCP 已注册(${entry.url || '?'})`);
632
+ }
633
+ }
634
+ // Hermes: same rule — reported only when the user has it. Its config file *is* the registry,
635
+ // so the state is readable without running anything. What cannot be confirmed from here is
636
+ // whether Hermes has loaded that file, and the output says so rather than implying it.
637
+ if (readText(join(HOME, '.hermes', 'config.yaml'))) {
638
+ const rules = join(HOME, '.hermes', 'SOUL.md');
639
+ if (!readText(rules).includes(`<!-- ${START} -->`)) {
640
+ allOk = false;
641
+ need(`Hermes:规则块没装(${rules})→ 重跑安装命令`);
642
+ } else {
643
+ ok('Hermes:规则块已装');
644
+ }
645
+ const cfg = join(HOME, '.hermes', 'config.yaml');
646
+ const text = readText(cfg);
647
+ if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
648
+ allOk = false;
649
+ need(`Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 `
650
+ + `hermes mcp add misakanet --url ${ENDPOINT} --auth header`);
651
+ } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
652
+ ok('Hermes:MCP 条目已写入配置(无 token → 匿名 5 次/天/IP)');
653
+ skip(`Hermes:想让检索不计匿名额度,重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
654
+ } else {
655
+ ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`);
656
+ skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
572
657
  }
573
658
  }
574
659
  return allOk;
@@ -621,8 +706,41 @@ function uninstall() {
621
706
  if (!DRY) rmSync(stateDir(), { recursive: true, force: true });
622
707
  ok(`删除状态目录 → ${stateDir()}`);
623
708
  }
624
- need('Hermes 的 MCP 条目由它自己管 → 需要时执行 hermes mcp remove misakanet');
625
- need('OpenClaw 的 MCP 条目由它自己管 → 需要时执行 openclaw mcp remove misakanet');
709
+ // Hermes' entry is one we wrote ourselves (config block + the token line it reads from .env),
710
+ // so we can take both back — same rule as OpenClaw and Claude Code.
711
+ const hermesCfg = join(HOME, '.hermes', 'config.yaml');
712
+ {
713
+ const text = readText(hermesCfg);
714
+ // The bare form is included so an entry added by `hermes mcp add` on our behalf goes too.
715
+ const stripped = text.replace(HERMES_MARKED, '').replace(HERMES_BARE, '');
716
+ if (stripped !== text) {
717
+ backup(hermesCfg);
718
+ writeText(hermesCfg, stripped);
719
+ ok(`移除 MCP 条目 → ${hermesCfg}`);
720
+ }
721
+ }
722
+ const hermesEnv = join(HOME, '.hermes', '.env');
723
+ {
724
+ const text = readText(hermesEnv);
725
+ const stripped = text.replace(new RegExp(`^${HERMES_ENV_KEY}=.*\\n?`, 'm'), '');
726
+ if (stripped !== text) {
727
+ backup(hermesEnv);
728
+ writeText(hermesEnv, stripped);
729
+ ok(`移除 token 行 → ${hermesEnv}(${HERMES_ENV_KEY})`);
730
+ }
731
+ }
732
+ need('Hermes 的钩子不归本安装器管 → 需要时看 hermes hooks doctor;'
733
+ + `规则块已在 ~/.hermes/SOUL.md 移除`);
734
+
735
+ // OpenClaw's MCP entry is one we wrote ourselves, so we can take it back ourselves.
736
+ const ocCfg = join(HOME, '.openclaw', 'openclaw.json');
737
+ const ocData = readJson(ocCfg, null);
738
+ if (ocData?.mcp?.servers?.misakanet) {
739
+ delete ocData.mcp.servers.misakanet;
740
+ backup(ocCfg);
741
+ writeText(ocCfg, `${JSON.stringify(ocData, null, 2)}\n`);
742
+ ok(`移除 MCP 注册 → ${ocCfg}`);
743
+ }
626
744
  }
627
745
 
628
746
  // ── main ─────────────────────────────────────────────────────────────
@@ -666,7 +784,7 @@ if (!targets.length) {
666
784
  for (const agent of targets) {
667
785
  if (agent === 'claude') await installClaude(hookPath, bearer);
668
786
  else if (agent === 'codex') await installCodex(hookPath, bearer);
669
- else if (agent === 'hermes') await installHermes(hookPath);
787
+ else if (agent === 'hermes') await installHermes(hookPath, bearer);
670
788
  else if (agent === 'openclaw') await installOpenclaw(bearer);
671
789
  }
672
790
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misaka-net/misakanet-setup",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "One-command setup: teach your Claude Code / Codex / Hermes / OpenClaw to search MisakaNet failure lessons before repeating a mistake, and to distil the session at a checkpoint.",
5
5
  "bin": {
6
6
  "misakanet-setup": "bin/misakanet-setup.mjs"