@misaka-net/misakanet-setup 0.5.5 → 0.6.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.
@@ -16,8 +16,8 @@
16
16
  * undone by the other.
17
17
  *
18
18
  * Three things, all required, or the install is theatre:
19
- * 1. the agent CAN call it → MCP server entry (with the token, so reads are not
20
- * metered by the anonymous 5/day limit)
19
+ * 1. the agent CAN call it → MCP server entry (with the token when we have one, plus the
20
+ * self-declared context hints; reads are unlimited either way)
21
21
  * 2. the agent KNOWS when to call → rules block in its own rules file
22
22
  * 3. the checkpoint FIRES → a hook, because "summarise every 20 turns" is dead
23
23
  * text: agents do not keep counters
@@ -39,7 +39,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFi
39
39
  // package.json since 0.4 and had never been executed on the version it names.
40
40
  import { randomUUID } from 'node:crypto';
41
41
  import { homedir } from 'node:os';
42
- import { delimiter, join, dirname, resolve } from 'node:path';
42
+ import { basename, delimiter, join, dirname, resolve } from 'node:path';
43
43
  import { fileURLToPath } from 'node:url';
44
44
 
45
45
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -78,6 +78,25 @@ const valueOf = (flag, dflt) => {
78
78
  };
79
79
  const only = valueOf('--only', '').split(',').map((s) => s.trim()).filter(Boolean);
80
80
  const DRY = has('--dry-run');
81
+ /**
82
+ * Permission tiers (2026-09-18).
83
+ *
84
+ * The installer does three different kinds of thing, and they do not need the same permission:
85
+ *
86
+ * ① register an MCP endpoint — the user's own agent config; the same privilege as adding
87
+ * any MCP server;
88
+ * ② write a rules block — changes the agent's behaviour ("search before retrying"),
89
+ * and for Hermes that means editing `~/.hermes/SOUL.md`, its
90
+ * *identity* file;
91
+ * ③ install hooks + state — acts automatically on every turn.
92
+ *
93
+ * Asking for all three in one command is why a machine with an approval process can only answer
94
+ * "no" (issue #1753, 2026-09-18): an operator willing to approve ① has no way to say it. `--mcp-only`
95
+ * is that way to say it — ① only, and the run says so in its own report instead of looking partial.
96
+ */
97
+ const MCP_ONLY = has('--mcp-only');
98
+ const LIST_WRITES = has('--list-writes');
99
+
81
100
  /**
82
101
  * `--silent` (issue #1784): the enterprise/MDM form. A GPO or Intune script pipes this program's
83
102
  * stdout into a log file, where the ✓/· narration is noise (and names local paths).
@@ -94,7 +113,8 @@ const SILENT = has('--silent');
94
113
  /** `--report-json`: the same report as JSON, and nothing else on stdout (#1784). */
95
114
  const REPORT_JSON = has('--report-json');
96
115
  const HOME = resolve(valueOf('--home', homedir()));
97
- const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale'];
116
+ const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
117
+ 'cursor', 'gemini', 'copilot', 'opencode', 'kiro'];
98
118
 
99
119
  const done = [];
100
120
  const manual = [];
@@ -127,8 +147,31 @@ function readText(path) {
127
147
  * (the value goes into the User-Agent header): js/file-access-to-http #268. The drift it was
128
148
  * meant to prevent is now caught in CI instead, by a test that binds this literal to the
129
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).
159
+ */
160
+ const VERSION = '0.6.0' // x-release-please-version
161
+
162
+ /**
163
+ * The shape of a client id, in one place.
164
+ *
165
+ * It gates two different things: what the endpoint accepts as `client_id` on registration, and — since
166
+ * 2026-09-19 — what may be written into an assistant's config as `X-MisakaNet-Client`. The second use
167
+ * is why the shape matters here and not only at the endpoint: that value can come from a file we found
168
+ * on disk, and it is interpolated into a TOML inline table, a YAML mapping and JSON. A string matching
169
+ * this cannot contain a quote, a newline or a `:`, so it cannot break any of the three.
130
170
  */
131
- const VERSION = '0.5.5'
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}$/
132
175
 
133
176
  // ── argument surface ─────────────────────────────────────────────────
134
177
  // Until 2026-09-17 no flag was validated at all: `--help` fell through to a *real install*
@@ -148,7 +191,9 @@ const FLAGS = [
148
191
  ['--uninstall', '移除本安装器写入的内容(保留 .misakanet.bak 备份)'],
149
192
  ['--upgrade', '与安装等价(覆盖安装即升级)'],
150
193
  ['--client-id <id>', '固定本机身份:同一个 id 永远拿回同一个 node(重装/换机也能延续)'],
151
- ['--no-register', '不注册匿名节点(不写 token,检索仍是 5 次/天/IP)'],
194
+ ['--mcp-only', '只注册 MCP 端点,不改 agent 行为(不写规则块、不装钩子)——权限分级用'],
195
+ ['--list-writes', '只打印会改哪些文件(文件 | 动作 | 撤销方式),不写任何东西'],
196
+ ['--no-register', '不注册匿名节点(不写 token;读不受影响,写入类工具不可用)'],
152
197
  ['--voice', '打开语音/桌面通知(默认关)'],
153
198
  ['--help, -h', '打印这份帮助并退出'],
154
199
  ['--version', '打印版本并退出'],
@@ -283,6 +328,91 @@ function writeText(path, text, { mode } = {}) {
283
328
  }
284
329
  }
285
330
 
