@misaka-net/misakanet-setup 0.5.5 → 0.5.6

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 CHANGED
@@ -16,7 +16,7 @@ knowledge base on its own instead of guessing.
16
16
 
17
17
  | # | Goal | Mechanism |
18
18
  |---|---|---|
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 so reads are not metered by the anonymous 5/day/IP limit. 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`. All four 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 |
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`. All four 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 |
20
20
  | 2 | the agent **knows when** | rules block appended to `~/.claude/CLAUDE.md` / `~/.codex/AGENTS.md` / `~/.hermes/SOUL.md` / `~/.openclaw/workspace/AGENTS.md` (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
21
  | 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
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 "distil and submit" reminder; a failed tool call injects a "search before you retry" reminder built from the error text. 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 |
@@ -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, so reads are not
20
- * metered by the anonymous 5/day limit)
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).
@@ -128,7 +147,18 @@ function readText(path) {
128
147
  * meant to prevent is now caught in CI instead, by a test that binds this literal to the
129
148
  * manifest — a failing test is a better place for that than a request header.
130
149
  */
131
- const VERSION = '0.5.5'
150
+ const VERSION = '0.5.6'
151
+
152
+ /**
153
+ * The shape of a client id, in one place.
154
+ *
155
+ * It gates two different things: what the endpoint accepts as `client_id` on registration, and — since
156
+ * 2026-09-19 — what may be written into an assistant's config as `X-MisakaNet-Client`. The second use
157
+ * is why the shape matters here and not only at the endpoint: that value can come from a file we found
158
+ * on disk, and it is interpolated into a TOML inline table, a YAML mapping and JSON. A string matching
159
+ * this cannot contain a quote, a newline or a `:`, so it cannot break any of the three.
160
+ */
161
+ const CLIENT_ID_SHAPE = /^[A-Za-z0-9._-]{8,64}$/
132
162
 
133
163
  // ── argument surface ─────────────────────────────────────────────────
134
164
  // Until 2026-09-17 no flag was validated at all: `--help` fell through to a *real install*
@@ -148,7 +178,9 @@ const FLAGS = [
148
178
  ['--uninstall', '移除本安装器写入的内容(保留 .misakanet.bak 备份)'],
149
179
  ['--upgrade', '与安装等价(覆盖安装即升级)'],
150
180
  ['--client-id <id>', '固定本机身份:同一个 id 永远拿回同一个 node(重装/换机也能延续)'],
151
- ['--no-register', '不注册匿名节点(不写 token,检索仍是 5 次/天/IP)'],
181
+ ['--mcp-only', '只注册 MCP 端点,不改 agent 行为(不写规则块、不装钩子)——权限分级用'],
182
+ ['--list-writes', '只打印会改哪些文件(文件 | 动作 | 撤销方式),不写任何东西'],
183
+ ['--no-register', '不注册匿名节点(不写 token;读不受影响,写入类工具不可用)'],
152
184
  ['--voice', '打开语音/桌面通知(默认关)'],
153
185
  ['--help, -h', '打印这份帮助并退出'],
154
186
  ['--version', '打印版本并退出'],
@@ -174,6 +206,9 @@ if (has('--version')) {
174
206
  process.exit(0);
175
207
  }
176
208
 
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
+
177
212
  const KNOWN = new Set(FLAGS.flatMap(([flag]) => flag.split(/[,\s]/).filter((f) => f.startsWith('-'))));
178
213
  {
179
214
  const unknown = args.filter((a) => a.startsWith('-') && !KNOWN.has(a));
@@ -415,6 +450,45 @@ function stripBlock(path) {
415
450
 
416
451
  const stateDir = () => join(HOME, '.misakanet-agent');
417
452
 
453
+ /**
454
+ * The headers every MCP entry we write carries.
455
+ *
456
+ * Two kinds, and the difference matters: `Authorization` is a credential (only when we have a token),
457
+ * while the other four are **self-declared hints** the service records on its analytics row — the
458
+ * stable pseudonym `X-MisakaNet-Client` (so a caller's history stays together without an account), the
459
+ * assistant name, the platform, and the installer version that wrote this. The endpoint treats them as
460
+ * hints and never as identity, which is what makes it acceptable to write them automatically
461
+ * (see AGENTS.md §3.3).
462
+ *
463
+ * Written only for agents whose config format supports custom headers; `--mcp-only` gets them too,
464
+ * because "the tool is callable" is exactly what tier ① means.
465
+ */
466
+ function mcpHeaders(bearer, agent) {
467
+ const headers = {};
468
+ if (bearer) headers.Authorization = `Bearer ${bearer}`;
469
+ const clientId = declaredClientId();
470
+ if (clientId) headers['X-MisakaNet-Client'] = clientId;
471
+ headers['X-MisakaNet-Agent'] = agent;
472
+ headers['X-MisakaNet-Os'] = `${process.platform}/${process.arch}`.slice(0, 40);
473
+ headers['X-MisakaNet-Version'] = VERSION;
474
+ return headers;
475
+ }
476
+
477
+ /**
478
+ * The pseudonym to declare, or `''` when this machine has not said.
479
+ *
480
+ * Explicit intent beats a file we merely found, the same rule the register path applies. The file is
481
+ * `integrations/agent-autostart`'s (it keeps one there for the Python channel), so picking it up is
482
+ * continuity worth having — but it is data from outside this process on its way into three config
483
+ * syntaxes, so it is checked against `CLIENT_ID_SHAPE` first. A `client_id` that is not id-shaped is
484
+ * not a hint worth writing; it is noise that could close a quote or a mapping.
485
+ */
486
+ function declaredClientId() {
487
+ if (knownClientId) return knownClientId;
488
+ const found = readText(join(stateDir(), 'client_id')).trim();
489
+ return CLIENT_ID_SHAPE.test(found) ? found : '';
490
+ }
491
+
418
492
  const CANONICAL_ENDPOINT = 'https://misakanet.org/mcp';
419
493
 
420
494
  /**
@@ -661,6 +735,16 @@ async function installHook() {
661
735
  }
662
736
 
663
737
  async function ensureIdentity() {
738
+ // The explicit intent is read first, because it decides the `X-MisakaNet-Client` hint on every run
739
+ // — including one that registers nothing (the token is already there) — so it cannot sit below the
740
+ // early returns. `--client-id` and `MISAKANET_CLIENT_ID` are the same intent.
741
+ const explicit = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
742
+ const usable = CLIENT_ID_SHAPE.test(explicit);
743
+ if (explicit && !usable) {
744
+ need('ignored --client-id / MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
745
+ }
746
+ if (usable) knownClientId = explicit;
747
+
664
748
  const file = join(stateDir(), 'token');
665
749
  let existing = '';
666
750
  try {
@@ -685,13 +769,8 @@ async function ensureIdentity() {
685
769
  // `--client-id` and `MISAKANET_CLIENT_ID` are the same explicit intent. The flag exists because
686
770
  // the closing guidance told users to keep an identity and the only way to supply one was a shell
687
771
  // export — an instruction a non-technical user cannot follow, and on Windows a different one.
688
- const exported = (valueOf('--client-id', '') || process.env.MISAKANET_CLIENT_ID || '').trim();
689
- if (exported && !/^[A-Za-z0-9._-]{8,64}$/.test(exported)) {
690
- need('ignored --client-id / MISAKANET_CLIENT_ID:只接受 8–64 位的 [A-Za-z0-9._-](这次会新生成一个)');
691
- }
692
- const clientId = (exported && /^[A-Za-z0-9._-]{8,64}$/.test(exported))
693
- ? exported
694
- : `setup-${randomUUID()}`;
772
+ const clientId = usable ? explicit : `setup-${randomUUID()}`;
773
+ knownClientId = clientId;
695
774
  const result = await mcpCall('misakanet_register', { agent_type: 'setup', client_id: clientId });
696
775
  // Validate before persisting: a response body is not something to write to disk unchecked
697
776
  // (CodeQL js/http-to-file-access #262/#264 is about exactly that flow). The endpoint is
@@ -699,18 +778,18 @@ async function ensureIdentity() {
699
778
  // a visible, debuggable outcome instead of a silent 401 later.
700
779
  const token = typeof result?.token === 'string' ? result.token.trim() : '';
701
780
  if (!/^mcp_[A-Za-z0-9_-]{20,}$/.test(token)) {
702
- // Say what it costs, not what went wrong internally: "凭据形状不对" is jargon, and
703
- // "读课程不受影响" was misleading — without a token the read path is capped at the anonymous
704
- // quota. Someone who does not know that will hit a wall five queries later and blame the tool.
705
- need('匿名注册没成功(多半是网络)→ 现在检索限额是每天 5 次/IP,写入类工具也用不了;'
706
- + '网络恢复后重跑本命令就能拿到 token,其它功能不受影响');
781
+ // Say what it costs, not what went wrong internally: "凭据形状不对" is jargon. Reading is not
782
+ // what is lost here (it needs no token and is not metered) — the write path is, and saying so
783
+ // plainly is the difference between a user retrying and a user concluding the tool is broken.
784
+ need('匿名注册没成功(多半是网络)→ 读课程不受影响,但写入类工具(write_lesson)现在用不了;'
785
+ + '网络恢复后重跑本命令就能拿到 token');
707
786
  return '';
708
787
  }
709
788
  // Created with mode 0600 rather than written and then chmodded: the old order left a window in
710
789
  // which the token sat in a world-readable file on every single run.
711
790
  writeText(file, token, { mode: 0o600 });
712
791
  ok(`匿名身份:${String(result.node_id || '?').slice(0, 32)}(token 存 ${file},权限 600)`);
713
- if (!exported) {
792
+ if (!usable) {
714
793
  // Printed, not stored: this process must not turn a file it found into request data
715
794
  // (CodeQL js/file-access-to-http #268), and the value is the user's to keep anyway.
716
795
  // Kept out of the "done" list on purpose — it is something to *keep*, not something that
@@ -722,6 +801,18 @@ async function ensureIdentity() {
722
801
  }
723
802
 
724
803
  // ── per-agent install ────────────────────────────────────────────────
804
+ /** Is our MCP entry already in this agent's config? (read-only; used by the report's scope line) */
805
+ function mcpEntryPresent(agent) {
806
+ const text = {
807
+ claude: () => JSON.stringify(readJson(join(HOME, '.claude.json'), {})?.mcpServers?.misakanet || null),
808
+ codex: () => readText(join(HOME, '.codex', 'config.toml')),
809
+ hermes: () => readText(join(HOME, '.hermes', 'config.yaml')),
810
+ openclaw: () => readText(join(HOME, '.openclaw', 'openclaw.json')),
811
+ codewhale: () => readText(join(HOME, '.codewhale', 'mcp.json')),
812
+ }[agent]?.() || '';
813
+ return text.includes('misakanet') && text !== 'null';
814
+ }
815
+
725
816
  function detect(agent) {
726
817
  const paths = {
727
818
  claude: ['.claude.json', '.claude'],
@@ -742,8 +833,7 @@ async function installClaude(hookPath, bearer) {
742
833
  }
743
834
  const doc = data || {};
744
835
  doc.mcpServers = doc.mcpServers || {};
745
- const entry = { type: 'http', url: ENDPOINT };
746
- if (bearer) entry.headers = { Authorization: `Bearer ${bearer}` };
836
+ const entry = { type: 'http', url: ENDPOINT, headers: mcpHeaders(bearer, 'claude-code') };
747
837
  if (sameJson(doc.mcpServers.misakanet, entry)) {
748
838
  ok('Claude Code:MCP 已注册(无改动)');
749
839
  } else {
@@ -754,8 +844,30 @@ async function installClaude(hookPath, bearer) {
754
844
  }
755
845
 
756
846
  const rules = join(HOME, '.claude', 'CLAUDE.md');
757
- ok(`Claude Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
758
-
847
+ if (!MCP_ONLY) ok(`Claude Code:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
848
+ else skip('Claude Code:规则块未写(--mcp-only:只注册端点)');
849
+
850
+ if (MCP_ONLY) {
851
+ // Tier ① = "the tool can be called", and that includes the read-only grants: the comment below
852
+ // says it plainly — without them the first search is *denied*, so an operator who approved
853
+ // "add an MCP server" would be shipping a tool their users cannot use. The grants are therefore
854
+ // tier ①. What tier ③ adds is the hooks, and those are not written here.
855
+ const settingsPath = join(HOME, '.claude', 'settings.json');
856
+ const settings = readJson(settingsPath, {}) || {};
857
+ const allow = ((settings.permissions = settings.permissions || {}).allow =
858
+ Array.isArray(settings.permissions.allow) ? settings.permissions.allow : []);
859
+ let changed = false;
860
+ for (const tool of CLAUDE_ALLOWED_TOOLS) {
861
+ if (!allow.includes(tool)) { allow.push(tool); changed = true; }
862
+ }
863
+ if (changed) {
864
+ backup(settingsPath);
865
+ writeText(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
866
+ }
867
+ ok(`Claude Code:只读工具已放行(${CLAUDE_ALLOWED_TOOLS.length} 个${changed ? '' : ',无改动'})`);
868
+ skip('Claude Code:自动沉淀钩子未装(--mcp-only:只注册端点与工具放行)');
869
+ return;
870
+ }
759
871
  if (!hookPath) {
760
872
  need('Claude Code:跳过了"自动沉淀"钩子(钩子文件没取到)');
761
873
  return;
@@ -852,12 +964,17 @@ async function installClaude(hookPath, bearer) {
852
964
 
853
965
  const codexTable = (bearer) => {
854
966
  const lines = ['[mcp_servers.misakanet]', 'type = "streamable-http"', `url = "${ENDPOINT}"`];
855
- if (bearer) {
856
- // http_headers, not bearer_token_env_var: the env var needs the user to export it, and
857
- // this user will not.
858
- lines.push(`http_headers = { Authorization = "Bearer ${bearer}" }`);
859
- } else {
860
- lines.push('# 没有 token:读走匿名通道(5/天/IP)');
967
+ // http_headers, not bearer_token_env_var: the env var needs the user to export it, and this user
968
+ // will not. All the headers go into the ONE inline table — a second `http_headers` line is a
969
+ // duplicate key, and TOML refuses the whole file for it. (Bare TOML keys may contain `-`, which is
970
+ // why the hint names need no quoting.)
971
+ const headers = Object.entries(mcpHeaders(bearer, 'codex'))
972
+ .map(([key, value]) => `${key} = "${value}"`).join(', ');
973
+ lines.push(`http_headers = { ${headers} }`);
974
+ if (!bearer) {
975
+ // Only worth saying when it is true. Since 2026-09-18 anonymous reads are not metered — what a
976
+ // token buys is the write path, not more reads.
977
+ lines.push('# 没有 token:读不限次数,写入类工具(write_lesson)不可用');
861
978
  }
862
979
  return `${lines.join('\n')}\n`;
863
980
  };
@@ -909,7 +1026,8 @@ async function installCodex(hookPath, bearer) {
909
1026
  }
910
1027
 
911
1028
  const rules = join(HOME, '.codex', 'AGENTS.md');
912
- ok(`Codex:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1029
+ if (!MCP_ONLY) ok(`Codex:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1030
+ else skip('Codex:规则块未写(--mcp-only:只注册端点)');
913
1031
  // Verified on codex-cli 0.154.0 (2026-09-15), so this is no longer a "could not
914
1032
  // confirm" note: `codex mcp list` shows misakanet enabled with the Bearer token,
915
1033
  // `codex doctor` reports config.toml parse ok + 1 streamable_http server + 0
@@ -1036,11 +1154,18 @@ async function installHermes(hookPath, bearer) {
1036
1154
  need('Hermes:找不到 ~/.hermes/config.yaml → 先运行一次 hermes 再回来装');
1037
1155
  return;
1038
1156
  }
1039
- ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1157
+ if (!MCP_ONLY) ok(`Hermes:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1158
+ else skip('Hermes:规则块未写(--mcp-only:只注册端点;SOUL.md 是它的人格文件)');
1040
1159
 
1041
1160
  const envKey = HERMES_ENV_KEY;
1042
1161
  const lines = [` misakanet: ${YAML_START}`, ` url: ${ENDPOINT}`];
1043
- if (bearer) lines.push(' headers:', ` Authorization: Bearer \${${envKey}}`);
1162
+ lines.push(' headers:');
1163
+ if (bearer) lines.push(` Authorization: Bearer \${${envKey}}`);
1164
+ // Hermes reads its headers from YAML, so these are literals rather than env templates: they are
1165
+ // hints, not secrets (a token would have to go through `.env` like the Authorization line above).
1166
+ for (const [key, value] of Object.entries(mcpHeaders('', 'hermes'))) {
1167
+ lines.push(` ${key}: ${value}`);
1168
+ }
1044
1169
  lines.push(` ${YAML_END}`);
1045
1170
  // No trailing newline: the three call sites below each own the one they need, and the
1046
1171
  // "insert after mcp_servers:" case must NOT add one (the line it replaces already ends with
@@ -1068,7 +1193,7 @@ async function installHermes(hookPath, bearer) {
1068
1193
  }
1069
1194
 
1070
1195
  if (!bearer) {
1071
- skip('Hermes:这次没有 token → 走匿名通道(5/天/IP),写入类工具不可用');
1196
+ skip('Hermes:这次没有 token → 读仍然不限次数,写入类工具不可用');
1072
1197
  return;
1073
1198
  }
1074
1199
  const envPath = join(HOME, '.hermes', '.env');
@@ -1117,6 +1242,11 @@ async function installHermes(hookPath, bearer) {
1117
1242
  * The token is the one thing codewhale will not take inline: `bearer_token_env_var` names
1118
1243
  * an environment variable, so "install once" for this agent ends with the user exporting
1119
1244
  * `MISAKANET_TOKEN` once. That is stated rather than silently half-installed.
1245
+ *
1246
+ * This is also the one agent that gets no `X-MisakaNet-*` hints: its server entry has a fixed
1247
+ * field set (`command`/`args`/`env`/`url`/timeouts/`bearer_token_env_var`) with no custom-header
1248
+ * field, and inventing one would risk the file being rejected outright. Hints are optional by
1249
+ * design — a call without them is still a normal anonymous read.
1120
1250
  */
1121
1251
  function codewhaleProjects() {
1122
1252
  const text = readText(join(HOME, '.codewhale', 'config.toml'));
@@ -1166,7 +1296,9 @@ async function installCodewhale(bearer) {
1166
1296
  const projects = codewhaleProjects();
1167
1297
  for (const project of projects) {
1168
1298
  if (!existsSync(project)) continue;
1169
- ok(`codewhale:规则块 ${injectBlock(join(project, 'AGENTS.md'), PROMPT_BLOCK)} → ${join(project, 'AGENTS.md')}`);
1299
+ if (!MCP_ONLY) {
1300
+ ok(`codewhale:规则块 ${injectBlock(join(project, 'AGENTS.md'), PROMPT_BLOCK)} → ${join(project, 'AGENTS.md')}`);
1301
+ } else skip('codewhale:规则块未写(--mcp-only)');
1170
1302
  }
1171
1303
  if (!projects.length) {
1172
1304
  need('codewhale:没有受信任的项目目录(config.toml 里没有 trust_level = "trusted")→ '
@@ -1183,7 +1315,8 @@ async function installOpenclaw(bearer) {
1183
1315
  if (!existsSync(workspace)) {
1184
1316
  need(`OpenClaw:找不到 workspace(${workspace})→ 先运行一次 openclaw 生成它`);
1185
1317
  } else {
1186
- ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1318
+ if (!MCP_ONLY) ok(`OpenClaw:规则块 ${injectBlock(rules, PROMPT_BLOCK)} → ${rules}`);
1319
+ else skip('OpenClaw:规则块未写(--mcp-only)');
1187
1320
  }
1188
1321
 
1189
1322
  const cfg = join(HOME, '.openclaw', 'openclaw.json');
@@ -1193,8 +1326,8 @@ async function installOpenclaw(bearer) {
1193
1326
  + `或手动执行 \`${manual}\``);
1194
1327
  return;
1195
1328
  }
1196
- const entry = { url: ENDPOINT, transport: 'streamable-http' };
1197
- if (bearer) entry.headers = { Authorization: `Bearer ${bearer}` };
1329
+ const entry = { url: ENDPOINT, transport: 'streamable-http',
1330
+ headers: mcpHeaders(bearer, 'openclaw') };
1198
1331
  data.mcp = data.mcp || {};
1199
1332
  data.mcp.servers = data.mcp.servers || {};
1200
1333
  if (sameJson(data.mcp.servers.misakanet, entry)) {
@@ -1251,9 +1384,9 @@ async function verify() {
1251
1384
  tokenPresent = '';
1252
1385
  }
1253
1386
  if (tokenPresent) {
1254
- ok('写入通道:token 已就绪(解除每天 5 次读限额,write_lesson 可用)');
1387
+ ok('写入通道:token 已就绪(write_lesson / preflight 可用)');
1255
1388
  } else {
1256
- skip('写入通道:无 token(只读也完全可用,但读有 5/天/IP 限额)');
1389
+ skip('写入通道:无 token(读不限次数、只读完全可用,写入类工具不可用)');
1257
1390
  }
1258
1391
  const hookPath = join(stateDir(), 'hook.mjs');
1259
1392
  let hookPresent = false;
@@ -1331,8 +1464,8 @@ async function verify() {
1331
1464
  need(`Hermes:MCP 未注册(${cfg})→ 重跑安装命令,或执行 `
1332
1465
  + `hermes mcp add misakanet --url ${ENDPOINT} --auth header`);
1333
1466
  } else if (!new RegExp(`^${HERMES_ENV_KEY}=`, 'm').test(readText(join(HOME, '.hermes', '.env')))) {
1334
- ok('Hermes:MCP 条目已写入配置(无 token → 匿名 5 次/天/IP)');
1335
- skip(`Hermes:想让检索不计匿名额度,重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
1467
+ ok('Hermes:MCP 条目已写入配置(无 token → 读不限次数,写入类工具不可用)');
1468
+ skip(`Hermes:想让写入类工具可用(write_lesson),重跑安装命令即可写入 ${HERMES_ENV_KEY}`);
1336
1469
  } else {
1337
1470
  ok(`Hermes:MCP 条目与 token 都在(${HERMES_ENV_KEY})`);
1338
1471
  skip('Hermes:本进程只能确认"配置已写",无法确认 Hermes 是否已加载 → 可用 hermes mcp list 复核');
@@ -1664,6 +1797,11 @@ function reportValues(allOk) {
1664
1797
  ['token', token ? 'present' : 'absent'],
1665
1798
  ['permissions', permissions],
1666
1799
  ['hook', existsSync(join(stateDir(), 'hook.mjs')) ? 'present' : 'absent'],
1800
+ // `mcp-only` = the endpoint is registered but the hooks are not, which is a different machine
1801
+ // from "nothing installed" and must not be counted as one (#1753's T1-lite rows).
1802
+ ['install-scope', existsSync(join(stateDir(), 'hook.mjs'))
1803
+ ? 'full'
1804
+ : (AGENTS.some((agent) => detect(agent) && mcpEntryPresent(agent)) ? 'mcp-only' : 'none')],
1667
1805
  ['voice', voiceStatus()],
1668
1806
  ['open-items', manual.length],
1669
1807
  // Redacted here, once, so neither encoder can print an unredacted message by accident.
@@ -1779,7 +1917,14 @@ if (mode !== 'report' && !SILENT) {
1779
1917
 
1780
1918
  if (mode === 'uninstall') {
1781
1919
  uninstall();
1782
- say(render());
1920
+ if (MCP_ONLY) {
1921
+ // The honest failure mode of tier ①: the tool is callable, but nothing tells the agent (or the
1922
+ // user) to call it — the rules block and the first-turn announcement live in tiers ②/③. Printed
1923
+ // as a "manual step" so it survives --silent (a deployment log keeps the `!` lines).
1924
+ need('只注册了端点:你的 agent 不会自己想到去查 —— 需要你自己说「先查 MisakaNet」,'
1925
+ + '或者请运营方批准 ②③ 后重跑同一条命令(去掉 --mcp-only)');
1926
+ }
1927
+ say(render());
1783
1928
  if (!SILENT) {
1784
1929
  console.log('\n已移除本安装器写入的内容(规则块、MCP 注册、钩子、状态目录)。');
1785
1930
  console.log(`备份保留在 <被改过的文件>.misakanet.bak(例如 ${join(HOME, '.claude', 'CLAUDE.md')}.misakanet.bak)`
@@ -1866,12 +2011,80 @@ const targets = (only.length ? only : AGENTS).filter((a) => {
1866
2011
  let installed = 0;
1867
2012
  // Set when a stable client id was minted: shown to the user at the end (see ensureIdentity).
1868
2013
  let clientIdHint = '';
2014
+ /**
2015
+ * The pseudonym this run knows the machine by — an explicit `--client-id` / `MISAKANET_CLIENT_ID`, or
2016
+ * the one this run minted. This is what `X-MisakaNet-Client` carries.
2017
+ *
2018
+ * Deliberately separate from `clientIdHint`, which exists only for the closing "keep this number"
2019
+ * guidance: a user who supplied an id must not be told to keep it, but their config still has to say
2020
+ * who they are — otherwise the hint would be missing for exactly the users who took the trouble to
2021
+ * have an identity at all.
2022
+ */
2023
+ let knownClientId = '';
2024
+ /**
2025
+ * `--list-writes`: the manifest an operator needs to approve, or to undo by hand.
2026
+ *
2027
+ * `--dry-run` already says which files it would touch; this adds the two things an approval process
2028
+ * asks for — *what* each write does (new file / append a marked block / add a config key) and *how to
2029
+ * take it back* — and it is machine-readable so a ticket can carry it.
2030
+ */
2031
+ if (LIST_WRITES) {
2032
+ // Every row carries its **tier**, which is the point: an operator approving "add an MCP server"
2033
+ // (①) is not approving "change how the agent behaves" (② rules block, ③ automatic hooks). Files
2034
+ // already exist → we append or update keys; missing → we create them.
2035
+ // Compare base names, not separators: a `/settings.json$` regex silently classifies nothing on
2036
+ // Windows (all three windows legs went red on the first CI run of this change), and a manifest
2037
+ // that hides a tier is worse than no manifest.
2038
+ const tierOf = (path) => {
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
+
2064
+ const tiers = { 1: '① 注册 MCP 端点', 2: '② 写规则块(改行为)', 3: '③ 装钩子(每轮自动执行)' };
2065
+ const list = [...rows.values()].filter((row) => (MCP_ONLY ? row.tier === 1 : true))
2066
+ .sort((a, b) => a.tier - b.tier || a.path.localeCompare(b.path));
2067
+ for (const row of list) {
2068
+ console.log(`tier${row.tier}\t${tiers[row.tier]}\t${row.path}\t${row.action}`);
2069
+ }
2070
+ const shown = new Set(list.map((row) => row.tier));
2071
+ if (MCP_ONLY) {
2072
+ console.log('# --mcp-only:只申请 ①。规则块(②)与钩子(③)不会写入,'
2073
+ + '需要时由运营方另行批准后重跑不带 --mcp-only 的同一条命令。');
2074
+ } else {
2075
+ console.log(`# 本次申请 ${shown.size} 级权限(${[...shown].sort().map((t) => tiers[t]).join(' · ')});`
2076
+ + '只想批 ① 就加 --mcp-only。');
2077
+ }
2078
+ console.log('# 这次没有写任何东西。撤销:npx @misaka-net/misakanet-setup --uninstall');
2079
+ process.exit(0);
2080
+ }
2081
+
1869
2082
  try {
1870
2083
  if (!targets.length) {
1871
2084
  need('没检测到 Claude Code / Codex / Hermes 的配置目录 → 请先打开一次你要用的那个助手,再回来运行本命令');
1872
2085
  } else {
1873
- const hookPath = await installHook();
1874
- stampVersion();
2086
+ const hookPath = MCP_ONLY ? null : await installHook();
2087
+ if (!MCP_ONLY) stampVersion();
1875
2088
  const bearer = has('--no-register') ? '' : await ensureIdentity();
1876
2089
  for (const agent of targets) {
1877
2090
  // One target failing must not skip the rest: an uncaught throw used to abort the loop, so
@@ -1909,6 +2122,13 @@ try {
1909
2122
  // the exact failures in it on a dirty one — which is the point (#1784). The machine-readable form
1910
2123
  // of the same run is `--report-json`, not this text.
1911
2124
  say(render());
2125
+ if (MCP_ONLY && !SILENT) {
2126
+ // The honest failure mode of tier ①, said where the user will read it: they are not installing a
2127
+ // behaviour. The rules block (②) and the first-turn announcement, which is injected by the hook
2128
+ // (③), are exactly what makes an agent look things up on its own.
2129
+ console.log('\n⚠️ --mcp-only:只注册了端点与只读工具放行 —— **你的 agent 不会自己想到去查**。'
2130
+ + '\n 需要你自己说「先查 MisakaNet」,或请运营方批准 ②③ 后重跑同一条命令(去掉 --mcp-only)。');
2131
+ }
1912
2132
  if (!SILENT) {
1913
2133
  console.log(`
1914
2134
  接下来:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misaka-net/misakanet-setup",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "One-command setup: teach your Claude Code / Codex / Hermes / OpenClaw / codewhale to search MisakaNet failure lessons before repeating a mistake, and to distil the session at a checkpoint.",
5
5
  "bin": {
6
6
  "misakanet-setup": "bin/misakanet-setup.mjs"