@misaka-net/misakanet-setup 0.5.6 → 0.7.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.
@@ -113,7 +113,8 @@ const SILENT = has('--silent');
113
113
  /** `--report-json`: the same report as JSON, and nothing else on stdout (#1784). */
114
114
  const REPORT_JSON = has('--report-json');
115
115
  const HOME = resolve(valueOf('--home', homedir()));
116
- const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale'];
116
+ const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
117
+ 'cursor', 'gemini', 'copilot', 'opencode', 'kiro', 'mcode'];
117
118
 
118
119
  const done = [];
119
120
  const manual = [];
@@ -146,8 +147,17 @@ function readText(path) {
146
147
  * (the value goes into the User-Agent header): js/file-access-to-http #268. The drift it was
147
148
  * meant to prevent is now caught in CI instead, by a test that binds this literal to the
148
149
  * manifest — a failing test is a better place for that than a request header.
150
+ *
151
+ * **Who moves it:** the release tooling, not a person. The `x-release-please-version` marker on
152
+ * this line is what release-please's generic updater looks for, and the file is listed in the
153
+ * setup package's `extra-files`, so the release pull request bumps this literal together with
154
+ * the manifest. That marker is not decoration: release-please replaces a version **only on a
155
+ * line that carries the marker** — an unmarked line is copied verbatim, so a release would ship
156
+ * a stale `--version` and a stale `X-MisakaNet-Version` header while every gate stayed green
157
+ * except the two that name the version (measured 2026-10-22: the 0.6.0 release pull request
158
+ * failed 9 e2e legs on exactly that).
149
159
  */
150
- const VERSION = '0.5.6'
160
+ const VERSION = '0.7.0' // x-release-please-version
151
161
 
152
162
  /**
153
163
  * The shape of a client id, in one place.
@@ -159,6 +169,9 @@ const VERSION = '0.5.6'
159
169
  * this cannot contain a quote, a newline or a `:`, so it cannot break any of the three.
160
170
  */
161
171
  const CLIENT_ID_SHAPE = /^[A-Za-z0-9._-]{8,64}$/
172
+ // The server's rule for a referral code (`misakanet_register` → `referral_code`), kept identical on
173
+ // both sides: a value the client is willing to send and the server is willing to count.
174
+ const REFERRAL_SHAPE = /^[A-Za-z0-9]{4,16}$/
162
175
 
163
176
  // ── argument surface ─────────────────────────────────────────────────
164
177
  // Until 2026-09-17 no flag was validated at all: `--help` fell through to a *real install*
@@ -172,7 +185,7 @@ const FLAGS = [
172
185
  ['--silent', '只压掉进度叙述,绝不压掉 ! 行与报告(MDM/GPO 用)'],
173
186
  ['--report', '打印一段可公开粘贴的脱敏状态报告(人读 YAML)'],
174
187
  ['--report-json', '同一份报告的 JSON 编码'],
175
- ['--strict', '配合 --report:NOT READY 时退出码 1(默认仍退 0)'],
188
+ ['--strict', '配合 --report:NOT READY 时退出码 1,没能验证(探测超时/代理)时 2(默认仍退 0)'],
176
189
  ['--ci', '= --report --strict'],
177
190
  ['--verify', '自检:端点可达、钩子、MCP 注册、版本新旧'],
178
191
  ['--uninstall', '移除本安装器写入的内容(保留 .misakanet.bak 备份)'],
@@ -193,8 +206,9 @@ function printHelp() {
193
206
 
194
207
  ${FLAGS.map(([flag, desc]) => ` ${flag.padEnd(16)} ${desc}`).join('\n')}
195
208
 
196
- 退出码(安装模式):0 至少装上了一个助手 / 1 什么都没装上 / 2 自己跑不起来。
197
- 健康检查用 --verify 或 --report --strict,不要看安装模式的退出码。`);
209
+ 退出码(三档,--verify 与 --report --strict 相同):0 READY / 1 NOT READY(机器问题)/
210
+ 2 没能验证(工具给不出机器判定:探测超时、代理不可信,或报告没生成)。
211
+ 安装模式:0 至少装上了一个助手 / 1 什么都没装上 / 2 自己跑不起来。`);
198
212
  }
199
213
 
200
214
  if (has('--help') || has('-h')) {
@@ -206,9 +220,6 @@ if (has('--version')) {
206
220
  process.exit(0);
207
221
  }
208
222
 
209
- /** What we would write, and how to take it back — printed by --list-writes, and used by the report. */
210
- const WRITE_MANIFEST = [];
211
-
212
223
  const KNOWN = new Set(FLAGS.flatMap(([flag]) => flag.split(/[,\s]/).filter((f) => f.startsWith('-'))));
213
224
  {
214
225
  const unknown = args.filter((a) => a.startsWith('-') && !KNOWN.has(a));
@@ -271,6 +282,16 @@ function sameJson(a, b) {
271
282
  * read-only file (it only needs write permission on the *directory*), so the previous EACCES
272
283
  * behaviour is kept explicitly rather than silently lost.
273
284
  */
285
+ /**
286
+ * Every file this run actually wrote.
287
+ *
288
+ * The preview says which files are *in scope*; this says which ones were really touched. The
289
+ * difference is the whole point of the count a user actually reads: on an already-installed
290
+ * machine the preview listed 7 files and every one of them came back "无改动", while the header
291
+ * still said "将修改 7 个文件" — a number nobody could trust (measured 2026-10-23).
292
+ */
293
+ const writtenFiles = new Set();
294
+
274
295
  function writeText(path, text, { mode } = {}) {
275
296
  if (DRY) return;
276
297
  mkdirSync(dirname(path), { recursive: true });
@@ -316,6 +337,172 @@ function writeText(path, text, { mode } = {}) {
316
337
  try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
317
338
  throw err;
318
339
  }
340
+ writtenFiles.add(path);
341
+ }
342
+
343
+ /**
344
+ * The targets whose whole install is one MCP entry in one JSON file.
345
+ *
346
+ * Each entry shape is the one that target's own documentation shows, and they are **not**
347
+ * interchangeable: Gemini CLI's remote field is `httpUrl` (its `url` means SSE), OpenCode nests under
348
+ * `mcp` rather than `mcpServers` and wants `type: "remote"`, Copilot CLI wants `type: "http"`, and
349
+ * Cursor and Kiro use a bare `url`. A copied entry with the wrong key fails **silently** — no error,
350
+ * the server simply never appears — which is why each shape is pinned by tests instead of assumed.
351
+ *
352
+ * None of these five has a rules block or a hook *this installer can write*: their rules live in
353
+ * project-scoped files (`.cursor/rules/*.mdc`, `GEMINI.md`, `AGENTS.md`, `.github/copilot-instructions.md`,
354
+ * `.kiro/steering/*.md`) and we do not know where a user's projects are. So tier ① is the whole
355
+ * install for them, `--mcp-only` is an identity, and the output says "MCP only" out loud instead of
356
+ * implying behaviour changed. That is also why `--report` says `install-scope: mcp-only` on a machine
357
+ * whose only target is one of these.
358
+ */
359
+ const MCP_ONLY_TARGETS = {
360
+ cursor: {
361
+ label: 'Cursor',
362
+ detect: ['.cursor'],
363
+ config: (home) => join(home, '.cursor', 'mcp.json'),
364
+ container: 'mcpServers',
365
+ urlField: 'url',
366
+ entry: (bearer) => ({ url: ENDPOINT, headers: mcpHeaders(bearer, 'cursor') }),
367
+ rules: '.cursor/rules/*.mdc 是项目级的,安装器不知道你的项目在哪',
368
+ ruleHint: '想让 Cursor 主动去查,把 .cursor/rules/misakanet-failure-memory.mdc 放进项目',
369
+ manual: '重启 Cursor 后在 Settings → MCP 里确认能看见 misakanet(本安装器只写用户级 '
370
+ + '~/.cursor/mcp.json,项目级 .cursor/mcp.json 归你自己管)',
371
+ verifyHint: '在 Settings → MCP 里复核',
372
+ },
373
+ gemini: {
374
+ label: 'Gemini CLI',
375
+ detect: ['.gemini'],
376
+ config: (home) => join(home, '.gemini', 'settings.json'),
377
+ container: 'mcpServers',
378
+ // `httpUrl` is the HTTP-streaming endpoint; `url` would mean SSE. Documented property list:
379
+ // geminicli.com/docs/tools/mcp-server ("headers: Custom HTTP headers when using url or httpUrl").
380
+ urlField: 'httpUrl',
381
+ entry: (bearer) => ({ httpUrl: ENDPOINT, headers: mcpHeaders(bearer, 'gemini') }),
382
+ rules: '它读 GEMINI.md(全局 ~/.gemini/GEMINI.md 与项目树),而安装器不写别人的规则文件',
383
+ ruleHint: '想让 Gemini CLI 主动去查,把规则块加进项目的 GEMINI.md',
384
+ manual: '重启会话后用 `/mcp` 复核 misakanet 是否列出',
385
+ verifyHint: '用 /mcp 复核',
386
+ },
387
+ copilot: {
388
+ label: 'Copilot CLI',
389
+ detect: ['.copilot'],
390
+ config: (home) => join(home, '.copilot', 'mcp-config.json'),
391
+ container: 'mcpServers',
392
+ urlField: 'url',
393
+ entry: (bearer) => ({ type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'copilot') }),
394
+ rules: '它读 .github/copilot-instructions.md 与 AGENTS.md(项目级),安装器不写项目文件',
395
+ ruleHint: '想让 Copilot CLI 主动去查,把规则写进项目的 .github/copilot-instructions.md',
396
+ manual: '跑 `copilot mcp list` 复核(VS Code 里的 Copilot 是另一份配置,键名是 `servers`)',
397
+ verifyHint: '用 `copilot mcp list` 复核',
398
+ },
399
+ opencode: {
400
+ label: 'OpenCode',
401
+ detect: ['.config/opencode'],
402
+ config: (home) => join(home, '.config', 'opencode', 'opencode.json'),
403
+ container: 'mcp',
404
+ urlField: 'url',
405
+ entry: (bearer) => ({ type: 'remote', url: ENDPOINT, enabled: true,
406
+ headers: mcpHeaders(bearer, 'opencode') }),
407
+ rules: '它读 AGENTS.md(项目根与 ~/.config/opencode/AGENTS.md),安装器不写别人的规则文件',
408
+ ruleHint: '想让 OpenCode 主动去查,把规则块加进项目的 AGENTS.md',
409
+ manual: '重启 OpenCode 后看它的 MCP 列表;若你设了 XDG_CONFIG_HOME,它读的是 '
410
+ + '$XDG_CONFIG_HOME/opencode/opencode.json,把同样的条目贴过去即可',
411
+ verifyHint: '看 OpenCode 的 MCP 列表复核',
412
+ parseNote: 'opencode.jsonc 允许注释,若你用的正是它,本安装器解析不了',
413
+ },
414
+ kiro: {
415
+ label: 'Kiro',
416
+ detect: ['.kiro'],
417
+ config: (home) => join(home, '.kiro', 'settings', 'mcp.json'),
418
+ container: 'mcpServers',
419
+ urlField: 'url',
420
+ entry: (bearer) => ({ url: ENDPOINT, headers: mcpHeaders(bearer, 'kiro') }),
421
+ rules: '它读 .kiro/steering/*.md(steering 文件),安装器不写别人的规则文件',
422
+ ruleHint: '想让 Kiro 主动去查,把规则加进 .kiro/steering/',
423
+ manual: '重启 Kiro 后用它的 MCP 面板复核 misakanet(Kiro 还支持 disabled / autoApprove 字段)',
424
+ verifyHint: '用 Kiro 的 MCP 面板复核',
425
+ },
426
+ };
427
+
428
+ /**
429
+ * Display names for every target, derived from the two lists that already define them.
430
+ *
431
+ * Why a table: the "no supported assistant was found" sentence used to spell out six names by
432
+ * hand (`Claude Code / Codex / Hermes / OpenClaw / Codewhale / Cursor`) while `AGENTS` had grown
433
+ * to ten — so a machine with none of the six was told about a smaller product than the one it was
434
+ * running, and Gemini/Copilot/OpenCode/Kiro users could not recognise themselves in it (#3778).
435
+ * The five MCP-only labels are read from `MCP_ONLY_TARGETS` rather than typed a second time, and
436
+ * `supportedAgentLabels()` falls back to the id if a future target is added without a label — an
437
+ * unlabelled target is then still named, instead of silently disappearing from the sentence.
438
+ */
439
+ const AGENT_LABELS = {
440
+ claude: 'Claude Code',
441
+ codex: 'Codex',
442
+ hermes: 'Hermes',
443
+ openclaw: 'OpenClaw',
444
+ codewhale: 'Codewhale',
445
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS).map(([id, spec]) => [id, spec.label])),
446
+ // Not an MCP-only target: it takes a rules block too (see `installMcode`), so its label cannot be
447
+ // read from that table.
448
+ mcode: 'MiniMax Code',
449
+ };
450
+
451
+ /**
452
+ * MiniMax Code (`mcode`) — a **plugin directory**, not a config file (issue #3777).
453
+ *
454
+ * Three facts decide this whole target, and none of them is guessable from the other clients:
455
+ *
456
+ * * mcode does not read a user-level MCP config. `~/.minimax/config.yaml`'s `mcpServers` is
457
+ * ignored (measured in #3777), so writing an entry there would look installed and register
458
+ * nothing.
459
+ * * It *does* read a **plugin directory** whose manifest is a Claude Code plugin manifest:
460
+ * `~/.minimax/plugins/misakanet/.claude-plugin/plugin.json` (source `LOCAL_CLAUDE`) makes
461
+ * `mcode plugin list` show `misakanet@local enabled`. Placing the directory is the install.
462
+ * * `mcode plugin add misakanet@local` answers `LOCAL_PLUGIN_INSTALL_UNSUPPORTED` — that is its
463
+ * refusal to *link* a local plugin, not evidence that the plugin is missing: the listing still
464
+ * shows it enabled while the directory is in place. Nothing in this installer runs that command.
465
+ *
466
+ * The manifest cannot be copied from the repository's own `.claude-plugin/plugin.json`: the npm
467
+ * package's `files` list does not ship it (`bin/`, `hook/`, `voice/`, `README.md` only), so the
468
+ * expected shape is built here. Rules are ordinary `AGENTS.md`: `~/.minimax/AGENTS.md` is its
469
+ * global scope and applies to every session. There is no hook mechanism, so tier ③ does not exist
470
+ * for this target and the install says so instead of implying behaviour changed.
471
+ *
472
+ * What this target cannot fix: mcode budgets ~2000 ms for HTTP MCP discovery and Node's `fetch`
473
+ * ignores proxy variables unless `NODE_USE_ENV_PROXY=1`. On a proxied machine the result is
474
+ * "installed but 0 tools" — a runtime fact, documented in docs/integrations/mcode.md, not
475
+ * something an installer can write around.
476
+ */
477
+ const MCODE_PLUGIN_REL = join('.minimax', 'plugins', 'misakanet', '.claude-plugin', 'plugin.json');
478
+ const MCODE_RULES_REL = join('.minimax', 'AGENTS.md');
479
+ const MCODE_DESCRIPTION = 'MisakaNet failure memory: search evidence-rated failure lessons before '
480
+ + 're-debugging, and turn new failures into shared knowledge.';
481
+
482
+ const mcodePluginPath = (home) => join(home, MCODE_PLUGIN_REL);
483
+
484
+ /** The manifest we write — the shape `mcode plugin list` reads. */
485
+ function mcodePluginDoc(bearer) {
486
+ return {
487
+ name: 'misakanet',
488
+ version: VERSION,
489
+ description: MCODE_DESCRIPTION,
490
+ mcpServers: {
491
+ misakanet: { type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'mcode') },
492
+ },
493
+ };
494
+ }
495
+
496
+ /** The `misakanet` entry in that manifest, or null when the file is absent or not JSON. */
497
+ function mcodePluginEntry() {
498
+ const doc = readJson(mcodePluginPath(HOME), null);
499
+ return doc && typeof doc === 'object' ? doc.mcpServers?.misakanet || null : null;
500
+ }
501
+
502
+
503
+ /** The supported assistants, named from `AGENTS` — never a hand-written second list. */
504
+ function supportedAgentLabels() {
505
+ return AGENTS.map((agent) => AGENT_LABELS[agent] || agent).join(' / ');
319
506
  }
