@misaka-net/misakanet-setup 0.6.0 → 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 +69 -19
- package/bin/misakanet-setup.mjs +845 -90
- package/package.json +1 -1
package/bin/misakanet-setup.mjs
CHANGED
|
@@ -114,7 +114,7 @@ const SILENT = has('--silent');
|
|
|
114
114
|
const REPORT_JSON = has('--report-json');
|
|
115
115
|
const HOME = resolve(valueOf('--home', homedir()));
|
|
116
116
|
const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
|
|
117
|
-
'cursor', 'gemini', 'copilot', 'opencode', 'kiro'];
|
|
117
|
+
'cursor', 'gemini', 'copilot', 'opencode', 'kiro', 'mcode'];
|
|
118
118
|
|
|
119
119
|
const done = [];
|
|
120
120
|
const manual = [];
|
|
@@ -157,7 +157,7 @@ function readText(path) {
|
|
|
157
157
|
* except the two that name the version (measured 2026-10-22: the 0.6.0 release pull request
|
|
158
158
|
* failed 9 e2e legs on exactly that).
|
|
159
159
|
*/
|
|
160
|
-
const VERSION = '0.
|
|
160
|
+
const VERSION = '0.7.0' // x-release-please-version
|
|
161
161
|
|
|
162
162
|
/**
|
|
163
163
|
* The shape of a client id, in one place.
|
|
@@ -185,7 +185,7 @@ const FLAGS = [
|
|
|
185
185
|
['--silent', '只压掉进度叙述,绝不压掉 ! 行与报告(MDM/GPO 用)'],
|
|
186
186
|
['--report', '打印一段可公开粘贴的脱敏状态报告(人读 YAML)'],
|
|
187
187
|
['--report-json', '同一份报告的 JSON 编码'],
|
|
188
|
-
['--strict', '配合 --report:NOT READY 时退出码 1(默认仍退 0)'],
|
|
188
|
+
['--strict', '配合 --report:NOT READY 时退出码 1,没能验证(探测超时/代理)时 2(默认仍退 0)'],
|
|
189
189
|
['--ci', '= --report --strict'],
|
|
190
190
|
['--verify', '自检:端点可达、钩子、MCP 注册、版本新旧'],
|
|
191
191
|
['--uninstall', '移除本安装器写入的内容(保留 .misakanet.bak 备份)'],
|
|
@@ -206,8 +206,9 @@ function printHelp() {
|
|
|
206
206
|
|
|
207
207
|
${FLAGS.map(([flag, desc]) => ` ${flag.padEnd(16)} ${desc}`).join('\n')}
|
|
208
208
|
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
退出码(三档,--verify 与 --report --strict 相同):0 READY / 1 NOT READY(机器问题)/
|
|
210
|
+
2 没能验证(工具给不出机器判定:探测超时、代理不可信,或报告没生成)。
|
|
211
|
+
安装模式:0 至少装上了一个助手 / 1 什么都没装上 / 2 自己跑不起来。`);
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
if (has('--help') || has('-h')) {
|
|
@@ -281,6 +282,16 @@ function sameJson(a, b) {
|
|
|
281
282
|
* read-only file (it only needs write permission on the *directory*), so the previous EACCES
|
|
282
283
|
* behaviour is kept explicitly rather than silently lost.
|
|
283
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
|
+
|
|
284
295
|
function writeText(path, text, { mode } = {}) {
|
|
285
296
|
if (DRY) return;
|
|
286
297
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -326,6 +337,7 @@ function writeText(path, text, { mode } = {}) {
|
|
|
326
337
|
try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
|
|
327
338
|
throw err;
|
|
328
339
|
}
|
|
340
|
+
writtenFiles.add(path);
|
|
329
341
|
}
|
|
330
342
|
|
|
331
343
|
/**
|
|
@@ -413,6 +425,86 @@ const MCP_ONLY_TARGETS = {
|
|
|
413
425
|
},
|
|
414
426
|
};
|
|
415
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(' / ');
|
|
506
|
+
}
|
|
507
|
+
|
|
416
508
|
/**
|
|
417
509
|
* The files each assistant's install writes, so a permission problem can be reported *before*
|
|
418
510
|
* anything is touched (and so `--dry-run` can say "I cannot change that" as well as "I would change
|
|
@@ -429,6 +521,10 @@ const AGENT_WRITE_PATHS = {
|
|
|
429
521
|
// for the write manifest, the detection paths and the report's scope line.
|
|
430
522
|
...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
|
|
431
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)],
|
|
432
528
|
openclaw: () => openclawWorkspaces().map((workspace) => join(workspace, 'AGENTS.md')),
|
|
433
529
|
};
|
|
434
530
|
|
|
@@ -481,6 +577,10 @@ function buildWriteManifest(agents) {
|
|
|
481
577
|
// make the manifest — and the preview's file count — promise a file that stays absent.
|
|
482
578
|
if (!has('--no-register')) add(join(stateDir(), 'token'), 'create (0600)', 1, 'token');
|
|
483
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');
|
|
484
584
|
add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
|
|
485
585
|
add(join(stateDir(), STATE_VERSION_FILE), 'create', 3, 'version');
|
|
486
586
|
}
|
|
@@ -517,6 +617,7 @@ function writePreview(rows) {
|
|
|
517
617
|
rules: '规则块(会改变 agent 的行为)',
|
|
518
618
|
hook: '钩子',
|
|
519
619
|
token: 'MCP 凭据',
|
|
620
|
+
endpoint: '端点记录',
|
|
520
621
|
hookScript: '钩子脚本',
|
|
521
622
|
version: '版本戳',
|
|
522
623
|
state: '本机状态',
|
|
@@ -524,7 +625,11 @@ function writePreview(rows) {
|
|
|
524
625
|
const width = Math.min(Math.max(...list.map((f) => f.path.length)) + 2, 32);
|
|
525
626
|
const pad = (text) => (text.length >= width ? `${text} ` : text.padEnd(width));
|
|
526
627
|
return [
|
|
527
|
-
|
|
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:只是预览,不写任何东西)' : ''}:`,
|
|
528
633
|
...list.map((f) => ` ${pad(f.path)}+ ${f.kinds.map((k) => labels[k]).join(' + ')}`),
|
|
529
634
|
'先看:--list-writes 不改行为:--mcp-only 撤销:--uninstall',
|
|
530
635
|
].join('\n');
|
|
@@ -623,15 +728,63 @@ function hookWired() {
|
|
|
623
728
|
return ourHookEntries(settings?.hooks).length > 0;
|
|
624
729
|
}
|
|
625
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
|
+
|
|
626
768
|
function injectBlock(path, block) {
|
|
627
769
|
const existing = readText(path);
|
|
628
|
-
const pattern = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
|
|
629
770
|
const body = `<!-- ${START} -->\n${block.trim()}\n<!-- ${END} -->\n`;
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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';
|
|
633
786
|
backup(path);
|
|
634
|
-
writeText(path,
|
|
787
|
+
writeText(path, out);
|
|
635
788
|
return 'updated';
|
|
636
789
|
}
|
|
637
790
|
backup(path);
|
|
@@ -640,20 +793,36 @@ function injectBlock(path, block) {
|
|
|
640
793
|
return 'added';
|
|
641
794
|
}
|
|
642
795
|
|
|
643
|
-
|
|
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) {
|
|
644
805
|
const text = readText(path);
|
|
645
806
|
if (!text) return false;
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
// while `diff` showed an extra empty line).
|
|
649
|
-
const pattern = new RegExp(`\\n?[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
|
|
650
|
-
if (!pattern.test(text)) return false;
|
|
807
|
+
const regions = blockRegions(text, block);
|
|
808
|
+
if (!regions.length) return false;
|
|
651
809
|
backup(path);
|
|
652
|
-
|
|
653
|
-
|
|
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()) {
|
|
654
823
|
if (!DRY) rmSync(path, { force: true });
|
|
655
824
|
} else {
|
|
656
|
-
writeText(path,
|
|
825
|
+
writeText(path, out);
|
|
657
826
|
}
|
|
658
827
|
return true;
|
|
659
828
|
}
|
|
@@ -712,6 +881,48 @@ function declaredClientId() {
|
|
|
712
881
|
return CLIENT_ID_SHAPE.test(found) ? found : '';
|
|
713
882
|
}
|
|
714
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
|
+
|
|
715
926
|
const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
|
|
716
927
|
|
|
717
928
|
/**
|
|
@@ -726,6 +937,64 @@ function probeEndpoint() {
|
|
|
726
937
|
return (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
|
|
727
938
|
}
|
|
728
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
|
+
|
|
729
998
|
/**
|
|
730
999
|
* The proxy environment, and whether *this* Node will actually use it.
|
|
731
1000
|
*
|
|
@@ -758,6 +1027,29 @@ function proxyProbeAdvice(proxy) {
|
|
|
758
1027
|
+ '(fetch 需要 Node ≥ 22.21.0 / 24.0.0;公司代理做 TLS 拦截时再加 `NODE_USE_SYSTEM_CA=1`)';
|
|
759
1028
|
}
|
|
760
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
|
+
|
|
761
1053
|
/**
|
|
762
1054
|
* One unauthenticated MCP call.
|
|
763
1055
|
*
|
|
@@ -769,6 +1061,16 @@ function proxyProbeAdvice(proxy) {
|
|
|
769
1061
|
// Records why the last request failed, so `--verify` can tell a timeout from a 404 from an
|
|
770
1062
|
// empty-but-successful answer instead of collapsing all three into "unreachable".
|
|
771
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 = '';
|
|
772
1074
|
|
|
773
1075
|
async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
|
|
774
1076
|
const headers = {
|
|
@@ -780,6 +1082,11 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
|
|
|
780
1082
|
};
|
|
781
1083
|
const controller = new AbortController();
|
|
782
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 = '';
|
|
783
1090
|
try {
|
|
784
1091
|
const response = await fetch(urlOverride || ENDPOINT, {
|
|
785
1092
|
method: 'POST',
|
|
@@ -787,7 +1094,18 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
|
|
|
787
1094
|
signal: controller.signal,
|
|
788
1095
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
789
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;
|
|
790
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
|
+
}
|
|
791
1109
|
const result = payload.result || {};
|
|
792
1110
|
// Three response shapes are in play: `structuredContent` (this worker's tools/call),
|
|
793
1111
|
// `content[0].text` (the MCP spec's text block), and plain fields (initialize, tools/list).
|
|
@@ -807,9 +1125,22 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
|
|
|
807
1125
|
// result indistinguishable to every caller — `--verify` then reported a working endpoint as
|
|
808
1126
|
// unreachable, which is the exact bug its own comment above describes. The shape of the return
|
|
809
1127
|
// value is unchanged (callers depend on it); the reason is now recorded for the report.
|
|
810
|
-
|
|
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
|
|
811
1142
|
? `timeout after ${timeoutMs}ms`
|
|
812
|
-
: (
|
|
1143
|
+
: (code && !detail.includes(code) ? `${detail}(${code})` : detail);
|
|
813
1144
|
return {};
|
|
814
1145
|
} finally {
|
|
815
1146
|
clearTimeout(timer);
|
|
@@ -821,6 +1152,65 @@ function mcpCall(tool, toolArgs, timeoutMs = 6000, urlOverride = '') {
|
|
|
821
1152
|
return mcpRequest('tools/call', { name: tool, arguments: toolArgs }, timeoutMs, urlOverride);
|
|
822
1153
|
}
|
|
823
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
|
+
|
|
824
1214
|
// Imperative first, and the trigger is line 1. Measured twice on real machines: the older
|
|
825
1215
|
// polite paragraph was followed by one agent and ignored by the next, while a short numbered
|
|
826
1216
|
// imperative list was followed immediately — a model that skips MCP tools entirely (reported
|
|
@@ -1083,6 +1473,11 @@ function mcpEntryPresent(agent) {
|
|
|
1083
1473
|
codewhale: () => readText(join(HOME, '.codewhale', 'mcp.json')),
|
|
1084
1474
|
...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
|
|
1085
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
|
+
: ''),
|
|
1086
1481
|
}[agent]?.() || '';
|
|
1087
1482
|
return text.includes('misakanet') && text !== 'null';
|
|
1088
1483
|
}
|
|
@@ -1095,14 +1490,21 @@ function detect(agent) {
|
|
|
1095
1490
|
openclaw: ['.openclaw'],
|
|
1096
1491
|
codewhale: ['.codewhale'],
|
|
1097
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'],
|
|
1098
1496
|
}[agent] || [];
|
|
1099
1497
|
return paths.some((p) => existsSync(join(HOME, p)));
|
|
1100
1498
|
}
|
|
1101
1499
|
|
|
1102
1500
|
async function installClaude(hookPath, bearer) {
|
|
1103
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);
|
|
1104
1506
|
const data = readJson(cfg, null);
|
|
1105
|
-
if (data === null &&
|
|
1507
|
+
if (data === null && existing) {
|
|
1106
1508
|
need(`Claude Code:${cfg} 不是合法 JSON → 请手动加入 mcpServers.misakanet`);
|
|
1107
1509
|
return;
|
|
1108
1510
|
}
|
|
@@ -1267,6 +1669,8 @@ function hasTopLevelKey(text, key) {
|
|
|
1267
1669
|
async function installCodex(hookPath, bearer) {
|
|
1268
1670
|
const cfg = join(HOME, '.codex', 'config.toml');
|
|
1269
1671
|
let text = readText(cfg);
|
|
1672
|
+
// Inherit before `codexTable()` rebuilds the inline table from mcpHeaders() (#3772).
|
|
1673
|
+
adoptExistingClientId(text);
|
|
1270
1674
|
let changed = false;
|
|
1271
1675
|
|
|
1272
1676
|
const topPattern = new RegExp(`^[ \\t]*#\\s*${TOP_START}\\s*$\\n?[\\s\\S]*?^[ \\t]*#\\s*${TOP_END}\\s*$\\n?`, 'm');
|
|
@@ -1316,6 +1720,16 @@ async function installCodex(hookPath, bearer) {
|
|
|
1316
1720
|
}
|
|
1317
1721
|
|
|
1318
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';
|
|
1319
1733
|
|
|
1320
1734
|
const REGISTRY_LATEST = process.env.MISAKANET_REGISTRY_URL
|
|
1321
1735
|
|| 'https://registry.npmjs.org/@misaka-net%2fmisakanet-setup/latest';
|
|
@@ -1330,6 +1744,24 @@ const REGISTRY_LATEST = process.env.MISAKANET_REGISTRY_URL
|
|
|
1330
1744
|
* `installed_at` is only refreshed when the version actually changes, so a re-run of the same
|
|
1331
1745
|
* version does not reset the 14-day timer the user is relying on for peace and quiet.
|
|
1332
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
|
+
|
|
1333
1765
|
function stampVersion() {
|
|
1334
1766
|
const file = join(stateDir(), STATE_VERSION_FILE);
|
|
1335
1767
|
const existing = readJson(file, null);
|
|
@@ -1424,6 +1856,9 @@ function openclawWorkspaces() {
|
|
|
1424
1856
|
async function installHermes(hookPath, bearer) {
|
|
1425
1857
|
const cfg = join(HOME, '.hermes', 'config.yaml');
|
|
1426
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);
|
|
1427
1862
|
const rules = join(HOME, '.hermes', 'SOUL.md');
|
|
1428
1863
|
if (!text) {
|
|
1429
1864
|
need('Hermes:找不到 ~/.hermes/config.yaml → 先运行一次 hermes 再回来装');
|
|
@@ -1595,6 +2030,9 @@ async function installOpenclaw(bearer) {
|
|
|
1595
2030
|
}
|
|
1596
2031
|
|
|
1597
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);
|
|
1598
2036
|
const data = readJson(cfg, null);
|
|
1599
2037
|
if (data === null) {
|
|
1600
2038
|
need(`OpenClaw:读不到或解析不了 ${cfg} → 先运行一次 openclaw 生成配置,`
|
|
@@ -1626,8 +2064,11 @@ async function installOpenclaw(bearer) {
|
|
|
1626
2064
|
async function installMcpOnly(agent, bearer) {
|
|
1627
2065
|
const spec = MCP_ONLY_TARGETS[agent];
|
|
1628
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);
|
|
1629
2070
|
const data = readJson(cfg, null);
|
|
1630
|
-
if (data === null &&
|
|
2071
|
+
if (data === null && raw) {
|
|
1631
2072
|
need(`${spec.label}:${cfg} 不是合法 JSON${spec.parseNote ? `(${spec.parseNote})` : ''}`
|
|
1632
2073
|
+ ` → 请手动加入 ${spec.container}.misakanet(形状见 https://misakanet.org/mcp 的客户端文档)`);
|
|
1633
2074
|
return;
|
|
@@ -1647,6 +2088,65 @@ async function installMcpOnly(agent, bearer) {
|
|
|
1647
2088
|
need(`${spec.label}:${spec.manual}`);
|
|
1648
2089
|
}
|
|
1649
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
|
+
|
|
1650
2150
|
// The last endpoint probe, recorded so `--report` can print it without probing twice.
|
|
1651
2151
|
let lastProbe = { reachable: false, tools: 0, note: '' };
|
|
1652
2152
|
|
|
@@ -1676,8 +2176,10 @@ async function verify() {
|
|
|
1676
2176
|
// written" (our own footprint). A machine whose endpoint answers and whose workspace is
|
|
1677
2177
|
// read-only is **usable** — and the old verdict called it NOT READY.
|
|
1678
2178
|
let usable = true;
|
|
1679
|
-
// The probe has three answers, not two: a
|
|
1680
|
-
//
|
|
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.
|
|
1681
2183
|
let probeState = 'ready';
|
|
1682
2184
|
// The install segment is judged on its own: a file we cannot write is a fact about our
|
|
1683
2185
|
// footprint, and it says nothing about the endpoint.
|
|
@@ -1692,11 +2194,12 @@ async function verify() {
|
|
|
1692
2194
|
// reported the rate-limit answer as "endpoint unreachable": the endpoint returns HTTP 200
|
|
1693
2195
|
// with the error inside the JSON-RPC result, so a user who had used their quota was told their
|
|
1694
2196
|
// network was broken (found 2026-09-15 by running the agent chain test on this machine).
|
|
1695
|
-
const
|
|
1696
|
-
const
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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} 个工具)`);
|
|
1700
2203
|
|
|
1701
2204
|
// Surface the permission gap: an install that is otherwise perfect still fails the user's first
|
|
1702
2205
|
// search if the host refuses the tool call, and that is invisible from the config files alone.
|
|
@@ -1712,39 +2215,58 @@ async function verify() {
|
|
|
1712
2215
|
+ '重跑安装命令即可放行');
|
|
1713
2216
|
}
|
|
1714
2217
|
} catch { /* a settings file we cannot read is reported by the Claude checks below */ }
|
|
1715
|
-
} else if (
|
|
2218
|
+
} else if (outcome.state === 'non-mcp') {
|
|
1716
2219
|
allOk = false;
|
|
1717
2220
|
usable = false;
|
|
1718
2221
|
probeState = 'not-ready';
|
|
1719
|
-
|
|
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)}`);
|
|
1720
2247
|
} else {
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
// that cannot tell must not pass silently, and CI machines are the ones actually configured
|
|
1727
|
-
// to make the probe trustworthy.
|
|
1728
|
-
lastProbe = { reachable: false, tools: 0, note: 'inconclusive-proxy' };
|
|
1729
|
-
probeState = 'unknown';
|
|
1730
|
-
if (STRICT) {
|
|
1731
|
-
allOk = false;
|
|
1732
|
-
usable = false;
|
|
1733
|
-
need(`端点探测不可信,且 --strict 不接受"说不清":${proxyProbeAdvice(proxy)}`);
|
|
1734
|
-
} else {
|
|
1735
|
-
need(`端点探测不可信(不是"不可达"):${proxyProbeAdvice(proxy)}`);
|
|
1736
|
-
}
|
|
1737
|
-
} else {
|
|
1738
|
-
allOk = false;
|
|
1739
|
-
usable = false;
|
|
1740
|
-
probeState = 'not-ready';
|
|
1741
|
-
need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
|
|
1742
|
-
+ (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
|
|
1743
|
-
}
|
|
2248
|
+
allOk = false;
|
|
2249
|
+
usable = false;
|
|
2250
|
+
probeState = 'not-ready';
|
|
2251
|
+
need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
|
|
2252
|
+
+ (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
|
|
1744
2253
|
}
|
|
1745
2254
|
// The endpoint segment ends here. Everything below is about our own footprint: the files we
|
|
1746
2255
|
// write, the entries we register, the token we keep.
|
|
1747
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
|
+
}
|
|
1748
2270
|
const tokenFile = join(stateDir(), 'token');
|
|
1749
2271
|
let tokenPresent = '';
|
|
1750
2272
|
try {
|
|
@@ -1808,7 +2330,18 @@ async function verify() {
|
|
|
1808
2330
|
need(canCreateHere(cfg)
|
|
1809
2331
|
? `Claude Code:MCP 未注册(${cfg})`
|
|
1810
2332
|
: `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
1811
|
-
} else
|
|
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
|
+
}
|
|
2344
|
+
}
|
|
1812
2345
|
}
|
|
1813
2346
|
}
|
|
1814
2347
|
// OpenClaw is only reported when the user actually has it: telling a machine without
|
|
@@ -1868,6 +2401,76 @@ async function verify() {
|
|
|
1868
2401
|
skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
|
|
1869
2402
|
}
|
|
1870
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
|
+
}
|
|
1871
2474
|
// The MCP-only targets are reported only when the user actually has them, like OpenClaw and
|
|
1872
2475
|
// Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
|
|
1873
2476
|
// the client has *loaded* that file cannot be confirmed from here, and the line says so.
|
|
@@ -1880,13 +2483,60 @@ async function verify() {
|
|
|
1880
2483
|
need(canCreateHere(cfg)
|
|
1881
2484
|
? `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`
|
|
1882
2485
|
: `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
1883
|
-
} else
|
|
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) {
|
|
1884
2519
|
installFailed();
|
|
1885
|
-
need(
|
|
1886
|
-
|
|
2520
|
+
need(canCreateHere(plugin)
|
|
2521
|
+
? `MiniMax Code:插件清单缺失或不是合法 JSON(${plugin})→ 重跑安装命令`
|
|
2522
|
+
: `MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
|
|
1887
2523
|
} else {
|
|
1888
|
-
|
|
1889
|
-
|
|
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
|
+
}
|
|
1890
2540
|
}
|
|
1891
2541
|
}
|
|
1892
2542
|
// Version: what is installed, and whether the registry has moved on. Read-only here — the
|
|
@@ -1915,6 +2565,12 @@ async function verify() {
|
|
|
1915
2565
|
allOk,
|
|
1916
2566
|
usable,
|
|
1917
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,
|
|
1918
2574
|
install: installOk ? 'complete' : 'incomplete',
|
|
1919
2575
|
// The install segment's open items, counted on their own: the report's `open-items` is every
|
|
1920
2576
|
// manual item, this is the ones that belong to our footprint.
|
|
@@ -1932,21 +2588,49 @@ async function verify() {
|
|
|
1932
2588
|
* wrong thing.
|
|
1933
2589
|
*/
|
|
1934
2590
|
function usabilityText(verdict) {
|
|
1935
|
-
if (verdict.probeState === '
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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
|
+
: '不就绪 —— 端点不可达或握手异常';
|
|
1940
2603
|
}
|
|
1941
2604
|
if (!verdict.usable) return '不就绪 —— 端点可达,但只读工具没放行,第一次检索会被拦下';
|
|
1942
2605
|
return '就绪 —— 端点可达,检索与读取可用';
|
|
1943
2606
|
}
|
|
1944
2607
|
|
|
1945
2608
|
function installText(verdict) {
|
|
2609
|
+
if (verdict.zeroTargets) {
|
|
2610
|
+
return '不完整 —— 没有检测到任何受支持的助手,因此什么都没装';
|
|
2611
|
+
}
|
|
1946
2612
|
if (verdict.install === 'complete') return '完整 —— 我们偏好的文件都写好了';
|
|
1947
2613
|
return `不完整 —— ${verdict.openItems} 项需要手动处理(上面每条 ! 都给了成因和做法)`;
|
|
1948
2614
|
}
|
|
1949
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
|
+
|
|
1950
2634
|
/**
|
|
1951
2635
|
* The verify output: two sections, because the two questions have two answers.
|
|
1952
2636
|
*
|
|
@@ -2071,7 +2755,7 @@ function restoreEmptiedFiles() {
|
|
|
2071
2755
|
}
|
|
2072
2756
|
|
|
2073
2757
|
function uninstall() {
|
|
2074
|
-
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']) {
|
|
2075
2759
|
if (stripBlock(join(HOME, rel))) ok(`移除规则块 → ${rel}`);
|
|
2076
2760
|
}
|
|
2077
2761
|
// Both candidates: a block may have been written before the workspace was read from config.
|
|
@@ -2111,6 +2795,16 @@ function uninstall() {
|
|
|
2111
2795
|
ok(`移除 MCP 注册 → ${cfg}`);
|
|
2112
2796
|
}
|
|
2113
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
|
+
}
|
|
2114
2808
|
const voiceDir = join(stateDir(), 'voice');
|
|
2115
2809
|
if (existsSync(voiceDir)) {
|
|
2116
2810
|
try {
|
|
@@ -2151,14 +2845,25 @@ function uninstall() {
|
|
|
2151
2845
|
for (const [s, e] of [[START, END], [TOP_START, TOP_END]]) {
|
|
2152
2846
|
text = text.replace(new RegExp(`^[ \\t]*#\\s*${s}\\s*$\\n?[\\s\\S]*?^[ \\t]*#\\s*${e}\\s*$\\n?`, 'm'), '');
|
|
2153
2847
|
}
|
|
2154
|
-
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.
|
|
2155
2854
|
backup(toml);
|
|
2156
|
-
|
|
2157
|
-
|
|
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
|
+
}
|
|
2158
2862
|
}
|
|
2159
2863
|
}
|
|
2160
2864
|
if (readText(join(stateDir(), 'token')) || readText(join(stateDir(), 'hook.mjs'))
|
|
2161
|
-
|| 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))) {
|
|
2162
2867
|
if (!DRY) rmSync(stateDir(), { recursive: true, force: true });
|
|
2163
2868
|
ok(`删除状态目录 → ${stateDir()}`);
|
|
2164
2869
|
}
|
|
@@ -2296,18 +3001,23 @@ function reportValues(verdict) {
|
|
|
2296
3001
|
['endpoint-usable', verdict.usable ? 'yes' : 'no'],
|
|
2297
3002
|
['endpoint-reachable', lastProbe.reachable],
|
|
2298
3003
|
['endpoint-tools', lastProbe.tools],
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
2301
|
-
//
|
|
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".
|
|
2302
3008
|
['endpoint-probe', lastProbe.note || 'ok'],
|
|
2303
3009
|
['token', token ? 'present' : 'absent'],
|
|
2304
3010
|
['permissions', permissions],
|
|
2305
3011
|
['hook', existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'],
|
|
2306
3012
|
// `mcp-only` = the endpoint is registered but the hooks are not, which is a different machine
|
|
2307
|
-
// from "nothing installed" and must not be counted as one (#1753's T1-lite rows).
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
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'))],
|
|
2311
3021
|
['voice', voiceStatus()],
|
|
2312
3022
|
['open-items', manual.length],
|
|
2313
3023
|
// Redacted here, once, so neither encoder can print an unredacted message by accident.
|
|
@@ -2449,7 +3159,12 @@ if (mode === 'report') {
|
|
|
2449
3159
|
// follow the verdict** — that breaks every existing use, and the
|
|
2450
3160
|
// gated form below already exists for CI.
|
|
2451
3161
|
// --report --strict (--ci) → 0 READY · 1 NOT READY (including `open-items > 0`) ·
|
|
2452
|
-
// 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.
|
|
2453
3168
|
//
|
|
2454
3169
|
// `--report-json` is the same mode, same contracts, same codes, other encoding: the JSON is
|
|
2455
3170
|
// printed even when the machine is NOT READY — that is precisely the state an MDM is collecting
|
|
@@ -2476,12 +3191,17 @@ if (mode === 'report') {
|
|
|
2476
3191
|
process.exit(2);
|
|
2477
3192
|
}
|
|
2478
3193
|
if (!STRICT) process.exit(0);
|
|
2479
|
-
const
|
|
2480
|
-
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) {
|
|
2481
3201
|
console.error(`--strict:NOT READY(verify: ${verdict.allOk ? 'READY' : 'NOT READY'},`
|
|
2482
3202
|
+ `open-items: ${verdict.openItems})→ 退出码 1;上面的 ${REPORT_JSON ? 'JSON' : 'YAML'} 就是证据。`);
|
|
2483
3203
|
}
|
|
2484
|
-
process.exit(
|
|
3204
|
+
process.exit(code);
|
|
2485
3205
|
}
|
|
2486
3206
|
|
|
2487
3207
|
if (mode === 'verify') {
|
|
@@ -2490,8 +3210,19 @@ if (mode === 'verify') {
|
|
|
2490
3210
|
// Kept under `--silent`: this line is the verdict, not progress. (It is also the only thing a
|
|
2491
3211
|
// silent `--verify` prints when the machine is healthy, which is what makes it usable in a
|
|
2492
3212
|
// deployment log.)
|
|
2493
|
-
|
|
2494
|
-
|
|
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);
|
|
2495
3226
|
}
|
|
2496
3227
|
|
|
2497
3228
|
const targets = (only.length ? only : AGENTS).filter((a) => {
|
|
@@ -2552,8 +3283,12 @@ if (LIST_WRITES) {
|
|
|
2552
3283
|
console.log('# --mcp-only:只申请 ①。规则块(②)与钩子(③)不会写入,'
|
|
2553
3284
|
+ '需要时由运营方另行批准后重跑不带 --mcp-only 的同一条命令。');
|
|
2554
3285
|
} else {
|
|
2555
|
-
|
|
2556
|
-
|
|
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。`);
|
|
2557
3292
|
}
|
|
2558
3293
|
console.log('# 这次没有写任何东西。撤销:npx @misaka-net/misakanet-setup --uninstall');
|
|
2559
3294
|
process.exit(0);
|
|
@@ -2573,14 +3308,24 @@ if (!SILENT && targets.length) {
|
|
|
2573
3308
|
|
|
2574
3309
|
try {
|
|
2575
3310
|
if (!targets.length) {
|
|
2576
|
-
|
|
2577
|
-
|
|
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()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
|
|
2578
3316
|
} else {
|
|
2579
3317
|
// Only install the hook when a target can actually consume it. Writing it for a Cursor-only or
|
|
2580
3318
|
// Codex-only machine produced a file nothing would ever run, and made `--report` claim
|
|
2581
3319
|
// `install-scope: full` for an install that registered an endpoint and nothing else.
|
|
2582
3320
|
const hookPath = (MCP_ONLY || !targets.includes('claude')) ? null : await installHook();
|
|
2583
|
-
|
|
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
|
+
}
|
|
2584
3329
|
const bearer = has('--no-register') ? '' : await ensureIdentity();
|
|
2585
3330
|
for (const agent of targets) {
|
|
2586
3331
|
// One target failing must not skip the rest: an uncaught throw used to abort the loop, so
|
|
@@ -2600,6 +3345,7 @@ try {
|
|
|
2600
3345
|
else if (agent === 'hermes') await installHermes(hookPath, bearer);
|
|
2601
3346
|
else if (agent === 'openclaw') await installOpenclaw(bearer);
|
|
2602
3347
|
else if (agent === 'codewhale') await installCodewhale(bearer);
|
|
3348
|
+
else if (agent === 'mcode') await installMcode(bearer);
|
|
2603
3349
|
else if (MCP_ONLY_TARGETS[agent]) await installMcpOnly(agent, bearer);
|
|
2604
3350
|
installed += 1;
|
|
2605
3351
|
} catch (err) {
|
|
@@ -2619,6 +3365,15 @@ try {
|
|
|
2619
3365
|
// the exact failures in it on a dirty one — which is the point (#1784). The machine-readable form
|
|
2620
3366
|
// of the same run is `--report-json`, not this text.
|
|
2621
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
|
+
}
|
|
2622
3377
|
if (MCP_ONLY && !SILENT) {
|
|
2623
3378
|
// The honest failure mode of tier ①, said where the user will read it: they are not installing a
|
|
2624
3379
|
// behaviour. The rules block (②) and the first-turn announcement, which is injected by the hook
|