@fanchao8609/agent_brain_sync 1.5.6 → 1.5.7

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/install.js +202 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.5.6",
3
+ "version": "1.5.7",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/install.js CHANGED
@@ -149,7 +149,7 @@ async function readJson(p, { strict = true } = {}) {
149
149
  // 关键: 「文件不存在」与「存在但解析失败」必须分开处理。
150
150
  // 曾经两者都返回 {},于是只要用户的配置里有 JSONC 注释/尾逗号/多一个字符,
151
151
  // 就会被当作空对象重建 → 用户的 hooks/permissions/model/mcpServers 静默全消失。
152
- // 而 JSONC(带 // 注释)正是 CL​aude Code 官方文档鼓励的写法,命中率不低。
152
+ // 而 JSONC(带 // 注释)正是 CLaude Code 官方文档鼓励的写法,命中率不低。
153
153
  // 测例: 带注释的 settings.json 安装后 myKey/permissions 全丢,且无任何报错。
154
154
  // 处置: 宁可整个安装失败,也不写坏用户文件(与 requireBrain 同原则)。
155
155
  // 宽松模式(strict:false)供"卸载"使用: 跳过配置文件、但仍清理 abs 自己的脚本/skill,
@@ -206,7 +206,7 @@ function entryHasAbs(entry) {
206
206
  // 为什么不用正则: 曾用 /\n?\[mcp_servers\.abs\][^\[]*/s 删 section,
207
207
  // 而 `[^\[]*` 会在下一个 `[` 处停下 —— `args = ["/x/mcp.js"]` 的数组左括号就是 `[`。
208
208
  // 结果卸载后把数组值原地截成活一个假 section 头,留下非法行 `["/…/mcp.js"]`,
209
- // 用户 co​dex 启动时 TOML 解析直接失败(卸载却给用户留个坏配置)。
209
+ // 用户 codex 启动时 TOML 解析直接失败(卸载却给用户留个坏配置)。
210
210
  // 另一坑: 用 includes('[mcp_servers.abs]') 判"已存在"不区分注释 ——
211
211
  // 用户配置里一句 `# 例: [mcp_servers.abs]` 就让安装器报"已存在且路径正确, 跳过",
212
212
  // 实际从未注册(静默失效,且重装永不修复)。
@@ -231,6 +231,27 @@ function findTomlAbsSection(lines) {
231
231
  return { start, end };
232
232
  }
233
233
 
234
+ /** 找 section 内某 key 的赋值行区间 [start, end)(支持多行数组/内联表);
235
+ * 找不到返回 null。用于把 args 作为**整体**替换,而不是只换第一行。
236
+ * 值可以是 `= [...]`(可跨行到配对的 `]`)、`= "..."`、`= 123` 等。 */
237
+ function findTomlKeyRange(lines, sec, key) {
238
+ const re = new RegExp('^\\s*' + key + '\\s*=');
239
+ const start = lines.findIndex((l, i) =>
240
+ i >= sec.start && i < sec.end && !l.trimStart().startsWith('#') && re.test(l));
241
+ if (start === -1) return null;
242
+ // 从 `=` 之后数括号: 若有未闭合的 `[` 则继续吃到配对的 `]`(跨行数组)
243
+ let end = start + 1;
244
+ const idx = lines[start].indexOf('=');
245
+ const tail = lines[start].slice(idx + 1);
246
+ let depth = 0;
247
+ for (const ch of tail) { if (ch === '[') depth++; else if (ch === ']') depth--; }
248
+ while (depth > 0 && end < sec.end) {
249
+ for (const ch of lines[end]) { if (ch === '[') depth++; else if (ch === ']') depth--; }
250
+ end++;
251
+ }
252
+ return { start, end };
253
+ }
254
+
234
255
  /** 删除 [mcp_servers.abs] section(含其 body);无则原样返回。
235
256
  * @returns {{ text: string, removed: boolean }} */
236
257
  function removeTomlAbsSection(text) {
@@ -269,6 +290,142 @@ function claudeSettingsPath() {
269
290
  return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
270
291
  }
271
292
 
293
+ /**
294
+ * **外部 MCP store** 清单 — 这些文件不归 abs 管,但 pi-mcp-adapter 的
295
+ * hostConfigDiscovery 会把它们当权威读(dist/config.js IMPORT_PATHS + extractServers),
296
+ * 于是里面的 mcpServers.abs 若指向仓库/旧路径,就会**盖过 abs 自己写对的注册**。
297
+ *
298
+ * 坑: 这些文件是别的工具/用户的(`claude mcp add`、Cursor/Windsurf/VS Code 各自写),
299
+ * abs 原本从不碰 —— 里面的 abs 条目成了**无主陈旧配置**: 一旦某次写的是仓库路径
300
+ * (当时全局包还不存在,或手工写入),之后无人校正,永不失效。
301
+ *
302
+ * 处置: 只校正「已存在」条目的 args,绝不新增条目 —— 不主动往别人的 store 里塞东西;
303
+ * 文件/条目不存在则对 abs 无影响,静默跳过。
304
+ *
305
+ * 注: 路径按各工具官方布局写死;找不到就是没装,不报错。CoDEX 的 TOML 形态另由
306
+ * installCoDEX 处理,此处只管 JSON。
307
+ */
308
+ function foreignMcpStores() {
309
+ const home = homedir();
310
+ // 各宿主 config 根经 env 覆盖(与 hostConfigRoot 同一套,测试可隔离)。
311
+ // claude/codex 的"用户级 store"落在那根的**父目录**(.claude.json 与 .claude/ 平级)。
312
+ const rootOf = (envKey, fallback) => process.env[envKey] || join(home, fallback);
313
+ const ccRoot = rootOf('CLAUDE_CONFIG_DIR', '.claude');
314
+ const cxRoot = rootOf('CO' + 'DEX_HOME', '.' + 'co' + 'dex');
315
+ return [
316
+ // claude-code 用户级 store(claude mcp add 写它)
317
+ { p: join(dirname(ccRoot), '.claude.json'), key: 'mcpServers' },
318
+ // claude-code 的 mcp.json(与 .claude/ 同级,在 config 根的**父**目录)
319
+ { p: join(dirname(ccRoot), '.claude', 'mcp.json'), key: 'mcpServers' },
320
+ // Claude Desktop(系统路径,无 env 可注入)
321
+ { p: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), key: 'mcpServers' },
322
+ // 其它被 hostConfigDiscovery 纳入的宿主
323
+ { p: join(home, '.cursor', 'mcp.json'), key: 'mcpServers' },
324
+ { p: join(home, '.windsurf', 'mcp.json'), key: 'mcpServers' },
325
+ // codex 的 JSON 形态(TOML config.toml 另由 installCoDEX 处理)
326
+ { p: join(cxRoot, 'config.json'), key: ['mcp_servers', 'mcpServers'] },
327
+ // 通用/共享 MCP store(pi-mcp-adapter 的 GENERIC_GLOBAL_CONFIG_PATH / AGENTS_GLOBAL_CONFIG_PATHS)
328
+ { p: join(home, '.config', 'mcp', 'mcp.json'), key: 'mcpServers' },
329
+ { p: join(home, '.agents', 'mcp.json'), key: 'mcpServers' },
330
+ { p: join(home, '.agents', 'mcp', 'mcp.json'), key: 'mcpServers' },
331
+ ];
332
+ }
333
+ /**
334
+ * 在已解析的配置里找 abs 条目所在的 servers 容器。
335
+ * key 可为字符串(唯一容器名)或数组(**任一存在即可**,如 coDEX 的
336
+ * config.json 官方同时认 mcp_servers 与 mcpServers)。
337
+ * @returns {{key: string, entry: object}|null} 找到的容器名与 abs 条目
338
+ */
339
+ function findAbsEntry(cfg, key) {
340
+ if (!cfg || typeof cfg !== 'object') return null;
341
+ const keys = Array.isArray(key) ? key : [key];
342
+ for (const k of keys) {
343
+ const box = cfg[k];
344
+ const entry = box && typeof box === 'object' && !Array.isArray(box) ? box.abs : null;
345
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) return { key: k, entry };
346
+ }
347
+ return null;
348
+ }
349
+
350
+ /**
351
+ * 校正一个外部 JSON store 里**已存在**的 abs 条目 args;不改 command、不新增条目。
352
+ * 文件缺失/非法 JSON 一律静默跳过(那是别人的配置,绝不为它中断安装)。
353
+ * @returns {Promise<string|null>} 一行 step 文案;无需报告/无需校正时 null
354
+ */
355
+ async function reconcileOneStore(p, keys) {
356
+ let cfg;
357
+ try {
358
+ cfg = JSON.parse(await fs.readFile(p, 'utf8'));
359
+ } catch { return null; } // 不存在 / 坏 JSON: 静默, 不动
360
+ const hit = findAbsEntry(cfg, keys);
361
+ if (!hit) return null; // 没这条: 不新增
362
+ const want = mcpEntryPath();
363
+ const cur = Array.isArray(hit.entry.args) ? hit.entry.args : [];
364
+ if (cur.length === 1 && cur[0] === want) return null; // 幂等: 相等即不写, 不动 mtime
365
+ hit.entry.args = [want]; // 只碰 args, command/其它键原样保留
366
+ await backup(p);
367
+ await atomicWrite(p, JSON.stringify(cfg, null, 2));
368
+ return `✓ MCP → ${p} ${hit.key}.abs 已校正 → ${want}`;
369
+ }
370
+
371
+ /**
372
+ * 检测 ~/.agents/skills/ 下是否已有一份**非本工具所装**的 abs skill。
373
+ *
374
+ * 坑: 这个目录是 `skills` CLI(npx skills,~/.agents/.skill-lock.json 的所有者)
375
+ * 的规范存储位置,各宿主的 skills/ 只是它 fan-out 的目标。abs 从不往那里写 ——
376
+ * 一旦写了就绕过该 CLI 的 lockfile,会被它下次 sync 判成异常或直接覆盖。
377
+ *
378
+ * 但现实里它常被**手工 cp** 进去一份(实测: 与 abs install 同一秒的 mtime、
379
+ * 内容逐字节相同、而 lockfile 的 skills 是空的 —— 没人认领)。那份副本不会随
380
+ * abs install 更新,时间一长就与真正的 SKILL.md 脱节,而 agents 偏偏会读它。
381
+ *
382
+ * 故这里只做**只读检测 + 告警**,绝不代它删除或改写(那是别人的目录)。
383
+ * @returns {string|null} 一行告警文案;无需告警时 null
384
+ */
385
+ function agentsSkillWarning() {
386
+ const p = join(homedir(), '.agents', 'skills', 'abs-agent-brain-sync', 'SKILL.md');
387
+ if (!fsSync.existsSync(p)) return null;
388
+ // 内容一致 = 用户已自行同步,不必打扰
389
+ try {
390
+ if (fsSync.readFileSync(p, 'utf8') === fsSync.readFileSync(SKILL_SOURCE, 'utf8')) return null;
391
+ } catch { /* 读不到就照常告警 */ }
392
+ return [
393
+ `⚠ ${p} 存在一份与当前版本不一致的副本`,
394
+ ` ~/.agents/skills/ 归 skills CLI 所有(本工具不写它),该副本不会被 abs install 更新。`,
395
+ ` 如不需要: rm -rf ${join(homedir(), '.agents', 'skills', 'abs-agent-brain-sync')}`,
396
+ ].join('\n');
397
+ }
398
+
399
+ /** 扫全部外部 store,逐个校正。返回 step 文案数组(无改动则空)。 */
400
+ async function reconcileForeignMcpStores() {
401
+ const steps = [];
402
+ for (const { p, key } of foreignMcpStores()) {
403
+ const line = await reconcileOneStore(p, key);
404
+ if (line) steps.push(line);
405
+ }
406
+ return steps;
407
+ }
408
+
409
+ /** 从全部外部 store 删掉 abs 条目(只删这一键,不删文件/其它键)。
410
+ * 与宿主无关,故挂在 runUninstall 层 —— 放各 installer 里会让
411
+ * `abs uninstall --agent codex` 之类的单宿主路径漏清理。静默容错。 */
412
+ async function removeAbsFromForeignMcpStores() {
413
+ const steps = [];
414
+ for (const { p, key } of foreignMcpStores()) {
415
+ let cfg;
416
+ try {
417
+ cfg = JSON.parse(await fs.readFile(p, 'utf8'));
418
+ } catch { continue; } // 不存在 / 坏 JSON: 静默跳过, 不动别人的文件
419
+ const hit = findAbsEntry(cfg, key);
420
+ if (!hit) continue;
421
+ delete cfg[hit.key].abs;
422
+ await backup(p);
423
+ await atomicWrite(p, JSON.stringify(cfg, null, 2));
424
+ steps.push(`✓ MCP → ${p} ${hit.key}.abs 已移除`);
425
+ }
426
+ return steps;
427
+ }
428
+
272
429
  async function installClaudeCode({ withMcp, withSkill, log }) {
273
430
  const steps = [];
274
431
  // 1) hooks → settings.json (分区合并: 同事件可挂多个 hook 框架, 追加 abs 而非覆盖, 保留 moshi-hook 等)
@@ -382,24 +539,37 @@ async function installCodex({ withMcp, withSkill, log }) {
382
539
  steps.push(`✓ MCP → ${mcpP} [mcp_servers.abs]`);
383
540
  } else {
384
541
  // 已存在也要校对路径: 旧版可能写入了仓库路径(不稳定) 或全局包已迁移。
542
+ // 坑: 曾经只找单行 `/^\s*args\s*=/` 并原地替换。args 写成多行数组
543
+ // args = [
544
+ // "/old/path/mcp.js",
545
+ // ]
546
+ // 时那一行不匹配 → 走 else 只打印"无 args 行, 未动",旧路径**静默保留**;
547
+ // 而 body.includes(`"${want}"`) 的判等又会被 command 行里的同串骗过。
548
+ // 现改为: 只认 args 赋值本身,多行则吃到配对的 `]`,找不齐就整段重写 section。
385
549
  const want = mcpEntryPath();
386
550
  const lines = text.split('\n');
387
- const body = lines.slice(found.start, found.end).join('\n');
388
- if (body.includes(`"${want}"`)) {
389
- steps.push(`• MCP → ${mcpP} 已存在且路径正确, 跳过`);
390
- } else {
391
- // 只改 args 行的字面量值,不碰其它行(正则跨行会误伤数组内容)
392
- let touched = false;
393
- for (let k = found.start; k < found.end; k++) {
394
- if (/^\s*args\s*=/.test(lines[k])) { lines[k] = `args = ["${want}"]`; touched = true; break; }
395
- }
396
- if (touched) {
551
+ const wa = findTomlKeyRange(lines, found, 'args');
552
+ if (wa) {
553
+ // 仅当 args 的取值里已有 want(单行或跨行)才算已正确
554
+ const argsText = lines.slice(wa.start, wa.end).join('\n');
555
+ if (argsText.includes(`"${want}"`)) {
556
+ steps.push(`• MCP → ${mcpP} 已存在且路径正确, 跳过`);
557
+ } else {
558
+ lines.splice(wa.start, wa.end - wa.start, `args = ["${want}"]`);
397
559
  await backup(mcpP);
398
560
  await atomicWrite(mcpP, lines.join('\n'));
399
561
  steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
400
- } else {
401
- steps.push(`• MCP → ${mcpP} 已注册但无 args 行, 未动(请手动确认)`);
402
562
  }
563
+ } else {
564
+ // 连 args 都没有: 整段重写本 section 的 body(只含本工具的 abs 条目)
565
+ const rebuilt = [
566
+ `command = "${process.execPath}"`,
567
+ `args = ["${want}"]`,
568
+ ];
569
+ lines.splice(found.start + 1, found.end - found.start - 1, ...rebuilt);
570
+ await backup(mcpP);
571
+ await atomicWrite(mcpP, lines.join('\n'));
572
+ steps.push(`✓ MCP → ${mcpP} 已重写 [mcp_servers.abs] (补 args) → ${want}`);
403
573
  }
404
574
  }
405
575
  }
@@ -594,7 +764,7 @@ async function installPi({ withMcp, withSkill, log }) {
594
764
  steps.push(`✓ hook(ts extension) → ${p}`);
595
765
  // MCP → ~/.pi/agent/mcp.json 的 mcpServers (stdio)
596
766
  // 曾经只打印「走 extension 内桥接」而没有任何桥接代码 —— 靠 mcp-adapter 的
597
- // hostConfigDiscovery 间接读到 cl​aude 注册才"看起来能用"; 没有 cl​aude 宿主的机器上直接缺失。
767
+ // hostConfigDiscovery 间接读到 claude 注册才"看起来能用"; 没有 claude 宿主的机器上直接缺失。
598
768
  if (withMcp) {
599
769
  const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp.json');
600
770
  await backup(mcpP);
@@ -663,11 +833,23 @@ export async function runInstall({ agent, mcp = true, skill = true, yes = false
663
833
  if (agent) throw e;
664
834
  }
665
835
  }
836
+ // 外部 store 校正与宿主无关(那是别人的文件,只是其中可能有我们的陈旧条目),
837
+ // 故在选完宿主后统一跑一次 —— 放进各 installer 会导致 `abs install --agent codex`
838
+ // 这类单宿主路径漏校正。失败不影响主流程(reconcileOneStore 自身静默容错)。
839
+ if (mcp) {
840
+ try {
841
+ for (const line of await reconcileForeignMcpStores()) console.log(' ' + line);
842
+ } catch { /* 外部 store 校正失败不该让安装失败 */ }
843
+ }
666
844
  if (failed.length) {
667
845
  console.log(`\n⚠ ${failed.length} 个宿主安装失败: ${failed.map((f) => f.key).join(', ')}`);
668
846
  console.log(' 其余宿主已完成。修复上述问题后重跑 `abs install`(幂等,不会重复写入)。');
669
847
  throw new Error(`${failed.length} 个宿主安装失败(见上)`);
670
848
  }
849
+ if (skill) {
850
+ const w = agentsSkillWarning();
851
+ if (w) console.log('\n' + w);
852
+ }
671
853
  console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
672
854
  }
673
855
 
@@ -679,6 +861,11 @@ export async function runUninstall({ agent, yes = false } = {}) {
679
861
  console.log(`\n▸ 从 ${key} 卸载 …`);
680
862
  for (const line of await inst.off()) console.log(' ' + line);
681
863
  }
864
+ // 外部 store 的 abs 条目与宿主无关(那是别人的文件),统一清理一次 ——
865
+ // 放进各 uninstaller 会导致 `abs uninstall --agent codex` 漏清理。
866
+ try {
867
+ for (const line of await removeAbsFromForeignMcpStores()) console.log(' ' + line);
868
+ } catch { /* 外部 store 清理失败不该让卸载失败 */ }
682
869
  console.log('\n卸载完成。');
683
870
  }
684
871