@misaka-net/misakanet-setup 0.7.1 → 0.8.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.
@@ -25,7 +25,7 @@
25
25
  * Everything is idempotent, every rewritten file is backed up to *.misakanet.bak, and
26
26
  * --uninstall removes exactly what was added (same markers), leaving user config intact.
27
27
  */
28
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFileSync, rmSync,
28
+ import { existsSync, mkdirSync, readFileSync, readdirSync, copyFileSync, rmSync,
29
29
  chmodSync, statSync, renameSync, accessSync, constants,
30
30
  openSync, writeSync, fsyncSync, closeSync } from 'node:fs';
31
31
  // No child_process import on purpose: this installer must never hand a file-derived value to
@@ -77,6 +77,24 @@ const valueOf = (flag, dflt) => {
77
77
  return i >= 0 && args[i + 1] ? args[i + 1] : dflt;
78
78
  };
79
79
  const only = valueOf('--only', '').split(',').map((s) => s.trim()).filter(Boolean);
80
+
81
+ /**
82
+ * `--client <id>`: judge **one** client only (issue #3699).
83
+ *
84
+ * The checks in `verify()` are per client — each block reads one assistant's own files — but the
85
+ * verdict was per machine: on a machine with three assistants, one broken assistant condemned all
86
+ * three, and a healthy one was indistinguishable from a broken one (#3696). The question a
87
+ * multi-client machine actually asks is "how is *the one I use*", so this flag answers exactly
88
+ * that: the other clients are not checked, not printed, and not counted.
89
+ *
90
+ * Not to be confused with `--client-id`, which pins this machine's pseudonym and has nothing to do
91
+ * with the assistants.
92
+ */
93
+ const CLIENT = valueOf('--client', '').trim().toLowerCase();
94
+
95
+ /** Should this client's checks run? A `--client` filter narrows the whole run down to one. */
96
+ const wantClient = (id) => !CLIENT || CLIENT === id;
97
+
80
98
  const DRY = has('--dry-run');
81
99
  /**
82
100
  * Permission tiers (2026-09-18).
@@ -116,12 +134,34 @@ const HOME = resolve(valueOf('--home', homedir()));
116
134
  const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
117
135
  'cursor', 'gemini', 'copilot', 'opencode', 'kiro', 'mcode'];
118
136
 
137
+ /**
138
+ * The three line lists, and what every line knows about itself.
139
+ *
140
+ * A line knows the **client** it is about, and a failure knows its **kind** — what is wrong, which
141
+ * is what decides the fix (issue #3699). The checks in `verify()` are per client (each block reads
142
+ * one assistant's own files), but the output could only say "this machine is ready": a machine with
143
+ * three assistants was condemned because of the one the user never touches, and a healthy assistant
144
+ * was indistinguishable from a broken one (#3696, #3699).
145
+ *
146
+ * The kinds:
147
+ * 'blocked' — we cannot write there. A re-run writes exactly as much as the last one did, so
148
+ * "re-run the install" is not the advice (measured 2026-10-22 on an OpenClaw machine
149
+ * whose workspace was root-owned and read-only — see `unwritableAdvice`).
150
+ * 'absent' — our footprint is not there at all. A re-run puts it there.
151
+ * 'broken' — our footprint is there but wrong (wrong endpoint, missing field). A re-run fixes it.
152
+ *
153
+ * The client id is '' for a line that is about the machine rather than about one client: the
154
+ * endpoint probe, the token, the version stamp.
155
+ */
119
156
  const done = [];
120
157
  const manual = [];
121
158
  const skipped = [];
122
- const ok = (m) => done.push(m);
123
- const need = (m) => manual.push(m);
124
- const skip = (m) => skipped.push(m);
159
+ const ok = (m, client = '') => done.push({ text: m, client });
160
+ const need = (m, client = '', kind = '') => manual.push({ text: m, client, kind });
161
+ const skip = (m, client = '') => skipped.push({ text: m, client });
162
+
163
+ /** The display name of a client id — `AGENT_LABELS`, with the id as the fallback. */
164
+ const clientLabel = (id) => AGENT_LABELS[id] || id;
125
165
 
