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