331
+ /**
332
+ * The targets whose whole install is one MCP entry in one JSON file.
333
+ *
334
+ * Each entry shape is the one that target's own documentation shows, and they are **not**
335
+ * interchangeable: Gemini CLI's remote field is `httpUrl` (its `url` means SSE), OpenCode nests under
336
+ * `mcp` rather than `mcpServers` and wants `type: "remote"`, Copilot CLI wants `type: "http"`, and
337
+ * Cursor and Kiro use a bare `url`. A copied entry with the wrong key fails **silently** — no error,
338
+ * the server simply never appears — which is why each shape is pinned by tests instead of assumed.
339
+ *
340
+ * None of these five has a rules block or a hook *this installer can write*: their rules live in
341
+ * project-scoped files (`.cursor/rules/*.mdc`, `GEMINI.md`, `AGENTS.md`, `.github/copilot-instructions.md`,
342
+ * `.kiro/steering/*.md`) and we do not know where a user's projects are. So tier ① is the whole
343
+ * install for them, `--mcp-only` is an identity, and the output says "MCP only" out loud instead of
344
+ * implying behaviour changed. That is also why `--report` says `install-scope: mcp-only` on a machine
345
+ * whose only target is one of these.
346
+ */
347
+ const MCP_ONLY_TARGETS = {
348
+ cursor: {
349
+ label: 'Cursor',
350
+ detect: ['.cursor'],
351
+ config: (home) => join(home, '.cursor', 'mcp.json'),
352
+ container: 'mcpServers',
353
+ urlField: 'url',
354
+ entry: (bearer) => ({ url: ENDPOINT, headers: mcpHeaders(bearer, 'cursor') }),
355
+ rules: '.cursor/rules/*.mdc 是项目级的,安装器不知道你的项目在哪',
356
+ ruleHint: '想让 Cursor 主动去查,把 .cursor/rules/misakanet-failure-memory.mdc 放进项目',
357
+ manual: '重启 Cursor 后在 Settings → MCP 里确认能看见 misakanet(本安装器只写用户级 '
358
+ + '~/.cursor/mcp.json,项目级 .cursor/mcp.json 归你自己管)',
359
+ verifyHint: '在 Settings → MCP 里复核',
360
+ },
361
+ gemini: {
362
+ label: 'Gemini CLI',
363
+ detect: ['.gemini'],
364
+ config: (home) => join(home, '.gemini', 'settings.json'),
365
+ container: 'mcpServers',
366
+ // `httpUrl` is the HTTP-streaming endpoint; `url` would mean SSE. Documented property list:
367
+ // geminicli.com/docs/tools/mcp-server ("headers: Custom HTTP headers when using url or httpUrl").
368
+ urlField: 'httpUrl',
369
+ entry: (bearer) => ({ httpUrl: ENDPOINT, headers: mcpHeaders(bearer, 'gemini') }),
370
+ rules: '它读 GEMINI.md(全局 ~/.gemini/GEMINI.md 与项目树),而安装器不写别人的规则文件',
371
+ ruleHint: '想让 Gemini CLI 主动去查,把规则块加进项目的 GEMINI.md',
372
+ manual: '重启会话后用 `/mcp` 复核 misakanet 是否列出',
373
+ verifyHint: '用 /mcp 复核',
374
+ },
375
+ copilot: {
376
+ label: 'Copilot CLI',
377
+ detect: ['.copilot'],
378
+ config: (home) => join(home, '.copilot', 'mcp-config.json'),
379
+ container: 'mcpServers',
380
+ urlField: 'url',
381
+ entry: (bearer) => ({ type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'copilot') }),
382
+ rules: '它读 .github/copilot-instructions.md 与 AGENTS.md(项目级),安装器不写项目文件',
383
+ ruleHint: '想让 Copilot CLI 主动去查,把规则写进项目的 .github/copilot-instructions.md',
384
+ manual: '跑 `copilot mcp list` 复核(VS Code 里的 Copilot 是另一份配置,键名是 `servers`)',
385
+ verifyHint: '用 `copilot mcp list` 复核',
386
+ },
387
+ opencode: {
388
+ label: 'OpenCode',
389
+ detect: ['.config/opencode'],
390
+ config: (home) => join(home, '.config', 'opencode', 'opencode.json'),
391
+ container: 'mcp',
392
+ urlField: 'url',
393
+ entry: (bearer) => ({ type: 'remote', url: ENDPOINT, enabled: true,
394
+ headers: mcpHeaders(bearer, 'opencode') }),
395
+ rules: '它读 AGENTS.md(项目根与 ~/.config/opencode/AGENTS.md),安装器不写别人的规则文件',
396
+ ruleHint: '想让 OpenCode 主动去查,把规则块加进项目的 AGENTS.md',
397
+ manual: '重启 OpenCode 后看它的 MCP 列表;若你设了 XDG_CONFIG_HOME,它读的是 '
398
+ + '$XDG_CONFIG_HOME/opencode/opencode.json,把同样的条目贴过去即可',
399
+ verifyHint: '看 OpenCode 的 MCP 列表复核',
400
+ parseNote: 'opencode.jsonc 允许注释,若你用的正是它,本安装器解析不了',
401
+ },
402
+ kiro: {
403
+ label: 'Kiro',
404
+ detect: ['.kiro'],
405
+ config: (home) => join(home, '.kiro', 'settings', 'mcp.json'),
406
+ container: 'mcpServers',
407
+ urlField: 'url',
408
+ entry: (bearer) => ({ url: ENDPOINT, headers: mcpHeaders(bearer, 'kiro') }),
409
+ rules: '它读 .kiro/steering/*.md(steering 文件),安装器不写别人的规则文件',
410
+ ruleHint: '想让 Kiro 主动去查,把规则加进 .kiro/steering/',
411
+ manual: '重启 Kiro 后用它的 MCP 面板复核 misakanet(Kiro 还支持 disabled / autoApprove 字段)',
412
+ verifyHint: '用 Kiro 的 MCP 面板复核',
413
+ },
414
+ };
415
+
286
416
  /**
287
417
  * The files each assistant's install writes, so a permission problem can be reported *before*
288
418
  * anything is touched (and so `--dry-run` can say "I cannot change that" as well as "I would change
@@ -295,9 +425,111 @@ const AGENT_WRITE_PATHS = {
295
425
  codex: (home) => [join(home, '.codex', 'config.toml'), join(home, '.codex', 'AGENTS.md')],
296
426
  hermes: (home) => [join(home, '.hermes', 'config.yaml'), join(home, '.hermes', 'SOUL.md')],
297
427
  codewhale: (home) => [join(home, '.codewhale', 'mcp.json')],
428
+ // Every MCP-only target writes exactly one file, so the table above is the single source of truth
429
+ // for the write manifest, the detection paths and the report's scope line.
430
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
431
+ .map(([id, spec]) => [id, (home) => [spec.config(home)]])),
298
432
  openclaw: () => openclawWorkspaces().map((workspace) => join(workspace, 'AGENTS.md')),
299
433
  };
300
434
 
435
+ /**
436
+ * The write manifest: every file this run would touch, with the kind of write and the tier it
437
+ * belongs to.
438
+ *
439
+ * One builder, two readers: `--list-writes` prints it for an operator who has to approve it, and
440
+ * the install preview prints it for the user who is about to say yes (#3690). They cannot drift,
441
+ * because there is only one list — a preview that disagrees with the manifest is worse than no
442
+ * preview at all.
443
+ *
444
+ * The tiers are the permission tiers from the top of this file: ① registers an MCP endpoint,
445
+ * ② writes a rules block (that changes how the agent behaves), ③ installs hooks and state (that
446
+ * run on every turn). Under `--mcp-only` only ① is in scope, so the other two are not listed —
447
+ * the same rule the install itself follows.
448
+ */
449
+ function buildWriteManifest(agents) {
450
+ // Compare base names, not separators: a `/settings.json$` regex silently classifies nothing on
451
+ // Windows (all three windows legs went red on the first CI run of this change), and a manifest
452
+ // that hides a tier is worse than no manifest.
453
+ const classify = (path) => {
454
+ const base = basename(path);
455
+ if (base === 'CLAUDE.md' || base === 'AGENTS.md' || base === 'SOUL.md') return { tier: 2, kind: 'rules' };
456
+ if (path.includes('.misakanet-agent')) return { tier: 3, kind: 'state' };
457
+ // `settings.json` carries both the read-only grants (①) and the hooks (③); the hook row is
458
+ // added separately below, and only when the hooks are in scope.
459
+ if (base === 'settings.json' && path.includes('.claude')) return { tier: 1, kind: 'grants' };
460
+ return { tier: 1, kind: 'mcp' };
461
+ };
462
+ const rows = new Map();
463
+ const add = (path, action, tier, kind) => {
464
+ const key = `${tier}|${path}`;
465
+ if (!rows.has(key)) rows.set(key, { path: redact(path), action, tier, kind });
466
+ };
467
+ for (const agent of agents) {
468
+ if (!detect(agent)) continue;
469
+ for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
470
+ const { tier, kind } = classify(path);
471
+ add(path, existsSync(path) ? 'append-or-update' : 'create', tier, kind);
472
+ if (!MCP_ONLY && basename(path) === 'settings.json' && path.includes('.claude')) {
473
+ add(path, 'append-or-update(钩子)', 3, 'hook');
474
+ }
475
+ }
476
+ }
477
+ // The state dir is ours, but it is still written to disk — and only when there is something to
478
+ // install: with no target detected the run writes nothing at all.
479
+ if (agents.length) {
480
+ // `--no-register` registers no anonymous node, so no token is written; listing it anyway would
481
+ // make the manifest — and the preview's file count — promise a file that stays absent.
482
+ if (!has('--no-register')) add(join(stateDir(), 'token'), 'create (0600)', 1, 'token');
483
+ if (!MCP_ONLY) {
484
+ add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
485
+ add(join(stateDir(), STATE_VERSION_FILE), 'create', 3, 'version');
486
+ }
487
+ }
488
+ return [...rows.values()]
489
+ .filter((row) => (MCP_ONLY ? row.tier === 1 : true))
490
+ .sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
491
+ }
492
+
493
+ /**
494
+ * The preview: what this run is about to write, said *before* it writes it (#3690).
495
+ *
496
+ * A user who never opens a checkout has no way to know that an installer also edits the agent's
497
+ * rules file — and that file is the one that changes how the agent behaves. So the summary names
498
+ * the files, the kind of change, and the three ways out: look first (`--list-writes`), ask for the
499
+ * endpoint only (`--mcp-only`), or take it back (`--uninstall`).
500
+ *
501
+ * One line per file, one line of ways out, and every path redacted to `~` — the person reading
502
+ * this may never see a checkout, so nothing here may assume one.
503
+ */
504
+ function writePreview(rows) {
505
+ const files = new Map();
506
+ for (const row of rows) {
507
+ const file = files.get(row.path) || { path: row.path, tier: row.tier, kinds: [] };
508
+ file.tier = Math.min(file.tier, row.tier);
509
+ file.kinds.push(row.kind);
510
+ files.set(row.path, file);
511
+ }
512
+ const list = [...files.values()].sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
513
+ if (!list.length) return '';
514
+ const labels = {
515
+ mcp: 'MCP 端点',
516
+ grants: '只读工具放行',
517
+ rules: '规则块(会改变 agent 的行为)',
518
+ hook: '钩子',
519
+ token: 'MCP 凭据',
520
+ hookScript: '钩子脚本',
521
+ version: '版本戳',
522
+ state: '本机状态',
523
+ };
524
+ const width = Math.min(Math.max(...list.map((f) => f.path.length)) + 2, 32);
525
+ const pad = (text) => (text.length >= width ? `${text} ` : text.padEnd(width));
526
+ return [
527
+ `将修改 ${list.length} 个文件${DRY ? '(--dry-run:只是预览,不写任何东西)' : ''}:`,
528
+ ...list.map((f) => ` ${pad(f.path)}+ ${f.kinds.map((k) => labels[k]).join(' + ')}`),
529
+ '先看:--list-writes 不改行为:--mcp-only 撤销:--uninstall',
530
+ ].join('\n');
531
+ }
532
+
301
533
  /**
302
534
  * Which of these paths this process may not write.
303
535
  *
@@ -378,6 +610,19 @@ function ourHookEntries(hooks) {
378
610
  }
379
611
 
380
612
  /** Insert or refresh a marker-delimited block. Uses a function replacement (no $-escapes). */
