@misaka-net/misakanet-setup 0.7.1 → 0.8.1
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 +29 -9
- package/bin/misakanet-setup.mjs +632 -146
- package/hook/checkpoint_reminder.mjs +47 -11
- package/package.json +1 -1
package/bin/misakanet-setup.mjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* Everything is idempotent, every rewritten file is backed up to *.misakanet.bak, and
|
|
26
26
|
* --uninstall removes exactly what was added (same markers), leaving user config intact.
|
|
27
27
|
*/
|
|
28
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync,
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, copyFileSync, rmSync,
|
|
29
29
|
chmodSync, statSync, renameSync, accessSync, constants,
|
|
30
30
|
openSync, writeSync, fsyncSync, closeSync } from 'node:fs';
|
|
31
31
|
// No child_process import on purpose: this installer must never hand a file-derived value to
|
|
@@ -77,6 +77,24 @@ const valueOf = (flag, dflt) => {
|
|
|
77
77
|
return i >= 0 && args[i + 1] ? args[i + 1] : dflt;
|
|
78
78
|
};
|
|
79
79
|
const only = valueOf('--only', '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* `--client <id>`: judge **one** client only (issue #3699).
|
|
83
|
+
*
|
|
84
|
+
* The checks in `verify()` are per client — each block reads one assistant's own files — but the
|
|
85
|
+
* verdict was per machine: on a machine with three assistants, one broken assistant condemned all
|
|
86
|
+
* three, and a healthy one was indistinguishable from a broken one (#3696). The question a
|
|
87
|
+
* multi-client machine actually asks is "how is *the one I use*", so this flag answers exactly
|
|
88
|
+
* that: the other clients are not checked, not printed, and not counted.
|
|
89
|
+
*
|
|
90
|
+
* Not to be confused with `--client-id`, which pins this machine's pseudonym and has nothing to do
|
|
91
|
+
* with the assistants.
|
|
92
|
+
*/
|
|
93
|
+
const CLIENT = valueOf('--client', '').trim().toLowerCase();
|
|
94
|
+
|
|
95
|
+
/** Should this client's checks run? A `--client` filter narrows the whole run down to one. */
|
|
96
|
+
const wantClient = (id) => !CLIENT || CLIENT === id;
|
|
97
|
+
|
|
80
98
|
const DRY = has('--dry-run');
|
|
81
99
|
/**
|
|
82
100
|
* Permission tiers (2026-09-18).
|
|
@@ -116,12 +134,34 @@ const HOME = resolve(valueOf('--home', homedir()));
|
|
|
116
134
|
const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
|
|
117
135
|
'cursor', 'gemini', 'copilot', 'opencode', 'kiro', 'mcode'];
|
|
118
136
|
|
|
137
|
+
/**
|
|
138
|
+
* The three line lists, and what every line knows about itself.
|
|
139
|
+
*
|
|
140
|
+
* A line knows the **client** it is about, and a failure knows its **kind** — what is wrong, which
|
|
141
|
+
* is what decides the fix (issue #3699). The checks in `verify()` are per client (each block reads
|
|
142
|
+
* one assistant's own files), but the output could only say "this machine is ready": a machine with
|
|
143
|
+
* three assistants was condemned because of the one the user never touches, and a healthy assistant
|
|
144
|
+
* was indistinguishable from a broken one (#3696, #3699).
|
|
145
|
+
*
|
|
146
|
+
* The kinds:
|
|
147
|
+
* 'blocked' — we cannot write there. A re-run writes exactly as much as the last one did, so
|
|
148
|
+
* "re-run the install" is not the advice (measured 2026-10-22 on an OpenClaw machine
|
|
149
|
+
* whose workspace was root-owned and read-only — see `unwritableAdvice`).
|
|
150
|
+
* 'absent' — our footprint is not there at all. A re-run puts it there.
|
|
151
|
+
* 'broken' — our footprint is there but wrong (wrong endpoint, missing field). A re-run fixes it.
|
|
152
|
+
*
|
|
153
|
+
* The client id is '' for a line that is about the machine rather than about one client: the
|
|
154
|
+
* endpoint probe, the token, the version stamp.
|
|
155
|
+
*/
|
|
119
156
|
const done = [];
|
|
120
157
|
const manual = [];
|
|
121
158
|
const skipped = [];
|
|
122
|
-
const ok = (m) => done.push(m);
|
|
123
|
-
const need = (m) => manual.push(m);
|
|
124
|
-
const skip = (m) => skipped.push(m);
|
|
159
|
+
const ok = (m, client = '') => done.push({ text: m, client });
|
|
160
|
+
const need = (m, client = '', kind = '') => manual.push({ text: m, client, kind });
|
|
161
|
+
const skip = (m, client = '') => skipped.push({ text: m, client });
|
|
162
|
+
|
|
163
|
+
/** The display name of a client id — `AGENT_LABELS`, with the id as the fallback. */
|
|
164
|
+
const clientLabel = (id) => AGENT_LABELS[id] || id;
|
|
125
165
|
|
|
126
166
|
function readJson(path, fallback) {
|
|
127
167
|
try {
|
|
@@ -157,7 +197,7 @@ function readText(path) {
|
|
|
157
197
|
* except the two that name the version (measured 2026-10-22: the 0.6.0 release pull request
|
|
158
198
|
* failed 9 e2e legs on exactly that).
|
|
159
199
|
*/
|
|
160
|
-
const VERSION = '0.
|
|
200
|
+
const VERSION = '0.8.1' // x-release-please-version
|
|
161
201
|
|
|
162
202
|
/**
|
|
163
203
|
* The shape of a client id, in one place.
|
|
@@ -181,6 +221,7 @@ const REFERRAL_SHAPE = /^[A-Za-z0-9]{4,16}$/
|
|
|
181
221
|
const FLAGS = [
|
|
182
222
|
['--home <dir>', '把配置写到这个目录,而不是真实家目录(不是沙箱:--uninstall 也认这个目录)'],
|
|
183
223
|
['--only <a,b>', `只处理列出的助手(${AGENTS.join(' / ')})`],
|
|
224
|
+
['--client <id>', `只检查这一个客户端(${AGENTS.join(' / ')})——只判它,不判整台机器`],
|
|
184
225
|
['--dry-run', '只说不做:打印会改哪些文件,不写任何东西'],
|
|
185
226
|
['--silent', '只压掉进度叙述,绝不压掉 ! 行与报告(MDM/GPO 用)'],
|
|
186
227
|
['--report', '打印一段可公开粘贴的脱敏状态报告(人读 YAML)'],
|
|
@@ -198,7 +239,7 @@ const FLAGS = [
|
|
|
198
239
|
['--help, -h', '打印这份帮助并退出'],
|
|
199
240
|
['--version', '打印版本并退出'],
|
|
200
241
|
];
|
|
201
|
-
const VALUE_FLAGS = ['--home', '--only', '--client-id'];
|
|
242
|
+
const VALUE_FLAGS = ['--home', '--only', '--client-id', '--client'];
|
|
202
243
|
|
|
203
244
|
function printHelp() {
|
|
204
245
|
console.log(`MisakaNet 安装程序 ${VERSION}
|
|
@@ -574,16 +615,27 @@ function buildWriteManifest(agents) {
|
|
|
574
615
|
// install: with no target detected the run writes nothing at all.
|
|
575
616
|
if (agents.length) {
|
|
576
617
|
// `--no-register` registers no anonymous node, so no token is written; listing it anyway would
|
|
577
|
-
// make the manifest — and the preview's file count — promise a file that stays absent.
|
|
578
|
-
if
|
|
618
|
+
// make the manifest — and the preview's file count — promise a file that stays absent. Even on
|
|
619
|
+
// a registering run it is only written *if the network lets the registration through*, so the
|
|
620
|
+
// row says so instead of presenting it as a file this run will certainly create (#3833).
|
|
621
|
+
if (!has('--no-register')) {
|
|
622
|
+
add(join(stateDir(), 'token'), 'create (0600,取决于注册是否成功)', 1, 'token');
|
|
623
|
+
}
|
|
579
624
|
if (!MCP_ONLY) {
|
|
625
|
+
// `hook.mjs` is the *Claude Code* hook's body: `installHook()` runs only when `claude` is one
|
|
626
|
+
// of the targets. Listing it for mcode/codex/… promised a file those installs never write —
|
|
627
|
+
// in the same run that said "没有 Claude Code 那种钩子" (measured 2026-10-25, #3833).
|
|
628
|
+
if (agents.includes('claude')) add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
|
|
580
629
|
// The endpoint record lives in the state directory, so it is tier ③ like everything else in
|
|
581
630
|
// there: `--mcp-only` must not create that directory at all (the e2e checks the directory
|
|
582
631
|
// itself, not just the files). The record is therefore written only when tier ③ is in scope.
|
|
583
632
|
add(join(stateDir(), STATE_ENDPOINT_FILE), 'create', 3, 'endpoint');
|
|
584
|
-
add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
|
|
585
633
|
add(join(stateDir(), STATE_VERSION_FILE), 'create', 3, 'version');
|
|
586
634
|
}
|
|
635
|
+
// The pseudonym is persisted only on a run that has to mint one (#3832) — so the manifest
|
|
636
|
+
// names that file only then, keeping the "every path exists afterwards" rule. Tier ① like the
|
|
637
|
+
// token: `ensureIdentity()` writes both, and `--mcp-only` still registers.
|
|
638
|
+
if (willMintClientId()) add(join(stateDir(), CLIENT_ID_FILE), 'create', 1, 'clientId');
|
|
587
639
|
}
|
|
588
640
|
return [...rows.values()]
|
|
589
641
|
.filter((row) => (MCP_ONLY ? row.tier === 1 : true))
|
|
@@ -616,10 +668,13 @@ function writePreview(rows) {
|
|
|
616
668
|
grants: '只读工具放行',
|
|
617
669
|
rules: '规则块(会改变 agent 的行为)',
|
|
618
670
|
hook: '钩子',
|
|
619
|
-
token
|
|
671
|
+
// The token is written only when the anonymous registration succeeds, so the preview says so
|
|
672
|
+
// rather than counting it as a file this run will certainly create (#3833).
|
|
673
|
+
token: 'MCP 凭据(取决于注册是否成功)',
|
|
620
674
|
endpoint: '端点记录',
|
|
621
675
|
hookScript: '钩子脚本',
|
|
622
676
|
version: '版本戳',
|
|
677
|
+
clientId: '身份编号',
|
|
623
678
|
state: '本机状态',
|
|
624
679
|
};
|
|
625
680
|
const width = Math.min(Math.max(...list.map((f) => f.path.length)) + 2, 32);
|
|
@@ -827,7 +882,11 @@ function stripBlock(path, block = PROMPT_BLOCK) {
|
|
|
827
882
|
return true;
|
|
828
883
|
}
|
|
829
884
|
|
|
830
|
-
|
|
885
|
+
// The hook already reads MISAKANET_AGENT_DIR (checkpoint_reminder.mjs), so honouring it here is
|
|
886
|
+
// what lets an install work on a machine whose HOME is not writable for the runtime user (#3701):
|
|
887
|
+
// writes and --verify then agree on the same directory instead of failing to mkdir under HOME.
|
|
888
|
+
// Kept unresolved on purpose — the hook does not resolve it either, so both ends read one path.
|
|
889
|
+
const stateDir = () => process.env.MISAKANET_AGENT_DIR || join(HOME, '.misakanet-agent');
|
|
831
890
|
|
|
832
891
|
/**
|
|
833
892
|
* The headers every MCP entry we write carries.
|
|
@@ -923,6 +982,91 @@ function adoptExistingClientId(text) {
|
|
|
923
982
|
clientIdHint = found;
|
|
924
983
|
}
|
|
925
984
|
|
|
985
|
+
/** The state file that pins this machine's pseudonym, so `--verify`-able identity survives a run. */
|
|
986
|
+
const CLIENT_ID_FILE = 'client_id';
|
|
987
|
+
|
|
988
|
+
/** The id `--client-id` / `MISAKANET_CLIENT_ID` asks for, shape-checked, or `''`. */
|
|
989
|
+
function explicitClientId() {
|
|
990
|
+
const value = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
|
|
991
|
+
return CLIENT_ID_SHAPE.test(value) ? value : '';
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** The id `<stateDir>/client_id` pins, or `''` — shape-checked, because a file wrote it. */
|
|
995
|
+
function storedClientId() {
|
|
996
|
+
const found = readText(join(stateDir(), CLIENT_ID_FILE)).trim();
|
|
997
|
+
return CLIENT_ID_SHAPE.test(found) ? found : '';
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* The pseudonym *any* config on this machine already declares, or `''`.
|
|
1002
|
+
*
|
|
1003
|
+
* "Which config" cannot be "the one this run is about to rewrite". A machine that installed one
|
|
1004
|
+
* target and then installed a second wrote the second config with no `X-MisakaNet-Client` at all:
|
|
1005
|
+
* `ensureIdentity()` returned early (the token existed), it never set `knownClientId`, and the
|
|
1006
|
+
* per-target adopters only look at their own file (issue #3832, hole 1). Every surface this
|
|
1007
|
+
* installer writes is a candidate, walked in `AGENTS` order so the answer is deterministic.
|
|
1008
|
+
*/
|
|
1009
|
+
function clientIdOnMachine() {
|
|
1010
|
+
for (const agent of AGENTS) {
|
|
1011
|
+
for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
|
|
1012
|
+
const found = readText(path).match(CLIENT_ID_IN_CONFIG)?.[1] || '';
|
|
1013
|
+
if (CLIENT_ID_SHAPE.test(found)) return found;
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
return '';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Will this run have to mint the pseudonym, and therefore persist it?
|
|
1021
|
+
*
|
|
1022
|
+
* Asked by the write manifest so the preview names `<stateDir>/client_id` only when the run is
|
|
1023
|
+
* really going to create it (#3832) — the same "every path must exist afterwards" rule the rest of
|
|
1024
|
+
* the manifest follows. Mirrors `clientIdForThisRun()`: nothing explicit, nothing pinned, nothing
|
|
1025
|
+
* to adopt → a mint (and a write).
|
|
1026
|
+
*/
|
|
1027
|
+
function willMintClientId() {
|
|
1028
|
+
if (has('--no-register')) return false;
|
|
1029
|
+
return !explicitClientId() && !storedClientId() && !clientIdOnMachine();
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** Remember the id and show it to the user: it is a number worth keeping for a reinstall. */
|
|
1033
|
+
function rememberClientId(id) {
|
|
1034
|
+
knownClientId = id;
|
|
1035
|
+
clientIdHint = id;
|
|
1036
|
+
return id;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Resolve `X-MisakaNet-Client` for a run that is *not* given an explicit id (#3832).
|
|
1041
|
+
*
|
|
1042
|
+
* Three sources, in order of how much the machine means them:
|
|
1043
|
+
* 1. `<stateDir>/client_id` — what a previous run pinned;
|
|
1044
|
+
* 2. any config that already declares one — the file *is* the record (#3772's rule, widened from
|
|
1045
|
+
* "the file I am about to rewrite" to "the machine");
|
|
1046
|
+
* 3. nothing — mint `setup-<uuid>` and **persist it**, so the next run has something to reuse.
|
|
1047
|
+
*
|
|
1048
|
+
* Persisting is the part #1688 deliberately removed, and the tradeoff is worth stating: #1688
|
|
1049
|
+
* dropped it because "read a local file, put the value in an outbound request" is the shape CodeQL
|
|
1050
|
+
* js/file-access-to-http (#268) flags, and the value is sent to a server. This file holds nothing
|
|
1051
|
+
* secret — it is a pseudonym the installer itself minted, shape-checked on read (`CLIENT_ID_SHAPE`),
|
|
1052
|
+
* and the server treats it as a hint, never as identity (AGENTS.md §3.3). Against that, *not*
|
|
1053
|
+
* persisting it means every failed registration mints a new id, rewrites the user's agent config
|
|
1054
|
+
* with it and leaves a backup behind — measurably wrong on the re-run path the installer tells the
|
|
1055
|
+
* user to walk ("稍后重跑本命令就能拿到 token"). One pinned pseudonym beats five unregistered
|
|
1056
|
+
* ones, so #3832 records the tradeoff here and keeps the file.
|
|
1057
|
+
*/
|
|
1058
|
+
function clientIdForThisRun() {
|
|
1059
|
+
if (knownClientId) return knownClientId;
|
|
1060
|
+
const stored = storedClientId();
|
|
1061
|
+
if (stored) return rememberClientId(stored);
|
|
1062
|
+
const adopted = clientIdOnMachine();
|
|
1063
|
+
if (adopted) return rememberClientId(adopted);
|
|
1064
|
+
const minted = `setup-${randomUUID()}`;
|
|
1065
|
+
rememberClientId(minted);
|
|
1066
|
+
writeText(join(stateDir(), CLIENT_ID_FILE), `${minted}\n`);
|
|
1067
|
+
return minted;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
926
1070
|
const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
|
|
927
1071
|
|
|
928
1072
|
/**
|
|
@@ -954,6 +1098,35 @@ function expectedEndpoint() {
|
|
|
954
1098
|
return CANONICAL_ENDPOINT;
|
|
955
1099
|
}
|
|
956
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* The endpoint URL in a Hermes config's `mcp_servers.misakanet` entry.
|
|
1103
|
+
*
|
|
1104
|
+
* Hermes' config is YAML and this installer has no YAML parser, so the URL is read the way it is
|
|
1105
|
+
* written: the `url:` line of the `misakanet:` block. Both shapes put it there — the block this
|
|
1106
|
+
* installer writes (marked with the comment pair) and the bare entry a user added through the
|
|
1107
|
+
* Hermes CLI.
|
|
1108
|
+
*/
|
|
1109
|
+
function hermesMcpUrl(text) {
|
|
1110
|
+
const lines = text.split('\n');
|
|
1111
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1112
|
+
const head = lines[i].match(/^([ \t]*)misakanet:/);
|
|
1113
|
+
if (!head) continue;
|
|
1114
|
+
const indent = head[1].length;
|
|
1115
|
+
// The entry's own keys are indented deeper than the `misakanet:` line; the first line that is
|
|
1116
|
+
// not ends the entry, and the next entry's `url:` must not be read as this one's.
|
|
1117
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
1118
|
+
const line = lines[j];
|
|
1119
|
+
if (!line.trim()) continue;
|
|
1120
|
+
const lineIndent = line.match(/^[ \t]*/)[0].length;
|
|
1121
|
+
if (lineIndent <= indent) break;
|
|
1122
|
+
const url = line.match(/^[ \t]*url:[ \t]*(.+?)[ \t]*$/);
|
|
1123
|
+
// The quotes are optional in YAML, and a user-added entry may well have them.
|
|
1124
|
+
if (url) return url[1].replace(/^["']|["']$/g, '');
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return '';
|
|
1128
|
+
}
|
|
1129
|
+
|
|
957
1130
|
/**
|
|
958
1131
|
* One verdict for every MCP entry this installer writes.
|
|
959
1132
|
*
|
|
@@ -1072,6 +1245,40 @@ let lastResponseOk = false;
|
|
|
1072
1245
|
// `--verify` can print is what actually came back (`{}` is not a diagnosis).
|
|
1073
1246
|
let lastResponsePreview = '';
|
|
1074
1247
|
|
|
1248
|
+
// The server's retry horizon on the last response, in seconds, when it sent one: the `Retry-After`
|
|
1249
|
+
// response header (seconds or an HTTP-date), the standard shape `docs/index.html` already reads on
|
|
1250
|
+
// 429s. Per-request, so a horizon from an earlier call can never leak into a later failure's words.
|
|
1251
|
+
let lastRetryAfterSeconds = null;
|
|
1252
|
+
|
|
1253
|
+
/** `Retry-After` → seconds. Both forms the header allows; anything else is "no horizon", not 0. */
|
|
1254
|
+
function retryAfterSeconds(headerValue) {
|
|
1255
|
+
const raw = String(headerValue || '').trim();
|
|
1256
|
+
if (!raw) return null;
|
|
1257
|
+
if (/^\d+$/.test(raw)) return Number(raw);
|
|
1258
|
+
const at = Date.parse(raw);
|
|
1259
|
+
if (Number.isNaN(at)) return null;
|
|
1260
|
+
return Math.max(0, Math.round((at - Date.now()) / 1000));
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* How long the server says to wait before re-running, when it says. The failure body's `retry_after`
|
|
1265
|
+
* (seconds, HTTP `Retry-After` semantics) wins over the header, and neither is guessed: a server that
|
|
1266
|
+
* stays silent gets no horizon sentence at all.
|
|
1267
|
+
*/
|
|
1268
|
+
function retryHorizonOf(result) {
|
|
1269
|
+
const fromBody = result && (result.retry_after ?? result.retry_after_seconds);
|
|
1270
|
+
const n = Number(fromBody);
|
|
1271
|
+
if (Number.isFinite(n) && n > 0) return Math.round(n);
|
|
1272
|
+
return lastRetryAfterSeconds;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** "45 秒" / "15 分钟" / "2 小时" — the horizon in the words a person reads a clock in. */
|
|
1276
|
+
function humanWait(seconds) {
|
|
1277
|
+
if (seconds < 60) return `${seconds} 秒`;
|
|
1278
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)} 分钟`;
|
|
1279
|
+
return `${Math.round(seconds / 3600)} 小时`;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1075
1282
|
async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
|
|
1076
1283
|
const headers = {
|
|
1077
1284
|
'Content-Type': 'application/json',
|
|
@@ -1087,6 +1294,7 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
|
|
|
1087
1294
|
lastRequestKind = '';
|
|
1088
1295
|
lastResponseOk = false;
|
|
1089
1296
|
lastResponsePreview = '';
|
|
1297
|
+
lastRetryAfterSeconds = null;
|
|
1090
1298
|
try {
|
|
1091
1299
|
const response = await fetch(urlOverride || ENDPOINT, {
|
|
1092
1300
|
method: 'POST',
|
|
@@ -1098,6 +1306,9 @@ async function mcpRequest(method, params, timeoutMs = 6000, urlOverride = '') {
|
|
|
1098
1306
|
// below, not by this line — but "it answered with something that is not a handshake" and
|
|
1099
1307
|
// "nothing answered" are different facts (see probeOutcome()).
|
|
1100
1308
|
lastResponseOk = true;
|
|
1309
|
+
lastRetryAfterSeconds = retryAfterSeconds(
|
|
1310
|
+
response.headers && typeof response.headers.get === 'function'
|
|
1311
|
+
? response.headers.get('Retry-After') : null);
|
|
1101
1312
|
const payload = await response.json();
|
|
1102
1313
|
lastResponsePreview = JSON.stringify(payload).slice(0, 160);
|
|
1103
1314
|
if (payload && payload.error) {
|
|
@@ -1371,7 +1582,9 @@ async function installHook() {
|
|
|
1371
1582
|
if (!DRY) {
|
|
1372
1583
|
mkdirSync(stateDir(), { recursive: true });
|
|
1373
1584
|
if (existing !== null) backup(hookPath);
|
|
1374
|
-
writeFileSync
|
|
1585
|
+
// `writeText`, not `writeFileSync`: the hook is a file this run wrote, so the closing count has
|
|
1586
|
+
// to include it — and it must be replaced atomically like every other file here (#3833).
|
|
1587
|
+
writeText(hookPath, bundled);
|
|
1375
1588
|
}
|
|
1376
1589
|
ok(isOurs
|
|
1377
1590
|
? `自动沉淀的钩子已更新(旧版备份为 ${hookPath}.misakanet.bak)→ ${hookPath}`
|
|
@@ -1399,22 +1612,23 @@ async function ensureIdentity() {
|
|
|
1399
1612
|
}
|
|
1400
1613
|
if (existing) {
|
|
1401
1614
|
ok('已有匿名身份(token 已存在)');
|
|
1615
|
+
// #3832 hole 1: this early return used to leave `knownClientId` empty, and the npm channel
|
|
1616
|
+
// persisted nothing, so the *next* target's config was written with no `X-MisakaNet-Client` at
|
|
1617
|
+
// all. Resolve the identity here too — nothing is registered this run, but the header still has
|
|
1618
|
+
// to say who this machine is.
|
|
1619
|
+
clientIdForThisRun();
|
|
1402
1620
|
return existing;
|
|
1403
1621
|
}
|
|
1404
1622
|
if (DRY) {
|
|
1405
1623
|
ok(`会注册匿名身份并把 token 写到 ${file}`);
|
|
1406
1624
|
return '';
|
|
1407
1625
|
}
|
|
1408
|
-
// The client id is what makes a re-registration return the *same* node
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
//
|
|
1413
|
-
|
|
1414
|
-
// `--client-id` and `MISAKANET_CLIENT_ID` are the same explicit intent. The flag exists because
|
|
1415
|
-
// the closing guidance told users to keep an identity and the only way to supply one was a shell
|
|
1416
|
-
// export — an instruction a non-technical user cannot follow, and on Windows a different one.
|
|
1417
|
-
const clientId = usable ? explicit : `setup-${randomUUID()}`;
|
|
1626
|
+
// The client id is what makes a re-registration return the *same* node. It used to be minted
|
|
1627
|
+
// fresh on every attempt that failed — held only in memory, so the re-run the installer asks for
|
|
1628
|
+
// minted another, rewrote the user's config and left a backup (issue #3832, hole 2).
|
|
1629
|
+
// `clientIdForThisRun()` pins the value in `<stateDir>/client_id`; its docstring records the
|
|
1630
|
+
// #1688 vs CodeQL tradeoff that makes reading that file back acceptable.
|
|
1631
|
+
const clientId = usable ? explicit : clientIdForThisRun();
|
|
1418
1632
|
knownClientId = clientId;
|
|
1419
1633
|
const referral = declaredReferral();
|
|
1420
1634
|
const result = await mcpCall('misakanet_register', {
|
|
@@ -1442,8 +1656,15 @@ async function ensureIdentity() {
|
|
|
1442
1656
|
: serverCode
|
|
1443
1657
|
? `服务端拒绝了这次注册(${serverCode})`
|
|
1444
1658
|
: `多半是网络(${lastRequestError || '没有收到响应'})`;
|
|
1659
|
+
// …and when the server says *how long* to wait, say that too instead of "稍后". The horizon is
|
|
1660
|
+
// never guessed and never rounded into a promise: a server that sent none keeps the vague
|
|
1661
|
+
// wording, because a made-up wait is the same lie as the network guess this replaced (issue
|
|
1662
|
+
// #3511 — the retry-horizon half; the `code` half landed with #3512).
|
|
1663
|
+
const horizon = retryHorizonOf(result);
|
|
1445
1664
|
need(`匿名注册没成功:${cause} → 读课程不受影响,但写入类工具(write_lesson)现在用不了;`
|
|
1446
|
-
+
|
|
1665
|
+
+ (horizon
|
|
1666
|
+
? `约 ${humanWait(horizon)}后重跑本命令就能拿到 token(服务端给出的重试窗口)`
|
|
1667
|
+
: '稍后重跑本命令就能拿到 token')
|
|
1447
1668
|
+ (serverHint ? `(服务端提示:${serverHint})` : ''));
|
|
1448
1669
|
return '';
|
|
1449
1670
|
}
|
|
@@ -1586,10 +1807,14 @@ async function installClaude(hookPath, bearer) {
|
|
|
1586
1807
|
const destDir = join(stateDir(), 'voice');
|
|
1587
1808
|
if (!DRY) {
|
|
1588
1809
|
mkdirSync(destDir, { recursive: true });
|
|
1810
|
+
// `copyFileSync` writes a file too, so these join `writtenFiles`: without it a `--voice`
|
|
1811
|
+
// install under-reports its count the same way the version stamp did (#3833).
|
|
1589
1812
|
copyFileSync(voice.player, join(destDir, 'voice-hook.mjs'));
|
|
1813
|
+
writtenFiles.add(join(destDir, 'voice-hook.mjs'));
|
|
1590
1814
|
const cues = existsSync(voice.cues) ? readdirSync(voice.cues).filter((f) => f.endsWith('.mp3')) : [];
|
|
1591
1815
|
for (const cue of cues) {
|
|
1592
1816
|
copyFileSync(join(voice.cues, cue), join(destDir, cue));
|
|
1817
|
+
writtenFiles.add(join(destDir, cue));
|
|
1593
1818
|
}
|
|
1594
1819
|
if (!cues.length) {
|
|
1595
1820
|
need('语音钩子:找到了播放器但没有找到音频文件 → 重装一次 npx 包即可(其它功能不受影响)');
|
|
@@ -1779,7 +2004,11 @@ function stampVersion() {
|
|
|
1779
2004
|
installed_at: new Date().toISOString(),
|
|
1780
2005
|
};
|
|
1781
2006
|
mkdirSync(stateDir(), { recursive: true });
|
|
1782
|
-
writeFileSync
|
|
2007
|
+
// `writeFileSync` here bypassed `writeText()`'s atomic replace *and* its `writtenFiles`
|
|
2008
|
+
// bookkeeping, so every fresh install reported one file fewer than it had written (#3833) — and
|
|
2009
|
+
// this was the one write path left without the truncation protection the rest of the installer
|
|
2010
|
+
// went through.
|
|
2011
|
+
writeText(file, `${JSON.stringify(stamp, null, 2)}\n`);
|
|
1783
2012
|
ok(`版本戳 → ${file}(${VERSION}${existing?.version ? `,原 ${existing.version}` : ''})`);
|
|
1784
2013
|
}
|
|
1785
2014
|
|
|
@@ -2169,6 +2398,11 @@ function unwritableAdvice(manual = '') {
|
|
|
2169
2398
|
: `只读或权限不足,重跑也写不进去 → ${fix}`;
|
|
2170
2399
|
}
|
|
2171
2400
|
|
|
2401
|
+
// Claude Code is in use when its user-level config file exists — a stale `~/.claude/` directory is
|
|
2402
|
+
// not evidence (#3699). The install still detects on either (it can offer to set Claude up); the
|
|
2403
|
+
// verification and the report must not blame a machine that merely left the directory behind.
|
|
2404
|
+
const claudeCodeInUse = () => existsSync(join(HOME, '.claude.json'));
|
|
2405
|
+
|
|
2172
2406
|
async function verify() {
|
|
2173
2407
|
let allOk = true;
|
|
2174
2408
|
// The report answers two different questions, and one of them failing must not condemn the
|
|
@@ -2185,6 +2419,21 @@ async function verify() {
|
|
|
2185
2419
|
// footprint, and it says nothing about the endpoint.
|
|
2186
2420
|
let installOk = true;
|
|
2187
2421
|
const installFailed = () => { installOk = false; allOk = false; };
|
|
2422
|
+
/**
|
|
2423
|
+
* A check whose answer is "we cannot write there" or "our footprint is not there".
|
|
2424
|
+
*
|
|
2425
|
+
* The two answers have different fixes, and the per-client summary has to name which one it is:
|
|
2426
|
+
* a read-only target is **not** fixed by re-running the installer — a re-run writes exactly as
|
|
2427
|
+
* much as the last one did (measured 2026-10-22 on an OpenClaw machine whose workspace was
|
|
2428
|
+
* root-owned and read-only; see `unwritableAdvice`). So the kind is decided **here**, where the
|
|
2429
|
+
* writability is measured, and never by reading the wording of the message: a message that
|
|
2430
|
+
* changes its phrasing must not silently change the verdict.
|
|
2431
|
+
*/
|
|
2432
|
+
const needWrite = (client, path, absentMsg, blockedMsg) => {
|
|
2433
|
+
installFailed();
|
|
2434
|
+
if (canCreateHere(path)) need(absentMsg, client, 'absent');
|
|
2435
|
+
else need(blockedMsg, client, 'blocked');
|
|
2436
|
+
};
|
|
2188
2437
|
// Where each segment starts, in the three line lists. The output prints the two segments as
|
|
2189
2438
|
// two sections, and this boundary is the only thing that has to be right about them.
|
|
2190
2439
|
const segmentStart = { done: done.length, manual: manual.length, skipped: skipped.length };
|
|
@@ -2206,13 +2455,16 @@ async function verify() {
|
|
|
2206
2455
|
try {
|
|
2207
2456
|
const granted = readJson(join(HOME, '.claude', 'settings.json'), null)?.permissions?.allow || [];
|
|
2208
2457
|
const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
|
|
2209
|
-
if (
|
|
2458
|
+
if (wantClient('claude') && claudeCodeInUse() && missing.length) {
|
|
2210
2459
|
allOk = false;
|
|
2211
2460
|
// Not an install fact: the endpoint answers, and the search still fails. It belongs to
|
|
2212
2461
|
// the usability segment, so it belongs there in the output too.
|
|
2213
2462
|
usable = false;
|
|
2463
|
+
// It is a Claude Code fact all the same — the permission list is Claude Code's own
|
|
2464
|
+
// config — so it is judged under Claude Code and not under the machine (#3699). The fix
|
|
2465
|
+
// is the same re-run, which is what 'absent' means: the grant is not in the list yet.
|
|
2214
2466
|
need(`Claude Code:只读 MCP 工具没有放行(${missing.length} 个)→ 第一次检索会被权限拦下;`
|
|
2215
|
-
+ '重跑安装命令即可放行');
|
|
2467
|
+
+ '重跑安装命令即可放行', 'claude', 'absent');
|
|
2216
2468
|
}
|
|
2217
2469
|
} catch { /* a settings file we cannot read is reported by the Claude checks below */ }
|
|
2218
2470
|
} else if (outcome.state === 'non-mcp') {
|
|
@@ -2261,11 +2513,20 @@ async function verify() {
|
|
|
2261
2513
|
// self-contradiction the issue was filed for (`detected-agents: []` next to
|
|
2262
2514
|
// `install-complete: complete`). Judged on "no target in AGENTS is detected", not on one
|
|
2263
2515
|
// hard-coded directory: any of the supported assistants in `AGENTS` counts.
|
|
2264
|
-
|
|
2516
|
+
// `detected-agents` counts only the assistants in scope: with `--client <id>` the question is
|
|
2517
|
+
// about that one, so another assistant's absence is not this machine's problem (#3699).
|
|
2518
|
+
const detectedAgents = AGENTS.filter((agent) => wantClient(agent) && detect(agent));
|
|
2265
2519
|
if (!detectedAgents.length) {
|
|
2266
2520
|
installFailed();
|
|
2267
|
-
|
|
2268
|
-
|
|
2521
|
+
if (CLIENT) {
|
|
2522
|
+
// The user named a client this machine does not have. That is an answer, not a silence:
|
|
2523
|
+
// "OpenClaw:没装过(这台机器上没检测到它)" says what to do about it.
|
|
2524
|
+
need(`${clientLabel(CLIENT)}:没装过(这台机器上没检测到它)`
|
|
2525
|
+
+ '→ 先打开一次那个助手,再回来运行本命令', CLIENT, 'absent');
|
|
2526
|
+
} else {
|
|
2527
|
+
need('没有检测到任何受支持的助手 —— 因此什么都没装'
|
|
2528
|
+
+ `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
|
|
2529
|
+
}
|
|
2269
2530
|
}
|
|
2270
2531
|
const tokenFile = join(stateDir(), 'token');
|
|
2271
2532
|
let tokenPresent = '';
|
|
@@ -2289,7 +2550,7 @@ async function verify() {
|
|
|
2289
2550
|
// Cursor-only install reported NOT READY with two Claude Code complaints about a target that was
|
|
2290
2551
|
// never selected — the same lie the OpenClaw and Hermes checks below already refuse to tell
|
|
2291
2552
|
// (found while adding Cursor, which is mcp-only by construction).
|
|
2292
|
-
if (
|
|
2553
|
+
if (wantClient('claude') && claudeCodeInUse()) {
|
|
2293
2554
|
const hookPath = join(stateDir(), 'hook.mjs');
|
|
2294
2555
|
let hookPresent = false;
|
|
2295
2556
|
try {
|
|
@@ -2298,10 +2559,9 @@ async function verify() {
|
|
|
2298
2559
|
hookPresent = false;
|
|
2299
2560
|
}
|
|
2300
2561
|
if (!hookPresent) {
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
: `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
|
|
2562
|
+
needWrite('claude', hookPath,
|
|
2563
|
+
'自动沉淀钩子:缺失 → 重跑安装命令',
|
|
2564
|
+
`自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
|
|
2305
2565
|
} else {
|
|
2306
2566
|
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
2307
2567
|
const settings = readJson(settingsPath, {}) || {};
|
|
@@ -2312,34 +2572,35 @@ async function verify() {
|
|
|
2312
2572
|
.filter((c) => typeof c === 'string');
|
|
2313
2573
|
if (!commands.length) {
|
|
2314
2574
|
installFailed();
|
|
2315
|
-
need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
|
|
2575
|
+
need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)', 'claude', 'absent');
|
|
2316
2576
|
} else {
|
|
2317
2577
|
const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
|
|
2318
2578
|
if (!existsSync(exe)) {
|
|
2319
2579
|
installFailed();
|
|
2320
|
-
need(`Claude Code:钩子里的解释器不存在(${exe})→
|
|
2580
|
+
need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`,
|
|
2581
|
+
'claude', 'broken');
|
|
2321
2582
|
} else {
|
|
2322
|
-
ok('Claude Code:钩子已装且解释器存在');
|
|
2583
|
+
ok('Claude Code:钩子已装且解释器存在', 'claude');
|
|
2323
2584
|
}
|
|
2324
2585
|
}
|
|
2325
2586
|
const cfg = join(HOME, '.claude.json');
|
|
2326
2587
|
const data = readJson(cfg, {}) || {};
|
|
2327
2588
|
const entry = data.mcpServers?.misakanet;
|
|
2328
2589
|
if (!entry) {
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
: `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2590
|
+
needWrite('claude', cfg,
|
|
2591
|
+
`Claude Code:MCP 未注册(${cfg})`,
|
|
2592
|
+
`Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2333
2593
|
} else {
|
|
2334
2594
|
const verdict = mcpEntryState(entry, 'url');
|
|
2335
2595
|
if (verdict.state === 'wrong-url') {
|
|
2336
2596
|
installFailed();
|
|
2337
|
-
need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→
|
|
2597
|
+
need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2598
|
+
'claude', 'broken');
|
|
2338
2599
|
} else if (verdict.state === 'no-url-field') {
|
|
2339
2600
|
installFailed();
|
|
2340
|
-
need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→
|
|
2601
|
+
need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'claude', 'broken');
|
|
2341
2602
|
} else {
|
|
2342
|
-
ok(`Claude Code:MCP 已注册(${verdict.url}
|
|
2603
|
+
ok(`Claude Code:MCP 已注册(${verdict.url})`, 'claude');
|
|
2343
2604
|
}
|
|
2344
2605
|
}
|
|
2345
2606
|
}
|
|
@@ -2348,57 +2609,84 @@ async function verify() {
|
|
|
2348
2609
|
// OpenClaw that it is "not ready" would be a lie about a target that was never selected.
|
|
2349
2610
|
// The state is read from the same file `openclaw mcp list` reports from, which keeps this
|
|
2350
2611
|
// check working where the CLI itself cannot start (its database may be unwritable).
|
|
2351
|
-
if (detect('openclaw')) {
|
|
2612
|
+
if (wantClient('openclaw') && detect('openclaw')) {
|
|
2352
2613
|
const rules = join(openclawWorkspaces()[0], 'AGENTS.md');
|
|
2353
2614
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2354
|
-
installFailed();
|
|
2355
2615
|
// "没装" and "装不了" are different facts with different fixes, and the difference is the
|
|
2356
2616
|
// whole point (issue #3696): a read-only workspace is not something a re-run installs.
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2617
|
+
needWrite('openclaw', rules,
|
|
2618
|
+
`OpenClaw:规则块没装(${rules})→ 重跑安装命令`,
|
|
2619
|
+
`OpenClaw:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2360
2620
|
} else {
|
|
2361
|
-
ok('OpenClaw:规则块已装');
|
|
2621
|
+
ok('OpenClaw:规则块已装', 'openclaw');
|
|
2362
2622
|
}
|
|
2363
2623
|
const cfg = join(HOME, '.openclaw', 'openclaw.json');
|
|
2364
2624
|
const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
|
|
2365
2625
|
if (!entry) {
|
|
2366
|
-
installFailed();
|
|
2367
2626
|
const manual = `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`;
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2627
|
+
needWrite('openclaw', cfg,
|
|
2628
|
+
`OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 ${manual}`,
|
|
2629
|
+
`OpenClaw:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
|
|
2371
2630
|
} else {
|
|
2372
|
-
|
|
2631
|
+
// The URL is checked here, and it was not: this block only ever asked "is there an entry",
|
|
2632
|
+
// so an OpenClaw pointed at `https://BROKEN.invalid/mcp` was reported as "已注册" with the
|
|
2633
|
+
// unreachable URL printed next to the tick. `mcpEntryState` exists for exactly this and was
|
|
2634
|
+
// simply never applied here (it is applied to Claude, Codex, codewhale, the MCP-only
|
|
2635
|
+
// targets and mcode). With a per-client verdict on screen, "就绪" next to an entry nobody
|
|
2636
|
+
// can reach would be a false green (#1984).
|
|
2637
|
+
const verdict = mcpEntryState(entry, 'url');
|
|
2638
|
+
if (verdict.state === 'wrong-url') {
|
|
2639
|
+
installFailed();
|
|
2640
|
+
need(`OpenClaw:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2641
|
+
'openclaw', 'broken');
|
|
2642
|
+
} else if (verdict.state === 'no-url-field') {
|
|
2643
|
+
installFailed();
|
|
2644
|
+
need(`OpenClaw:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'openclaw', 'broken');
|
|
2645
|
+
} else {
|
|
2646
|
+
ok(`OpenClaw:MCP 已注册(${verdict.url})`, 'openclaw');
|
|
2647
|
+
}
|
|
2373
2648
|
}
|
|
2374
2649
|
}
|
|
2375
2650
|
// Hermes: same rule — reported only when the user has it. Its config file *is* the registry,
|
|
2376
2651
|
// so the state is readable without running anything. What cannot be confirmed from here is
|
|
2377
2652
|
// whether Hermes has loaded that file, and the output says so rather than implying it.
|
|
2378
|
-
if (readText(join(HOME, '.hermes', 'config.yaml'))) {
|
|
2653
|
+
if (wantClient('hermes') && readText(join(HOME, '.hermes', 'config.yaml'))) {
|
|
2379
2654
|
const rules = join(HOME, '.hermes', 'SOUL.md');
|
|
2380
2655
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
: `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2656
|
+
needWrite('hermes', rules,
|
|
2657
|
+
`Hermes:规则块没装(${rules})→ 重跑安装命令`,
|
|
2658
|
+
`Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2385
2659
|
} else {
|
|
2386
|
-
ok('Hermes:规则块已装');
|
|
2660
|
+
ok('Hermes:规则块已装', 'hermes');
|
|
2387
2661
|
}
|
|
2388
2662
|
const cfg = join(HOME, '.hermes', 'config.yaml');
|
|
2389
2663
|
const text = readText(cfg);
|
|
2390
2664
|
if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
|
|
2391
|
-
installFailed();
|
|
2392
2665
|
const manual = `hermes mcp add misakanet --url ${ENDPOINT} --auth header`;
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
} else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
|
|
2397
|
-
ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
|
|
2398
|
-
skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
|
|
2666
|
+
needWrite('hermes', cfg,
|
|
2667
|
+
`Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 ${manual}`,
|
|
2668
|
+
`Hermes:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
|
|
2399
2669
|
} else {
|
|
2400
|
-
|
|
2401
|
-
|
|
2670
|
+
// The entry is there — but is it pointed at our endpoint? Same gap as OpenClaw had: the
|
|
2671
|
+
// check asked "is there an entry" and never "does it point at the right place", so a Hermes
|
|
2672
|
+
// on `https://BROKEN.invalid/mcp` was reported as registered (#1984: a green run is not
|
|
2673
|
+
// evidence unless something in it can differ).
|
|
2674
|
+
const url = hermesMcpUrl(text);
|
|
2675
|
+
const expected = expectedEndpoint();
|
|
2676
|
+
if (url && url !== expected) {
|
|
2677
|
+
installFailed();
|
|
2678
|
+
need(`Hermes:MCP 条目指向的端点不对(${url},应为 ${expected})(${cfg})→ 重跑安装命令`,
|
|
2679
|
+
'hermes', 'broken');
|
|
2680
|
+
} else if (!url) {
|
|
2681
|
+
installFailed();
|
|
2682
|
+
need(`Hermes:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'hermes', 'broken');
|
|
2683
|
+
} else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
|
|
2684
|
+
ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)', 'hermes');
|
|
2685
|
+
skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`, 'hermes');
|
|
2686
|
+
} else {
|
|
2687
|
+
ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`, 'hermes');
|
|
2688
|
+
skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核', 'hermes');
|
|
2689
|
+
}
|
|
2402
2690
|
}
|
|
2403
2691
|
}
|
|
2404
2692
|
// Codex and codewhale: reported only when the user has them, like every other target. They were
|
|
@@ -2406,68 +2694,67 @@ async function verify() {
|
|
|
2406
2694
|
// all, so a machine whose Codex entry pointed at `https://BROKEN.invalid/mcp` and whose rules
|
|
2407
2695
|
// block had lost its markers still reported READY with exit code 0 — while `--report`'s
|
|
2408
2696
|
// `detected-agents` listed codex and codewhale as present, and the gate let it through.
|
|
2409
|
-
if (detect('codex')) {
|
|
2697
|
+
if (wantClient('codex') && detect('codex')) {
|
|
2410
2698
|
const rules = join(HOME, '.codex', 'AGENTS.md');
|
|
2411
2699
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
: `Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2700
|
+
needWrite('codex', rules,
|
|
2701
|
+
`Codex:规则块没装(${rules})→ 重跑安装命令`,
|
|
2702
|
+
`Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2416
2703
|
} else {
|
|
2417
|
-
ok('Codex:规则块已装');
|
|
2704
|
+
ok('Codex:规则块已装', 'codex');
|
|
2418
2705
|
}
|
|
2419
2706
|
const cfg = join(HOME, '.codex', 'config.toml');
|
|
2420
2707
|
const toml = readText(cfg);
|
|
2421
2708
|
const table = codexMisakanetTable(toml);
|
|
2422
2709
|
const verdict = mcpEntryState(table, 'url');
|
|
2423
2710
|
if (verdict.state === 'missing') {
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
: `Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2711
|
+
needWrite('codex', cfg,
|
|
2712
|
+
`Codex:MCP 未注册(${cfg})→ 重跑安装命令`,
|
|
2713
|
+
`Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2428
2714
|
} else if (verdict.state === 'wrong-url') {
|
|
2429
2715
|
installFailed();
|
|
2430
|
-
need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→
|
|
2716
|
+
need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2717
|
+
'codex', 'broken');
|
|
2431
2718
|
} else if (verdict.state === 'no-url-field') {
|
|
2432
2719
|
installFailed();
|
|
2433
|
-
need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→
|
|
2720
|
+
need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'codex', 'broken');
|
|
2434
2721
|
} else {
|
|
2435
|
-
ok(`Codex:MCP 已注册(${verdict.url}
|
|
2722
|
+
ok(`Codex:MCP 已注册(${verdict.url})`, 'codex');
|
|
2436
2723
|
}
|
|
2437
2724
|
// The top-level key is what makes Codex speak streamable-http at all: without it the entry
|
|
2438
2725
|
// above is present and still unusable.
|
|
2439
2726
|
if (!hasTopLevelKey(toml, 'experimental_use_rmcp_client')) {
|
|
2440
2727
|
installFailed();
|
|
2441
2728
|
need(`Codex:config.toml 里没有顶级键 experimental_use_rmcp_client(${cfg})`
|
|
2442
|
-
+ '→ Codex 连不上 streamable-http,重跑安装命令即可补上');
|
|
2729
|
+
+ '→ Codex 连不上 streamable-http,重跑安装命令即可补上', 'codex', 'broken');
|
|
2443
2730
|
}
|
|
2444
2731
|
}
|
|
2445
|
-
if (detect('codewhale')) {
|
|
2732
|
+
if (wantClient('codewhale') && detect('codewhale')) {
|
|
2446
2733
|
const cfg = join(HOME, '.codewhale', 'mcp.json');
|
|
2447
2734
|
const entry = (readJson(cfg, null) || {}).servers?.misakanet;
|
|
2448
2735
|
const verdict = mcpEntryState(entry, 'url');
|
|
2449
2736
|
if (verdict.state === 'missing') {
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
: `codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2737
|
+
needWrite('codewhale', cfg,
|
|
2738
|
+
`codewhale:MCP 未注册(${cfg})→ 重跑安装命令`,
|
|
2739
|
+
`codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2454
2740
|
} else if (verdict.state === 'wrong-url') {
|
|
2455
2741
|
installFailed();
|
|
2456
|
-
need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→
|
|
2742
|
+
need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2743
|
+
'codewhale', 'broken');
|
|
2457
2744
|
} else if (verdict.state === 'no-url-field') {
|
|
2458
2745
|
installFailed();
|
|
2459
|
-
need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→
|
|
2746
|
+
need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'codewhale', 'broken');
|
|
2460
2747
|
} else {
|
|
2461
|
-
ok(`codewhale:MCP 已注册(${verdict.url}
|
|
2748
|
+
ok(`codewhale:MCP 已注册(${verdict.url})`, 'codewhale');
|
|
2462
2749
|
}
|
|
2463
2750
|
// Its rules blocks live in the projects it trusts, so that is where they are checked.
|
|
2464
2751
|
for (const project of codewhaleProjects()) {
|
|
2465
2752
|
const rules = join(project, 'AGENTS.md');
|
|
2466
2753
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2467
2754
|
installFailed();
|
|
2468
|
-
need(`codewhale:规则块没装(${rules})→
|
|
2755
|
+
need(`codewhale:规则块没装(${rules})→ 重跑安装命令`, 'codewhale', 'absent');
|
|
2469
2756
|
} else {
|
|
2470
|
-
ok(`codewhale:规则块已装(${rules}
|
|
2757
|
+
ok(`codewhale:规则块已装(${rules})`, 'codewhale');
|
|
2471
2758
|
}
|
|
2472
2759
|
}
|
|
2473
2760
|
}
|
|
@@ -2475,26 +2762,27 @@ async function verify() {
|
|
|
2475
2762
|
// Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
|
|
2476
2763
|
// the client has *loaded* that file cannot be confirmed from here, and the line says so.
|
|
2477
2764
|
for (const [agent, spec] of Object.entries(MCP_ONLY_TARGETS)) {
|
|
2478
|
-
if (!detect(agent)) continue;
|
|
2765
|
+
if (!wantClient(agent) || !detect(agent)) continue;
|
|
2479
2766
|
const cfg = spec.config(HOME);
|
|
2480
2767
|
const entry = (readJson(cfg, null) || {})[spec.container]?.misakanet;
|
|
2481
2768
|
if (!entry) {
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
: `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2769
|
+
needWrite(agent, cfg,
|
|
2770
|
+
`${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`,
|
|
2771
|
+
`${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2486
2772
|
} else {
|
|
2487
2773
|
const verdict = mcpEntryState(entry, spec.urlField);
|
|
2488
2774
|
if (verdict.state === 'no-url-field') {
|
|
2489
2775
|
installFailed();
|
|
2490
2776
|
need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
|
|
2491
|
-
+ `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}
|
|
2777
|
+
+ `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`,
|
|
2778
|
+
agent, 'broken');
|
|
2492
2779
|
} else if (verdict.state === 'wrong-url') {
|
|
2493
2780
|
installFailed();
|
|
2494
|
-
need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→
|
|
2781
|
+
need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2782
|
+
agent, 'broken');
|
|
2495
2783
|
} else {
|
|
2496
|
-
ok(`${spec.label}:MCP 已注册(${verdict.url}
|
|
2497
|
-
skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}
|
|
2784
|
+
ok(`${spec.label}:MCP 已注册(${verdict.url})`, agent);
|
|
2785
|
+
skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`, agent);
|
|
2498
2786
|
}
|
|
2499
2787
|
}
|
|
2500
2788
|
}
|
|
@@ -2503,39 +2791,38 @@ async function verify() {
|
|
|
2503
2791
|
// hand-edit turned into `{}` is exactly the state a re-run fixes. `mcode plugin add` refuses
|
|
2504
2792
|
// local plugins on purpose (LOCAL_PLUGIN_INSTALL_UNSUPPORTED), so its exit code is not evidence
|
|
2505
2793
|
// either way — the directory is, and that is what this reads.
|
|
2506
|
-
if (detect('mcode')) {
|
|
2794
|
+
if (wantClient('mcode') && detect('mcode')) {
|
|
2507
2795
|
const rules = join(HOME, MCODE_RULES_REL);
|
|
2508
2796
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
: `MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2797
|
+
needWrite('mcode', rules,
|
|
2798
|
+
`MiniMax Code:规则块没装(${rules})→ 重跑安装命令`,
|
|
2799
|
+
`MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2513
2800
|
} else {
|
|
2514
|
-
ok('MiniMax Code:规则块已装');
|
|
2801
|
+
ok('MiniMax Code:规则块已装', 'mcode');
|
|
2515
2802
|
}
|
|
2516
2803
|
const plugin = mcodePluginPath(HOME);
|
|
2517
2804
|
const doc = readJson(plugin, null);
|
|
2518
2805
|
if (doc === null) {
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
: `MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
|
|
2806
|
+
needWrite('mcode', plugin,
|
|
2807
|
+
`MiniMax Code:插件清单缺失或不是合法 JSON(${plugin})→ 重跑安装命令`,
|
|
2808
|
+
`MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
|
|
2523
2809
|
} else {
|
|
2524
2810
|
const verdict = mcpEntryState(doc.mcpServers?.misakanet, 'url');
|
|
2525
2811
|
if (verdict.state === 'missing') {
|
|
2526
2812
|
installFailed();
|
|
2527
|
-
need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→
|
|
2813
|
+
need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→ 重跑安装命令`,
|
|
2814
|
+
'mcode', 'absent');
|
|
2528
2815
|
} else if (verdict.state === 'no-url-field') {
|
|
2529
2816
|
installFailed();
|
|
2530
|
-
need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→
|
|
2817
|
+
need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→ 重跑安装命令`, 'mcode', 'broken');
|
|
2531
2818
|
} else if (verdict.state === 'wrong-url') {
|
|
2532
2819
|
installFailed();
|
|
2533
2820
|
need(`MiniMax Code:插件清单指向的端点不对(${verdict.url},应为 ${verdict.expected})(${plugin})`
|
|
2534
|
-
+ '→ 重跑安装命令');
|
|
2821
|
+
+ '→ 重跑安装命令', 'mcode', 'broken');
|
|
2535
2822
|
} else {
|
|
2536
|
-
ok(`MiniMax Code:插件清单已注册(${verdict.url}
|
|
2823
|
+
ok(`MiniMax Code:插件清单已注册(${verdict.url})`, 'mcode');
|
|
2537
2824
|
skip('MiniMax Code:只能确认"插件目录在位",无法确认 mcode 是否已加载 → 跑 `mcode plugin list` 复核'
|
|
2538
|
-
+ '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)');
|
|
2825
|
+
+ '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)', 'mcode');
|
|
2539
2826
|
}
|
|
2540
2827
|
}
|
|
2541
2828
|
}
|
|
@@ -2575,6 +2862,10 @@ async function verify() {
|
|
|
2575
2862
|
// The install segment's open items, counted on their own: the report's `open-items` is every
|
|
2576
2863
|
// manual item, this is the ones that belong to our footprint.
|
|
2577
2864
|
openItems: manual.length - segmentEnd.manual,
|
|
2865
|
+
// One verdict per client, so the conclusion can be read per assistant instead of per machine
|
|
2866
|
+
// (issue #3699). The machine-level verdict below is derived from these, not the other way
|
|
2867
|
+
// round: a client that was not checked cannot be the reason this machine is not ready.
|
|
2868
|
+
clients: clientVerdicts(),
|
|
2578
2869
|
segments: { start: segmentStart, end: segmentEnd },
|
|
2579
2870
|
};
|
|
2580
2871
|
}
|
|
@@ -2606,6 +2897,14 @@ function usabilityText(verdict) {
|
|
|
2606
2897
|
}
|
|
2607
2898
|
|
|
2608
2899
|
function installText(verdict) {
|
|
2900
|
+
// The clients that are not ready, **by name** (issue #3699). "3 项需要手动处理" said how many;
|
|
2901
|
+
// it never said which assistant the three belonged to, so on a multi-client machine the reader
|
|
2902
|
+
// had to go and match the count against the lines above.
|
|
2903
|
+
const notReady = (verdict.clients || []).filter((c) => c.verdict !== '就绪');
|
|
2904
|
+
if (notReady.length) {
|
|
2905
|
+
return `不完整 —— ${notReady.map((c) => `${c.label}:${c.verdict}`).join(';')}`
|
|
2906
|
+
+ '(上面每条 ! 都给了成因和做法)';
|
|
2907
|
+
}
|
|
2609
2908
|
if (verdict.zeroTargets) {
|
|
2610
2909
|
return '不完整 —— 没有检测到任何受支持的助手,因此什么都没装';
|
|
2611
2910
|
}
|
|
@@ -2631,32 +2930,118 @@ function exitCodeFor(verdict) {
|
|
|
2631
2930
|
return (verdict.unverified && verdict.install === 'complete') ? 2 : 1;
|
|
2632
2931
|
}
|
|
2633
2932
|
|
|
2933
|
+
/**
|
|
2934
|
+
* One verdict per client, from the lines that client produced (issue #3699).
|
|
2935
|
+
*
|
|
2936
|
+
* The machine-level verdict was a single boolean over every check, but the checks are per client:
|
|
2937
|
+
* a machine with three assistants was condemned because of the one the user never touches, and a
|
|
2938
|
+
* healthy assistant was indistinguishable from a broken one (#3696, #3699). So the conclusion is
|
|
2939
|
+
* per client — and each client gets one of four words, because the four fixes are four different
|
|
2940
|
+
* things:
|
|
2941
|
+
*
|
|
2942
|
+
* 就绪 — everything we checked for this client passed; there is nothing to do.
|
|
2943
|
+
* 装不了 — we cannot write there. Fix the permissions: a re-run writes exactly as much as the
|
|
2944
|
+
* last one did (measured 2026-10-22 on an OpenClaw machine whose workspace was
|
|
2945
|
+
* root-owned and read-only — see `unwritableAdvice`).
|
|
2946
|
+
* 没装过 — our footprint is not there: the file, the entry, the permission grant. A re-run puts
|
|
2947
|
+
* it there.
|
|
2948
|
+
* 装坏了 — our footprint is there but points at the wrong place: the wrong endpoint, a missing
|
|
2949
|
+
* field, a missing interpreter. A re-run fixes it.
|
|
2950
|
+
*
|
|
2951
|
+
* Issue #3699 names the first three. The fourth exists because "our entry is there and points at
|
|
2952
|
+
* the wrong endpoint" is none of the three, and calling it 没装过 would be a lie about the
|
|
2953
|
+
* machine.
|
|
2954
|
+
*
|
|
2955
|
+
* `blocked` wins over `broken` wins over `absent`: a client we cannot write to is not merely "not
|
|
2956
|
+
* installed", and saying so would send the reader after the wrong fix.
|
|
2957
|
+
*/
|
|
2958
|
+
const CLIENT_VERDICT_WORDS = { blocked: '装不了', broken: '装坏了', absent: '没装过' };
|
|
2959
|
+
/** What to do about each verdict, in the order the fix differs. */
|
|
2960
|
+
const CLIENT_VERDICT_HINTS = {
|
|
2961
|
+
就绪: '',
|
|
2962
|
+
装不了: '(改权限后重跑安装命令)',
|
|
2963
|
+
没装过: '(重跑安装命令)',
|
|
2964
|
+
装坏了: '(重跑安装命令)',
|
|
2965
|
+
};
|
|
2966
|
+
|
|
2967
|
+
function clientVerdicts() {
|
|
2968
|
+
const buckets = new Map();
|
|
2969
|
+
const bucket = (id) => {
|
|
2970
|
+
if (!buckets.has(id)) {
|
|
2971
|
+
buckets.set(id, { id, done: [], manual: [], skipped: [], kinds: new Set() });
|
|
2972
|
+
}
|
|
2973
|
+
return buckets.get(id);
|
|
2974
|
+
};
|
|
2975
|
+
for (const line of done) if (line.client) bucket(line.client).done.push(line);
|
|
2976
|
+
for (const line of manual) {
|
|
2977
|
+
if (!line.client) continue;
|
|
2978
|
+
const b = bucket(line.client);
|
|
2979
|
+
b.manual.push(line);
|
|
2980
|
+
if (line.kind) b.kinds.add(line.kind);
|
|
2981
|
+
}
|
|
2982
|
+
for (const line of skipped) if (line.client) bucket(line.client).skipped.push(line);
|
|
2983
|
+
// `AGENTS` is the order, so the output reads the same way on every machine.
|
|
2984
|
+
return AGENTS.filter((id) => buckets.has(id)).map((id) => {
|
|
2985
|
+
const b = buckets.get(id);
|
|
2986
|
+
const verdict = !b.kinds.size ? '就绪'
|
|
2987
|
+
: b.kinds.has('blocked') ? CLIENT_VERDICT_WORDS.blocked
|
|
2988
|
+
: b.kinds.has('broken') ? CLIENT_VERDICT_WORDS.broken
|
|
2989
|
+
: CLIENT_VERDICT_WORDS.absent;
|
|
2990
|
+
return {
|
|
2991
|
+
id,
|
|
2992
|
+
label: clientLabel(id),
|
|
2993
|
+
verdict,
|
|
2994
|
+
hint: CLIENT_VERDICT_HINTS[verdict],
|
|
2995
|
+
done: b.done,
|
|
2996
|
+
manual: b.manual,
|
|
2997
|
+
skipped: b.skipped,
|
|
2998
|
+
};
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
|
|
2634
3002
|
/**
|
|
2635
3003
|
* The verify output: two sections, because the two questions have two answers.
|
|
2636
3004
|
*
|
|
2637
3005
|
* The old output was one list, and one list cannot say "the endpoint answered, the files did not
|
|
2638
3006
|
* get written" — it can only say NOT READY, which is the sentence the OpenClaw trialer read as
|
|
2639
3007
|
* "MisakaNet does not work here" (issue #3696).
|
|
3008
|
+
*
|
|
3009
|
+
* The install section is grouped **per client** (issue #3699). The checks were per client all
|
|
3010
|
+
* along — each block reads one assistant's own files — but the output could only say "this
|
|
3011
|
+
* machine", so a machine with three assistants was condemned because of the one the user never
|
|
3012
|
+
* touches, and a healthy assistant was indistinguishable from a broken one. Now each client gets
|
|
3013
|
+
* one verdict line and its own details under it.
|
|
2640
3014
|
*/
|
|
2641
3015
|
function renderVerify(verdict) {
|
|
2642
3016
|
const { start, end } = verdict.segments;
|
|
2643
3017
|
const tail = { done: done.length, manual: manual.length, skipped: skipped.length };
|
|
2644
|
-
|
|
3018
|
+
// A line is either about the machine (the endpoint probe, the token, the version stamp) or
|
|
3019
|
+
// about one client. The two never share a line, so the sections can be split by that.
|
|
3020
|
+
const machineLines = (list, from, to) => list.slice(from, to).filter((l) => !l.client);
|
|
3021
|
+
const section = (label, from, to, clients) => {
|
|
2645
3022
|
const out = [];
|
|
2646
|
-
const finished = done
|
|
2647
|
-
const open = manual
|
|
2648
|
-
const ignored = skipped
|
|
3023
|
+
const finished = machineLines(done, from.done, to.done);
|
|
3024
|
+
const open = machineLines(manual, from.manual, to.manual);
|
|
3025
|
+
const ignored = machineLines(skipped, from.skipped, to.skipped);
|
|
2649
3026
|
// `--silent` drops the narration, never the errors (the same contract `render()` keeps): the
|
|
2650
3027
|
// `!` 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}`));
|
|
3028
|
+
if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l.text}`));
|
|
3029
|
+
if (open.length) out.push(...open.map((l) => ` ! ${l.text}`));
|
|
3030
|
+
if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l.text}`));
|
|
3031
|
+
for (const client of clients || []) {
|
|
3032
|
+
// The verdict line is the conclusion, not narration: it survives `--silent`, because a
|
|
3033
|
+
// silent `--verify` that prints nothing about the client cannot be read at all.
|
|
3034
|
+
out.push(` ${client.label}:${client.verdict}${client.hint}`);
|
|
3035
|
+
if (client.done.length && !SILENT) out.push(...client.done.map((l) => ` ✓ ${l.text}`));
|
|
3036
|
+
if (client.manual.length) out.push(...client.manual.map((l) => ` ! ${l.text}`));
|
|
3037
|
+
if (client.skipped.length && !SILENT) out.push(...client.skipped.map((l) => ` · ${l.text}`));
|
|
3038
|
+
}
|
|
2654
3039
|
if (!out.length) return out;
|
|
2655
3040
|
return [`\n── ${label} ──`, ...out];
|
|
2656
3041
|
};
|
|
2657
3042
|
return [
|
|
2658
|
-
...section('端点/可用性', start, end),
|
|
2659
|
-
...section('安装完整性', end, tail),
|
|
3043
|
+
...section('端点/可用性', start, end, []),
|
|
3044
|
+
...section('安装完整性', end, tail, verdict.clients),
|
|
2660
3045
|
'',
|
|
2661
3046
|
`可用性:${usabilityText(verdict)}`,
|
|
2662
3047
|
`安装完整性:${installText(verdict)}`,
|
|
@@ -2754,9 +3139,49 @@ function restoreEmptiedFiles() {
|
|
|
2754
3139
|
}
|
|
2755
3140
|
}
|
|
2756
3141
|
|
|
3142
|
+
/**
|
|
3143
|
+
* Is `~/.minimax/plugins/misakanet/` ours to delete?
|
|
3144
|
+
*
|
|
3145
|
+
* The install side answers this by reading the manifest and refusing to touch a directory whose
|
|
3146
|
+
* `name` is not `misakanet` (`installMcode`), reporting "已原样保留". Uninstall used to `rmSync`
|
|
3147
|
+
* the subtree with no question at all, so on a machine where the install had just promised to keep
|
|
3148
|
+
* the user's plugin, the next `--uninstall` deleted it (issue #3831, measured 2026-10-25) — the
|
|
3149
|
+
* installer's own wording turned into a lie by the command it recommends for undoing.
|
|
3150
|
+
*
|
|
3151
|
+
* A manifest that is absent or unparseable is not proof of ownership either: a plugin directory is
|
|
3152
|
+
* a user-owned space, and `misakanet` is a name another plugin can legitimately have. Being wrong
|
|
3153
|
+
* here costs irreversible data, so only a manifest that says `name: "misakanet"` answers "yes".
|
|
3154
|
+
*/
|
|
3155
|
+
function mcodePluginDirectoryOwnership() {
|
|
3156
|
+
const plugin = mcodePluginPath(HOME);
|
|
3157
|
+
const text = readText(plugin);
|
|
3158
|
+
if (!text) {
|
|
3159
|
+
return { ours: false, why: '里没有 .claude-plugin/plugin.json(无法确认归属)' };
|
|
3160
|
+
}
|
|
3161
|
+
let doc;
|
|
3162
|
+
try {
|
|
3163
|
+
doc = JSON.parse(text);
|
|
3164
|
+
} catch {
|
|
3165
|
+
return { ours: false, why: '的清单不是合法 JSON(无法确认归属)' };
|
|
3166
|
+
}
|
|
3167
|
+
if (doc?.name !== 'misakanet') {
|
|
3168
|
+
return {
|
|
3169
|
+
ours: false,
|
|
3170
|
+
why: `的清单不是本安装器写的(name=${JSON.stringify(doc?.name ?? null)})`,
|
|
3171
|
+
};
|
|
3172
|
+
}
|
|
3173
|
+
return { ours: true, why: '' };
|
|
3174
|
+
}
|
|
3175
|
+
|
|
2757
3176
|
function uninstall() {
|
|
3177
|
+
let hermesRulesRemoved = false;
|
|
2758
3178
|
for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md', '.minimax/AGENTS.md']) {
|
|
2759
|
-
if (stripBlock(join(HOME, rel)))
|
|
3179
|
+
if (stripBlock(join(HOME, rel))) {
|
|
3180
|
+
ok(`移除规则块 → ${rel}`);
|
|
3181
|
+
// Recorded here because the block is already gone by the time the Hermes summary is built —
|
|
3182
|
+
// "已移除" must describe what this run did, not what a file says now (#3834).
|
|
3183
|
+
if (rel === '.hermes/SOUL.md') hermesRulesRemoved = true;
|
|
3184
|
+
}
|
|
2760
3185
|
}
|
|
2761
3186
|
// Both candidates: a block may have been written before the workspace was read from config.
|
|
2762
3187
|
for (const workspace of openclawWorkspaces()) {
|
|
@@ -2796,14 +3221,29 @@ function uninstall() {
|
|
|
2796
3221
|
}
|
|
2797
3222
|
}
|
|
2798
3223
|
// MiniMax Code's plugin directory is ours outright (the install creates it), so uninstall takes
|
|
2799
|
-
// the whole subtree back. Only *our* directory
|
|
2800
|
-
//
|
|
3224
|
+
// the whole subtree back. Only *our* directory, and only once its manifest says it is ours: see
|
|
3225
|
+
// `mcodePluginDirectoryOwnership` for what asking the same question as the install costs (#3831).
|
|
3226
|
+
// `~/.minimax/plugins/` is the user's namespace and stays even when it ends up empty — deleting
|
|
3227
|
+
// it would remove a place their other plugins live.
|
|
2801
3228
|
const mcodeDir = join(HOME, '.minimax', 'plugins', 'misakanet');
|
|
2802
3229
|
if (existsSync(mcodeDir)) {
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
ok
|
|
2806
|
-
|
|
3230
|
+
const ownership = mcodePluginDirectoryOwnership();
|
|
3231
|
+
if (!ownership.ours) {
|
|
3232
|
+
// Deliberately a `need`, never an `ok`: the directory is still there, and only a human can
|
|
3233
|
+
// decide whether it is theirs to remove. Putting it under "已完成" was the second half of the
|
|
3234
|
+
// bug — the run claimed a removal it had skipped.
|
|
3235
|
+
need(`MiniMax Code:${mcodeDir} ${ownership.why} → 已保留(不删);`
|
|
3236
|
+
+ '确认它是你的插件后手动删掉这个目录,本安装器不碰它');
|
|
3237
|
+
} else {
|
|
3238
|
+
try {
|
|
3239
|
+
if (!DRY) rmSync(mcodeDir, { recursive: true, force: true });
|
|
3240
|
+
ok(`移除插件目录 → ${mcodeDir}`);
|
|
3241
|
+
} catch (err) {
|
|
3242
|
+
// A failed delete used to be swallowed by an empty `catch`, so the run said nothing while
|
|
3243
|
+
// the directory survived. Now it is a manual step, which is what it is.
|
|
3244
|
+
need(`MiniMax Code:删不掉 ${mcodeDir}(${redact((err && err.message) || err)})→ 手动删除它`);
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
2807
3247
|
}
|
|
2808
3248
|
const voiceDir = join(stateDir(), 'voice');
|
|
2809
3249
|
if (existsSync(voiceDir)) {
|
|
@@ -2890,8 +3330,18 @@ function uninstall() {
|
|
|
2890
3330
|
ok(`移除 token 行 → ${hermesEnv}(${HERMES_ENV_KEY})`);
|
|
2891
3331
|
}
|
|
2892
3332
|
}
|
|
2893
|
-
|
|
2894
|
-
|
|
3333
|
+
// Hermes' advice, only on a machine that has Hermes. It was unconditional, so a machine that had
|
|
3334
|
+
// never installed Hermes was told "规则块已在 ~/.hermes/SOUL.md 移除" and sent to a
|
|
3335
|
+
// `hermes hooks doctor` that does not exist there (#3834, measured 2026-10-25). The `!` list is
|
|
3336
|
+
// the user's only action list — an item that does not apply dilutes it.
|
|
3337
|
+
if (existsSync(join(HOME, '.hermes'))) {
|
|
3338
|
+
// And say what actually happened to the rules block: "已移除" only when this run removed one,
|
|
3339
|
+
// "本来就没装" when the file was there without our markers.
|
|
3340
|
+
need('Hermes 的钩子不归本安装器管 → 需要时看 hermes hooks doctor;'
|
|
3341
|
+
+ (hermesRulesRemoved
|
|
3342
|
+
? '规则块已在 ~/.hermes/SOUL.md 移除'
|
|
3343
|
+
: '规则块本来就没装(~/.hermes/SOUL.md 里没有本安装器的标记块)'));
|
|
3344
|
+
}
|
|
2895
3345
|
|
|
2896
3346
|
// OpenClaw's MCP entry is one we wrote ourselves, so we can take it back ourselves.
|
|
2897
3347
|
const ocCfg = join(HOME, '.openclaw', 'openclaw.json');
|
|
@@ -2978,7 +3428,7 @@ function reportValues(verdict) {
|
|
|
2978
3428
|
const permissions = (() => {
|
|
2979
3429
|
// `n/a` when Claude Code is not a target on this machine: an absent settings file is not a
|
|
2980
3430
|
// gap, and reporting one would send a Codex-only user chasing a fix they do not need.
|
|
2981
|
-
if (!
|
|
3431
|
+
if (!claudeCodeInUse()) return 'n/a';
|
|
2982
3432
|
const granted = readJson(join(HOME, '.claude', 'settings.json'), null)?.permissions?.allow || [];
|
|
2983
3433
|
const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
|
|
2984
3434
|
return missing.length ? 'incomplete' : 'ok';
|
|
@@ -3021,7 +3471,7 @@ function reportValues(verdict) {
|
|
|
3021
3471
|
['voice', voiceStatus()],
|
|
3022
3472
|
['open-items', manual.length],
|
|
3023
3473
|
// Redacted here, once, so neither encoder can print an unredacted message by accident.
|
|
3024
|
-
['open-items-detail', manual.map((m) => redact(m))],
|
|
3474
|
+
['open-items-detail', manual.map((m) => redact(m.text))],
|
|
3025
3475
|
...HUMAN_REPORT_FIELDS.map(([key, value, note]) => [key, value, note]),
|
|
3026
3476
|
];
|
|
3027
3477
|
}
|
|
@@ -3104,9 +3554,9 @@ function render() {
|
|
|
3104
3554
|
// `--silent` drops the narration, never the errors: `done` is progress, `manual` is what a human
|
|
3105
3555
|
// (or a GPO log reader) has to act on, and a run that failed silently would be worse than a loud
|
|
3106
3556
|
// one. See SILENT at the top for the full contract.
|
|
3107
|
-
if (done.length && !SILENT) out.push(`\n已完成(${done.length}):`, ...done.map((l) => ` ✓ ${l}`));
|
|
3108
|
-
if (manual.length) out.push(`\n需要你手动一步(${manual.length}):`, ...manual.map((l) => ` ! ${l}`));
|
|
3109
|
-
if (skipped.length && !SILENT) out.push(`\n跳过(${skipped.length}):`, ...skipped.map((l) => ` · ${l}`));
|
|
3557
|
+
if (done.length && !SILENT) out.push(`\n已完成(${done.length}):`, ...done.map((l) => ` ✓ ${l.text}`));
|
|
3558
|
+
if (manual.length) out.push(`\n需要你手动一步(${manual.length}):`, ...manual.map((l) => ` ! ${l.text}`));
|
|
3559
|
+
if (skipped.length && !SILENT) out.push(`\n跳过(${skipped.length}):`, ...skipped.map((l) => ` · ${l.text}`));
|
|
3110
3560
|
return out.join('\n');
|
|
3111
3561
|
}
|
|
3112
3562
|
|
|
@@ -3123,6 +3573,22 @@ const STRICT = has('--strict') || has('--ci');
|
|
|
3123
3573
|
const mode = has('--uninstall') ? 'uninstall'
|
|
3124
3574
|
: ((has('--report') || REPORT_JSON || (has('--ci') && !has('--verify'))) ? 'report'
|
|
3125
3575
|
: (has('--verify') ? 'verify' : 'install'));
|
|
3576
|
+
// `--client` is a question about *judging*, so it belongs to `--verify` and nowhere else: in
|
|
3577
|
+
// install mode `--only` already says which assistants to write to, and in report mode the report
|
|
3578
|
+
// has its own shape. A flag that silently does nothing is worse than one that refuses: `--client`
|
|
3579
|
+
// next to `--client-id` is exactly the kind of pair a user mixes up, so it says what it means.
|
|
3580
|
+
// Exit 2 is this tool's "could not answer" code (same as a probe timeout, #3778) — the machine
|
|
3581
|
+
// is not broken, the request was.
|
|
3582
|
+
if (CLIENT && mode !== 'verify') {
|
|
3583
|
+
console.error(`--client 只对 --verify 有意义(它决定"只判哪一个客户端");`
|
|
3584
|
+
+ `当前模式是 ${mode},请用 --only <助手> 指定要处理的助手。`);
|
|
3585
|
+
process.exit(2);
|
|
3586
|
+
}
|
|
3587
|
+
if (CLIENT && !AGENTS.includes(CLIENT)) {
|
|
3588
|
+
console.error(`未知的客户端:${CLIENT}`);
|
|
3589
|
+
console.error(`支持的客户端:${supportedAgentLabels()}`);
|
|
3590
|
+
process.exit(2);
|
|
3591
|
+
}
|
|
3126
3592
|
if (mode !== 'report' && !SILENT) {
|
|
3127
3593
|
// The banner names the home directory, which is exactly the kind of thing that gets pasted
|
|
3128
3594
|
// along with a report — so `--report` prints only the report. `--silent` drops it for the other
|
|
@@ -3377,6 +3843,25 @@ if (!SILENT && !DRY) {
|
|
|
3377
3843
|
// looked identical. `--dry-run` keeps its own contract — it already says it would write
|
|
3378
3844
|
// nothing, so a "0 files written" line under it would only restate the mode.
|
|
3379
3845
|
console.log(`本次实际写入 ${writtenFiles.size} 个文件(上面的清单是"涉及哪些",这一行是"真的动了哪些")。`);
|
|
3846
|
+
|
|
3847
|
+
if (installed > 0) {
|
|
3848
|
+
// The tail is what a user reads, and until now it held only next steps: a real Windows run on
|
|
3849
|
+
// 2026-10-29 ended on "5) 想关掉…" and the identity line, so the sole evidence that anything had
|
|
3850
|
+
// worked was the absence of errors. Verified here rather than assumed: the version is this build's,
|
|
3851
|
+
// and whether it is the published one is asked of the registry — the same two primitives `--verify`
|
|
3852
|
+
// uses, so the closing line and the self-check cannot disagree about what "latest" means.
|
|
3853
|
+
const latest = await latestPublishedVersion();
|
|
3854
|
+
const delta = latest ? compareVersions(VERSION, latest) : null;
|
|
3855
|
+
let versionNote = ';查不到 npm 上的最新版本(离线或 registry 不可达)';
|
|
3856
|
+
if (delta === 0) versionNote = '(已是最新)';
|
|
3857
|
+
else if (delta !== null && delta < 0) {
|
|
3858
|
+
versionNote = `;npm 上已有 ${latest} → 重跑 npx @misaka-net/misakanet-setup@latest 可更新`;
|
|
3859
|
+
} else if (delta !== null) {
|
|
3860
|
+
versionNote = `;比 npm 上的 ${latest} 新(本地构建或预发布)`;
|
|
3861
|
+
}
|
|
3862
|
+
console.log(`\n✓ 安装成功 —— misakanet-setup ${VERSION}${versionNote}`
|
|
3863
|
+
+ `\n 已配置 ${installed} 个助手;撤销:npx @misaka-net/misakanet-setup --uninstall`);
|
|
3864
|
+
}
|
|
3380
3865
|
}
|
|
3381
3866
|
if (MCP_ONLY && !SILENT) {
|
|
3382
3867
|
// The honest failure mode of tier ①, said where the user will read it: they are not installing a
|
|
@@ -3388,13 +3873,14 @@ if (MCP_ONLY && !SILENT) {
|
|
|
3388
3873
|
if (!SILENT) {
|
|
3389
3874
|
console.log(`
|
|
3390
3875
|
接下来:
|
|
3391
|
-
1)
|
|
3392
|
-
2)
|
|
3393
|
-
3)
|
|
3394
|
-
4)
|
|
3876
|
+
1) 启动时加载 MCP 配置的客户端(Claude Code / Codex 等)重开一次会话;dsh 下一条消息就生效,mcporter 这类按次调用的通道不需要重启
|
|
3877
|
+
2) 确认工具装上了:跑客户端的 MCP 列表命令(如 mcporter list misakanet,或 Claude Code 的 /mcp)——应看到 8 个工具
|
|
3878
|
+
3) 随便挑一句带报错原文的片段问它(例如「${ONBOARDING_QUERIES[0]}」「${ONBOARDING_QUERIES[1]}」「${ONBOARDING_QUERIES[2]}」)——它应该先去查经验库
|
|
3879
|
+
4) 想确认状态:npx @misaka-net/misakanet-setup --verify
|
|
3880
|
+
5) 想关掉:npx @misaka-net/misakanet-setup --uninstall`
|
|
3395
3881
|
+ (clientIdHint
|
|
3396
3882
|
? `
|
|
3397
|
-
|
|
3883
|
+
6) 记不记都行:这台机器的身份编号是 ${clientIdHint}
|
|
3398
3884
|
想让它以后重装/换机还算同一个身份,再跑安装时加:--client-id ${clientIdHint}`
|
|
3399
3885
|
: ''));
|
|
3400
3886
|
}
|