320
507
 
321
508
  /**
@@ -330,9 +517,124 @@ const AGENT_WRITE_PATHS = {
330
517
  codex: (home) => [join(home, '.codex', 'config.toml'), join(home, '.codex', 'AGENTS.md')],
331
518
  hermes: (home) => [join(home, '.hermes', 'config.yaml'), join(home, '.hermes', 'SOUL.md')],
332
519
  codewhale: (home) => [join(home, '.codewhale', 'mcp.json')],
520
+ // Every MCP-only target writes exactly one file, so the table above is the single source of truth
521
+ // for the write manifest, the detection paths and the report's scope line.
522
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
523
+ .map(([id, spec]) => [id, (home) => [spec.config(home)]])),
524
+ // Two files, two tiers: the plugin manifest registers the endpoint (①) and the global AGENTS.md
525
+ // carries the rules block (②). Listing both here is what makes the preview, `--list-writes` and
526
+ // the permission pre-check agree with `installMcode`.
527
+ mcode: (home) => [join(home, MCODE_PLUGIN_REL), join(home, MCODE_RULES_REL)],
333
528
  openclaw: () => openclawWorkspaces().map((workspace) => join(workspace, 'AGENTS.md')),
334
529
  };
335
530
 
531
+ /**
532
+ * The write manifest: every file this run would touch, with the kind of write and the tier it
533
+ * belongs to.
534
+ *
535
+ * One builder, two readers: `--list-writes` prints it for an operator who has to approve it, and
536
+ * the install preview prints it for the user who is about to say yes (#3690). They cannot drift,
537
+ * because there is only one list — a preview that disagrees with the manifest is worse than no
538
+ * preview at all.
539
+ *
540
+ * The tiers are the permission tiers from the top of this file: ① registers an MCP endpoint,
541
+ * ② writes a rules block (that changes how the agent behaves), ③ installs hooks and state (that
542
+ * run on every turn). Under `--mcp-only` only ① is in scope, so the other two are not listed —
543
+ * the same rule the install itself follows.
544
+ */
545
+ function buildWriteManifest(agents) {
546
+ // Compare base names, not separators: a `/settings.json$` regex silently classifies nothing on
547
+ // Windows (all three windows legs went red on the first CI run of this change), and a manifest
548
+ // that hides a tier is worse than no manifest.
549
+ const classify = (path) => {
550
+ const base = basename(path);
551
+ if (base === 'CLAUDE.md' || base === 'AGENTS.md' || base === 'SOUL.md') return { tier: 2, kind: 'rules' };
552
+ if (path.includes('.misakanet-agent')) return { tier: 3, kind: 'state' };
553
+ // `settings.json` carries both the read-only grants (①) and the hooks (③); the hook row is
554
+ // added separately below, and only when the hooks are in scope.
555
+ if (base === 'settings.json' && path.includes('.claude')) return { tier: 1, kind: 'grants' };
556
+ return { tier: 1, kind: 'mcp' };
557
+ };
558
+ const rows = new Map();
559
+ const add = (path, action, tier, kind) => {
560
+ const key = `${tier}|${path}`;
561
+ if (!rows.has(key)) rows.set(key, { path: redact(path), action, tier, kind });
562
+ };
563
+ for (const agent of agents) {
564
+ if (!detect(agent)) continue;
565
+ for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
566
+ const { tier, kind } = classify(path);
567
+ add(path, existsSync(path) ? 'append-or-update' : 'create', tier, kind);
568
+ if (!MCP_ONLY && basename(path) === 'settings.json' && path.includes('.claude')) {
569
+ add(path, 'append-or-update(钩子)', 3, 'hook');
570
+ }
571
+ }
572
+ }
573
+ // The state dir is ours, but it is still written to disk — and only when there is something to
574
+ // install: with no target detected the run writes nothing at all.
575
+ if (agents.length) {
576
+ // `--no-register` registers no anonymous node, so no token is written; listing it anyway would
577
+ // make the manifest — and the preview's file count — promise a file that stays absent.
578
+ if (!has('--no-register')) add(join(stateDir(), 'token'), 'create (0600)', 1, 'token');
579
+ if (!MCP_ONLY) {
580
+ // The endpoint record lives in the state directory, so it is tier ③ like everything else in
581
+ // there: `--mcp-only` must not create that directory at all (the e2e checks the directory
582
+ // itself, not just the files). The record is therefore written only when tier ③ is in scope.
583
+ add(join(stateDir(), STATE_ENDPOINT_FILE), 'create', 3, 'endpoint');
584
+ add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
585
+ add(join(stateDir(), STATE_VERSION_FILE), 'create', 3, 'version');
586
+ }
587
+ }
588
+ return [...rows.values()]
589
+ .filter((row) => (MCP_ONLY ? row.tier === 1 : true))
590
+ .sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
591
+ }
592
+
593
+ /**
594
+ * The preview: what this run is about to write, said *before* it writes it (#3690).
595
+ *
596
+ * A user who never opens a checkout has no way to know that an installer also edits the agent's
597
+ * rules file — and that file is the one that changes how the agent behaves. So the summary names
598
+ * the files, the kind of change, and the three ways out: look first (`--list-writes`), ask for the
599
+ * endpoint only (`--mcp-only`), or take it back (`--uninstall`).
600
+ *
601
+ * One line per file, one line of ways out, and every path redacted to `~` — the person reading
602
+ * this may never see a checkout, so nothing here may assume one.
603
+ */
604
+ function writePreview(rows) {
605
+ const files = new Map();
606
+ for (const row of rows) {
607
+ const file = files.get(row.path) || { path: row.path, tier: row.tier, kinds: [] };
608
+ file.tier = Math.min(file.tier, row.tier);
609
+ file.kinds.push(row.kind);
610
+ files.set(row.path, file);
611
+ }
612
+ const list = [...files.values()].sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
613
+ if (!list.length) return '';
614
+ const labels = {
615
+ mcp: 'MCP 端点',
616
+ grants: '只读工具放行',
617
+ rules: '规则块(会改变 agent 的行为)',
618
+ hook: '钩子',
619
+ token: 'MCP 凭据',
620
+ endpoint: '端点记录',
621
+ hookScript: '钩子脚本',
622
+ version: '版本戳',
623
+ state: '本机状态',
624
+ };
625
+ const width = Math.min(Math.max(...list.map((f) => f.path.length)) + 2, 32);
626
+ const pad = (text) => (text.length >= width ? `${text} ` : text.padEnd(width));
627
+ return [
628
+ // "将修改" was a promise the run could not keep: on an already-installed machine every file
629
+ // in this list came back "无改动" while the header still said "将修改 7 个文件" — and that is
630
+ // the number the reader looks at. The list is a scope, so it says so; the count of files that
631
+ // were really touched is printed after the install (see writtenFiles).
632
+ `将涉及 ${list.length} 个文件${DRY ? '(--dry-run:只是预览,不写任何东西)' : ''}:`,
633
+ ...list.map((f) => ` ${pad(f.path)}+ ${f.kinds.map((k) => labels[k]).join(' + ')}`),
634
+ '先看:--list-writes 不改行为:--mcp-only 撤销:--uninstall',
635
+ ].join('\n');
636
+ }
637
+
336
638
  /**
337
639
  * Which of these paths this process may not write.
338
640
  *
@@ -413,15 +715,76 @@ function ourHookEntries(hooks) {
413
715
  }
414
716
 
415
717
  /** Insert or refresh a marker-delimited block. Uses a function replacement (no $-escapes). */