126
166
  function readJson(path, fallback) {
127
167
  try {
@@ -157,7 +197,7 @@ function readText(path) {
157
197
  * except the two that name the version (measured 2026-10-22: the 0.6.0 release pull request
158
198
  * failed 9 e2e legs on exactly that).
159
199
  */
160
- const VERSION = '0.7.1' // x-release-please-version
200
+ const VERSION = '0.8.0' // x-release-please-version
161
201
 
162
202
  /**
163
203
  * The shape of a client id, in one place.
@@ -181,6 +221,7 @@ const REFERRAL_SHAPE = /^[A-Za-z0-9]{4,16}$/
181
221
  const FLAGS = [
182
222
  ['--home <dir>', '把配置写到这个目录,而不是真实家目录(不是沙箱:--uninstall 也认这个目录)'],
183
223
  ['--only <a,b>', `只处理列出的助手(${AGENTS.join(' / ')})`],
224
+ ['--client <id>', `只检查这一个客户端(${AGENTS.join(' / ')})——只判它,不判整台机器`],
184
225
  ['--dry-run', '只说不做:打印会改哪些文件,不写任何东西'],
185
226
  ['--silent', '只压掉进度叙述,绝不压掉 ! 行与报告(MDM/GPO 用)'],
186
227
  ['--report', '打印一段可公开粘贴的脱敏状态报告(人读 YAML)'],
@@ -198,7 +239,7 @@ const FLAGS = [
198
239
  ['--help, -h', '打印这份帮助并退出'],
199
240
  ['--version', '打印版本并退出'],
200
241
  ];
201
- const VALUE_FLAGS = ['--home', '--only', '--client-id'];
242
+ const VALUE_FLAGS = ['--home', '--only', '--client-id', '--client'];
202
243
 
203
244
  function printHelp() {
204
245
  console.log(`MisakaNet 安装程序 ${VERSION}
@@ -574,16 +615,27 @@ function buildWriteManifest(agents) {
574
615
  // install: with no target detected the run writes nothing at all.
575
616
  if (agents.length) {
576
617
  // `--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');
618
+ // make the manifest — and the preview's file count — promise a file that stays absent. Even on
619
+ // a registering run it is only written *if the network lets the registration through*, so the
620
+ // row says so instead of presenting it as a file this run will certainly create (#3833).
621
+ if (!has('--no-register')) {
622
+ add(join(stateDir(), 'token'), 'create (0600,取决于注册是否成功)', 1, 'token');
623
+ }
579
624
  if (!MCP_ONLY) {
625
+ // `hook.mjs` is the *Claude Code* hook's body: `installHook()` runs only when `claude` is one
626
+ // of the targets. Listing it for mcode/codex/… promised a file those installs never write —
627
+ // in the same run that said "没有 Claude Code 那种钩子" (measured 2026-10-25, #3833).
628
+ if (agents.includes('claude')) add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
580
629
  // The endpoint record lives in the state directory, so it is tier ③ like everything else in
581
630
  // there: `--mcp-only` must not create that directory at all (the e2e checks the directory
582
631
  // itself, not just the files). The record is therefore written only when tier ③ is in scope.
583
632
  add(join(stateDir(), STATE_ENDPOINT_FILE), 'create', 3, 'endpoint');
584
- add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
585
633
  add(join(stateDir(), STATE_VERSION_FILE), 'create', 3, 'version');
586
634
  }
635
+ // The pseudonym is persisted only on a run that has to mint one (#3832) — so the manifest
636
+ // names that file only then, keeping the "every path exists afterwards" rule. Tier ① like the
637
+ // token: `ensureIdentity()` writes both, and `--mcp-only` still registers.
638
+ if (willMintClientId()) add(join(stateDir(), CLIENT_ID_FILE), 'create', 1, 'clientId');
587
639
  }
588
640
  return [...rows.values()]
589
641
  .filter((row) => (MCP_ONLY ? row.tier === 1 : true))
@@ -616,10 +668,13 @@ function writePreview(rows) {
616
668
  grants: '只读工具放行',
617
669
  rules: '规则块(会改变 agent 的行为)',
618
670
  hook: '钩子',
619
- token: 'MCP 凭据',
671
+ // The token is written only when the anonymous registration succeeds, so the preview says so
672
+ // rather than counting it as a file this run will certainly create (#3833).
673
+ token: 'MCP 凭据(取决于注册是否成功)',
620
674
  endpoint: '端点记录',
621
675
  hookScript: '钩子脚本',
622
676
  version: '版本戳',
677
+ clientId: '身份编号',
623
678
  state: '本机状态',
624
679
  };
625
680
  const width = Math.min(Math.max(...list.map((f) => f.path.length)) + 2, 32);
@@ -827,7 +882,11 @@ function stripBlock(path, block = PROMPT_BLOCK) {
827
882
  return true;
828
883
  }
829
884
 
830
- const stateDir = () => join(HOME, '.misakanet-agent');
885
+ // The hook already reads MISAKANET_AGENT_DIR (checkpoint_reminder.mjs), so honouring it here is
886
+ // what lets an install work on a machine whose HOME is not writable for the runtime user (#3701):
887
+ // writes and --verify then agree on the same directory instead of failing to mkdir under HOME.
888
+ // Kept unresolved on purpose — the hook does not resolve it either, so both ends read one path.
889
+ const stateDir = () => process.env.MISAKANET_AGENT_DIR || join(HOME, '.misakanet-agent');
831
890
 
832
891
  /**
833
892
  * The headers every MCP entry we write carries.
@@ -923,6 +982,91 @@ function adoptExistingClientId(text) {
923
982
  clientIdHint = found;
924
983
  }
925
984
 
985
+ /** The state file that pins this machine's pseudonym, so `--verify`-able identity survives a run. */
986
+ const CLIENT_ID_FILE = 'client_id';
987
+
988
+ /** The id `--client-id` / `MISAKANET_CLIENT_ID` asks for, shape-checked, or `''`. */
989
+ function explicitClientId() {
990
+ const value = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
991
+ return CLIENT_ID_SHAPE.test(value) ? value : '';
992
+ }
993
+
994
+ /** The id `<stateDir>/client_id` pins, or `''` — shape-checked, because a file wrote it. */
995
+ function storedClientId() {
996
+ const found = readText(join(stateDir(), CLIENT_ID_FILE)).trim();
997
+ return CLIENT_ID_SHAPE.test(found) ? found : '';
998
+ }
999
+
1000
+ /**
1001
+ * The pseudonym *any* config on this machine already declares, or `''`.
1002
+ *
1003
+ * "Which config" cannot be "the one this run is about to rewrite". A machine that installed one
1004
+ * target and then installed a second wrote the second config with no `X-MisakaNet-Client` at all:
1005
+ * `ensureIdentity()` returned early (the token existed), it never set `knownClientId`, and the
1006
+ * per-target adopters only look at their own file (issue #3832, hole 1). Every surface this
1007
+ * installer writes is a candidate, walked in `AGENTS` order so the answer is deterministic.
1008
+ */
1009
+ function clientIdOnMachine() {
1010
+ for (const agent of AGENTS) {
1011
+ for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
1012
+ const found = readText(path).match(CLIENT_ID_IN_CONFIG)?.[1] || '';
1013
+ if (CLIENT_ID_SHAPE.test(found)) return found;
1014
+ }
1015
+ }
1016
+ return '';
1017
+ }
1018
+
1019
+ /**
1020
+ * Will this run have to mint the pseudonym, and therefore persist it?
1021
+ *
1022
+ * Asked by the write manifest so the preview names `<stateDir>/client_id` only when the run is
1023
+ * really going to create it (#3832) — the same "every path must exist afterwards" rule the rest of
1024
+ * the manifest follows. Mirrors `clientIdForThisRun()`: nothing explicit, nothing pinned, nothing
1025
+ * to adopt → a mint (and a write).
1026
+ */
1027
+ function willMintClientId() {
1028
+ if (has('--no-register')) return false;
1029
+ return !explicitClientId() && !storedClientId() && !clientIdOnMachine();
1030
+ }
1031
+
1032
+ /** Remember the id and show it to the user: it is a number worth keeping for a reinstall. */
1033
+ function rememberClientId(id) {
1034
+ knownClientId = id;
1035
+ clientIdHint = id;
1036
+ return id;
1037
+ }
1038
+
1039
+ /**
1040
+ * Resolve `X-MisakaNet-Client` for a run that is *not* given an explicit id (#3832).
1041
+ *
1042
+ * Three sources, in order of how much the machine means them:
1043
+ * 1. `<stateDir>/client_id` — what a previous run pinned;
1044
+ * 2. any config that already declares one — the file *is* the record (#3772's rule, widened from
1045
+ * "the file I am about to rewrite" to "the machine");
1046
+ * 3. nothing — mint `setup-<uuid>` and **persist it**, so the next run has something to reuse.
1047
+ *
1048
+ * Persisting is the part #1688 deliberately removed, and the tradeoff is worth stating: #1688
1049
+ * dropped it because "read a local file, put the value in an outbound request" is the shape CodeQL
1050
+ * js/file-access-to-http (#268) flags, and the value is sent to a server. This file holds nothing
1051
+ * secret — it is a pseudonym the installer itself minted, shape-checked on read (`CLIENT_ID_SHAPE`),
1052
+ * and the server treats it as a hint, never as identity (AGENTS.md §3.3). Against that, *not*
1053
+ * persisting it means every failed registration mints a new id, rewrites the user's agent config
1054
+ * with it and leaves a backup behind — measurably wrong on the re-run path the installer tells the
1055
+ * user to walk ("稍后重跑本命令就能拿到 token"). One pinned pseudonym beats five unregistered
1056
+ * ones, so #3832 records the tradeoff here and keeps the file.
1057
+ */
1058
+ function clientIdForThisRun() {
1059
+ if (knownClientId) return knownClientId;
1060
+ const stored = storedClientId();
1061
+ if (stored) return rememberClientId(stored);
1062
+ const adopted = clientIdOnMachine();
1063
+ if (adopted) return rememberClientId(adopted);
1064
+ const minted = `setup-${randomUUID()}`;
1065
+ rememberClientId(minted);
1066
+ writeText(join(stateDir(), CLIENT_ID_FILE), `${minted}\n`);
1067
+ return minted;
1068
+ }
1069
+
926
1070
  const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
927
1071
 
928
1072
  /**
@@ -954,6 +1098,35 @@ function expectedEndpoint() {
954
1098
  return CANONICAL_ENDPOINT;
955
1099
  }
956
1100
 
1101
+ /**
1102
+ * The endpoint URL in a Hermes config's `mcp_servers.misakanet` entry.
1103
+ *
1104
+ * Hermes' config is YAML and this installer has no YAML parser, so the URL is read the way it is
1105
+ * written: the `url:` line of the `misakanet:` block. Both shapes put it there — the block this
1106
+ * installer writes (marked with the comment pair) and the bare entry a user added through the
1107
+ * Hermes CLI.
1108
+ */
1109
+ function hermesMcpUrl(text) {
1110
+ const lines = text.split('\n');
1111
+ for (let i = 0; i < lines.length; i += 1) {
1112
+ const head = lines[i].match(/^([ \t]*)misakanet:/);
1113
+ if (!head) continue;
1114
+ const indent = head[1].length;
1115
+ // The entry's own keys are indented deeper than the `misakanet:` line; the first line that is
1116
+ // not ends the entry, and the next entry's `url:` must not be read as this one's.
1117
+ for (let j = i + 1; j < lines.length; j += 1) {
1118
+ const line = lines[j];
1119
+ if (!line.trim()) continue;
1120
+ const lineIndent = line.match(/^[ \t]*/)[0].length;
1121
+ if (lineIndent <= indent) break;
1122
+ const url = line.match(/^[ \t]*url:[ \t]*(.+?)[ \t]*$/);
1123
+ // The quotes are optional in YAML, and a user-added entry may well have them.
1124
+ if (url) return url[1].replace(/^["']|["']$/g, '');
1125
+ }
1126
+ }
1127
+ return '';
1128
+ }
1129
+
957
1130
  /**
958
1131
  * One verdict for every MCP entry this installer writes.
959
1132
  *
@@ -1371,7 +1544,9 @@ async function installHook() {
1371
1544
  if (!DRY) {
1372
1545
  mkdirSync(stateDir(), { recursive: true });
1373
1546
  if (existing !== null) backup(hookPath);
1374
- writeFileSync(hookPath, bundled);
1547
+ // `writeText`, not `writeFileSync`: the hook is a file this run wrote, so the closing count has
1548
+ // to include it — and it must be replaced atomically like every other file here (#3833).
1549
+ writeText(hookPath, bundled);
1375
1550
  }
1376
1551
  ok(isOurs
1377
1552
  ? `自动沉淀的钩子已更新(旧版备份为 ${hookPath}.misakanet.bak)→ ${hookPath}`
@@ -1399,22 +1574,23 @@ async function ensureIdentity() {
1399
1574
  }
1400
1575
  if (existing) {
1401
1576
  ok('已有匿名身份(token 已存在)');
1577
+ // #3832 hole 1: this early return used to leave `knownClientId` empty, and the npm channel
1578
+ // persisted nothing, so the *next* target's config was written with no `X-MisakaNet-Client` at
1579
+ // all. Resolve the identity here too — nothing is registered this run, but the header still has
1580
+ // to say who this machine is.
1581
+ clientIdForThisRun();
1402
1582
  return existing;
1403
1583
  }
1404
1584
  if (DRY) {
1405
1585
  ok(`会注册匿名身份并把 token 写到 ${file}`);
1406
1586
  return '';
1407
1587
  }
1408
- // The client id is what makes a re-registration return the *same* node, so it has to come
1409
- // from somewhere the user owns rather than from a file this process found on disk: CodeQL
1410
- // js/file-access-to-http (#268) reads "read a local file, put it in an outbound request" as
1411
- // what it looks like, and it is right — the value may be ours, but the shape is the bug.
1412
- // An exported MISAKANET_CLIENT_ID is explicit intent (the same standard the hook applies to
1413
- // its token); otherwise this run mints one and prints it so the user can keep it.
1414
- // `--client-id` and `MISAKANET_CLIENT_ID` are the same explicit intent. The flag exists because
1415
- // the closing guidance told users to keep an identity and the only way to supply one was a shell
1416
- // export — an instruction a non-technical user cannot follow, and on Windows a different one.
1417
- const clientId = usable ? explicit : `setup-${randomUUID()}`;
1588
+ // The client id is what makes a re-registration return the *same* node. It used to be minted
1589
+ // fresh on every attempt that failed — held only in memory, so the re-run the installer asks for
1590
+ // minted another, rewrote the user's config and left a backup (issue #3832, hole 2).
1591
+ // `clientIdForThisRun()` pins the value in `<stateDir>/client_id`; its docstring records the
1592
+ // #1688 vs CodeQL tradeoff that makes reading that file back acceptable.
1593
+ const clientId = usable ? explicit : clientIdForThisRun();
1418
1594
  knownClientId = clientId;
1419
1595
  const referral = declaredReferral();
1420
1596
  const result = await mcpCall('misakanet_register', {
@@ -1586,10 +1762,14 @@ async function installClaude(hookPath, bearer) {
1586
1762
  const destDir = join(stateDir(), 'voice');
1587
1763
  if (!DRY) {
1588
1764
  mkdirSync(destDir, { recursive: true });
1765
+ // `copyFileSync` writes a file too, so these join `writtenFiles`: without it a `--voice`
1766
+ // install under-reports its count the same way the version stamp did (#3833).
1589
1767
  copyFileSync(voice.player, join(destDir, 'voice-hook.mjs'));
1768
+ writtenFiles.add(join(destDir, 'voice-hook.mjs'));
1590
1769
  const cues = existsSync(voice.cues) ? readdirSync(voice.cues).filter((f) => f.endsWith('.mp3')) : [];
1591
1770
  for (const cue of cues) {
1592
1771
  copyFileSync(join(voice.cues, cue), join(destDir, cue));
1772
+ writtenFiles.add(join(destDir, cue));
1593
1773
  }
1594
1774
  if (!cues.length) {
1595
1775
  need('语音钩子:找到了播放器但没有找到音频文件 → 重装一次 npx 包即可(其它功能不受影响)');
@@ -1779,7 +1959,11 @@ function stampVersion() {
1779
1959
  installed_at: new Date().toISOString(),
1780
1960
  };
1781
1961
  mkdirSync(stateDir(), { recursive: true });
1782
- writeFileSync(file, `${JSON.stringify(stamp, null, 2)}\n`);
1962
+ // `writeFileSync` here bypassed `writeText()`'s atomic replace *and* its `writtenFiles`
1963
+ // bookkeeping, so every fresh install reported one file fewer than it had written (#3833) — and
1964
+ // this was the one write path left without the truncation protection the rest of the installer
1965
+ // went through.
1966
+ writeText(file, `${JSON.stringify(stamp, null, 2)}\n`);
1783
1967
  ok(`版本戳 → ${file}(${VERSION}${existing?.version ? `,原 ${existing.version}` : ''})`);
1784
1968
  }
1785
1969
 
@@ -2169,6 +2353,11 @@ function unwritableAdvice(manual = '') {
2169
2353
  : `只读或权限不足,重跑也写不进去 → ${fix}`;
2170
2354
  }
2171
2355
 
2356
+ // Claude Code is in use when its user-level config file exists — a stale `~/.claude/` directory is
2357
+ // not evidence (#3699). The install still detects on either (it can offer to set Claude up); the
2358
+ // verification and the report must not blame a machine that merely left the directory behind.
2359
+ const claudeCodeInUse = () => existsSync(join(HOME, '.claude.json'));
2360
+
2172
2361
  async function verify() {
2173
2362
  let allOk = true;
2174
2363
  // The report answers two different questions, and one of them failing must not condemn the
@@ -2185,6 +2374,21 @@ async function verify() {
2185
2374
  // footprint, and it says nothing about the endpoint.
2186
2375
  let installOk = true;
2187
2376
  const installFailed = () => { installOk = false; allOk = false; };
2377
+ /**
2378
+ * A check whose answer is "we cannot write there" or "our footprint is not there".
2379
+ *
2380
+ * The two answers have different fixes, and the per-client summary has to name which one it is:
2381
+ * a read-only target is **not** fixed by re-running the installer — a re-run writes exactly as
2382
+ * much as the last one did (measured 2026-10-22 on an OpenClaw machine whose workspace was
2383
+ * root-owned and read-only; see `unwritableAdvice`). So the kind is decided **here**, where the
2384
+ * writability is measured, and never by reading the wording of the message: a message that
2385
+ * changes its phrasing must not silently change the verdict.
2386
+ */
2387
+ const needWrite = (client, path, absentMsg, blockedMsg) => {
2388
+ installFailed();
2389
+ if (canCreateHere(path)) need(absentMsg, client, 'absent');
2390
+ else need(blockedMsg, client, 'blocked');
2391
+ };
2188
2392
  // Where each segment starts, in the three line lists. The output prints the two segments as
2189
2393
  // two sections, and this boundary is the only thing that has to be right about them.
2190
2394
  const segmentStart = { done: done.length, manual: manual.length, skipped: skipped.length };
@@ -2206,13 +2410,16 @@ async function verify() {
2206
2410
  try {
2207
2411
  const granted = readJson(join(HOME, '.claude', 'settings.json'), null)?.permissions?.allow || [];
2208
2412
  const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
2209
- if (detect('claude') && missing.length) {
2413
+ if (wantClient('claude') && claudeCodeInUse() && missing.length) {
2210
2414
  allOk = false;
2211
2415
  // Not an install fact: the endpoint answers, and the search still fails. It belongs to
2212
2416
  // the usability segment, so it belongs there in the output too.
2213
2417
  usable = false;
2418
+ // It is a Claude Code fact all the same — the permission list is Claude Code's own
2419
+ // config — so it is judged under Claude Code and not under the machine (#3699). The fix
2420
+ // is the same re-run, which is what 'absent' means: the grant is not in the list yet.
2214
2421
  need(`Claude Code:只读 MCP 工具没有放行(${missing.length} 个)→ 第一次检索会被权限拦下;`
2215
- + '重跑安装命令即可放行');
2422
+ + '重跑安装命令即可放行', 'claude', 'absent');
2216
2423
  }
2217
2424
  } catch { /* a settings file we cannot read is reported by the Claude checks below */ }
2218
2425
  } else if (outcome.state === 'non-mcp') {
@@ -2261,11 +2468,20 @@ async function verify() {
2261
2468
  // self-contradiction the issue was filed for (`detected-agents: []` next to
2262
2469
  // `install-complete: complete`). Judged on "no target in AGENTS is detected", not on one
2263
2470
  // hard-coded directory: any of the supported assistants in `AGENTS` counts.
2264
- const detectedAgents = AGENTS.filter((agent) => detect(agent));
2471
+ // `detected-agents` counts only the assistants in scope: with `--client <id>` the question is
2472
+ // about that one, so another assistant's absence is not this machine's problem (#3699).
2473
+ const detectedAgents = AGENTS.filter((agent) => wantClient(agent) && detect(agent));
2265
2474
  if (!detectedAgents.length) {
2266
2475
  installFailed();
2267
- need('没有检测到任何受支持的助手 —— 因此什么都没装'
2268
- + `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
2476
+ if (CLIENT) {
2477
+ // The user named a client this machine does not have. That is an answer, not a silence:
2478
+ // "OpenClaw:没装过(这台机器上没检测到它)" says what to do about it.
2479
+ need(`${clientLabel(CLIENT)}:没装过(这台机器上没检测到它)`
2480
+ + '→ 先打开一次那个助手,再回来运行本命令', CLIENT, 'absent');
2481
+ } else {
2482
+ need('没有检测到任何受支持的助手 —— 因此什么都没装'
2483
+ + `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
2484
+ }
2269
2485
  }
2270
2486
  const tokenFile = join(stateDir(), 'token');
2271
2487
  let tokenPresent = '';
@@ -2289,7 +2505,7 @@ async function verify() {
2289
2505
  // Cursor-only install reported NOT READY with two Claude Code complaints about a target that was
2290
2506
  // never selected — the same lie the OpenClaw and Hermes checks below already refuse to tell
2291
2507
  // (found while adding Cursor, which is mcp-only by construction).
2292
- if (detect('claude')) {
2508
+ if (wantClient('claude') && claudeCodeInUse()) {
2293
2509
  const hookPath = join(stateDir(), 'hook.mjs');
2294
2510
  let hookPresent = false;
2295
2511
  try {
@@ -2298,10 +2514,9 @@ async function verify() {
2298
2514
  hookPresent = false;
2299
2515
  }
2300
2516
  if (!hookPresent) {
2301
- installFailed();
2302
- need(canCreateHere(hookPath)
2303
- ? '自动沉淀钩子:缺失 → 重跑安装命令'
2304
- : `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
2517
+ needWrite('claude', hookPath,
2518
+ '自动沉淀钩子:缺失 → 重跑安装命令',
2519
+ `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
2305
2520
  } else {
2306
2521
  const settingsPath = join(HOME, '.claude', 'settings.json');
2307
2522
  const settings = readJson(settingsPath, {}) || {};
@@ -2312,34 +2527,35 @@ async function verify() {
2312
2527
  .filter((c) => typeof c === 'string');
2313
2528
  if (!commands.length) {
2314
2529
  installFailed();
2315
- need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
2530
+ need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)', 'claude', 'absent');
2316
2531
  } else {
2317
2532
  const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
2318
2533
  if (!existsSync(exe)) {
2319
2534
  installFailed();
2320
- need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`);
2535
+ need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`,
2536
+ 'claude', 'broken');
2321
2537
  } else {
2322
- ok('Claude Code:钩子已装且解释器存在');
2538
+ ok('Claude Code:钩子已装且解释器存在', 'claude');
2323
2539
  }
2324
2540
  }
2325
2541
  const cfg = join(HOME, '.claude.json');
2326
2542
  const data = readJson(cfg, {}) || {};
2327
2543
  const entry = data.mcpServers?.misakanet;
2328
2544
  if (!entry) {
2329
- installFailed();
2330
- need(canCreateHere(cfg)
2331
- ? `Claude Code:MCP 未注册(${cfg})`
2332
- : `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2545
+ needWrite('claude', cfg,
2546
+ `Claude Code:MCP 未注册(${cfg})`,
2547
+ `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2333
2548
  } else {
2334
2549
  const verdict = mcpEntryState(entry, 'url');
2335
2550
  if (verdict.state === 'wrong-url') {
2336
2551
  installFailed();
2337
- need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2552
+ need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2553
+ 'claude', 'broken');
2338
2554
  } else if (verdict.state === 'no-url-field') {
2339
2555
  installFailed();
2340
- need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2556
+ need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'claude', 'broken');
2341
2557
  } else {
2342
- ok(`Claude Code:MCP 已注册(${verdict.url})`);
2558
+ ok(`Claude Code:MCP 已注册(${verdict.url})`, 'claude');
2343
2559
  }
2344
2560
  }
2345
2561
  }
@@ -2348,57 +2564,84 @@ async function verify() {
2348
2564
  // OpenClaw that it is "not ready" would be a lie about a target that was never selected.
2349
2565
  // The state is read from the same file `openclaw mcp list` reports from, which keeps this
2350
2566
  // check working where the CLI itself cannot start (its database may be unwritable).
2351
- if (detect('openclaw')) {
2567
+ if (wantClient('openclaw') && detect('openclaw')) {
2352
2568
  const rules = join(openclawWorkspaces()[0], 'AGENTS.md');
2353
2569
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2354
- installFailed();
2355
2570
  // "没装" and "装不了" are different facts with different fixes, and the difference is the
2356
2571
  // 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()}`);
2572
+ needWrite('openclaw', rules,
2573
+ `OpenClaw:规则块没装(${rules})→ 重跑安装命令`,
2574
+ `OpenClaw:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2360
2575
  } else {
2361
- ok('OpenClaw:规则块已装');
2576
+ ok('OpenClaw:规则块已装', 'openclaw');
2362
2577
  }
2363
2578
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
2364
2579
  const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
2365
2580
  if (!entry) {
2366
- installFailed();
2367
2581
  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)}`);
2582
+ needWrite('openclaw', cfg,
2583
+ `OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 ${manual}`,
2584
+ `OpenClaw:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
2371
2585
  } else {
2372
- ok(`OpenClaw:MCP 已注册(${entry.url || '?'})`);
2586
+ // The URL is checked here, and it was not: this block only ever asked "is there an entry",
2587
+ // so an OpenClaw pointed at `https://BROKEN.invalid/mcp` was reported as "已注册" with the
2588
+ // unreachable URL printed next to the tick. `mcpEntryState` exists for exactly this and was
2589
+ // simply never applied here (it is applied to Claude, Codex, codewhale, the MCP-only
2590
+ // targets and mcode). With a per-client verdict on screen, "就绪" next to an entry nobody
2591
+ // can reach would be a false green (#1984).
2592
+ const verdict = mcpEntryState(entry, 'url');
2593
+ if (verdict.state === 'wrong-url') {
2594
+ installFailed();
2595
+ need(`OpenClaw:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2596
+ 'openclaw', 'broken');
2597
+ } else if (verdict.state === 'no-url-field') {
2598
+ installFailed();
2599
+ need(`OpenClaw:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'openclaw', 'broken');
2600
+ } else {
2601
+ ok(`OpenClaw:MCP 已注册(${verdict.url})`, 'openclaw');
2602
+ }
2373
2603
  }
2374
2604
  }
2375
2605
  // Hermes: same rule — reported only when the user has it. Its config file *is* the registry,
2376
2606
  // so the state is readable without running anything. What cannot be confirmed from here is
2377
2607
  // whether Hermes has loaded that file, and the output says so rather than implying it.
2378
- if (readText(join(HOME, '.hermes', 'config.yaml'))) {
2608
+ if (wantClient('hermes') && readText(join(HOME, '.hermes', 'config.yaml'))) {
2379
2609
  const rules = join(HOME, '.hermes', 'SOUL.md');
2380
2610
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2381
- installFailed();
2382
- need(canCreateHere(rules)
2383
- ? `Hermes:规则块没装(${rules})→ 重跑安装命令`
2384
- : `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2611
+ needWrite('hermes', rules,
2612
+ `Hermes:规则块没装(${rules})→ 重跑安装命令`,
2613
+ `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2385
2614
  } else {
2386
- ok('Hermes:规则块已装');
2615
+ ok('Hermes:规则块已装', 'hermes');
2387
2616
  }
2388
2617
  const cfg = join(HOME, '.hermes', 'config.yaml');
2389
2618
  const text = readText(cfg);
2390
2619
  if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
2391
- installFailed();
2392
2620
  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)}`);
2396
- } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
2397
- ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
2398
- skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
2621
+ needWrite('hermes', cfg,
2622
+ `Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 ${manual}`,
2623
+ `Hermes:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
2399
2624
  } else {
2400
- ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`);
2401
- skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
2625
+ // The entry is there — but is it pointed at our endpoint? Same gap as OpenClaw had: the
2626
+ // check asked "is there an entry" and never "does it point at the right place", so a Hermes
2627
+ // on `https://BROKEN.invalid/mcp` was reported as registered (#1984: a green run is not
2628
+ // evidence unless something in it can differ).
2629
+ const url = hermesMcpUrl(text);
2630
+ const expected = expectedEndpoint();
2631
+ if (url && url !== expected) {
2632
+ installFailed();
2633
+ need(`Hermes:MCP 条目指向的端点不对(${url},应为 ${expected})(${cfg})→ 重跑安装命令`,
2634
+ 'hermes', 'broken');
2635
+ } else if (!url) {
2636
+ installFailed();
2637
+ need(`Hermes:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'hermes', 'broken');
2638
+ } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
2639
+ ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)', 'hermes');
2640
+ skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`, 'hermes');
2641
+ } else {
2642
+ ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`, 'hermes');
2643
+ skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核', 'hermes');
2644
+ }
2402
2645
  }
2403
2646
  }
2404
2647
  // Codex and codewhale: reported only when the user has them, like every other target. They were
@@ -2406,68 +2649,67 @@ async function verify() {
2406
2649
  // all, so a machine whose Codex entry pointed at `https://BROKEN.invalid/mcp` and whose rules
2407
2650
  // block had lost its markers still reported READY with exit code 0 — while `--report`'s
2408
2651
  // `detected-agents` listed codex and codewhale as present, and the gate let it through.
2409
- if (detect('codex')) {
2652
+ if (wantClient('codex') && detect('codex')) {
2410
2653
  const rules = join(HOME, '.codex', 'AGENTS.md');
2411
2654
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2412
- installFailed();
2413
- need(canCreateHere(rules)
2414
- ? `Codex:规则块没装(${rules})→ 重跑安装命令`
2415
- : `Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2655
+ needWrite('codex', rules,
2656
+ `Codex:规则块没装(${rules})→ 重跑安装命令`,
2657
+ `Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2416
2658
  } else {
2417
- ok('Codex:规则块已装');
2659
+ ok('Codex:规则块已装', 'codex');
2418
2660
  }
2419
2661
  const cfg = join(HOME, '.codex', 'config.toml');
2420
2662
  const toml = readText(cfg);
2421
2663
  const table = codexMisakanetTable(toml);
2422
2664
  const verdict = mcpEntryState(table, 'url');
2423
2665
  if (verdict.state === 'missing') {
2424
- installFailed();
2425
- need(canCreateHere(cfg)
2426
- ? `Codex:MCP 未注册(${cfg})→ 重跑安装命令`
2427
- : `Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2666
+ needWrite('codex', cfg,
2667
+ `Codex:MCP 未注册(${cfg})→ 重跑安装命令`,
2668
+ `Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2428
2669
  } else if (verdict.state === 'wrong-url') {
2429
2670
  installFailed();
2430
- need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2671
+ need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2672
+ 'codex', 'broken');
2431
2673
  } else if (verdict.state === 'no-url-field') {
2432
2674
  installFailed();
2433
- need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2675
+ need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'codex', 'broken');
2434
2676
  } else {
2435
- ok(`Codex:MCP 已注册(${verdict.url})`);
2677
+ ok(`Codex:MCP 已注册(${verdict.url})`, 'codex');
2436
2678
  }
2437
2679
  // The top-level key is what makes Codex speak streamable-http at all: without it the entry
2438
2680
  // above is present and still unusable.
2439
2681
  if (!hasTopLevelKey(toml, 'experimental_use_rmcp_client')) {
2440
2682
  installFailed();
2441
2683
  need(`Codex:config.toml 里没有顶级键 experimental_use_rmcp_client(${cfg})`
2442
- + '→ Codex 连不上 streamable-http,重跑安装命令即可补上');
2684
+ + '→ Codex 连不上 streamable-http,重跑安装命令即可补上', 'codex', 'broken');
2443
2685
  }
2444
2686
  }
2445
- if (detect('codewhale')) {
2687
+ if (wantClient('codewhale') && detect('codewhale')) {
2446
2688
  const cfg = join(HOME, '.codewhale', 'mcp.json');
2447
2689
  const entry = (readJson(cfg, null) || {}).servers?.misakanet;
2448
2690
  const verdict = mcpEntryState(entry, 'url');
2449
2691
  if (verdict.state === 'missing') {
2450
- installFailed();
2451
- need(canCreateHere(cfg)
2452
- ? `codewhale:MCP 未注册(${cfg})→ 重跑安装命令`
2453
- : `codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2692
+ needWrite('codewhale', cfg,
2693
+ `codewhale:MCP 未注册(${cfg})→ 重跑安装命令`,
2694
+ `codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2454
2695
  } else if (verdict.state === 'wrong-url') {
2455
2696
  installFailed();
2456
- need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2697
+ need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2698
+ 'codewhale', 'broken');
2457
2699
  } else if (verdict.state === 'no-url-field') {
2458
2700
  installFailed();
2459
- need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`);
2701
+ need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'codewhale', 'broken');
2460
2702
  } else {
2461
- ok(`codewhale:MCP 已注册(${verdict.url})`);
2703
+ ok(`codewhale:MCP 已注册(${verdict.url})`, 'codewhale');
2462
2704
  }
2463
2705
  // Its rules blocks live in the projects it trusts, so that is where they are checked.
2464
2706
  for (const project of codewhaleProjects()) {
2465
2707
  const rules = join(project, 'AGENTS.md');
2466
2708
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2467
2709
  installFailed();
2468
- need(`codewhale:规则块没装(${rules})→ 重跑安装命令`);
2710
+ need(`codewhale:规则块没装(${rules})→ 重跑安装命令`, 'codewhale', 'absent');
2469
2711
  } else {
2470
- ok(`codewhale:规则块已装(${rules})`);
2712
+ ok(`codewhale:规则块已装(${rules})`, 'codewhale');
2471
2713
  }
2472
2714
  }
2473
2715
  }
@@ -2475,26 +2717,27 @@ async function verify() {
2475
2717
  // Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
2476
2718
  // the client has *loaded* that file cannot be confirmed from here, and the line says so.
2477
2719
  for (const [agent, spec] of Object.entries(MCP_ONLY_TARGETS)) {
2478
- if (!detect(agent)) continue;
2720
+ if (!wantClient(agent) || !detect(agent)) continue;
2479
2721
  const cfg = spec.config(HOME);
2480
2722
  const entry = (readJson(cfg, null) || {})[spec.container]?.misakanet;
2481
2723
  if (!entry) {
2482
- installFailed();
2483
- need(canCreateHere(cfg)
2484
- ? `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`
2485
- : `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2724
+ needWrite(agent, cfg,
2725
+ `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`,
2726
+ `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
2486
2727
  } else {
2487
2728
  const verdict = mcpEntryState(entry, spec.urlField);
2488
2729
  if (verdict.state === 'no-url-field') {
2489
2730
  installFailed();
2490
2731
  need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
2491
- + `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`);
2732
+ + `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`,
2733
+ agent, 'broken');
2492
2734
  } else if (verdict.state === 'wrong-url') {
2493
2735
  installFailed();
2494
- need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`);
2736
+ need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
2737
+ agent, 'broken');
2495
2738
  } else {
2496
- ok(`${spec.label}:MCP 已注册(${verdict.url})`);
2497
- skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`);
2739
+ ok(`${spec.label}:MCP 已注册(${verdict.url})`, agent);
2740
+ skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`, agent);
2498
2741
  }
2499
2742
  }
2500
2743
  }
@@ -2503,39 +2746,38 @@ async function verify() {
2503
2746
  // hand-edit turned into `{}` is exactly the state a re-run fixes. `mcode plugin add` refuses
2504
2747
  // local plugins on purpose (LOCAL_PLUGIN_INSTALL_UNSUPPORTED), so its exit code is not evidence
2505
2748
  // either way — the directory is, and that is what this reads.
2506
- if (detect('mcode')) {
2749
+ if (wantClient('mcode') && detect('mcode')) {
2507
2750
  const rules = join(HOME, MCODE_RULES_REL);
2508
2751
  if (!readText(rules).includes(`<!-- ${START} -->`)) {
2509
- installFailed();
2510
- need(canCreateHere(rules)
2511
- ? `MiniMax Code:规则块没装(${rules})→ 重跑安装命令`
2512
- : `MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2752
+ needWrite('mcode', rules,
2753
+ `MiniMax Code:规则块没装(${rules})→ 重跑安装命令`,
2754
+ `MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
2513
2755
  } else {
2514
- ok('MiniMax Code:规则块已装');
2756
+ ok('MiniMax Code:规则块已装', 'mcode');
2515
2757
  }
2516
2758
  const plugin = mcodePluginPath(HOME);
2517
2759
  const doc = readJson(plugin, null);
2518
2760
  if (doc === null) {
2519
- installFailed();
2520
- need(canCreateHere(plugin)
2521
- ? `MiniMax Code:插件清单缺失或不是合法 JSON(${plugin})→ 重跑安装命令`
2522
- : `MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
2761
+ needWrite('mcode', plugin,
2762
+ `MiniMax Code:插件清单缺失或不是合法 JSON(${plugin})→ 重跑安装命令`,
2763
+ `MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
2523
2764
  } else {
2524
2765
  const verdict = mcpEntryState(doc.mcpServers?.misakanet, 'url');
2525
2766
  if (verdict.state === 'missing') {
2526
2767
  installFailed();
2527
- need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→ 重跑安装命令`);
2768
+ need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→ 重跑安装命令`,
2769
+ 'mcode', 'absent');
2528
2770
  } else if (verdict.state === 'no-url-field') {
2529
2771
  installFailed();
2530
- need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→ 重跑安装命令`);
2772
+ need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→ 重跑安装命令`, 'mcode', 'broken');
2531
2773
  } else if (verdict.state === 'wrong-url') {
2532
2774
  installFailed();
2533
2775
  need(`MiniMax Code:插件清单指向的端点不对(${verdict.url},应为 ${verdict.expected})(${plugin})`
2534
- + '→ 重跑安装命令');
2776
+ + '→ 重跑安装命令', 'mcode', 'broken');
2535
2777
  } else {
2536
- ok(`MiniMax Code:插件清单已注册(${verdict.url})`);
2778
+ ok(`MiniMax Code:插件清单已注册(${verdict.url})`, 'mcode');
2537
2779
  skip('MiniMax Code:只能确认"插件目录在位",无法确认 mcode 是否已加载 → 跑 `mcode plugin list` 复核'
2538
- + '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)');
2780
+ + '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)', 'mcode');
2539
2781
  }
2540
2782
  }
2541
2783
  }
@@ -2575,6 +2817,10 @@ async function verify() {
2575
2817
  // The install segment's open items, counted on their own: the report's `open-items` is every
2576
2818
  // manual item, this is the ones that belong to our footprint.
2577
2819
  openItems: manual.length - segmentEnd.manual,
2820
+ // One verdict per client, so the conclusion can be read per assistant instead of per machine
2821
+ // (issue #3699). The machine-level verdict below is derived from these, not the other way
2822
+ // round: a client that was not checked cannot be the reason this machine is not ready.
2823
+ clients: clientVerdicts(),
2578
2824
  segments: { start: segmentStart, end: segmentEnd },
2579
2825
  };
2580
2826
  }
@@ -2606,6 +2852,14 @@ function usabilityText(verdict) {
2606
2852
  }
2607
2853
 
2608
2854
  function installText(verdict) {
2855
+ // The clients that are not ready, **by name** (issue #3699). "3 项需要手动处理" said how many;
2856
+ // it never said which assistant the three belonged to, so on a multi-client machine the reader
2857
+ // had to go and match the count against the lines above.
2858
+ const notReady = (verdict.clients || []).filter((c) => c.verdict !== '就绪');
2859
+ if (notReady.length) {
2860
+ return `不完整 —— ${notReady.map((c) => `${c.label}:${c.verdict}`).join(';')}`
2861
+ + '(上面每条 ! 都给了成因和做法)';
2862
+ }
2609
2863
  if (verdict.zeroTargets) {
2610
2864
  return '不完整 —— 没有检测到任何受支持的助手,因此什么都没装';
2611
2865
  }
@@ -2631,32 +2885,118 @@ function exitCodeFor(verdict) {
2631
2885
  return (verdict.unverified && verdict.install === 'complete') ? 2 : 1;
2632
2886
  }
2633
2887
 
2888
+ /**
2889
+ * One verdict per client, from the lines that client produced (issue #3699).
2890
+ *
2891
+ * The machine-level verdict was a single boolean over every check, but the checks are per client:
2892
+ * a machine with three assistants was condemned because of the one the user never touches, and a
2893
+ * healthy assistant was indistinguishable from a broken one (#3696, #3699). So the conclusion is
2894
+ * per client — and each client gets one of four words, because the four fixes are four different
2895
+ * things:
2896
+ *
2897
+ * 就绪 — everything we checked for this client passed; there is nothing to do.
2898
+ * 装不了 — we cannot write there. Fix the permissions: a re-run writes exactly as much as the
2899
+ * last one did (measured 2026-10-22 on an OpenClaw machine whose workspace was
2900
+ * root-owned and read-only — see `unwritableAdvice`).
2901
+ * 没装过 — our footprint is not there: the file, the entry, the permission grant. A re-run puts
2902
+ * it there.
2903
+ * 装坏了 — our footprint is there but points at the wrong place: the wrong endpoint, a missing
2904
+ * field, a missing interpreter. A re-run fixes it.
2905
+ *
2906
+ * Issue #3699 names the first three. The fourth exists because "our entry is there and points at
2907
+ * the wrong endpoint" is none of the three, and calling it 没装过 would be a lie about the
2908
+ * machine.
2909
+ *
2910
+ * `blocked` wins over `broken` wins over `absent`: a client we cannot write to is not merely "not
2911
+ * installed", and saying so would send the reader after the wrong fix.
2912
+ */
2913
+ const CLIENT_VERDICT_WORDS = { blocked: '装不了', broken: '装坏了', absent: '没装过' };
2914
+ /** What to do about each verdict, in the order the fix differs. */
2915
+ const CLIENT_VERDICT_HINTS = {
2916
+ 就绪: '',
2917
+ 装不了: '(改权限后重跑安装命令)',
2918
+ 没装过: '(重跑安装命令)',
2919
+ 装坏了: '(重跑安装命令)',
2920
+ };
2921
+
2922
+ function clientVerdicts() {
2923
+ const buckets = new Map();
2924
+ const bucket = (id) => {
2925
+ if (!buckets.has(id)) {
2926
+ buckets.set(id, { id, done: [], manual: [], skipped: [], kinds: new Set() });
2927
+ }
2928
+ return buckets.get(id);
2929
+ };
2930
+ for (const line of done) if (line.client) bucket(line.client).done.push(line);
2931
+ for (const line of manual) {
2932
+ if (!line.client) continue;
2933
+ const b = bucket(line.client);
2934
+ b.manual.push(line);
2935
+ if (line.kind) b.kinds.add(line.kind);
2936
+ }
2937
+ for (const line of skipped) if (line.client) bucket(line.client).skipped.push(line);
2938
+ // `AGENTS` is the order, so the output reads the same way on every machine.
2939
+ return AGENTS.filter((id) => buckets.has(id)).map((id) => {
2940
+ const b = buckets.get(id);
2941
+ const verdict = !b.kinds.size ? '就绪'
2942
+ : b.kinds.has('blocked') ? CLIENT_VERDICT_WORDS.blocked
2943
+ : b.kinds.has('broken') ? CLIENT_VERDICT_WORDS.broken
2944
+ : CLIENT_VERDICT_WORDS.absent;
2945
+ return {
2946
+ id,
2947
+ label: clientLabel(id),
2948
+ verdict,
2949
+ hint: CLIENT_VERDICT_HINTS[verdict],
2950
+ done: b.done,
2951
+ manual: b.manual,
2952
+ skipped: b.skipped,
2953
+ };
2954
+ });
2955
+ }
2956
+
2634
2957
  /**
2635
2958
  * The verify output: two sections, because the two questions have two answers.
2636
2959
  *
2637
2960
  * The old output was one list, and one list cannot say "the endpoint answered, the files did not
2638
2961
  * get written" — it can only say NOT READY, which is the sentence the OpenClaw trialer read as
2639
2962
  * "MisakaNet does not work here" (issue #3696).
2963
+ *
2964
+ * The install section is grouped **per client** (issue #3699). The checks were per client all
2965
+ * along — each block reads one assistant's own files — but the output could only say "this
2966
+ * machine", so a machine with three assistants was condemned because of the one the user never
2967
+ * touches, and a healthy assistant was indistinguishable from a broken one. Now each client gets
2968
+ * one verdict line and its own details under it.
2640
2969
  */
2641
2970
  function renderVerify(verdict) {
2642
2971
  const { start, end } = verdict.segments;
2643
2972
  const tail = { done: done.length, manual: manual.length, skipped: skipped.length };
2644
- const section = (label, from, to) => {
2973
+ // A line is either about the machine (the endpoint probe, the token, the version stamp) or
2974
+ // about one client. The two never share a line, so the sections can be split by that.
2975
+ const machineLines = (list, from, to) => list.slice(from, to).filter((l) => !l.client);
2976
+ const section = (label, from, to, clients) => {
2645
2977
  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);
2978
+ const finished = machineLines(done, from.done, to.done);
2979
+ const open = machineLines(manual, from.manual, to.manual);
2980
+ const ignored = machineLines(skipped, from.skipped, to.skipped);
2649
2981
  // `--silent` drops the narration, never the errors (the same contract `render()` keeps): the
2650
2982
  // `!` 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}`));
2983
+ if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l.text}`));
2984
+ if (open.length) out.push(...open.map((l) => ` ! ${l.text}`));
2985
+ if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l.text}`));
2986
+ for (const client of clients || []) {
2987
+ // The verdict line is the conclusion, not narration: it survives `--silent`, because a
2988
+ // silent `--verify` that prints nothing about the client cannot be read at all.
2989
+ out.push(` ${client.label}:${client.verdict}${client.hint}`);
2990
+ if (client.done.length && !SILENT) out.push(...client.done.map((l) => ` ✓ ${l.text}`));
2991
+ if (client.manual.length) out.push(...client.manual.map((l) => ` ! ${l.text}`));
2992
+ if (client.skipped.length && !SILENT) out.push(...client.skipped.map((l) => ` · ${l.text}`));
2993
+ }
2654
2994
  if (!out.length) return out;
2655
2995
  return [`\n── ${label} ──`, ...out];
2656
2996
  };
2657
2997
  return [
2658
- ...section('端点/可用性', start, end),
2659
- ...section('安装完整性', end, tail),
2998
+ ...section('端点/可用性', start, end, []),
2999
+ ...section('安装完整性', end, tail, verdict.clients),
2660
3000
  '',
2661
3001
  `可用性:${usabilityText(verdict)}`,
2662
3002
  `安装完整性:${installText(verdict)}`,
@@ -2754,9 +3094,49 @@ function restoreEmptiedFiles() {
2754
3094
  }
2755
3095
  }
2756
3096
 
3097
+ /**
3098
+ * Is `~/.minimax/plugins/misakanet/` ours to delete?
3099
+ *
3100
+ * The install side answers this by reading the manifest and refusing to touch a directory whose
3101
+ * `name` is not `misakanet` (`installMcode`), reporting "已原样保留". Uninstall used to `rmSync`
3102
+ * the subtree with no question at all, so on a machine where the install had just promised to keep
3103
+ * the user's plugin, the next `--uninstall` deleted it (issue #3831, measured 2026-10-25) — the
3104
+ * installer's own wording turned into a lie by the command it recommends for undoing.
3105
+ *
3106
+ * A manifest that is absent or unparseable is not proof of ownership either: a plugin directory is
3107
+ * a user-owned space, and `misakanet` is a name another plugin can legitimately have. Being wrong
3108
+ * here costs irreversible data, so only a manifest that says `name: "misakanet"` answers "yes".
3109
+ */
3110
+ function mcodePluginDirectoryOwnership() {
3111
+ const plugin = mcodePluginPath(HOME);
3112
+ const text = readText(plugin);
3113
+ if (!text) {
3114
+ return { ours: false, why: '里没有 .claude-plugin/plugin.json(无法确认归属)' };
3115
+ }
3116
+ let doc;
3117
+ try {
3118
+ doc = JSON.parse(text);
3119
+ } catch {
3120
+ return { ours: false, why: '的清单不是合法 JSON(无法确认归属)' };
3121
+ }
3122
+ if (doc?.name !== 'misakanet') {
3123
+ return {
3124
+ ours: false,
3125
+ why: `的清单不是本安装器写的(name=${JSON.stringify(doc?.name ?? null)})`,
3126
+ };
3127
+ }
3128
+ return { ours: true, why: '' };
3129
+ }
3130
+
2757
3131
  function uninstall() {
3132
+ let hermesRulesRemoved = false;
2758
3133
  for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md', '.minimax/AGENTS.md']) {
2759
- if (stripBlock(join(HOME, rel))) ok(`移除规则块 → ${rel}`);
3134
+ if (stripBlock(join(HOME, rel))) {
3135
+ ok(`移除规则块 → ${rel}`);
3136
+ // Recorded here because the block is already gone by the time the Hermes summary is built —
3137
+ // "已移除" must describe what this run did, not what a file says now (#3834).
3138
+ if (rel === '.hermes/SOUL.md') hermesRulesRemoved = true;
3139
+ }
2760
3140
  }
2761
3141
  // Both candidates: a block may have been written before the workspace was read from config.
2762
3142
  for (const workspace of openclawWorkspaces()) {
@@ -2796,14 +3176,29 @@ function uninstall() {
2796
3176
  }
2797
3177
  }
2798
3178
  // 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.
3179
+ // the whole subtree back. Only *our* directory, and only once its manifest says it is ours: see
3180
+ // `mcodePluginDirectoryOwnership` for what asking the same question as the install costs (#3831).
3181
+ // `~/.minimax/plugins/` is the user's namespace and stays even when it ends up empty — deleting
3182
+ // it would remove a place their other plugins live.
2801
3183
  const mcodeDir = join(HOME, '.minimax', 'plugins', 'misakanet');
2802
3184
  if (existsSync(mcodeDir)) {
2803
- try {
2804
- if (!DRY) rmSync(mcodeDir, { recursive: true, force: true });
2805
- ok(`移除插件目录 → ${mcodeDir}`);
2806
- } catch { /* best effort */ }
3185
+ const ownership = mcodePluginDirectoryOwnership();
3186
+ if (!ownership.ours) {
3187
+ // Deliberately a `need`, never an `ok`: the directory is still there, and only a human can
3188
+ // decide whether it is theirs to remove. Putting it under "已完成" was the second half of the
3189
+ // bug — the run claimed a removal it had skipped.
3190
+ need(`MiniMax Code:${mcodeDir} ${ownership.why} → 已保留(不删);`
3191
+ + '确认它是你的插件后手动删掉这个目录,本安装器不碰它');
3192
+ } else {
3193
+ try {
3194
+ if (!DRY) rmSync(mcodeDir, { recursive: true, force: true });
3195
+ ok(`移除插件目录 → ${mcodeDir}`);
3196
+ } catch (err) {
3197
+ // A failed delete used to be swallowed by an empty `catch`, so the run said nothing while
3198
+ // the directory survived. Now it is a manual step, which is what it is.
3199
+ need(`MiniMax Code:删不掉 ${mcodeDir}(${redact((err && err.message) || err)})→ 手动删除它`);
3200
+ }
3201
+ }
2807
3202
  }
2808
3203
  const voiceDir = join(stateDir(), 'voice');
2809
3204
  if (existsSync(voiceDir)) {
@@ -2890,8 +3285,18 @@ function uninstall() {
2890
3285
  ok(`移除 token 行 → ${hermesEnv}(${HERMES_ENV_KEY})`);
2891
3286
  }
2892
3287
  }
2893
- need('Hermes 的钩子不归本安装器管 → 需要时看 hermes hooks doctor;'
2894
- + `规则块已在 ~/.hermes/SOUL.md 移除`);
3288
+ // Hermes' advice, only on a machine that has Hermes. It was unconditional, so a machine that had
3289
+ // never installed Hermes was told "规则块已在 ~/.hermes/SOUL.md 移除" and sent to a
3290
+ // `hermes hooks doctor` that does not exist there (#3834, measured 2026-10-25). The `!` list is
3291
+ // the user's only action list — an item that does not apply dilutes it.
3292
+ if (existsSync(join(HOME, '.hermes'))) {
3293
+ // And say what actually happened to the rules block: "已移除" only when this run removed one,
3294
+ // "本来就没装" when the file was there without our markers.
3295
+ need('Hermes 的钩子不归本安装器管 → 需要时看 hermes hooks doctor;'
3296
+ + (hermesRulesRemoved
3297
+ ? '规则块已在 ~/.hermes/SOUL.md 移除'
3298
+ : '规则块本来就没装(~/.hermes/SOUL.md 里没有本安装器的标记块)'));
3299
+ }
2895
3300
 
2896
3301
  // OpenClaw's MCP entry is one we wrote ourselves, so we can take it back ourselves.
2897
3302
  const ocCfg = join(HOME, '.openclaw', 'openclaw.json');
@@ -2978,7 +3383,7 @@ function reportValues(verdict) {
2978
3383
  const permissions = (() => {
2979
3384
  // `n/a` when Claude Code is not a target on this machine: an absent settings file is not a
2980
3385
  // gap, and reporting one would send a Codex-only user chasing a fix they do not need.
2981
- if (!detect('claude')) return 'n/a';
3386
+ if (!claudeCodeInUse()) return 'n/a';
2982
3387
  const granted = readJson(join(HOME, '.claude', 'settings.json'), null)?.permissions?.allow || [];
2983
3388
  const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
2984
3389
  return missing.length ? 'incomplete' : 'ok';
@@ -3021,7 +3426,7 @@ function reportValues(verdict) {
3021
3426
  ['voice', voiceStatus()],
3022
3427
  ['open-items', manual.length],
3023
3428
  // Redacted here, once, so neither encoder can print an unredacted message by accident.
3024
- ['open-items-detail', manual.map((m) => redact(m))],
3429
+ ['open-items-detail', manual.map((m) => redact(m.text))],
3025
3430
  ...HUMAN_REPORT_FIELDS.map(([key, value, note]) => [key, value, note]),
3026
3431
  ];
3027
3432
  }
@@ -3104,9 +3509,9 @@ function render() {
3104
3509
  // `--silent` drops the narration, never the errors: `done` is progress, `manual` is what a human
3105
3510
  // (or a GPO log reader) has to act on, and a run that failed silently would be worse than a loud
3106
3511
  // one. See SILENT at the top for the full contract.
3107
- if (done.length && !SILENT) out.push(`\n已完成(${done.length}):`, ...done.map((l) => ` ✓ ${l}`));
3108
- if (manual.length) out.push(`\n需要你手动一步(${manual.length}):`, ...manual.map((l) => ` ! ${l}`));
3109
- if (skipped.length && !SILENT) out.push(`\n跳过(${skipped.length}):`, ...skipped.map((l) => ` · ${l}`));
3512
+ if (done.length && !SILENT) out.push(`\n已完成(${done.length}):`, ...done.map((l) => ` ✓ ${l.text}`));
3513
+ if (manual.length) out.push(`\n需要你手动一步(${manual.length}):`, ...manual.map((l) => ` ! ${l.text}`));
3514
+ if (skipped.length && !SILENT) out.push(`\n跳过(${skipped.length}):`, ...skipped.map((l) => ` · ${l.text}`));
3110
3515
  return out.join('\n');
3111
3516
  }
3112
3517
 
@@ -3123,6 +3528,22 @@ const STRICT = has('--strict') || has('--ci');
3123
3528
  const mode = has('--uninstall') ? 'uninstall'
3124
3529
  : ((has('--report') || REPORT_JSON || (has('--ci') && !has('--verify'))) ? 'report'
3125
3530
  : (has('--verify') ? 'verify' : 'install'));
3531
+ // `--client` is a question about *judging*, so it belongs to `--verify` and nowhere else: in
3532
+ // install mode `--only` already says which assistants to write to, and in report mode the report
3533
+ // has its own shape. A flag that silently does nothing is worse than one that refuses: `--client`
3534
+ // next to `--client-id` is exactly the kind of pair a user mixes up, so it says what it means.
3535
+ // Exit 2 is this tool's "could not answer" code (same as a probe timeout, #3778) — the machine
3536
+ // is not broken, the request was.
3537
+ if (CLIENT && mode !== 'verify') {
3538
+ console.error(`--client 只对 --verify 有意义(它决定"只判哪一个客户端");`
3539
+ + `当前模式是 ${mode},请用 --only <助手> 指定要处理的助手。`);
3540
+ process.exit(2);
3541
+ }
3542
+ if (CLIENT && !AGENTS.includes(CLIENT)) {
3543
+ console.error(`未知的客户端:${CLIENT}`);
3544
+ console.error(`支持的客户端:${supportedAgentLabels()}`);
3545
+ process.exit(2);
3546
+ }
3126
3547
  if (mode !== 'report' && !SILENT) {
3127
3548
  // The banner names the home directory, which is exactly the kind of thing that gets pasted
3128
3549
  // along with a report — so `--report` prints only the report. `--silent` drops it for the other
@@ -3377,6 +3798,25 @@ if (!SILENT && !DRY) {
3377
3798
  // looked identical. `--dry-run` keeps its own contract — it already says it would write
3378
3799
  // nothing, so a "0 files written" line under it would only restate the mode.
3379
3800
  console.log(`本次实际写入 ${writtenFiles.size} 个文件(上面的清单是"涉及哪些",这一行是"真的动了哪些")。`);
3801
+
3802
+ if (installed > 0) {
3803
+ // The tail is what a user reads, and until now it held only next steps: a real Windows run on
3804
+ // 2026-10-29 ended on "5) 想关掉…" and the identity line, so the sole evidence that anything had
3805
+ // worked was the absence of errors. Verified here rather than assumed: the version is this build's,
3806
+ // and whether it is the published one is asked of the registry — the same two primitives `--verify`
3807
+ // uses, so the closing line and the self-check cannot disagree about what "latest" means.
3808
+ const latest = await latestPublishedVersion();
3809
+ const delta = latest ? compareVersions(VERSION, latest) : null;
3810
+ let versionNote = ';查不到 npm 上的最新版本(离线或 registry 不可达)';
3811
+ if (delta === 0) versionNote = '(已是最新)';
3812
+ else if (delta !== null && delta < 0) {
3813
+ versionNote = `;npm 上已有 ${latest} → 重跑 npx @misaka-net/misakanet-setup@latest 可更新`;
3814
+ } else if (delta !== null) {
3815
+ versionNote = `;比 npm 上的 ${latest} 新(本地构建或预发布)`;
3816
+ }
3817
+ console.log(`\n✓ 安装成功 —— misakanet-setup ${VERSION}${versionNote}`
3818
+ + `\n 已配置 ${installed} 个助手;撤销:npx @misaka-net/misakanet-setup --uninstall`);
3819
+ }
3380
3820
  }
3381
3821
  if (MCP_ONLY && !SILENT) {
3382
3822
  // The honest failure mode of tier ①, said where the user will read it: they are not installing a
@@ -3388,13 +3828,14 @@ if (MCP_ONLY && !SILENT) {
3388
3828
  if (!SILENT) {
3389
3829
  console.log(`
3390
3830
  接下来:
3391
- 1) **把这个助手窗口关掉再打开一次**(新功能要重开会话才生效)
3392
- 2) 随便挑一句带报错原文的片段问它(例如「${ONBOARDING_QUERIES[0]}」「${ONBOARDING_QUERIES[1]}」「${ONBOARDING_QUERIES[2]}」)——它应该先去查经验库
3393
- 3) 想确认状态:npx @misaka-net/misakanet-setup --verify
3394
- 4) 想关掉:npx @misaka-net/misakanet-setup --uninstall`
3831
+ 1) 启动时加载 MCP 配置的客户端(Claude Code / Codex 等)重开一次会话;dsh 下一条消息就生效,mcporter 这类按次调用的通道不需要重启
3832
+ 2) 确认工具装上了:跑客户端的 MCP 列表命令(如 mcporter list misakanet,或 Claude Code 的 /mcp)——应看到 8 个工具
3833
+ 3) 随便挑一句带报错原文的片段问它(例如「${ONBOARDING_QUERIES[0]}」「${ONBOARDING_QUERIES[1]}」「${ONBOARDING_QUERIES[2]}」)——它应该先去查经验库
3834
+ 4) 想确认状态:npx @misaka-net/misakanet-setup --verify
3835
+ 5) 想关掉:npx @misaka-net/misakanet-setup --uninstall`
3395
3836
  + (clientIdHint
3396
3837
  ? `
3397
- 5) 记不记都行:这台机器的身份编号是 ${clientIdHint}
3838
+ 6) 记不记都行:这台机器的身份编号是 ${clientIdHint}
3398
3839
  想让它以后重装/换机还算同一个身份,再跑安装时加:--client-id ${clientIdHint}`
3399
3840
  : ''));
3400
3841
  }