@misaka-net/misakanet-setup 0.5.6 → 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 +597 -100
- package/hook/checkpoint_reminder.mjs +94 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
# @misaka-net/misakanet-setup
|
|
2
2
|
|
|
3
|
-
One command to teach your **Claude Code**, **Codex**, **Hermes**, **OpenClaw
|
|
4
|
-
|
|
5
|
-
lessons
|
|
3
|
+
One command to teach your **Claude Code**, **Codex**, **Hermes**, **OpenClaw**, **codewhale**,
|
|
4
|
+
**Cursor**, **Gemini CLI**, **Copilot CLI**, **OpenCode** or **Kiro** to
|
|
5
|
+
check MisakaNet's failure lessons before repeating a mistake — and to write up the reusable lessons
|
|
6
|
+
this session produced, at a checkpoint.
|
|
7
|
+
|
|
8
|
+
**What it writes:** an MCP endpoint into each agent config, rules blocks for the clients that take
|
|
9
|
+
one, and a local hook for Claude Code. **What leaves this machine:** at most one anonymous request
|
|
10
|
+
while the installer runs; afterwards, only what the agent itself sends — with one opt-in exception:
|
|
11
|
+
`MISAKANET_HOOK_FETCH=1` (off by default) makes the hook itself send the failed call's error text
|
|
12
|
+
(or the command, when the tool gave no error text) as a search query.
|
|
6
13
|
|
|
7
14
|
```bash
|
|
8
15
|
npx @misaka-net/misakanet-setup
|
|
@@ -16,19 +23,54 @@ knowledge base on its own instead of guessing.
|
|
|
16
23
|
|
|
17
24
|
| # | Goal | Mechanism |
|
|
18
25
|
|---|---|---|
|
|
19
|
-
| 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json`, `~/.codex/config.toml` and `~/.openclaw/openclaw.json` (`mcp.servers.misakanet`), with the Bearer token when registration succeeds — reads need no token (anonymous reads are unmetered since 2026-09-18); the token is what opens the **write** path. Every entry also carries the self-declared context hints `X-MisakaNet-Client` / `-Agent` / `-Os` / `-Version`, which the endpoint records on the read's analytics row and never treats as identity (`codewhale` is the one exception: its server entry has no custom-header field). Hermes is the same thing in its own files: `mcp_servers.misakanet` in `~/.hermes/config.yaml` plus the token in `~/.hermes/.env` under `MCP_MISAKANET_API_KEY`.
|
|
20
|
-
|
|
26
|
+
| 1 | the agent **can** call it | MCP server `https://misakanet.org/mcp` (streamable HTTP) in `~/.claude.json`, `~/.codex/config.toml` and `~/.openclaw/openclaw.json` (`mcp.servers.misakanet`), with the Bearer token when registration succeeds — reads need no token (anonymous reads are unmetered since 2026-09-18); the token is what opens the **write** path. Every entry also carries the self-declared context hints `X-MisakaNet-Client` / `-Agent` / `-Os` / `-Version`, which the endpoint records on the read's analytics row and never treats as identity (`codewhale` is the one exception: its server entry has no custom-header field). Hermes is the same thing in its own files: `mcp_servers.misakanet` in `~/.hermes/config.yaml` plus the token in `~/.hermes/.env` under `MCP_MISAKANET_API_KEY`. Five clients take the same idea in one file each, and **each file uses its own key path and URL field** — they are not interchangeable, and a copied entry with the wrong key fails silently (no error, the server simply never appears):
|
|
27
|
+
|
|
28
|
+
| client | file | container | URL field |
|
|
29
|
+
|---|---|---|---|
|
|
30
|
+
| Cursor | `~/.cursor/mcp.json` | `mcpServers` | `url` |
|
|
31
|
+
| Gemini CLI | `~/.gemini/settings.json` | `mcpServers` | **`httpUrl`** (its `url` means SSE) |
|
|
32
|
+
| Copilot CLI | `~/.copilot/mcp-config.json` | `mcpServers` | `url`, with `type: "http"` |
|
|
33
|
+
| OpenCode | `~/.config/opencode/opencode.json` | **`mcp`** | `url`, with `type: "remote"` |
|
|
34
|
+
| Kiro | `~/.kiro/settings/mcp.json` | `mcpServers` | `url` |
|
|
35
|
+
|
|
36
|
+
All of them are config files written file-to-file — no subprocess is spawned, so **no token ever appears in a command line**. For Claude Code only, the five **read-only** tools are also added to `permissions.allow` — "can call it" otherwise still means a permission prompt on the very first search, so `0.5.3` pre-allows them (`--verify` reports it, `--report` prints `permissions:`); `write_lesson` is deliberately **not** pre-allowed, because a tool that writes should ask |
|
|
37
|
+
| 2 | the agent **knows when** | rules block appended to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.hermes/SOUL.md` / `~/.openclaw/workspace/AGENTS.md`. **The five JSON-file clients are the exception: they get none**, because their rules are project-scoped (`.cursor/rules/*.mdc`, `GEMINI.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `.kiro/steering/*.md`) and the installer cannot know where your projects live — the install output says so, with the project-side action for each client, instead of implying behaviour changed (issue, retry, risky-operation triggers; desensitisation rules). Wording is **imperative first** ("必须先调 misakanet_search") rather than descriptive, because a field test showed a weaker model reads a descriptive block as background and never calls the tool |
|
|
21
38
|
| 4 | *(opt-in)* you **hear** it | `--voice` adds a `PostToolUse` hook that plays the cue the server asked for (`lesson-found` / `failure-warning` / `connect-success` / `pair-success`). Off by default; mute with `MISAKANET_VOICE=0`; see `docs/integrations/mcp-voice-hooks.md` |
|
|
22
|
-
| 3 | the checkpoint **fires** | a hook that counts user turns: turn 1 announces the install to the user, turn 20 (and every 10 after) injects the "
|
|
39
|
+
| 3 | the checkpoint **fires** | a hook that counts user turns: turn 1 announces the install to the user, turn 20 (and every 10 after) injects the "write up the reusable lessons and submit them" reminder — the agent does that writing and that submitting, by hand, and the hook itself sends nothing by default; a failed tool call injects a "search before you retry" reminder built from the error text, and only with `MISAKANET_HOOK_FETCH=1` (off by default) does the hook itself send that error text as a search query. Claude Code only today — Codex's user-level hook shape is unconfirmed and OpenClaw's events are unverified, so those two work from the rules block, and `--verify` says so per target instead of implying otherwise |
|
|
23
40
|
|
|
24
41
|
Without (3) a rule saying "summarise every 20 turns" never fires — agents do not keep
|
|
25
42
|
counters. Without (1)/(2) the hook has nothing to call.
|
|
26
43
|
|
|
44
|
+
### dsh is not a file target — it is a plugin
|
|
45
|
+
|
|
46
|
+
dsh takes its MCP servers from a **plugin bundle**, not from a JSON file. Its instruction files are a
|
|
47
|
+
chain: **`$DSH_HOME/AGENTS.md`** (user-global, default `~/.dsh/AGENTS.md`, applies to every session)
|
|
48
|
+
first, then the project's `AGENTS.md`/`CLAUDE.md` from the project root down to the working directory
|
|
49
|
+
(`@deepseek-ai/dsh-agent-instructions`, byte-budgeted at 65,536 by default). It has no Claude-Code-style
|
|
50
|
+
hooks. So the two commands are:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
dsh plugin --profile web add misakanet # ① the tools (mcp__misakanet__*)
|
|
54
|
+
# ② put the rules block in ~/.dsh/AGENTS.md (global) or the project's AGENTS.md
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Measured 2026-10-17: writing `~/.dsh/AGENTS.md` is picked up as `user-global` instructions on the next
|
|
58
|
+
message, with no restart.
|
|
59
|
+
|
|
60
|
+
Measured 2026-10-17: installing the current `misakanet` takes ~1s into `~/.dsh/profiles/web`; the plugin's
|
|
61
|
+
`index.js` mounts the MCP client and `lib/client.js` is the web panel — **neither injects a rule**,
|
|
62
|
+
which is why ② is a separate, manual step. Full guide: `docs/integrations/dsh.md`.
|
|
63
|
+
|
|
27
64
|
## Flags
|
|
28
65
|
|
|
29
66
|
```bash
|
|
30
67
|
npx @misaka-net/misakanet-setup --dry-run # show what would change, write nothing
|
|
31
68
|
npx @misaka-net/misakanet-setup --verify # READY / NOT READY, installed version, and the fix for each gap
|
|
69
|
+
# two sections, two questions: 端点/可用性 (can you use it — the
|
|
70
|
+
# endpoint answers) and 安装完整性 (are the files we prefer
|
|
71
|
+
# written); one failing does not condemn the other, and a file
|
|
72
|
+
# we cannot write says 装不了 with its cause — not 没装 with
|
|
73
|
+
# the one fix that cannot work ("re-run the installer")
|
|
32
74
|
npx @misaka-net/misakanet-setup --only claude # one agent only
|
|
33
75
|
npx @misaka-net/misakanet-setup --no-register # read-only, no anonymous token
|
|
34
76
|
npx @misaka-net/misakanet-setup --upgrade # same as installing the latest (the command is idempotent)
|
|
@@ -98,6 +140,10 @@ GPO / Intune / Jamf / Ansible snippets, and the section an IT security review ac
|
|
|
98
140
|
`--no-register` avoids registration entirely):
|
|
99
141
|
[`docs/maintainer/enterprise-deployment.md`](https://github.com/Ikalus1988/MisakaNet/blob/main/docs/maintainer/enterprise-deployment.md).
|
|
100
142
|
|
|
143
|
+
A reviewer-facing landing page — the same boundary questions, plus an explicit list of what does
|
|
144
|
+
**not** exist yet (no compliance certifications, no organisation-level audit export, no self-hosted
|
|
145
|
+
form of the hosted endpoint): <https://misakanet.org/enterprise/>.
|
|
146
|
+
|
|
101
147
|
The short version for a deployment script — two commands, both silent:
|
|
102
148
|
|
|
103
149
|
```bash
|
|
@@ -153,9 +199,30 @@ npx @misaka-net/misakanet-setup@latest
|
|
|
153
199
|
## Offline / restricted networks
|
|
154
200
|
|
|
155
201
|
The hook ships inside the npm tarball, so installing needs no download beyond npm itself.
|
|
156
|
-
Registration is best-effort: without it you keep the anonymous read path (
|
|
202
|
+
Registration is best-effort: without it you keep the anonymous read path (unmetered since 2026-09-18; only a per-address burst window applies)
|
|
157
203
|
and the installer says so in plain words.
|
|
158
204
|
|
|
205
|
+
**Behind a corporate proxy, read the probe line before believing it.** `--verify` probes the
|
|
206
|
+
endpoint with Node's `fetch`, and Node ignores `HTTP_PROXY` / `HTTPS_PROXY` unless the runtime is
|
|
207
|
+
told to use them — `NODE_USE_ENV_PROXY=1` or `--use-env-proxy`, with `fetch` support in Node
|
|
208
|
+
≥ 22.21.0 / 24.0.0 ([Node docs](https://nodejs.org/learn/http/enterprise-network-configuration)).
|
|
209
|
+
On a proxy machine the probe can therefore fail while your agent reaches the endpoint fine. The
|
|
210
|
+
report now says which of the two it is:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
NODE_USE_ENV_PROXY=1 npx @misaka-net/misakanet-setup --verify # let Node use the proxy
|
|
214
|
+
curl -sS https://misakanet.org/mcp -H 'Accept: application/json' \
|
|
215
|
+
-H 'MCP-Protocol-Version: 2025-06-18' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`endpoint-probe: inconclusive-proxy` in `--report` means exactly this: a proxy is configured, this
|
|
219
|
+
process could not use it, so the `endpoint-reachable` boolean above it says nothing. `--strict` /
|
|
220
|
+
`--ci` still fail in that case — a gate that cannot tell must not pass silently. If your proxy also
|
|
221
|
+
intercepts TLS, add `NODE_USE_SYSTEM_CA=1`.
|
|
222
|
+
|
|
223
|
+
The Python installer (`integrations/agent-autostart/install_misakanet_agent.py`) needs none of this:
|
|
224
|
+
it uses `urllib`, which honours the proxy environment variables by default.
|
|
225
|
+
|
|
159
226
|
## Requires
|
|
160
227
|
|
|
161
228
|
Node 18+ (the same runtime your assistant already uses). No dependencies, no Python.
|
package/bin/misakanet-setup.mjs
CHANGED
|
@@ -113,7 +113,8 @@ const SILENT = has('--silent');
|
|
|
113
113
|
/** `--report-json`: the same report as JSON, and nothing else on stdout (#1784). */
|
|
114
114
|
const REPORT_JSON = has('--report-json');
|
|
115
115
|
const HOME = resolve(valueOf('--home', homedir()));
|
|
116
|
-
const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale'
|
|
116
|
+
const AGENTS = ['claude', 'codex', 'hermes', 'openclaw', 'codewhale',
|
|
117
|
+
'cursor', 'gemini', 'copilot', 'opencode', 'kiro'];
|
|
117
118
|
|
|
118
119
|
const done = [];
|
|
119
120
|
const manual = [];
|
|
@@ -146,8 +147,17 @@ function readText(path) {
|
|
|
146
147
|
* (the value goes into the User-Agent header): js/file-access-to-http #268. The drift it was
|
|
147
148
|
* meant to prevent is now caught in CI instead, by a test that binds this literal to the
|
|
148
149
|
* manifest — a failing test is a better place for that than a request header.
|
|
150
|
+
*
|
|
151
|
+
* **Who moves it:** the release tooling, not a person. The `x-release-please-version` marker on
|
|
152
|
+
* this line is what release-please's generic updater looks for, and the file is listed in the
|
|
153
|
+
* setup package's `extra-files`, so the release pull request bumps this literal together with
|
|
154
|
+
* the manifest. That marker is not decoration: release-please replaces a version **only on a
|
|
155
|
+
* line that carries the marker** — an unmarked line is copied verbatim, so a release would ship
|
|
156
|
+
* a stale `--version` and a stale `X-MisakaNet-Version` header while every gate stayed green
|
|
157
|
+
* except the two that name the version (measured 2026-10-22: the 0.6.0 release pull request
|
|
158
|
+
* failed 9 e2e legs on exactly that).
|
|
149
159
|
*/
|
|
150
|
-
const VERSION = '0.
|
|
160
|
+
const VERSION = '0.6.0' // x-release-please-version
|
|
151
161
|
|
|
152
162
|
/**
|
|
153
163
|
* The shape of a client id, in one place.
|
|
@@ -159,6 +169,9 @@ const VERSION = '0.5.6'
|
|
|
159
169
|
* this cannot contain a quote, a newline or a `:`, so it cannot break any of the three.
|
|
160
170
|
*/
|
|
161
171
|
const CLIENT_ID_SHAPE = /^[A-Za-z0-9._-]{8,64}$/
|
|
172
|
+
// The server's rule for a referral code (`misakanet_register` → `referral_code`), kept identical on
|
|
173
|
+
// both sides: a value the client is willing to send and the server is willing to count.
|
|
174
|
+
const REFERRAL_SHAPE = /^[A-Za-z0-9]{4,16}$/
|
|
162
175
|
|
|
163
176
|
// ── argument surface ─────────────────────────────────────────────────
|
|
164
177
|
// Until 2026-09-17 no flag was validated at all: `--help` fell through to a *real install*
|
|
@@ -206,9 +219,6 @@ if (has('--version')) {
|
|
|
206
219
|
process.exit(0);
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
/** What we would write, and how to take it back — printed by --list-writes, and used by the report. */
|
|
210
|
-
const WRITE_MANIFEST = [];
|
|
211
|
-
|
|
212
222
|
const KNOWN = new Set(FLAGS.flatMap(([flag]) => flag.split(/[,\s]/).filter((f) => f.startsWith('-'))));
|
|
213
223
|
{
|
|
214
224
|
const unknown = args.filter((a) => a.startsWith('-') && !KNOWN.has(a));
|
|
@@ -318,6 +328,91 @@ function writeText(path, text, { mode } = {}) {
|
|
|
318
328
|
}
|
|
319
329
|
}
|
|
320
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
|
+
|
|
321
416
|
/**
|
|
322
417
|
* The files each assistant's install writes, so a permission problem can be reported *before*
|
|
323
418
|
* anything is touched (and so `--dry-run` can say "I cannot change that" as well as "I would change
|
|
@@ -330,9 +425,111 @@ const AGENT_WRITE_PATHS = {
|
|
|
330
425
|
codex: (home) => [join(home, '.codex', 'config.toml'), join(home, '.codex', 'AGENTS.md')],
|
|
331
426
|
hermes: (home) => [join(home, '.hermes', 'config.yaml'), join(home, '.hermes', 'SOUL.md')],
|
|
332
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)]])),
|
|
333
432
|
openclaw: () => openclawWorkspaces().map((workspace) => join(workspace, 'AGENTS.md')),
|
|
334
433
|
};
|
|
335
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
|
+
|
|
336
533
|
/**
|
|
337
534
|
* Which of these paths this process may not write.
|
|
338
535
|
*
|
|
@@ -413,6 +610,19 @@ function ourHookEntries(hooks) {
|
|
|
413
610
|
}
|
|
414
611
|
|
|
415
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
|
+
|
|
416
626
|
function injectBlock(path, block) {
|
|
417
627
|
const existing = readText(path);
|
|
418
628
|
const pattern = new RegExp(`[ \\t]*<!--\\s*${START}\\s*-->[\\s\\S]*?<!--\\s*${END}\\s*-->\\n?`);
|
|
@@ -483,6 +693,19 @@ function mcpHeaders(bearer, agent) {
|
|
|
483
693
|
* syntaxes, so it is checked against `CLIENT_ID_SHAPE` first. A `client_id` that is not id-shaped is
|
|
484
694
|
* not a hint worth writing; it is noise that could close a quote or a mapping.
|
|
485
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
|
+
|
|
486
709
|
function declaredClientId() {
|
|
487
710
|
if (knownClientId) return knownClientId;
|
|
488
711
|
const found = readText(join(stateDir(), 'client_id')).trim();
|
|
@@ -503,6 +726,38 @@ function probeEndpoint() {
|
|
|
503
726
|
return (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
|
|
504
727
|
}
|
|
505
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
|
+
|
|
506
761
|
/**
|
|
507
762
|
* One unauthenticated MCP call.
|
|
508
763
|
*
|
|
@@ -621,7 +876,7 @@ const PROMPT_BLOCK = `调用规则(按顺序照做):
|
|
|
621
876
|
5) 查不到就 misakanet_submit_intake(kind="question")。
|
|
622
877
|
6) 约 20 轮后、或刚解决一个非平凡问题时:把本次「失败→根因→修复→验证」里可泛化、有判据、且搜过没有重复的部分,
|
|
623
878
|
脱敏后 misakanet_submit_intake(kind="missing_lesson") 提交;不够价值就不提交。
|
|
624
|
-
|
|
879
|
+
脱敏:已由代码自动完成——本地钩子发出的查询在离开本机前、服务端入库前,都会把密钥/绝对路径/邮箱换成占位符;你不必手动改写,但仍不要粘整段会话转录或与故障无关的业务机密。全程不要打断用户任务。`;
|
|
625
880
|
|
|
626
881
|
// ── the hook file: shipped in the package, downloaded only if missing ─
|
|
627
882
|
/**
|
|
@@ -771,7 +1026,10 @@ async function ensureIdentity() {
|
|
|
771
1026
|
// export — an instruction a non-technical user cannot follow, and on Windows a different one.
|
|
772
1027
|
const clientId = usable ? explicit : `setup-${randomUUID()}`;
|
|
773
1028
|
knownClientId = clientId;
|
|
774
|
-
const
|
|
1029
|
+
const referral = declaredReferral();
|
|
1030
|
+
const result = await mcpCall('misakanet_register', {
|
|
1031
|
+
agent_type: 'setup', client_id: clientId, ...(referral ? { referral_code: referral } : {}),
|
|
1032
|
+
});
|
|
775
1033
|
// Validate before persisting: a response body is not something to write to disk unchecked
|
|
776
1034
|
// (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
|
|
777
1035
|
// ours, but "trust the shape" is the correct habit and it makes the value failing to match
|
|
@@ -781,8 +1039,22 @@ async function ensureIdentity() {
|
|
|
781
1039
|
// Say what it costs, not what went wrong internally: "凭据形状不对" is jargon. Reading is not
|
|
782
1040
|
// what is lost here (it needs no token and is not metered) — the write path is, and saying so
|
|
783
1041
|
// plainly is the difference between a user retrying and a user concluding the tool is broken.
|
|
784
|
-
|
|
785
|
-
|
|
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})` : ''));
|
|
786
1058
|
return '';
|
|
787
1059
|
}
|
|
788
1060
|
// Created with mode 0600 rather than written and then chmodded: the old order left a window in
|
|
@@ -809,6 +1081,8 @@ function mcpEntryPresent(agent) {
|
|
|
809
1081
|
hermes: () => readText(join(HOME, '.hermes', 'config.yaml')),
|
|
810
1082
|
openclaw: () => readText(join(HOME, '.openclaw', 'openclaw.json')),
|
|
811
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))])),
|
|
812
1086
|
}[agent]?.() || '';
|
|
813
1087
|
return text.includes('misakanet') && text !== 'null';
|
|
814
1088
|
}
|
|
@@ -820,6 +1094,7 @@ function detect(agent) {
|
|
|
820
1094
|
hermes: ['.hermes'],
|
|
821
1095
|
openclaw: ['.openclaw'],
|
|
822
1096
|
codewhale: ['.codewhale'],
|
|
1097
|
+
...Object.fromEntries(Object.entries(MCP_ONLY_TARGETS).map(([id, spec]) => [id, spec.detect])),
|
|
823
1098
|
}[agent] || [];
|
|
824
1099
|
return paths.some((p) => existsSync(join(HOME, p)));
|
|
825
1100
|
}
|
|
@@ -1340,11 +1615,77 @@ async function installOpenclaw(bearer) {
|
|
|
1340
1615
|
ok(`OpenClaw:注册 MCP(streamable-http)→ ${cfg}`);
|
|
1341
1616
|
}
|
|
1342
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
|
+
|
|
1343
1650
|
// The last endpoint probe, recorded so `--report` can print it without probing twice.
|
|
1344
|
-
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
|
+
}
|
|
1345
1671
|
|
|
1346
1672
|
async function verify() {
|
|
1347
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 };
|
|
1348
1689
|
// Handshake, not a search. `tools/list` proves the endpoint speaks MCP and answers, and it
|
|
1349
1690
|
// consumes no anonymous read quota — so `--verify` can be run as often as the user likes. The
|
|
1350
1691
|
// search used to be the probe, which spent one of the five free reads per run and, worse,
|
|
@@ -1364,18 +1705,46 @@ async function verify() {
|
|
|
1364
1705
|
const missing = CLAUDE_ALLOWED_TOOLS.filter((tool) => !granted.includes(tool));
|
|
1365
1706
|
if (detect('claude') && missing.length) {
|
|
1366
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;
|
|
1367
1711
|
need(`Claude Code:只读 MCP 工具没有放行(${missing.length} 个)→ 第一次检索会被权限拦下;`
|
|
1368
1712
|
+ '重跑安装命令即可放行');
|
|
1369
1713
|
}
|
|
1370
1714
|
} catch { /* a settings file we cannot read is reported by the Claude checks below */ }
|
|
1371
1715
|
} else if (probe && (probe.error || probe.protocolVersion || probe.serverInfo)) {
|
|
1372
1716
|
allOk = false;
|
|
1717
|
+
usable = false;
|
|
1718
|
+
probeState = 'not-ready';
|
|
1373
1719
|
need(`端点可达但握手异常:${JSON.stringify(probe).slice(0, 120)}`);
|
|
1374
1720
|
} else {
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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
|
+
}
|
|
1378
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 };
|
|
1379
1748
|
const tokenFile = join(stateDir(), 'token');
|
|
1380
1749
|
let tokenPresent = '';
|
|
1381
1750
|
try {
|
|
@@ -1386,43 +1755,61 @@ async function verify() {
|
|
|
1386
1755
|
if (tokenPresent) {
|
|
1387
1756
|
ok('写入通道:token 已就绪(write_lesson / preflight 可用)');
|
|
1388
1757
|
} else {
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
const
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
if (!
|
|
1410
|
-
|
|
1411
|
-
need(
|
|
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()}`);
|
|
1412
1783
|
} else {
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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 里没有本安装器写入的命令)');
|
|
1417
1794
|
} else {
|
|
1418
|
-
|
|
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
|
+
}
|
|
1419
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})`);
|
|
1420
1812
|
}
|
|
1421
|
-
const cfg = join(HOME, '.claude.json');
|
|
1422
|
-
const data = readJson(cfg, {}) || {};
|
|
1423
|
-
const entry = data.mcpServers?.misakanet;
|
|
1424
|
-
if (!entry) { allOk = false; need(`Claude Code:MCP 未注册(${cfg})`); }
|
|
1425
|
-
else ok(`Claude Code:MCP 已注册(${entry.url})`);
|
|
1426
1813
|
}
|
|
1427
1814
|
// OpenClaw is only reported when the user actually has it: telling a machine without
|
|
1428
1815
|
// OpenClaw that it is "not ready" would be a lie about a target that was never selected.
|
|
@@ -1431,17 +1818,23 @@ async function verify() {
|
|
|
1431
1818
|
if (detect('openclaw')) {
|
|
1432
1819
|
const rules = join(openclawWorkspaces()[0], 'AGENTS.md');
|
|
1433
1820
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
1434
|
-
|
|
1435
|
-
|
|
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()}`);
|
|
1436
1827
|
} else {
|
|
1437
1828
|
ok('OpenClaw:规则块已装');
|
|
1438
1829
|
}
|
|
1439
1830
|
const cfg = join(HOME, '.openclaw', 'openclaw.json');
|
|
1440
1831
|
const entry = (readJson(cfg, null) || {}).mcp?.servers?.misakanet;
|
|
1441
1832
|
if (!entry) {
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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)}`);
|
|
1445
1838
|
} else {
|
|
1446
1839
|
ok(`OpenClaw:MCP 已注册(${entry.url || '?'})`);
|
|
1447
1840
|
}
|
|
@@ -1452,17 +1845,21 @@ async function verify() {
|
|
|
1452
1845
|
if (readText(join(HOME, '.hermes', 'config.yaml'))) {
|
|
1453
1846
|
const rules = join(HOME, '.hermes', 'SOUL.md');
|
|
1454
1847
|
if (!readText(rules).includes(`<!-- ${START} -->`)) {
|
|
1455
|
-
|
|
1456
|
-
need(
|
|
1848
|
+
installFailed();
|
|
1849
|
+
need(canCreateHere(rules)
|
|
1850
|
+
? `Hermes:规则块没装(${rules})→ 重跑安装命令`
|
|
1851
|
+
: `Hermes:规则块装不了(${rules})→ ${unwritableAdvice()}`);
|
|
1457
1852
|
} else {
|
|
1458
1853
|
ok('Hermes:规则块已装');
|
|
1459
1854
|
}
|
|
1460
1855
|
const cfg = join(HOME, '.hermes', 'config.yaml');
|
|
1461
1856
|
const text = readText(cfg);
|
|
1462
1857
|
if (!HERMES_MARKED.test(text) && !HERMES_BARE.test(text)) {
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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)}`);
|
|
1466
1863
|
} else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
|
|
1467
1864
|
ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
|
|
1468
1865
|
skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
|
|
@@ -1471,6 +1868,27 @@ async function verify() {
|
|
|
1471
1868
|
skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
|
|
1472
1869
|
}
|
|
1473
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
|
+
}
|
|
1474
1892
|
// Version: what is installed, and whether the registry has moved on. Read-only here — the
|
|
1475
1893
|
// hook owns the periodic nudge (it cannot be a network call in this process's hot path), and
|
|
1476
1894
|
// `--verify` is an explicit user action, so one lookup is both cheap and expected.
|
|
@@ -1493,7 +1911,72 @@ async function verify() {
|
|
|
1493
1911
|
+ 'npx @misaka-net/misakanet-setup@latest');
|
|
1494
1912
|
}
|
|
1495
1913
|
}
|
|
1496
|
-
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');
|
|
1497
1980
|
}
|
|
1498
1981
|
|
|
1499
1982
|
/**
|
|
@@ -1550,7 +2033,8 @@ function restoreEmptiedFiles() {
|
|
|
1550
2033
|
// JSON configs: prune containers that our own removal emptied. The file itself is never deleted —
|
|
1551
2034
|
// it may have existed before us (an empty `~/.claude.json` is a legitimate user state).
|
|
1552
2035
|
const jsonFiles = [join(HOME, '.claude.json'), join(HOME, '.claude', 'settings.json'),
|
|
1553
|
-
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))];
|
|
1554
2038
|
for (const file of jsonFiles) {
|
|
1555
2039
|
const data = readJson(file, null);
|
|
1556
2040
|
if (!data || typeof data !== 'object') continue;
|
|
@@ -1617,6 +2101,16 @@ function uninstall() {
|
|
|
1617
2101
|
writeText(cfg, `${JSON.stringify(data, null, 2)}\n`);
|
|
1618
2102
|
ok(`移除 MCP 注册 → ${cfg}`);
|
|
1619
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
|
+
}
|
|
1620
2114
|
const voiceDir = join(stateDir(), 'voice');
|
|
1621
2115
|
if (existsSync(voiceDir)) {
|
|
1622
2116
|
try {
|
|
@@ -1772,7 +2266,8 @@ function distroName() {
|
|
|
1772
2266
|
* scalars (`true`, `[claude, codex]`, `""`); the JSON keeps their real types (boolean, array,
|
|
1773
2267
|
* string), which is the whole point of a machine-readable form.
|
|
1774
2268
|
*/
|
|
1775
|
-
function reportValues(
|
|
2269
|
+
function reportValues(verdict) {
|
|
2270
|
+
const allOk = verdict.allOk;
|
|
1776
2271
|
const agents = AGENTS.filter((agent) => detect(agent));
|
|
1777
2272
|
const token = existsSync(join(stateDir(), 'token'));
|
|
1778
2273
|
const permissions = (() => {
|
|
@@ -1792,14 +2287,25 @@ function reportValues(allOk) {
|
|
|
1792
2287
|
['node', process.version],
|
|
1793
2288
|
['detected-agents', agents],
|
|
1794
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'],
|
|
1795
2297
|
['endpoint-reachable', lastProbe.reachable],
|
|
1796
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'],
|
|
1797
2303
|
['token', token ? 'present' : 'absent'],
|
|
1798
2304
|
['permissions', permissions],
|
|
1799
2305
|
['hook', existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'],
|
|
1800
2306
|
// `mcp-only` = the endpoint is registered but the hooks are not, which is a different machine
|
|
1801
2307
|
// from "nothing installed" and must not be counted as one (#1753's T1-lite rows).
|
|
1802
|
-
['install-scope',
|
|
2308
|
+
['install-scope', hookWired()
|
|
1803
2309
|
? 'full'
|
|
1804
2310
|
: (AGENTS.some((agent) => detect(agent) && mcpEntryPresent(agent)) ? 'mcp-only' : 'none')],
|
|
1805
2311
|
['voice', voiceStatus()],
|
|
@@ -1876,10 +2382,10 @@ function reportJson(values) {
|
|
|
1876
2382
|
* encoding never changes the verdict.
|
|
1877
2383
|
*/
|
|
1878
2384
|
async function report(asJson) {
|
|
1879
|
-
const
|
|
1880
|
-
const values = reportValues(
|
|
2385
|
+
const verdict = await verify();
|
|
2386
|
+
const values = reportValues(verdict);
|
|
1881
2387
|
console.log(asJson ? reportJson(values) : reportYaml(values));
|
|
1882
|
-
return {
|
|
2388
|
+
return { ...verdict, openItems: manual.length };
|
|
1883
2389
|
}
|
|
1884
2390
|
|
|
1885
2391
|
// ── main ─────────────────────────────────────────────────────────────
|
|
@@ -1979,13 +2485,13 @@ if (mode === 'report') {
|
|
|
1979
2485
|
}
|
|
1980
2486
|
|
|
1981
2487
|
if (mode === 'verify') {
|
|
1982
|
-
const
|
|
1983
|
-
say(
|
|
2488
|
+
const verdict = await verify();
|
|
2489
|
+
say(renderVerify(verdict));
|
|
1984
2490
|
// Kept under `--silent`: this line is the verdict, not progress. (It is also the only thing a
|
|
1985
2491
|
// silent `--verify` prints when the machine is healthy, which is what makes it usable in a
|
|
1986
2492
|
// deployment log.)
|
|
1987
|
-
console.log(`\n结论:${allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 !
|
|
1988
|
-
process.exit(allOk ? 0 : 1);
|
|
2493
|
+
console.log(`\n结论:${verdict.allOk ? `READY —— 打开一个新会话,问它「${ONBOARDING_QUERIES[0]}」这类带报错原文的片段` : 'NOT READY —— 上面每条 ! 都给了成因和做法'}`);
|
|
2494
|
+
process.exit(verdict.allOk ? 0 : 1);
|
|
1989
2495
|
}
|
|
1990
2496
|
|
|
1991
2497
|
const targets = (only.length ? only : AGENTS).filter((a) => {
|
|
@@ -2032,38 +2538,12 @@ if (LIST_WRITES) {
|
|
|
2032
2538
|
// Every row carries its **tier**, which is the point: an operator approving "add an MCP server"
|
|
2033
2539
|
// (①) is not approving "change how the agent behaves" (② rules block, ③ automatic hooks). Files
|
|
2034
2540
|
// already exist → we append or update keys; missing → we create them.
|
|
2035
|
-
//
|
|
2036
|
-
//
|
|
2037
|
-
//
|
|
2038
|
-
|
|
2039
|
-
const base = basename(path);
|
|
2040
|
-
if (base === 'CLAUDE.md' || base === 'AGENTS.md' || base === 'SOUL.md') return 2;
|
|
2041
|
-
if (path.includes('.misakanet-agent')) return 3; // our own state dir
|
|
2042
|
-
// `settings.json` carries both the read-only grants (①) and the hooks (③); the caller adds the
|
|
2043
|
-
// second row only when the hooks are in scope.
|
|
2044
|
-
if (base === 'settings.json' && path.includes('.claude')) return 1;
|
|
2045
|
-
return 1; // an MCP entry in the agent's config
|
|
2046
|
-
};
|
|
2047
|
-
const rows = new Map();
|
|
2048
|
-
const add = (path, action, tier) => {
|
|
2049
|
-
const key = `${tier}|${path}`;
|
|
2050
|
-
if (!rows.has(key)) rows.set(key, { path: redact(path), action, tier });
|
|
2051
|
-
};
|
|
2052
|
-
for (const agent of AGENTS) {
|
|
2053
|
-
if (!detect(agent)) continue;
|
|
2054
|
-
for (const path of (AGENT_WRITE_PATHS[agent] || (() => []))(HOME)) {
|
|
2055
|
-
add(path, existsSync(path) ? 'append-or-update' : 'create', tierOf(path));
|
|
2056
|
-
if (!MCP_ONLY && basename(path) === 'settings.json' && path.includes('.claude')) {
|
|
2057
|
-
add(path, 'append-or-update(钩子)', 3);
|
|
2058
|
-
}
|
|
2059
|
-
}
|
|
2060
|
-
}
|
|
2061
|
-
add(join(stateDir(), 'token'), 'create (0600)', 1);
|
|
2062
|
-
if (!MCP_ONLY) add(join(stateDir(), 'hook.mjs'), 'create', 3);
|
|
2063
|
-
|
|
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.
|
|
2064
2545
|
const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 装钩子(每轮自动执行)' };
|
|
2065
|
-
const list =
|
|
2066
|
-
.sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
|
|
2546
|
+
const list = buildWriteManifest(targets);
|
|
2067
2547
|
for (const row of list) {
|
|
2068
2548
|
console.log(`tier${row.tier}\t${tiers[row.tier]}\t${row.path}\t${row.action}`);
|
|
2069
2549
|
}
|
|
@@ -2079,11 +2559,27 @@ if (LIST_WRITES) {
|
|
|
2079
2559
|
process.exit(0);
|
|
2080
2560
|
}
|
|
2081
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
|
+
|
|
2082
2574
|
try {
|
|
2083
2575
|
if (!targets.length) {
|
|
2084
|
-
need('
|
|
2576
|
+
need('没检测到任何受支持助手的配置目录(Claude Code / Codex / Hermes / OpenClaw / Codewhale / '
|
|
2577
|
+
+ 'Cursor)→ 请先打开一次你要用的那个助手,再回来运行本命令');
|
|
2085
2578
|
} else {
|
|
2086
|
-
|
|
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();
|
|
2087
2583
|
if (!MCP_ONLY) stampVersion();
|
|
2088
2584
|
const bearer = has('--no-register') ? '' : await ensureIdentity();
|
|
2089
2585
|
for (const agent of targets) {
|
|
@@ -2104,6 +2600,7 @@ try {
|
|
|
2104
2600
|
else if (agent === 'hermes') await installHermes(hookPath, bearer);
|
|
2105
2601
|
else if (agent === 'openclaw') await installOpenclaw(bearer);
|
|
2106
2602
|
else if (agent === 'codewhale') await installCodewhale(bearer);
|
|
2603
|
+
else if (MCP_ONLY_TARGETS[agent]) await installMcpOnly(agent, bearer);
|
|
2107
2604
|
installed += 1;
|
|
2108
2605
|
} catch (err) {
|
|
2109
2606
|
need(`${agent}:写入配置失败(${redact((err && err.message) || err)})`
|
|
@@ -226,6 +226,94 @@ function failureText(payload) {
|
|
|
226
226
|
return '';
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Deterministic redaction — the query must not carry the machine's secrets out of it.
|
|
231
|
+
*
|
|
232
|
+
* Issue #3692: the rules block used to *ask* the agent to redact, and an instruction is
|
|
233
|
+
* only a request. When the model did not comply, the internal table name, the home
|
|
234
|
+
* directory path or the token left the machine inside a search query. This table is the
|
|
235
|
+
* mechanical version of that rule: it runs on the error text before the fragment is
|
|
236
|
+
* printed into the agent's context, and again before anything is sent over the network.
|
|
237
|
+
* Redacting only at the network call would not be enough — the un-redacted text would
|
|
238
|
+
* still be in the agent's context, and the agent's own `misakanet_search` call would
|
|
239
|
+
* carry it.
|
|
240
|
+
*
|
|
241
|
+
* The ids are the server's (`workers/lib/redact-patterns.json`), so both ends name the
|
|
242
|
+
* same classes and the client never invents a rule the server does not know. Real
|
|
243
|
+
* domains and personal names are deliberately NOT redacted: the corpus is indexed by error
|
|
244
|
+
* text, and over-redaction would cost search quality for a leak that is not in scope.
|
|
245
|
+
*
|
|
246
|
+
* One path segment: anything but a space, a separator or punctuation that would end the
|
|
247
|
+
* segment (so a trailing comma or bracket in prose stays outside the match).
|
|
248
|
+
*/
|
|
249
|
+
const SEG = "[^\\s/\\\\'\"<>,;:!?()\\[\\]{}]";
|
|
250
|
+
// A path only counts as a *filesystem* path when it starts at a known root, or when its
|
|
251
|
+
// last segment carries a file extension. Without that, every API route in the corpus
|
|
252
|
+
// ("/api/v1/users", "/oauth/token") would be swallowed — and the corpus is indexed by
|
|
253
|
+
// error text, so a swallowed route destroys the lesson it belongs to. The roots are
|
|
254
|
+
// case-sensitive, except "Users" and "Volumes", which are written in capitals by
|
|
255
|
+
// convention but arrive in every casing.
|
|
256
|
+
const FS_ROOTS = "(?:home|root|var|opt|etc|usr|tmp|mnt|srv|data|private|workspace|app|code|project"
|
|
257
|
+
+ "|[Uu][Ss][Ee][Rr][Ss]|[Vv][Oo][Ll][Uu][Mm][Ee][Ss])";
|
|
258
|
+
const FILE_EXT = "[A-Za-z][A-Za-z0-9_\\-]*";
|
|
259
|
+
|
|
260
|
+
const REDACT_RULES = [
|
|
261
|
+
// A Unix absolute path: either it starts at a known filesystem root, or its last
|
|
262
|
+
// segment has a file extension ("/opt/app/config.yaml"). The look-behind keeps it out
|
|
263
|
+
// of a URL ("https://host/a/b" — the slash follows a word character) and out of a
|
|
264
|
+
// relative path; "file://" is matched as a whole, because its inner path is a path.
|
|
265
|
+
{ id: 'abs_path', pattern: `(?<![A-Za-z0-9_:/~.\\-])(?:file://)?/(?:${FS_ROOTS}(?:/${SEG}+)+|(?:${SEG}+/)*${SEG}*\\.${FILE_EXT})`, flags: 'g', replacement: '<PATH>' },
|
|
266
|
+
// A Windows path, with either separator. The look-behind keeps the "s://" of a URL out.
|
|
267
|
+
{ id: 'windows_path', pattern: `(?<![A-Za-z0-9_])[A-Za-z]:[\\\\/](?:${SEG}+[\\\\/])*${SEG}*`, flags: 'g', replacement: '<PATH>' },
|
|
268
|
+
// The secret rules are the server's shapes with two additions its copy does not need: a
|
|
269
|
+
// word boundary (so "task_identifier" is not read as the key "sk_identifier") and a floor
|
|
270
|
+
// of six characters on the bearer value (so the phrase "Bearer token" in prose survives).
|
|
271
|
+
// A real credential is longer than that; a real key is not glued to the end of a word.
|
|
272
|
+
{ id: 'bearer_token', pattern: '(?<![A-Za-z0-9])(?:Bearer|Authorization)\\s+[A-Za-z0-9\\-._~+/]{6,}=*', flags: 'gi', replacement: '[REDACTED:bearer_token]' },
|
|
273
|
+
{ id: 'github_token', pattern: '(?<![A-Za-z0-9])(?:ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9]{10,}', flags: 'g', replacement: '[REDACTED:github_token]' },
|
|
274
|
+
{ id: 'api_key', pattern: '(?<![A-Za-z0-9])(?:sk|pk|rk|ak)[_-][A-Za-z0-9]{10,}', flags: 'g', replacement: '[REDACTED:api_key]' },
|
|
275
|
+
// The top-level domain has to be alphabetic: "foo@1.2.3" is an npm version spec, and
|
|
276
|
+
// eating it would destroy the version the reporter was talking about.
|
|
277
|
+
{ id: 'email', pattern: '[A-Za-z0-9._%+\\-]+@[A-Za-z0-9\\-]+(?:\\.[A-Za-z0-9\\-]*)*\\.[A-Za-z]{2,}', flags: 'g', replacement: '<REDACTED:email>' },
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
const COMPILED_REDACT_RULES = REDACT_RULES.map((rule) => ({
|
|
281
|
+
...rule,
|
|
282
|
+
regex: new RegExp(rule.pattern, rule.flags),
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Replace every secret the table knows about. Never throws: a broken rule must not become
|
|
287
|
+
* a failed hook (the hook's contract is that it cannot break a session).
|
|
288
|
+
*/
|
|
289
|
+
function redact(text) {
|
|
290
|
+
let out = String(text);
|
|
291
|
+
for (const rule of COMPILED_REDACT_RULES) {
|
|
292
|
+
try {
|
|
293
|
+
out = out.replace(rule.regex, rule.replacement);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
debug(`redaction rule ${rule.id} failed: ${err}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Cut the query to a length, without cutting a placeholder in half: a truncated
|
|
303
|
+
* `<REDACTED:email` is noise the agent would carry into its own call.
|
|
304
|
+
*
|
|
305
|
+
* Counted in code points, not UTF-16 units, so this and the Python twin cut in the same
|
|
306
|
+
* place — the parity test compares their stdout byte for byte.
|
|
307
|
+
*/
|
|
308
|
+
function clip(text, limit) {
|
|
309
|
+
const chars = Array.from(text);
|
|
310
|
+
if (chars.length <= limit) return text;
|
|
311
|
+
let cut = chars.slice(0, limit).join('');
|
|
312
|
+
const open = cut.lastIndexOf('<');
|
|
313
|
+
if (open !== -1 && cut.indexOf('>', open) === -1) cut = cut.slice(0, open);
|
|
314
|
+
return cut;
|
|
315
|
+
}
|
|
316
|
+
|
|
229
317
|
function target() {
|
|
230
318
|
const configured = (process.env.MISAKANET_ENDPOINT || CANONICAL_ENDPOINT).trim();
|
|
231
319
|
const envToken = (process.env.MISAKANET_TOKEN || '').trim();
|
|
@@ -251,7 +339,9 @@ async function search(query) {
|
|
|
251
339
|
signal: controller.signal,
|
|
252
340
|
body: JSON.stringify({
|
|
253
341
|
jsonrpc: '2.0', id: 1, method: 'tools/call',
|
|
254
|
-
|
|
342
|
+
// Redacted again on the way out: the fragment is already redacted, but this is the
|
|
343
|
+
// only place where the text actually leaves the machine, so it gets its own gate.
|
|
344
|
+
params: { name: 'misakanet_search', arguments: { query: redact(query).slice(0, 300), top: 1, detail: 'summary' } },
|
|
255
345
|
}),
|
|
256
346
|
});
|
|
257
347
|
clearTimeout(timer);
|
|
@@ -302,7 +392,9 @@ function promptMode(payload) {
|
|
|
302
392
|
async function failureMode(payload) {
|
|
303
393
|
const text = failureText(payload);
|
|
304
394
|
if (!text) return;
|
|
305
|
-
|
|
395
|
+
// Redact first, cut second: the query the agent is told to search with must not carry a
|
|
396
|
+
// path, an address or a token, and the agent may copy it into its own later call.
|
|
397
|
+
const fragment = clip(redact(text).split(/\s+/).join(' '), 120);
|
|
306
398
|
const lines = [
|
|
307
399
|
'[MisakaNet] 刚刚有一次工具调用失败。在**重试或换修法之前**先查一次(第二次盲试就是『重复犯错』):',
|
|
308
400
|
` misakanet_search(query=${JSON.stringify(fragment)}) # 查不到就用 kind="question" 提 intake,别猜`,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@misaka-net/misakanet-setup",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "MisakaNet failure lessons for your agents: writes an MCP endpoint into each agent config, rules blocks where the client takes one, and a Claude Code hook. At most one anonymous request leaves this machine at install; then only the agent's calls — unless MISAKANET_HOOK_FETCH=1 (off by default) the hook sends the failed call's error text or command.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"misakanet-setup": "bin/misakanet-setup.mjs"
|
|
7
7
|
},
|