@fanchao8609/agent_brain_sync 1.5.4 → 1.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 +2 -2
- package/bin/abs.js +64 -6
- package/bin/mcp.js +12 -12
- package/hooks/abs.opencode.ts +113 -0
- package/hooks/abs.pi.ts +130 -0
- package/package.json +1 -1
- package/skill/SKILL.md +10 -5
- package/src/index.js +15 -15
- package/src/install.js +317 -302
- 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,159 @@ 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
|
+
/** 删除 [mcp_servers.abs] section(含其 body);无则原样返回。
|
|
235
|
+
* @returns {{ text: string, removed: boolean }} */
|
|
236
|
+
function removeTomlAbsSection(text) {
|
|
237
|
+
const lines = String(text || '').split('\n');
|
|
238
|
+
const sec = findTomlAbsSection(lines);
|
|
239
|
+
if (!sec) return { text, removed: false };
|
|
240
|
+
// 一并去掉 section 前的多余空行,避免留下连续空行
|
|
241
|
+
let from = sec.start;
|
|
242
|
+
while (from > 0 && lines[from - 1].trim() === '') from--;
|
|
243
|
+
const out = [...lines.slice(0, from), ...lines.slice(sec.end)];
|
|
244
|
+
return { text: out.join('\n').replace(/\n{3,}/g, '\n\n'), removed: true };
|
|
84
245
|
}
|
|
85
246
|
|
|
86
247
|
// ============================ Hook 脚本落盘 ============================
|
|
@@ -93,7 +254,7 @@ async function stageHookScripts(agentKey, events) {
|
|
|
93
254
|
const name = `abs-${ev}.sh`;
|
|
94
255
|
const p = join(dir, name);
|
|
95
256
|
const script = tpl
|
|
96
|
-
.replaceAll('__ABS_BIN__',
|
|
257
|
+
.replaceAll('__ABS_BIN__', stableBinPath('abs.js'))
|
|
97
258
|
.replaceAll('__NODE_BIN__', process.execPath)
|
|
98
259
|
.replaceAll('__EVENT__', ev);
|
|
99
260
|
await atomicWrite(p, script);
|
|
@@ -147,9 +308,13 @@ async function installClaudeCode({ withMcp, withSkill, log }) {
|
|
|
147
308
|
async function uninstallClaudeCode() {
|
|
148
309
|
const steps = [];
|
|
149
310
|
const settingsP = claudeSettingsPath();
|
|
150
|
-
const settings = await readJson(settingsP);
|
|
311
|
+
const settings = await readJson(settingsP, { strict: false });
|
|
312
|
+
if (settings === null) {
|
|
313
|
+
// 配置解析失败: 跳过该文件(绝不覆盖),但仍继续清理 abs 自己的脚本/skill。
|
|
314
|
+
steps.push(`⚠ ${settingsP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
315
|
+
}
|
|
151
316
|
let touched = false;
|
|
152
|
-
if (settings.hooks) {
|
|
317
|
+
if (settings && settings.hooks) {
|
|
153
318
|
const events = HOSTS[0].events;
|
|
154
319
|
for (const ev of events) {
|
|
155
320
|
if (!Array.isArray(settings.hooks[ev])) continue;
|
|
@@ -161,11 +326,11 @@ async function uninstallClaudeCode() {
|
|
|
161
326
|
}
|
|
162
327
|
}
|
|
163
328
|
}
|
|
164
|
-
if (settings.mcpServers && settings.mcpServers.abs) {
|
|
329
|
+
if (settings && settings.mcpServers && settings.mcpServers.abs) {
|
|
165
330
|
delete settings.mcpServers.abs; touched = true;
|
|
166
331
|
}
|
|
167
332
|
if (touched) await atomicWrite(settingsP, JSON.stringify(settings, null, 2));
|
|
168
|
-
steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
|
|
333
|
+
if (settings) steps.push(`✓ hooks/MCP 已从 ${settingsP} 移除`);
|
|
169
334
|
// staged hook 脚本目录 —— 只删本 agent 的,绝不整删 ~/.abs/(其它 agent 的 hook / mcp.log 共存)
|
|
170
335
|
await fs.rm(join(homedir(), '.abs', 'hooks', 'claude-code'), { recursive: true, force: true });
|
|
171
336
|
steps.push(`✓ ~/.abs/hooks/claude-code/ (本 agent hook 脚本) 已删除`);
|
|
@@ -208,7 +373,9 @@ async function installCodex({ withMcp, withSkill, log }) {
|
|
|
208
373
|
const mcpP = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'config.toml');
|
|
209
374
|
let text = '';
|
|
210
375
|
try { text = await fs.readFile(mcpP, 'utf8'); } catch {}
|
|
211
|
-
|
|
376
|
+
const found = findTomlAbsSection(text.split('\n'));
|
|
377
|
+
if (!found) {
|
|
378
|
+
// 必须用 findTomlAbsSection 而非 includes —— 注释里的同名文本不算已注册(H1)。
|
|
212
379
|
const block = `\n[mcp_servers.abs]\ncommand = "${process.execPath}"\nargs = ["${mcpEntryPath()}"]\n`;
|
|
213
380
|
await backup(mcpP);
|
|
214
381
|
await atomicWrite(mcpP, text.replace(/\s*$/, '') + '\n' + block);
|
|
@@ -216,13 +383,23 @@ async function installCodex({ withMcp, withSkill, log }) {
|
|
|
216
383
|
} else {
|
|
217
384
|
// 已存在也要校对路径: 旧版可能写入了仓库路径(不稳定) 或全局包已迁移。
|
|
218
385
|
const want = mcpEntryPath();
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
await atomicWrite(mcpP, text.replace(re, `$1"${want}"$2`));
|
|
223
|
-
steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
|
|
224
|
-
} else {
|
|
386
|
+
const lines = text.split('\n');
|
|
387
|
+
const body = lines.slice(found.start, found.end).join('\n');
|
|
388
|
+
if (body.includes(`"${want}"`)) {
|
|
225
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) {
|
|
397
|
+
await backup(mcpP);
|
|
398
|
+
await atomicWrite(mcpP, lines.join('\n'));
|
|
399
|
+
steps.push(`✓ MCP → ${mcpP} 路径已校正 → ${want}`);
|
|
400
|
+
} else {
|
|
401
|
+
steps.push(`• MCP → ${mcpP} 已注册但无 args 行, 未动(请手动确认)`);
|
|
402
|
+
}
|
|
226
403
|
}
|
|
227
404
|
}
|
|
228
405
|
}
|
|
@@ -238,13 +415,16 @@ async function uninstallCodex() {
|
|
|
238
415
|
const steps = [];
|
|
239
416
|
const home = process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
240
417
|
const p = join(home, 'hooks.json');
|
|
241
|
-
const cfg = await readJson(p);
|
|
418
|
+
const cfg = await readJson(p, { strict: false });
|
|
419
|
+
if (cfg === null) {
|
|
420
|
+
steps.push(`⚠ ${p} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
421
|
+
}
|
|
242
422
|
let changed = false;
|
|
243
|
-
if (Array.isArray(cfg.hooks)) {
|
|
423
|
+
if (cfg && Array.isArray(cfg.hooks)) {
|
|
244
424
|
const before = cfg.hooks.length;
|
|
245
|
-
cfg.hooks = cfg.hooks.filter((h) => !String(h.command || '')
|
|
425
|
+
cfg.hooks = cfg.hooks.filter((h) => !isAbsStagedCommand(String(h.command || '')));
|
|
246
426
|
changed = cfg.hooks.length !== before;
|
|
247
|
-
} else if (cfg.hooks && typeof cfg.hooks === 'object') {
|
|
427
|
+
} else if (cfg && cfg.hooks && typeof cfg.hooks === 'object') {
|
|
248
428
|
for (const ev of Object.keys(cfg.hooks)) {
|
|
249
429
|
const arr = cfg.hooks[ev];
|
|
250
430
|
if (!Array.isArray(arr)) continue;
|
|
@@ -263,9 +443,11 @@ async function uninstallCodex() {
|
|
|
263
443
|
const mcpP = join(home, 'config.toml');
|
|
264
444
|
try {
|
|
265
445
|
const text = await fs.readFile(mcpP, 'utf8');
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
446
|
+
// 用行级扫描而非正则: 正则会在 args = [...] 的 [ 处截断,
|
|
447
|
+
// 留下非法行 ["/…/mcp.js"] 让用户的 TOML 解析失败。
|
|
448
|
+
const { text: after, removed } = removeTomlAbsSection(text);
|
|
449
|
+
if (removed) {
|
|
450
|
+
await atomicWrite(mcpP, after);
|
|
269
451
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
270
452
|
}
|
|
271
453
|
} catch {}
|
|
@@ -274,133 +456,53 @@ async function uninstallCodex() {
|
|
|
274
456
|
return steps;
|
|
275
457
|
}
|
|
276
458
|
|
|
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 走收尾循环。
|
|
459
|
+
/**
|
|
460
|
+
* opencode 路径歧义提示(返回一行警告文本,无歧义时返回 null)。
|
|
461
|
+
*
|
|
462
|
+
* 背景: opencode 的全局配置默认在 ~/.config/opencode/,但它同样尊重
|
|
463
|
+
* XDG_CONFIG_HOME(官方支持 XDG 规范)。若用户设了该变量,opencode 会去
|
|
464
|
+
* $XDG_CONFIG_HOME/opencode/ 读配置,而本工具默认仍写 ~/.config/opencode/ ——
|
|
465
|
+
* 于是插件装了却不生效(静默失效,最难查)。同理 OPENCODE_CONFIG 可覆盖配置文件路径。
|
|
466
|
+
*
|
|
467
|
+
* 为什么只警告不自动跟随: ①跟随会让"装到哪"依赖环境变量,变得不可预测;
|
|
468
|
+
* ②若 opencode 实际没读该变量,跟着写反而错。故只提示,把决定权留给用户 ——
|
|
469
|
+
* 用户可用 ABS_OPENCODE_HOME 显式指定目标。
|
|
291
470
|
*/
|
|
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: 永不阻塞宿主
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// 从 cwd 向上找最近含 .brain/ 的祖先目录
|
|
314
|
-
async function findBrain(cwd) {
|
|
315
|
-
let d = cwd || process.cwd()
|
|
316
|
-
for (;;) {
|
|
317
|
-
try { const st = await stat(join(d, ".brain")); if (st.isDirectory()) return join(d, ".brain") } catch {}
|
|
318
|
-
const up = dirname(d)
|
|
319
|
-
if (up === d) return null
|
|
320
|
-
d = up
|
|
321
|
-
}
|
|
471
|
+
function opencodePathWarning() {
|
|
472
|
+
// 本工具实际会写入的位置(ABS_OPENCODE_HOME 覆盖时即该值)
|
|
473
|
+
const ours = hostConfigRoot("opencode");
|
|
474
|
+
const hints = [];
|
|
475
|
+
|
|
476
|
+
// opencode 自身可能读取的位置。它支持 XDG 规范,故 XDG_CONFIG_HOME 优先于 ~/.config。
|
|
477
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
478
|
+
const opencodeWillRead = xdg ? join(xdg, "opencode") : null;
|
|
479
|
+
if (opencodeWillRead && resolve(opencodeWillRead) !== resolve(ours)) {
|
|
480
|
+
hints.push(`XDG_CONFIG_HOME=${xdg} → opencode 会读 ${opencodeWillRead},但本工具写的是 ${ours}`);
|
|
322
481
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// 坑: 曾用裸日期 substring(includes('2026-09-10')), 结果正文/任务行里任何一处
|
|
326
|
-
// 提到今天就能把 nudge 永久压掉(误判为已收尾), 守卫形同虚设。
|
|
327
|
-
async function loggedToday(brain) {
|
|
328
|
-
try {
|
|
329
|
-
const txt = await readFile(join(brain, "log.md"), "utf8")
|
|
330
|
-
const d = new Date()
|
|
331
|
-
const pad = (n) => String(n).padStart(2, "0")
|
|
332
|
-
const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
333
|
-
return new RegExp("^## \\\\[" + today + " \\\\d{2}:\\\\d{2}\\\\]", "m").test(txt)
|
|
334
|
-
} catch { return false }
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const TEARDOWN_MSG =
|
|
338
|
-
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\\n" +
|
|
339
|
-
"1) 跑 abs load 看 Today 还有哪些未完成;\\n" +
|
|
340
|
-
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \\"断点\\";\\n" +
|
|
341
|
-
"3) 值得留的经验 abs note \\"...\\"(宁少勿滥,能从代码 grep 到的不记);\\n" +
|
|
342
|
-
"4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
|
|
343
|
-
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。"
|
|
344
|
-
|
|
345
|
-
// bash 里只跑查询类命令不算改文件 (与 pi 侧 READONLY_CMD 同义, 但生成代码里要写进模板串)
|
|
346
|
-
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/
|
|
347
|
-
|
|
348
|
-
return {
|
|
349
|
-
// 观测真实写操作: write/edit/patch 类工具成功即标记。
|
|
350
|
-
// 坑: 曾漏掉 bash —— opencode 里很多修改是经 bash(heredoc/sed) 完成的, 纯 bash 会话
|
|
351
|
-
// 永远不置位 wroteFiles, 于是收尾提醒静默不发。与 pi 侧 WRITE_TOOLS 保持一致。
|
|
352
|
-
"tool.execute.after": async ({ tool, args }) => {
|
|
353
|
-
const t = String(tool || "").toLowerCase()
|
|
354
|
-
if (["write", "edit", "patch", "multiedit", "apply_patch"].includes(t)) {
|
|
355
|
-
wroteFiles = true
|
|
356
|
-
return
|
|
357
|
-
}
|
|
358
|
-
// bash 里只有非只读命令算改文件(ls/cat/git status 之类不算)
|
|
359
|
-
if (t === "bash") {
|
|
360
|
-
const cmd = String((args && (args.command || args.cmd)) || "")
|
|
361
|
-
if (cmd && !READONLY_CMD.test(cmd)) wroteFiles = true
|
|
362
|
-
}
|
|
363
|
-
},
|
|
364
|
-
|
|
365
|
-
event: async ({ event }) => {
|
|
366
|
-
const type = event && event.type
|
|
367
|
-
if (!type) return
|
|
368
|
-
if (type === "session.created" || type === "session.deleted") {
|
|
369
|
-
await logHook(type)
|
|
370
|
-
return
|
|
371
|
-
}
|
|
372
|
-
if (type !== "session.idle") return // idle = 每轮结束, 不记日志(太吵), 只做收尾判定
|
|
373
|
-
const cwd = directory || process.cwd()
|
|
374
|
-
const brain = await findBrain(cwd)
|
|
375
|
-
// 可观测性: 首次 idle 无条件留一行痕(否则无法区分“事件没触发”与“被守卫拦下”)
|
|
376
|
-
if (!idleSeen) {
|
|
377
|
-
idleSeen = true
|
|
378
|
-
await logHook(\`session.idle:seen cwd=\${cwd} brain=\${brain || 'none'}\`)
|
|
379
|
-
}
|
|
380
|
-
if (nudged || !wroteFiles) return
|
|
381
|
-
const sessionID = event.properties && event.properties.sessionID
|
|
382
|
-
if (!sessionID) return
|
|
383
|
-
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
384
|
-
if (await loggedToday(brain)) return // 今天已收尾过
|
|
385
|
-
nudged = true
|
|
386
|
-
await logHook("session.idle:teardown-nudge")
|
|
387
|
-
try {
|
|
388
|
-
await client.session.promptAsync({
|
|
389
|
-
path: { id: sessionID },
|
|
390
|
-
query: { directory: cwd },
|
|
391
|
-
body: { parts: [{ type: "text", text: TEARDOWN_MSG }] },
|
|
392
|
-
})
|
|
393
|
-
} catch {} // 注入失败不阻塞宿主
|
|
394
|
-
},
|
|
482
|
+
if (process.env.OPENCODE_CONFIG) {
|
|
483
|
+
hints.push(`OPENCODE_CONFIG=${process.env.OPENCODE_CONFIG} 会覆盖全局配置路径(优先级高于上面两者)`);
|
|
395
484
|
}
|
|
396
|
-
}
|
|
397
485
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
486
|
+
if (!hints.length) return null;
|
|
487
|
+
// 修复: 曾经 ABS_OPENCODE_HOME 一被设置就无条件 return null —— 于是即使用户
|
|
488
|
+
// 显式指定的目标与 opencode 实际会读的位置也不一致,警告仍完全静默。
|
|
489
|
+
// 现在改为「只比较路径是否一致」: 一致就静默(无歧义),不一致就提示,
|
|
490
|
+
// 无论目标来自默认值还是显式 env。
|
|
491
|
+
const via = process.env.ABS_OPENCODE_HOME ? "(目标由 ABS_OPENCODE_HOME 显式指定)" : "";
|
|
492
|
+
return [
|
|
493
|
+
"⚠ opencode 配置路径可能不一致(已装的插件可能不生效):" + via,
|
|
494
|
+
...hints.map((h) => ` - ${h}`),
|
|
495
|
+
" 如需指定目标: ABS_OPENCODE_HOME=<配置根> abs install --agent opencode",
|
|
496
|
+
].join("\n");
|
|
401
497
|
}
|
|
402
|
-
|
|
403
|
-
|
|
498
|
+
|
|
499
|
+
// ============================ Opencode / Pi (TS 插件) ============================
|
|
500
|
+
function opencodePluginSource() {
|
|
501
|
+
// 模板已外置到 hooks/abs.opencode.ts(真文件,非字符串)。
|
|
502
|
+
// 为什么外置: ①改一处逻辑不用在模板里再改一遍(曾漏改导致行为不一致)
|
|
503
|
+
// ②生成产物能被测试直接 import 断言, 不再解析字符串产物(间接测试的税)
|
|
504
|
+
// 转义坑已消除: 以前模板嵌在模板字符串里,\n / \" / \` 双层转义极易写错(代码注释亦踩过)。
|
|
505
|
+
return renderPluginTemplate('abs.opencode.ts', { '@@MARK@@': MARK });
|
|
404
506
|
}
|
|
405
507
|
|
|
406
508
|
// ============================ Pi (TS extension) ============================
|
|
@@ -408,145 +510,29 @@ ${MARK}
|
|
|
408
510
|
// 不能复用 opencodePluginSource 的写法(那是 export const ... = ({ project }) => ...)。
|
|
409
511
|
// 两者同纪律: 生命周期事件只进技术日志 hooks.log, 不进图谱 log.md。
|
|
410
512
|
function piPluginSource() {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
* session_shutdown 额外触发 abs wrapup: 把当前项目未完成任务快照到 wrapup.log (跨会话收尾保险)。
|
|
417
|
-
* agent_end 收尾注入: 本会话真改过文件 + .brain 今日无记录 → 注入一条收尾指令, 逼 agent 走收尾循环
|
|
418
|
-
* (被动记日志不够 —— 没人提醒就不会有人收尾)。每会话最多一次, 且已收尾后不再打扰。
|
|
419
|
-
*/
|
|
420
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
421
|
-
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
422
|
-
import { homedir } from "node:os"
|
|
423
|
-
import { join, dirname } from "node:path"
|
|
424
|
-
import { spawn } from "node:child_process"
|
|
425
|
-
|
|
426
|
-
const ABS_BIN = "${join(ABS_DIR, 'bin', 'abs.js')}"
|
|
427
|
-
|
|
428
|
-
async function logHook(evt: string): Promise<void> {
|
|
429
|
-
const dir = process.env.ABS_LOG_DIR || join(homedir(), ".abs", "log")
|
|
430
|
-
const d = new Date()
|
|
431
|
-
const pad = (n: number) => String(n).padStart(2, "0")
|
|
432
|
-
const stamp = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()) + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
|
|
433
|
-
const line = "[" + stamp + "] pi:" + evt + "\\n"
|
|
434
|
-
await mkdir(dir, { recursive: true })
|
|
435
|
-
await appendFile(join(dir, "hooks.log"), line)
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// 会话结束时快照当前项目滞留任务到 wrapup.log(detached fire-and-forget,wrapup 自身幂等去重不刷屏)。
|
|
439
|
-
// cwd 用事件 ctx.cwd(当前项目),让 abs 从该目录向上定位 .brain/。
|
|
440
|
-
function snapshotWrapup(cwd: string): void {
|
|
441
|
-
try {
|
|
442
|
-
const child = spawn(process.execPath, [ABS_BIN, "wrapup"], {
|
|
443
|
-
cwd: cwd || process.cwd(), stdio: "ignore", detached: true,
|
|
444
|
-
})
|
|
445
|
-
child.unref()
|
|
446
|
-
} catch {} // fire-and-forget
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
// ---- 收尾注入判定 ----
|
|
450
|
-
// 真改过文件的工具(read/grep/ls 不算, 那些不产生可沉淀的产出)
|
|
451
|
-
const WRITE_TOOLS = new Set(["write", "edit", "multi_edit", "apply_patch", "bash"])
|
|
452
|
-
// bash 里只跑查询类命令不算改文件
|
|
453
|
-
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/
|
|
454
|
-
|
|
455
|
-
function hasWriteWork(toolResults: any[]): boolean {
|
|
456
|
-
for (const r of toolResults || []) {
|
|
457
|
-
const name = String(r?.toolName || "")
|
|
458
|
-
if (!WRITE_TOOLS.has(name)) continue
|
|
459
|
-
if (r?.isError) continue
|
|
460
|
-
if (name === "bash") {
|
|
461
|
-
const cmd = String(r?.input?.command ?? r?.args?.command ?? "")
|
|
462
|
-
if (READONLY_CMD.test(cmd)) continue
|
|
463
|
-
}
|
|
464
|
-
return true
|
|
465
|
-
}
|
|
466
|
-
return false
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/** 从 cwd 向上找最近含 .brain/ 的祖先目录。 */
|
|
470
|
-
async function findBrain(cwd: string): Promise<string | null> {
|
|
471
|
-
let d = cwd || process.cwd()
|
|
472
|
-
for (;;) {
|
|
473
|
-
try {
|
|
474
|
-
const st = await stat(join(d, ".brain"))
|
|
475
|
-
if (st.isDirectory()) return join(d, ".brain")
|
|
476
|
-
} catch {}
|
|
477
|
-
const up = dirname(d)
|
|
478
|
-
if (up === d) return null
|
|
479
|
-
d = up
|
|
480
|
-
}
|
|
513
|
+
// 模板已外置到 hooks/abs.pi.ts。理由同 opencodePluginSource。
|
|
514
|
+
return renderPluginTemplate('abs.pi.ts', {
|
|
515
|
+
'@@ABS_BIN@@': stableBinPath('abs.js'),
|
|
516
|
+
'@@MARK@@': MARK,
|
|
517
|
+
});
|
|
481
518
|
}
|
|
482
519
|
|
|
483
520
|
/**
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
521
|
+
* 读 hooks/ 下的插件模板(真文件,非字符串)并替换 @@占位符@@。
|
|
522
|
+
* 为什么用占位符而非模板字符串: 模板本身是 TS 代码,含 \n / " / \` 等;
|
|
523
|
+
* 以前嵌在模板字符串里需双层转义,极易写错且只能靠“生成的字符串”间接测。
|
|
524
|
+
* 现在模板是真文件 → 可用真 TS 编辑器/测试直接 import 断言。
|
|
525
|
+
* 找不到模板文件时抛错(宁可失败也不静默生成空插件)。
|
|
487
526
|
*/
|
|
488
|
-
|
|
527
|
+
function renderPluginTemplate(name, vars) {
|
|
528
|
+
let t;
|
|
489
529
|
try {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const today = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
|
494
|
-
// 注意: 这里是真实 TS, 不是生成代码的模板字符串。用拼接形式避免反斜杠逃逸歧义。
|
|
495
|
-
return new RegExp("^## \\\\[" + today + " \\\\d{2}:\\\\d{2}\\\\]", "m").test(txt)
|
|
496
|
-
} catch { return false }
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
export default function absPiHook(pi: ExtensionAPI): void {
|
|
500
|
-
pi.on("session_start", () => logHook("session_start").catch(() => {}))
|
|
501
|
-
|
|
502
|
-
// 收尾注入: 每个会话最多一次, 避免反复打扰。
|
|
503
|
-
let teardownNudged = false
|
|
504
|
-
let agentEndSeen = false
|
|
505
|
-
pi.on("agent_end", async (event: any, ctx: any) => {
|
|
506
|
-
if (teardownNudged) return
|
|
507
|
-
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
508
|
-
const brain = await findBrain(cwd)
|
|
509
|
-
// 可观测性: 本会话首次 agent_end 无条件留一行痕。
|
|
510
|
-
// 没有它就无法区分三种状态: ①事件没触发 ②触发了但被守卫拦下 ③真注入了。
|
|
511
|
-
if (!agentEndSeen) {
|
|
512
|
-
agentEndSeen = true
|
|
513
|
-
// 带上 cwd 与命中的 brain —— 否则事后无法解释“为何这条 nudge 会出现”
|
|
514
|
-
await logHook(\`agent_end:seen cwd=\${cwd} brain=\${brain || 'none'}\`).catch(() => {})
|
|
515
|
-
}
|
|
516
|
-
if (!brain) return // 无图谱=不在这项目沉淀, 不打扰
|
|
517
|
-
if (!hasWriteWork(event?.messages ? collectToolResults(event.messages) : [])) return
|
|
518
|
-
if (await loggedToday(brain)) return // 今天已收尾过
|
|
519
|
-
teardownNudged = true
|
|
520
|
-
await logHook("agent_end:teardown-nudge").catch(() => {})
|
|
521
|
-
try {
|
|
522
|
-
pi.sendUserMessage(
|
|
523
|
-
"[abs 收尾提醒] 本会话改过文件但 .brain/ 今天还没有记录。请立即走收尾循环:\\n" +
|
|
524
|
-
"1) 跑 abs load 看 Today 还有哪些未完成;\\n" +
|
|
525
|
-
"2) 实际做完漏登记的 abs todo done <id>,做到一半的 abs todo note <id> --note \\"断点\\";\\n" +
|
|
526
|
-
"3) 值得留的经验 abs note \\"...\\"(宁少勿滥,能从代码 grep 到的不记);\\n" +
|
|
527
|
-
"4) abs log \\"完成 X:...\\" 记一行工作成果,新页同步进 index。\\n" +
|
|
528
|
-
"简洁执行,不要复述本条提醒。若本次确实没有可沉淀产出,直接回一句\\"无可沉淀\\"即可。",
|
|
529
|
-
{ deliverAs: "followUp" },
|
|
530
|
-
)
|
|
531
|
-
} catch {}
|
|
532
|
-
})
|
|
533
|
-
|
|
534
|
-
pi.on("session_shutdown", (_e: any, ctx: any) => {
|
|
535
|
-
snapshotWrapup((ctx && ctx.cwd) || process.cwd())
|
|
536
|
-
logHook("session_shutdown").catch(() => {})
|
|
537
|
-
})
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/** agent_end 的 event.messages 里翻出 toolResult 消息。 */
|
|
541
|
-
function collectToolResults(messages: any[]): any[] {
|
|
542
|
-
const out: any[] = []
|
|
543
|
-
for (const m of messages || []) {
|
|
544
|
-
if (m && m.role === "toolResult") out.push(m)
|
|
530
|
+
t = fsSync.readFileSync(join(ABS_DIR, 'hooks', name), 'utf8');
|
|
531
|
+
} catch (e) {
|
|
532
|
+
throw new Error(`插件模板缺失: hooks/${name}(安装包不完整?): ${e.message}`);
|
|
545
533
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
${MARK}
|
|
549
|
-
`;
|
|
534
|
+
for (const [k, v] of Object.entries(vars || {})) t = t.split(k).join(v);
|
|
535
|
+
return t;
|
|
550
536
|
}
|
|
551
537
|
|
|
552
538
|
async function installOpenCode({ withMcp, withSkill, log }) {
|
|
@@ -555,6 +541,8 @@ async function installOpenCode({ withMcp, withSkill, log }) {
|
|
|
555
541
|
const p = join(dir, 'abs.ts');
|
|
556
542
|
await atomicWrite(p, opencodePluginSource());
|
|
557
543
|
steps.push(`✓ hook(ts plugin) → ${p}`);
|
|
544
|
+
const warn = opencodePathWarning();
|
|
545
|
+
if (warn) steps.push(warn);
|
|
558
546
|
if (withMcp) {
|
|
559
547
|
const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
|
|
560
548
|
const cfg = await readJson(mcpP);
|
|
@@ -577,12 +565,18 @@ async function installOpenCode({ withMcp, withSkill, log }) {
|
|
|
577
565
|
|
|
578
566
|
async function uninstallOpenCode() {
|
|
579
567
|
const steps = [];
|
|
568
|
+
// 路径歧义提示(卸载侧同样有价值:可能提示用户去清另一个位置的旧副本)
|
|
569
|
+
const warn = opencodePathWarning();
|
|
570
|
+
if (warn) steps.push(warn);
|
|
580
571
|
const plugin = join(hostConfigRoot('opencode'), 'plugins', 'abs.ts');
|
|
581
572
|
await fs.rm(plugin, { force: true });
|
|
582
573
|
steps.push(`✓ plugin 已删除`);
|
|
583
574
|
const mcpP = join(hostConfigRoot('opencode'), 'opencode.json');
|
|
584
|
-
const cfg = await readJson(mcpP);
|
|
585
|
-
if (cfg
|
|
575
|
+
const cfg = await readJson(mcpP, { strict: false });
|
|
576
|
+
if (cfg === null) {
|
|
577
|
+
steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
578
|
+
}
|
|
579
|
+
if (cfg && cfg.mcp && cfg.mcp.abs) {
|
|
586
580
|
delete cfg.mcp.abs;
|
|
587
581
|
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
588
582
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
@@ -625,8 +619,11 @@ async function uninstallPi() {
|
|
|
625
619
|
await fs.rm(hostSkillDir('pi'), { recursive: true, force: true });
|
|
626
620
|
steps.push(`✓ skill 已删除`);
|
|
627
621
|
const mcpP = join(hostConfigRoot('pi'), 'agent', 'mcp.json');
|
|
628
|
-
const cfg = await readJson(mcpP);
|
|
629
|
-
if (cfg
|
|
622
|
+
const cfg = await readJson(mcpP, { strict: false });
|
|
623
|
+
if (cfg === null) {
|
|
624
|
+
steps.push(`⚠ ${mcpP} 无法解析为 JSON,已跳过(未改动你的配置)`);
|
|
625
|
+
}
|
|
626
|
+
if (cfg && cfg.mcpServers && cfg.mcpServers.abs) {
|
|
630
627
|
delete cfg.mcpServers.abs;
|
|
631
628
|
await atomicWrite(mcpP, JSON.stringify(cfg, null, 2));
|
|
632
629
|
steps.push(`✓ MCP 已从 ${mcpP} 移除`);
|
|
@@ -647,15 +644,30 @@ export function installSummary() {
|
|
|
647
644
|
}
|
|
648
645
|
|
|
649
646
|
export async function runInstall({ agent, mcp = true, skill = true, yes = false } = {}) {
|
|
650
|
-
const targets = agent ? [agent] : await pickAgents();
|
|
647
|
+
const targets = agent ? [agent] : await pickAgents(yes);
|
|
648
|
+
const failed = [];
|
|
651
649
|
for (const key of targets) {
|
|
652
650
|
const inst = INSTALLERS[key];
|
|
653
651
|
if (!inst) throw new Error(`未知 agent: ${key} (可用: ${Object.keys(INSTALLERS).join(', ')})`);
|
|
654
652
|
console.log(`\n▸ 安装到 ${key} …`);
|
|
655
|
-
|
|
656
|
-
|
|
653
|
+
try {
|
|
654
|
+
for (const line of await inst.on({ withMcp: mcp, withSkill: skill })) {
|
|
655
|
+
console.log(' ' + line);
|
|
656
|
+
}
|
|
657
|
+
} catch (e) {
|
|
658
|
+
// 逐宿主容错: 一个宿主失败不应让其余宿主整体被跳过。
|
|
659
|
+
// 坑: 曾经任一处报错就直接上抛,于是前序宿主已装、后续宿主全未装 —— 半成品状态。
|
|
660
|
+
// 注意: 单宿主安装(agent 指定)时仍上抛,保持 CLI 非零退出语义。
|
|
661
|
+
failed.push({ key, msg: String(e && e.message ? e.message : e) });
|
|
662
|
+
console.error(` ✗ ${key} 安装失败: ${failed[failed.length - 1].msg}`);
|
|
663
|
+
if (agent) throw e;
|
|
657
664
|
}
|
|
658
665
|
}
|
|
666
|
+
if (failed.length) {
|
|
667
|
+
console.log(`\n⚠ ${failed.length} 个宿主安装失败: ${failed.map((f) => f.key).join(', ')}`);
|
|
668
|
+
console.log(' 其余宿主已完成。修复上述问题后重跑 `abs install`(幂等,不会重复写入)。');
|
|
669
|
+
throw new Error(`${failed.length} 个宿主安装失败(见上)`);
|
|
670
|
+
}
|
|
659
671
|
console.log('\n完成。项目内运行 abs init 建图谱; 会话里说 "abs load" 续接。');
|
|
660
672
|
}
|
|
661
673
|
|
|
@@ -671,8 +683,11 @@ export async function runUninstall({ agent, yes = false } = {}) {
|
|
|
671
683
|
}
|
|
672
684
|
|
|
673
685
|
// 交互式多选(无 TTY 时回退为全部)
|
|
674
|
-
async function pickAgents() {
|
|
675
|
-
|
|
686
|
+
async function pickAgents(yes = false) {
|
|
687
|
+
// --yes / 非 TTY: 直接全选,绝不弹交互。
|
|
688
|
+
// 坑: 曾经只看 isTTY,--yes 被收下却从不使用 —— 在 TTY 里跑
|
|
689
|
+
// abs install --yes(自动化/脚本)仍会弹提示并挂起等输入。
|
|
690
|
+
if (yes || !process.stdin.isTTY) return Object.keys(INSTALLERS);
|
|
676
691
|
const readline = await import('node:readline/promises');
|
|
677
692
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
678
693
|
console.log('选择要安装的智能体 (逗号分隔, 回车=全部):');
|