613
+ /**
614
+ * Is tier ③ (the behaviour layer) actually *wired*, or merely present on disk?
615
+ *
616
+ * `hook.mjs` living in our own state directory proves nothing about the user's agents: the hook only
617
+ * runs where a config file points at it, and today that is Claude Code alone. `--report` used to
618
+ * answer `install-scope: full` from the file's existence, so a Cursor-only machine claimed the
619
+ * behaviour layer was installed. It is `full` only when Claude Code's settings carry our entries.
620
+ */
621
+ function hookWired() {
622
+ const settings = readJson(join(HOME, '.claude', 'settings.json'), null);
623
+ return ourHookEntries(settings?.hooks).length > 0;
624
+ }
625
+
381
626
  function injectBlock(path, block) {
382
627
  const existing = readText(path);
383
628
  const pattern = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
@@ -415,6 +660,58 @@ function stripBlock(path) {
415
660
 
416
661
  const stateDir = () => join(HOME, '.misakanet-agent');
417
662
 
663
+ /**
664
+ * The headers every MCP entry we write carries.
665
+ *
666
+ * Two kinds, and the difference matters: `Authorization` is a credential (only when we have a token),
667
+ * while the other four are **self-declared hints** the service records on its analytics row — the
668
+ * stable pseudonym `X-MisakaNet-Client` (so a caller's history stays together without an account), the
669
+ * assistant name, the platform, and the installer version that wrote this. The endpoint treats them as
670
+ * hints and never as identity, which is what makes it acceptable to write them automatically
671
+ * (see AGENTS.md §3.3).
672
+ *
673
+ * Written only for agents whose config format supports custom headers; `--mcp-only` gets them too,
674
+ * because "the tool is callable" is exactly what tier ① means.
675
+ */
676
+ function mcpHeaders(bearer, agent) {
677
+ const headers = {};
678
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
679
+ const clientId = declaredClientId();
680
+ if (clientId) headers['X-MisakaNet-Client'] = clientId;
681
+ headers['X-MisakaNet-Agent'] = agent;
682
+ headers['X-MisakaNet-Os'] = `${process.platform}/${process.arch}`.slice(0, 40);
683
+ headers['X-MisakaNet-Version'] = VERSION;
684
+ return headers;
685
+ }
686
+
687
+ /**
688
+ * The pseudonym to declare, or `''` when this machine has not said.
689
+ *
690
+ * Explicit intent beats a file we merely found, the same rule the register path applies. The file is
691
+ * `integrations/agent-autostart`'s (it keeps one there for the Python channel), so picking it up is
692
+ * continuity worth having — but it is data from outside this process on its way into three config
693
+ * syntaxes, so it is checked against `CLIENT_ID_SHAPE` first. A `client_id` that is not id-shaped is
694
+ * not a hint worth writing; it is noise that could close a quote or a mapping.
695
+ */
696
+ /**
697
+ * The referral code of the node that invited this one, or `''`.
698
+ *
699
+ * `python3 scripts/referral.py --apply=CODE` writes it to `<stateDir>/referral_code` — the same
700
+ * directory `client_id` and `token` live in, so every client on the machine can read it. Shape-checked
701
+ * for the same reason `client_id` is: it is a value from a file on its way into an API request, and the
702
+ * server will ignore anything that is not code-shaped, which would be a silent no-op (#1996).
703
+ */
704
+ function declaredReferral() {
705
+ const found = readText(join(stateDir(), 'referral_code')).trim();
706
+ return REFERRAL_SHAPE.test(found) ? found : '';
707
+ }
708
+
709
+ function declaredClientId() {
710
+ if (knownClientId) return knownClientId;
711
+ const found = readText(join(stateDir(), 'client_id')).trim();
712
+ return CLIENT_ID_SHAPE.test(found) ? found : '';
713
+ }
714
+
418
715
  const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
419
716
 
420
717
  /**
@@ -429,6 +726,38 @@ function probeEndpoint() {
429
726
  return (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
430
727
  }
431
728
 
729
+ /**
730
+ * The proxy environment, and whether *this* Node will actually use it.
731
+ *
732
+ * Node's `fetch` ignores `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` unless the runtime is told to
733
+ * honour them — `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, with fetch support in v22.21.0 /
734
+ * v24.0.0+ (nodejs.org/learn/http/enterprise-network-configuration). So in an enterprise-proxy
735
+ * environment a failed probe is **not** evidence that the endpoint is down: `curl` and the user's
736
+ * agent may both be reaching it through a proxy this process never saw. Reporting that as
737
+ * "端点不可达(网络受限?)" sends a user whose install works off to debug a network that is fine —
738
+ * and the audience for this installer includes exactly the people whose proxies cause this.
739
+ *
740
+ * Returns `null` when no proxy is configured (the ordinary path), otherwise the variables in play
741
+ * and whether the runtime was told to use them.
742
+ */
743
+ function proxyEnvironment() {
744
+ const vars = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']
745
+ .filter((name) => (process.env[name] || '').trim());
746
+ if (!vars.length) return null;
747
+ const enabled = Boolean((process.env.NODE_USE_ENV_PROXY || '').trim())
748
+ || process.execArgv.some((arg) => arg.includes('use-env-proxy'))
749
+ || (process.env.NODE_OPTIONS || '').includes('use-env-proxy');
750
+ return { vars, enabled, noProxy: (process.env.NO_PROXY || process.env.no_proxy || '').trim() };
751
+ }
752
+
753
+ /** The one line that turns an inconclusive probe into a next action. */
754
+ function proxyProbeAdvice(proxy) {
755
+ return `本机配了代理(${proxy.vars.join('、')}),而 Node 的 fetch 默认不读这些变量 → 探测结果不可信,`
756
+ + '不代表端点不可达。确认方式:先 `curl -sS https://misakanet.org/mcp` 手测,'
757
+ + '或让 Node 走代理重跑:`NODE_USE_ENV_PROXY=1 npx @misaka-net/misakanet-setup --verify`'
758
+ + '(fetch 需要 Node ≥ 22.21.0 / 24.0.0;公司代理做 TLS 拦截时再加 `NODE_USE_SYSTEM_CA=1`)';
759
+ }
760
+
432
761
  /**
433
762
  * One unauthenticated MCP call.
434
763
  *
@@ -547,7 +876,7 @@ const PROMPT_BLOCK = `调用规则(按顺序照做):
547
876
  5) 查不到就 misakanet_submit_intake(kind="question")。
548
877
  6) 约 20 轮后、或刚解决一个非平凡问题时:把本次「失败→根因→修复→验证」里可泛化、有判据、且搜过没有重复的部分,
549
878
  脱敏后 misakanet_submit_intake(kind="missing_lesson") 提交;不够价值就不提交。
550
- 脱敏:密钥/凭据→<REDACTED>,人名/邮箱/真实域名/绝对家目录→泛化。全程不要打断用户任务。`;
879
+ 脱敏:已由代码自动完成——本地钩子发出的查询在离开本机前、服务端入库前,都会把密钥/绝对路径/邮箱换成占位符;你不必手动改写,但仍不要粘整段会话转录或与故障无关的业务机密。全程不要打断用户任务。`;
551
880
 
552
881
  // ── the hook file: shipped in the package, downloaded only if missing ─
553
882
  /**
@@ -661,6 +990,16 @@ async function installHook() {
661
990
  }
662
991
 
663
992
  async function ensureIdentity() {
993
+ // The explicit intent is read first, because it decides the `X-MisakaNet-Client` hint on every run
994
+ // — including one that registers nothing (the token is already there) — so it cannot sit below the
995
+ // early returns. `--client-id` and `MISAKANET_CLIENT_ID` are the same intent.
996
+ const explicit = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
997
+ const usable = CLIENT_ID_SHAPE.test(explicit);
998
+ if (explicit && !usable) {
999
+ need('ignored --client-id / MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
1000
+ }
1001
+ if (usable) knownClientId = explicit;
1002
+
664
1003
  const file = join(stateDir(), 'token');
665
1004
  let existing = '';
666
1005
  try {
@@ -685,32 +1024,44 @@ async function ensureIdentity() {
685
1024
  // `--client-id` and `MISAKANET_CLIENT_ID` are the same explicit intent. The flag exists because
686
1025
  // the closing guidance told users to keep an identity and the only way to supply one was a shell
687
1026
  // export — an instruction a non-technical user cannot follow, and on Windows a different one.
688
- const exported = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
689
- if (exported && !/^[A-Za-z0-9._-]{8,64}$/.test(exported)) {
690
- need('ignored --client-id / MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
691
- }
692
- const clientId = (exported && /^[A-Za-z0-9._-]{8,64}$/.test(exported))
693
- ? exported
694
- : `setup-${randomUUID()}`;
695
- const result = await mcpCall('misakanet_register', { agent_type: 'setup', client_id: clientId });
1027
+ const clientId = usable ? explicit : `setup-${randomUUID()}`;
1028
+ knownClientId = clientId;
1029
+ const referral = declaredReferral();
1030
+ const result = await mcpCall('misakanet_register', {
1031
+ agent_type: 'setup', client_id: clientId, ...(referral ? { referral_code: referral } : {}),
1032
+ });
696
1033
  // Validate before persisting: a response body is not something to write to disk unchecked
697
1034
  // (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
698
1035
  // ours, but "trust the shape" is the correct habit and it makes the value failing to match
699
1036
  // a visible, debuggable outcome instead of a silent 401 later.
700
1037
  const token = typeof result?.token === 'string' ? result.token.trim() : '';
701
1038
  if (!/^mcp_[A-Za-z0-9_-]{20,}$/.test(token)) {
702
- // Say what it costs, not what went wrong internally: "凭据形状不对" is jargon, and
703
- // "读课程不受影响" was misleading — without a token the read path is capped at the anonymous
704
- // quota. Someone who does not know that will hit a wall five queries later and blame the tool.
705
- need('匿名注册没成功(多半是网络)→ 现在检索限额是每天 5 次/IP,写入类工具也用不了;'
706
- + '网络恢复后重跑本命令就能拿到 token,其它功能不受影响');
1039
+ // Say what it costs, not what went wrong internally: "凭据形状不对" is jargon. Reading is not
1040
+ // what is lost here (it needs no token and is not metered) — the write path is, and saying so
1041
+ // plainly is the difference between a user retrying and a user concluding the tool is broken.
1042
+ //
1043
+ // And say *whose* problem it is. This branch used to say "多半是网络" unconditionally, which on
1044
+ // 2026-10-18 was measurably wrong: the server answered
1045
+ // `{"code":"storage_unavailable","storage":{"token":true},"hint":"Retry shortly."}` because D1's
1046
+ // daily quota was spent, and the user was sent to inspect a network that was working. The
1047
+ // server already names the cause and the wait; the fix is to read it instead of guessing.
1048
+ const serverCode = typeof result?.code === 'string' ? result.code : '';
1049
+ const serverHint = typeof result?.hint === 'string' ? result.hint.trim() : '';
1050
+ const cause = serverCode === 'storage_unavailable'
1051
+ ? '服务端暂时存不了 token(**不是你的网络**)'
1052
+ : serverCode
1053
+ ? `服务端拒绝了这次注册(${serverCode})`
1054
+ : `多半是网络(${lastRequestError || '没有收到响应'})`;
1055
+ need(`匿名注册没成功:${cause} → 读课程不受影响,但写入类工具(write_lesson)现在用不了;`
1056
+ + '稍后重跑本命令就能拿到 token'
1057
+ + (serverHint ? `(服务端提示:${serverHint})` : ''));
707
1058
  return '';
708
1059
  }
709
1060
  // Created with mode 0600 rather than written and then chmodded: the old order left a window in
710
1061
  // which the token sat in a world-readable file on every single run.
711
1062
  writeText(file, token, { mode: 0o600 });
712
1063
  ok(`匿名身份:${String(result.node_id || '?').slice(0, 32)}(token 存 ${file},权限 600)`);
713
- if (!exported) {
1064
+ if (!usable) {
714
1065
  // Printed, not stored: this process must not turn a file it found into request data
715
1066
  // (CodeQL js/file-access-to-http #268), and the value is the user's to keep anyway.
716
1067
  // Kept out of the "done" list on purpose — it is something to *keep*, not something that
@@ -722,6 +1073,20 @@ async function ensureIdentity() {
722
1073
  }
723
1074
 
724
1075
  // ── per-agent install ────────────────────────────────────────────────
1076
+ /** Is our MCP entry already in this agent's config? (read-only; used by the report's scope line) */
1077
+ function mcpEntryPresent(agent) {
1078
+ const text = {
1079
+ claude: () => JSON.stringify(readJson(join(HOME, '.claude.json'), {})?.mcpServers?.misakanet || null),
1080
+ codex: () => readText(join(HOME, '.codex', 'config.toml')),
1081
+ hermes: () => readText(join(HOME, '.hermes', 'config.yaml')),
1082
+ openclaw: () => readText(join(HOME, '.openclaw', 'openclaw.json')),
1083
+ codewhale: () => readText(join(HOME, '.codewhale', 'mcp.json')),
1084
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
1085
+ .map(([id, spec]) => [id, () => readText(spec.config(HOME))])),
1086
+ }[agent]?.() || '';
1087
+ return text.includes('misakanet') && text !== 'null';
1088
+ }
1089
+
725
1090
  function detect(agent) {
726
1091
  const paths = {
727
1092
  claude: ['.claude.json', '.claude'],
@@ -729,6 +1094,7 @@ function detect(agent) {
729
1094
  hermes: ['.hermes'],
730
1095
  openclaw: ['.openclaw'],
731
1096
  codewhale: ['.codewhale'],
1097
+ ...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS).map(([id, spec]) => [id, spec.detect])),
732
1098
  }[agent] || [];
733
1099
  return paths.some((p) => existsSync(join(HOME, p)));
734
1100
  }
