@misaka-net/misakanet-setup 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -9
- package/bin/misakanet-setup.mjs +591 -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.0' // x-release-please-version
|
|
161
201
|
|
|
162
202
|
/**
|
|
163
203
|
* The shape of a client id, in one place.
|
|
@@ -181,6 +221,7 @@ const REFERRAL_SHAPE = /^[A-Za-z0-9]{4,16}$/
|
|
|
181
221
|
const FLAGS = [
|
|
182
222
|
['--home <dir>', '把配置写到这个目录,而不是真实家目录(不是沙箱:--uninstall 也认这个目录)'],
|
|
183
223
|
['--only <a,b>', `只处理列出的助手(${AGENTS.join(' / ')})`],
|
|
224
|
+
['--client <id>', `只检查这一个客户端(${AGENTS.join(' / ')})——只判它,不判整台机器`],
|
|
184
225
|
['--dry-run', '只说不做:打印会改哪些文件,不写任何东西'],
|
|
185
226
|
['--silent', '只压掉进度叙述,绝不压掉 ! 行与报告(MDM/GPO 用)'],
|
|
186
227
|
['--report', '打印一段可公开粘贴的脱敏状态报告(人读 YAML)'],
|
|
@@ -198,7 +239,7 @@ const FLAGS = [
|
|
|
198
239
|
['--help, -h', '打印这份帮助并退出'],
|
|
199
240
|
['--version', '打印版本并退出'],
|
|
200
241
|
];
|
|
201
|
-
const VALUE_FLAGS = ['--home', '--only', '--client-id'];
|
|
242
|
+
const VALUE_FLAGS = ['--home', '--only', '--client-id', '--client'];
|
|
202
243
|
|
|
203
244
|
function printHelp() {
|
|
204
245
|
console.log(`MisakaNet 安装程序 ${VERSION}
|
|
@@ -574,16 +615,27 @@ function buildWriteManifest(agents) {
|
|
|
574
615
|
// install: with no target detected the run writes nothing at all.
|
|
575
616
|
if (agents.length) {
|
|
576
617
|
// `--no-register` registers no anonymous node, so no token is written; listing it anyway would
|
|
577
|
-
// make the manifest — and the preview's file count — promise a file that stays absent.
|
|
578
|
-
if
|
|
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
|
*
|
|
@@ -1371,7 +1544,9 @@ async function installHook() {
|
|
|
1371
1544
|
if (!DRY) {
|
|
1372
1545
|
mkdirSync(stateDir(), { recursive: true });
|
|
1373
1546
|
if (existing !== null) backup(hookPath);
|
|
1374
|
-
writeFileSync
|
|
1547
|
+
// `writeText`, not `writeFileSync`: the hook is a file this run wrote, so the closing count has
|
|
1548
|
+
// to include it — and it must be replaced atomically like every other file here (#3833).
|
|
1549
|
+
writeText(hookPath, bundled);
|
|
1375
1550
|
}
|
|
1376
1551
|
ok(isOurs
|
|
1377
1552
|
? `自动沉淀的钩子已更新(旧版备份为 ${hookPath}.misakanet.bak)→ ${hookPath}`
|
|
@@ -1399,22 +1574,23 @@ async function ensureIdentity() {
|
|
|
1399
1574
|
}
|
|
1400
1575
|
if (existing) {
|
|
1401
1576
|
ok('已有匿名身份(token 已存在)');
|
|
1577
|
+
// #3832 hole 1: this early return used to leave `knownClientId` empty, and the npm channel
|
|
1578
|
+
// persisted nothing, so the *next* target's config was written with no `X-MisakaNet-Client` at
|
|
1579
|
+
// all. Resolve the identity here too — nothing is registered this run, but the header still has
|
|
1580
|
+
// to say who this machine is.
|
|
1581
|
+
clientIdForThisRun();
|
|
1402
1582
|
return existing;
|
|
1403
1583
|
}
|
|
1404
1584
|
if (DRY) {
|
|
1405
1585
|
ok(`会注册匿名身份并把 token 写到 ${file}`);
|
|
1406
1586
|
return '';
|
|
1407
1587
|
}
|
|
1408
|
-
// The client id is what makes a re-registration return the *same* node
|
|
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()}`;
|
|
1588
|
+
// The client id is what makes a re-registration return the *same* node. It used to be minted
|
|
1589
|
+
// fresh on every attempt that failed — held only in memory, so the re-run the installer asks for
|
|
1590
|
+
// minted another, rewrote the user's config and left a backup (issue #3832, hole 2).
|
|
1591
|
+
// `clientIdForThisRun()` pins the value in `<stateDir>/client_id`; its docstring records the
|
|
1592
|
+
// #1688 vs CodeQL tradeoff that makes reading that file back acceptable.
|
|
1593
|
+
const clientId = usable ? explicit : clientIdForThisRun();
|
|
1418
1594
|
knownClientId = clientId;
|
|
1419
1595
|
const referral = declaredReferral();
|
|
1420
1596
|
const result = await mcpCall('misakanet_register', {
|
|
@@ -1586,10 +1762,14 @@ async function installClaude(hookPath, bearer) {
|
|
|
1586
1762
|
const destDir = join(stateDir(), 'voice');
|
|
1587
1763
|
if (!DRY) {
|
|
1588
1764
|
mkdirSync(destDir, { recursive: true });
|
|
1765
|
+
// `copyFileSync` writes a file too, so these join `writtenFiles`: without it a `--voice`
|
|
1766
|
+
// install under-reports its count the same way the version stamp did (#3833).
|
|
1589
1767
|
copyFileSync(voice.player, join(destDir, 'voice-hook.mjs'));
|
|
1768
|
+
writtenFiles.add(join(destDir, 'voice-hook.mjs'));
|
|
1590
1769
|
const cues = existsSync(voice.cues) ? readdirSync(voice.cues).filter((f) => f.endsWith('.mp3')) : [];
|
|
1591
1770
|
for (const cue of cues) {
|
|
1592
1771
|
copyFileSync(join(voice.cues, cue), join(destDir, cue));
|
|
1772
|
+
writtenFiles.add(join(destDir, cue));
|
|
1593
1773
|
}
|
|
1594
1774
|
if (!cues.length) {
|
|
1595
1775
|
need('语音钩子:找到了播放器但没有找到音频文件 → 重装一次 npx 包即可(其它功能不受影响)');
|
|
@@ -1779,7 +1959,11 @@ function stampVersion() {
|
|
|
1779
1959
|
installed_at: new Date().toISOString(),
|
|
1780
1960
|
};
|
|
1781
1961
|
mkdirSync(stateDir(), { recursive: true });
|
|
1782
|
-
writeFileSync
|
|
1962
|
+
// `writeFileSync` here bypassed `writeText()`'s atomic replace *and* its `writtenFiles`
|
|
1963
|
+
// bookkeeping, so every fresh install reported one file fewer than it had written (#3833) — and
|
|
1964
|
+
// this was the one write path left without the truncation protection the rest of the installer
|
|
1965
|
+
// went through.
|
|
1966
|
+
writeText(file, `${JSON.stringify(stamp, null, 2)}\n`);
|
|
1783
1967
|
ok(`版本戳 → ${file}(${VERSION}${existing?.version ? `,原 ${existing.version}` : ''})`);
|
|
1784
1968
|
}
|
|
1785
1969
|
|
|
@@ -2169,6 +2353,11 @@ function unwritableAdvice(manual = '') {
|
|
|
2169
2353
|
: `只读或权限不足,重跑也写不进去 → ${fix}`;
|
|
2170
2354
|
}
|
|
2171
2355
|
|
|
2356
|
+
// Claude Code is in use when its user-level config file exists — a stale `~/.claude/` directory is
|
|
2357
|
+
// not evidence (#3699). The install still detects on either (it can offer to set Claude up); the
|
|
2358
|
+
// verification and the report must not blame a machine that merely left the directory behind.
|
|
2359
|
+
const claudeCodeInUse = () => existsSync(join(HOME, '.claude.json'));
|
|
2360
|
+
|
|
2172
2361
|
async function verify() {
|
|
2173
2362
|
let allOk = true;
|
|
2174
2363
|
// The report answers two different questions, and one of them failing must not condemn the
|
|
@@ -2185,6 +2374,21 @@ async function verify() {
|
|
|
2185
2374
|
// footprint, and it says nothing about the endpoint.
|
|
2186
2375
|
let installOk = true;
|
|
2187
2376
|
const installFailed = () => { installOk = false; allOk = false; };
|
|
2377
|
+
/**
|
|
2378
|
+
* A check whose answer is "we cannot write there" or "our footprint is not there".
|
|
2379
|
+
*
|
|
2380
|
+
* The two answers have different fixes, and the per-client summary has to name which one it is:
|
|
2381
|
+
* a read-only target is **not** fixed by re-running the installer — a re-run writes exactly as
|
|
2382
|
+
* much as the last one did (measured 2026-10-22 on an OpenClaw machine whose workspace was
|
|
2383
|
+
* root-owned and read-only; see `unwritableAdvice`). So the kind is decided **here**, where the
|
|
2384
|
+
* writability is measured, and never by reading the wording of the message: a message that
|
|
2385
|
+
* changes its phrasing must not silently change the verdict.
|
|
2386
|
+
*/
|
|
2387
|
+
const needWrite = (client, path, absentMsg, blockedMsg) => {
|
|
2388
|
+
installFailed();
|
|
2389
|
+
if (canCreateHere(path)) need(absentMsg, client, 'absent');
|
|
2390
|
+
else need(blockedMsg, client, 'blocked');
|
|
2391
|
+
};
|
|
2188
2392
|
// Where each segment starts, in the three line lists. The output prints the two segments as
|
|
2189
2393
|
// two sections, and this boundary is the only thing that has to be right about them.
|
|
2190
2394
|
const segmentStart = { done: done.length, manual: manual.length, skipped: skipped.length };
|
|
@@ -2206,13 +2410,16 @@ async function verify() {
|
|
|
2206
2410
|
try {
|
|
2207
2411
|
const granted = readJson(join(HOME, '.claude', 'settings.json'), null)?.permissions?.allow || [];
|
|
2208
2412
|
const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
|
|
2209
|
-
if (
|
|
2413
|
+
if (wantClient('claude') && claudeCodeInUse() && missing.length) {
|
|
2210
2414
|
allOk = false;
|
|
2211
2415
|
// Not an install fact: the endpoint answers, and the search still fails. It belongs to
|
|
2212
2416
|
// the usability segment, so it belongs there in the output too.
|
|
2213
2417
|
usable = false;
|
|
2418
|
+
// It is a Claude Code fact all the same — the permission list is Claude Code's own
|
|
2419
|
+
// config — so it is judged under Claude Code and not under the machine (#3699). The fix
|
|
2420
|
+
// is the same re-run, which is what 'absent' means: the grant is not in the list yet.
|
|
2214
2421
|
need(`Claude Code:只读 MCP 工具没有放行(${missing.length} 个)→ 第一次检索会被权限拦下;`
|
|
2215
|
-
+ '重跑安装命令即可放行');
|
|
2422
|
+
+ '重跑安装命令即可放行', 'claude', 'absent');
|
|
2216
2423
|
}
|
|
2217
2424
|
} catch { /* a settings file we cannot read is reported by the Claude checks below */ }
|
|
2218
2425
|
} else if (outcome.state === 'non-mcp') {
|
|
@@ -2261,11 +2468,20 @@ async function verify() {
|
|
|
2261
2468
|
// self-contradiction the issue was filed for (`detected-agents: []` next to
|
|
2262
2469
|
// `install-complete: complete`). Judged on "no target in AGENTS is detected", not on one
|
|
2263
2470
|
// hard-coded directory: any of the supported assistants in `AGENTS` counts.
|
|
2264
|
-
|
|
2471
|
+
// `detected-agents` counts only the assistants in scope: with `--client <id>` the question is
|
|
2472
|
+
// about that one, so another assistant's absence is not this machine's problem (#3699).
|
|
2473
|
+
const detectedAgents = AGENTS.filter((agent) => wantClient(agent) && detect(agent));
|
|
2265
2474
|
if (!detectedAgents.length) {
|
|
2266
2475
|
installFailed();
|
|
2267
|
-
|
|
2268
|
-
|
|
2476
|
+
if (CLIENT) {
|
|
2477
|
+
// The user named a client this machine does not have. That is an answer, not a silence:
|
|
2478
|
+
// "OpenClaw:没装过(这台机器上没检测到它)" says what to do about it.
|
|
2479
|
+
need(`${clientLabel(CLIENT)}:没装过(这台机器上没检测到它)`
|
|
2480
|
+
+ '→ 先打开一次那个助手,再回来运行本命令', CLIENT, 'absent');
|
|
2481
|
+
} else {
|
|
2482
|
+
need('没有检测到任何受支持的助手 —— 因此什么都没装'
|
|
2483
|
+
+ `(支持:${supportedAgentLabels()})→ 先打开一次你要用的那个助手,再回来运行本命令`);
|
|
2484
|
+
}
|
|
2269
2485
|
}
|
|
2270
2486
|
const tokenFile = join(stateDir(), 'token');
|
|
2271
2487
|
let tokenPresent = '';
|
|
@@ -2289,7 +2505,7 @@ async function verify() {
|
|
|
2289
2505
|
// Cursor-only install reported NOT READY with two Claude Code complaints about a target that was
|
|
2290
2506
|
// never selected — the same lie the OpenClaw and Hermes checks below already refuse to tell
|
|
2291
2507
|
// (found while adding Cursor, which is mcp-only by construction).
|
|
2292
|
-
if (
|
|
2508
|
+
if (wantClient('claude') && claudeCodeInUse()) {
|
|
2293
2509
|
const hookPath = join(stateDir(), 'hook.mjs');
|
|
2294
2510
|
let hookPresent = false;
|
|
2295
2511
|
try {
|
|
@@ -2298,10 +2514,9 @@ async function verify() {
|
|
|
2298
2514
|
hookPresent = false;
|
|
2299
2515
|
}
|
|
2300
2516
|
if (!hookPresent) {
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
: `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
|
|
2517
|
+
needWrite('claude', hookPath,
|
|
2518
|
+
'自动沉淀钩子:缺失 → 重跑安装命令',
|
|
2519
|
+
`自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
|
|
2305
2520
|
} else {
|
|
2306
2521
|
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
2307
2522
|
const settings = readJson(settingsPath, {}) || {};
|
|
@@ -2312,34 +2527,35 @@ async function verify() {
|
|
|
2312
2527
|
.filter((c) => typeof c === 'string');
|
|
2313
2528
|
if (!commands.length) {
|
|
2314
2529
|
installFailed();
|
|
2315
|
-
need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
|
|
2530
|
+
need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)', 'claude', 'absent');
|
|
2316
2531
|
} else {
|
|
2317
2532
|
const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
|
|
2318
2533
|
if (!existsSync(exe)) {
|
|
2319
2534
|
installFailed();
|
|
2320
|
-
need(`Claude Code:钩子里的解释器不存在(${exe})→
|
|
2535
|
+
need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`,
|
|
2536
|
+
'claude', 'broken');
|
|
2321
2537
|
} else {
|
|
2322
|
-
ok('Claude Code:钩子已装且解释器存在');
|
|
2538
|
+
ok('Claude Code:钩子已装且解释器存在', 'claude');
|
|
2323
2539
|
}
|
|
2324
2540
|
}
|
|
2325
2541
|
const cfg = join(HOME, '.claude.json');
|
|
2326
2542
|
const data = readJson(cfg, {}) || {};
|
|
2327
2543
|
const entry = data.mcpServers?.misakanet;
|
|
2328
2544
|
if (!entry) {
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
: `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2545
|
+
needWrite('claude', cfg,
|
|
2546
|
+
`Claude Code:MCP 未注册(${cfg})`,
|
|
2547
|
+
`Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2333
2548
|
} else {
|
|
2334
2549
|
const verdict = mcpEntryState(entry, 'url');
|
|
2335
2550
|
if (verdict.state === 'wrong-url') {
|
|
2336
2551
|
installFailed();
|
|
2337
|
-
need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→
|
|
2552
|
+
need(`Claude Code:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2553
|
+
'claude', 'broken');
|
|
2338
2554
|
} else if (verdict.state === 'no-url-field') {
|
|
2339
2555
|
installFailed();
|
|
2340
|
-
need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→
|
|
2556
|
+
need(`Claude Code:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'claude', 'broken');
|
|
2341
2557
|
} else {
|
|
2342
|
-
ok(`Claude Code:MCP 已注册(${verdict.url}
|
|
2558
|
+
ok(`Claude Code:MCP 已注册(${verdict.url})`, 'claude');
|
|
2343
2559
|
}
|
|
2344
2560
|
}
|
|
2345
2561
|
}
|
|
@@ -2348,57 +2564,84 @@ async function verify() {
|
|
|
2348
2564
|
// OpenClaw that it is "not ready" would be a lie about a target that was never selected.
|
|
2349
2565
|
// The state is read from the same file `openclaw mcp list` reports from, which keeps this
|
|
2350
2566
|
// check working where the CLI itself cannot start (its database may be unwritable).
|
|
2351
|
-
if (detect('openclaw')) {
|
|
2567
|
+
if (wantClient('openclaw') && detect('openclaw')) {
|
|
2352
2568
|
const rules = join(openclawWorkspaces()[0], 'AGENTS.md');
|
|
2353
2569
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2354
|
-
installFailed();
|
|
2355
2570
|
// "没装" and "装不了" are different facts with different fixes, and the difference is the
|
|
2356
2571
|
// whole point (issue #3696): a read-only workspace is not something a re-run installs.
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2572
|
+
needWrite('openclaw', rules,
|
|
2573
|
+
`OpenClaw:规则块没装(${rules})→ 重跑安装命令`,
|
|
2574
|
+
`OpenClaw:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2360
2575
|
} else {
|
|
2361
|
-
ok('OpenClaw:规则块已装');
|
|
2576
|
+
ok('OpenClaw:规则块已装', 'openclaw');
|
|
2362
2577
|
}
|
|
2363
2578
|
const cfg = join(HOME, '.openclaw', 'openclaw.json');
|
|
2364
2579
|
const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
|
|
2365
2580
|
if (!entry) {
|
|
2366
|
-
installFailed();
|
|
2367
2581
|
const manual = `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`;
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2582
|
+
needWrite('openclaw', cfg,
|
|
2583
|
+
`OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 ${manual}`,
|
|
2584
|
+
`OpenClaw:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
|
|
2371
2585
|
} else {
|
|
2372
|
-
|
|
2586
|
+
// The URL is checked here, and it was not: this block only ever asked "is there an entry",
|
|
2587
|
+
// so an OpenClaw pointed at `https://BROKEN.invalid/mcp` was reported as "已注册" with the
|
|
2588
|
+
// unreachable URL printed next to the tick. `mcpEntryState` exists for exactly this and was
|
|
2589
|
+
// simply never applied here (it is applied to Claude, Codex, codewhale, the MCP-only
|
|
2590
|
+
// targets and mcode). With a per-client verdict on screen, "就绪" next to an entry nobody
|
|
2591
|
+
// can reach would be a false green (#1984).
|
|
2592
|
+
const verdict = mcpEntryState(entry, 'url');
|
|
2593
|
+
if (verdict.state === 'wrong-url') {
|
|
2594
|
+
installFailed();
|
|
2595
|
+
need(`OpenClaw:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2596
|
+
'openclaw', 'broken');
|
|
2597
|
+
} else if (verdict.state === 'no-url-field') {
|
|
2598
|
+
installFailed();
|
|
2599
|
+
need(`OpenClaw:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'openclaw', 'broken');
|
|
2600
|
+
} else {
|
|
2601
|
+
ok(`OpenClaw:MCP 已注册(${verdict.url})`, 'openclaw');
|
|
2602
|
+
}
|
|
2373
2603
|
}
|
|
2374
2604
|
}
|
|
2375
2605
|
// Hermes: same rule — reported only when the user has it. Its config file *is* the registry,
|
|
2376
2606
|
// so the state is readable without running anything. What cannot be confirmed from here is
|
|
2377
2607
|
// whether Hermes has loaded that file, and the output says so rather than implying it.
|
|
2378
|
-
if (readText(join(HOME, '.hermes', 'config.yaml'))) {
|
|
2608
|
+
if (wantClient('hermes') && readText(join(HOME, '.hermes', 'config.yaml'))) {
|
|
2379
2609
|
const rules = join(HOME, '.hermes', 'SOUL.md');
|
|
2380
2610
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
: `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2611
|
+
needWrite('hermes', rules,
|
|
2612
|
+
`Hermes:规则块没装(${rules})→ 重跑安装命令`,
|
|
2613
|
+
`Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2385
2614
|
} else {
|
|
2386
|
-
ok('Hermes:规则块已装');
|
|
2615
|
+
ok('Hermes:规则块已装', 'hermes');
|
|
2387
2616
|
}
|
|
2388
2617
|
const cfg = join(HOME, '.hermes', 'config.yaml');
|
|
2389
2618
|
const text = readText(cfg);
|
|
2390
2619
|
if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
|
|
2391
|
-
installFailed();
|
|
2392
2620
|
const manual = `hermes mcp add misakanet --url ${ENDPOINT} --auth header`;
|
|
2393
|
-
|
|
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}`);
|
|
2621
|
+
needWrite('hermes', cfg,
|
|
2622
|
+
`Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 ${manual}`,
|
|
2623
|
+
`Hermes:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
|
|
2399
2624
|
} else {
|
|
2400
|
-
|
|
2401
|
-
|
|
2625
|
+
// The entry is there — but is it pointed at our endpoint? Same gap as OpenClaw had: the
|
|
2626
|
+
// check asked "is there an entry" and never "does it point at the right place", so a Hermes
|
|
2627
|
+
// on `https://BROKEN.invalid/mcp` was reported as registered (#1984: a green run is not
|
|
2628
|
+
// evidence unless something in it can differ).
|
|
2629
|
+
const url = hermesMcpUrl(text);
|
|
2630
|
+
const expected = expectedEndpoint();
|
|
2631
|
+
if (url && url !== expected) {
|
|
2632
|
+
installFailed();
|
|
2633
|
+
need(`Hermes:MCP 条目指向的端点不对(${url},应为 ${expected})(${cfg})→ 重跑安装命令`,
|
|
2634
|
+
'hermes', 'broken');
|
|
2635
|
+
} else if (!url) {
|
|
2636
|
+
installFailed();
|
|
2637
|
+
need(`Hermes:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'hermes', 'broken');
|
|
2638
|
+
} else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
|
|
2639
|
+
ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)', 'hermes');
|
|
2640
|
+
skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`, 'hermes');
|
|
2641
|
+
} else {
|
|
2642
|
+
ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`, 'hermes');
|
|
2643
|
+
skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核', 'hermes');
|
|
2644
|
+
}
|
|
2402
2645
|
}
|
|
2403
2646
|
}
|
|
2404
2647
|
// Codex and codewhale: reported only when the user has them, like every other target. They were
|
|
@@ -2406,68 +2649,67 @@ async function verify() {
|
|
|
2406
2649
|
// all, so a machine whose Codex entry pointed at `https://BROKEN.invalid/mcp` and whose rules
|
|
2407
2650
|
// block had lost its markers still reported READY with exit code 0 — while `--report`'s
|
|
2408
2651
|
// `detected-agents` listed codex and codewhale as present, and the gate let it through.
|
|
2409
|
-
if (detect('codex')) {
|
|
2652
|
+
if (wantClient('codex') && detect('codex')) {
|
|
2410
2653
|
const rules = join(HOME, '.codex', 'AGENTS.md');
|
|
2411
2654
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
: `Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2655
|
+
needWrite('codex', rules,
|
|
2656
|
+
`Codex:规则块没装(${rules})→ 重跑安装命令`,
|
|
2657
|
+
`Codex:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2416
2658
|
} else {
|
|
2417
|
-
ok('Codex:规则块已装');
|
|
2659
|
+
ok('Codex:规则块已装', 'codex');
|
|
2418
2660
|
}
|
|
2419
2661
|
const cfg = join(HOME, '.codex', 'config.toml');
|
|
2420
2662
|
const toml = readText(cfg);
|
|
2421
2663
|
const table = codexMisakanetTable(toml);
|
|
2422
2664
|
const verdict = mcpEntryState(table, 'url');
|
|
2423
2665
|
if (verdict.state === 'missing') {
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
: `Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2666
|
+
needWrite('codex', cfg,
|
|
2667
|
+
`Codex:MCP 未注册(${cfg})→ 重跑安装命令`,
|
|
2668
|
+
`Codex:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2428
2669
|
} else if (verdict.state === 'wrong-url') {
|
|
2429
2670
|
installFailed();
|
|
2430
|
-
need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→
|
|
2671
|
+
need(`Codex:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2672
|
+
'codex', 'broken');
|
|
2431
2673
|
} else if (verdict.state === 'no-url-field') {
|
|
2432
2674
|
installFailed();
|
|
2433
|
-
need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→
|
|
2675
|
+
need(`Codex:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'codex', 'broken');
|
|
2434
2676
|
} else {
|
|
2435
|
-
ok(`Codex:MCP 已注册(${verdict.url}
|
|
2677
|
+
ok(`Codex:MCP 已注册(${verdict.url})`, 'codex');
|
|
2436
2678
|
}
|
|
2437
2679
|
// The top-level key is what makes Codex speak streamable-http at all: without it the entry
|
|
2438
2680
|
// above is present and still unusable.
|
|
2439
2681
|
if (!hasTopLevelKey(toml, 'experimental_use_rmcp_client')) {
|
|
2440
2682
|
installFailed();
|
|
2441
2683
|
need(`Codex:config.toml 里没有顶级键 experimental_use_rmcp_client(${cfg})`
|
|
2442
|
-
+ '→ Codex 连不上 streamable-http,重跑安装命令即可补上');
|
|
2684
|
+
+ '→ Codex 连不上 streamable-http,重跑安装命令即可补上', 'codex', 'broken');
|
|
2443
2685
|
}
|
|
2444
2686
|
}
|
|
2445
|
-
if (detect('codewhale')) {
|
|
2687
|
+
if (wantClient('codewhale') && detect('codewhale')) {
|
|
2446
2688
|
const cfg = join(HOME, '.codewhale', 'mcp.json');
|
|
2447
2689
|
const entry = (readJson(cfg, null) || {}).servers?.misakanet;
|
|
2448
2690
|
const verdict = mcpEntryState(entry, 'url');
|
|
2449
2691
|
if (verdict.state === 'missing') {
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
: `codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2692
|
+
needWrite('codewhale', cfg,
|
|
2693
|
+
`codewhale:MCP 未注册(${cfg})→ 重跑安装命令`,
|
|
2694
|
+
`codewhale:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2454
2695
|
} else if (verdict.state === 'wrong-url') {
|
|
2455
2696
|
installFailed();
|
|
2456
|
-
need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→
|
|
2697
|
+
need(`codewhale:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2698
|
+
'codewhale', 'broken');
|
|
2457
2699
|
} else if (verdict.state === 'no-url-field') {
|
|
2458
2700
|
installFailed();
|
|
2459
|
-
need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→
|
|
2701
|
+
need(`codewhale:MCP 条目在,但没有 url 字段(${cfg})→ 重跑安装命令`, 'codewhale', 'broken');
|
|
2460
2702
|
} else {
|
|
2461
|
-
ok(`codewhale:MCP 已注册(${verdict.url}
|
|
2703
|
+
ok(`codewhale:MCP 已注册(${verdict.url})`, 'codewhale');
|
|
2462
2704
|
}
|
|
2463
2705
|
// Its rules blocks live in the projects it trusts, so that is where they are checked.
|
|
2464
2706
|
for (const project of codewhaleProjects()) {
|
|
2465
2707
|
const rules = join(project, 'AGENTS.md');
|
|
2466
2708
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2467
2709
|
installFailed();
|
|
2468
|
-
need(`codewhale:规则块没装(${rules})→
|
|
2710
|
+
need(`codewhale:规则块没装(${rules})→ 重跑安装命令`, 'codewhale', 'absent');
|
|
2469
2711
|
} else {
|
|
2470
|
-
ok(`codewhale:规则块已装(${rules}
|
|
2712
|
+
ok(`codewhale:规则块已装(${rules})`, 'codewhale');
|
|
2471
2713
|
}
|
|
2472
2714
|
}
|
|
2473
2715
|
}
|
|
@@ -2475,26 +2717,27 @@ async function verify() {
|
|
|
2475
2717
|
// Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
|
|
2476
2718
|
// the client has *loaded* that file cannot be confirmed from here, and the line says so.
|
|
2477
2719
|
for (const [agent, spec] of Object.entries(MCP_ONLY_TARGETS)) {
|
|
2478
|
-
if (!detect(agent)) continue;
|
|
2720
|
+
if (!wantClient(agent) || !detect(agent)) continue;
|
|
2479
2721
|
const cfg = spec.config(HOME);
|
|
2480
2722
|
const entry = (readJson(cfg, null) || {})[spec.container]?.misakanet;
|
|
2481
2723
|
if (!entry) {
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
: `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2724
|
+
needWrite(agent, cfg,
|
|
2725
|
+
`${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`,
|
|
2726
|
+
`${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
2486
2727
|
} else {
|
|
2487
2728
|
const verdict = mcpEntryState(entry, spec.urlField);
|
|
2488
2729
|
if (verdict.state === 'no-url-field') {
|
|
2489
2730
|
installFailed();
|
|
2490
2731
|
need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
|
|
2491
|
-
+ `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}
|
|
2732
|
+
+ `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`,
|
|
2733
|
+
agent, 'broken');
|
|
2492
2734
|
} else if (verdict.state === 'wrong-url') {
|
|
2493
2735
|
installFailed();
|
|
2494
|
-
need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→
|
|
2736
|
+
need(`${spec.label}:MCP 条目指向的端点不对(${verdict.url},应为 ${verdict.expected})(${cfg})→ 重跑安装命令`,
|
|
2737
|
+
agent, 'broken');
|
|
2495
2738
|
} else {
|
|
2496
|
-
ok(`${spec.label}:MCP 已注册(${verdict.url}
|
|
2497
|
-
skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}
|
|
2739
|
+
ok(`${spec.label}:MCP 已注册(${verdict.url})`, agent);
|
|
2740
|
+
skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`, agent);
|
|
2498
2741
|
}
|
|
2499
2742
|
}
|
|
2500
2743
|
}
|
|
@@ -2503,39 +2746,38 @@ async function verify() {
|
|
|
2503
2746
|
// hand-edit turned into `{}` is exactly the state a re-run fixes. `mcode plugin add` refuses
|
|
2504
2747
|
// local plugins on purpose (LOCAL_PLUGIN_INSTALL_UNSUPPORTED), so its exit code is not evidence
|
|
2505
2748
|
// either way — the directory is, and that is what this reads.
|
|
2506
|
-
if (detect('mcode')) {
|
|
2749
|
+
if (wantClient('mcode') && detect('mcode')) {
|
|
2507
2750
|
const rules = join(HOME, MCODE_RULES_REL);
|
|
2508
2751
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
: `MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2752
|
+
needWrite('mcode', rules,
|
|
2753
|
+
`MiniMax Code:规则块没装(${rules})→ 重跑安装命令`,
|
|
2754
|
+
`MiniMax Code:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
2513
2755
|
} else {
|
|
2514
|
-
ok('MiniMax Code:规则块已装');
|
|
2756
|
+
ok('MiniMax Code:规则块已装', 'mcode');
|
|
2515
2757
|
}
|
|
2516
2758
|
const plugin = mcodePluginPath(HOME);
|
|
2517
2759
|
const doc = readJson(plugin, null);
|
|
2518
2760
|
if (doc === null) {
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
: `MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
|
|
2761
|
+
needWrite('mcode', plugin,
|
|
2762
|
+
`MiniMax Code:插件清单缺失或不是合法 JSON(${plugin})→ 重跑安装命令`,
|
|
2763
|
+
`MiniMax Code:插件清单装不了(${plugin})→ ${unwritableAdvice()}`);
|
|
2523
2764
|
} else {
|
|
2524
2765
|
const verdict = mcpEntryState(doc.mcpServers?.misakanet, 'url');
|
|
2525
2766
|
if (verdict.state === 'missing') {
|
|
2526
2767
|
installFailed();
|
|
2527
|
-
need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→
|
|
2768
|
+
need(`MiniMax Code:插件清单里没有 mcpServers.misakanet(${plugin})→ 重跑安装命令`,
|
|
2769
|
+
'mcode', 'absent');
|
|
2528
2770
|
} else if (verdict.state === 'no-url-field') {
|
|
2529
2771
|
installFailed();
|
|
2530
|
-
need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→
|
|
2772
|
+
need(`MiniMax Code:插件清单里的条目没有 url 字段(${plugin})→ 重跑安装命令`, 'mcode', 'broken');
|
|
2531
2773
|
} else if (verdict.state === 'wrong-url') {
|
|
2532
2774
|
installFailed();
|
|
2533
2775
|
need(`MiniMax Code:插件清单指向的端点不对(${verdict.url},应为 ${verdict.expected})(${plugin})`
|
|
2534
|
-
+ '→ 重跑安装命令');
|
|
2776
|
+
+ '→ 重跑安装命令', 'mcode', 'broken');
|
|
2535
2777
|
} else {
|
|
2536
|
-
ok(`MiniMax Code:插件清单已注册(${verdict.url}
|
|
2778
|
+
ok(`MiniMax Code:插件清单已注册(${verdict.url})`, 'mcode');
|
|
2537
2779
|
skip('MiniMax Code:只能确认"插件目录在位",无法确认 mcode 是否已加载 → 跑 `mcode plugin list` 复核'
|
|
2538
|
-
+ '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)');
|
|
2780
|
+
+ '(`mcode plugin add` 报 LOCAL_PLUGIN_INSTALL_UNSUPPORTED 不是失败)', 'mcode');
|
|
2539
2781
|
}
|
|
2540
2782
|
}
|
|
2541
2783
|
}
|
|
@@ -2575,6 +2817,10 @@ async function verify() {
|
|
|
2575
2817
|
// The install segment's open items, counted on their own: the report's `open-items` is every
|
|
2576
2818
|
// manual item, this is the ones that belong to our footprint.
|
|
2577
2819
|
openItems: manual.length - segmentEnd.manual,
|
|
2820
|
+
// One verdict per client, so the conclusion can be read per assistant instead of per machine
|
|
2821
|
+
// (issue #3699). The machine-level verdict below is derived from these, not the other way
|
|
2822
|
+
// round: a client that was not checked cannot be the reason this machine is not ready.
|
|
2823
|
+
clients: clientVerdicts(),
|
|
2578
2824
|
segments: { start: segmentStart, end: segmentEnd },
|
|
2579
2825
|
};
|
|
2580
2826
|
}
|
|
@@ -2606,6 +2852,14 @@ function usabilityText(verdict) {
|
|
|
2606
2852
|
}
|
|
2607
2853
|
|
|
2608
2854
|
function installText(verdict) {
|
|
2855
|
+
// The clients that are not ready, **by name** (issue #3699). "3 项需要手动处理" said how many;
|
|
2856
|
+
// it never said which assistant the three belonged to, so on a multi-client machine the reader
|
|
2857
|
+
// had to go and match the count against the lines above.
|
|
2858
|
+
const notReady = (verdict.clients || []).filter((c) => c.verdict !== '就绪');
|
|
2859
|
+
if (notReady.length) {
|
|
2860
|
+
return `不完整 —— ${notReady.map((c) => `${c.label}:${c.verdict}`).join(';')}`
|
|
2861
|
+
+ '(上面每条 ! 都给了成因和做法)';
|
|
2862
|
+
}
|
|
2609
2863
|
if (verdict.zeroTargets) {
|
|
2610
2864
|
return '不完整 —— 没有检测到任何受支持的助手,因此什么都没装';
|
|
2611
2865
|
}
|
|
@@ -2631,32 +2885,118 @@ function exitCodeFor(verdict) {
|
|
|
2631
2885
|
return (verdict.unverified && verdict.install === 'complete') ? 2 : 1;
|
|
2632
2886
|
}
|
|
2633
2887
|
|
|
2888
|
+
/**
|
|
2889
|
+
* One verdict per client, from the lines that client produced (issue #3699).
|
|
2890
|
+
*
|
|
2891
|
+
* The machine-level verdict was a single boolean over every check, but the checks are per client:
|
|
2892
|
+
* a machine with three assistants was condemned because of the one the user never touches, and a
|
|
2893
|
+
* healthy assistant was indistinguishable from a broken one (#3696, #3699). So the conclusion is
|
|
2894
|
+
* per client — and each client gets one of four words, because the four fixes are four different
|
|
2895
|
+
* things:
|
|
2896
|
+
*
|
|
2897
|
+
* 就绪 — everything we checked for this client passed; there is nothing to do.
|
|
2898
|
+
* 装不了 — we cannot write there. Fix the permissions: a re-run writes exactly as much as the
|
|
2899
|
+
* last one did (measured 2026-10-22 on an OpenClaw machine whose workspace was
|
|
2900
|
+
* root-owned and read-only — see `unwritableAdvice`).
|
|
2901
|
+
* 没装过 — our footprint is not there: the file, the entry, the permission grant. A re-run puts
|
|
2902
|
+
* it there.
|
|
2903
|
+
* 装坏了 — our footprint is there but points at the wrong place: the wrong endpoint, a missing
|
|
2904
|
+
* field, a missing interpreter. A re-run fixes it.
|
|
2905
|
+
*
|
|
2906
|
+
* Issue #3699 names the first three. The fourth exists because "our entry is there and points at
|
|
2907
|
+
* the wrong endpoint" is none of the three, and calling it 没装过 would be a lie about the
|
|
2908
|
+
* machine.
|
|
2909
|
+
*
|
|
2910
|
+
* `blocked` wins over `broken` wins over `absent`: a client we cannot write to is not merely "not
|
|
2911
|
+
* installed", and saying so would send the reader after the wrong fix.
|
|
2912
|
+
*/
|
|
2913
|
+
const CLIENT_VERDICT_WORDS = { blocked: '装不了', broken: '装坏了', absent: '没装过' };
|
|
2914
|
+
/** What to do about each verdict, in the order the fix differs. */
|
|
2915
|
+
const CLIENT_VERDICT_HINTS = {
|
|
2916
|
+
就绪: '',
|
|
2917
|
+
装不了: '(改权限后重跑安装命令)',
|
|
2918
|
+
没装过: '(重跑安装命令)',
|
|
2919
|
+
装坏了: '(重跑安装命令)',
|
|
2920
|
+
};
|
|
2921
|
+
|
|
2922
|
+
function clientVerdicts() {
|
|
2923
|
+
const buckets = new Map();
|
|
2924
|
+
const bucket = (id) => {
|
|
2925
|
+
if (!buckets.has(id)) {
|
|
2926
|
+
buckets.set(id, { id, done: [], manual: [], skipped: [], kinds: new Set() });
|
|
2927
|
+
}
|
|
2928
|
+
return buckets.get(id);
|
|
2929
|
+
};
|
|
2930
|
+
for (const line of done) if (line.client) bucket(line.client).done.push(line);
|
|
2931
|
+
for (const line of manual) {
|
|
2932
|
+
if (!line.client) continue;
|
|
2933
|
+
const b = bucket(line.client);
|
|
2934
|
+
b.manual.push(line);
|
|
2935
|
+
if (line.kind) b.kinds.add(line.kind);
|
|
2936
|
+
}
|
|
2937
|
+
for (const line of skipped) if (line.client) bucket(line.client).skipped.push(line);
|
|
2938
|
+
// `AGENTS` is the order, so the output reads the same way on every machine.
|
|
2939
|
+
return AGENTS.filter((id) => buckets.has(id)).map((id) => {
|
|
2940
|
+
const b = buckets.get(id);
|
|
2941
|
+
const verdict = !b.kinds.size ? '就绪'
|
|
2942
|
+
: b.kinds.has('blocked') ? CLIENT_VERDICT_WORDS.blocked
|
|
2943
|
+
: b.kinds.has('broken') ? CLIENT_VERDICT_WORDS.broken
|
|
2944
|
+
: CLIENT_VERDICT_WORDS.absent;
|
|
2945
|
+
return {
|
|
2946
|
+
id,
|
|
2947
|
+
label: clientLabel(id),
|
|
2948
|
+
verdict,
|
|
2949
|
+
hint: CLIENT_VERDICT_HINTS[verdict],
|
|
2950
|
+
done: b.done,
|
|
2951
|
+
manual: b.manual,
|
|
2952
|
+
skipped: b.skipped,
|
|
2953
|
+
};
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2634
2957
|
/**
|
|
2635
2958
|
* The verify output: two sections, because the two questions have two answers.
|
|
2636
2959
|
*
|
|
2637
2960
|
* The old output was one list, and one list cannot say "the endpoint answered, the files did not
|
|
2638
2961
|
* get written" — it can only say NOT READY, which is the sentence the OpenClaw trialer read as
|
|
2639
2962
|
* "MisakaNet does not work here" (issue #3696).
|
|
2963
|
+
*
|
|
2964
|
+
* The install section is grouped **per client** (issue #3699). The checks were per client all
|
|
2965
|
+
* along — each block reads one assistant's own files — but the output could only say "this
|
|
2966
|
+
* machine", so a machine with three assistants was condemned because of the one the user never
|
|
2967
|
+
* touches, and a healthy assistant was indistinguishable from a broken one. Now each client gets
|
|
2968
|
+
* one verdict line and its own details under it.
|
|
2640
2969
|
*/
|
|
2641
2970
|
function renderVerify(verdict) {
|
|
2642
2971
|
const { start, end } = verdict.segments;
|
|
2643
2972
|
const tail = { done: done.length, manual: manual.length, skipped: skipped.length };
|
|
2644
|
-
|
|
2973
|
+
// A line is either about the machine (the endpoint probe, the token, the version stamp) or
|
|
2974
|
+
// about one client. The two never share a line, so the sections can be split by that.
|
|
2975
|
+
const machineLines = (list, from, to) => list.slice(from, to).filter((l) => !l.client);
|
|
2976
|
+
const section = (label, from, to, clients) => {
|
|
2645
2977
|
const out = [];
|
|
2646
|
-
const finished = done
|
|
2647
|
-
const open = manual
|
|
2648
|
-
const ignored = skipped
|
|
2978
|
+
const finished = machineLines(done, from.done, to.done);
|
|
2979
|
+
const open = machineLines(manual, from.manual, to.manual);
|
|
2980
|
+
const ignored = machineLines(skipped, from.skipped, to.skipped);
|
|
2649
2981
|
// `--silent` drops the narration, never the errors (the same contract `render()` keeps): the
|
|
2650
2982
|
// `!` lines are what a human has to act on, the `✓` and `·` lines are progress.
|
|
2651
|
-
if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l}`));
|
|
2652
|
-
if (open.length) out.push(...open.map((l) => ` ! ${l}`));
|
|
2653
|
-
if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l}`));
|
|
2983
|
+
if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l.text}`));
|
|
2984
|
+
if (open.length) out.push(...open.map((l) => ` ! ${l.text}`));
|
|
2985
|
+
if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l.text}`));
|
|
2986
|
+
for (const client of clients || []) {
|
|
2987
|
+
// The verdict line is the conclusion, not narration: it survives `--silent`, because a
|
|
2988
|
+
// silent `--verify` that prints nothing about the client cannot be read at all.
|
|
2989
|
+
out.push(` ${client.label}:${client.verdict}${client.hint}`);
|
|
2990
|
+
if (client.done.length && !SILENT) out.push(...client.done.map((l) => ` ✓ ${l.text}`));
|
|
2991
|
+
if (client.manual.length) out.push(...client.manual.map((l) => ` ! ${l.text}`));
|
|
2992
|
+
if (client.skipped.length && !SILENT) out.push(...client.skipped.map((l) => ` · ${l.text}`));
|
|
2993
|
+
}
|
|
2654
2994
|
if (!out.length) return out;
|
|
2655
2995
|
return [`\n── ${label} ──`, ...out];
|
|
2656
2996
|
};
|
|
2657
2997
|
return [
|
|
2658
|
-
...section('端点/可用性', start, end),
|
|
2659
|
-
...section('安装完整性', end, tail),
|
|
2998
|
+
...section('端点/可用性', start, end, []),
|
|
2999
|
+
...section('安装完整性', end, tail, verdict.clients),
|
|
2660
3000
|
'',
|
|
2661
3001
|
`可用性:${usabilityText(verdict)}`,
|
|
2662
3002
|
`安装完整性:${installText(verdict)}`,
|
|
@@ -2754,9 +3094,49 @@ function restoreEmptiedFiles() {
|
|
|
2754
3094
|
}
|
|
2755
3095
|
}
|
|
2756
3096
|
|
|
3097
|
+
/**
|
|
3098
|
+
* Is `~/.minimax/plugins/misakanet/` ours to delete?
|
|
3099
|
+
*
|
|
3100
|
+
* The install side answers this by reading the manifest and refusing to touch a directory whose
|
|
3101
|
+
* `name` is not `misakanet` (`installMcode`), reporting "已原样保留". Uninstall used to `rmSync`
|
|
3102
|
+
* the subtree with no question at all, so on a machine where the install had just promised to keep
|
|
3103
|
+
* the user's plugin, the next `--uninstall` deleted it (issue #3831, measured 2026-10-25) — the
|
|
3104
|
+
* installer's own wording turned into a lie by the command it recommends for undoing.
|
|
3105
|
+
*
|
|
3106
|
+
* A manifest that is absent or unparseable is not proof of ownership either: a plugin directory is
|
|
3107
|
+
* a user-owned space, and `misakanet` is a name another plugin can legitimately have. Being wrong
|
|
3108
|
+
* here costs irreversible data, so only a manifest that says `name: "misakanet"` answers "yes".
|
|
3109
|
+
*/
|
|
3110
|
+
function mcodePluginDirectoryOwnership() {
|
|
3111
|
+
const plugin = mcodePluginPath(HOME);
|
|
3112
|
+
const text = readText(plugin);
|
|
3113
|
+
if (!text) {
|
|
3114
|
+
return { ours: false, why: '里没有 .claude-plugin/plugin.json(无法确认归属)' };
|
|
3115
|
+
}
|
|
3116
|
+
let doc;
|
|
3117
|
+
try {
|
|
3118
|
+
doc = JSON.parse(text);
|
|
3119
|
+
} catch {
|
|
3120
|
+
return { ours: false, why: '的清单不是合法 JSON(无法确认归属)' };
|
|
3121
|
+
}
|
|
3122
|
+
if (doc?.name !== 'misakanet') {
|
|
3123
|
+
return {
|
|
3124
|
+
ours: false,
|
|
3125
|
+
why: `的清单不是本安装器写的(name=${JSON.stringify(doc?.name ?? null)})`,
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
return { ours: true, why: '' };
|
|
3129
|
+
}
|
|
3130
|
+
|
|
2757
3131
|
function uninstall() {
|
|
3132
|
+
let hermesRulesRemoved = false;
|
|
2758
3133
|
for (const rel of ['.claude/CLAUDE.md', '.codex/AGENTS.md', '.hermes/SOUL.md', '.minimax/AGENTS.md']) {
|
|
2759
|
-
if (stripBlock(join(HOME, rel)))
|
|
3134
|
+
if (stripBlock(join(HOME, rel))) {
|
|
3135
|
+
ok(`移除规则块 → ${rel}`);
|
|
3136
|
+
// Recorded here because the block is already gone by the time the Hermes summary is built —
|
|
3137
|
+
// "已移除" must describe what this run did, not what a file says now (#3834).
|
|
3138
|
+
if (rel === '.hermes/SOUL.md') hermesRulesRemoved = true;
|
|
3139
|
+
}
|
|
2760
3140
|
}
|
|
2761
3141
|
// Both candidates: a block may have been written before the workspace was read from config.
|
|
2762
3142
|
for (const workspace of openclawWorkspaces()) {
|
|
@@ -2796,14 +3176,29 @@ function uninstall() {
|
|
|
2796
3176
|
}
|
|
2797
3177
|
}
|
|
2798
3178
|
// MiniMax Code's plugin directory is ours outright (the install creates it), so uninstall takes
|
|
2799
|
-
// the whole subtree back. Only *our* directory
|
|
2800
|
-
//
|
|
3179
|
+
// the whole subtree back. Only *our* directory, and only once its manifest says it is ours: see
|
|
3180
|
+
// `mcodePluginDirectoryOwnership` for what asking the same question as the install costs (#3831).
|
|
3181
|
+
// `~/.minimax/plugins/` is the user's namespace and stays even when it ends up empty — deleting
|
|
3182
|
+
// it would remove a place their other plugins live.
|
|
2801
3183
|
const mcodeDir = join(HOME, '.minimax', 'plugins', 'misakanet');
|
|
2802
3184
|
if (existsSync(mcodeDir)) {
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
ok
|
|
2806
|
-
|
|
3185
|
+
const ownership = mcodePluginDirectoryOwnership();
|
|
3186
|
+
if (!ownership.ours) {
|
|
3187
|
+
// Deliberately a `need`, never an `ok`: the directory is still there, and only a human can
|
|
3188
|
+
// decide whether it is theirs to remove. Putting it under "已完成" was the second half of the
|
|
3189
|
+
// bug — the run claimed a removal it had skipped.
|
|
3190
|
+
need(`MiniMax Code:${mcodeDir} ${ownership.why} → 已保留(不删);`
|
|
3191
|
+
+ '确认它是你的插件后手动删掉这个目录,本安装器不碰它');
|
|
3192
|
+
} else {
|
|
3193
|
+
try {
|
|
3194
|
+
if (!DRY) rmSync(mcodeDir, { recursive: true, force: true });
|
|
3195
|
+
ok(`移除插件目录 → ${mcodeDir}`);
|
|
3196
|
+
} catch (err) {
|
|
3197
|
+
// A failed delete used to be swallowed by an empty `catch`, so the run said nothing while
|
|
3198
|
+
// the directory survived. Now it is a manual step, which is what it is.
|
|
3199
|
+
need(`MiniMax Code:删不掉 ${mcodeDir}(${redact((err && err.message) || err)})→ 手动删除它`);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
2807
3202
|
}
|
|
2808
3203
|
const voiceDir = join(stateDir(), 'voice');
|
|
2809
3204
|
if (existsSync(voiceDir)) {
|
|
@@ -2890,8 +3285,18 @@ function uninstall() {
|
|
|
2890
3285
|
ok(`移除 token 行 → ${hermesEnv}(${HERMES_ENV_KEY})`);
|
|
2891
3286
|
}
|
|
2892
3287
|
}
|
|
2893
|
-
|
|
2894
|
-
|
|
3288
|
+
// Hermes' advice, only on a machine that has Hermes. It was unconditional, so a machine that had
|
|
3289
|
+
// never installed Hermes was told "规则块已在 ~/.hermes/SOUL.md 移除" and sent to a
|
|
3290
|
+
// `hermes hooks doctor` that does not exist there (#3834, measured 2026-10-25). The `!` list is
|
|
3291
|
+
// the user's only action list — an item that does not apply dilutes it.
|
|
3292
|
+
if (existsSync(join(HOME, '.hermes'))) {
|
|
3293
|
+
// And say what actually happened to the rules block: "已移除" only when this run removed one,
|
|
3294
|
+
// "本来就没装" when the file was there without our markers.
|
|
3295
|
+
need('Hermes 的钩子不归本安装器管 → 需要时看 hermes hooks doctor;'
|
|
3296
|
+
+ (hermesRulesRemoved
|
|
3297
|
+
? '规则块已在 ~/.hermes/SOUL.md 移除'
|
|
3298
|
+
: '规则块本来就没装(~/.hermes/SOUL.md 里没有本安装器的标记块)'));
|
|
3299
|
+
}
|
|
2895
3300
|
|
|
2896
3301
|
// OpenClaw's MCP entry is one we wrote ourselves, so we can take it back ourselves.
|
|
2897
3302
|
const ocCfg = join(HOME, '.openclaw', 'openclaw.json');
|
|
@@ -2978,7 +3383,7 @@ function reportValues(verdict) {
|
|
|
2978
3383
|
const permissions = (() => {
|
|
2979
3384
|
// `n/a` when Claude Code is not a target on this machine: an absent settings file is not a
|
|
2980
3385
|
// gap, and reporting one would send a Codex-only user chasing a fix they do not need.
|
|
2981
|
-
if (!
|
|
3386
|
+
if (!claudeCodeInUse()) return 'n/a';
|
|
2982
3387
|
const granted = readJson(join(HOME, '.claude', 'settings.json'), null)?.permissions?.allow || [];
|
|
2983
3388
|
const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
|
|
2984
3389
|
return missing.length ? 'incomplete' : 'ok';
|
|
@@ -3021,7 +3426,7 @@ function reportValues(verdict) {
|
|
|
3021
3426
|
['voice', voiceStatus()],
|
|
3022
3427
|
['open-items', manual.length],
|
|
3023
3428
|
// Redacted here, once, so neither encoder can print an unredacted message by accident.
|
|
3024
|
-
['open-items-detail', manual.map((m) => redact(m))],
|
|
3429
|
+
['open-items-detail', manual.map((m) => redact(m.text))],
|
|
3025
3430
|
...HUMAN_REPORT_FIELDS.map(([key, value, note]) => [key, value, note]),
|
|
3026
3431
|
];
|
|
3027
3432
|
}
|
|
@@ -3104,9 +3509,9 @@ function render() {
|
|
|
3104
3509
|
// `--silent` drops the narration, never the errors: `done` is progress, `manual` is what a human
|
|
3105
3510
|
// (or a GPO log reader) has to act on, and a run that failed silently would be worse than a loud
|
|
3106
3511
|
// one. See SILENT at the top for the full contract.
|
|
3107
|
-
if (done.length && !SILENT) out.push(`\n已完成(${done.length}):`, ...done.map((l) => ` ✓ ${l}`));
|
|
3108
|
-
if (manual.length) out.push(`\n需要你手动一步(${manual.length}):`, ...manual.map((l) => ` ! ${l}`));
|
|
3109
|
-
if (skipped.length && !SILENT) out.push(`\n跳过(${skipped.length}):`, ...skipped.map((l) => ` · ${l}`));
|
|
3512
|
+
if (done.length && !SILENT) out.push(`\n已完成(${done.length}):`, ...done.map((l) => ` ✓ ${l.text}`));
|
|
3513
|
+
if (manual.length) out.push(`\n需要你手动一步(${manual.length}):`, ...manual.map((l) => ` ! ${l.text}`));
|
|
3514
|
+
if (skipped.length && !SILENT) out.push(`\n跳过(${skipped.length}):`, ...skipped.map((l) => ` · ${l.text}`));
|
|
3110
3515
|
return out.join('\n');
|
|
3111
3516
|
}
|
|
3112
3517
|
|
|
@@ -3123,6 +3528,22 @@ const STRICT = has('--strict') || has('--ci');
|
|
|
3123
3528
|
const mode = has('--uninstall') ? 'uninstall'
|
|
3124
3529
|
: ((has('--report') || REPORT_JSON || (has('--ci') && !has('--verify'))) ? 'report'
|
|
3125
3530
|
: (has('--verify') ? 'verify' : 'install'));
|
|
3531
|
+
// `--client` is a question about *judging*, so it belongs to `--verify` and nowhere else: in
|
|
3532
|
+
// install mode `--only` already says which assistants to write to, and in report mode the report
|
|
3533
|
+
// has its own shape. A flag that silently does nothing is worse than one that refuses: `--client`
|
|
3534
|
+
// next to `--client-id` is exactly the kind of pair a user mixes up, so it says what it means.
|
|
3535
|
+
// Exit 2 is this tool's "could not answer" code (same as a probe timeout, #3778) — the machine
|
|
3536
|
+
// is not broken, the request was.
|
|
3537
|
+
if (CLIENT && mode !== 'verify') {
|
|
3538
|
+
console.error(`--client 只对 --verify 有意义(它决定"只判哪一个客户端");`
|
|
3539
|
+
+ `当前模式是 ${mode},请用 --only <助手> 指定要处理的助手。`);
|
|
3540
|
+
process.exit(2);
|
|
3541
|
+
}
|
|
3542
|
+
if (CLIENT && !AGENTS.includes(CLIENT)) {
|
|
3543
|
+
console.error(`未知的客户端:${CLIENT}`);
|
|
3544
|
+
console.error(`支持的客户端:${supportedAgentLabels()}`);
|
|
3545
|
+
process.exit(2);
|
|
3546
|
+
}
|
|
3126
3547
|
if (mode !== 'report' && !SILENT) {
|
|
3127
3548
|
// The banner names the home directory, which is exactly the kind of thing that gets pasted
|
|
3128
3549
|
// along with a report — so `--report` prints only the report. `--silent` drops it for the other
|
|
@@ -3273,7 +3694,11 @@ if (LIST_WRITES) {
|
|
|
3273
3694
|
// The rows come from the shared manifest builder, the same list the install preview prints: one
|
|
3274
3695
|
// source of truth, so the manifest an operator approves and the preview a user reads cannot
|
|
3275
3696
|
// disagree about what this command would write.
|
|
3276
|
-
|
|
3697
|
+
// Tier ③ is not always a hook: `hook.mjs`, the endpoint record and the version stamp live in the
|
|
3698
|
+
// same state directory, and a target with no hook mechanism (Cursor, mcode, …) still gets that
|
|
3699
|
+
// directory written. Calling the whole tier "装钩子" told those users they were approving a
|
|
3700
|
+
// behaviour change they were not (#3777 follow-up).
|
|
3701
|
+
const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 钩子/状态目录' };
|
|
3277
3702
|
const list = buildWriteManifest(targets);
|
|
3278
3703
|
for (const row of list) {
|
|
3279
3704
|
console.log(`tier${row.tier}\t${tiers[row.tier]}\t${row.path}\t${row.action}`);
|
|
@@ -3373,6 +3798,25 @@ if (!SILENT && !DRY) {
|
|
|
3373
3798
|
// looked identical. `--dry-run` keeps its own contract — it already says it would write
|
|
3374
3799
|
// nothing, so a "0 files written" line under it would only restate the mode.
|
|
3375
3800
|
console.log(`本次实际写入 ${writtenFiles.size} 个文件(上面的清单是"涉及哪些",这一行是"真的动了哪些")。`);
|
|
3801
|
+
|
|
3802
|
+
if (installed > 0) {
|
|
3803
|
+
// The tail is what a user reads, and until now it held only next steps: a real Windows run on
|
|
3804
|
+
// 2026-10-29 ended on "5) 想关掉…" and the identity line, so the sole evidence that anything had
|
|
3805
|
+
// worked was the absence of errors. Verified here rather than assumed: the version is this build's,
|
|
3806
|
+
// and whether it is the published one is asked of the registry — the same two primitives `--verify`
|
|
3807
|
+
// uses, so the closing line and the self-check cannot disagree about what "latest" means.
|
|
3808
|
+
const latest = await latestPublishedVersion();
|
|
3809
|
+
const delta = latest ? compareVersions(VERSION, latest) : null;
|
|
3810
|
+
let versionNote = ';查不到 npm 上的最新版本(离线或 registry 不可达)';
|
|
3811
|
+
if (delta === 0) versionNote = '(已是最新)';
|
|
3812
|
+
else if (delta !== null && delta < 0) {
|
|
3813
|
+
versionNote = `;npm 上已有 ${latest} → 重跑 npx @misaka-net/misakanet-setup@latest 可更新`;
|
|
3814
|
+
} else if (delta !== null) {
|
|
3815
|
+
versionNote = `;比 npm 上的 ${latest} 新(本地构建或预发布)`;
|
|
3816
|
+
}
|
|
3817
|
+
console.log(`\n✓ 安装成功 —— misakanet-setup ${VERSION}${versionNote}`
|
|
3818
|
+
+ `\n 已配置 ${installed} 个助手;撤销:npx @misaka-net/misakanet-setup --uninstall`);
|
|
3819
|
+
}
|
|
3376
3820
|
}
|
|
3377
3821
|
if (MCP_ONLY && !SILENT) {
|
|
3378
3822
|
// The honest failure mode of tier ①, said where the user will read it: they are not installing a
|
|
@@ -3384,13 +3828,14 @@ if (MCP_ONLY && !SILENT) {
|
|
|
3384
3828
|
if (!SILENT) {
|
|
3385
3829
|
console.log(`
|
|
3386
3830
|
接下来:
|
|
3387
|
-
1)
|
|
3388
|
-
2)
|
|
3389
|
-
3)
|
|
3390
|
-
4)
|
|
3831
|
+
1) 启动时加载 MCP 配置的客户端(Claude Code / Codex 等)重开一次会话;dsh 下一条消息就生效,mcporter 这类按次调用的通道不需要重启
|
|
3832
|
+
2) 确认工具装上了:跑客户端的 MCP 列表命令(如 mcporter list misakanet,或 Claude Code 的 /mcp)——应看到 8 个工具
|
|
3833
|
+
3) 随便挑一句带报错原文的片段问它(例如「${ONBOARDING_QUERIES[0]}」「${ONBOARDING_QUERIES[1]}」「${ONBOARDING_QUERIES[2]}」)——它应该先去查经验库
|
|
3834
|
+
4) 想确认状态:npx @misaka-net/misakanet-setup --verify
|
|
3835
|
+
5) 想关掉:npx @misaka-net/misakanet-setup --uninstall`
|
|
3391
3836
|
+ (clientIdHint
|
|
3392
3837
|
? `
|
|
3393
|
-
|
|
3838
|
+
6) 记不记都行:这台机器的身份编号是 ${clientIdHint}
|
|
3394
3839
|
想让它以后重装/换机还算同一个身份,再跑安装时加:--client-id ${clientIdHint}`
|
|
3395
3840
|
: ''));
|
|
3396
3841
|
}
|