@fanchao8609/agent_brain_sync 1.5.5 → 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.
- package/bin/abs.js +64 -6
- package/hooks/abs.opencode.ts +113 -0
- package/hooks/abs.pi.ts +130 -0
- package/package.json +1 -1
- package/src/install.js +504 -289
- package/src/store.js +27 -6
- package/src/todo.js +22 -0
package/src/install.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { promises as fs } from 'node:fs';
|
|
9
9
|
import * as fsSync from 'node:fs';
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
|
-
import { join, dirname, resolve } from 'node:path';
|
|
11
|
+
import { join, dirname, resolve, basename } from 'node:path';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { HOSTS, hostByKey } from './hosts.js';
|
|
14
14
|
|
|
@@ -17,28 +17,57 @@ const HOOK_TEMPLATE = join(ABS_DIR, 'hooks', 'event.sh');
|
|
|
17
17
|
const SKILL_SOURCE = join(ABS_DIR, 'skill', 'SKILL.md');
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
20
|
+
* 本包的稳定入口路径解析(mcp.js / abs.js 通用)。
|
|
21
|
+
*
|
|
21
22
|
* 坑: 直接用 ABS_DIR 是不稳定的 —— ABS_DIR = "install.js 自己住哪", 从仓库跑
|
|
22
|
-
* `abs install`
|
|
23
|
-
*
|
|
24
|
-
*
|
|
23
|
+
* `abs install` 就把仓库路径烧进宿主配置/hook 脚本。之后若卸载/移动仓库或全局包,
|
|
24
|
+
* 该路径直接失效; 而某些分支"已存在即跳过", 一旦写错永不修正。
|
|
25
|
+
*
|
|
26
|
+
* 故统一优先解析本包的稳定安装位置(全局 node_modules),解析不到才回退 ABS_DIR。
|
|
27
|
+
* 单一收口: MCP 注册与 hook 脚本都走这里 —— 曾经 MCP 修了、hook 没修,
|
|
28
|
+
* 同一份安装里 hook 指向仓库而 MCP 指向全局,是两个不同的包。
|
|
29
|
+
*
|
|
30
|
+
* @param entryFile 包内相对 bin/ 的文件名,如 'mcp.js' / 'abs.js'
|
|
25
31
|
*/
|
|
26
|
-
function
|
|
32
|
+
function stableBinPath(entryFile) {
|
|
27
33
|
// 本包名(package.json),用于反查全局安装位置
|
|
28
34
|
let pkgName = '@fanchao8609/agent_brain_sync';
|
|
29
35
|
try {
|
|
30
36
|
pkgName = JSON.parse(fsSync.readFileSync(join(ABS_DIR, 'package.json'), 'utf8')).name || pkgName;
|
|
31
37
|
} catch { /* 保持默认 */ }
|
|
38
|
+
|
|
39
|
+
// 各平台 npm 全局根下的本包位置。
|
|
40
|
+
// Unix (nvm/homebrew/apt): <node根>/lib/node_modules/<pkg>
|
|
41
|
+
// Windows: %APPDATA%\npm\node_modules\<pkg>
|
|
42
|
+
// 注: 曾经只有 Unix 候选 —— Windows 上两个都不中,会回退到 ABS_DIR(仓库路径),
|
|
43
|
+
// 即 H3 在 Windows 上仍会复现。此条为代码层推断,未在 Windows 实测。
|
|
44
|
+
const nodeRoot = dirname(dirname(process.execPath)); // <node>/bin/node → <node>
|
|
32
45
|
const candidates = [
|
|
33
|
-
//
|
|
34
|
-
join(dirname(process.execPath), '..', 'lib', 'node_modules', pkgName, 'bin', 'mcp.js'),
|
|
35
|
-
join(process.env.npm_config_prefix || '', 'lib', 'node_modules', pkgName, 'bin', 'mcp.js'),
|
|
46
|
+
join(nodeRoot, 'lib', 'node_modules', pkgName, 'bin', entryFile), // Unix
|
|
36
47
|
];
|
|
48
|
+
// npm 自己报告的全局根(npm_config_prefix 由 npm 子进程设置;交互 shell 里通常为空)
|
|
49
|
+
if (process.env.npm_config_prefix) {
|
|
50
|
+
candidates.push(join(process.env.npm_config_prefix, 'lib', 'node_modules', pkgName, 'bin', entryFile));
|
|
51
|
+
candidates.push(join(process.env.npm_config_prefix, 'node_modules', pkgName, 'bin', entryFile));
|
|
52
|
+
}
|
|
53
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
54
|
+
candidates.push(join(process.env.APPDATA, 'npm', 'node_modules', pkgName, 'bin', entryFile));
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
for (const c of candidates) {
|
|
38
|
-
try {
|
|
58
|
+
try {
|
|
59
|
+
// 归一化为绝对路径,避免任何依赖 cwd 的相对候选被误用
|
|
60
|
+
const abs = resolve(c);
|
|
61
|
+
if (fsSync.existsSync(abs)) return abs;
|
|
62
|
+
} catch { /* 试下一个 */ }
|
|
39
63
|
}
|
|
40
64
|
// 回退:仓库/本地安装形态
|
|
41
|
-
return join(ABS_DIR, 'bin',
|
|
65
|
+
return join(ABS_DIR, 'bin', entryFile);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 写进宿主 MCP 配置的 mcp.js 路径。 */
|
|
69
|
+
function mcpEntryPath() {
|
|
70
|
+
return stableBinPath('mcp.js');
|
|
42
71
|
}
|
|
43
72
|
|
|
44
73
|
const MARK = '// abs-managed (agent-brain-sync)'; // TS plugin 标记
|
|
@@ -60,27 +89,180 @@ export function hostSkillDir(key) {
|
|
|
60
89
|
async function atomicWrite(p, text) {
|
|
61
90
|
await fs.mkdir(dirname(p), { recursive: true });
|
|
62
91
|
const tmp = `${p}.abs-tmp-${Date.now()}`;
|
|
63
|
-
|
|
64
|
-
|
|
92
|
+
try {
|
|
93
|
+
await fs.writeFile(tmp, text, 'utf8');
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// 写 tmp 就失败(如目标是目录): 清掉半成品再抛,否则沙盒里留 .abs-tmp-* 残骸
|
|
96
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
97
|
+
throw e;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
await fs.rename(tmp, p);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// rename 失败(如目标路径被目录占位): 同样清理,不留 tmp
|
|
103
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
65
106
|
}
|
|
66
107
|
|
|
67
108
|
async function backup(p) {
|
|
68
109
|
try {
|
|
69
|
-
|
|
110
|
+
// 文件名含日期+时分秒: 曾经只有日期,同日多次安装/卸载会互相覆盖,
|
|
111
|
+
// 只剩最后一份 —— 用户装坏了想回滚时,最早的可用副本已经不在了。
|
|
112
|
+
const ts = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
113
|
+
const bak = `${p}.abs-bak-${ts}`;
|
|
70
114
|
await fs.copyFile(p, bak);
|
|
115
|
+
await pruneBackups(p);
|
|
71
116
|
return bak;
|
|
72
117
|
} catch { return null; } // 文件不存在则无备份
|
|
73
118
|
}
|
|
74
119
|
|
|
75
|
-
|
|
76
|
-
|
|
120
|
+
/** 只保留最近 KEEP 份备份(按文件名时间戳倒序),避免备份目录无限增长。 */
|
|
121
|
+
const BACKUP_KEEP = 5;
|
|
122
|
+
async function pruneBackups(p) {
|
|
123
|
+
try {
|
|
124
|
+
const dir = dirname(p);
|
|
125
|
+
const base = `${basename(p)}.abs-bak-`;
|
|
126
|
+
const olds = (await fs.readdir(dir))
|
|
127
|
+
.filter((f) => f.startsWith(base))
|
|
128
|
+
.sort() // 时间戳格式 YYYY-MM-DD-HH-mm-ss 字典序 == 时间序
|
|
129
|
+
.reverse();
|
|
130
|
+
for (const f of olds.slice(BACKUP_KEEP)) {
|
|
131
|
+
await fs.rm(join(dir, f), { force: true });
|
|
132
|
+
}
|
|
133
|
+
} catch { /* 清理失败不影响主流程 */ }
|
|
77
134
|
}
|
|
78
135
|
|
|
79
|
-
|
|
80
|
-
|
|
136
|
+
async function readJson(p, { strict = true } = {}) {
|
|
137
|
+
let text;
|
|
138
|
+
try {
|
|
139
|
+
text = await fs.readFile(p, 'utf8');
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (e.code === 'ENOENT') return {}; // 文件不存在 = 空配置(正常首装)
|
|
142
|
+
throw e; // 权限/IO 错不能静默吞
|
|
143
|
+
}
|
|
144
|
+
if (!text.trim()) return {};
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(text);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
if (!strict) return null; // 宽松模式: 调用方据此跳过写文件,但仍继续其它清理
|
|
149
|
+
// 关键: 「文件不存在」与「存在但解析失败」必须分开处理。
|
|
150
|
+
// 曾经两者都返回 {},于是只要用户的配置里有 JSONC 注释/尾逗号/多一个字符,
|
|
151
|
+
// 就会被当作空对象重建 → 用户的 hooks/permissions/model/mcpServers 静默全消失。
|
|
152
|
+
// 而 JSONC(带 // 注释)正是 CLaude Code 官方文档鼓励的写法,命中率不低。
|
|
153
|
+
// 测例: 带注释的 settings.json 安装后 myKey/permissions 全丢,且无任何报错。
|
|
154
|
+
// 处置: 宁可整个安装失败,也不写坏用户文件(与 requireBrain 同原则)。
|
|
155
|
+
// 宽松模式(strict:false)供"卸载"使用: 跳过配置文件、但仍清理 abs 自己的脚本/skill,
|
|
156
|
+
// 避免因一个坏配置就留下残余。
|
|
157
|
+
throw new Error(
|
|
158
|
+
`配置文件无法解析为 JSON,已中止以免覆盖你的配置:\n` +
|
|
159
|
+
` ${p}\n` +
|
|
160
|
+
` ${e.message}\n` +
|
|
161
|
+
` 若文件含 // 注释或尾逗号(JSONC),请先转成标准 JSON;` +
|
|
162
|
+
`或备份后移走该文件重跑。`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 判断一个 hook 数组元素是不是 abs 装的(用于卸载只删自己、安装去重)。
|
|
169
|
+
*
|
|
170
|
+
* 修复: 曾经用 command.includes('/.abs/hooks/') —— 子串匹配。任何「命令文本里恰好
|
|
171
|
+
* 出现该片段」的用户 hook 都会被误判为 abs 装的,于是卸载时删掉、安装时静默丢弃。
|
|
172
|
+
* 实测真例(都被误删):
|
|
173
|
+
* grep -r try /home/u/.abs/hooks/ > /tmp/report
|
|
174
|
+
* tar czf /tmp/b.tgz /home/u/.abs/hooks/ && echo done
|
|
175
|
+
* 现在改为精确匹配本工具实际写入的路径前缀: <homedir>/.abs/hooks/<agentKey>/abs-
|
|
176
|
+
* (stageHookScripts 生成的脚本名统一是 abs-<Event>.sh)。
|
|
177
|
+
*/
|
|
178
|
+
function absHookDir(agentKey) {
|
|
179
|
+
return join(homedir(), '.abs', 'hooks', agentKey);
|
|
180
|
+
}
|
|
181
|
+
/** 本工具会生成的 hook 脚本名(与 stageHookScripts 的 abs-<Event>.sh 一致)。 */
|
|
182
|
+
const ABS_SCRIPT_NAMES = new Set(
|
|
183
|
+
[...new Set(HOSTS.flatMap((h) => h.events))].map((ev) => `abs-${ev}.sh`),
|
|
184
|
+
);
|
|
185
|
+
function isAbsStagedCommand(cmd) {
|
|
186
|
+
if (typeof cmd !== 'string') return false;
|
|
187
|
+
// 逐个路径 token 比对: 必须是 <…>/.abs/hooks/<宿名>/<本工具生成的脚本名>。
|
|
188
|
+
// 不用"任意 abs-*"——那会把用户自己的 abs-mine.sh 也判成我们的(实测边界)。
|
|
189
|
+
const tokens = cmd.match(/\S+/g) || [];
|
|
190
|
+
return tokens.some((raw) => {
|
|
191
|
+
const t = raw.replace(/^['"]+|['"]+$/g, '');
|
|
192
|
+
const m = t.match(/(?:^|\/)\.abs\/hooks\/([^/]+)\/([^/]+)$/);
|
|
193
|
+
return !!m && ABS_SCRIPT_NAMES.has(m[2]);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
81
196
|
function entryHasAbs(entry) {
|
|
82
197
|
const hs = entry && entry.hooks ? (Array.isArray(entry.hooks) ? entry.hooks : [entry.hooks]) : [];
|
|
83
|
-
|
|
198
|
+
const cmds = [];
|
|
199
|
+
for (const h of hs) if (h && typeof h.command === 'string') cmds.push(h.command);
|
|
200
|
+
// 兼容扁平形态 { command } (无 hooks 字段)
|
|
201
|
+
if (entry && typeof entry.command === 'string') cmds.push(entry.command);
|
|
202
|
+
return cmds.some(isAbsStagedCommand);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================ TOML section 行级工具 ============================
|
|
206
|
+
// 为什么不用正则: 曾用 /\n?\[mcp_servers\.abs\][^\[]*/s 删 section,
|
|
207
|
+
// 而 `[^\[]*` 会在下一个 `[` 处停下 —— `args = ["/x/mcp.js"]` 的数组左括号就是 `[`。
|
|
208
|
+
// 结果卸载后把数组值原地截成活一个假 section 头,留下非法行 `["/…/mcp.js"]`,
|
|
209
|
+
// 用户 codex 启动时 TOML 解析直接失败(卸载却给用户留个坏配置)。
|
|
210
|
+
// 另一坑: 用 includes('[mcp_servers.abs]') 判"已存在"不区分注释 ——
|
|
211
|
+
// 用户配置里一句 `# 例: [mcp_servers.abs]` 就让安装器报"已存在且路径正确, 跳过",
|
|
212
|
+
// 实际从未注册(静默失效,且重装永不修复)。
|
|
213
|
+
// 故统一用行级扫描: section 头必须锚定行首、非注释;section 体到下一个 section 头为止。
|
|
214
|
+
const TOML_ABS_SECTION = '[mcp_servers.abs]';
|
|
215
|
+
|
|
216
|
+
/** 某行是否为 section 头(含数组表格 [[x]])。 */
|
|
217
|
+
function isTomlSectionHeader(line) {
|
|
218
|
+
return line.trimStart().startsWith('[');
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 找出 [mcp_servers.abs] 真实 section 的 [start, end) 行下标;无则 null。
|
|
222
|
+
* 注释行(# 开头)不算 —— 这是修复 H1(注释导致假装成功)的关键。 */
|
|
223
|
+
function findTomlAbsSection(lines) {
|
|
224
|
+
const start = lines.findIndex((l) => {
|
|
225
|
+
const t = l.trimStart();
|
|
226
|
+
return !t.startsWith('#') && t.trim() === TOML_ABS_SECTION;
|
|
227
|
+
});
|
|
228
|
+
if (start === -1) return null;
|
|
229
|
+
let end = start + 1;
|
|
230
|
+
while (end < lines.length && !isTomlSectionHeader(lines[end])) end++;
|
|
231
|
+
return { start, end };
|
|
232
|
+
}
|
|
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
|
+
|
|
255
|
+
/** 删除 [mcp_servers.abs] section(含其 body);无则原样返回。
|
|
256
|
+
* @returns {{ text: string, removed: boolean }} */
|
|
257
|
+
function removeTomlAbsSection(text) {
|
|
258
|
+
const lines = String(text || '').split('\n');
|
|
259
|
+
const sec = findTomlAbsSection(lines);
|
|
260
|
+
if (!sec) return { text, removed: false };
|
|
261
|
+
// 一并去掉 section 前的多余空行,避免留下连续空行
|
|
262
|
+
let from = sec.start;
|
|
263
|
+
while (from > 0 && lines[from - 1].trim() === '') from--;
|
|
264
|
+
const out = [...lines.slice(0, from), ...lines.slice(sec.end)];
|
|
265
|
+
return { text: out.join('\n').replace(/\n{3,}/g, '\n\n'), removed: true };
|
|
84
266
|
}
|
|
85
267
|
|
|
86
268
|
// ============================ Hook 脚本落盘 ============================
|
|
@@ -93,7 +275,7 @@ async function stageHookScripts(agentKey, events) {
|
|
|
93
275
|
const name = `abs-${ev}.sh`;
|
|
94
276
|
const p = join(dir, name);
|
|
95
277
|
const script = tpl
|
|
96
|
-
.replaceAll('__ABS_BIN__',
|
|
278
|
+
.replaceAll('__ABS_BIN__', stableBinPath('abs.js'))
|
|
97
279
|
.replaceAll('__NODE_BIN__', process.execPath)
|
|
98
280
|
.replaceAll('__EVENT__', ev);
|
|
99
281
|
await atomicWrite(p, script);
|
|
@@ -108,6 +290,142 @@ function claudeSettingsPath() {
|
|
|
108
290
|
return join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'settings.json');
|
|
109
291
|
}
|
|
110
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
|
+
|
|
111
429
|
async function installClaudeCode({ withMcp, withSkill, log }) {
|
|
112
430
|
const steps = [];
|
|
113
431
|
// 1) hooks → settings.json (分区合并: 同事件可挂多个 hook 框架, 追加 abs 而非覆盖, 保留 moshi-hook 等)
|
|
@@ -147,9 +465,13 @@ async function installClaudeCode({ withMcp, withSkill, log }) {
|
|
|
147
465
|
async function uninstallClaudeCode() {
|
|
148
466
|
const steps = [];
|
|
149
467
|
const settingsP = claudeSettingsPath();
|
|
150
|
-
const settings = await readJson(settingsP);
|
|
468
|
+
const settings = await readJson(settingsP, { strict: false });
|
|
469
|
+
if (settings === null) {
|
|
470
|
+
// 配置解析失败: 跳过该文件(绝不覆盖),但仍继续清理 abs 自己的脚本/skill。
|
|
471
|
+
steps.push(`⚠ ${settingsP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
472
|
+
}
|
|
151
473
|
let touched = false;
|
|
152
|
-
if (settings.hooks) {
|
|
474
|
+
if (settings && settings.hooks) {
|
|
153
475
|
const events = HOSTS[0].events;
|
|
154
476
|
for (const ev of events) {
|
|
155
477
|
if (!Array.isArray(settings.hooks[ev])) continue;
|
|
@@ -161,11 +483,11 @@ async function uninstallClaudeCode() {
|
|
|
161
483
|
}
|
|
162
484
|
}
|
|
163
485
|
}
|
|
164
|
-
if (settings.mcpServers && settings.mcpServers.abs) {
|
|
486
|
+
if (settings && settings.mcpServers && settings.mcpServers.abs) {
|
|
165
487
|
delete settings.mcpServers.abs; touched = true;
|
|
166
488
|
}
|
|
167
489
|
if (touched) await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
|
|
168
|
-
steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
|
|
490
|
+
if (settings) steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
|
|
169
491
|
// staged hook 脚本目录 —— 只删本 agent 的,绝不整删 ~/.abs/(其它 agent 的 hook / mcp.log 共存)
|
|
170
492
|
await fs.rm(join(homedir(), '.abs', 'hooks', 'claude-code'), { recursive: true, force: true });
|
|
171
493
|
steps.push(`✓ ~/.abs/hooks/claude-code/ (本 agent hook 脚本) 已删除`);
|
|
@@ -208,21 +530,46 @@ async function installCodex({ withMcp, withSkill, log }) {
|
|
|
208
530
|
const mcpP = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml');
|
|
209
531
|
let text = '';
|
|
210
532
|
try { text = await fs.readFile(mcpP, 'utf8'); } catch {}
|
|
211
|
-
|
|
533
|
+
const found = findTomlAbsSection(text.split('\n'));
|
|
534
|
+
if (!found) {
|
|
535
|
+
// 必须用 findTomlAbsSection 而非 includes —— 注释里的同名文本不算已注册(H1)。
|
|
212
536
|
const block = `\n[mcp_servers.abs]\ncommand = "${process.execPath}"\nargs = ["${mcpEntryPath()}"]\n`;
|
|
213
537
|
await backup(mcpP);
|
|
214
538
|
await atomicWrite(mcpP, text.replace(/\s*$/, '') + '\n' + block);
|
|
215
539
|
steps.push(`✓ MCP → ${mcpP} [mcp_servers.abs]`);
|
|
216
540
|
} else {
|
|
217
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。
|
|
218
549
|
const want = mcpEntryPath();
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
550
|
+
const lines = text.split('\n');
|
|
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}"]`);
|
|
559
|
+
await backup(mcpP);
|
|
560
|
+
await atomicWrite(mcpP, lines.join('\n'));
|
|
561
|
+
steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
|
|
562
|
+
}
|
|
224
563
|
} else {
|
|
225
|
-
|
|
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}`);
|
|
226
573
|
}
|
|
227
574
|
}
|
|
228
575
|
}
|
|
@@ -238,13 +585,16 @@ async function uninstallCodex() {
|
|
|
238
585
|
const steps = [];
|
|
239
586
|
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
240
587
|
const p = join(home, 'hooks.json');
|
|
241
|
-
const cfg = await readJson(p);
|
|
588
|
+
const cfg = await readJson(p, { strict: false });
|
|
589
|
+
if (cfg === null) {
|
|
590
|
+
steps.push(`⚠ ${p} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
591
|
+
}
|
|
242
592
|
let changed = false;
|
|
243
|
-
if (Array.isArray(cfg.hooks)) {
|
|
593
|
+
if (cfg && Array.isArray(cfg.hooks)) {
|
|
244
594
|
const before = cfg.hooks.length;
|
|
245
|
-
cfg.hooks = cfg.hooks.filter((h) => !String(h.command || '')
|
|
595
|
+
cfg.hooks = cfg.hooks.filter((h) => !isAbsStagedCommand(String(h.command || '')));
|
|
246
596
|
changed = cfg.hooks.length !== before;
|
|
247
|
-
} else if (cfg.hooks && typeof cfg.hooks === 'object') {
|
|
597
|
+
} else if (cfg && cfg.hooks && typeof cfg.hooks === 'object') {
|
|
248
598
|
for (const ev of Object.keys(cfg.hooks)) {
|
|
249
599
|
const arr = cfg.hooks[ev];
|
|
250
600
|
if (!Array.isArray(arr)) continue;
|
|
@@ -263,9 +613,11 @@ async function uninstallCodex() {
|
|
|
263
613
|
const mcpP = join(home, 'config.toml');
|
|
264
614
|
try {
|
|
265
615
|
const text = await fs.readFile(mcpP, 'utf8');
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
616
|
+
// 用行级扫描而非正则: 正则会在 args = [...] 的 [ 处截断,
|
|
617
|
+
// 留下非法行 ["/…/mcp.js"] 让用户的 TOML 解析失败。
|
|
618
|
+
const { text: after, removed } = removeTomlAbsSection(text);
|
|
619
|
+
if (removed) {
|
|
620
|
+
await atomicWrite(mcpP, after);
|
|
269
621
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
270
622
|
}
|
|
271
623
|
} catch {}
|
|
@@ -274,128 +626,53 @@ async function uninstallCodex() {
|
|
|
274
626
|
return steps;
|
|
275
627
|
}
|
|
276
628
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
* 纪律: hook 事件只进技术日志, 不进图谱 log.md (log.md 只收工作成果沉淀, 与 event.sh 同纪律)。
|
|
289
|
-
* Opencode 事件: session.created / session.idle / session.deleted (idle = 每轮结束, 对应 pi 的 agent_end)。
|
|
290
|
-
* 收尾注入: session.idle 且本会话真改过文件 + .brain 今日无记录 → promptAsync 推 agent 走收尾循环。
|
|
629
|
+
/**
|
|
630
|
+
* opencode 路径歧义提示(返回一行警告文本,无歧义时返回 null)。
|
|
631
|
+
*
|
|
632
|
+
* 背景: opencode 的全局配置默认在 ~/.config/opencode/,但它同样尊重
|
|
633
|
+
* XDG_CONFIG_HOME(官方支持 XDG 规范)。若用户设了该变量,opencode 会去
|
|
634
|
+
* $XDG_CONFIG_HOME/opencode/ 读配置,而本工具默认仍写 ~/.config/opencode/ ——
|
|
635
|
+
* 于是插件装了却不生效(静默失效,最难查)。同理 OPENCODE_CONFIG 可覆盖配置文件路径。
|
|
636
|
+
*
|
|
637
|
+
* 为什么只警告不自动跟随: ①跟随会让"装到哪"依赖环境变量,变得不可预测;
|
|
638
|
+
* ②若 opencode 实际没读该变量,跟着写反而错。故只提示,把决定权留给用户 ——
|
|
639
|
+
* 用户可用 ABS_OPENCODE_HOME 显式指定目标。
|
|
291
640
|
*/
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
async function logHook(evt) {
|
|
303
|
-
try {
|
|
304
|
-
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
305
|
-
const d = new Date()
|
|
306
|
-
const pad = (n) => String(n).padStart(2, "0")
|
|
307
|
-
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
308
|
-
await mkdir(dir, { recursive: true })
|
|
309
|
-
await appendFile(join(dir, "hooks.log"), "[" + stamp + "] opencode:" + evt + "\\n")
|
|
310
|
-
} catch {} // fire-and-forget: 永不阻塞宿主
|
|
641
|
+
function opencodePathWarning() {
|
|
642
|
+
// 本工具实际会写入的位置(ABS_OPENCODE_HOME 覆盖时即该值)
|
|
643
|
+
const ours = hostConfigRoot("opencode");
|
|
644
|
+
const hints = [];
|
|
645
|
+
|
|
646
|
+
// opencode 自身可能读取的位置。它支持 XDG 规范,故 XDG_CONFIG_HOME 优先于 ~/.config。
|
|
647
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
648
|
+
const opencodeWillRead = xdg ? join(xdg, "opencode") : null;
|
|
649
|
+
if (opencodeWillRead && resolve(opencodeWillRead) !== resolve(ours)) {
|
|
650
|
+
hints.push(`XDG_CONFIG_HOME=${xdg} → opencode 会读 ${opencodeWillRead},但本工具写的是 ${ours}`);
|
|
311
651
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
async function findBrain(cwd) {
|
|
315
|
-
const d = cwd || process.cwd()
|
|
316
|
-
try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
|
|
652
|
+
if (process.env.OPENCODE_CONFIG) {
|
|
653
|
+
hints.push(`OPENCODE_CONFIG=${process.env.OPENCODE_CONFIG} 会覆盖全局配置路径(优先级高于上面两者)`);
|
|
317
654
|
}
|
|
318
655
|
|
|
319
|
-
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const TEARDOWN_MSG =
|
|
333
|
-
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\\n" +
|
|
334
|
-
"1) 跑 abs load 看 Today 还有哪些未完成;\\n" +
|
|
335
|
-
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \\"断点\\";\\n" +
|
|
336
|
-
"3) 值得留的经验 abs note \\"...\\"(宁少勿滥,能从代码 grep 到的不记);\\n" +
|
|
337
|
-
"4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
|
|
338
|
-
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。"
|
|
339
|
-
|
|
340
|
-
// bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
|
|
341
|
-
const READONLY_CMD = /^\\s*(ls|cat|grep|rg|find|head|tail|wc|git\\s+(status|log|diff|show|branch)|pwd|which|echo|node\\s+-v|npm\\s+(ls|view)|curl)\\b/
|
|
342
|
-
|
|
343
|
-
return {
|
|
344
|
-
// 观测真实写操作: write/edit/patch 类工具成功即标记。
|
|
345
|
-
// 坑: 曾漏掉 bash —— opencode 里很多修改是经 bash(heredoc/sed) 完成的, 纯 bash 会话
|
|
346
|
-
// 永远不置位 wroteFiles, 于是收尾提醒静默不发。与 pi 侧 WRITE_TOOLS 保持一致。
|
|
347
|
-
"tool.execute.after": async ({ tool, args }) => {
|
|
348
|
-
const t = String(tool || "").toLowerCase()
|
|
349
|
-
if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) {
|
|
350
|
-
wroteFiles = true
|
|
351
|
-
return
|
|
352
|
-
}
|
|
353
|
-
// bash 里只有非只读命令算改文件(ls/cat/git status 之类不算)
|
|
354
|
-
if (t === "bash") {
|
|
355
|
-
const cmd = String((args && (args.command || args.cmd)) || "")
|
|
356
|
-
if (cmd && !READONLY_CMD.test(cmd)) wroteFiles = true
|
|
357
|
-
}
|
|
358
|
-
},
|
|
359
|
-
|
|
360
|
-
event: async ({ event }) => {
|
|
361
|
-
const type = event && event.type
|
|
362
|
-
if (!type) return
|
|
363
|
-
if (type === "session.created" || type === "session.deleted") {
|
|
364
|
-
await logHook(type)
|
|
365
|
-
return
|
|
366
|
-
}
|
|
367
|
-
if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵), 只做收尾判定
|
|
368
|
-
const cwd = directory || process.cwd()
|
|
369
|
-
const brain = await findBrain(cwd)
|
|
370
|
-
// 可观测性: 首次 idle 无条件留一行痕(否则无法区分“事件没触发”与“被守卫拦下”)
|
|
371
|
-
if (!idleSeen) {
|
|
372
|
-
idleSeen = true
|
|
373
|
-
await logHook(\`session.idle:seen cwd=\${cwd} brain=\${brain || 'none'}\`)
|
|
374
|
-
}
|
|
375
|
-
if (nudged || !wroteFiles) return
|
|
376
|
-
const sessionID = event.properties && event.properties.sessionID
|
|
377
|
-
if (!sessionID) return
|
|
378
|
-
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
379
|
-
if (await loggedToday(brain)) return // 今天已收尾过
|
|
380
|
-
nudged = true
|
|
381
|
-
await logHook("session.idle:teardown-nudge")
|
|
382
|
-
try {
|
|
383
|
-
await client.session.promptAsync({
|
|
384
|
-
path: { id: sessionID },
|
|
385
|
-
query: { directory: cwd },
|
|
386
|
-
body: { parts: [{ type: "text", text: TEARDOWN_MSG }] },
|
|
387
|
-
})
|
|
388
|
-
} catch {} // 注入失败不阻塞宿主
|
|
389
|
-
},
|
|
390
|
-
}
|
|
656
|
+
if (!hints.length) return null;
|
|
657
|
+
// 修复: 曾经 ABS_OPENCODE_HOME 一被设置就无条件 return null —— 于是即使用户
|
|
658
|
+
// 显式指定的目标与 opencode 实际会读的位置也不一致,警告仍完全静默。
|
|
659
|
+
// 现在改为「只比较路径是否一致」: 一致就静默(无歧义),不一致就提示,
|
|
660
|
+
// 无论目标来自默认值还是显式 env。
|
|
661
|
+
const via = process.env.ABS_OPENCODE_HOME ? "(目标由 ABS_OPENCODE_HOME 显式指定)" : "";
|
|
662
|
+
return [
|
|
663
|
+
"⚠ opencode 配置路径可能不一致(已装的插件可能不生效):" + via,
|
|
664
|
+
...hints.map((h) => ` - ${h}`),
|
|
665
|
+
" 如需指定目标: ABS_OPENCODE_HOME=<配置根> abs install --agent opencode",
|
|
666
|
+
].join("\n");
|
|
391
667
|
}
|
|
392
668
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
669
|
+
// ============================ Opencode / Pi (TS 插件) ============================
|
|
670
|
+
function opencodePluginSource() {
|
|
671
|
+
// 模板已外置到 hooks/abs.opencode.ts(真文件,非字符串)。
|
|
672
|
+
// 为什么外置: ①改一处逻辑不用在模板里再改一遍(曾漏改导致行为不一致)
|
|
673
|
+
// ②生成产物能被测试直接 import 断言, 不再解析字符串产物(间接测试的税)
|
|
674
|
+
// 转义坑已消除: 以前模板嵌在模板字符串里,\n / \" / \` 双层转义极易写错(代码注释亦踩过)。
|
|
675
|
+
return renderPluginTemplate('abs.opencode.ts', { '@@MARK@@': MARK });
|
|
399
676
|
}
|
|
400
677
|
|
|
401
678
|
// ============================ Pi (TS extension) ============================
|
|
@@ -403,137 +680,29 @@ ${MARK}
|
|
|
403
680
|
// 不能复用 opencodePluginSource 的写法(那是 export const ... = ({ project }) => ...)。
|
|
404
681
|
// 两者同纪律: 生命周期事件只进技术日志 hooks.log, 不进图谱 log.md。
|
|
405
682
|
function piPluginSource() {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
* session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
|
|
412
|
-
* agent_end 收尾注入: 本会话真改过文件 + .brain 今日无记录 → 注入一条收尾指令, 逼 agent 走收尾循环
|
|
413
|
-
* (被动记日志不够 —— 没人提醒就不会有人收尾)。每会话最多一次, 且已收尾后不再打扰。
|
|
414
|
-
*/
|
|
415
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
416
|
-
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
417
|
-
import { homedir } from "node:os"
|
|
418
|
-
import { join, dirname } from "node:path"
|
|
419
|
-
import { spawn } from "node:child_process"
|
|
420
|
-
|
|
421
|
-
const ABS_BIN = "${join(ABS_DIR, 'bin', 'abs.js')}"
|
|
422
|
-
|
|
423
|
-
async function logHook(evt: string): Promise<void> {
|
|
424
|
-
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
425
|
-
const d = new Date()
|
|
426
|
-
const pad = (n: number) => String(n).padStart(2, "0")
|
|
427
|
-
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
428
|
-
const line = "[" + stamp + "] pi:" + evt + "\\n"
|
|
429
|
-
await mkdir(dir, { recursive: true })
|
|
430
|
-
await appendFile(join(dir, "hooks.log"), line)
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
|
|
434
|
-
// cwd 用事件 ctx.cwd(当前项目),让 abs 在该目录定位 .brain/。
|
|
435
|
-
function snapshotWrapup(cwd: string): void {
|
|
436
|
-
try {
|
|
437
|
-
const child = spawn(process.execPath, [ABS_BIN, "wrapup"], {
|
|
438
|
-
cwd: cwd || process.cwd(), stdio: "ignore", detached: true,
|
|
439
|
-
})
|
|
440
|
-
child.unref()
|
|
441
|
-
} catch {} // fire-and-forget
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// ---- 收尾注入判定 ----
|
|
445
|
-
// 真改过文件的工具(read/grep/ls 不算, 那些不产生可沉淀的产出)
|
|
446
|
-
const WRITE_TOOLS = new Set(["write", "edit", "multi_edit", "apply_patch", "bash"])
|
|
447
|
-
// bash 里只跑查询类命令不算改文件
|
|
448
|
-
const READONLY_CMD = /^\\s*(ls|cat|grep|rg|find|head|tail|wc|git\\s+(status|log|diff|show|branch)|pwd|which|echo|node\\s+-v|npm\\s+(ls|view)|curl)\\b/
|
|
449
|
-
|
|
450
|
-
function hasWriteWork(toolResults: any[]): boolean {
|
|
451
|
-
for (const r of toolResults || []) {
|
|
452
|
-
const name = String(r?.toolName || "")
|
|
453
|
-
if (!WRITE_TOOLS.has(name)) continue
|
|
454
|
-
if (r?.isError) continue
|
|
455
|
-
if (name === "bash") {
|
|
456
|
-
const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
|
|
457
|
-
if (READONLY_CMD.test(cmd)) continue
|
|
458
|
-
}
|
|
459
|
-
return true
|
|
460
|
-
}
|
|
461
|
-
return false
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
/** 定位当前项目的 .brain/:只看 cwd 本身,不向上搜索(防爬到家目录图谱)。 */
|
|
465
|
-
async function findBrain(cwd: string): Promise<string | null> {
|
|
466
|
-
const d = cwd || process.cwd()
|
|
467
|
-
try { const st = await stat(join(d, ".brain")); return st.isDirectory() ? join(d, ".brain") : null } catch { return null }
|
|
683
|
+
// 模板已外置到 hooks/abs.pi.ts。理由同 opencodePluginSource。
|
|
684
|
+
return renderPluginTemplate('abs.pi.ts', {
|
|
685
|
+
'@@ABS_BIN@@': stableBinPath('abs.js'),
|
|
686
|
+
'@@MARK@@': MARK,
|
|
687
|
+
});
|
|
468
688
|
}
|
|
469
689
|
|
|
470
690
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
691
|
+
* 读 hooks/ 下的插件模板(真文件,非字符串)并替换 @@占位符@@。
|
|
692
|
+
* 为什么用占位符而非模板字符串: 模板本身是 TS 代码,含 \n / " / \` 等;
|
|
693
|
+
* 以前嵌在模板字符串里需双层转义,极易写错且只能靠“生成的字符串”间接测。
|
|
694
|
+
* 现在模板是真文件 → 可用真 TS 编辑器/测试直接 import 断言。
|
|
695
|
+
* 找不到模板文件时抛错(宁可失败也不静默生成空插件)。
|
|
474
696
|
*/
|
|
475
|
-
|
|
697
|
+
function renderPluginTemplate(name, vars) {
|
|
698
|
+
let t;
|
|
476
699
|
try {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
481
|
-
// 注意: 这里是真实 TS, 不是生成代码的模板字符串。用拼接形式避免反斜杠逃逸歧义。
|
|
482
|
-
return new RegExp("^## \\\\[" + today + " \\\\d{2}:\\\\d{2}\\\\]", "m").test(txt)
|
|
483
|
-
} catch { return false }
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
export default function absPiHook(pi: ExtensionAPI): void {
|
|
487
|
-
pi.on("session_start", () => logHook("session_start").catch(() => {}))
|
|
488
|
-
|
|
489
|
-
// 收尾注入: 每个会话最多一次, 避免反复打扰。
|
|
490
|
-
let teardownNudged = false
|
|
491
|
-
let agentEndSeen = false
|
|
492
|
-
pi.on("agent_end", async (event: any, ctx: any) => {
|
|
493
|
-
if (teardownNudged) return
|
|
494
|
-
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
495
|
-
const brain = await findBrain(cwd)
|
|
496
|
-
// 可观测性: 本会话首次 agent_end 无条件留一行痕。
|
|
497
|
-
// 没有它就无法区分三种状态: ①事件没触发 ②触发了但被守卫拦下 ③真注入了。
|
|
498
|
-
if (!agentEndSeen) {
|
|
499
|
-
agentEndSeen = true
|
|
500
|
-
// 带上 cwd 与命中的 brain —— 否则事后无法解释“为何这条 nudge 会出现”
|
|
501
|
-
await logHook(\`agent_end:seen cwd=\${cwd} brain=\${brain || 'none'}\`).catch(() => {})
|
|
502
|
-
}
|
|
503
|
-
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
504
|
-
if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
|
|
505
|
-
if (await loggedToday(brain)) return // 今天已收尾过
|
|
506
|
-
teardownNudged = true
|
|
507
|
-
await logHook("agent_end:teardown-nudge").catch(() => {})
|
|
508
|
-
try {
|
|
509
|
-
pi.sendUserMessage(
|
|
510
|
-
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\\n" +
|
|
511
|
-
"1) 跑 abs load 看 Today 还有哪些未完成;\\n" +
|
|
512
|
-
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \\"断点\\";\\n" +
|
|
513
|
-
"3) 值得留的经验 abs note \\"...\\"(宁少勿滥,能从代码 grep 到的不记);\\n" +
|
|
514
|
-
"4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
|
|
515
|
-
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。",
|
|
516
|
-
{ deliverAs: "followUp" },
|
|
517
|
-
)
|
|
518
|
-
} catch {}
|
|
519
|
-
})
|
|
520
|
-
|
|
521
|
-
pi.on("session_shutdown", (_e: any, ctx: any) => {
|
|
522
|
-
snapshotWrapup((ctx && ctx.cwd) || process.cwd())
|
|
523
|
-
logHook("session_shutdown").catch(() => {})
|
|
524
|
-
})
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/** agent_end 的 event.messages 里翻出 toolResult 消息。 */
|
|
528
|
-
function collectToolResults(messages: any[]): any[] {
|
|
529
|
-
const out: any[] = []
|
|
530
|
-
for (const m of messages || []) {
|
|
531
|
-
if (m && m.role === "toolResult") out.push(m)
|
|
700
|
+
t = fsSync.readFileSync(join(ABS_DIR, 'hooks', name), 'utf8');
|
|
701
|
+
} catch (e) {
|
|
702
|
+
throw new Error(`插件模板缺失: hooks/${name}(安装包不完整?): ${e.message}`);
|
|
532
703
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
${MARK}
|
|
536
|
-
`;
|
|
704
|
+
for (const [k, v] of Object.entries(vars || {})) t = t.split(k).join(v);
|
|
705
|
+
return t;
|
|
537
706
|
}
|
|
538
707
|
|
|
539
708
|
async function installOpenCode({ withMcp, withSkill, log }) {
|
|
@@ -542,6 +711,8 @@ async function installOpenCode({ withMcp, withSkill, log }) {
|
|
|
542
711
|
const p = join(dir, 'abs.ts');
|
|
543
712
|
await atomicWrite(p, opencodePluginSource());
|
|
544
713
|
steps.push(`✓ hook(ts plugin) → ${p}`);
|
|
714
|
+
const warn = opencodePathWarning();
|
|
715
|
+
if (warn) steps.push(warn);
|
|
545
716
|
if (withMcp) {
|
|
546
717
|
const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
|
|
547
718
|
const cfg = await readJson(mcpP);
|
|
@@ -564,12 +735,18 @@ async function installOpenCode({ withMcp, withSkill, log }) {
|
|
|
564
735
|
|
|
565
736
|
async function uninstallOpenCode() {
|
|
566
737
|
const steps = [];
|
|
738
|
+
// 路径歧义提示(卸载侧同样有价值:可能提示用户去清另一个位置的旧副本)
|
|
739
|
+
const warn = opencodePathWarning();
|
|
740
|
+
if (warn) steps.push(warn);
|
|
567
741
|
const plugin = join(hostConfigRoot('opencode'), 'plugins', 'abs.ts');
|
|
568
742
|
await fs.rm(plugin, { force: true });
|
|
569
743
|
steps.push(`✓ plugin 已删除`);
|
|
570
744
|
const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
|
|
571
|
-
const cfg = await readJson(mcpP);
|
|
572
|
-
if (cfg
|
|
745
|
+
const cfg = await readJson(mcpP, { strict: false });
|
|
746
|
+
if (cfg === null) {
|
|
747
|
+
steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
748
|
+
}
|
|
749
|
+
if (cfg && cfg.mcp && cfg.mcp.abs) {
|
|
573
750
|
delete cfg.mcp.abs;
|
|
574
751
|
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
575
752
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
@@ -587,7 +764,7 @@ async function installPi({ withMcp, withSkill, log }) {
|
|
|
587
764
|
steps.push(`✓ hook(ts extension) → ${p}`);
|
|
588
765
|
// MCP → ~/.pi/agent/mcp.json 的 mcpServers (stdio)
|
|
589
766
|
// 曾经只打印「走 extension 内桥接」而没有任何桥接代码 —— 靠 mcp-adapter 的
|
|
590
|
-
// hostConfigDiscovery 间接读到
|
|
767
|
+
// hostConfigDiscovery 间接读到 claude 注册才"看起来能用"; 没有 claude 宿主的机器上直接缺失。
|
|
591
768
|
if (withMcp) {
|
|
592
769
|
const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp.json');
|
|
593
770
|
await backup(mcpP);
|
|
@@ -612,8 +789,11 @@ async function uninstallPi() {
|
|
|
612
789
|
await fs.rm(hostSkillDir('pi'), { recursive: true, force: true });
|
|
613
790
|
steps.push(`✓ skill 已删除`);
|
|
614
791
|
const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp.json');
|
|
615
|
-
const cfg = await readJson(mcpP);
|
|
616
|
-
if (cfg
|
|
792
|
+
const cfg = await readJson(mcpP, { strict: false });
|
|
793
|
+
if (cfg === null) {
|
|
794
|
+
steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
795
|
+
}
|
|
796
|
+
if (cfg && cfg.mcpServers && cfg.mcpServers.abs) {
|
|
617
797
|
delete cfg.mcpServers.abs;
|
|
618
798
|
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
619
799
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
@@ -634,15 +814,42 @@ export function installSummary() {
|
|
|
634
814
|
}
|
|
635
815
|
|
|
636
816
|
export async function runInstall({ agent, mcp = true, skill = true, yes = false } = {}) {
|
|
637
|
-
const targets = agent ? [agent] : await pickAgents();
|
|
817
|
+
const targets = agent ? [agent] : await pickAgents(yes);
|
|
818
|
+
const failed = [];
|
|
638
819
|
for (const key of targets) {
|
|
639
820
|
const inst = INSTALLERS[key];
|
|
640
821
|
if (!inst) throw new Error(`未知 agent: ${key} (可用: ${Object.keys(INSTALLERS).join(', ')})`);
|
|
641
822
|
console.log(`\n▸ 安装到 ${key} …`);
|
|
642
|
-
|
|
643
|
-
|
|
823
|
+
try {
|
|
824
|
+
for (const line of await inst.on({ withMcp: mcp, withSkill: skill })) {
|
|
825
|
+
console.log(' ' + line);
|
|
826
|
+
}
|
|
827
|
+
} catch (e) {
|
|
828
|
+
// 逐宿主容错: 一个宿主失败不应让其余宿主整体被跳过。
|
|
829
|
+
// 坑: 曾经任一处报错就直接上抛,于是前序宿主已装、后续宿主全未装 —— 半成品状态。
|
|
830
|
+
// 注意: 单宿主安装(agent 指定)时仍上抛,保持 CLI 非零退出语义。
|
|
831
|
+
failed.push({ key, msg: String(e && e.message ? e.message : e) });
|
|
832
|
+
console.error(` ✗ ${key} 安装失败: ${failed[failed.length - 1].msg}`);
|
|
833
|
+
if (agent) throw e;
|
|
644
834
|
}
|
|
645
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
|
+
}
|
|
844
|
+
if (failed.length) {
|
|
845
|
+
console.log(`\n⚠ ${failed.length} 个宿主安装失败: ${failed.map((f) => f.key).join(', ')}`);
|
|
846
|
+
console.log(' 其余宿主已完成。修复上述问题后重跑 `abs install`(幂等,不会重复写入)。');
|
|
847
|
+
throw new Error(`${failed.length} 个宿主安装失败(见上)`);
|
|
848
|
+
}
|
|
849
|
+
if (skill) {
|
|
850
|
+
const w = agentsSkillWarning();
|
|
851
|
+
if (w) console.log('\n' + w);
|
|
852
|
+
}
|
|
646
853
|
console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
|
|
647
854
|
}
|
|
648
855
|
|
|
@@ -654,12 +861,20 @@ export async function runUninstall({ agent, yes = false } = {}) {
|
|
|
654
861
|
console.log(`\n▸ 从 ${key} 卸载 …`);
|
|
655
862
|
for (const line of await inst.off()) console.log(' ' + line);
|
|
656
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 清理失败不该让卸载失败 */ }
|
|
657
869
|
console.log('\n卸载完成。');
|
|
658
870
|
}
|
|
659
871
|
|
|
660
872
|
// 交互式多选(无 TTY 时回退为全部)
|
|
661
|
-
async function pickAgents() {
|
|
662
|
-
|
|
873
|
+
async function pickAgents(yes = false) {
|
|
874
|
+
// --yes / 非 TTY: 直接全选,绝不弹交互。
|
|
875
|
+
// 坑: 曾经只看 isTTY,--yes 被收下却从不使用 —— 在 TTY 里跑
|
|
876
|
+
// abs install --yes(自动化/脚本)仍会弹提示并挂起等输入。
|
|
877
|
+
if (yes || !process.stdin.isTTY) return Object.keys(INSTALLERS);
|
|
663
878
|
const readline = await import('node:readline/promises');
|
|
664
879
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
665
880
|
console.log('选择要安装的智能体 (逗号分隔, 回车=全部):');
|