718
+ /**
719
+ * Is tier ③ (the behaviour layer) actually *wired*, or merely present on disk?
720
+ *
721
+ * `hook.mjs` living in our own state directory proves nothing about the user's agents: the hook only
722
+ * runs where a config file points at it, and today that is Claude Code alone. `--report` used to
723
+ * answer `install-scope: full` from the file's existence, so a Cursor-only machine claimed the
724
+ * behaviour layer was installed. It is `full` only when Claude Code's settings carry our entries.
725
+ */
726
+ function hookWired() {
727
+ const settings = readJson(join(HOME, '.claude', 'settings.json'), null);
728
+ return ourHookEntries(settings?.hooks).length > 0;
729
+ }
730
+
731
+ /**
732
+ * Our rules block in a file, with or without its markers.
733
+ *
734
+ * The markers can be lost — a hand-edit, a merge conflict — and then the block is still ours,
735
+ * sitting in the file as plain text. Two consequences, both measured on a real machine
736
+ * (2026-10-23): the installer looked for the markers, so it appended a *second* copy of a block
737
+ * that was already there (reporting "added", i.e. success); the uninstaller removed by marker,
738
+ * so it took only one of the two away and left the other for ever (also reporting success).
739
+ * Three rounds of install/uninstall left zero residue as long as the markers were intact, which
740
+ * is why this only ever showed up after the markers went missing.
741
+ *
742
+ * So both sides look for the same thing now: the marker pair, or the block's own text.
743
+ */
744
+ function blockRegions(text, block) {
745
+ const regions = [];
746
+ // The marked region is exactly what the old installer matched — and it must stay that narrow:
747
+ // a region that also swallowed the newline in front of the block made every re-install rewrite
748
+ // the file (the e2e caught it: "the rules file was rewritten by a run that had nothing to
749
+ // change"). Taking that newline is the *uninstaller's* job, and only the uninstaller's.
750
+ const marked = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`, 'g');
751
+ for (const m of text.matchAll(marked)) {
752
+ regions.push({ start: m.index, end: m.index + m[0].length, text: m[0], marked: true });
753
+ }
754
+ const body = block.trim();
755
+ let from = 0;
756
+ for (;;) {
757
+ const at = text.indexOf(body, from);
758
+ if (at < 0) break;
759
+ const inside = regions.some((r) => at >= r.start && at < r.end);
760
+ if (!inside) {
761
+ regions.push({ start: at, end: at + body.length, text: body, marked: false });
762
+ }
763
+ from = at + body.length;
764
+ }
765
+ return regions.sort((a, b) => a.start - b.start);
766
+ }
767
+
416
768
  function injectBlock(path, block) {
417
769
  const existing = readText(path);
418
- const pattern = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
419
770
  const body = `<!-- ${START} -->\n${block.trim()}\n<!-- ${END} -->\n`;
420
- if (pattern.test(existing)) {
421
- const updated = existing.replace(pattern, () => body);
422
- if (updated === existing) return 'unchanged';
771
+ const regions = blockRegions(existing, block);
772
+ if (regions.length) {
773
+ // Keep the first copy where it is, re-wrap it in its markers, and drop the others: a file
774
+ // that ended up with two copies of the rules gets one. This is the case that used to
775
+ // append a second copy every time the installer ran.
776
+ const first = regions[0];
777
+ let out = '';
778
+ let cursor = 0;
779
+ for (const r of regions) {
780
+ out += existing.slice(cursor, r.start);
781
+ if (r === first) out += body;
782
+ cursor = r.end;
783
+ }
784
+ out += existing.slice(cursor);
785
+ if (out === existing) return 'unchanged';
423
786
  backup(path);
424
- writeText(path, updated);
787
+ writeText(path, out);
425
788
  return 'updated';
426
789
  }
427
790
  backup(path);
@@ -430,20 +793,36 @@ function injectBlock(path, block) {
430
793
  return 'added';
431
794
  }
432
795
 
433
- function stripBlock(path) {
796
+ /**
797
+ * Take our rules block back out — marked or not.
798
+ *
799
+ * The unmarked case is the one that used to leave a permanent copy behind: the block was ours,
800
+ * but without its markers neither side recognised it, so the installer duplicated it and the
801
+ * uninstaller could not see it. `blockRegions()` finds both shapes, so removal matches what
802
+ * installation now recognises.
803
+ */
804
+ function stripBlock(path, block = PROMPT_BLOCK) {
434
805
  const text = readText(path);
435
806
  if (!text) return false;
436
- // `\n?` on both sides: install writes a blank line, then the block, so consuming only the
437
- // block left the file one newline longer than it started (uninstall claimed "已恢复原状"
438
- // while `diff` showed an extra empty line).
439
- const pattern = new RegExp(`\\n?[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
440
- if (!pattern.test(text)) return false;
807
+ const regions = blockRegions(text, block);
808
+ if (!regions.length) return false;
441
809
  backup(path);
442
- const stripped = text.replace(pattern, '');
443
- if (!stripped.trim()) {
810
+ let out = '';
811
+ let cursor = 0;
812
+ for (const r of regions) {
813
+ // The install writes a blank line, then the block; consuming only the block left the file one
814
+ // newline longer than it started (uninstall claimed "已恢复原状" while `diff` showed an extra
815
+ // empty line). So the removal takes that newline with it.
816
+ let start = r.start;
817
+ if (start > 0 && text[start - 1] === '\n') start -= 1;
818
+ out += text.slice(cursor, start);
819
+ cursor = r.end;
820
+ }
821
+ out += text.slice(cursor);
822
+ if (!out.trim()) {
444
823
  if (!DRY) rmSync(path, { force: true });
445
824
  } else {
446
- writeText(path, stripped);
825
+ writeText(path, out);
447
826
  }
448
827
  return true;
449
828
  }
@@ -483,12 +862,67 @@ function mcpHeaders(bearer, agent) {
483
862
  * syntaxes, so it is checked against `CLIENT_ID_SHAPE` first. A `client_id` that is not id-shaped is
484
863
  * not a hint worth writing; it is noise that could close a quote or a mapping.
485
864
  */
865
+ /**
866
+ * The referral code of the node that invited this one, or `''`.
867
+ *
868
+ * `python3 scripts/referral.py --apply=CODE` writes it to `<stateDir>/referral_code` — the same
869
+ * directory `client_id` and `token` live in, so every client on the machine can read it. Shape-checked
870
+ * for the same reason `client_id` is: it is a value from a file on its way into an API request, and the
871
+ * server will ignore anything that is not code-shaped, which would be a silent no-op (#1996).
872
+ */
873
+ function declaredReferral() {
874
+ const found = readText(join(stateDir(), 'referral_code')).trim();
875
+ return REFERRAL_SHAPE.test(found) ? found : '';
876
+ }
877
+
486
878
  function declaredClientId() {
487
879
  if (knownClientId) return knownClientId;
488
880
  const found = readText(join(stateDir(), 'client_id')).trim();
489
881
  return CLIENT_ID_SHAPE.test(found) ? found : '';
490
882
  }
491
883
 
884
+ /**
885
+ * An `X-MisakaNet-Client` value already written into a target's config.
886
+ *
887
+ * Three syntaxes, because three kinds of target carry this header: JSON (`"X-MisakaNet-Client": "…"`
888
+ * in `.claude.json`, `openclaw.json` and the `--mcp-only` configs), TOML (Codex writes it **inside**
889
+ * its one-line `http_headers = { … }` table, so it is not at the start of a line) and YAML (Hermes's
890
+ * `config.yaml`, where the key is followed by `:`). The leading `(?:^|[\s{,])` is what lets the TOML
891
+ * case match mid-line while still requiring a plausible delimiter before the key.
892
+ *
893
+ * The capture is limited to the id alphabet so a value that could close a quote or a mapping can
894
+ * never travel from this file into the next one; `CLIENT_ID_SHAPE` then rejects it on length too.
895
+ */
896
+ const CLIENT_ID_IN_CONFIG = /(?:^|[\s{,])["']?X-MisakaNet-Client["']?\s*[:=]\s*["']?([A-Za-z0-9._-]+)/m;
897
+
898
+ /**
899
+ * Adopt the client id a target's config already declares, so a re-run keeps writing the same header.
900
+ *
901
+ * What went wrong without it (#3772): the first run mints `setup-<uuid>`, writes it into every
902
+ * config and prints it — but deliberately does not persist it to `<stateDir>/client_id` (that is the
903
+ * "read a local file, send it in a request" shape #1688 removed). The second run therefore had no id
904
+ * to declare, `mcpHeaders()` dropped the header, every config compared unequal, and all of them were
905
+ * rewritten while the closing report said the same files had been installed again.
906
+ *
907
+ * The value is already in the file this run is about to rewrite, so inheriting it in place needs no
908
+ * new persistence and no file-to-network flow: it stays a file-to-file honouring of what this
909
+ * machine already told the service. The first target to adopt carries the id to the rest through
910
+ * `knownClientId`, which is what keeps every config on the machine pointing at one identity.
911
+ *
912
+ * Explicit intent and the state file win: `declaredClientId()` is checked first, the same precedence
913
+ * the register path applies. `clientIdHint` is set as well, because it only feeds the closing
914
+ * "这台机器的身份编号是 X" guidance — after this change the second run prints the identity instead
915
+ * of saying nothing, which is one of the consequences #3772 listed (the only remaining copy used to
916
+ * be buried inside a config file).
917
+ */
918
+ function adoptExistingClientId(text) {
919
+ if (declaredClientId()) return;
920
+ const found = text.match(CLIENT_ID_IN_CONFIG)?.[1] || '';
921
+ if (!CLIENT_ID_SHAPE.test(found)) return;
922
+ knownClientId = found;
923
+ clientIdHint = found;
924
+ }
925
+
492
926
  const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
493
927
 
494
928
  /**
@@ -503,6 +937,119 @@ function probeEndpoint() {
503
937
  return (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
504
938
  }
505
939
 
940
+ /**
941
+ * The endpoint this machine's MCP entries are supposed to point at.
942
+ *
943
+ * The record comes first: it is the fact about *this machine* — what the installer wrote here.
944
+ * The env var only says what the operator has in mind right now, and the two can differ without
945
+ * anything being wrong (a mirror installed once, then verified without the variable exported).
946
+ * Comparing against the env var alone turned a healthy machine into a NOT READY one — the same
947
+ * shape as the false alarm the CLI already refuses to raise about the endpoint probe.
948
+ */
949
+ function expectedEndpoint() {
950
+ const recorded = readText(join(stateDir(), STATE_ENDPOINT_FILE)).trim();
951
+ if (recorded) return recorded;
952
+ const fromEnv = (process.env.MISAKANET_ENDPOINT || '').trim();
953
+ if (fromEnv) return fromEnv;
954
+ return CANONICAL_ENDPOINT;
955
+ }
956
+
957
+ /**
958
+ * One verdict for every MCP entry this installer writes.
959
+ *
960
+ * "The entry exists" used to be the whole check, so an entry pointing at
961
+ * `https://BROKEN.invalid/mcp` was reported as `✓ MCP 已注册(https://BROKEN.invalid/mcp)` — a
962
+ * green tick next to a URL nobody can reach, and the broken URL printed verbatim after it. The
963
+ * entry has to point at *our* endpoint, not merely have a field with the right name in it.
964
+ */
965
+ function mcpEntryState(entry, urlField) {
966
+ if (!entry) return { state: 'missing' };
967
+ const url = entry[urlField];
968
+ if (!url) return { state: 'no-url-field' };
969
+ const expected = expectedEndpoint();
970
+ if (url !== expected) return { state: 'wrong-url', url, expected };
971
+ return { state: 'ok', url };
972
+ }
973
+
974
+ /**
975
+ * The `[mcp_servers.misakanet]` table in a Codex config, if it is there.
976
+ *
977
+ * The table header is the anchor, not our comment markers: the markers can be lost (a hand-edit,
978
+ * a merge conflict) and the entry is still the entry.
979
+ */
980
+ function codexMisakanetTable(text) {
981
+ const lines = text.split('\n');
982
+ let inTable = false;
983
+ let url = '';
984
+ for (const line of lines) {
985
+ const t = line.trim();
986
+ if (t.startsWith('[')) {
987
+ if (inTable) break; // the next table starts: ours ended
988
+ inTable = t === '[mcp_servers.misakanet]';
989
+ continue;
990
+ }
991
+ if (!inTable) continue;
992
+ const m = t.match(/^url\s*=\s*"([^"]*)"/);
993
+ if (m) url = m[1];
994
+ }
995
+ return inTable ? { url } : null;
996
+ }
997
+
998
+ /**
999
+ * The proxy environment, and whether *this* Node will actually use it.
1000
+ *
1001
+ * Node's `fetch` ignores `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` unless the runtime is told to
1002
+ * honour them — `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, with fetch support in v22.21.0 /
1003
+ * v24.0.0+ (nodejs.org/learn/http/enterprise-network-configuration). So in an enterprise-proxy
1004
+ * environment a failed probe is **not** evidence that the endpoint is down: `curl` and the user's
1005
+ * agent may both be reaching it through a proxy this process never saw. Reporting that as
1006
+ * "端点不可达(网络受限?)" sends a user whose install works off to debug a network that is fine —
1007
+ * and the audience for this installer includes exactly the people whose proxies cause this.
1008
+ *
1009
+ * Returns `null` when no proxy is configured (the ordinary path), otherwise the variables in play
1010
+ * and whether the runtime was told to use them.
1011
+ */
1012
+ function proxyEnvironment() {
1013
+ const vars = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']
1014
+ .filter((name) => (process.env[name] || '').trim());
1015
+ if (!vars.length) return null;
1016
+ const enabled = Boolean((process.env.NODE_USE_ENV_PROXY || '').trim())
1017
+ || process.execArgv.some((arg) => arg.includes('use-env-proxy'))
1018
+ || (process.env.NODE_OPTIONS || '').includes('use-env-proxy');
1019
+ return { vars, enabled, noProxy: (process.env.NO_PROXY || process.env.no_proxy || '').trim() };
1020
+ }
1021
+
1022
+ /** The one line that turns an inconclusive probe into a next action. */
1023
+ function proxyProbeAdvice(proxy) {
1024
+ return `本机配了代理(${proxy.vars.join('、')}),而 Node 的 fetch 默认不读这些变量 → 探测结果不可信,`
1025
+ + '不代表端点不可达。确认方式:先 `curl -sS https://misakanet.org/mcp` 手测,'
1026
+ + '或让 Node 走代理重跑:`NODE_USE_ENV_PROXY=1 npx @misaka-net/misakanet-setup --verify`'
1027
+ + '(fetch 需要 Node ≥ 22.21.0 / 24.0.0;公司代理做 TLS 拦截时再加 `NODE_USE_SYSTEM_CA=1`)';
1028
+ }
1029
+
1030
+ /**
1031
+ * How long one probe attempt may take.
1032
+ *
1033
+ * 6 s is the shipped value; `MISAKANET_PROBE_TIMEOUT_MS` exists so the test suite can prove the
1034
+ * timeout *classification* in a few hundred milliseconds instead of six seconds per attempt. It is
1035
+ * test plumbing, not a supported knob: a deployment that sets it is shortening the only honest
1036
+ * signal it has, and the cap below keeps it from turning `--verify` into a hang.
1037
+ */
1038
+ function probeTimeoutMs() {
1039
+ const raw = Number(process.env.MISAKANET_PROBE_TIMEOUT_MS || '');
1040
+ return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 60000) : 6000;
1041
+ }
1042
+
1043
+ /**
1044
+ * The probe's retries and the pause between them.
1045
+ *
1046
+ * Fixed numbers, never random: the same machine with the same network must produce the same
1047
+ * `endpoint-probe` value on two runs (#3778). A jittered backoff would make a timeout retry
1048
+ * succeed on one run and fail on the next, which is how "flaky" gets filed against a health check.
1049
+ */
1050
+ const PROBE_ATTEMPTS = 3; // one attempt + at most two retries, per the issue
1051
+ const PROBE_RETRY_DELAY_MS = 300;
1052
+
506
1053
  /**
507
1054
  * One unauthenticated MCP call.
508
1055
  *
@@ -514,6 +1061,16 @@ function probeEndpoint() {
514
1061
  // Records why the last request failed, so `--verify` can tell a timeout from a 404 from an
515
1062
  // empty-but-successful answer instead of collapsing all three into "unreachable".
516
1063
  let lastRequestError = '';
1064
+ // …and *which kind* of failure it was, which is what `endpoint-probe` reports: a timeout and a
1065
+ // refused connection deserve different verdicts (#3778). Reset per request, so a stale reason from
1066
+ // an earlier call (registration, a version lookup) can never be attributed to the probe.
1067
+ let lastRequestKind = '';
1068
+ // Whether HTTP answered at all. `200 {"hello":"world"}` parses fine and yields no `tools`, and the
1069
+ // old code called that "unreachable" — the endpoint did answer, it just does not speak MCP.
1070
+ let lastResponseOk = false;
1071
+ // The body it answered with, truncated: when the answer is not a handshake, the most useful thing
1072
+ // `--verify` can print is what actually came back (`{}` is not a diagnosis).
1073
+ let lastResponsePreview = '';
517
1074
 
518
1075
  async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
519
1076
  const headers = {
@@ -525,6 +1082,11 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
525
1082
  };
526
1083
  const controller = new AbortController();
527
1084
  const timer = setTimeout(() => controller.abort(), timeoutMs);
1085
+ // Per request, not per process: the probe must not inherit the reason an earlier call failed.
1086
+ lastRequestError = '';
1087
+ lastRequestKind = '';
1088
+ lastResponseOk = false;
1089
+ lastResponsePreview = '';
528
1090
  try {
529
1091
  const response = await fetch(urlOverride || ENDPOINT, {
530
1092
  method: 'POST',
@@ -532,7 +1094,18 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
532
1094
  signal: controller.signal,
533
1095
  body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
534
1096
  });
1097
+ // The exchange reached an HTTP peer. Whether that peer speaks MCP is decided by the body
1098
+ // below, not by this line — but "it answered with something that is not a handshake" and
1099
+ // "nothing answered" are different facts (see probeOutcome()).
1100
+ lastResponseOk = true;
535
1101
  const payload = await response.json();
1102
+ lastResponsePreview = JSON.stringify(payload).slice(0, 160);
1103
+ if (payload && payload.error) {
1104
+ // A JSON-RPC error is an answer from an MCP-speaking server, but the *call* failed. Record
1105
+ // its message so `--verify` can print the server's own words instead of "unreachable".
1106
+ lastRequestError = String(payload.error.message || JSON.stringify(payload.error)).slice(0, 200);
1107
+ lastRequestKind = 'json-rpc-error';
1108
+ }
536
1109
  const result = payload.result || {};
537
1110
  // Three response shapes are in play: `structuredContent` (this worker's tools/call),
538
1111
  // `content[0].text` (the MCP spec's text block), and plain fields (initialize, tools/list).
@@ -552,9 +1125,22 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
552
1125
  // result indistinguishable to every caller — `--verify` then reported a working endpoint as
553
1126
  // unreachable, which is the exact bug its own comment above describes. The shape of the return
554
1127
  // value is unchanged (callers depend on it); the reason is now recorded for the report.
555
- lastRequestError = (err && err.name === 'AbortError')
1128
+ //
1129
+ // The *kind* is recorded too, because it decides the verdict: `timeout` means "this machine
1130
+ // could not answer", `unreachable` means "the connection really failed" (#3778). `ETIMEDOUT`
1131
+ // is the socket-level spelling of the same thing, and undici wraps the real cause in
1132
+ // `err.cause` (`fetch failed` alone names nothing: the interesting words are `bad port`,
1133
+ // `connect ECONNREFUSED …`, `getaddrinfo EAI_AGAIN …`).
1134
+ const cause = err && err.cause;
1135
+ const code = String((cause && cause.code) || (err && err.code) || '');
1136
+ const detail = String((cause && (cause.message || cause.code)) || (err && err.message) || err);
1137
+ const timedOut = (err && (err.name === 'AbortError' || err.name === 'TimeoutError'))
1138
+ || code === 'ETIMEDOUT'
1139
+ || /timed? ?out|timeout|aborted/i.test(`${detail} ${(err && err.message) || ''}`);
1140
+ lastRequestKind = timedOut ? 'timeout' : 'unreachable';
1141
+ lastRequestError = timedOut
556
1142
  ? `timeout after ${timeoutMs}ms`
557
- : ((err && err.message) || String(err));
1143
+ : (code && !detail.includes(code) ? `${detail}(${code})` : detail);
558
1144
  return {};
559
1145
  } finally {
560
1146
  clearTimeout(timer);
@@ -566,6 +1152,65 @@ function mcpCall(tool, toolArgs, timeoutMs = 6000, urlOverride = '') {
566
1152
  return mcpRequest('tools/call', { name: tool, arguments: toolArgs }, timeoutMs, urlOverride);
567
1153
  }
568
1154
 
1155
+ /**
1156
+ * The endpoint probe: up to `PROBE_ATTEMPTS` `tools/list` calls, retrying only on a timeout.
1157
+ *
1158
+ * Why retry at all: one stall on a slow link is not evidence about the endpoint, and a health
1159
+ * check that flips on a single stall is not usable as a gate. Why *only* timeouts: a refused
1160
+ * connection or a DNS failure is an answer, not a hiccup — retrying it just makes `--verify`
1161
+ * slower. Why a fixed pause: see `PROBE_ATTEMPTS`.
1162
+ */
1163
+ async function probeHandshake() {
1164
+ const timeoutMs = probeTimeoutMs();
1165
+ let payload = {};
1166
+ let attempts = 0;
1167
+ while (attempts < PROBE_ATTEMPTS) {
1168
+ attempts += 1;
1169
+ payload = await mcpRequest('tools/list', {}, timeoutMs, probeEndpoint());
1170
+ if ((Array.isArray(payload?.tools) ? payload.tools : []).length) break;
1171
+ if (lastRequestKind !== 'timeout') break;
1172
+ if (attempts < PROBE_ATTEMPTS) {
1173
+ await new Promise((resolve) => setTimeout(resolve, PROBE_RETRY_DELAY_MS));
1174
+ }
1175
+ }
1176
+ return { payload, attempts, timeoutMs };
1177
+ }
1178
+
1179
+ /**
1180
+ * What the probe actually proved, as one of the values `endpoint-probe` reports.
1181
+ *
1182
+ * `ok` the handshake succeeded and the server listed tools
1183
+ * `timeout` no answer within the timeout, after retries — "could not verify", which is
1184
+ * not the same claim as "the endpoint is down"
1185
+ * `unreachable` a real connection failure (ECONNREFUSED / ENOTFOUND / EAI_AGAIN / TLS …)
1186
+ * `inconclusive-proxy` a proxy is configured that this Node cannot use, and nothing better is
1187
+ * known — the probe says nothing about the machine
1188
+ * `non-mcp` HTTP answered, but the exchange produced no MCP handshake (a non-MCP body,
1189
+ * a wrong URL, a gateway, or an MCP error answer)
1190
+ *
1191
+ * The order is the contract, not an accident. `timeout` is checked before the proxy because a
1192
+ * timeout is the better classification and must never be folded into the proxy story; the proxy is
1193
+ * checked before `unreachable` because a refused *direct* connection does not prove the endpoint is
1194
+ * down on a machine whose traffic is meant to go through a proxy this process cannot see. That
1195
+ * precedence is what keeps the proxy case at "could not verify" (2) instead of sending the user to
1196
+ * debug a network `curl` reaches fine (#3778).
1197
+ */
1198
+ function probeOutcome(payload) {
1199
+ const tools = Array.isArray(payload?.tools) ? payload.tools : [];
1200
+ if (tools.length) return { state: 'ok', tools: tools.length };
1201
+ // Before `lastResponseOk`: headers can arrive and the *body* can still hit the timeout (the
1202
+ // same AbortController covers both), which leaves `lastResponseOk` true while the call is a
1203
+ // timeout. Classifying that as `non-mcp` would name the wrong cause — measured on this repo's
1204
+ // own endpoint, where a slow body occasionally produced exactly that pair.
1205
+ if (lastRequestKind === 'timeout') return { state: 'timeout' };
1206
+ if (lastResponseOk || payload?.error || payload?.protocolVersion || payload?.serverInfo) {
1207
+ return { state: 'non-mcp' };
1208
+ }
1209
+ const proxy = proxyEnvironment();
1210
+ if (proxy && !proxy.enabled) return { state: 'inconclusive-proxy', proxy };
1211
+ return { state: 'unreachable' };
1212
+ }
1213
+
569
1214
  // Imperative first, and the trigger is line 1. Measured twice on real machines: the older
570
1215
  // polite paragraph was followed by one agent and ignored by the next, while a short numbered
571
1216
  // imperative list was followed immediately — a model that skips MCP tools entirely (reported
@@ -621,7 +1266,7 @@ const PROMPT_BLOCK = `调用规则(按顺序照做):
621
1266
  5) 查不到就 misakanet_submit_intake(kind="question")。
622
1267
  6) 约 20 轮后、或刚解决一个非平凡问题时:把本次「失败→根因→修复→验证」里可泛化、有判据、且搜过没有重复的部分,
623
1268
  脱敏后 misakanet_submit_intake(kind="missing_lesson") 提交;不够价值就不提交。
624
- 脱敏:密钥/凭据→<REDACTED>,人名/邮箱/真实域名/绝对家目录→泛化。全程不要打断用户任务。`;
1269
+ 脱敏:已由代码自动完成——本地钩子发出的查询在离开本机前、服务端入库前,都会把密钥/绝对路径/邮箱换成占位符;你不必手动改写,但仍不要粘整段会话转录或与故障无关的业务机密。全程不要打断用户任务。`;
625
1270
 
626
1271
  // ── the hook file: shipped in the package, downloaded only if missing ─
627
1272
  /**
@@ -771,7 +1416,10 @@ async function ensureIdentity() {
771
1416
  // export — an instruction a non-technical user cannot follow, and on Windows a different one.
772
1417
  const clientId = usable ? explicit : `setup-${randomUUID()}`;
773
1418
  knownClientId = clientId;
774
- const result = await mcpCall('misakanet_register', { agent_type: 'setup', client_id: clientId });
1419
+ const referral = declaredReferral();
1420
+ const result = await mcpCall('misakanet_register', {
1421
+ agent_type: 'setup', client_id: clientId, ...(referral ? { referral_code: referral } : {}),
1422
+ });
775
1423
  // Validate before persisting: a response body is not something to write to disk unchecked
776
1424
  // (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
777
1425
  // ours, but "trust the shape" is the correct habit and it makes the value failing to match
@@ -781,8 +1429,22 @@ async function ensureIdentity() {
781
1429
  // Say what it costs, not what went wrong internally: "凭据形状不对" is jargon. Reading is not
782
1430
  // what is lost here (it needs no token and is not metered) — the write path is, and saying so
783
1431
  // plainly is the difference between a user retrying and a user concluding the tool is broken.
784
- need('匿名注册没成功(多半是网络)→ 读课程不受影响,但写入类工具(write_lesson)现在用不了;'
785
- + '网络恢复后重跑本命令就能拿到 token');
1432
+ //
1433
+ // And say *whose* problem it is. This branch used to say "多半是网络" unconditionally, which on
1434
+ // 2026-10-18 was measurably wrong: the server answered
1435
+ // `{"code":"storage_unavailable","storage":{"token":true},"hint":"Retry shortly."}` because D1's
1436
+ // daily quota was spent, and the user was sent to inspect a network that was working. The
1437
+ // server already names the cause and the wait; the fix is to read it instead of guessing.
1438
+ const serverCode = typeof result?.code === 'string' ? result.code : '';
1439
+ const serverHint = typeof result?.hint === 'string' ? result.hint.trim() : '';
1440
+ const cause = serverCode === 'storage_unavailable'
1441
+ ? '服务端暂时存不了 token(**不是你的网络**)'
1442
+ : serverCode
1443
+ ? `服务端拒绝了这次注册(${serverCode})`
1444
+ : `多半是网络(${lastRequestError || '没有收到响应'})`;
1445
+ need(`匿名注册没成功:${cause} → 读课程不受影响,但写入类工具(write_lesson)现在用不了;`
1446
+ + '稍后重跑本命令就能拿到 token'
1447
+ + (serverHint ? `(服务端提示:${serverHint})` : ''));
786
1448
  return '';
787
1449
  }
788
1450
  // Created with mode 0600 rather than written and then chmodded: the old order left a window in
@@ -809,6 +1471,13 @@ function mcpEntryPresent(agent) {
809
1471
  hermes: () => readText(join(HOME, '.hermes', 'config.yaml')),
810
1472
  openclaw: () => readText(join(HOME, '.openclaw', 'openclaw.json')),
811
1473
  codewhale: () => readText(join(HOME, '.codewhale', 'mcp.json')),
1474
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
1475
+ .map(([id, spec]) => [id, () => readText(spec.config(HOME))])),
1476
+ // The manifest *is* the registry here, and the URL is part of the answer: a plugin pointing at
1477
+ // a dead mirror is not "registered" for the question this function asks (`install-scope`).
1478
+ mcode: () => (mcpEntryState(mcodePluginEntry(), 'url').state === 'ok'
1479
+ ? readText(mcodePluginPath(HOME))
1480
+ : ''),
812
1481
  }[agent]?.() || '';
813
1482
  return text.includes('misakanet') && text !== 'null';
814
1483
  }
@@ -820,14 +1489,22 @@ function detect(agent) {
820
1489
  hermes: ['.hermes'],
821
1490
  openclaw: ['.openclaw'],
822
1491
  codewhale: ['.codewhale'],
1492
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS).map(([id, spec]) => [id, spec.detect])),
1493
+ // Both names are in circulation: `~/.minimax` is the current one, `~/.minimax-code` the older
1494
+ // directory some installs still have (issue #3777). Either is evidence the CLI was here.
1495
+ mcode: ['.minimax', '.minimax-code'],
823
1496
  }[agent] || [];
824
1497
  return paths.some((p) => existsSync(join(HOME, p)));
825
1498
  }
826
1499
 
827
1500
  async function installClaude(hookPath, bearer) {
828
1501
  const cfg = join(HOME, '.claude.json');
1502
+ const existing = readText(cfg);
1503
+ // Before building the entry: a re-run must declare the id this file already carries, or the
1504
+ // comparison below sees a different entry and "deletes" the header (#3772).
1505
+ adoptExistingClientId(existing);
829
1506
  const data = readJson(cfg, null);
830
- if (data === null && readText(cfg)) {
1507
+ if (data === null && existing) {
831
1508
  need(`Claude Code:${cfg} 不是合法 JSON → 请手动加入 mcpServers.misakanet`);
832
1509
  return;
833
1510
  }
@@ -992,6 +1669,8 @@ function hasTopLevelKey(text, key) {
992
1669
  async function installCodex(hookPath, bearer) {
993
1670
  const cfg = join(HOME, '.codex', 'config.toml');
994
1671
  let text = readText(cfg);
1672
+ // Inherit before `codexTable()` rebuilds the inline table from mcpHeaders() (#3772).
1673
+ adoptExistingClientId(text);
995
1674
  let changed = false;
996
1675
 
997
1676
  const topPattern = new RegExp(`^[ \\t]*#\\s*${TOP_START}\\s*$\\n?[\\s\\S]*?^[ \\t]*#\\s*${TOP_END}\\s*$\\n?`, 'm');
@@ -1041,6 +1720,16 @@ async function installCodex(hookPath, bearer) {
1041
1720
  }
1042
1721
 
1043
1722
  const STATE_VERSION_FILE = 'version';
1723
+ /**
1724
+ * The endpoint this machine was pointed at, written at install time.
1725
+ *
1726
+ * `--verify` has to answer "does this machine point at *our* endpoint?", and on a self-hosted or
1727
+ * mirrored install that endpoint is not the canonical one. Without a record of what was written,
1728
+ * verifying without re-exporting `MISAKANET_ENDPOINT` would compare the mirror URL against the
1729
+ * canonical one and report a mismatch that is not there — so the record is what makes the check
1730
+ * honest instead of merely strict.
1731
+ */
1732
+ const STATE_ENDPOINT_FILE = 'endpoint';
1044
1733
 
1045
1734
  const REGISTRY_LATEST = process.env.MISAKANET_REGISTRY_URL
1046
1735
  || 'https://registry.npmjs.org/@misaka-net%2fmisakanet-setup/latest';
@@ -1055,6 +1744,24 @@ const REGISTRY_LATEST = process.env.MISAKANET_REGISTRY_URL
1055
1744
  * `installed_at` is only refreshed when the version actually changes, so a re-run of the same
1056
1745
  * version does not reset the 14-day timer the user is relying on for peace and quiet.
1057
1746
  */
1747
+ /**
1748
+ * Record the endpoint this install wrote, so `--verify` can tell "our endpoint" from "a URL".
1749
+ */
1750
+ function stampEndpoint() {
1751
+ const file = join(stateDir(), STATE_ENDPOINT_FILE);
1752
+ if (readText(file).trim() === ENDPOINT) {
1753
+ ok(`端点记录已存在(${ENDPOINT})`);
1754
+ return;
1755
+ }
1756
+ if (DRY) {
1757
+ ok(`会写入端点记录 → ${file}(${ENDPOINT})`);
1758
+ return;
1759
+ }
1760
+ mkdirSync(stateDir(), { recursive: true });
1761
+ writeText(file, `${ENDPOINT}\n`);
1762
+ ok(`端点记录 → ${file}(${ENDPOINT})`);
1763
+ }
1764
+
1058
1765
  function stampVersion() {
1059
1766
  const file = join(stateDir(), STATE_VERSION_FILE);
1060
1767
  const existing = readJson(file, null);
@@ -1149,6 +1856,9 @@ function openclawWorkspaces() {
1149
1856
  async function installHermes(hookPath, bearer) {
1150
1857
  const cfg = join(HOME, '.hermes', 'config.yaml');
1151
1858
  const text = readText(cfg);
1859
+ // The YAML block below is rebuilt from mcpHeaders() every run, so the id has to come from this
1860
+ // file first or re-running would drop the header (#3772).
1861
+ adoptExistingClientId(text);
1152
1862
  const rules = join(HOME, '.hermes', 'SOUL.md');
1153
1863
  if (!text) {
1154
1864
  need('Hermes:找不到 ~/.hermes/config.yaml → 先运行一次 hermes 再回来装');
@@ -1320,6 +2030,9 @@ async function installOpenclaw(bearer) {
1320
2030
  }
1321
2031
 
1322
2032
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
2033
+ const raw = readText(cfg);
2034
+ // Same inheritance as the other JSON targets: the entry is rebuilt from mcpHeaders() (#3772).
2035
+ adoptExistingClientId(raw);
1323
2036
  const data = readJson(cfg, null);
1324
2037
  if (data === null) {
1325
2038
  need(`OpenClaw:读不到或解析不了 ${cfg} → 先运行一次 openclaw 生成配置,`
@@ -1340,22 +2053,153 @@ async function installOpenclaw(bearer) {
1340
2053
  ok(`OpenClaw:注册 MCP(streamable-http)→ ${cfg}`);
1341
2054
  }
1342
2055
 
2056
+ /**
2057
+ * Install one MCP-only target: read its JSON, merge our entry under the vendor's own key path, write.
2058
+ *
2059
+ * File-to-file, never a subprocess, so no token ever reaches a command line. An existing file is
2060
+ * preserved (other servers, other keys) and backed up first; a file we cannot parse stops at a manual
2061
+ * step that names exactly what to paste, because guessing at a half-written config is how a user
2062
+ * loses the servers they already had.
2063
+ */
2064
+ async function installMcpOnly(agent, bearer) {
2065
+ const spec = MCP_ONLY_TARGETS[agent];
2066
+ const cfg = spec.config(HOME);
2067
+ const raw = readText(cfg);
2068
+ // All five MCP-only targets share this path; adopt before spec.entry() builds mcpHeaders() (#3772).
2069
+ adoptExistingClientId(raw);
2070
+ const data = readJson(cfg, null);
2071
+ if (data === null && raw) {
2072
+ need(`${spec.label}:${cfg} 不是合法 JSON${spec.parseNote ? `(${spec.parseNote})` : ''}`
2073
+ + ` → 请手动加入 ${spec.container}.misakanet(形状见 https://misakanet.org/mcp 的客户端文档)`);
2074
+ return;
2075
+ }
2076
+ const doc = data || {};
2077
+ doc[spec.container] = doc[spec.container] || {};
2078
+ const entry = spec.entry(bearer);
2079
+ if (sameJson(doc[spec.container].misakanet, entry)) {
2080
+ ok(`${spec.label}:MCP 已注册(无改动)`);
2081
+ } else {
2082
+ doc[spec.container].misakanet = entry;
2083
+ backup(cfg);
2084
+ writeText(cfg, `${JSON.stringify(doc, null, 2)}\n`);
2085
+ ok(`${spec.label}:注册 MCP → ${cfg}`);
2086
+ }
2087
+ skip(`${spec.label}:没有规则块与钩子(${spec.rules})→ ${spec.ruleHint}`);
2088
+ need(`${spec.label}:${spec.manual}`);
2089
+ }
2090
+
2091
+ /**
2092
+ * MiniMax Code: write the plugin manifest, then the rules block in its global `AGENTS.md`.
2093
+ *
2094
+ * Two rules from the comment on `MCODE_PLUGIN_REL` drive the whole function:
2095
+ *
2096
+ * * **the file may not be ours.** A plugin directory is a user-owned space, so an existing
2097
+ * manifest with a different `name` (or one that is not JSON) is *preserved* and reported as a
2098
+ * manual step — overwriting it would delete someone else's plugin to install ours.
2099
+ * * **`--mcp-only` is tier ①.** The manifest is the endpoint registration; `~/.minimax/AGENTS.md`
2100
+ * is a rules block (②), so it is skipped there — the same split every other target follows.
2101
+ *
2102
+ * Idempotent through `sameJson` (an editor that reorders keys is not a change) and through
2103
+ * `adoptExistingClientId`, so a re-run without `--client-id` keeps the header the file already
2104
+ * declares (#3772).
2105
+ */
2106
+ async function installMcode(bearer) {
2107
+ const plugin = mcodePluginPath(HOME);
2108
+ const existing = readText(plugin);
2109
+ // Before building the entry: a re-run must declare the id this manifest already carries.
2110
+ if (existing) adoptExistingClientId(existing);
2111
+ const wanted = mcodePluginDoc(bearer);
2112
+
2113
+ if (!existing) {
2114
+ writeText(plugin, `${JSON.stringify(wanted, null, 2)}\n`);
2115
+ ok(`MiniMax Code:写入插件清单 → ${plugin}`);
2116
+ } else {
2117
+ const current = readJson(plugin, null);
2118
+ if (current === null) {
2119
+ need(`MiniMax Code:${plugin} 不是合法 JSON → 已原样保留;请手动修正或删掉它,再重跑本命令`);
2120
+ return;
2121
+ }
2122
+ if (current?.name !== 'misakanet') {
2123
+ need(`MiniMax Code:${plugin} 已存在且不是本安装器写的(name=${JSON.stringify(current?.name ?? null)})`
2124
+ + ' → 已原样保留;要装 MisakaNet 请先把这个插件目录移走,再重跑本命令');
2125
+ return;
2126
+ }
2127
+ if (sameJson(current, wanted)) {
2128
+ ok('MiniMax Code:插件清单已是目标内容(无改动)');
2129
+ } else {
2130
+ backup(plugin);
2131
+ writeText(plugin, `${JSON.stringify(wanted, null, 2)}\n`);
2132
+ ok(`MiniMax Code:更新插件清单 → ${plugin}`);
2133
+ }
2134
+ }
2135
+
2136
+ const rules = join(HOME, MCODE_RULES_REL);
2137
+ if (!MCP_ONLY) ok(`MiniMax Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
2138
+ else skip('MiniMax Code:规则块未写(--mcp-only:只放插件清单)');
2139
+
2140
+ // Said out loud for the same reason Cursor's and Gemini's layers are: an install that looks
2141
+ // complete and has no behaviour layer is the failure this installer refuses to hide.
2142
+ skip('MiniMax Code:没有 Claude Code 那种钩子(mcode 没有 hook 机制)'
2143
+ + ' → 规则块在 ~/.minimax/AGENTS.md(每个会话生效),自动沉淀那一步做不到');
2144
+ need('MiniMax Code:重启后跑 `mcode plugin list` 复核(应显示 misakanet@local enabled);'
2145
+ + '公司代理下还要设 NODE_USE_ENV_PROXY=1 —— 它的 HTTP MCP 发现预算约 2000ms,'
2146
+ + '不设会表现为"装上了但 0 个工具"(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED '
2147
+ + '不是失败,插件目录在位即为生效)');
2148
+ }
2149
+
1343
2150
  // The last endpoint probe, recorded so `--report` can print it without probing twice.
1344
- let lastProbe = { reachable: false, tools: 0 };
2151
+ let lastProbe = { reachable: false, tools: 0, note: '' };
2152
+
2153
+ /**
2154
+ * The advice for a file we could not write.
2155
+ *
2156
+ * It has to be an advice that can work — and "re-run the install command" is not one: the file is
2157
+ * read-only or the permissions do not allow it, so a re-run writes exactly as much as the last one
2158
+ * did. Measured 2026-10-22 on an OpenClaw machine whose workspace was root-owned and read-only:
2159
+ * `--verify` said "规则块没装 → 重跑安装命令", and the trialer went after the network, the
2160
+ * permissions and the gateway while the installer had already named the real blocker.
2161
+ *
2162
+ * The question is asked the way the installer asks it before writing (`canCreateHere`): by
2163
+ * attempting the access, never by `existsSync` first.
2164
+ */
2165
+ function unwritableAdvice(manual = '') {
2166
+ const fix = '改权限(或换到可写的目录)后再重跑';
2167
+ return manual
2168
+ ? `只读或权限不足,重跑也写不进去 → ${manual}(或${fix})`
2169
+ : `只读或权限不足,重跑也写不进去 → ${fix}`;
2170
+ }
1345
2171
 
1346
2172
  async function verify() {
1347
2173
  let allOk = true;
2174
+ // The report answers two different questions, and one of them failing must not condemn the
2175
+ // other (issue #3696): "can you use it" (the endpoint answers) and "are the files we prefer
2176
+ // written" (our own footprint). A machine whose endpoint answers and whose workspace is
2177
+ // read-only is **usable** — and the old verdict called it NOT READY.
2178
+ let usable = true;
2179
+ // The probe has three answers, not two: 'ready' (a handshake), 'unverified' (timeout, or a
2180
+ // proxy this Node cannot use — the tool could not answer) and 'not-ready' (unreachable, or HTTP
2181
+ // answered without MCP — a real problem with the machine or the address). `unverified` is the
2182
+ // one #3778 added: it is not READY, and it is not a machine verdict either.
2183
+ let probeState = 'ready';
2184
+ // The install segment is judged on its own: a file we cannot write is a fact about our
2185
+ // footprint, and it says nothing about the endpoint.
2186
+ let installOk = true;
2187
+ const installFailed = () => { installOk = false; allOk = false; };
2188
+ // Where each segment starts, in the three line lists. The output prints the two segments as
2189
+ // two sections, and this boundary is the only thing that has to be right about them.
2190
+ const segmentStart = { done: done.length, manual: manual.length, skipped: skipped.length };
1348
2191
  // Handshake, not a search. `tools/list` proves the endpoint speaks MCP and answers, and it
1349
2192
  // consumes no anonymous read quota — so `--verify` can be run as often as the user likes. The
1350
2193
  // search used to be the probe, which spent one of the five free reads per run and, worse,
1351
2194
  // reported the rate-limit answer as "endpoint unreachable": the endpoint returns HTTP 200
1352
2195
  // with the error inside the JSON-RPC result, so a user who had used their quota was told their
1353
2196
  // network was broken (found 2026-09-15 by running the agent chain test on this machine).
1354
- const probe = await mcpRequest('tools/list', {}, 6000, probeEndpoint());
1355
- const tools = Array.isArray(probe?.tools) ? probe.tools : [];
1356
- lastProbe = { reachable: tools.length > 0, tools: tools.length };
1357
- if (tools.length) {
1358
- ok(`端点可达:${ENDPOINT}(MCP 握手成功,${tools.length} 个工具)`);
2197
+ const { payload: probe, attempts, timeoutMs } = await probeHandshake();
2198
+ const outcome = probeOutcome(probe);
2199
+ const tools = outcome.state === 'ok' ? outcome.tools : 0;
2200
+ lastProbe = { reachable: tools > 0, tools, note: outcome.state };
2201
+ if (lastProbe.reachable) {
2202
+ ok(`端点可达:${ENDPOINT}(MCP 握手成功,${tools} 个工具)`);
1359
2203
 
1360
2204
  // Surface the permission gap: an install that is otherwise perfect still fails the user's first
1361
2205
  // search if the host refuses the tool call, and that is invisible from the config files alone.
@@ -1364,18 +2208,65 @@ async function verify() {
1364
2208
  const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
1365
2209
  if (detect('claude') && missing.length) {
1366
2210
  allOk = false;
2211
+ // Not an install fact: the endpoint answers, and the search still fails. It belongs to
2212
+ // the usability segment, so it belongs there in the output too.
2213
+ usable = false;
1367
2214
  need(`Claude Code:只读 MCP 工具没有放行(${missing.length} 个)→ 第一次检索会被权限拦下;`
1368
2215
  + '重跑安装命令即可放行');
1369
2216
  }
1370
2217
  } catch { /* a settings file we cannot read is reported by the Claude checks below */ }
1371
- } else if (probe && (probe.error || probe.protocolVersion || probe.serverInfo)) {
2218
+ } else if (outcome.state === 'non-mcp') {
1372
2219
  allOk = false;
1373
- need(`端点可达但握手异常:${JSON.stringify(probe).slice(0, 120)}`);
2220
+ usable = false;
2221
+ probeState = 'not-ready';
2222
+ // What came back, not `{}`: `200 {"hello":"world"}` is the shape that used to be reported as
2223
+ // "unreachable" and send the reader after a network that was working the whole time.
2224
+ need(`端点可达但响应不是 MCP(${lastResponsePreview || JSON.stringify(probe).slice(0, 160)})`
2225
+ + ' → 地址指错了,或中间有网关/门户在应答;读课程会静默失败'
2226
+ + (lastRequestError ? `;最近一次失败原因:${lastRequestError}` : ''));
2227
+ } else if (outcome.state === 'timeout') {
2228
+ // Timeout is "could not verify", full stop. It is never folded into the proxy story (that is
2229
+ // a different doubt) and never into "unreachable" (that is a claim we cannot make): the exit
2230
+ // code for it is 2, and 2 is not a machine verdict (#3778).
2231
+ allOk = false;
2232
+ usable = false;
2233
+ probeState = 'unverified';
2234
+ need(`端点探测超时:${ENDPOINT}(${attempts} 次尝试,每次 ${timeoutMs}ms;`
2235
+ + `最近一次失败原因:${lastRequestError || `timeout after ${timeoutMs}ms`})`
2236
+ + ' → 这不说明机器坏了,是工具没能拿到答案;先确认网络/代理'
2237
+ + '(`curl -sS https://misakanet.org/mcp`),再重跑 --verify');
2238
+ } else if (outcome.state === 'inconclusive-proxy') {
2239
+ // Inconclusive rather than unreachable — and now a hard "could not verify" in every mode, not
2240
+ // only under `--strict`: the old interactive path exited 0 while printing "the probe is
2241
+ // untrustworthy", which is a READY a script cannot tell from a healthy machine (#3778).
2242
+ allOk = false;
2243
+ usable = false;
2244
+ probeState = 'unverified';
2245
+ need(`端点探测不可信(inconclusive-proxy —— 最近一次失败原因:`
2246
+ + `${lastRequestError || '没有收到响应'}):${proxyProbeAdvice(outcome.proxy)}`);
1374
2247
  } else {
1375
2248
  allOk = false;
2249
+ usable = false;
2250
+ probeState = 'not-ready';
1376
2251
  need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
1377
2252
  + (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
1378
2253
  }
2254
+ // The endpoint segment ends here. Everything below is about our own footprint: the files we
2255
+ // write, the entries we register, the token we keep.
2256
+ const segmentEnd = { done: done.length, manual: manual.length, skipped: skipped.length };
2257
+ // Zero targets (#3778). This is the honest answer to "is the install complete": there was
2258
+ // nothing to install into, so nothing was installed. The old code only ever called
2259
+ // `installFailed()` inside the per-agent checks, so with no agent at all not one line ran and
2260
+ // `install: complete` / `verify: READY` were reported for an untouched home — the exact
2261
+ // self-contradiction the issue was filed for (`detected-agents: []` next to
2262
+ // `install-complete: complete`). Judged on "no target in AGENTS is detected", not on one
2263
+ // hard-coded directory: any of the supported assistants in `AGENTS` counts.
2264
+ const detectedAgents = AGENTS.filter((agent) => detect(agent));
2265
+ if (!detectedAgents.length) {
2266
+ installFailed();
2267
+ need('没有检测到任何受支持的助手 —— 因此什么都没装'
2268
+ + `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
2269
+ }
1379
2270
  const tokenFile = join(stateDir(), 'token');
1380
2271
  let tokenPresent = '';
1381
2272
  try {
@@ -1386,43 +2277,72 @@ async function verify() {
1386
2277
  if (tokenPresent) {
1387
2278
  ok('写入通道:token 已就绪(write_lesson / preflight 可用)');
1388
2279
  } else {
1389
- skip('写入通道:无 token(读不限次数、只读完全可用,写入类工具不可用)');
1390
- }
1391
- const hookPath = join(stateDir(), 'hook.mjs');
1392
- let hookPresent = false;
1393
- try {
1394
- hookPresent = readFileSync(hookPath, 'utf8').length > 0;
1395
- } catch {
1396
- hookPresent = false;
1397
- }
1398
- if (!hookPresent) {
1399
- allOk = false;
1400
- need('自动沉淀钩子:缺失 → 重跑安装命令');
1401
- } else {
1402
- const settingsPath = join(HOME, '.claude', 'settings.json');
1403
- const settings = readJson(settingsPath, {}) || {};
1404
- // Only our own entries: a foreign hook that merely mentions "hook.mjs" is not evidence
1405
- // that this machine is installed (and its interpreter may not even be ours).
1406
- const commands = ourHookEntries(settings.hooks)
1407
- .flatMap((entry) => (entry.hooks || []).map((h) => h.command))
1408
- .filter((c) => typeof c === 'string');
1409
- if (!commands.length) {
1410
- allOk = false;
1411
- need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
2280
+ // Which token this is looking for, said out loud: it reads **our own** state directory, so a
2281
+ // machine that got its token through another channel (mcporter, a gateway) shows "no token"
2282
+ // here even though writes work. The trialer read that as "nothing is set up".
2283
+ skip('写入通道:无 token(这一项只检查本安装器状态目录里的 token;如果 token 来自别的通道,'
2284
+ + '读路径完全不受影响——读不限次数、只读完全可用,写入类工具不可用)');
2285
+ }
2286
+ // The hook is Claude Code's alone: it is the one target whose config carries it (Codex's
2287
+ // user-level hook shape is unconfirmed, OpenClaw's events are unverified), so a machine without
2288
+ // Claude Code must not be told its hook is missing. Until 2026-09-20 it was: a Codex-only or
2289
+ // Cursor-only install reported NOT READY with two Claude Code complaints about a target that was
2290
+ // never selected — the same lie the OpenClaw and Hermes checks below already refuse to tell
2291
+ // (found while adding Cursor, which is mcp-only by construction).
2292
+ if (detect('claude')) {
2293
+ const hookPath = join(stateDir(), 'hook.mjs');
2294
+ let hookPresent = false;
2295
+ try {
2296
+ hookPresent = readFileSync(hookPath, 'utf8').length > 0;
2297
+ } catch {
2298
+ hookPresent = false;
2299
+ }
2300
+ if (!hookPresent) {
2301
+ installFailed();
2302
+ need(canCreateHere(hookPath)
2303
+ ? '自动沉淀钩子:缺失 → 重跑安装命令'
2304
+ : `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
1412
2305
  } else {
1413
- const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
1414
- if (!existsSync(exe)) {
1415
- allOk = false;
1416
- need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`);
2306
+ const settingsPath = join(HOME, '.claude', 'settings.json');
2307
+ const settings = readJson(settingsPath, {}) || {};
2308
+ // Only our own entries: a foreign hook that merely mentions "hook.mjs" is not evidence
2309
+ // that this machine is installed (and its interpreter may not even be ours).
2310
+ const commands = ourHookEntries(settings.hooks)
2311
+ .flatMap((entry) => (entry.hooks || []).map((h) => h.command))
2312
+ .filter((c) => typeof c === 'string');
2313
+ if (!commands.length) {
2314
+ installFailed();
2315
+ need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
1417
2316
  } else {
1418
- ok('Claude Code:钩子已装且解释器存在');
2317
+ const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
2318
+ if (!existsSync(exe)) {
2319
+ installFailed();
2320
+ need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`);
2321
+ } else {
2322
+ ok('Claude Code:钩子已装且解释器存在');
2323
+ }
2324
+ }
2325
+ const cfg = join(HOME, '.claude.json');
2326
+ const data = readJson(cfg, {}) || {};
2327
+ const entry = data.mcpServers?.misakanet;
2328
+ if (!entry) {
2329
+ installFailed();
2330
+ need(canCreateHere(cfg)
2331
+ ? `Claude Code:MCP 未注册(${cfg})`
2332
+ : `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2333
+ } else {
2334
+ const verdict = mcpEntryState(entry, 'url');
2335
+ if (verdict.state === 'wrong-url') {
2336
+ installFailed();
2337
+ need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2338
+ } else if (verdict.state === 'no-url-field') {
2339
+ installFailed();
2340
+ need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2341
+ } else {
2342
+ ok(`Claude Code:MCP 已注册(${verdict.url})`);
2343
+ }
1419
2344
  }
1420
2345
  }
1421
- const cfg = join(HOME, '.claude.json');
1422
- const data = readJson(cfg, {}) || {};
1423
- const entry = data.mcpServers?.misakanet;
1424
- if (!entry) { allOk = false; need(`Claude Code:MCP 未注册(${cfg})`); }
1425
- else ok(`Claude Code:MCP 已注册(${entry.url})`);
1426
2346
  }
1427
2347
  // OpenClaw is only reported when the user actually has it: telling a machine without
1428
2348
  // OpenClaw that it is "not ready" would be a lie about a target that was never selected.
@@ -1431,17 +2351,23 @@ async function verify() {
1431
2351
  if (detect('openclaw')) {
1432
2352
  const rules = join(openclawWorkspaces()[0], 'AGENTS.md');
1433
2353
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
1434
- allOk = false;
1435
- need(`OpenClaw:规则块没装(${rules})→ 重跑安装命令`);
2354
+ installFailed();
2355
+ // "没装" and "装不了" are different facts with different fixes, and the difference is the
2356
+ // whole point (issue #3696): a read-only workspace is not something a re-run installs.
2357
+ need(canCreateHere(rules)
2358
+ ? `OpenClaw:规则块没装(${rules})→ 重跑安装命令`
2359
+ : `OpenClaw:规则块装不了(${rules})→ ${unwritableAdvice()}`);
1436
2360
  } else {
1437
2361
  ok('OpenClaw:规则块已装');
1438
2362
  }
1439
2363
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
1440
2364
  const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
1441
2365
  if (!entry) {
1442
- allOk = false;
1443
- need(`OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 `
1444
- + `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`);
2366
+ installFailed();
2367
+ const manual = `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`;
2368
+ need(canCreateHere(cfg)
2369
+ ? `OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 ${manual}`
2370
+ : `OpenClaw:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
1445
2371
  } else {
1446
2372
  ok(`OpenClaw:MCP 已注册(${entry.url || '?'})`);
1447
2373
  }
@@ -1452,17 +2378,21 @@ async function verify() {
1452
2378
  if (readText(join(HOME, '.hermes', 'config.yaml'))) {
1453
2379
  const rules = join(HOME, '.hermes', 'SOUL.md');
1454
2380
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
1455
- allOk = false;
1456
- need(`Hermes:规则块没装(${rules})→ 重跑安装命令`);
2381
+ installFailed();
2382
+ need(canCreateHere(rules)
2383
+ ? `Hermes:规则块没装(${rules})→ 重跑安装命令`
2384
+ : `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
1457
2385
  } else {
1458
2386
  ok('Hermes:规则块已装');
1459
2387
  }
1460
2388
  const cfg = join(HOME, '.hermes', 'config.yaml');
1461
2389
  const text = readText(cfg);
1462
2390
  if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
1463
- allOk = false;
1464
- need(`Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 `
1465
- + `hermes mcp add misakanet --url ${ENDPOINT} --auth header`);
2391
+ installFailed();
2392
+ const manual = `hermes mcp add misakanet --url ${ENDPOINT} --auth header`;
2393
+ need(canCreateHere(cfg)
2394
+ ? `Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 ${manual}`
2395
+ : `Hermes:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
1466
2396
  } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
1467
2397
  ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
1468
2398
  skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
@@ -1471,6 +2401,144 @@ async function verify() {
1471
2401
  skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
1472
2402
  }
1473
2403
  }
2404
+ // Codex and codewhale: reported only when the user has them, like every other target. They were
2405
+ // the two gaps in the integrity check (measured 2026-10-23): `--verify` had no row for them at
2406
+ // all, so a machine whose Codex entry pointed at `https://BROKEN.invalid/mcp` and whose rules
2407
+ // block had lost its markers still reported READY with exit code 0 — while `--report`'s
2408
+ // `detected-agents` listed codex and codewhale as present, and the gate let it through.
2409
+ if (detect('codex')) {
2410
+ const rules = join(HOME, '.codex', 'AGENTS.md');
2411
+ if (!readText(rules).includes(`<!-- ${START} -->`)) {
2412
+ installFailed();
2413
+ need(canCreateHere(rules)
2414
+ ? `Codex:规则块没装(${rules})→ 重跑安装命令`
2415
+ : `Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2416
+ } else {
2417
+ ok('Codex:规则块已装');
2418
+ }
2419
+ const cfg = join(HOME, '.codex', 'config.toml');
2420
+ const toml = readText(cfg);
2421
+ const table = codexMisakanetTable(toml);
2422
+ const verdict = mcpEntryState(table, 'url');
2423
+ if (verdict.state === 'missing') {
2424
+ installFailed();
2425
+ need(canCreateHere(cfg)
2426
+ ? `Codex:MCP 未注册(${cfg})→ 重跑安装命令`
2427
+ : `Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2428
+ } else if (verdict.state === 'wrong-url') {
2429
+ installFailed();
2430
+ need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2431
+ } else if (verdict.state === 'no-url-field') {
2432
+ installFailed();
2433
+ need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2434
+ } else {
2435
+ ok(`Codex:MCP 已注册(${verdict.url})`);
2436
+ }
2437
+ // The top-level key is what makes Codex speak streamable-http at all: without it the entry
2438
+ // above is present and still unusable.
2439
+ if (!hasTopLevelKey(toml, 'experimental_use_rmcp_client')) {
2440
+ installFailed();
2441
+ need(`Codex:config.toml 里没有顶级键 experimental_use_rmcp_client(${cfg})`
2442
+ + '→ Codex 连不上 streamable-http,重跑安装命令即可补上');
2443
+ }
2444
+ }
2445
+ if (detect('codewhale')) {
2446
+ const cfg = join(HOME, '.codewhale', 'mcp.json');
2447
+ const entry = (readJson(cfg, null) || {}).servers?.misakanet;
2448
+ const verdict = mcpEntryState(entry, 'url');
2449
+ if (verdict.state === 'missing') {
2450
+ installFailed();
2451
+ need(canCreateHere(cfg)
2452
+ ? `codewhale:MCP 未注册(${cfg})→ 重跑安装命令`
2453
+ : `codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2454
+ } else if (verdict.state === 'wrong-url') {
2455
+ installFailed();
2456
+ need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2457
+ } else if (verdict.state === 'no-url-field') {
2458
+ installFailed();
2459
+ need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2460
+ } else {
2461
+ ok(`codewhale:MCP 已注册(${verdict.url})`);
2462
+ }
2463
+ // Its rules blocks live in the projects it trusts, so that is where they are checked.
2464
+ for (const project of codewhaleProjects()) {
2465
+ const rules = join(project, 'AGENTS.md');
2466
+ if (!readText(rules).includes(`<!-- ${START} -->`)) {
2467
+ installFailed();
2468
+ need(`codewhale:规则块没装(${rules})→ 重跑安装命令`);
2469
+ } else {
2470
+ ok(`codewhale:规则块已装(${rules})`);
2471
+ }
2472
+ }
2473
+ }
2474
+ // The MCP-only targets are reported only when the user actually has them, like OpenClaw and
2475
+ // Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
2476
+ // the client has *loaded* that file cannot be confirmed from here, and the line says so.
2477
+ for (const [agent, spec] of Object.entries(MCP_ONLY_TARGETS)) {
2478
+ if (!detect(agent)) continue;
2479
+ const cfg = spec.config(HOME);
2480
+ const entry = (readJson(cfg, null) || {})[spec.container]?.misakanet;
2481
+ if (!entry) {
2482
+ installFailed();
2483
+ need(canCreateHere(cfg)
2484
+ ? `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`
2485
+ : `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2486
+ } else {
2487
+ const verdict = mcpEntryState(entry, spec.urlField);
2488
+ if (verdict.state === 'no-url-field') {
2489
+ installFailed();
2490
+ need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
2491
+ + `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`);
2492
+ } else if (verdict.state === 'wrong-url') {
2493
+ installFailed();
2494
+ need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2495
+ } else {
2496
+ ok(`${spec.label}:MCP 已注册(${verdict.url})`);
2497
+ skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`);
2498
+ }
2499
+ }
2500
+ }
2501
+ // MiniMax Code: a plugin directory + a rules file, so "installed" is two facts and both are
2502
+ // checked. The manifest has to be valid JSON with our entry at the right URL; a file that a
2503
+ // hand-edit turned into `{}` is exactly the state a re-run fixes. `mcode plugin add` refuses
2504
+ // local plugins on purpose (LOCAL_PLUGIN_INSTALL_UNSUPPORTED), so its exit code is not evidence
2505
+ // either way — the directory is, and that is what this reads.
2506
+ if (detect('mcode')) {
2507
+ const rules = join(HOME, MCODE_RULES_REL);
2508
+ if (!readText(rules).includes(`<!-- ${START} -->`)) {
2509
+ installFailed();
2510
+ need(canCreateHere(rules)
2511
+ ? `MiniMax Code:规则块没装(${rules})→ 重跑安装命令`
2512
+ : `MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2513
+ } else {
2514
+ ok('MiniMax Code:规则块已装');
2515
+ }
2516
+ const plugin = mcodePluginPath(HOME);
2517
+ const doc = readJson(plugin, null);
2518
+ if (doc === null) {
2519
+ installFailed();
2520
+ need(canCreateHere(plugin)
2521
+ ? `MiniMax Code:插件清单缺失或不是合法 JSON(${plugin})→ 重跑安装命令`
2522
+ : `MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
2523
+ } else {
2524
+ const verdict = mcpEntryState(doc.mcpServers?.misakanet, 'url');
2525
+ if (verdict.state === 'missing') {
2526
+ installFailed();
2527
+ need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→ 重跑安装命令`);
2528
+ } else if (verdict.state === 'no-url-field') {
2529
+ installFailed();
2530
+ need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→ 重跑安装命令`);
2531
+ } else if (verdict.state === 'wrong-url') {
2532
+ installFailed();
2533
+ need(`MiniMax Code:插件清单指向的端点不对(${verdict.url},应为 ${verdict.expected})(${plugin})`
2534
+ + '→ 重跑安装命令');
2535
+ } else {
2536
+ ok(`MiniMax Code:插件清单已注册(${verdict.url})`);
2537
+ skip('MiniMax Code:只能确认"插件目录在位",无法确认 mcode 是否已加载 → 跑 `mcode plugin list` 复核'
2538
+ + '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)');
2539
+ }
2540
+ }
2541
+ }
1474
2542
  // Version: what is installed, and whether the registry has moved on. Read-only here — the
1475
2543
  // hook owns the periodic nudge (it cannot be a network call in this process's hot path), and
1476
2544
  // `--verify` is an explicit user action, so one lookup is both cheap and expected.
@@ -1493,7 +2561,106 @@ async function verify() {
1493
2561
  + 'npx @misaka-net/misakanet-setup@latest');
1494
2562
  }
1495
2563
  }
1496
- return allOk;
2564
+ return {
2565
+ allOk,
2566
+ usable,
2567
+ probeState,
2568
+ // `true` only for the probe answers that are not a verdict about the machine (timeout, and a
2569
+ // proxy this Node cannot use). It is what turns the exit code from 1 into 2 (#3778).
2570
+ unverified: probeState === 'unverified',
2571
+ // A home with none of the supported targets: distinct from "detected, but our files are missing",
2572
+ // and the only case where `install-scope` is forced to `none` in the report.
2573
+ zeroTargets: !detectedAgents.length,
2574
+ install: installOk ? 'complete' : 'incomplete',
2575
+ // The install segment's open items, counted on their own: the report's `open-items` is every
2576
+ // manual item, this is the ones that belong to our footprint.
2577
+ openItems: manual.length - segmentEnd.manual,
2578
+ segments: { start: segmentStart, end: segmentEnd },
2579
+ };
2580
+ }
2581
+
2582
+ /**
2583
+ * The two verdicts, in words.
2584
+ *
2585
+ * They are two answers to two questions, and the point of #3696 is that they are printed as two:
2586
+ * a machine whose endpoint answers perfectly and whose workspace is read-only is **usable**, and
2587
+ * the old single verdict said NOT READY about it — the one word that sends the user to debug the
2588
+ * wrong thing.
2589
+ */
2590
+ function usabilityText(verdict) {
2591
+ if (verdict.probeState === 'unverified') {
2592
+ // The third answer (#3778): neither READY nor a machine verdict. Saying "NOT READY" here is
2593
+ // the false alarm the issue is about; saying "ready" is a lie a script cannot detect.
2594
+ return '没能验证 —— 探测超时或代理不可信(不等于端点不可达,也不等于机器不健康)';
2595
+ }
2596
+ if (verdict.probeState === 'not-ready') {
2597
+ // "unreachable" and "answered, but not MCP" are both not-ready and still different facts:
2598
+ // the second one means the address or something in front of it is wrong, and telling the
2599
+ // reader "端点不可达" about a host that just answered would send them to the wrong fix.
2600
+ return lastProbe.note === 'non-mcp'
2601
+ ? '不就绪 —— 端点有应答,但响应不是 MCP(地址指错,或中间有网关/门户)'
2602
+ : '不就绪 —— 端点不可达或握手异常';
2603
+ }
2604
+ if (!verdict.usable) return '不就绪 —— 端点可达,但只读工具没放行,第一次检索会被拦下';
2605
+ return '就绪 —— 端点可达,检索与读取可用';
2606
+ }
2607
+
2608
+ function installText(verdict) {
2609
+ if (verdict.zeroTargets) {
2610
+ return '不完整 —— 没有检测到任何受支持的助手,因此什么都没装';
2611
+ }
2612
+ if (verdict.install === 'complete') return '完整 —— 我们偏好的文件都写好了';
2613
+ return `不完整 —— ${verdict.openItems} 项需要手动处理(上面每条 ! 都给了成因和做法)`;
2614
+ }
2615
+
2616
+ /**
2617
+ * The three-code contract, in one place so `--verify` and `--report --strict` cannot disagree.
2618
+ *
2619
+ * 0 READY
2620
+ * 1 NOT READY — a machine problem (zero targets, our files missing, the endpoint really
2621
+ * unreachable, or an address that does not speak MCP)
2622
+ * 2 could not verify — the tool could not produce a machine verdict (a probe timeout, a proxy
2623
+ * this Node cannot use, or the report/CLI crashing outright)
2624
+ *
2625
+ * `2` outranks `1` only when *nothing else* is wrong: if the install segment is incomplete, that
2626
+ * is a real machine verdict regardless of what the probe managed to do, and it stays `1`. That is
2627
+ * why the check is `install === 'complete'` rather than `!allOk`.
2628
+ */
2629
+ function exitCodeFor(verdict) {
2630
+ if (verdict.allOk && verdict.openItems === 0) return 0;
2631
+ return (verdict.unverified && verdict.install === 'complete') ? 2 : 1;
2632
+ }
2633
+
2634
+ /**
2635
+ * The verify output: two sections, because the two questions have two answers.
2636
+ *
2637
+ * The old output was one list, and one list cannot say "the endpoint answered, the files did not
2638
+ * get written" — it can only say NOT READY, which is the sentence the OpenClaw trialer read as
2639
+ * "MisakaNet does not work here" (issue #3696).
2640
+ */
2641
+ function renderVerify(verdict) {
2642
+ const { start, end } = verdict.segments;
2643
+ const tail = { done: done.length, manual: manual.length, skipped: skipped.length };
2644
+ const section = (label, from, to) => {
2645
+ const out = [];
2646
+ const finished = done.slice(from.done, to.done);
2647
+ const open = manual.slice(from.manual, to.manual);
2648
+ const ignored = skipped.slice(from.skipped, to.skipped);
2649
+ // `--silent` drops the narration, never the errors (the same contract `render()` keeps): the
2650
+ // `!` lines are what a human has to act on, the `✓` and `·` lines are progress.
2651
+ if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l}`));
2652
+ if (open.length) out.push(...open.map((l) => ` ! ${l}`));
2653
+ if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l}`));
2654
+ if (!out.length) return out;
2655
+ return [`\n── ${label} ──`, ...out];
2656
+ };
2657
+ return [
2658
+ ...section('端点/可用性', start, end),
2659
+ ...section('安装完整性', end, tail),
2660
+ '',
2661
+ `可用性:${usabilityText(verdict)}`,
2662
+ `安装完整性:${installText(verdict)}`,
2663
+ ].join('\n');
1497
2664
  }
1498
2665
 
1499
2666
  /**
@@ -1550,7 +2717,8 @@ function restoreEmptiedFiles() {
1550
2717
  // JSON configs: prune containers that our own removal emptied. The file itself is never deleted —
1551
2718
  // it may have existed before us (an empty `~/.claude.json` is a legitimate user state).
1552
2719
  const jsonFiles = [join(HOME, '.claude.json'), join(HOME, '.claude', 'settings.json'),
1553
- join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json')];
2720
+ join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json'),
2721
+ ...Object.values(MCP_ONLY_TARGETS).map((spec) => spec.config(HOME))];
1554
2722
  for (const file of jsonFiles) {
1555
2723
  const data = readJson(file, null);
1556
2724
  if (!data || typeof data !== 'object') continue;
@@ -1587,7 +2755,7 @@ function restoreEmptiedFiles() {
1587
2755
  }
1588
2756
 
1589
2757
  function uninstall() {
1590
- for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md']) {
2758
+ for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md', '.minimax/AGENTS.md']) {
1591
2759
  if (stripBlock(join(HOME, rel))) ok(`移除规则块 → ${rel}`);
1592
2760
  }
1593
2761
  // Both candidates: a block may have been written before the workspace was read from config.
@@ -1617,6 +2785,26 @@ function uninstall() {
1617
2785
  writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
1618
2786
  ok(`移除 MCP 注册 → ${cfg}`);
1619
2787
  }
2788
+ for (const spec of Object.values(MCP_ONLY_TARGETS)) {
2789
+ const cfg = spec.config(HOME);
2790
+ const data = readJson(cfg, null);
2791
+ if (data?.[spec.container]?.misakanet) {
2792
+ delete data[spec.container].misakanet;
2793
+ backup(cfg);
2794
+ writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
2795
+ ok(`移除 MCP 注册 → ${cfg}`);
2796
+ }
2797
+ }
2798
+ // MiniMax Code's plugin directory is ours outright (the install creates it), so uninstall takes
2799
+ // the whole subtree back. Only *our* directory: `~/.minimax/plugins/` is the user's namespace and
2800
+ // stays even when it ends up empty — deleting it would remove a place their other plugins live.
2801
+ const mcodeDir = join(HOME, '.minimax', 'plugins', 'misakanet');
2802
+ if (existsSync(mcodeDir)) {
2803
+ try {
2804
+ if (!DRY) rmSync(mcodeDir, { recursive: true, force: true });
2805
+ ok(`移除插件目录 → ${mcodeDir}`);
2806
+ } catch { /* best effort */ }
2807
+ }
1620
2808
  const voiceDir = join(stateDir(), 'voice');
1621
2809
  if (existsSync(voiceDir)) {
1622
2810
  try {
@@ -1657,14 +2845,25 @@ function uninstall() {
1657
2845
  for (const [s, e] of [[START, END], [TOP_START, TOP_END]]) {
1658
2846
  text = text.replace(new RegExp(`^[ \\t]*#\\s*${s}\\s*$\\n?[\\s\\S]*?^[ \\t]*#\\s*${e}\\s*$\\n?`, 'm'), '');
1659
2847
  }
1660
- if (text && text !== before) {
2848
+ if (text !== before) {
2849
+ // `text &&` used to guard this write, and that guard is the bug: when our blocks were the
2850
+ // *only* thing in the file, removing them left an empty string, the guard said no, and the
2851
+ // file was left exactly as it was — so the MCP entry survived the uninstall while the output
2852
+ // said "已移除本安装器写入的内容(MCP 注册…)" (measured 2026-10-23). An empty file that is
2853
+ // ours to remove goes away with the blocks; the rule the rules-block path already follows.
1661
2854
  backup(toml);
1662
- writeText(toml, text);
1663
- ok(`移除 MCP 表 → ${toml}`);
2855
+ if (!text.trim()) {
2856
+ if (!DRY) rmSync(toml, { force: true });
2857
+ ok(`移除 MCP 表(文件里只剩我们的内容 → 一并删除)→ ${toml}`);
2858
+ } else {
2859
+ writeText(toml, text);
2860
+ ok(`移除 MCP 表 → ${toml}`);
2861
+ }
1664
2862
  }
1665
2863
  }
1666
2864
  if (readText(join(stateDir(), 'token')) || readText(join(stateDir(), 'hook.mjs'))
1667
- || readText(join(stateDir(), 'client_id')) || readText(join(stateDir(), STATE_VERSION_FILE))) {
2865
+ || readText(join(stateDir(), 'client_id')) || readText(join(stateDir(), STATE_VERSION_FILE))
2866
+ || readText(join(stateDir(), STATE_ENDPOINT_FILE))) {
1668
2867
  if (!DRY) rmSync(stateDir(), { recursive: true, force: true });
1669
2868
  ok(`删除状态目录 → ${stateDir()}`);
1670
2869
  }
@@ -1772,7 +2971,8 @@ function distroName() {
1772
2971
  * scalars (`true`, `[claude, codex]`, `""`); the JSON keeps their real types (boolean, array,
1773
2972
  * string), which is the whole point of a machine-readable form.
1774
2973
  */
1775
- function reportValues(allOk) {
2974
+ function reportValues(verdict) {
2975
+ const allOk = verdict.allOk;
1776
2976
  const agents = AGENTS.filter((agent) => detect(agent));
1777
2977
  const token = existsSync(join(stateDir(), 'token'));
1778
2978
  const permissions = (() => {
@@ -1792,16 +2992,32 @@ function reportValues(allOk) {
1792
2992
  ['node', process.version],
1793
2993
  ['detected-agents', agents],
1794
2994
  ['verify', allOk ? 'READY' : 'NOT READY'],
2995
+ // The verdict has two halves, and a machine that collects this report needs to be able to
2996
+ // tell them apart: `verify: NOT READY` because our files could not be written is a
2997
+ // different machine from `verify: NOT READY` because the endpoint does not answer
2998
+ // (issue #3696). `endpoint-reachable` and `endpoint-probe` below already say the endpoint's
2999
+ // half; this is ours.
3000
+ ['install-complete', verdict.install],
3001
+ ['endpoint-usable', verdict.usable ? 'yes' : 'no'],
1795
3002
  ['endpoint-reachable', lastProbe.reachable],
1796
3003
  ['endpoint-tools', lastProbe.tools],
3004
+ // The classification of the probe, and the field that says whether the boolean above means
3005
+ // anything (#3778). `ok` = handshake succeeded; `timeout` / `inconclusive-proxy` = the tool
3006
+ // could not answer (exit 2 — network or proxy, not a verdict about this machine);
3007
+ // `unreachable` / `non-mcp` = it did answer, and the answer is "this machine cannot read".
3008
+ ['endpoint-probe', lastProbe.note || 'ok'],
1797
3009
  ['token', token ? 'present' : 'absent'],
1798
3010
  ['permissions', permissions],
1799
3011
  ['hook', existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'],
1800
3012
  // `mcp-only` = the endpoint is registered but the hooks are not, which is a different machine
1801
- // from "nothing installed" and must not be counted as one (#1753's T1-lite rows).
1802
- ['install-scope', existsSync(join(stateDir(), 'hook.mjs'))
1803
- ? 'full'
1804
- : (AGENTS.some((agent) => detect(agent) && mcpEntryPresent(agent)) ? 'mcp-only' : 'none')],
3013
+ // from "nothing installed" and must not be counted as one (#1753's T1-lite rows). Zero
3014
+ // targets is always `none`, even if a stale state directory still holds a hook: with no agent
3015
+ // to consume it, nothing is installed (#3778).
3016
+ ['install-scope', agents.length === 0
3017
+ ? 'none'
3018
+ : (hookWired()
3019
+ ? 'full'
3020
+ : (agents.some((agent) => mcpEntryPresent(agent)) ? 'mcp-only' : 'none'))],
1805
3021
  ['voice', voiceStatus()],
1806
3022
  ['open-items', manual.length],
1807
3023
  // Redacted here, once, so neither encoder can print an unredacted message by accident.
@@ -1876,10 +3092,10 @@ function reportJson(values) {
1876
3092
  * encoding never changes the verdict.
1877
3093
  */
1878
3094
  async function report(asJson) {
1879
- const allOk = await verify();
1880
- const values = reportValues(allOk);
3095
+ const verdict = await verify();
3096
+ const values = reportValues(verdict);
1881
3097
  console.log(asJson ? reportJson(values) : reportYaml(values));
1882
- return { allOk, openItems: manual.length };
3098
+ return { ...verdict, openItems: manual.length };
1883
3099
  }
1884
3100
 
1885
3101
  // ── main ─────────────────────────────────────────────────────────────
@@ -1943,7 +3159,12 @@ if (mode === 'report') {
1943
3159
  // follow the verdict** — that breaks every existing use, and the
1944
3160
  // gated form below already exists for CI.
1945
3161
  // --report --strict (--ci) → 0 READY · 1 NOT READY (including `open-items > 0`) ·
1946
- // 2 the report could not be produced at all.
3162
+ // 2 could not verify (a probe timeout / a proxy this Node cannot
3163
+ // use) or the report could not be produced at all.
3164
+ //
3165
+ // The two meanings of 2 are one sentence: "the tool could not answer". The report still prints
3166
+ // first in the probe case — `endpoint-probe` says `timeout` / `inconclusive-proxy` — so CI has
3167
+ // the evidence and the code together (#3778). `--verify` uses the same three codes.
1947
3168
  //
1948
3169
  // `--report-json` is the same mode, same contracts, same codes, other encoding: the JSON is
1949
3170
  // printed even when the machine is NOT READY — that is precisely the state an MDM is collecting
@@ -1970,22 +3191,38 @@ if (mode === 'report') {
1970
3191
  process.exit(2);
1971
3192
  }
1972
3193
  if (!STRICT) process.exit(0);
1973
- const ready = verdict.allOk && verdict.openItems === 0;
1974
- if (!ready) {
3194
+ const code = exitCodeFor(verdict);
3195
+ if (code === 2) {
3196
+ // The report is already on stdout. This line says what the code means, because "2" here is
3197
+ // not "unhealthy" and not "the tool crashed": it is "the probe could not answer".
3198
+ console.error(`--strict:没能验证(endpoint-probe: ${lastProbe.note})→ 退出码 2;`
3199
+ + `上面的 ${REPORT_JSON ? 'JSON' : 'YAML'} 已经照常打印,这个码说的是网络/代理,不是机器坏了。`);
3200
+ } else if (code === 1) {
1975
3201
  console.error(`--strict:NOT READY(verify: ${verdict.allOk ? 'READY' : 'NOT READY'},`
1976
3202
  + `open-items: ${verdict.openItems})→ 退出码 1;上面的 ${REPORT_JSON ? 'JSON' : 'YAML'} 就是证据。`);
1977
3203
  }
1978
- process.exit(ready ? 0 : 1);
3204
+ process.exit(code);
1979
3205
  }
1980
3206
 
1981
3207
  if (mode === 'verify') {
1982
- const allOk = await verify();
1983
- say(render());
3208
+ const verdict = await verify();
3209
+ say(renderVerify(verdict));
1984
3210
  // Kept under `--silent`: this line is the verdict, not progress. (It is also the only thing a
1985
3211
  // silent `--verify` prints when the machine is healthy, which is what makes it usable in a
1986
3212
  // deployment log.)
1987
- console.log(`\n结论:${allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 ! 都给了修复动作'}`);
1988
- process.exit(allOk ? 0 : 1);
3213
+ //
3214
+ // Three conclusions for three answers (#3778): READY, NOT READY (a machine problem), and
3215
+ // "could not verify" — which used to exit 0 while saying the probe was untrustworthy, so a
3216
+ // script could not tell it from a healthy machine.
3217
+ const code = exitCodeFor(verdict);
3218
+ const conclusion = code === 0
3219
+ ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段`
3220
+ : code === 2
3221
+ ? '没能验证 —— 端点探测超时或代理不可信(看 endpoint-probe);这不是"机器不健康",'
3222
+ + '先解决网络/代理再重跑 --verify'
3223
+ : 'NOT READY —— 上面每条 ! 都给了成因和做法';
3224
+ console.log(`\n结论:${conclusion}`);
3225
+ process.exit(code);
1989
3226
  }
1990
3227
 
1991
3228
  const targets = (only.length ? only : AGENTS).filter((a) => {
@@ -2032,38 +3269,12 @@ if (LIST_WRITES) {
2032
3269
  // Every row carries its **tier**, which is the point: an operator approving "add an MCP server"
2033
3270
  // (①) is not approving "change how the agent behaves" (② rules block, ③ automatic hooks). Files
2034
3271
  // already exist → we append or update keys; missing → we create them.
2035
- // Compare base names, not separators: a `/settings.json$` regex silently classifies nothing on
2036
- // Windows (all three windows legs went red on the first CI run of this change), and a manifest
2037
- // that hides a tier is worse than no manifest.
2038
- const tierOf = (path) => {
2039
- const base = basename(path);
2040
- if (base === 'CLAUDE.md' || base === 'AGENTS.md' || base === 'SOUL.md') return 2;
2041
- if (path.includes('.misakanet-agent')) return 3; // our own state dir
2042
- // `settings.json` carries both the read-only grants (①) and the hooks (③); the caller adds the
2043
- // second row only when the hooks are in scope.
2044
- if (base === 'settings.json' && path.includes('.claude')) return 1;
2045
- return 1; // an MCP entry in the agent's config
2046
- };
2047
- const rows = new Map();
2048
- const add = (path, action, tier) => {
2049
- const key = `${tier}|${path}`;
2050
- if (!rows.has(key)) rows.set(key, { path: redact(path), action, tier });
2051
- };
2052
- for (const agent of AGENTS) {
2053
- if (!detect(agent)) continue;
2054
- for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
2055
- add(path, existsSync(path) ? 'append-or-update' : 'create', tierOf(path));
2056
- if (!MCP_ONLY && basename(path) === 'settings.json' && path.includes('.claude')) {
2057
- add(path, 'append-or-update(钩子)', 3);
2058
- }
2059
- }
2060
- }
2061
- add(join(stateDir(), 'token'), 'create (0600)', 1);
2062
- if (!MCP_ONLY) add(join(stateDir(), 'hook.mjs'), 'create', 3);
2063
-
3272
+ //
3273
+ // The rows come from the shared manifest builder, the same list the install preview prints: one
3274
+ // source of truth, so the manifest an operator approves and the preview a user reads cannot
3275
+ // disagree about what this command would write.
2064
3276
  const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 装钩子(每轮自动执行)' };
2065
- const list = [...rows.values()].filter((row) => (MCP_ONLY ? row.tier === 1 : true))
2066
- .sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
3277
+ const list = buildWriteManifest(targets);
2067
3278
  for (const row of list) {
2068
3279
  console.log(`tier${row.tier}\t${tiers[row.tier]}\t${row.path}\t${row.action}`);
2069
3280
  }
@@ -2072,19 +3283,49 @@ if (LIST_WRITES) {
2072
3283
  console.log('# --mcp-only:只申请 ①。规则块(②)与钩子(③)不会写入,'
2073
3284
  + '需要时由运营方另行批准后重跑不带 --mcp-only 的同一条命令。');
2074
3285
  } else {
2075
- console.log(`# 本次申请 ${shown.size} 级权限(${[...shown].sort().map((t) => tiers[t]).join(' · ')});`
2076
- + '只想批 ① 就加 --mcp-only。');
3286
+ // With no target detected the manifest is empty, and the old line printed `0 级权限()` — an
3287
+ // empty parenthesis where the answer should be (#3778). Say "none" and why.
3288
+ const asked = shown.size
3289
+ ? `${shown.size} 级权限(${[...shown].sort().map((t) => tiers[t]).join(' · ')})`
3290
+ : '0 级权限(无 —— 没有检测到任何受支持的助手,这次不会写任何文件)';
3291
+ console.log(`# 本次申请 ${asked};只想批 ① 就加 --mcp-only。`);
2077
3292
  }
2078
3293
  console.log('# 这次没有写任何东西。撤销:npx @misaka-net/misakanet-setup --uninstall');
2079
3294
  process.exit(0);
2080
3295
  }
2081
3296
 
3297
+ // The preview (#3690): the first thing a user sees about what this run is about to write, printed
3298
+ // **before** the first write — so a user who did not expect these files still sees them while
3299
+ // there is nothing to undo yet.
3300
+ //
3301
+ // `--silent` keeps its contract: a clean silent run is an empty log, and this summary is progress
3302
+ // narration, not an error line. `--list-writes` has already exited above with the machine-readable
3303
+ // version of the same list.
3304
+ if (!SILENT && targets.length) {
3305
+ const preview = writePreview(buildWriteManifest(targets));
3306
+ if (preview) console.log(preview);
3307
+ }
3308
+
2082
3309
  try {
2083
3310
  if (!targets.length) {
2084
- need('没检测到 Claude Code / Codex / Hermes 的配置目录 → 请先打开一次你要用的那个助手,再回来运行本命令');
3311
+ // The list comes from `AGENTS` (#3778): it used to name six assistants by hand while ten were
3312
+ // supported, so a Gemini/Copilot/OpenCode/Kiro user was told about a smaller product than the
3313
+ // one they had just run — and the `--verify` path said nothing at all.
3314
+ need('没有检测到任何受支持的助手 —— 因此什么都没装'
3315
+ + `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
2085
3316
  } else {
2086
- const hookPath = MCP_ONLY ? null : await installHook();
2087
- if (!MCP_ONLY) stampVersion();
3317
+ // Only install the hook when a target can actually consume it. Writing it for a Cursor-only or
3318
+ // Codex-only machine produced a file nothing would ever run, and made `--report` claim
3319
+ // `install-scope: full` for an install that registered an endpoint and nothing else.
3320
+ const hookPath = (MCP_ONLY || !targets.includes('claude')) ? null : await installHook();
3321
+ // Tier ③ only: the record lives in the state directory, and `--mcp-only` must not create it.
3322
+ // Without a record, `--verify` falls back to the env override and then to the canonical
3323
+ // endpoint — which is the older behaviour, and the only place a mirrored install can be
3324
+ // reported as broken (verify with the same MISAKANET_ENDPOINT it was installed with).
3325
+ if (!MCP_ONLY) {
3326
+ stampVersion();
3327
+ stampEndpoint();
3328
+ }
2088
3329
  const bearer = has('--no-register') ? '' : await ensureIdentity();
2089
3330
  for (const agent of targets) {
2090
3331
  // One target failing must not skip the rest: an uncaught throw used to abort the loop, so
@@ -2104,6 +3345,8 @@ try {
2104
3345
  else if (agent === 'hermes') await installHermes(hookPath, bearer);
2105
3346
  else if (agent === 'openclaw') await installOpenclaw(bearer);
2106
3347
  else if (agent === 'codewhale') await installCodewhale(bearer);
3348
+ else if (agent === 'mcode') await installMcode(bearer);
3349
+ else if (MCP_ONLY_TARGETS[agent]) await installMcpOnly(agent, bearer);
2107
3350
  installed += 1;
2108
3351
  } catch (err) {
2109
3352
  need(`${agent}:写入配置失败(${redact((err && err.message) || err)})`
@@ -2122,6 +3365,15 @@ try {
2122
3365
  // the exact failures in it on a dirty one — which is the point (#1784). The machine-readable form
2123
3366
  // of the same run is `--report-json`, not this text.
2124
3367
  say(render());
3368
+ if (!SILENT && !DRY) {
3369
+ // The number the reader actually wants: not "how many files are in scope" but "how many did
3370
+ // this run really change". Printed even when it is 0, which is the honest answer on an
3371
+ // already-installed machine and the one #3775 was filed about: the line used to appear only
3372
+ // when something was written, so "already installed" and "the installer never got this far"
3373
+ // looked identical. `--dry-run` keeps its own contract — it already says it would write
3374
+ // nothing, so a "0 files written" line under it would only restate the mode.
3375
+ console.log(`本次实际写入 ${writtenFiles.size} 个文件(上面的清单是"涉及哪些",这一行是"真的动了哪些")。`);
3376
+ }
2125
3377
  if (MCP_ONLY && !SILENT) {
2126
3378
  // The honest failure mode of tier ①, said where the user will read it: they are not installing a
2127
3379
  // behaviour. The rules block (②) and the first-turn announcement, which is injected by the hook