@misaka-net/misakanet-setup 0.5.5 → 0.6.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 +74 -7
- package/bin/misakanet-setup.mjs +820 -103
- package/hook/checkpoint_reminder.mjs +94 -2
- package/package.json +2 -2
package/bin/misakanet-setup.mjs
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* undone by the other.
|
|
17
17
|
*
|
|
18
18
|
* Three things, all required, or the install is theatre:
|
|
19
|
-
* 1. the agent CAN call it → MCP server entry (with the token
|
|
20
|
-
*
|
|
19
|
+
* 1. the agent CAN call it → MCP server entry (with the token when we have one, plus the
|
|
20
|
+
* self-declared context hints; reads are unlimited either way)
|
|
21
21
|
* 2. the agent KNOWS when to call → rules block in its own rules file
|
|
22
22
|
* 3. the checkpoint FIRES → a hook, because "summarise every 20 turns" is dead
|
|
23
23
|
* text: agents do not keep counters
|
|
@@ -39,7 +39,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, copyFi
|
|
|
39
39
|
// package.json since 0.4 and had never been executed on the version it names.
|
|
40
40
|
import { randomUUID } from 'node:crypto';
|
|
41
41
|
import { homedir } from 'node:os';
|
|
42
|
-
import { delimiter, join, dirname, resolve } from 'node:path';
|
|
42
|
+
import { basename, delimiter, join, dirname, resolve } from 'node:path';
|
|
43
43
|
import { fileURLToPath } from 'node:url';
|
|
44
44
|
|
|
45
45
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -78,6 +78,25 @@ const valueOf = (flag, dflt) => {
|
|
|
78
78
|
};
|
|
79
79
|
const only = valueOf('--only', '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
80
80
|
const DRY = has('--dry-run');
|
|
81
|
+
/**
|
|
82
|
+
* Permission tiers (2026-09-18).
|
|
83
|
+
*
|
|
84
|
+
* The installer does three different kinds of thing, and they do not need the same permission:
|
|
85
|
+
*
|
|
86
|
+
* ① register an MCP endpoint — the user's own agent config; the same privilege as adding
|
|
87
|
+
* any MCP server;
|
|
88
|
+
* ② write a rules block — changes the agent's behaviour ("search before retrying"),
|
|
89
|
+
* and for Hermes that means editing `~/.hermes/SOUL.md`, its
|
|
90
|
+
* *identity* file;
|
|
91
|
+
* ③ install hooks + state — acts automatically on every turn.
|
|
92
|
+
*
|
|
93
|
+
* Asking for all three in one command is why a machine with an approval process can only answer
|
|
94
|
+
* "no" (issue #1753, 2026-09-18): an operator willing to approve ① has no way to say it. `--mcp-only`
|
|
95
|
+
* is that way to say it — ① only, and the run says so in its own report instead of looking partial.
|
|
96
|
+
*/
|
|
97
|
+
const MCP_ONLY = has('--mcp-only');
|
|
98
|
+
const LIST_WRITES = has('--list-writes');
|
|
99
|
+
|
|
81
100
|
/**
|
|
82
101
|
* `--silent` (issue #1784): the enterprise/MDM form. A GPO or Intune script pipes this program's
|
|
83
102
|
* stdout into a log file, where the ✓/· narration is noise (and names local paths).
|
|
@@ -94,7 +113,8 @@ const SILENT = has('--silent');
|
|
|
94
113
|
/** `--report-json`: the same report as JSON, and nothing else on stdout (#1784). */
|
|
95
114
|
const REPORT_JSON = has('--report-json');
|
|
96
115
|
const HOME = resolve(valueOf('--home', homedir()));
|
|
97
|
-
const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale'
|
|
116
|
+
const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
|
|
117
|
+
'cursor', 'gemini', 'copilot', 'opencode', 'kiro'];
|
|
98
118
|
|
|
99
119
|
const done = [];
|
|
100
120
|
const manual = [];
|
|
@@ -127,8 +147,31 @@ function readText(path) {
|
|
|
127
147
|
* (the value goes into the User-Agent header): js/file-access-to-http #268. The drift it was
|
|
128
148
|
* meant to prevent is now caught in CI instead, by a test that binds this literal to the
|
|
129
149
|
* manifest — a failing test is a better place for that than a request header.
|
|
150
|
+
*
|
|
151
|
+
* **Who moves it:** the release tooling, not a person. The `x-release-please-version` marker on
|
|
152
|
+
* this line is what release-please's generic updater looks for, and the file is listed in the
|
|
153
|
+
* setup package's `extra-files`, so the release pull request bumps this literal together with
|
|
154
|
+
* the manifest. That marker is not decoration: release-please replaces a version **only on a
|
|
155
|
+
* line that carries the marker** — an unmarked line is copied verbatim, so a release would ship
|
|
156
|
+
* a stale `--version` and a stale `X-MisakaNet-Version` header while every gate stayed green
|
|
157
|
+
* except the two that name the version (measured 2026-10-22: the 0.6.0 release pull request
|
|
158
|
+
* failed 9 e2e legs on exactly that).
|
|
159
|
+
*/
|
|
160
|
+
const VERSION = '0.6.0' // x-release-please-version
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The shape of a client id, in one place.
|
|
164
|
+
*
|
|
165
|
+
* It gates two different things: what the endpoint accepts as `client_id` on registration, and — since
|
|
166
|
+
* 2026-09-19 — what may be written into an assistant's config as `X-MisakaNet-Client`. The second use
|
|
167
|
+
* is why the shape matters here and not only at the endpoint: that value can come from a file we found
|
|
168
|
+
* on disk, and it is interpolated into a TOML inline table, a YAML mapping and JSON. A string matching
|
|
169
|
+
* this cannot contain a quote, a newline or a `:`, so it cannot break any of the three.
|
|
130
170
|
*/
|
|
131
|
-
const
|
|
171
|
+
const CLIENT_ID_SHAPE = /^[A-Za-z0-9._-]{8,64}$/
|
|
172
|
+
// The server's rule for a referral code (`misakanet_register` → `referral_code`), kept identical on
|
|
173
|
+
// both sides: a value the client is willing to send and the server is willing to count.
|
|
174
|
+
const REFERRAL_SHAPE = /^[A-Za-z0-9]{4,16}$/
|
|
132
175
|
|
|
133
176
|
// ── argument surface ─────────────────────────────────────────────────
|
|
134
177
|
// Until 2026-09-17 no flag was validated at all: `--help` fell through to a *real install*
|
|
@@ -148,7 +191,9 @@ const FLAGS = [
|
|
|
148
191
|
['--uninstall', '移除本安装器写入的内容(保留 .misakanet.bak 备份)'],
|
|
149
192
|
['--upgrade', '与安装等价(覆盖安装即升级)'],
|
|
150
193
|
['--client-id <id>', '固定本机身份:同一个 id 永远拿回同一个 node(重装/换机也能延续)'],
|
|
151
|
-
['--
|
|
194
|
+
['--mcp-only', '只注册 MCP 端点,不改 agent 行为(不写规则块、不装钩子)——权限分级用'],
|
|
195
|
+
['--list-writes', '只打印会改哪些文件(文件 | 动作 | 撤销方式),不写任何东西'],
|
|
196
|
+
['--no-register', '不注册匿名节点(不写 token;读不受影响,写入类工具不可用)'],
|
|
152
197
|
['--voice', '打开语音/桌面通知(默认关)'],
|
|
153
198
|
['--help, -h', '打印这份帮助并退出'],
|
|
154
199
|
['--version', '打印版本并退出'],
|
|
@@ -283,6 +328,91 @@ function writeText(path, text, { mode } = {}) {
|
|
|
283
328
|
}
|
|
284
329
|
}
|
|
285
330
|
|
|
331
|
+
/**
|
|
332
|
+
* The targets whose whole install is one MCP entry in one JSON file.
|
|
333
|
+
*
|
|
334
|
+
* Each entry shape is the one that target's own documentation shows, and they are **not**
|
|
335
|
+
* interchangeable: Gemini CLI's remote field is `httpUrl` (its `url` means SSE), OpenCode nests under
|
|
336
|
+
* `mcp` rather than `mcpServers` and wants `type: "remote"`, Copilot CLI wants `type: "http"`, and
|
|
337
|
+
* Cursor and Kiro use a bare `url`. A copied entry with the wrong key fails **silently** — no error,
|
|
338
|
+
* the server simply never appears — which is why each shape is pinned by tests instead of assumed.
|
|
339
|
+
*
|
|
340
|
+
* None of these five has a rules block or a hook *this installer can write*: their rules live in
|
|
341
|
+
* project-scoped files (`.cursor/rules/*.mdc`, `GEMINI.md`, `AGENTS.md`, `.github/copilot-instructions.md`,
|
|
342
|
+
* `.kiro/steering/*.md`) and we do not know where a user's projects are. So tier ① is the whole
|
|
343
|
+
* install for them, `--mcp-only` is an identity, and the output says "MCP only" out loud instead of
|
|
344
|
+
* implying behaviour changed. That is also why `--report` says `install-scope: mcp-only` on a machine
|
|
345
|
+
* whose only target is one of these.
|
|
346
|
+
*/
|
|
347
|
+
const MCP_ONLY_TARGETS = {
|
|
348
|
+
cursor: {
|
|
349
|
+
label: 'Cursor',
|
|
350
|
+
detect: ['.cursor'],
|
|
351
|
+
config: (home) => join(home, '.cursor', 'mcp.json'),
|
|
352
|
+
container: 'mcpServers',
|
|
353
|
+
urlField: 'url',
|
|
354
|
+
entry: (bearer) => ({ url: ENDPOINT, headers: mcpHeaders(bearer, 'cursor') }),
|
|
355
|
+
rules: '.cursor/rules/*.mdc 是项目级的,安装器不知道你的项目在哪',
|
|
356
|
+
ruleHint: '想让 Cursor 主动去查,把 .cursor/rules/misakanet-failure-memory.mdc 放进项目',
|
|
357
|
+
manual: '重启 Cursor 后在 Settings → MCP 里确认能看见 misakanet(本安装器只写用户级 '
|
|
358
|
+
+ '~/.cursor/mcp.json,项目级 .cursor/mcp.json 归你自己管)',
|
|
359
|
+
verifyHint: '在 Settings → MCP 里复核',
|
|
360
|
+
},
|
|
361
|
+
gemini: {
|
|
362
|
+
label: 'Gemini CLI',
|
|
363
|
+
detect: ['.gemini'],
|
|
364
|
+
config: (home) => join(home, '.gemini', 'settings.json'),
|
|
365
|
+
container: 'mcpServers',
|
|
366
|
+
// `httpUrl` is the HTTP-streaming endpoint; `url` would mean SSE. Documented property list:
|
|
367
|
+
// geminicli.com/docs/tools/mcp-server ("headers: Custom HTTP headers when using url or httpUrl").
|
|
368
|
+
urlField: 'httpUrl',
|
|
369
|
+
entry: (bearer) => ({ httpUrl: ENDPOINT, headers: mcpHeaders(bearer, 'gemini') }),
|
|
370
|
+
rules: '它读 GEMINI.md(全局 ~/.gemini/GEMINI.md 与项目树),而安装器不写别人的规则文件',
|
|
371
|
+
ruleHint: '想让 Gemini CLI 主动去查,把规则块加进项目的 GEMINI.md',
|
|
372
|
+
manual: '重启会话后用 `/mcp` 复核 misakanet 是否列出',
|
|
373
|
+
verifyHint: '用 /mcp 复核',
|
|
374
|
+
},
|
|
375
|
+
copilot: {
|
|
376
|
+
label: 'Copilot CLI',
|
|
377
|
+
detect: ['.copilot'],
|
|
378
|
+
config: (home) => join(home, '.copilot', 'mcp-config.json'),
|
|
379
|
+
container: 'mcpServers',
|
|
380
|
+
urlField: 'url',
|
|
381
|
+
entry: (bearer) => ({ type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'copilot') }),
|
|
382
|
+
rules: '它读 .github/copilot-instructions.md 与 AGENTS.md(项目级),安装器不写项目文件',
|
|
383
|
+
ruleHint: '想让 Copilot CLI 主动去查,把规则写进项目的 .github/copilot-instructions.md',
|
|
384
|
+
manual: '跑 `copilot mcp list` 复核(VS Code 里的 Copilot 是另一份配置,键名是 `servers`)',
|
|
385
|
+
verifyHint: '用 `copilot mcp list` 复核',
|
|
386
|
+
},
|
|
387
|
+
opencode: {
|
|
388
|
+
label: 'OpenCode',
|
|
389
|
+
detect: ['.config/opencode'],
|
|
390
|
+
config: (home) => join(home, '.config', 'opencode', 'opencode.json'),
|
|
391
|
+
container: 'mcp',
|
|
392
|
+
urlField: 'url',
|
|
393
|
+
entry: (bearer) => ({ type: 'remote', url: ENDPOINT, enabled: true,
|
|
394
|
+
headers: mcpHeaders(bearer, 'opencode') }),
|
|
395
|
+
rules: '它读 AGENTS.md(项目根与 ~/.config/opencode/AGENTS.md),安装器不写别人的规则文件',
|
|
396
|
+
ruleHint: '想让 OpenCode 主动去查,把规则块加进项目的 AGENTS.md',
|
|
397
|
+
manual: '重启 OpenCode 后看它的 MCP 列表;若你设了 XDG_CONFIG_HOME,它读的是 '
|
|
398
|
+
+ '$XDG_CONFIG_HOME/opencode/opencode.json,把同样的条目贴过去即可',
|
|
399
|
+
verifyHint: '看 OpenCode 的 MCP 列表复核',
|
|
400
|
+
parseNote: 'opencode.jsonc 允许注释,若你用的正是它,本安装器解析不了',
|
|
401
|
+
},
|
|
402
|
+
kiro: {
|
|
403
|
+
label: 'Kiro',
|
|
404
|
+
detect: ['.kiro'],
|
|
405
|
+
config: (home) => join(home, '.kiro', 'settings', 'mcp.json'),
|
|
406
|
+
container: 'mcpServers',
|
|
407
|
+
urlField: 'url',
|
|
408
|
+
entry: (bearer) => ({ url: ENDPOINT, headers: mcpHeaders(bearer, 'kiro') }),
|
|
409
|
+
rules: '它读 .kiro/steering/*.md(steering 文件),安装器不写别人的规则文件',
|
|
410
|
+
ruleHint: '想让 Kiro 主动去查,把规则加进 .kiro/steering/',
|
|
411
|
+
manual: '重启 Kiro 后用它的 MCP 面板复核 misakanet(Kiro 还支持 disabled / autoApprove 字段)',
|
|
412
|
+
verifyHint: '用 Kiro 的 MCP 面板复核',
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
|
|
286
416
|
/**
|
|
287
417
|
* The files each assistant's install writes, so a permission problem can be reported *before*
|
|
288
418
|
* anything is touched (and so `--dry-run` can say "I cannot change that" as well as "I would change
|
|
@@ -295,9 +425,111 @@ const AGENT_WRITE_PATHS = {
|
|
|
295
425
|
codex: (home) => [join(home, '.codex', 'config.toml'), join(home, '.codex', 'AGENTS.md')],
|
|
296
426
|
hermes: (home) => [join(home, '.hermes', 'config.yaml'), join(home, '.hermes', 'SOUL.md')],
|
|
297
427
|
codewhale: (home) => [join(home, '.codewhale', 'mcp.json')],
|
|
428
|
+
// Every MCP-only target writes exactly one file, so the table above is the single source of truth
|
|
429
|
+
// for the write manifest, the detection paths and the report's scope line.
|
|
430
|
+
...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
|
|
431
|
+
.map(([id, spec]) => [id, (home) => [spec.config(home)]])),
|
|
298
432
|
openclaw: () => openclawWorkspaces().map((workspace) => join(workspace, 'AGENTS.md')),
|
|
299
433
|
};
|
|
300
434
|
|
|
435
|
+
/**
|
|
436
|
+
* The write manifest: every file this run would touch, with the kind of write and the tier it
|
|
437
|
+
* belongs to.
|
|
438
|
+
*
|
|
439
|
+
* One builder, two readers: `--list-writes` prints it for an operator who has to approve it, and
|
|
440
|
+
* the install preview prints it for the user who is about to say yes (#3690). They cannot drift,
|
|
441
|
+
* because there is only one list — a preview that disagrees with the manifest is worse than no
|
|
442
|
+
* preview at all.
|
|
443
|
+
*
|
|
444
|
+
* The tiers are the permission tiers from the top of this file: ① registers an MCP endpoint,
|
|
445
|
+
* ② writes a rules block (that changes how the agent behaves), ③ installs hooks and state (that
|
|
446
|
+
* run on every turn). Under `--mcp-only` only ① is in scope, so the other two are not listed —
|
|
447
|
+
* the same rule the install itself follows.
|
|
448
|
+
*/
|
|
449
|
+
function buildWriteManifest(agents) {
|
|
450
|
+
// Compare base names, not separators: a `/settings.json$` regex silently classifies nothing on
|
|
451
|
+
// Windows (all three windows legs went red on the first CI run of this change), and a manifest
|
|
452
|
+
// that hides a tier is worse than no manifest.
|
|
453
|
+
const classify = (path) => {
|
|
454
|
+
const base = basename(path);
|
|
455
|
+
if (base === 'CLAUDE.md' || base === 'AGENTS.md' || base === 'SOUL.md') return { tier: 2, kind: 'rules' };
|
|
456
|
+
if (path.includes('.misakanet-agent')) return { tier: 3, kind: 'state' };
|
|
457
|
+
// `settings.json` carries both the read-only grants (①) and the hooks (③); the hook row is
|
|
458
|
+
// added separately below, and only when the hooks are in scope.
|
|
459
|
+
if (base === 'settings.json' && path.includes('.claude')) return { tier: 1, kind: 'grants' };
|
|
460
|
+
return { tier: 1, kind: 'mcp' };
|
|
461
|
+
};
|
|
462
|
+
const rows = new Map();
|
|
463
|
+
const add = (path, action, tier, kind) => {
|
|
464
|
+
const key = `${tier}|${path}`;
|
|
465
|
+
if (!rows.has(key)) rows.set(key, { path: redact(path), action, tier, kind });
|
|
466
|
+
};
|
|
467
|
+
for (const agent of agents) {
|
|
468
|
+
if (!detect(agent)) continue;
|
|
469
|
+
for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
|
|
470
|
+
const { tier, kind } = classify(path);
|
|
471
|
+
add(path, existsSync(path) ? 'append-or-update' : 'create', tier, kind);
|
|
472
|
+
if (!MCP_ONLY && basename(path) === 'settings.json' && path.includes('.claude')) {
|
|
473
|
+
add(path, 'append-or-update(钩子)', 3, 'hook');
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// The state dir is ours, but it is still written to disk — and only when there is something to
|
|
478
|
+
// install: with no target detected the run writes nothing at all.
|
|
479
|
+
if (agents.length) {
|
|
480
|
+
// `--no-register` registers no anonymous node, so no token is written; listing it anyway would
|
|
481
|
+
// make the manifest — and the preview's file count — promise a file that stays absent.
|
|
482
|
+
if (!has('--no-register')) add(join(stateDir(), 'token'), 'create (0600)', 1, 'token');
|
|
483
|
+
if (!MCP_ONLY) {
|
|
484
|
+
add(join(stateDir(), 'hook.mjs'), 'create', 3, 'hookScript');
|
|
485
|
+
add(join(stateDir(), STATE_VERSION_FILE), 'create', 3, 'version');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return [...rows.values()]
|
|
489
|
+
.filter((row) => (MCP_ONLY ? row.tier === 1 : true))
|
|
490
|
+
.sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The preview: what this run is about to write, said *before* it writes it (#3690).
|
|
495
|
+
*
|
|
496
|
+
* A user who never opens a checkout has no way to know that an installer also edits the agent's
|
|
497
|
+
* rules file — and that file is the one that changes how the agent behaves. So the summary names
|
|
498
|
+
* the files, the kind of change, and the three ways out: look first (`--list-writes`), ask for the
|
|
499
|
+
* endpoint only (`--mcp-only`), or take it back (`--uninstall`).
|
|
500
|
+
*
|
|
501
|
+
* One line per file, one line of ways out, and every path redacted to `~` — the person reading
|
|
502
|
+
* this may never see a checkout, so nothing here may assume one.
|
|
503
|
+
*/
|
|
504
|
+
function writePreview(rows) {
|
|
505
|
+
const files = new Map();
|
|
506
|
+
for (const row of rows) {
|
|
507
|
+
const file = files.get(row.path) || { path: row.path, tier: row.tier, kinds: [] };
|
|
508
|
+
file.tier = Math.min(file.tier, row.tier);
|
|
509
|
+
file.kinds.push(row.kind);
|
|
510
|
+
files.set(row.path, file);
|
|
511
|
+
}
|
|
512
|
+
const list = [...files.values()].sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
|
|
513
|
+
if (!list.length) return '';
|
|
514
|
+
const labels = {
|
|
515
|
+
mcp: 'MCP 端点',
|
|
516
|
+
grants: '只读工具放行',
|
|
517
|
+
rules: '规则块(会改变 agent 的行为)',
|
|
518
|
+
hook: '钩子',
|
|
519
|
+
token: 'MCP 凭据',
|
|
520
|
+
hookScript: '钩子脚本',
|
|
521
|
+
version: '版本戳',
|
|
522
|
+
state: '本机状态',
|
|
523
|
+
};
|
|
524
|
+
const width = Math.min(Math.max(...list.map((f) => f.path.length)) + 2, 32);
|
|
525
|
+
const pad = (text) => (text.length >= width ? `${text} ` : text.padEnd(width));
|
|
526
|
+
return [
|
|
527
|
+
`将修改 ${list.length} 个文件${DRY ? '(--dry-run:只是预览,不写任何东西)' : ''}:`,
|
|
528
|
+
...list.map((f) => ` ${pad(f.path)}+ ${f.kinds.map((k) => labels[k]).join(' + ')}`),
|
|
529
|
+
'先看:--list-writes 不改行为:--mcp-only 撤销:--uninstall',
|
|
530
|
+
].join('\n');
|
|
531
|
+
}
|
|
532
|
+
|
|
301
533
|
/**
|
|
302
534
|
* Which of these paths this process may not write.
|
|
303
535
|
*
|
|
@@ -378,6 +610,19 @@ function ourHookEntries(hooks) {
|
|
|
378
610
|
}
|
|
379
611
|
|
|
380
612
|
/** Insert or refresh a marker-delimited block. Uses a function replacement (no $-escapes). */
|
|
613
|
+
/**
|
|
614
|
+
* Is tier ③ (the behaviour layer) actually *wired*, or merely present on disk?
|
|
615
|
+
*
|
|
616
|
+
* `hook.mjs` living in our own state directory proves nothing about the user's agents: the hook only
|
|
617
|
+
* runs where a config file points at it, and today that is Claude Code alone. `--report` used to
|
|
618
|
+
* answer `install-scope: full` from the file's existence, so a Cursor-only machine claimed the
|
|
619
|
+
* behaviour layer was installed. It is `full` only when Claude Code's settings carry our entries.
|
|
620
|
+
*/
|
|
621
|
+
function hookWired() {
|
|
622
|
+
const settings = readJson(join(HOME, '.claude', 'settings.json'), null);
|
|
623
|
+
return ourHookEntries(settings?.hooks).length > 0;
|
|
624
|
+
}
|
|
625
|
+
|
|
381
626
|
function injectBlock(path, block) {
|
|
382
627
|
const existing = readText(path);
|
|
383
628
|
const pattern = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
|
|
@@ -415,6 +660,58 @@ function stripBlock(path) {
|
|
|
415
660
|
|
|
416
661
|
const stateDir = () => join(HOME, '.misakanet-agent');
|
|
417
662
|
|
|
663
|
+
/**
|
|
664
|
+
* The headers every MCP entry we write carries.
|
|
665
|
+
*
|
|
666
|
+
* Two kinds, and the difference matters: `Authorization` is a credential (only when we have a token),
|
|
667
|
+
* while the other four are **self-declared hints** the service records on its analytics row — the
|
|
668
|
+
* stable pseudonym `X-MisakaNet-Client` (so a caller's history stays together without an account), the
|
|
669
|
+
* assistant name, the platform, and the installer version that wrote this. The endpoint treats them as
|
|
670
|
+
* hints and never as identity, which is what makes it acceptable to write them automatically
|
|
671
|
+
* (see AGENTS.md §3.3).
|
|
672
|
+
*
|
|
673
|
+
* Written only for agents whose config format supports custom headers; `--mcp-only` gets them too,
|
|
674
|
+
* because "the tool is callable" is exactly what tier ① means.
|
|
675
|
+
*/
|
|
676
|
+
function mcpHeaders(bearer, agent) {
|
|
677
|
+
const headers = {};
|
|
678
|
+
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
679
|
+
const clientId = declaredClientId();
|
|
680
|
+
if (clientId) headers['X-MisakaNet-Client'] = clientId;
|
|
681
|
+
headers['X-MisakaNet-Agent'] = agent;
|
|
682
|
+
headers['X-MisakaNet-Os'] = `${process.platform}/${process.arch}`.slice(0, 40);
|
|
683
|
+
headers['X-MisakaNet-Version'] = VERSION;
|
|
684
|
+
return headers;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The pseudonym to declare, or `''` when this machine has not said.
|
|
689
|
+
*
|
|
690
|
+
* Explicit intent beats a file we merely found, the same rule the register path applies. The file is
|
|
691
|
+
* `integrations/agent-autostart`'s (it keeps one there for the Python channel), so picking it up is
|
|
692
|
+
* continuity worth having — but it is data from outside this process on its way into three config
|
|
693
|
+
* syntaxes, so it is checked against `CLIENT_ID_SHAPE` first. A `client_id` that is not id-shaped is
|
|
694
|
+
* not a hint worth writing; it is noise that could close a quote or a mapping.
|
|
695
|
+
*/
|
|
696
|
+
/**
|
|
697
|
+
* The referral code of the node that invited this one, or `''`.
|
|
698
|
+
*
|
|
699
|
+
* `python3 scripts/referral.py --apply=CODE` writes it to `<stateDir>/referral_code` — the same
|
|
700
|
+
* directory `client_id` and `token` live in, so every client on the machine can read it. Shape-checked
|
|
701
|
+
* for the same reason `client_id` is: it is a value from a file on its way into an API request, and the
|
|
702
|
+
* server will ignore anything that is not code-shaped, which would be a silent no-op (#1996).
|
|
703
|
+
*/
|
|
704
|
+
function declaredReferral() {
|
|
705
|
+
const found = readText(join(stateDir(), 'referral_code')).trim();
|
|
706
|
+
return REFERRAL_SHAPE.test(found) ? found : '';
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function declaredClientId() {
|
|
710
|
+
if (knownClientId) return knownClientId;
|
|
711
|
+
const found = readText(join(stateDir(), 'client_id')).trim();
|
|
712
|
+
return CLIENT_ID_SHAPE.test(found) ? found : '';
|
|
713
|
+
}
|
|
714
|
+
|
|
418
715
|
const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
|
|
419
716
|
|
|
420
717
|
/**
|
|
@@ -429,6 +726,38 @@ function probeEndpoint() {
|
|
|
429
726
|
return (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
|
|
430
727
|
}
|
|
431
728
|
|
|
729
|
+
/**
|
|
730
|
+
* The proxy environment, and whether *this* Node will actually use it.
|
|
731
|
+
*
|
|
732
|
+
* Node's `fetch` ignores `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` unless the runtime is told to
|
|
733
|
+
* honour them — `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, with fetch support in v22.21.0 /
|
|
734
|
+
* v24.0.0+ (nodejs.org/learn/http/enterprise-network-configuration). So in an enterprise-proxy
|
|
735
|
+
* environment a failed probe is **not** evidence that the endpoint is down: `curl` and the user's
|
|
736
|
+
* agent may both be reaching it through a proxy this process never saw. Reporting that as
|
|
737
|
+
* "端点不可达(网络受限?)" sends a user whose install works off to debug a network that is fine —
|
|
738
|
+
* and the audience for this installer includes exactly the people whose proxies cause this.
|
|
739
|
+
*
|
|
740
|
+
* Returns `null` when no proxy is configured (the ordinary path), otherwise the variables in play
|
|
741
|
+
* and whether the runtime was told to use them.
|
|
742
|
+
*/
|
|
743
|
+
function proxyEnvironment() {
|
|
744
|
+
const vars = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy']
|
|
745
|
+
.filter((name) => (process.env[name] || '').trim());
|
|
746
|
+
if (!vars.length) return null;
|
|
747
|
+
const enabled = Boolean((process.env.NODE_USE_ENV_PROXY || '').trim())
|
|
748
|
+
|| process.execArgv.some((arg) => arg.includes('use-env-proxy'))
|
|
749
|
+
|| (process.env.NODE_OPTIONS || '').includes('use-env-proxy');
|
|
750
|
+
return { vars, enabled, noProxy: (process.env.NO_PROXY || process.env.no_proxy || '').trim() };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** The one line that turns an inconclusive probe into a next action. */
|
|
754
|
+
function proxyProbeAdvice(proxy) {
|
|
755
|
+
return `本机配了代理(${proxy.vars.join('、')}),而 Node 的 fetch 默认不读这些变量 → 探测结果不可信,`
|
|
756
|
+
+ '不代表端点不可达。确认方式:先 `curl -sS https://misakanet.org/mcp` 手测,'
|
|
757
|
+
+ '或让 Node 走代理重跑:`NODE_USE_ENV_PROXY=1 npx @misaka-net/misakanet-setup --verify`'
|
|
758
|
+
+ '(fetch 需要 Node ≥ 22.21.0 / 24.0.0;公司代理做 TLS 拦截时再加 `NODE_USE_SYSTEM_CA=1`)';
|
|
759
|
+
}
|
|
760
|
+
|
|
432
761
|
/**
|
|
433
762
|
* One unauthenticated MCP call.
|
|
434
763
|
*
|
|
@@ -547,7 +876,7 @@ const PROMPT_BLOCK = `调用规则(按顺序照做):
|
|
|
547
876
|
5) 查不到就 misakanet_submit_intake(kind="question")。
|
|
548
877
|
6) 约 20 轮后、或刚解决一个非平凡问题时:把本次「失败→根因→修复→验证」里可泛化、有判据、且搜过没有重复的部分,
|
|
549
878
|
脱敏后 misakanet_submit_intake(kind="missing_lesson") 提交;不够价值就不提交。
|
|
550
|
-
|
|
879
|
+
脱敏:已由代码自动完成——本地钩子发出的查询在离开本机前、服务端入库前,都会把密钥/绝对路径/邮箱换成占位符;你不必手动改写,但仍不要粘整段会话转录或与故障无关的业务机密。全程不要打断用户任务。`;
|
|
551
880
|
|
|
552
881
|
// ── the hook file: shipped in the package, downloaded only if missing ─
|
|
553
882
|
/**
|
|
@@ -661,6 +990,16 @@ async function installHook() {
|
|
|
661
990
|
}
|
|
662
991
|
|
|
663
992
|
async function ensureIdentity() {
|
|
993
|
+
// The explicit intent is read first, because it decides the `X-MisakaNet-Client` hint on every run
|
|
994
|
+
// — including one that registers nothing (the token is already there) — so it cannot sit below the
|
|
995
|
+
// early returns. `--client-id` and `MISAKANET_CLIENT_ID` are the same intent.
|
|
996
|
+
const explicit = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
|
|
997
|
+
const usable = CLIENT_ID_SHAPE.test(explicit);
|
|
998
|
+
if (explicit && !usable) {
|
|
999
|
+
need('ignored --client-id / MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
|
|
1000
|
+
}
|
|
1001
|
+
if (usable) knownClientId = explicit;
|
|
1002
|
+
|
|
664
1003
|
const file = join(stateDir(), 'token');
|
|
665
1004
|
let existing = '';
|
|
666
1005
|
try {
|
|
@@ -685,32 +1024,44 @@ async function ensureIdentity() {
|
|
|
685
1024
|
// `--client-id` and `MISAKANET_CLIENT_ID` are the same explicit intent. The flag exists because
|
|
686
1025
|
// the closing guidance told users to keep an identity and the only way to supply one was a shell
|
|
687
1026
|
// export — an instruction a non-technical user cannot follow, and on Windows a different one.
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
: `setup-${randomUUID()}`;
|
|
695
|
-
const result = await mcpCall('misakanet_register', { agent_type: 'setup', client_id: clientId });
|
|
1027
|
+
const clientId = usable ? explicit : `setup-${randomUUID()}`;
|
|
1028
|
+
knownClientId = clientId;
|
|
1029
|
+
const referral = declaredReferral();
|
|
1030
|
+
const result = await mcpCall('misakanet_register', {
|
|
1031
|
+
agent_type: 'setup', client_id: clientId, ...(referral ? { referral_code: referral } : {}),
|
|
1032
|
+
});
|
|
696
1033
|
// Validate before persisting: a response body is not something to write to disk unchecked
|
|
697
1034
|
// (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
|
|
698
1035
|
// ours, but "trust the shape" is the correct habit and it makes the value failing to match
|
|
699
1036
|
// a visible, debuggable outcome instead of a silent 401 later.
|
|
700
1037
|
const token = typeof result?.token === 'string' ? result.token.trim() : '';
|
|
701
1038
|
if (!/^mcp_[A-Za-z0-9_-]{20,}$/.test(token)) {
|
|
702
|
-
// Say what it costs, not what went wrong internally: "凭据形状不对" is jargon
|
|
703
|
-
//
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
1039
|
+
// Say what it costs, not what went wrong internally: "凭据形状不对" is jargon. Reading is not
|
|
1040
|
+
// what is lost here (it needs no token and is not metered) — the write path is, and saying so
|
|
1041
|
+
// plainly is the difference between a user retrying and a user concluding the tool is broken.
|
|
1042
|
+
//
|
|
1043
|
+
// And say *whose* problem it is. This branch used to say "多半是网络" unconditionally, which on
|
|
1044
|
+
// 2026-10-18 was measurably wrong: the server answered
|
|
1045
|
+
// `{"code":"storage_unavailable","storage":{"token":true},"hint":"Retry shortly."}` because D1's
|
|
1046
|
+
// daily quota was spent, and the user was sent to inspect a network that was working. The
|
|
1047
|
+
// server already names the cause and the wait; the fix is to read it instead of guessing.
|
|
1048
|
+
const serverCode = typeof result?.code === 'string' ? result.code : '';
|
|
1049
|
+
const serverHint = typeof result?.hint === 'string' ? result.hint.trim() : '';
|
|
1050
|
+
const cause = serverCode === 'storage_unavailable'
|
|
1051
|
+
? '服务端暂时存不了 token(**不是你的网络**)'
|
|
1052
|
+
: serverCode
|
|
1053
|
+
? `服务端拒绝了这次注册(${serverCode})`
|
|
1054
|
+
: `多半是网络(${lastRequestError || '没有收到响应'})`;
|
|
1055
|
+
need(`匿名注册没成功:${cause} → 读课程不受影响,但写入类工具(write_lesson)现在用不了;`
|
|
1056
|
+
+ '稍后重跑本命令就能拿到 token'
|
|
1057
|
+
+ (serverHint ? `(服务端提示:${serverHint})` : ''));
|
|
707
1058
|
return '';
|
|
708
1059
|
}
|
|
709
1060
|
// Created with mode 0600 rather than written and then chmodded: the old order left a window in
|
|
710
1061
|
// which the token sat in a world-readable file on every single run.
|
|
711
1062
|
writeText(file, token, { mode: 0o600 });
|
|
712
1063
|
ok(`匿名身份:${String(result.node_id || '?').slice(0, 32)}(token 存 ${file},权限 600)`);
|
|
713
|
-
if (!
|
|
1064
|
+
if (!usable) {
|
|
714
1065
|
// Printed, not stored: this process must not turn a file it found into request data
|
|
715
1066
|
// (CodeQL js/file-access-to-http #268), and the value is the user's to keep anyway.
|
|
716
1067
|
// Kept out of the "done" list on purpose — it is something to *keep*, not something that
|
|
@@ -722,6 +1073,20 @@ async function ensureIdentity() {
|
|
|
722
1073
|
}
|
|
723
1074
|
|
|
724
1075
|
// ── per-agent install ────────────────────────────────────────────────
|
|
1076
|
+
/** Is our MCP entry already in this agent's config? (read-only; used by the report's scope line) */
|
|
1077
|
+
function mcpEntryPresent(agent) {
|
|
1078
|
+
const text = {
|
|
1079
|
+
claude: () => JSON.stringify(readJson(join(HOME, '.claude.json'), {})?.mcpServers?.misakanet || null),
|
|
1080
|
+
codex: () => readText(join(HOME, '.codex', 'config.toml')),
|
|
1081
|
+
hermes: () => readText(join(HOME, '.hermes', 'config.yaml')),
|
|
1082
|
+
openclaw: () => readText(join(HOME, '.openclaw', 'openclaw.json')),
|
|
1083
|
+
codewhale: () => readText(join(HOME, '.codewhale', 'mcp.json')),
|
|
1084
|
+
...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS)
|
|
1085
|
+
.map(([id, spec]) => [id, () => readText(spec.config(HOME))])),
|
|
1086
|
+
}[agent]?.() || '';
|
|
1087
|
+
return text.includes('misakanet') && text !== 'null';
|
|
1088
|
+
}
|
|
1089
|
+
|
|
725
1090
|
function detect(agent) {
|
|
726
1091
|
const paths = {
|
|
727
1092
|
claude: ['.claude.json', '.claude'],
|
|
@@ -729,6 +1094,7 @@ function detect(agent) {
|
|
|
729
1094
|
hermes: ['.hermes'],
|
|
730
1095
|
openclaw: ['.openclaw'],
|
|
731
1096
|
codewhale: ['.codewhale'],
|
|
1097
|
+
...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS).map(([id, spec]) => [id, spec.detect])),
|
|
732
1098
|
}[agent] || [];
|
|
733
1099
|
return paths.some((p) => existsSync(join(HOME, p)));
|
|
734
1100
|
}
|
|
@@ -742,8 +1108,7 @@ async function installClaude(hookPath, bearer) {
|
|
|
742
1108
|
}
|
|
743
1109
|
const doc = data || {};
|
|
744
1110
|
doc.mcpServers = doc.mcpServers || {};
|
|
745
|
-
const entry = { type: 'http', url: ENDPOINT };
|
|
746
|
-
if (bearer) entry.headers = { Authorization: `Bearer ${bearer}` };
|
|
1111
|
+
const entry = { type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'claude-code') };
|
|
747
1112
|
if (sameJson(doc.mcpServers.misakanet, entry)) {
|
|
748
1113
|
ok('Claude Code:MCP 已注册(无改动)');
|
|
749
1114
|
} else {
|
|
@@ -754,8 +1119,30 @@ async function installClaude(hookPath, bearer) {
|
|
|
754
1119
|
}
|
|
755
1120
|
|
|
756
1121
|
const rules = join(HOME, '.claude', 'CLAUDE.md');
|
|
757
|
-
ok(`Claude Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
|
|
758
|
-
|
|
1122
|
+
if (!MCP_ONLY) ok(`Claude Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
|
|
1123
|
+
else skip('Claude Code:规则块未写(--mcp-only:只注册端点)');
|
|
1124
|
+
|
|
1125
|
+
if (MCP_ONLY) {
|
|
1126
|
+
// Tier ① = "the tool can be called", and that includes the read-only grants: the comment below
|
|
1127
|
+
// says it plainly — without them the first search is *denied*, so an operator who approved
|
|
1128
|
+
// "add an MCP server" would be shipping a tool their users cannot use. The grants are therefore
|
|
1129
|
+
// tier ①. What tier ③ adds is the hooks, and those are not written here.
|
|
1130
|
+
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
1131
|
+
const settings = readJson(settingsPath, {}) || {};
|
|
1132
|
+
const allow = ((settings.permissions = settings.permissions || {}).allow =
|
|
1133
|
+
Array.isArray(settings.permissions.allow) ? settings.permissions.allow : []);
|
|
1134
|
+
let changed = false;
|
|
1135
|
+
for (const tool of CLAUDE_ALLOWED_TOOLS) {
|
|
1136
|
+
if (!allow.includes(tool)) { allow.push(tool); changed = true; }
|
|
1137
|
+
}
|
|
1138
|
+
if (changed) {
|
|
1139
|
+
backup(settingsPath);
|
|
1140
|
+
writeText(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
1141
|
+
}
|
|
1142
|
+
ok(`Claude Code:只读工具已放行(${CLAUDE_ALLOWED_TOOLS.length} 个${changed ? '' : ',无改动'})`);
|
|
1143
|
+
skip('Claude Code:自动沉淀钩子未装(--mcp-only:只注册端点与工具放行)');
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
759
1146
|
if (!hookPath) {
|
|
760
1147
|
need('Claude Code:跳过了"自动沉淀"钩子(钩子文件没取到)');
|
|
761
1148
|
return;
|
|
@@ -852,12 +1239,17 @@ async function installClaude(hookPath, bearer) {
|
|
|
852
1239
|
|
|
853
1240
|
const codexTable = (bearer) => {
|
|
854
1241
|
const lines = ['[mcp_servers.misakanet]', 'type = "streamable-http"', `url = "${ENDPOINT}"`];
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1242
|
+
// http_headers, not bearer_token_env_var: the env var needs the user to export it, and this user
|
|
1243
|
+
// will not. All the headers go into the ONE inline table — a second `http_headers` line is a
|
|
1244
|
+
// duplicate key, and TOML refuses the whole file for it. (Bare TOML keys may contain `-`, which is
|
|
1245
|
+
// why the hint names need no quoting.)
|
|
1246
|
+
const headers = Object.entries(mcpHeaders(bearer, 'codex'))
|
|
1247
|
+
.map(([key, value]) => `${key} = "${value}"`).join(', ');
|
|
1248
|
+
lines.push(`http_headers = { ${headers} }`);
|
|
1249
|
+
if (!bearer) {
|
|
1250
|
+
// Only worth saying when it is true. Since 2026-09-18 anonymous reads are not metered — what a
|
|
1251
|
+
// token buys is the write path, not more reads.
|
|
1252
|
+
lines.push('# 没有 token:读不限次数,写入类工具(write_lesson)不可用');
|
|
861
1253
|
}
|
|
862
1254
|
return `${lines.join('\n')}\n`;
|
|
863
1255
|
};
|
|
@@ -909,7 +1301,8 @@ async function installCodex(hookPath, bearer) {
|
|
|
909
1301
|
}
|
|
910
1302
|
|
|
911
1303
|
const rules = join(HOME, '.codex', 'AGENTS.md');
|
|
912
|
-
ok(`Codex:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
|
|
1304
|
+
if (!MCP_ONLY) ok(`Codex:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
|
|
1305
|
+
else skip('Codex:规则块未写(--mcp-only:只注册端点)');
|
|
913
1306
|
// Verified on codex-cli 0.154.0 (2026-09-15), so this is no longer a "could not
|
|
914
1307
|
// confirm" note: `codex mcp list` shows misakanet enabled with the Bearer token,
|
|
915
1308
|
// `codex doctor` reports config.toml parse ok + 1 streamable_http server + 0
|
|
@@ -1036,11 +1429,18 @@ async function installHermes(hookPath, bearer) {
|
|
|
1036
1429
|
need('Hermes:找不到 ~/.hermes/config.yaml → 先运行一次 hermes 再回来装');
|
|
1037
1430
|
return;
|
|
1038
1431
|
}
|
|
1039
|
-
ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
|
|
1432
|
+
if (!MCP_ONLY) ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
|
|
1433
|
+
else skip('Hermes:规则块未写(--mcp-only:只注册端点;SOUL.md 是它的人格文件)');
|
|
1040
1434
|
|
|
1041
1435
|
const envKey = HERMES_ENV_KEY;
|
|
1042
1436
|
const lines = [` misakanet: ${YAML_START}`, ` url: ${ENDPOINT}`];
|
|
1043
|
-
|
|
1437
|
+
lines.push(' headers:');
|
|
1438
|
+
if (bearer) lines.push(` Authorization: Bearer \${${envKey}}`);
|
|
1439
|
+
// Hermes reads its headers from YAML, so these are literals rather than env templates: they are
|
|
1440
|
+
// hints, not secrets (a token would have to go through `.env` like the Authorization line above).
|
|
1441
|
+
for (const [key, value] of Object.entries(mcpHeaders('', 'hermes'))) {
|
|
1442
|
+
lines.push(` ${key}: ${value}`);
|
|
1443
|
+
}
|
|
1044
1444
|
lines.push(` ${YAML_END}`);
|
|
1045
1445
|
// No trailing newline: the three call sites below each own the one they need, and the
|
|
1046
1446
|
// "insert after mcp_servers:" case must NOT add one (the line it replaces already ends with
|
|
@@ -1068,7 +1468,7 @@ async function installHermes(hookPath, bearer) {
|
|
|
1068
1468
|
}
|
|
1069
1469
|
|
|
1070
1470
|
if (!bearer) {
|
|
1071
|
-
skip('Hermes:这次没有 token →
|
|
1471
|
+
skip('Hermes:这次没有 token → 读仍然不限次数,写入类工具不可用');
|
|
1072
1472
|
return;
|
|
1073
1473
|
}
|
|
1074
1474
|
const envPath = join(HOME, '.hermes', '.env');
|
|
@@ -1117,6 +1517,11 @@ async function installHermes(hookPath, bearer) {
|
|
|
1117
1517
|
* The token is the one thing codewhale will not take inline: `bearer_token_env_var` names
|
|
1118
1518
|
* an environment variable, so "install once" for this agent ends with the user exporting
|
|
1119
1519
|
* `MISAKANET_TOKEN` once. That is stated rather than silently half-installed.
|
|
1520
|
+
*
|
|
1521
|
+
* This is also the one agent that gets no `X-MisakaNet-*` hints: its server entry has a fixed
|
|
1522
|
+
* field set (`command`/`args`/`env`/`url`/timeouts/`bearer_token_env_var`) with no custom-header
|
|
1523
|
+
* field, and inventing one would risk the file being rejected outright. Hints are optional by
|
|
1524
|
+
* design — a call without them is still a normal anonymous read.
|
|
1120
1525
|
*/
|
|
1121
1526
|
function codewhaleProjects() {
|
|
1122
1527
|
const text = readText(join(HOME, '.codewhale', 'config.toml'));
|
|
@@ -1166,7 +1571,9 @@ async function installCodewhale(bearer) {
|
|
|
1166
1571
|
const projects = codewhaleProjects();
|
|
1167
1572
|
for (const project of projects) {
|
|
1168
1573
|
if (!existsSync(project)) continue;
|
|
1169
|
-
|
|
1574
|
+
if (!MCP_ONLY) {
|
|
1575
|
+
ok(`codewhale:规则块 ${injectBlock(join(project, 'AGENTS.md'), PROMPT_BLOCK)} → ${join(project, 'AGENTS.md')}`);
|
|
1576
|
+
} else skip('codewhale:规则块未写(--mcp-only)');
|
|
1170
1577
|
}
|
|
1171
1578
|
if (!projects.length) {
|
|
1172
1579
|
need('codewhale:没有受信任的项目目录(config.toml 里没有 trust_level = "trusted")→ '
|
|
@@ -1183,7 +1590,8 @@ async function installOpenclaw(bearer) {
|
|
|
1183
1590
|
if (!existsSync(workspace)) {
|
|
1184
1591
|
need(`OpenClaw:找不到 workspace(${workspace})→ 先运行一次 openclaw 生成它`);
|
|
1185
1592
|
} else {
|
|
1186
|
-
ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
|
|
1593
|
+
if (!MCP_ONLY) ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
|
|
1594
|
+
else skip('OpenClaw:规则块未写(--mcp-only)');
|
|
1187
1595
|
}
|
|
1188
1596
|
|
|
1189
1597
|
const cfg = join(HOME, '.openclaw', 'openclaw.json');
|
|
@@ -1193,8 +1601,8 @@ async function installOpenclaw(bearer) {
|
|
|
1193
1601
|
+ `或手动执行 \`${manual}\``);
|
|
1194
1602
|
return;
|
|
1195
1603
|
}
|
|
1196
|
-
const entry = { url: ENDPOINT, transport: 'streamable-http'
|
|
1197
|
-
|
|
1604
|
+
const entry = { url: ENDPOINT, transport: 'streamable-http',
|
|
1605
|
+
headers: mcpHeaders(bearer, 'openclaw') };
|
|
1198
1606
|
data.mcp = data.mcp || {};
|
|
1199
1607
|
data.mcp.servers = data.mcp.servers || {};
|
|
1200
1608
|
if (sameJson(data.mcp.servers.misakanet, entry)) {
|
|
@@ -1207,11 +1615,77 @@ async function installOpenclaw(bearer) {
|
|
|
1207
1615
|
ok(`OpenClaw:注册 MCP(streamable-http)→ ${cfg}`);
|
|
1208
1616
|
}
|
|
1209
1617
|
|
|
1618
|
+
/**
|
|
1619
|
+
* Install one MCP-only target: read its JSON, merge our entry under the vendor's own key path, write.
|
|
1620
|
+
*
|
|
1621
|
+
* File-to-file, never a subprocess, so no token ever reaches a command line. An existing file is
|
|
1622
|
+
* preserved (other servers, other keys) and backed up first; a file we cannot parse stops at a manual
|
|
1623
|
+
* step that names exactly what to paste, because guessing at a half-written config is how a user
|
|
1624
|
+
* loses the servers they already had.
|
|
1625
|
+
*/
|
|
1626
|
+
async function installMcpOnly(agent, bearer) {
|
|
1627
|
+
const spec = MCP_ONLY_TARGETS[agent];
|
|
1628
|
+
const cfg = spec.config(HOME);
|
|
1629
|
+
const data = readJson(cfg, null);
|
|
1630
|
+
if (data === null && readText(cfg)) {
|
|
1631
|
+
need(`${spec.label}:${cfg} 不是合法 JSON${spec.parseNote ? `(${spec.parseNote})` : ''}`
|
|
1632
|
+
+ ` → 请手动加入 ${spec.container}.misakanet(形状见 https://misakanet.org/mcp 的客户端文档)`);
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
const doc = data || {};
|
|
1636
|
+
doc[spec.container] = doc[spec.container] || {};
|
|
1637
|
+
const entry = spec.entry(bearer);
|
|
1638
|
+
if (sameJson(doc[spec.container].misakanet, entry)) {
|
|
1639
|
+
ok(`${spec.label}:MCP 已注册(无改动)`);
|
|
1640
|
+
} else {
|
|
1641
|
+
doc[spec.container].misakanet = entry;
|
|
1642
|
+
backup(cfg);
|
|
1643
|
+
writeText(cfg, `${JSON.stringify(doc, null, 2)}\n`);
|
|
1644
|
+
ok(`${spec.label}:注册 MCP → ${cfg}`);
|
|
1645
|
+
}
|
|
1646
|
+
skip(`${spec.label}:没有规则块与钩子(${spec.rules})→ ${spec.ruleHint}`);
|
|
1647
|
+
need(`${spec.label}:${spec.manual}`);
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1210
1650
|
// The last endpoint probe, recorded so `--report` can print it without probing twice.
|
|
1211
|
-
let lastProbe = { reachable: false, tools: 0 };
|
|
1651
|
+
let lastProbe = { reachable: false, tools: 0, note: '' };
|
|
1652
|
+
|
|
1653
|
+
/**
|
|
1654
|
+
* The advice for a file we could not write.
|
|
1655
|
+
*
|
|
1656
|
+
* It has to be an advice that can work — and "re-run the install command" is not one: the file is
|
|
1657
|
+
* read-only or the permissions do not allow it, so a re-run writes exactly as much as the last one
|
|
1658
|
+
* did. Measured 2026-10-22 on an OpenClaw machine whose workspace was root-owned and read-only:
|
|
1659
|
+
* `--verify` said "规则块没装 → 重跑安装命令", and the trialer went after the network, the
|
|
1660
|
+
* permissions and the gateway while the installer had already named the real blocker.
|
|
1661
|
+
*
|
|
1662
|
+
* The question is asked the way the installer asks it before writing (`canCreateHere`): by
|
|
1663
|
+
* attempting the access, never by `existsSync` first.
|
|
1664
|
+
*/
|
|
1665
|
+
function unwritableAdvice(manual = '') {
|
|
1666
|
+
const fix = '改权限(或换到可写的目录)后再重跑';
|
|
1667
|
+
return manual
|
|
1668
|
+
? `只读或权限不足,重跑也写不进去 → ${manual}(或${fix})`
|
|
1669
|
+
: `只读或权限不足,重跑也写不进去 → ${fix}`;
|
|
1670
|
+
}
|
|
1212
1671
|
|
|
1213
1672
|
async function verify() {
|
|
1214
1673
|
let allOk = true;
|
|
1674
|
+
// The report answers two different questions, and one of them failing must not condemn the
|
|
1675
|
+
// other (issue #3696): "can you use it" (the endpoint answers) and "are the files we prefer
|
|
1676
|
+
// written" (our own footprint). A machine whose endpoint answers and whose workspace is
|
|
1677
|
+
// read-only is **usable** — and the old verdict called it NOT READY.
|
|
1678
|
+
let usable = true;
|
|
1679
|
+
// The probe has three answers, not two: a probe that could not run is not a probe that failed.
|
|
1680
|
+
// "unknown" is reported as unknown, and only `--strict` refuses to pass on it.
|
|
1681
|
+
let probeState = 'ready';
|
|
1682
|
+
// The install segment is judged on its own: a file we cannot write is a fact about our
|
|
1683
|
+
// footprint, and it says nothing about the endpoint.
|
|
1684
|
+
let installOk = true;
|
|
1685
|
+
const installFailed = () => { installOk = false; allOk = false; };
|
|
1686
|
+
// Where each segment starts, in the three line lists. The output prints the two segments as
|
|
1687
|
+
// two sections, and this boundary is the only thing that has to be right about them.
|
|
1688
|
+
const segmentStart = { done: done.length, manual: manual.length, skipped: skipped.length };
|
|
1215
1689
|
// Handshake, not a search. `tools/list` proves the endpoint speaks MCP and answers, and it
|
|
1216
1690
|
// consumes no anonymous read quota — so `--verify` can be run as often as the user likes. The
|
|
1217
1691
|
// search used to be the probe, which spent one of the five free reads per run and, worse,
|
|
@@ -1231,18 +1705,46 @@ async function verify() {
|
|
|
1231
1705
|
const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
|
|
1232
1706
|
if (detect('claude') && missing.length) {
|
|
1233
1707
|
allOk = false;
|
|
1708
|
+
// Not an install fact: the endpoint answers, and the search still fails. It belongs to
|
|
1709
|
+
// the usability segment, so it belongs there in the output too.
|
|
1710
|
+
usable = false;
|
|
1234
1711
|
need(`Claude Code:只读 MCP 工具没有放行(${missing.length} 个)→ 第一次检索会被权限拦下;`
|
|
1235
1712
|
+ '重跑安装命令即可放行');
|
|
1236
1713
|
}
|
|
1237
1714
|
} catch { /* a settings file we cannot read is reported by the Claude checks below */ }
|
|
1238
1715
|
} else if (probe && (probe.error || probe.protocolVersion || probe.serverInfo)) {
|
|
1239
1716
|
allOk = false;
|
|
1717
|
+
usable = false;
|
|
1718
|
+
probeState = 'not-ready';
|
|
1240
1719
|
need(`端点可达但握手异常:${JSON.stringify(probe).slice(0, 120)}`);
|
|
1241
1720
|
} else {
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1721
|
+
const proxy = proxyEnvironment();
|
|
1722
|
+
if (proxy && !proxy.enabled) {
|
|
1723
|
+
// Inconclusive rather than unreachable. Interactive runs warn (a visible `!` that does not
|
|
1724
|
+
// flip the verdict), because the install itself is fine and the user's agent may well be
|
|
1725
|
+
// reaching the endpoint through that proxy. `--strict` / `--ci` keep a hard verdict: a gate
|
|
1726
|
+
// that cannot tell must not pass silently, and CI machines are the ones actually configured
|
|
1727
|
+
// to make the probe trustworthy.
|
|
1728
|
+
lastProbe = { reachable: false, tools: 0, note: 'inconclusive-proxy' };
|
|
1729
|
+
probeState = 'unknown';
|
|
1730
|
+
if (STRICT) {
|
|
1731
|
+
allOk = false;
|
|
1732
|
+
usable = false;
|
|
1733
|
+
need(`端点探测不可信,且 --strict 不接受"说不清":${proxyProbeAdvice(proxy)}`);
|
|
1734
|
+
} else {
|
|
1735
|
+
need(`端点探测不可信(不是"不可达"):${proxyProbeAdvice(proxy)}`);
|
|
1736
|
+
}
|
|
1737
|
+
} else {
|
|
1738
|
+
allOk = false;
|
|
1739
|
+
usable = false;
|
|
1740
|
+
probeState = 'not-ready';
|
|
1741
|
+
need(`端点不可达:${ENDPOINT}(网络受限?读课程会静默失败)`
|
|
1742
|
+
+ (lastRequestError ? ` —— 最近一次失败原因:${lastRequestError}` : ''));
|
|
1743
|
+
}
|
|
1245
1744
|
}
|
|
1745
|
+
// The endpoint segment ends here. Everything below is about our own footprint: the files we
|
|
1746
|
+
// write, the entries we register, the token we keep.
|
|
1747
|
+
const segmentEnd = { done: done.length, manual: manual.length, skipped: skipped.length };
|
|
1246
1748
|
const tokenFile = join(stateDir(), 'token');
|
|
1247
1749
|
let tokenPresent = '';
|
|
1248
1750
|
try {
|
|
@@ -1251,45 +1753,63 @@ async function verify() {
|
|
|
1251
1753
|
tokenPresent = '';
|
|
1252
1754
|
}
|
|
1253
1755
|
if (tokenPresent) {
|
|
1254
|
-
ok('写入通道:token
|
|
1255
|
-
} else {
|
|
1256
|
-
skip('写入通道:无 token(只读也完全可用,但读有 5/天/IP 限额)');
|
|
1257
|
-
}
|
|
1258
|
-
const hookPath = join(stateDir(), 'hook.mjs');
|
|
1259
|
-
let hookPresent = false;
|
|
1260
|
-
try {
|
|
1261
|
-
hookPresent = readFileSync(hookPath, 'utf8').length > 0;
|
|
1262
|
-
} catch {
|
|
1263
|
-
hookPresent = false;
|
|
1264
|
-
}
|
|
1265
|
-
if (!hookPresent) {
|
|
1266
|
-
allOk = false;
|
|
1267
|
-
need('自动沉淀钩子:缺失 → 重跑安装命令');
|
|
1756
|
+
ok('写入通道:token 已就绪(write_lesson / preflight 可用)');
|
|
1268
1757
|
} else {
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
//
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1758
|
+
// Which token this is looking for, said out loud: it reads **our own** state directory, so a
|
|
1759
|
+
// machine that got its token through another channel (mcporter, a gateway) shows "no token"
|
|
1760
|
+
// here even though writes work. The trialer read that as "nothing is set up".
|
|
1761
|
+
skip('写入通道:无 token(这一项只检查本安装器状态目录里的 token;如果 token 来自别的通道,'
|
|
1762
|
+
+ '读路径完全不受影响——读不限次数、只读完全可用,写入类工具不可用)');
|
|
1763
|
+
}
|
|
1764
|
+
// The hook is Claude Code's alone: it is the one target whose config carries it (Codex's
|
|
1765
|
+
// user-level hook shape is unconfirmed, OpenClaw's events are unverified), so a machine without
|
|
1766
|
+
// Claude Code must not be told its hook is missing. Until 2026-09-20 it was: a Codex-only or
|
|
1767
|
+
// Cursor-only install reported NOT READY with two Claude Code complaints about a target that was
|
|
1768
|
+
// never selected — the same lie the OpenClaw and Hermes checks below already refuse to tell
|
|
1769
|
+
// (found while adding Cursor, which is mcp-only by construction).
|
|
1770
|
+
if (detect('claude')) {
|
|
1771
|
+
const hookPath = join(stateDir(), 'hook.mjs');
|
|
1772
|
+
let hookPresent = false;
|
|
1773
|
+
try {
|
|
1774
|
+
hookPresent = readFileSync(hookPath, 'utf8').length > 0;
|
|
1775
|
+
} catch {
|
|
1776
|
+
hookPresent = false;
|
|
1777
|
+
}
|
|
1778
|
+
if (!hookPresent) {
|
|
1779
|
+
installFailed();
|
|
1780
|
+
need(canCreateHere(hookPath)
|
|
1781
|
+
? '自动沉淀钩子:缺失 → 重跑安装命令'
|
|
1782
|
+
: `自动沉淀钩子:装不了(${hookPath})→ ${unwritableAdvice()}`);
|
|
1279
1783
|
} else {
|
|
1280
|
-
const
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1784
|
+
const settingsPath = join(HOME, '.claude', 'settings.json');
|
|
1785
|
+
const settings = readJson(settingsPath, {}) || {};
|
|
1786
|
+
// Only our own entries: a foreign hook that merely mentions "hook.mjs" is not evidence
|
|
1787
|
+
// that this machine is installed (and its interpreter may not even be ours).
|
|
1788
|
+
const commands = ourHookEntries(settings.hooks)
|
|
1789
|
+
.flatMap((entry) => (entry.hooks || []).map((h) => h.command))
|
|
1790
|
+
.filter((c) => typeof c === 'string');
|
|
1791
|
+
if (!commands.length) {
|
|
1792
|
+
installFailed();
|
|
1793
|
+
need('Claude Code:钩子没装(settings.json 里没有本安装器写入的命令)');
|
|
1284
1794
|
} else {
|
|
1285
|
-
|
|
1795
|
+
const exe = commands[0].startsWith('"') ? commands[0].split('"')[1] : commands[0].split(' ')[0];
|
|
1796
|
+
if (!existsSync(exe)) {
|
|
1797
|
+
installFailed();
|
|
1798
|
+
need(`Claude Code:钩子里的解释器不存在(${exe})→ 钩子永远不会触发,重跑安装命令即可修`);
|
|
1799
|
+
} else {
|
|
1800
|
+
ok('Claude Code:钩子已装且解释器存在');
|
|
1801
|
+
}
|
|
1286
1802
|
}
|
|
1803
|
+
const cfg = join(HOME, '.claude.json');
|
|
1804
|
+
const data = readJson(cfg, {}) || {};
|
|
1805
|
+
const entry = data.mcpServers?.misakanet;
|
|
1806
|
+
if (!entry) {
|
|
1807
|
+
installFailed();
|
|
1808
|
+
need(canCreateHere(cfg)
|
|
1809
|
+
? `Claude Code:MCP 未注册(${cfg})`
|
|
1810
|
+
: `Claude Code:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
1811
|
+
} else ok(`Claude Code:MCP 已注册(${entry.url})`);
|
|
1287
1812
|
}
|
|
1288
|
-
const cfg = join(HOME, '.claude.json');
|
|
1289
|
-
const data = readJson(cfg, {}) || {};
|
|
1290
|
-
const entry = data.mcpServers?.misakanet;
|
|
1291
|
-
if (!entry) { allOk = false; need(`Claude Code:MCP 未注册(${cfg})`); }
|
|
1292
|
-
else ok(`Claude Code:MCP 已注册(${entry.url})`);
|
|
1293
1813
|
}
|
|
1294
1814
|
// OpenClaw is only reported when the user actually has it: telling a machine without
|
|
1295
1815
|
// OpenClaw that it is "not ready" would be a lie about a target that was never selected.
|
|
@@ -1298,17 +1818,23 @@ async function verify() {
|
|
|
1298
1818
|
if (detect('openclaw')) {
|
|
1299
1819
|
const rules = join(openclawWorkspaces()[0], 'AGENTS.md');
|
|
1300
1820
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
1301
|
-
|
|
1302
|
-
|
|
1821
|
+
installFailed();
|
|
1822
|
+
// "没装" and "装不了" are different facts with different fixes, and the difference is the
|
|
1823
|
+
// whole point (issue #3696): a read-only workspace is not something a re-run installs.
|
|
1824
|
+
need(canCreateHere(rules)
|
|
1825
|
+
? `OpenClaw:规则块没装(${rules})→ 重跑安装命令`
|
|
1826
|
+
: `OpenClaw:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
1303
1827
|
} else {
|
|
1304
1828
|
ok('OpenClaw:规则块已装');
|
|
1305
1829
|
}
|
|
1306
1830
|
const cfg = join(HOME, '.openclaw', 'openclaw.json');
|
|
1307
1831
|
const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
|
|
1308
1832
|
if (!entry) {
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1833
|
+
installFailed();
|
|
1834
|
+
const manual = `openclaw mcp add misakanet --url ${ENDPOINT} --transport streamable-http`;
|
|
1835
|
+
need(canCreateHere(cfg)
|
|
1836
|
+
? `OpenClaw:MCP 未注册(${cfg})→ 重跑安装命令,或手动执行 ${manual}`
|
|
1837
|
+
: `OpenClaw:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
|
|
1312
1838
|
} else {
|
|
1313
1839
|
ok(`OpenClaw:MCP 已注册(${entry.url || '?'})`);
|
|
1314
1840
|
}
|
|
@@ -1319,25 +1845,50 @@ async function verify() {
|
|
|
1319
1845
|
if (readText(join(HOME, '.hermes', 'config.yaml'))) {
|
|
1320
1846
|
const rules = join(HOME, '.hermes', 'SOUL.md');
|
|
1321
1847
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
1322
|
-
|
|
1323
|
-
need(
|
|
1848
|
+
installFailed();
|
|
1849
|
+
need(canCreateHere(rules)
|
|
1850
|
+
? `Hermes:规则块没装(${rules})→ 重跑安装命令`
|
|
1851
|
+
: `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
1324
1852
|
} else {
|
|
1325
1853
|
ok('Hermes:规则块已装');
|
|
1326
1854
|
}
|
|
1327
1855
|
const cfg = join(HOME, '.hermes', 'config.yaml');
|
|
1328
1856
|
const text = readText(cfg);
|
|
1329
1857
|
if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1858
|
+
installFailed();
|
|
1859
|
+
const manual = `hermes mcp add misakanet --url ${ENDPOINT} --auth header`;
|
|
1860
|
+
need(canCreateHere(cfg)
|
|
1861
|
+
? `Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 ${manual}`
|
|
1862
|
+
: `Hermes:MCP 未注册(${cfg})→ ${unwritableAdvice(manual)}`);
|
|
1333
1863
|
} else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
|
|
1334
|
-
ok('Hermes:MCP 条目已写入配置(无 token →
|
|
1335
|
-
skip(`Hermes
|
|
1864
|
+
ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
|
|
1865
|
+
skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
|
|
1336
1866
|
} else {
|
|
1337
1867
|
ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`);
|
|
1338
1868
|
skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
|
|
1339
1869
|
}
|
|
1340
1870
|
}
|
|
1871
|
+
// The MCP-only targets are reported only when the user actually has them, like OpenClaw and
|
|
1872
|
+
// Hermes. Their state is one JSON file, so it is readable without starting anything — but whether
|
|
1873
|
+
// the client has *loaded* that file cannot be confirmed from here, and the line says so.
|
|
1874
|
+
for (const [agent, spec] of Object.entries(MCP_ONLY_TARGETS)) {
|
|
1875
|
+
if (!detect(agent)) continue;
|
|
1876
|
+
const cfg = spec.config(HOME);
|
|
1877
|
+
const entry = (readJson(cfg, null) || {})[spec.container]?.misakanet;
|
|
1878
|
+
if (!entry) {
|
|
1879
|
+
installFailed();
|
|
1880
|
+
need(canCreateHere(cfg)
|
|
1881
|
+
? `${spec.label}:MCP 未注册(${cfg})→ 重跑安装命令,然后${spec.verifyHint}`
|
|
1882
|
+
: `${spec.label}:MCP 未注册(${cfg})→ ${unwritableAdvice()}`);
|
|
1883
|
+
} else if (!entry[spec.urlField]) {
|
|
1884
|
+
installFailed();
|
|
1885
|
+
need(`${spec.label}:MCP 条目在,但没有 ${spec.urlField} 字段(${cfg})→ 重跑安装命令;`
|
|
1886
|
+
+ `这家用的是 ${spec.urlField}${spec.container === 'mcp' ? '(注意嵌套在 mcp 而不是 mcpServers 下)' : ''}`);
|
|
1887
|
+
} else {
|
|
1888
|
+
ok(`${spec.label}:MCP 已注册(${entry[spec.urlField]})`);
|
|
1889
|
+
skip(`${spec.label}:只能确认"配置已写",无法确认它是否已加载 → ${spec.verifyHint}`);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1341
1892
|
// Version: what is installed, and whether the registry has moved on. Read-only here — the
|
|
1342
1893
|
// hook owns the periodic nudge (it cannot be a network call in this process's hot path), and
|
|
1343
1894
|
// `--verify` is an explicit user action, so one lookup is both cheap and expected.
|
|
@@ -1360,7 +1911,72 @@ async function verify() {
|
|
|
1360
1911
|
+ 'npx @misaka-net/misakanet-setup@latest');
|
|
1361
1912
|
}
|
|
1362
1913
|
}
|
|
1363
|
-
return
|
|
1914
|
+
return {
|
|
1915
|
+
allOk,
|
|
1916
|
+
usable,
|
|
1917
|
+
probeState,
|
|
1918
|
+
install: installOk ? 'complete' : 'incomplete',
|
|
1919
|
+
// The install segment's open items, counted on their own: the report's `open-items` is every
|
|
1920
|
+
// manual item, this is the ones that belong to our footprint.
|
|
1921
|
+
openItems: manual.length - segmentEnd.manual,
|
|
1922
|
+
segments: { start: segmentStart, end: segmentEnd },
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
/**
|
|
1927
|
+
* The two verdicts, in words.
|
|
1928
|
+
*
|
|
1929
|
+
* They are two answers to two questions, and the point of #3696 is that they are printed as two:
|
|
1930
|
+
* a machine whose endpoint answers perfectly and whose workspace is read-only is **usable**, and
|
|
1931
|
+
* the old single verdict said NOT READY about it — the one word that sends the user to debug the
|
|
1932
|
+
* wrong thing.
|
|
1933
|
+
*/
|
|
1934
|
+
function usabilityText(verdict) {
|
|
1935
|
+
if (verdict.probeState === 'not-ready') return '不就绪 —— 端点不可达或握手异常';
|
|
1936
|
+
if (verdict.probeState === 'unknown') {
|
|
1937
|
+
return STRICT
|
|
1938
|
+
? '不就绪 —— 探测不可信,--strict 不接受"说不清"'
|
|
1939
|
+
: '说不清 —— 探测不可信(不等于不可达)';
|
|
1940
|
+
}
|
|
1941
|
+
if (!verdict.usable) return '不就绪 —— 端点可达,但只读工具没放行,第一次检索会被拦下';
|
|
1942
|
+
return '就绪 —— 端点可达,检索与读取可用';
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function installText(verdict) {
|
|
1946
|
+
if (verdict.install === 'complete') return '完整 —— 我们偏好的文件都写好了';
|
|
1947
|
+
return `不完整 —— ${verdict.openItems} 项需要手动处理(上面每条 ! 都给了成因和做法)`;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* The verify output: two sections, because the two questions have two answers.
|
|
1952
|
+
*
|
|
1953
|
+
* The old output was one list, and one list cannot say "the endpoint answered, the files did not
|
|
1954
|
+
* get written" — it can only say NOT READY, which is the sentence the OpenClaw trialer read as
|
|
1955
|
+
* "MisakaNet does not work here" (issue #3696).
|
|
1956
|
+
*/
|
|
1957
|
+
function renderVerify(verdict) {
|
|
1958
|
+
const { start, end } = verdict.segments;
|
|
1959
|
+
const tail = { done: done.length, manual: manual.length, skipped: skipped.length };
|
|
1960
|
+
const section = (label, from, to) => {
|
|
1961
|
+
const out = [];
|
|
1962
|
+
const finished = done.slice(from.done, to.done);
|
|
1963
|
+
const open = manual.slice(from.manual, to.manual);
|
|
1964
|
+
const ignored = skipped.slice(from.skipped, to.skipped);
|
|
1965
|
+
// `--silent` drops the narration, never the errors (the same contract `render()` keeps): the
|
|
1966
|
+
// `!` lines are what a human has to act on, the `✓` and `·` lines are progress.
|
|
1967
|
+
if (finished.length && !SILENT) out.push(...finished.map((l) => ` ✓ ${l}`));
|
|
1968
|
+
if (open.length) out.push(...open.map((l) => ` ! ${l}`));
|
|
1969
|
+
if (ignored.length && !SILENT) out.push(...ignored.map((l) => ` · ${l}`));
|
|
1970
|
+
if (!out.length) return out;
|
|
1971
|
+
return [`\n── ${label} ──`, ...out];
|
|
1972
|
+
};
|
|
1973
|
+
return [
|
|
1974
|
+
...section('端点/可用性', start, end),
|
|
1975
|
+
...section('安装完整性', end, tail),
|
|
1976
|
+
'',
|
|
1977
|
+
`可用性:${usabilityText(verdict)}`,
|
|
1978
|
+
`安装完整性:${installText(verdict)}`,
|
|
1979
|
+
].join('\n');
|
|
1364
1980
|
}
|
|
1365
1981
|
|
|
1366
1982
|
/**
|
|
@@ -1417,7 +2033,8 @@ function restoreEmptiedFiles() {
|
|
|
1417
2033
|
// JSON configs: prune containers that our own removal emptied. The file itself is never deleted —
|
|
1418
2034
|
// it may have existed before us (an empty `~/.claude.json` is a legitimate user state).
|
|
1419
2035
|
const jsonFiles = [join(HOME, '.claude.json'), join(HOME, '.claude', 'settings.json'),
|
|
1420
|
-
join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json')
|
|
2036
|
+
join(HOME, '.openclaw', 'openclaw.json'), join(HOME, '.codewhale', 'mcp.json'),
|
|
2037
|
+
...Object.values(MCP_ONLY_TARGETS).map((spec) => spec.config(HOME))];
|
|
1421
2038
|
for (const file of jsonFiles) {
|
|
1422
2039
|
const data = readJson(file, null);
|
|
1423
2040
|
if (!data || typeof data !== 'object') continue;
|
|
@@ -1484,6 +2101,16 @@ function uninstall() {
|
|
|
1484
2101
|
writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
|
|
1485
2102
|
ok(`移除 MCP 注册 → ${cfg}`);
|
|
1486
2103
|
}
|
|
2104
|
+
for (const spec of Object.values(MCP_ONLY_TARGETS)) {
|
|
2105
|
+
const cfg = spec.config(HOME);
|
|
2106
|
+
const data = readJson(cfg, null);
|
|
2107
|
+
if (data?.[spec.container]?.misakanet) {
|
|
2108
|
+
delete data[spec.container].misakanet;
|
|
2109
|
+
backup(cfg);
|
|
2110
|
+
writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
|
|
2111
|
+
ok(`移除 MCP 注册 → ${cfg}`);
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
1487
2114
|
const voiceDir = join(stateDir(), 'voice');
|
|
1488
2115
|
if (existsSync(voiceDir)) {
|
|
1489
2116
|
try {
|
|
@@ -1639,7 +2266,8 @@ function distroName() {
|
|
|
1639
2266
|
* scalars (`true`, `[claude, codex]`, `""`); the JSON keeps their real types (boolean, array,
|
|
1640
2267
|
* string), which is the whole point of a machine-readable form.
|
|
1641
2268
|
*/
|
|
1642
|
-
function reportValues(
|
|
2269
|
+
function reportValues(verdict) {
|
|
2270
|
+
const allOk = verdict.allOk;
|
|
1643
2271
|
const agents = AGENTS.filter((agent) => detect(agent));
|
|
1644
2272
|
const token = existsSync(join(stateDir(), 'token'));
|
|
1645
2273
|
const permissions = (() => {
|
|
@@ -1659,11 +2287,27 @@ function reportValues(allOk) {
|
|
|
1659
2287
|
['node', process.version],
|
|
1660
2288
|
['detected-agents', agents],
|
|
1661
2289
|
['verify', allOk ? 'READY' : 'NOT READY'],
|
|
2290
|
+
// The verdict has two halves, and a machine that collects this report needs to be able to
|
|
2291
|
+
// tell them apart: `verify: NOT READY` because our files could not be written is a
|
|
2292
|
+
// different machine from `verify: NOT READY` because the endpoint does not answer
|
|
2293
|
+
// (issue #3696). `endpoint-reachable` and `endpoint-probe` below already say the endpoint's
|
|
2294
|
+
// half; this is ours.
|
|
2295
|
+
['install-complete', verdict.install],
|
|
2296
|
+
['endpoint-usable', verdict.usable ? 'yes' : 'no'],
|
|
1662
2297
|
['endpoint-reachable', lastProbe.reachable],
|
|
1663
2298
|
['endpoint-tools', lastProbe.tools],
|
|
2299
|
+
// `ok` = the probe ran and its answer is meaningful; `inconclusive-proxy` = the machine has a
|
|
2300
|
+
// proxy this process cannot use, so the boolean above says nothing. Without this field the YAML
|
|
2301
|
+
// a user pastes into a public issue cannot tell a real outage from a false negative.
|
|
2302
|
+
['endpoint-probe', lastProbe.note || 'ok'],
|
|
1664
2303
|
['token', token ? 'present' : 'absent'],
|
|
1665
2304
|
['permissions', permissions],
|
|
1666
2305
|
['hook', existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'],
|
|
2306
|
+
// `mcp-only` = the endpoint is registered but the hooks are not, which is a different machine
|
|
2307
|
+
// from "nothing installed" and must not be counted as one (#1753's T1-lite rows).
|
|
2308
|
+
['install-scope', hookWired()
|
|
2309
|
+
? 'full'
|
|
2310
|
+
: (AGENTS.some((agent) => detect(agent) && mcpEntryPresent(agent)) ? 'mcp-only' : 'none')],
|
|
1667
2311
|
['voice', voiceStatus()],
|
|
1668
2312
|
['open-items', manual.length],
|
|
1669
2313
|
// Redacted here, once, so neither encoder can print an unredacted message by accident.
|
|
@@ -1738,10 +2382,10 @@ function reportJson(values) {
|
|
|
1738
2382
|
* encoding never changes the verdict.
|
|
1739
2383
|
*/
|
|
1740
2384
|
async function report(asJson) {
|
|
1741
|
-
const
|
|
1742
|
-
const values = reportValues(
|
|
2385
|
+
const verdict = await verify();
|
|
2386
|
+
const values = reportValues(verdict);
|
|
1743
2387
|
console.log(asJson ? reportJson(values) : reportYaml(values));
|
|
1744
|
-
return {
|
|
2388
|
+
return { ...verdict, openItems: manual.length };
|
|
1745
2389
|
}
|
|
1746
2390
|
|
|
1747
2391
|
// ── main ─────────────────────────────────────────────────────────────
|
|
@@ -1779,7 +2423,14 @@ if (mode !== 'report' && !SILENT) {
|
|
|
1779
2423
|
|
|
1780
2424
|
if (mode === 'uninstall') {
|
|
1781
2425
|
uninstall();
|
|
1782
|
-
|
|
2426
|
+
if (MCP_ONLY) {
|
|
2427
|
+
// The honest failure mode of tier ①: the tool is callable, but nothing tells the agent (or the
|
|
2428
|
+
// user) to call it — the rules block and the first-turn announcement live in tiers ②/③. Printed
|
|
2429
|
+
// as a "manual step" so it survives --silent (a deployment log keeps the `!` lines).
|
|
2430
|
+
need('只注册了端点:你的 agent 不会自己想到去查 —— 需要你自己说「先查 MisakaNet」,'
|
|
2431
|
+
+ '或者请运营方批准 ②③ 后重跑同一条命令(去掉 --mcp-only)');
|
|
2432
|
+
}
|
|
2433
|
+
say(render());
|
|
1783
2434
|
if (!SILENT) {
|
|
1784
2435
|
console.log('\n已移除本安装器写入的内容(规则块、MCP 注册、钩子、状态目录)。');
|
|
1785
2436
|
console.log(`备份保留在 <被改过的文件>.misakanet.bak(例如 ${join(HOME, '.claude', 'CLAUDE.md')}.misakanet.bak)`
|
|
@@ -1834,13 +2485,13 @@ if (mode === 'report') {
|
|
|
1834
2485
|
}
|
|
1835
2486
|
|
|
1836
2487
|
if (mode === 'verify') {
|
|
1837
|
-
const
|
|
1838
|
-
say(
|
|
2488
|
+
const verdict = await verify();
|
|
2489
|
+
say(renderVerify(verdict));
|
|
1839
2490
|
// Kept under `--silent`: this line is the verdict, not progress. (It is also the only thing a
|
|
1840
2491
|
// silent `--verify` prints when the machine is healthy, which is what makes it usable in a
|
|
1841
2492
|
// deployment log.)
|
|
1842
|
-
console.log(`\n结论:${allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 !
|
|
1843
|
-
process.exit(allOk ? 0 : 1);
|
|
2493
|
+
console.log(`\n结论:${verdict.allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 ! 都给了成因和做法'}`);
|
|
2494
|
+
process.exit(verdict.allOk ? 0 : 1);
|
|
1844
2495
|
}
|
|
1845
2496
|
|
|
1846
2497
|
const targets = (only.length ? only : AGENTS).filter((a) => {
|
|
@@ -1866,12 +2517,70 @@ const targets = (only.length ? only : AGENTS).filter((a) => {
|
|
|
1866
2517
|
let installed = 0;
|
|
1867
2518
|
// Set when a stable client id was minted: shown to the user at the end (see ensureIdentity).
|
|
1868
2519
|
let clientIdHint = '';
|
|
2520
|
+
/**
|
|
2521
|
+
* The pseudonym this run knows the machine by — an explicit `--client-id` / `MISAKANET_CLIENT_ID`, or
|
|
2522
|
+
* the one this run minted. This is what `X-MisakaNet-Client` carries.
|
|
2523
|
+
*
|
|
2524
|
+
* Deliberately separate from `clientIdHint`, which exists only for the closing "keep this number"
|
|
2525
|
+
* guidance: a user who supplied an id must not be told to keep it, but their config still has to say
|
|
2526
|
+
* who they are — otherwise the hint would be missing for exactly the users who took the trouble to
|
|
2527
|
+
* have an identity at all.
|
|
2528
|
+
*/
|
|
2529
|
+
let knownClientId = '';
|
|
2530
|
+
/**
|
|
2531
|
+
* `--list-writes`: the manifest an operator needs to approve, or to undo by hand.
|
|
2532
|
+
*
|
|
2533
|
+
* `--dry-run` already says which files it would touch; this adds the two things an approval process
|
|
2534
|
+
* asks for — *what* each write does (new file / append a marked block / add a config key) and *how to
|
|
2535
|
+
* take it back* — and it is machine-readable so a ticket can carry it.
|
|
2536
|
+
*/
|
|
2537
|
+
if (LIST_WRITES) {
|
|
2538
|
+
// Every row carries its **tier**, which is the point: an operator approving "add an MCP server"
|
|
2539
|
+
// (①) is not approving "change how the agent behaves" (② rules block, ③ automatic hooks). Files
|
|
2540
|
+
// already exist → we append or update keys; missing → we create them.
|
|
2541
|
+
//
|
|
2542
|
+
// The rows come from the shared manifest builder, the same list the install preview prints: one
|
|
2543
|
+
// source of truth, so the manifest an operator approves and the preview a user reads cannot
|
|
2544
|
+
// disagree about what this command would write.
|
|
2545
|
+
const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 装钩子(每轮自动执行)' };
|
|
2546
|
+
const list = buildWriteManifest(targets);
|
|
2547
|
+
for (const row of list) {
|
|
2548
|
+
console.log(`tier${row.tier}\t${tiers[row.tier]}\t${row.path}\t${row.action}`);
|
|
2549
|
+
}
|
|
2550
|
+
const shown = new Set(list.map((row) => row.tier));
|
|
2551
|
+
if (MCP_ONLY) {
|
|
2552
|
+
console.log('# --mcp-only:只申请 ①。规则块(②)与钩子(③)不会写入,'
|
|
2553
|
+
+ '需要时由运营方另行批准后重跑不带 --mcp-only 的同一条命令。');
|
|
2554
|
+
} else {
|
|
2555
|
+
console.log(`# 本次申请 ${shown.size} 级权限(${[...shown].sort().map((t) => tiers[t]).join(' · ')});`
|
|
2556
|
+
+ '只想批 ① 就加 --mcp-only。');
|
|
2557
|
+
}
|
|
2558
|
+
console.log('# 这次没有写任何东西。撤销:npx @misaka-net/misakanet-setup --uninstall');
|
|
2559
|
+
process.exit(0);
|
|
2560
|
+
}
|
|
2561
|
+
|
|
2562
|
+
// The preview (#3690): the first thing a user sees about what this run is about to write, printed
|
|
2563
|
+
// **before** the first write — so a user who did not expect these files still sees them while
|
|
2564
|
+
// there is nothing to undo yet.
|
|
2565
|
+
//
|
|
2566
|
+
// `--silent` keeps its contract: a clean silent run is an empty log, and this summary is progress
|
|
2567
|
+
// narration, not an error line. `--list-writes` has already exited above with the machine-readable
|
|
2568
|
+
// version of the same list.
|
|
2569
|
+
if (!SILENT && targets.length) {
|
|
2570
|
+
const preview = writePreview(buildWriteManifest(targets));
|
|
2571
|
+
if (preview) console.log(preview);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
1869
2574
|
try {
|
|
1870
2575
|
if (!targets.length) {
|
|
1871
|
-
need('
|
|
2576
|
+
need('没检测到任何受支持助手的配置目录(Claude Code / Codex / Hermes / OpenClaw / Codewhale / '
|
|
2577
|
+
+ 'Cursor)→ 请先打开一次你要用的那个助手,再回来运行本命令');
|
|
1872
2578
|
} else {
|
|
1873
|
-
|
|
1874
|
-
|
|
2579
|
+
// Only install the hook when a target can actually consume it. Writing it for a Cursor-only or
|
|
2580
|
+
// Codex-only machine produced a file nothing would ever run, and made `--report` claim
|
|
2581
|
+
// `install-scope: full` for an install that registered an endpoint and nothing else.
|
|
2582
|
+
const hookPath = (MCP_ONLY || !targets.includes('claude')) ? null : await installHook();
|
|
2583
|
+
if (!MCP_ONLY) stampVersion();
|
|
1875
2584
|
const bearer = has('--no-register') ? '' : await ensureIdentity();
|
|
1876
2585
|
for (const agent of targets) {
|
|
1877
2586
|
// One target failing must not skip the rest: an uncaught throw used to abort the loop, so
|
|
@@ -1891,6 +2600,7 @@ try {
|
|
|
1891
2600
|
else if (agent === 'hermes') await installHermes(hookPath, bearer);
|
|
1892
2601
|
else if (agent === 'openclaw') await installOpenclaw(bearer);
|
|
1893
2602
|
else if (agent === 'codewhale') await installCodewhale(bearer);
|
|
2603
|
+
else if (MCP_ONLY_TARGETS[agent]) await installMcpOnly(agent, bearer);
|
|
1894
2604
|
installed += 1;
|
|
1895
2605
|
} catch (err) {
|
|
1896
2606
|
need(`${agent}:写入配置失败(${redact((err && err.message) || err)})`
|
|
@@ -1909,6 +2619,13 @@ try {
|
|
|
1909
2619
|
// the exact failures in it on a dirty one — which is the point (#1784). The machine-readable form
|
|
1910
2620
|
// of the same run is `--report-json`, not this text.
|
|
1911
2621
|
say(render());
|
|
2622
|
+
if (MCP_ONLY && !SILENT) {
|
|
2623
|
+
// The honest failure mode of tier ①, said where the user will read it: they are not installing a
|
|
2624
|
+
// behaviour. The rules block (②) and the first-turn announcement, which is injected by the hook
|
|
2625
|
+
// (③), are exactly what makes an agent look things up on its own.
|
|
2626
|
+
console.log('\n⚠️ --mcp-only:只注册了端点与只读工具放行 —— **你的 agent 不会自己想到去查**。'
|
|
2627
|
+
+ '\n 需要你自己说「先查 MisakaNet」,或请运营方批准 ②③ 后重跑同一条命令(去掉 --mcp-only)。');
|
|
2628
|
+
}
|
|
1912
2629
|
if (!SILENT) {
|
|
1913
2630
|
console.log(`
|
|
1914
2631
|
接下来:
|