@@ -742,8 +1108,7 @@ async function installClaude(hookPath, bearer) {
742
1108
  }
743
1109
  const doc = data || {};
744
1110
  doc.mcpServers = doc.mcpServers || {};
745
- const entry = { type: 'http', url: ENDPOINT };
746
- if (bearer) entry.headers = { Authorization: `Bearer ${bearer}` };
1111
+ const entry = { type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'claude-code') };
747
1112
  if (sameJson(doc.mcpServers.misakanet, entry)) {
748
1113
  ok('Claude Code:MCP 已注册(无改动)');
749
1114
  } else {
@@ -754,8 +1119,30 @@ async function installClaude(hookPath, bearer) {
754
1119
  }
755
1120
 
756
1121
  const rules = join(HOME, '.claude', 'CLAUDE.md');
757
- ok(`Claude Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
758
-
1122
+ if (!MCP_ONLY) ok(`Claude Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1123
+ else skip('Claude Code:规则块未写(--mcp-only:只注册端点)');
1124
+
1125
+ if (MCP_ONLY) {
1126
+ // Tier ① = "the tool can be called", and that includes the read-only grants: the comment below
1127
+ // says it plainly — without them the first search is *denied*, so an operator who approved
1128
+ // "add an MCP server" would be shipping a tool their users cannot use. The grants are therefore
1129
+ // tier ①. What tier ③ adds is the hooks, and those are not written here.
1130
+ const settingsPath = join(HOME, '.claude', 'settings.json');
1131
+ const settings = readJson(settingsPath, {}) || {};
1132
+ const allow = ((settings.permissions = settings.permissions || {}).allow =
1133
+ Array.isArray(settings.permissions.allow) ? settings.permissions.allow : []);
1134
+ let changed = false;
1135
+ for (const tool of CLAUDE_ALLOWED_TOOLS) {
1136
+ if (!allow.includes(tool)) { allow.push(tool); changed = true; }
1137
+ }
1138
+ if (changed) {
1139
+ backup(settingsPath);
1140
+ writeText(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
1141
+ }
1142
+ ok(`Claude Code:只读工具已放行(${CLAUDE_ALLOWED_TOOLS.length} 个${changed ? '' : ',无改动'})`);
1143
+ skip('Claude Code:自动沉淀钩子未装(--mcp-only:只注册端点与工具放行)');
1144
+ return;
1145
+ }
759
1146
  if (!hookPath) {
760
1147
  need('Claude Code:跳过了"自动沉淀"钩子(钩子文件没取到)');
761
1148
  return;
@@ -852,12 +1239,17 @@ async function installClaude(hookPath, bearer) {
852
1239
 
853
1240
  const codexTable = (bearer) => {
854
1241
  const lines = ['[mcp_servers.misakanet]', 'type = "streamable-http"', `url = "${ENDPOINT}"`];
855
- if (bearer) {
856
- // http_headers, not bearer_token_env_var: the env var needs the user to export it, and
857
- // this user will not.
858
- lines.push(`http_headers = { Authorization = "Bearer ${bearer}" }`);
859
- } else {
860
- lines.push('# 没有 token:读走匿名通道(5/天/IP)');
1242
+ // http_headers, not bearer_token_env_var: the env var needs the user to export it, and this user
1243
+ // will not. All the headers go into the ONE inline table — a second `http_headers` line is a
1244
+ // duplicate key, and TOML refuses the whole file for it. (Bare TOML keys may contain `-`, which is
1245
+ // why the hint names need no quoting.)
1246
+ const headers = Object.entries(mcpHeaders(bearer, 'codex'))
1247
+ .map(([key, value]) => `${key} = "${value}"`).join(', ');
1248
+ lines.push(`http_headers = { ${headers} }`);
1249
+ if (!bearer) {
1250
+ // Only worth saying when it is true. Since 2026-09-18 anonymous reads are not metered — what a
1251
+ // token buys is the write path, not more reads.
1252
+ lines.push('# 没有 token:读不限次数,写入类工具(write_lesson)不可用');
861
1253
  }
862
1254
  return `${lines.join('\n')}\n`;
863
1255
  };
@@ -909,7 +1301,8 @@ async function installCodex(hookPath, bearer) {
909
1301
  }
910
1302
 
911
1303
  const rules = join(HOME, '.codex', 'AGENTS.md');
912
- ok(`Codex:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1304
+ if (!MCP_ONLY) ok(`Codex:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1305
+ else skip('Codex:规则块未写(--mcp-only:只注册端点)');
913
1306
  // Verified on codex-cli 0.154.0 (2026-09-15), so this is no longer a "could not
914
1307
  // confirm" note: `codex mcp list` shows misakanet enabled with the Bearer token,
915
1308
  // `codex doctor` reports config.toml parse ok + 1 streamable_http server + 0
@@ -1036,11 +1429,18 @@ async function installHermes(hookPath, bearer) {
1036
1429
  need('Hermes:找不到 ~/.hermes/config.yaml → 先运行一次 hermes 再回来装');
1037
1430
  return;
1038
1431
  }
1039
- ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1432
+ if (!MCP_ONLY) ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1433
+ else skip('Hermes:规则块未写(--mcp-only:只注册端点;SOUL.md 是它的人格文件)');
1040
1434
 
1041
1435
  const envKey = HERMES_ENV_KEY;
1042
1436
  const lines = [` misakanet: ${YAML_START}`, ` url: ${ENDPOINT}`];
1043
- if (bearer) lines.push(' headers:', ` Authorization: Bearer \${${envKey}}`);
1437
+ lines.push(' headers:');
1438
+ if (bearer) lines.push(` Authorization: Bearer \${${envKey}}`);
1439
+ // Hermes reads its headers from YAML, so these are literals rather than env templates: they are
1440
+ // hints, not secrets (a token would have to go through `.env` like the Authorization line above).
1441
+ for (const [key, value] of Object.entries(mcpHeaders('', 'hermes'))) {
1442
+ lines.push(` ${key}: ${value}`);
1443
+ }
1044
1444
  lines.push(` ${YAML_END}`);
1045
1445
  // No trailing newline: the three call sites below each own the one they need, and the
1046
1446
  // "insert after mcp_servers:" case must NOT add one (the line it replaces already ends with
@@ -1068,7 +1468,7 @@ async function installHermes(hookPath, bearer) {
1068
1468
  }
1069
1469
 
1070
1470
  if (!bearer) {
1071
- skip('Hermes:这次没有 token → 走匿名通道(5/天/IP),写入类工具不可用');
1471
+ skip('Hermes:这次没有 token → 读仍然不限次数,写入类工具不可用');
1072
1472
  return;
1073
1473
  }
1074
1474
  const envPath = join(HOME, '.hermes', '.env');
@@ -1117,6 +1517,11 @@ async function installHermes(hookPath, bearer) {
1117
1517
  * The token is the one thing codewhale will not take inline: `bearer_token_env_var` names
1118
1518
  * an environment variable, so "install once" for this agent ends with the user exporting
1119
1519
  * `MISAKANET_TOKEN` once. That is stated rather than silently half-installed.
1520
+ *
1521
+ * This is also the one agent that gets no `X-MisakaNet-*` hints: its server entry has a fixed
1522
+ * field set (`command`/`args`/`env`/`url`/timeouts/`bearer_token_env_var`) with no custom-header
1523
+ * field, and inventing one would risk the file being rejected outright. Hints are optional by
1524
+ * design — a call without them is still a normal anonymous read.
1120
1525
  */
1121
1526
  function codewhaleProjects() {
1122
1527
  const text = readText(join(HOME, '.codewhale', 'config.toml'));
@@ -1166,7 +1571,9 @@ async function installCodewhale(bearer) {
1166
1571
  const projects = codewhaleProjects();
1167
1572
  for (const project of projects) {
1168
1573
  if (!existsSync(project)) continue;
1169
- ok(`codewhale:规则块 ${injectBlock(join(project, 'AGENTS.md'), PROMPT_BLOCK)} → ${join(project, 'AGENTS.md')}`);
1574
+ if (!MCP_ONLY) {
1575
+ ok(`codewhale:规则块 ${injectBlock(join(project, 'AGENTS.md'), PROMPT_BLOCK)} → ${join(project, 'AGENTS.md')}`);
1576
+ } else skip('codewhale:规则块未写(--mcp-only)');
1170
1577
  }
1171
1578
  if (!projects.length) {
1172
1579
  need('codewhale:没有受信任的项目目录(config.toml 里没有 trust_level = "trusted")→ '
@@ -1183,7 +1590,8 @@ async function installOpenclaw(bearer) {
1183
1590
  if (!existsSync(workspace)) {
1184
1591
  need(`OpenClaw:找不到 workspace(${workspace})→ 先运行一次 openclaw 生成它`);
1185
1592
  } else {
1186
- ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1593
+ if (!MCP_ONLY) ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1594
+ else skip('OpenClaw:规则块未写(--mcp-only)');
1187
1595
  }
1188
1596
 
1189
1597
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
@@ -1193,8 +1601,8 @@ async function installOpenclaw(bearer) {
1193
1601
  + `或手动执行 \`${manual}\``);
1194
1602
  return;
1195
1603
  }
1196
- const entry = { url: ENDPOINT, transport: 'streamable-http' };
1197
- if (bearer) entry.headers = { Authorization: `Bearer ${bearer}` };
1604
+ const entry = { url: ENDPOINT, transport: 'streamable-http',
1605
+ headers: mcpHeaders(bearer, 'openclaw') };
1198
1606
  data.mcp = data.mcp || {};
1199
1607
  data.mcp.servers = data.mcp.servers || {};
1200
1608
  if (sameJson(data.mcp.servers.misakanet, entry)) {
@@ -1207,11 +1615,77 @@ async function installOpenclaw(bearer) {
1207
1615
  ok(`OpenClaw:注册 MCP(streamable-http)→ ${cfg}`);
1208
1616
  }
1209
1617
 
1618
+ /**
1619
+ * Install one MCP-only target: read its JSON, merge our entry under the vendor's own key path, write.
1620
+ *
1621
+ * File-to-file, never a subprocess, so no token ever reaches a command line. An existing file is
1622
+ * preserved (other servers, other keys) and backed up first; a file we cannot parse stops at a manual
1623
+ * step that names exactly what to paste, because guessing at a half-written config is how a user
1624
+ * loses the servers they already had.
1625
+ */
1626
+ async function installMcpOnly(agent, bearer) {
1627
+ const spec = MCP_ONLY_TARGETS[agent];
1628
+ const cfg = spec.config(HOME);
1629
+ const data = readJson(cfg, null);
1630
+ if (data === null && readText(cfg)) {
1631
+ need(`${spec.label}:${cfg} 不是合法 JSON${spec.parseNote ? `(${spec.parseNote})` : ''}`
1632
+ + ` → 请手动加入 ${spec.container}.misakanet(形状见 https://misakanet.org/mcp 的客户端文档)`);
1633
+ return;
1634
+ }
1635
+ const doc = data || {};
1636
+ doc[spec.container] = doc[spec.container] || {};
1637
+ const entry = spec.entry(bearer);
1638
+ if (sameJson(doc[spec.container].misakanet, entry)) {
1639
+ ok(`${spec.label}:MCP 已注册(无改动)`);
1640
+ } else {
1641
+ doc[spec.container].misakanet = entry;
1642
+ backup(cfg);
1643
+ writeText(cfg, `${JSON.stringify(doc, null, 2)}\n`);
1644
+ ok(`${spec.label}:注册 MCP → ${cfg}`);
1645
+ }
1646
+ skip(`${spec.label}:没有规则块与钩子(${spec.rules})→ ${spec.ruleHint}`);
1647
+ need(`${spec.label}:${spec.manual}`);
1648
+ }
1649
+
1210
1650
  // The last endpoint probe, recorded so `--report` can print it without probing twice.
1211
- let lastProbe = { reachable: false, tools: 0 };
1651
+ let lastProbe = { reachable: false, tools: 0, note: '' };
1652
+
1653
+ /**
1654
+ * The advice for a file we could not write.
1655
+ *
1656
+ * It has to be an advice that can work — and "re-run the install command" is not one: the file is
1657
+ * read-only or the permissions do not allow it, so a re-run writes exactly as much as the last one
1658
+ * did. Measured 2026-10-22 on an OpenClaw machine whose workspace was root-owned and read-only:
1659
+ * `--verify` said "规则块没装 → 重跑安装命令", and the trialer went after the network, the
1660
+ * permissions and the gateway while the installer had already named the real blocker.
1661
+ *
1662
+ * The question is asked the way the installer asks it before writing (`canCreateHere`): by
1663
+ * attempting the access, never by `existsSync` first.
1664
+ */
1665
+ function unwritableAdvice(manual = '') {
1666
+ const fix = '改权限(或换到可写的目录)后再重跑';
1667
+ return manual
1668
+ ? `只读或权限不足,重跑也写不进去 → ${manual}(或${fix})`
1669
+ : `只读或权限不足,重跑也写不进去 → ${fix}`;
1670
+ }
1212
1671
 
1213
1672
  async function verify() {
1214
1673
  let allOk = true;
1674
+ // The report answers two different questions, and one of them failing must not condemn the
1675
+ // other (issue #3696): "can you use it" (the endpoint answers) and "are the files we prefer
1676
+ // written" (our own footprint). A machine whose endpoint answers and whose workspace is
1677
+ // read-only is **usable** — and the old verdict called it NOT READY.
1678
+ let usable = true;
1679
+ // The probe has three answers, not two: a probe that could not run is not a probe that failed.
1680
+ // "unknown" is reported as unknown, and only `--strict` refuses to pass on it.
1681
+ let probeState = 'ready';
1682
+ // The install segment is judged on its own: a file we cannot write is a fact about our
1683
+ // footprint, and it says nothing about the endpoint.
1684
+ let installOk = true;
1685
+ const installFailed = () => { installOk = false; allOk = false; };
1686
+ // Where each segment starts, in the three line lists. The output prints the two segments as
1687
+ // two sections, and this boundary is the only thing that has to be right about them.
1688
+ const segmentStart = { done: done.length, manual: manual.length, skipped: skipped.length };
1215
1689
  // Handshake, not a search. `tools/list` proves the endpoint speaks MCP and answers, and it
1216
1690
  // consumes no anonymous read quota — so `--verify` can be run as often as the user likes. The
1217
1691
  // search used to be the probe, which spent one of the five free reads per run and, worse,
@@ -1231,18 +1705,46 @@ async function verify() {
1231
1705
  const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
1232
1706
  if (detect('claude') && missing.length) {
1233
1707
  allOk = false;
1708
+ // Not an install fact: the endpoint answers, and the search still fails. It belongs to
1709
+ // the usability segment, so it belongs there in the output too.
1710
+ usable = false;
1234
1711
  need(`Claude Code:只读 MCP 工具没有放行(${missing.length} 个)→ 第一次检索会被权限拦下;`
1235
1712
  + '重跑安装命令即可放行');
1236
1713
  }
1237
1714
  } catch { /* a settings file we cannot read is reported by the Claude checks below */ }
1238
1715
  } else if (probe && (probe.error || probe.protocolVersion || probe.serverInfo)) {
1239
1716
  allOk = false;
1717
+ usable = false;
1718
+ probeState = 'not-ready';
1240
1719
  need(`端点可达但握手异常:${JSON.stringify(probe).slice(0, 120)}`);
1241
1720
  } else {
1242
- allOk = false;
1243
- need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
1244
- + (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
1721
+ const proxy = proxyEnvironment();
1722
+ if (proxy && !proxy.enabled) {
1723
+ // Inconclusive rather than unreachable. Interactive runs warn (a visible `!` that does not
1724
+ // flip the verdict), because the install itself is fine and the user's agent may well be
1725
+ // reaching the endpoint through that proxy. `--strict` / `--ci` keep a hard verdict: a gate
1726
+ // that cannot tell must not pass silently, and CI machines are the ones actually configured
1727
+ // to make the probe trustworthy.
1728
+ lastProbe = { reachable: false, tools: 0, note: 'inconclusive-proxy' };
1729
+ probeState = 'unknown';
1730
+ if (STRICT) {
1731
+ allOk = false;
1732
+ usable = false;
1733
+ need(`端点探测不可信,且 --strict 不接受"说不清":${proxyProbeAdvice(proxy)}`);
1734
+ } else {
1735
+ need(`端点探测不可信(不是"不可达"):${proxyProbeAdvice(proxy)}`);
1736
+ }
1737
+ } else {
1738
+ allOk = false;
1739
+ usable = false;
1740
+ probeState = 'not-ready';
1741
+ need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
1742
+ + (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
1743
+ }
1245
1744
  }
1745
+ // The endpoint segment ends here. Everything below is about our own footprint: the files we
1746
+ // write, the entries we register, the token we keep.
1747
+ const segmentEnd = { done: done.length, manual: manual.length, skipped: skipped.length };
1246
1748
  const tokenFile = join(stateDir(), 'token');
1247
1749
  let tokenPresent = '';
1248
1750
  try {
@@ -1251,45 +1753,63 @@ async function verify() {
1251
1753
  tokenPresent = '';
1252
1754
  }
1253
1755
  if (tokenPresent) {
1254
- ok('写入通道:token 已就绪(解除每天 5 次读限额,write_lesson 可用)');
1255
- } else {
1256
- skip('写入通道:无 token(只读也完全可用,但读有 5/天/IP 限额)');
1257
- }
1258
- const hookPath = join(stateDir(), 'hook.mjs');
1259
- let hookPresent = false;
1260
- try {
1261
- hookPresent = readFileSync(hookPath, 'utf8').length > 0;
1262
- } catch {
1263
- hookPresent = false;
1264
- }
1265
- if (!hookPresent) {
1266
- allOk = false;
1267
- need('自动沉淀钩子:缺失 → 重跑安装命令');
1756
+ ok('写入通道:token 已就绪(write_lesson / preflight 可用)');
1268
1757
  } else {
1269
- const settingsPath = join(HOME, '.claude', 'settings.json');
1270
- const settings = readJson(settingsPath, {}) || {};
1271
- // Only our own entries: a foreign hook that merely mentions "hook.mjs" is not evidence
1272
- // that this machine is installed (and its interpreter may not even be ours).
1273
- const commands = ourHookEntries(settings.hooks)
1274
- .flatMap((entry) => (entry.hooks || []).map((h) => h.command))
1275
- .filter((c) => typeof c === 'string');
1276
- if (!commands.length) {
1277
- allOk = false;
1278
- need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
1758
+ // Which token this is looking for, said out loud: it reads **our own** state directory, so a
1759
+ // machine that got its token through another channel (mcporter, a gateway) shows "no token"
1760
+ // here even though writes work. The trialer read that as "nothing is set up".
1761
+ skip('写入通道:无 token(这一项只检查本安装器状态目录里的 token;如果 token 来自别的通道,'
1762
+ + '读路径完全不受影响——读不限次数、只读完全可用,写入类工具不可用)');
1763
+ }
1764
+ // The hook is Claude Code's alone: it is the one target whose config carries it (Codex's
1765
+ // user-level hook shape is unconfirmed, OpenClaw's events are unverified), so a machine without
1766
+ // Claude Code must not be told its hook is missing. Until 2026-09-20 it was: a Codex-only or
1767
+ // Cursor-only install reported NOT READY with two Claude Code complaints about a target that was
1768
+ // never selected — the same lie the OpenClaw and Hermes checks below already refuse to tell
1769
+ // (found while adding Cursor, which is mcp-only by construction).
1770
+ if (detect('claude')) {
1771
+ const hookPath = join(stateDir(), 'hook.mjs');
1772
+ let hookPresent = false;
1773
+ try {
1774
+ hookPresent = readFileSync(hookPath, 'utf8').length > 0;
1775
+ } catch {
1776
+ hookPresent = false;
1777
+ }
1778
+ if (!hookPresent) {
1779
+ installFailed();
1780
+ need(canCreateHere(hookPath)
1781
+ ? '自动沉淀钩子:缺失 → 重跑安装命令'
1782
+ : `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
1279
1783
  } else {
1280
- const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
1281
- if (!existsSync(exe)) {
1282
- allOk = false;
1283
- need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`);
1784
+ const settingsPath = join(HOME, '.claude', 'settings.json');
1785
+ const settings = readJson(settingsPath, {}) || {};
1786
+ // Only our own entries: a foreign hook that merely mentions "hook.mjs" is not evidence
1787
+ // that this machine is installed (and its interpreter may not even be ours).
1788
+ const commands = ourHookEntries(settings.hooks)
1789
+ .flatMap((entry) => (entry.hooks || []).map((h) => h.command))
1790
+ .filter((c) => typeof c === 'string');
1791
+ if (!commands.length) {
1792
+ installFailed();
1793
+ need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
1284
1794
  } else {
1285
- ok('Claude Code:钩子已装且解释器存在');
1795
+ const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
1796
+ if (!existsSync(exe)) {
1797
+ installFailed();
1798
+ need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`);
1799
+ } else {
1800
+ ok('Claude Code:钩子已装且解释器存在');
1801
+ }
1286
1802
  }
1803
+ const cfg = join(HOME, '.claude.json');
1804
+ const data = readJson(cfg, {}) || {};
1805
+ const entry = data.mcpServers?.misakanet;
1806
+ if (!entry) {
1807
+ installFailed();
1808
+ need(canCreateHere(cfg)
1809
+ ? `Claude Code:MCP 未注册(${cfg})`
1810
+ : `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
1811
+ } else ok(`Claude Code:MCP 已注册(${entry.url})`);
1287
1812
  }
1288
- const cfg = join(HOME, '.claude.json');
1289
- const data = readJson(cfg, {}) || {};
1290
- const entry = data.mcpServers?.misakanet;
1291
- if (!entry) { allOk = false; need(`Claude Code:MCP 未注册(${cfg})`); }
1292
- else ok(`Claude Code:MCP 已注册(${entry.url})`);
1293
1813
  }
1294
1814
  // OpenClaw is only reported when the user actually has it: telling a machine without
1295
1815
  // OpenClaw that it is "not ready" would be a lie about a target that was never selected.
@@ -1298,17 +1818,23 @@ async function verify() {
1298
1818
  if (detect('openclaw')) {
1299
1819
  const rules = join(openclawWorkspaces()[0], 'AGENTS.md');
1300
1820
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
1301
- allOk = false;
1302
- need(`OpenClaw:规则块没装(${rules})→ 重跑安装命令`);
1821
+ installFailed();
1822
+ // "没装" and "装不了" are different facts with different fixes, and the difference is the
1823
+ // whole point (issue #3696): a read-only workspace is not something a re-run installs.
1824
+ need(canCreateHere(rules)
1825
+ ? `OpenClaw:规则块没装(${rules})→ 重跑安装命令`
1826
+ : `OpenClaw:规则块装不了(${rules})→ ${unwritableAdvice()}`);
1303
1827
  } else {
1304
1828
  ok('OpenClaw:规则块已装');
1305
1829
  }
1306
1830
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
1307
1831
  const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
1308
1832
  if (!entry) {
1309
- allOk = false;
1310
- need(`OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 `
1311
- + `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`);
1833
+ installFailed();
1834
+ const manual = `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`;
1835
+ need(canCreateHere(cfg)
1836
+ ? `OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 ${manual}`
1837
+ : `OpenClaw:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
1312
1838
  } else {
1313
1839
  ok(`OpenClaw:MCP 已注册(${entry.url || '?'})`);
1314
1840
  }
@@ -1319,25 +1845,50 @@ async function verify() {
1319
1845
  if (readText(join(HOME, '.hermes', 'config.yaml'))) {
1320
1846
  const rules = join(HOME, '.hermes', 'SOUL.md');
1321
1847
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
1322
- allOk = false;
1323
- need(`Hermes:规则块没装(${rules})→ 重跑安装命令`);
1848
+ installFailed();
1849
+ need(canCreateHere(rules)
1850
+ ? `Hermes:规则块没装(${rules})→ 重跑安装命令`
1851
+ : `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
1324
1852
  } else {
1325
1853
  ok('Hermes:规则块已装');
1326
1854
  }
1327
1855
  const cfg = join(HOME, '.hermes', 'config.yaml');
1328
1856
  const text = readText(cfg);
1329
1857
  if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
1330
- allOk = false;
1331
- need(`Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 `
1332
- + `hermes mcp add misakanet --url ${ENDPOINT} --auth header`);
1858
+ installFailed();
1859
+ const manual = `hermes mcp add misakanet --url ${ENDPOINT} --auth header`;
1860
+ need(canCreateHere(cfg)
1861
+ ? `Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 ${manual}`
1862
+ : `Hermes:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
1333
1863
  } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
1334
- ok('Hermes:MCP 条目已写入配置(无 token → 匿名 5 次/天/IP)');
1335
- skip(`Hermes:想让检索不计匿名额度,重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
1864
+ ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
1865
+ skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
1336
1866
  } else {
1337
1867
  ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`);
1338
1868
  skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
1339
1869
  }
1340
1870
  }
1871
+ // The MCP-only targets are reported only when the user actually has them, like OpenClaw and
1872
+ // Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
1873
+ // the client has *loaded* that file cannot be confirmed from here, and the line says so.
1874
+ for (const [agent, spec] of Object.entries(MCP_ONLY_TARGETS)) {
1875
+ if (!detect(agent)) continue;
1876
+ const cfg = spec.config(HOME);
1877
+ const entry = (readJson(cfg, null) || {})[spec.container]?.misakanet;
1878
+ if (!entry) {
1879
+ installFailed();
1880
+ need(canCreateHere(cfg)
1881
+ ? `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`
1882
+ : `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
1883
+ } else if (!entry[spec.urlField]) {
1884
+ installFailed();
1885
+ need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
1886
+ + `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`);
1887
+ } else {
1888
+ ok(`${spec.label}:MCP 已注册(${entry[spec.urlField]})`);
1889
+ skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`);
1890
+ }
1891
+ }
1341
1892
  // Version: what is installed, and whether the registry has moved on. Read-only here — the
1342
1893
  // hook owns the periodic nudge (it cannot be a network call in this process's hot path), and
1343
1894
  // `--verify` is an explicit user action, so one lookup is both cheap and expected.
@@ -1360,7 +1911,72 @@ async function verify() {
1360
1911
  + 'npx @misaka-net/misakanet-setup@latest');
1361
1912
  }
1362
1913
  }
1363
- return allOk;
1914
+ return {
1915
+ allOk,
1916
+ usable,
1917
+ probeState,
1918
+ install: installOk ? 'complete' : 'incomplete',
1919
+ // The install segment's open items, counted on their own: the report's `open-items` is every
1920
+ // manual item, this is the ones that belong to our footprint.
1921
+ openItems: manual.length - segmentEnd.manual,
1922
+ segments: { start: segmentStart, end: segmentEnd },
1923
+ };
1924
+ }
1925
+
1926
+ /**
1927
+ * The two verdicts, in words.
1928
+ *
1929
+ * They are two answers to two questions, and the point of #3696 is that they are printed as two:
1930
+ * a machine whose endpoint answers perfectly and whose workspace is read-only is **usable**, and
1931
+ * the old single verdict said NOT READY about it — the one word that sends the user to debug the
1932
+ * wrong thing.
1933
+ */
1934
+ function usabilityText(verdict) {
1935
+ if (verdict.probeState === 'not-ready') return '不就绪 —— 端点不可达或握手异常';
1936
+ if (verdict.probeState === 'unknown') {
1937
+ return STRICT
1938
+ ? '不就绪 —— 探测不可信,--strict 不接受"说不清"'
1939
+ : '说不清 —— 探测不可信(不等于不可达)';
1940
+ }
1941
+ if (!verdict.usable) return '不就绪 —— 端点可达,但只读工具没放行,第一次检索会被拦下';
1942
+ return '就绪 —— 端点可达,检索与读取可用';
1943
+ }
1944
+
1945
+ function installText(verdict) {
1946
+ if (verdict.install === 'complete') return '完整 —— 我们偏好的文件都写好了';
1947
+ return `不完整 —— ${verdict.openItems} 项需要手动处理(上面每条 ! 都给了成因和做法)`;
1948
+ }
1949
+
1950
+ /**
1951
+ * The verify output: two sections, because the two questions have two answers.
1952
+ *
1953
+ * The old output was one list, and one list cannot say "the endpoint answered, the files did not
1954
+ * get written" — it can only say NOT READY, which is the sentence the OpenClaw trialer read as
1955
+ * "MisakaNet does not work here" (issue #3696).
1956
+ */
1957
+ function renderVerify(verdict) {
1958
+ const { start, end } = verdict.segments;
1959
+ const tail = { done: done.length, manual: manual.length, skipped: skipped.length };
1960
+ const section = (label, from, to) => {
1961
+ const out = [];
1962
+ const finished = done.slice(from.done, to.done);
1963
+ const open = manual.slice(from.manual, to.manual);
1964
+ const ignored = skipped.slice(from.skipped, to.skipped);
1965
+ // `--silent` drops the narration, never the errors (the same contract `render()` keeps): the
1966
+ // `!` lines are what a human has to act on, the `✓` and `·` lines are progress.
1967
+ if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l}`));
1968
+ if (open.length) out.push(...open.map((l) => ` ! ${l}`));
1969
+ if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l}`));
1970
+ if (!out.length) return out;
1971
+ return [`\n── ${label} ──`, ...out];
1972
+ };
1973
+ return [
1974
+ ...section('端点/可用性', start, end),
1975
+ ...section('安装完整性', end, tail),
1976
+ '',
1977
+ `可用性:${usabilityText(verdict)}`,
1978
+ `安装完整性:${installText(verdict)}`,
1979
+ ].join('\n');
1364
1980
  }
1365
1981
 
1366
1982
  /**
@@ -1417,7 +2033,8 @@ function restoreEmptiedFiles() {
1417
2033
  // JSON configs: prune containers that our own removal emptied. The file itself is never deleted —
1418
2034
  // it may have existed before us (an empty `~/.claude.json` is a legitimate user state).
1419
2035
  const jsonFiles = [join(HOME, '.claude.json'), join(HOME, '.claude', 'settings.json'),
1420
- join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json')];
2036
+ join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json'),
2037
+ ...Object.values(MCP_ONLY_TARGETS).map((spec) => spec.config(HOME))];
1421
2038
  for (const file of jsonFiles) {
1422
2039
  const data = readJson(file, null);
1423
2040
  if (!data || typeof data !== 'object') continue;
@@ -1484,6 +2101,16 @@ function uninstall() {
1484
2101
  writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
1485
2102
  ok(`移除 MCP 注册 → ${cfg}`);
1486
2103
  }
2104
+ for (const spec of Object.values(MCP_ONLY_TARGETS)) {
2105
+ const cfg = spec.config(HOME);
2106
+ const data = readJson(cfg, null);
2107
+ if (data?.[spec.container]?.misakanet) {
2108
+ delete data[spec.container].misakanet;
2109
+ backup(cfg);
2110
+ writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
2111
+ ok(`移除 MCP 注册 → ${cfg}`);
2112
+ }
2113
+ }
1487
2114
  const voiceDir = join(stateDir(), 'voice');
1488
2115
  if (existsSync(voiceDir)) {
1489
2116
  try {
@@ -1639,7 +2266,8 @@ function distroName() {
1639
2266
  * scalars (`true`, `[claude, codex]`, `""`); the JSON keeps their real types (boolean, array,
1640
2267
  * string), which is the whole point of a machine-readable form.
1641
2268
  */
1642
- function reportValues(allOk) {
2269
+ function reportValues(verdict) {
2270
+ const allOk = verdict.allOk;
1643
2271
  const agents = AGENTS.filter((agent) => detect(agent));
1644
2272
  const token = existsSync(join(stateDir(), 'token'));
1645
2273
  const permissions = (() => {
@@ -1659,11 +2287,27 @@ function reportValues(allOk) {
1659
2287
  ['node', process.version],
1660
2288
  ['detected-agents', agents],
1661
2289
  ['verify', allOk ? 'READY' : 'NOT READY'],
2290
+ // The verdict has two halves, and a machine that collects this report needs to be able to
2291
+ // tell them apart: `verify: NOT READY` because our files could not be written is a
2292
+ // different machine from `verify: NOT READY` because the endpoint does not answer
2293
+ // (issue #3696). `endpoint-reachable` and `endpoint-probe` below already say the endpoint's
2294
+ // half; this is ours.
2295
+ ['install-complete', verdict.install],
2296
+ ['endpoint-usable', verdict.usable ? 'yes' : 'no'],
1662
2297
  ['endpoint-reachable', lastProbe.reachable],
1663
2298
  ['endpoint-tools', lastProbe.tools],
2299
+ // `ok` = the probe ran and its answer is meaningful; `inconclusive-proxy` = the machine has a
2300
+ // proxy this process cannot use, so the boolean above says nothing. Without this field the YAML
2301
+ // a user pastes into a public issue cannot tell a real outage from a false negative.
2302
+ ['endpoint-probe', lastProbe.note || 'ok'],
1664
2303
  ['token', token ? 'present' : 'absent'],
1665
2304
  ['permissions', permissions],
1666
2305
  ['hook', existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'],
2306
+ // `mcp-only` = the endpoint is registered but the hooks are not, which is a different machine
2307
+ // from "nothing installed" and must not be counted as one (#1753's T1-lite rows).
2308
+ ['install-scope', hookWired()
2309
+ ? 'full'
2310
+ : (AGENTS.some((agent) => detect(agent) && mcpEntryPresent(agent)) ? 'mcp-only' : 'none')],
1667
2311
  ['voice', voiceStatus()],
1668
2312
  ['open-items', manual.length],
1669
2313
  // Redacted here, once, so neither encoder can print an unredacted message by accident.
@@ -1738,10 +2382,10 @@ function reportJson(values) {
1738
2382
  * encoding never changes the verdict.
1739
2383
  */
1740
2384
  async function report(asJson) {
1741
- const allOk = await verify();
1742
- const values = reportValues(allOk);
2385
+ const verdict = await verify();
2386
+ const values = reportValues(verdict);
1743
2387
  console.log(asJson ? reportJson(values) : reportYaml(values));
1744
- return { allOk, openItems: manual.length };
2388
+ return { ...verdict, openItems: manual.length };
1745
2389
  }
1746
2390
 
1747
2391
  // ── main ─────────────────────────────────────────────────────────────
@@ -1779,7 +2423,14 @@ if (mode !== 'report' && !SILENT) {
1779
2423
 
1780
2424
  if (mode === 'uninstall') {
1781
2425
  uninstall();
1782
- say(render());
2426
+ if (MCP_ONLY) {
2427
+ // The honest failure mode of tier ①: the tool is callable, but nothing tells the agent (or the
2428
+ // user) to call it — the rules block and the first-turn announcement live in tiers ②/③. Printed
2429
+ // as a "manual step" so it survives --silent (a deployment log keeps the `!` lines).
2430
+ need('只注册了端点:你的 agent 不会自己想到去查 —— 需要你自己说「先查 MisakaNet」,'
2431
+ + '或者请运营方批准 ②③ 后重跑同一条命令(去掉 --mcp-only)');
2432
+ }
2433
+ say(render());
1783
2434
  if (!SILENT) {
1784
2435
  console.log('\n已移除本安装器写入的内容(规则块、MCP 注册、钩子、状态目录)。');
1785
2436
  console.log(`备份保留在 <被改过的文件>.misakanet.bak(例如 ${join(HOME, '.claude', 'CLAUDE.md')}.misakanet.bak)`
@@ -1834,13 +2485,13 @@ if (mode === 'report') {
1834
2485
  }
1835
2486
 
1836
2487
  if (mode === 'verify') {
1837
- const allOk = await verify();
1838
- say(render());
2488
+ const verdict = await verify();
2489
+ say(renderVerify(verdict));
1839
2490
  // Kept under `--silent`: this line is the verdict, not progress. (It is also the only thing a
1840
2491
  // silent `--verify` prints when the machine is healthy, which is what makes it usable in a
1841
2492
  // deployment log.)
1842
- console.log(`\n结论:${allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 ! 都给了修复动作'}`);
1843
- process.exit(allOk ? 0 : 1);
2493
+ console.log(`\n结论:${verdict.allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 ! 都给了成因和做法'}`);
2494
+ process.exit(verdict.allOk ? 0 : 1);
1844
2495
  }
1845
2496
 
1846
2497
  const targets = (only.length ? only : AGENTS).filter((a) => {
@@ -1866,12 +2517,70 @@ const targets = (only.length ? only : AGENTS).filter((a) => {
1866
2517
  let installed = 0;
1867
2518
  // Set when a stable client id was minted: shown to the user at the end (see ensureIdentity).
1868
2519
  let clientIdHint = '';
2520
+ /**
2521
+ * The pseudonym this run knows the machine by — an explicit `--client-id` / `MISAKANET_CLIENT_ID`, or
2522
+ * the one this run minted. This is what `X-MisakaNet-Client` carries.
2523
+ *
2524
+ * Deliberately separate from `clientIdHint`, which exists only for the closing "keep this number"
2525
+ * guidance: a user who supplied an id must not be told to keep it, but their config still has to say
2526
+ * who they are — otherwise the hint would be missing for exactly the users who took the trouble to
2527
+ * have an identity at all.
2528
+ */
2529
+ let knownClientId = '';
2530
+ /**
2531
+ * `--list-writes`: the manifest an operator needs to approve, or to undo by hand.
2532
+ *
2533
+ * `--dry-run` already says which files it would touch; this adds the two things an approval process
2534
+ * asks for — *what* each write does (new file / append a marked block / add a config key) and *how to
2535
+ * take it back* — and it is machine-readable so a ticket can carry it.
2536
+ */
2537
+ if (LIST_WRITES) {
2538
+ // Every row carries its **tier**, which is the point: an operator approving "add an MCP server"
2539
+ // (①) is not approving "change how the agent behaves" (② rules block, ③ automatic hooks). Files
2540
+ // already exist → we append or update keys; missing → we create them.
2541
+ //
2542
+ // The rows come from the shared manifest builder, the same list the install preview prints: one
2543
+ // source of truth, so the manifest an operator approves and the preview a user reads cannot
2544
+ // disagree about what this command would write.
2545
+ const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 装钩子(每轮自动执行)' };
2546
+ const list = buildWriteManifest(targets);
2547
+ for (const row of list) {
2548
+ console.log(`tier${row.tier}\t${tiers[row.tier]}\t${row.path}\t${row.action}`);
2549
+ }
2550
+ const shown = new Set(list.map((row) => row.tier));
2551
+ if (MCP_ONLY) {
2552
+ console.log('# --mcp-only:只申请 ①。规则块(②)与钩子(③)不会写入,'
2553
+ + '需要时由运营方另行批准后重跑不带 --mcp-only 的同一条命令。');
2554
+ } else {
2555
+ console.log(`# 本次申请 ${shown.size} 级权限(${[...shown].sort().map((t) => tiers[t]).join(' · ')});`
2556
+ + '只想批 ① 就加 --mcp-only。');
2557
+ }
2558
+ console.log('# 这次没有写任何东西。撤销:npx @misaka-net/misakanet-setup --uninstall');
2559
+ process.exit(0);
2560
+ }
2561
+
2562
+ // The preview (#3690): the first thing a user sees about what this run is about to write, printed
2563
+ // **before** the first write — so a user who did not expect these files still sees them while
2564
+ // there is nothing to undo yet.
2565
+ //
2566
+ // `--silent` keeps its contract: a clean silent run is an empty log, and this summary is progress
2567
+ // narration, not an error line. `--list-writes` has already exited above with the machine-readable
2568
+ // version of the same list.
2569
+ if (!SILENT && targets.length) {
2570
+ const preview = writePreview(buildWriteManifest(targets));
2571
+ if (preview) console.log(preview);
2572
+ }
2573
+
1869
2574
  try {
1870
2575
  if (!targets.length) {
1871
- need('没检测到 Claude Code / Codex / Hermes 的配置目录 → 请先打开一次你要用的那个助手,再回来运行本命令');
2576
+ need('没检测到任何受支持助手的配置目录(Claude Code / Codex / Hermes / OpenClaw / Codewhale / '
2577
+ + 'Cursor)→ 请先打开一次你要用的那个助手,再回来运行本命令');
1872
2578
  } else {
1873
- const hookPath = await installHook();
1874
- stampVersion();
2579
+ // Only install the hook when a target can actually consume it. Writing it for a Cursor-only or
2580
+ // Codex-only machine produced a file nothing would ever run, and made `--report` claim
2581
+ // `install-scope: full` for an install that registered an endpoint and nothing else.
2582
+ const hookPath = (MCP_ONLY || !targets.includes('claude')) ? null : await installHook();
2583
+ if (!MCP_ONLY) stampVersion();
1875
2584
  const bearer = has('--no-register') ? '' : await ensureIdentity();
1876
2585
  for (const agent of targets) {
1877
2586
  // One target failing must not skip the rest: an uncaught throw used to abort the loop, so
@@ -1891,6 +2600,7 @@ try {
1891
2600
  else if (agent === 'hermes') await installHermes(hookPath, bearer);
1892
2601
  else if (agent === 'openclaw') await installOpenclaw(bearer);
1893
2602
  else if (agent === 'codewhale') await installCodewhale(bearer);
2603
+ else if (MCP_ONLY_TARGETS[agent]) await installMcpOnly(agent, bearer);
1894
2604
  installed += 1;
1895
2605
  } catch (err) {
1896
2606
  need(`${agent}:写入配置失败(${redact((err && err.message) || err)})`
@@ -1909,6 +2619,13 @@ try {
1909
2619
  // the exact failures in it on a dirty one — which is the point (#1784). The machine-readable form
1910
2620
  // of the same run is `--report-json`, not this text.
1911
2621
  say(render());
2622
+ if (MCP_ONLY && !SILENT) {
2623
+ // The honest failure mode of tier ①, said where the user will read it: they are not installing a
2624
+ // behaviour. The rules block (②) and the first-turn announcement, which is injected by the hook
2625
+ // (③), are exactly what makes an agent look things up on its own.
2626
+ console.log('\n⚠️ --mcp-only:只注册了端点与只读工具放行 —— **你的 agent 不会自己想到去查**。'
2627
+ + '\n 需要你自己说「先查 MisakaNet」,或请运营方批准 ②③ 后重跑同一条命令(去掉 --mcp-only)。');
2628
+ }
1912
2629
  if (!SILENT) {
1913
2630
  console.log(`
1914
2631
  接下来: