memory-eternal 0.10.6 → 0.10.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/lib/setup.js CHANGED
@@ -1,470 +1,515 @@
1
- // 记忆核心 · 一键自动挂载器。
2
- //
3
- // 安装/激活时自动检测本机已装的 MCP 宿主(Claude Code / Codex CLI / Cursor),
4
- // 幂等写入各自 MCP 配置,指向本包 bin/dsh-memory.mjs —— 实现「装完即用」。
5
- //
6
- // 安全约定:
7
- // - 幂等:目标配置已存在且内容一致 → 跳过;不一致 → 先备份(.memory-eternal-bak-<ts>)再覆盖
8
- // - 透明:每一步动作输出到 stdout
9
- // - 逃生:环境变量 MEMORY_ETERNAL_SKIP_AUTO=1 或 opts.enabled=false 时完全不动外部文件
10
- // - 只碰自己的键(mcpServers.memory / hooks 里 id 标记的条目),不改其他内容
11
-
12
- import { promises as fs } from 'node:fs'
13
- import path from 'node:path'
14
- import os from 'node:os'
15
- import { fileURLToPath } from 'node:url'
16
-
17
- const __dirname = path.dirname(fileURLToPath(import.meta.url))
18
- export const PACKAGE_ROOT = path.join(__dirname, '..')
19
- export const BIN_PATH = path.join(PACKAGE_ROOT, 'bin', 'dsh-memory.mjs')
20
-
21
- const home = os.homedir()
22
-
23
- async function exists(p) {
24
- try { await fs.access(p); return true } catch { return false }
25
- }
26
-
27
- async function backupOnce(file) {
28
- const bak = `${file}.memory-eternal-bak-${Date.now()}`
29
- await fs.copyFile(file, bak)
30
- return bak
31
- }
32
-
33
- export function mcpCommand(nodePath = process.execPath) {
34
- return { command: nodePath, args: [BIN_PATH, 'mcp'] }
35
- }
36
-
37
- function sameMcpEntry(existing, want) {
38
- if (!existing || typeof existing !== 'object') return false
39
- if (existing.type === 'stdio' || existing.type === undefined) {
40
- return existing.command === want.command
41
- && JSON.stringify(existing.args ?? []) === JSON.stringify(want.args)
42
- }
43
- return false
44
- }
45
-
46
- // 是否已写入我们(capture.mjs)的会话结束 hook。
47
- // marker 用于区分宿主:codex 走 hooks.json 的 Stop;cursor 走 hooks.json 的 stop/sessionEnd。
48
- async function hookState(hooksJson, agent, cursorStyle = false) {
49
- try {
50
- const cfg = JSON.parse(await fs.readFile(hooksJson, 'utf8'))
51
- const hooks = cfg.hooks || {}
52
- // 遍历所有事件下命令里是否含 capture.mjs
53
- const all = Object.values(hooks)
54
- for (const arr of all) {
55
- if (!Array.isArray(arr)) continue
56
- for (const h of arr) {
57
- const cmd = typeof h === 'object' ? (h.command || h.hooks?.map((x) => x.command).join(' ')) : String(h)
58
- if (cmd && cmd.includes('capture.mjs')) return 'configured'
59
- }
60
- }
61
- return 'missing'
62
- } catch {
63
- return 'missing'
64
- }
65
- }
66
-
67
- // -- Claude Code:~/.claude.json 的 mcpServers + ~/.claude/settings.json 的 hooks --
68
-
69
- export async function setupClaude({ nodePath, withHooks = true, dryRun = false, log = () => {} } = {}) {
70
- const results = []
71
- const claudeJson = path.join(home, '.claude.json')
72
- if (await exists(claudeJson)) {
73
- const want = mcpCommand(nodePath)
74
- let cfg
75
- try { cfg = JSON.parse(await fs.readFile(claudeJson, 'utf8')) } catch { cfg = null }
76
- if (cfg && typeof cfg === 'object') {
77
- cfg.mcpServers = cfg.mcpServers ?? {}
78
- if (sameMcpEntry(cfg.mcpServers.memory, want)) {
79
- log('✓ Claude Code MCP 已是最新,跳过')
80
- } else if (!dryRun) {
81
- if (cfg.mcpServers.memory) await backupOnce(claudeJson)
82
- cfg.mcpServers.memory = { type: 'stdio', ...want }
83
- await fs.writeFile(claudeJson, JSON.stringify(cfg, null, 2), 'utf8')
84
- log('✓ Claude Code MCP 已写入 ~/.claude.json')
85
- } else {
86
- log('[dry-run] 将写入 Claude Code MCP ~/.claude.json')
87
- }
88
- results.push({ agent: 'claude-code', ok: true })
89
- }
90
- } else {
91
- log('✗ 未检测到 Claude Code(~/.claude.json 不存在)')
92
- results.push({ agent: 'claude-code', ok: false, reason: 'not installed' })
93
- }
94
-
95
- if (withHooks) results.push(await setupClaudeHooks({ nodePath, dryRun, log }))
96
- return results
97
- }
98
-
99
- async function setupClaudeHooks({ nodePath, dryRun, log }) {
100
- const hookScript = path.join(PACKAGE_ROOT, 'hooks', 'capture.mjs')
101
- if (!(await exists(hookScript))) {
102
- log('✗ hooks/capture.mjs 缺失,跳过 hooks')
103
- return { agent: 'claude-hooks', ok: false, reason: 'hook script missing' }
104
- }
105
- const settingsJson = path.join(home, '.claude', 'settings.json')
106
- let cfg = {}
107
- if (await exists(settingsJson)) {
108
- try { cfg = JSON.parse(await fs.readFile(settingsJson, 'utf8')) } catch { cfg = {} }
109
- }
110
- const want = {
111
- matcher: '',
112
- hooks: [{ type: 'command', command: `${JSON.stringify(nodePath)} ${JSON.stringify(hookScript)} claude` }],
113
- }
114
- const hookKey = 'SessionEnd'
115
- cfg.hooks = cfg.hooks ?? {}
116
- cfg.hooks[hookKey] = Array.isArray(cfg.hooks[hookKey]) ? cfg.hooks[hookKey] : []
117
- const already = cfg.hooks[hookKey].some((h) => h?.hooks?.some((c) => c?.command?.includes('capture.mjs')))
118
- if (already) {
119
- log('✓ Claude Code SessionEnd hook 已存在,跳过')
120
- return { agent: 'claude-hooks', ok: true }
121
- }
122
- if (!dryRun) {
123
- if (Object.keys(cfg).length > 0) await backupOnce(settingsJson).catch(() => {})
124
- cfg.hooks[hookKey].push(want)
125
- await fs.mkdir(path.dirname(settingsJson), { recursive: true })
126
- await fs.writeFile(settingsJson, JSON.stringify(cfg, null, 2), 'utf8')
127
- log('✓ Claude Code SessionEnd hook 已写入 ~/.claude/settings.json')
128
- } else {
129
- log('[dry-run] 将写入 Claude Code hook')
130
- }
131
- return { agent: 'claude-hooks', ok: true }
132
- }
133
-
134
- // -- Codex CLI:~/.codex/config.toml 的 [mcp_servers.memory] --
135
-
136
- export async function setupCodex({ nodePath, dryRun = false, log = () => {} } = {}) {
137
- const codexToml = path.join(home, '.codex', 'config.toml')
138
- if (!(await exists(codexToml))) {
139
- log('✗ 未检测到 Codex CLI(~/.codex/config.toml 不存在)')
140
- return [{ agent: 'codex', ok: false, reason: 'not installed' }]
141
- }
142
- const want = mcpCommand(nodePath)
143
- const text = await fs.readFile(codexToml, 'utf8')
144
- const sectionRe = /^\[mcp_servers\.memory\]$/m
145
- if (sectionRe.test(text)) {
146
- // 已存在:检查 command 是否与当前 node 一致,不一致则重写为当前 node(修复 dsh-desktop vs node 路径漂移)
147
- const curCmd = /command\s*=\s*"([^"]*)"/.exec(text)?.[1]
148
- if (curCmd && curCmd === want.command) {
149
- log('✓ Codex MCP 已是最新,跳过')
150
- return [{ agent: 'codex', ok: true, reason: 'section exists' }]
151
- }
152
- // node 路径不一致 → 重写 command 行
153
- const newText = text.replace(/command\s*=\s*"[^"]*"/, `command = ${JSON.stringify(want.command)}`)
154
- if (!dryRun) {
155
- await backupOnce(codexToml)
156
- await fs.writeFile(codexToml, newText, 'utf8')
157
- log(`✓ Codex MCP node 路径已更新:${want.command}`)
158
- } else {
159
- log('[dry-run] 将更新 Codex MCP node 路径')
160
- }
161
- return [{ agent: 'codex', ok: true, reason: 'node path updated' }]
162
- }
163
- const block = [
164
- '',
165
- '# memory-eternal(自动写入;移除可运行 dsh-memory setup --codex-only --remove)',
166
- '[mcp_servers.memory]',
167
- `command = ${JSON.stringify(want.command)}`,
168
- `args = ${JSON.stringify(want.args)}`,
169
- '',
170
- ].join('\n')
171
- if (!dryRun) {
172
- await backupOnce(codexToml)
173
- await fs.writeFile(codexToml, text.replace(/\s*$/, '\n') + block, 'utf8')
174
- log('✓ Codex MCP 已写入 ~/.codex/config.toml')
175
- } else {
176
- log('[dry-run] 将写入 Codex MCP ~/.codex/config.toml')
177
- }
178
- return [{ agent: 'codex', ok: true }]
179
- }
180
-
181
- // -- Cursor:~/.cursor/mcp.json --
182
-
183
- export async function setupCursor({ nodePath, dryRun = false, log = () => {} } = {}) {
184
- const cursorJson = path.join(home, '.cursor', 'mcp.json')
185
- let cfg = { mcpServers: {} }
186
- if (await exists(cursorJson)) {
187
- try { cfg = JSON.parse(await fs.readFile(cursorJson, 'utf8')) } catch {
188
- log('✗ ~/.cursor/mcp.json 解析失败,跳过')
189
- return [{ agent: 'cursor', ok: false, reason: 'parse error' }]
190
- }
191
- } else {
192
- // Cursor 目录不存在 = 未安装(保守判断:只报提示,不写文件)
193
- const cursorDir = path.join(home, '.cursor')
194
- if (!(await exists(cursorDir))) {
195
- log('✗ 未检测到 Cursor(~/.cursor 不存在)')
196
- return [{ agent: 'cursor', ok: false, reason: 'not installed' }]
197
- }
198
- }
199
- const want = mcpCommand(nodePath)
200
- cfg.mcpServers = cfg.mcpServers ?? {}
201
- if (sameMcpEntry(cfg.mcpServers.memory, want)) {
202
- log('✓ Cursor MCP 已是最新,跳过')
203
- return [{ agent: 'cursor', ok: true }]
204
- }
205
- if (!dryRun) {
206
- if (await exists(cursorJson)) await backupOnce(cursorJson)
207
- cfg.mcpServers.memory = want
208
- await fs.mkdir(path.dirname(cursorJson), { recursive: true })
209
- await fs.writeFile(cursorJson, JSON.stringify(cfg, null, 2), 'utf8')
210
- log('✓ Cursor MCP 已写入 ~/.cursor/mcp.json')
211
- } else {
212
- log('[dry-run] 将写入 Cursor MCP ~/.cursor/mcp.json')
213
- }
214
- return [{ agent: 'cursor', ok: true }]
215
- }
216
-
217
- // -- 总入口 -------------------------------------------------------------------
218
-
219
- export async function runSetup(opts = {}) {
220
- const {
221
- only = [], // ['claude','codex','cursor'];空 = 全部
222
- withHooks = true,
223
- dryRun = false,
224
- log = (m) => console.log(m),
225
- enabled = process.env.MEMORY_ETERNAL_SKIP_AUTO !== '1',
226
- } = opts
227
- if (!enabled) {
228
- log('⏭ MEMORY_ETERNAL_SKIP_AUTO=1,跳过自动挂载')
229
- return { ok: true, results: [] }
230
- }
231
- const all = []
232
- const want = (name) => only.length === 0 || only.includes(name)
233
- if (want('claude')) all.push(...await setupClaude({ nodePath: process.execPath, withHooks, dryRun, log }))
234
- if (want('codex')) all.push(...await setupCodex({ nodePath: process.execPath, dryRun, log }))
235
- if (want('cursor')) all.push(...await setupCursor({ nodePath: process.execPath, dryRun, log }))
236
- return { ok: true, results: all }
237
- }
238
-
239
- /**
240
- * 只读查询各 agent 的 MCP 配置状态。不修改任何文件。
241
- * @returns {Promise<{ok:true, agents: Array, lastCheckedAt: number}>}
242
- */
243
- export async function getSetupStatus({ nodePath = process.execPath } = {}) {
244
- const agents = []
245
- const want = mcpCommand(nodePath)
246
- // claude-code
247
- const claudeJson = path.join(home, '.claude.json')
248
- if (!(await exists(claudeJson))) {
249
- agents.push({ name: 'claude-code', installed: false, mcpConfigured: false, hook: 'unknown' })
250
- } else {
251
- let cfg = null
252
- try { cfg = JSON.parse(await fs.readFile(claudeJson, 'utf8')) } catch {}
253
- const entry = cfg?.mcpServers?.memory
254
- const mcpExists = !!entry
255
- agents.push({
256
- name: 'claude-code',
257
- installed: true,
258
- mcpConfigured: mcpExists,
259
- mcpMatchesCurrentNode: mcpExists && sameMcpEntry(entry, want),
260
- currentNodePath: nodePath,
261
- configuredNodePath: entry?.command || '',
262
- mcpPath: claudeJson,
263
- })
264
- // hook
265
- const settingsJson = path.join(home, '.claude', 'settings.json')
266
- if (await exists(settingsJson)) {
267
- try {
268
- const scfg = JSON.parse(await fs.readFile(settingsJson, 'utf8'))
269
- const list = scfg?.hooks?.SessionEnd || []
270
- const has = list.some((h) => h?.hooks?.some((c) => c?.command?.includes('capture.mjs')))
271
- agents[agents.length - 1].hook = has ? 'configured' : 'missing'
272
- } catch { agents[agents.length - 1].hook = 'unknown' }
273
- } else {
274
- agents[agents.length - 1].hook = 'missing'
275
- }
276
- }
277
- // codex
278
- const codexToml = path.join(home, '.codex', 'config.toml')
279
- if (!(await exists(codexToml))) {
280
- agents.push({ name: 'codex', installed: false, mcpConfigured: false })
281
- } else {
282
- const text = await fs.readFile(codexToml, 'utf8')
283
- const has = /^\[mcp_servers\.memory\]$/m.test(text)
284
- let configuredNodePath = ''
285
- if (has) { const m = /command\s*=\s*"([^"]+)"/.exec(text); if (m) configuredNodePath = m[1] }
286
- agents.push({
287
- name: 'codex',
288
- installed: true,
289
- mcpConfigured: has,
290
- mcpMatchesCurrentNode: has && configuredNodePath === nodePath,
291
- currentNodePath: nodePath,
292
- configuredNodePath,
293
- mcpPath: codexToml,
294
- hook: await hookState(path.join(home, '.codex', 'hooks.json'), 'codex'),
295
- })
296
- }
297
- // cursor
298
- const cursorJson = path.join(home, '.cursor', 'mcp.json')
299
- const cursorDir = path.join(home, '.cursor')
300
- if (!(await exists(cursorDir))) {
301
- agents.push({ name: 'cursor', installed: false, mcpConfigured: false, reason: '~/.cursor 不存在' })
302
- } else if (!(await exists(cursorJson))) {
303
- agents.push({ name: 'cursor', installed: true, mcpConfigured: false, mcpPath: cursorJson })
304
- } else {
305
- try {
306
- const cfg = JSON.parse(await fs.readFile(cursorJson, 'utf8'))
307
- const entry = cfg?.mcpServers?.memory
308
- const mcpExists = !!entry
309
- agents.push({
310
- name: 'cursor',
311
- installed: true,
312
- mcpConfigured: mcpExists,
313
- mcpMatchesCurrentNode: mcpExists && sameMcpEntry(entry, want),
314
- currentNodePath: nodePath,
315
- configuredNodePath: entry?.command || '',
316
- mcpPath: cursorJson,
317
- hook: await hookState(path.join(home, '.cursor', 'hooks.json'), 'cursor', true),
318
- })
319
- } catch { agents.push({ name: 'cursor', installed: true, mcpConfigured: false, mcpPath: cursorJson, reason: 'JSON 解析失败' }) }
320
- }
321
- return { ok: true, agents, lastCheckedAt: Date.now(), want: { command: nodePath, args: want.args } }
322
- }
323
-
324
- // -- 会话结束自动沉淀 hook(connect <agent>,agentmemory 的 `connect --with-hooks` 兜底路径)--
325
- // 不依赖 plugin(Codex Desktop 也适用),只写「用户级 hooks.json + 绝对路径指向 capture.mjs」,
326
- // 幂等 + 备份,只碰 capture.mjs 标记的条目,绝不动其它用户条目。
327
-
328
- /** capture.mjs 的完整调用(绝对路径,避免升级后路径内嵌版本号而失效)。 */
329
- function captureCmd(agent) {
330
- const node = process.execPath
331
- const script = path.join(PACKAGE_ROOT, 'hooks', 'capture.mjs')
332
- return `${JSON.stringify(node)} ${JSON.stringify(script)} ${agent}`
333
- }
334
-
335
- /** 写一个幂等 hooks.json:events → [{command}](cursor 风格)或 {hooks:[{type,command}]}(codex 风格)。
336
- * 幂等 + 路径迁移:已有 capture.mjs 条目但路径不同 → 就地改为当前包路径;没有 → 追加;正确 → 跳过。 */
337
- async function writeHooksJson(hooksJson, evEntries, { dryRun = false, log = () => {} } = {}) {
338
- let cfg = { version: 1, hooks: {} }
339
- if (await exists(hooksJson)) {
340
- try { cfg = JSON.parse(await fs.readFile(hooksJson, 'utf8')) } catch {
341
- log(`✗ ${hooksJson} 解析失败,跳过`)
342
- return { ok: false, reason: 'parse error' }
343
- }
344
- }
345
- cfg.hooks = cfg.hooks ?? {}
346
- let changed = false
347
- for (const ev of evEntries) {
348
- const arr = cfg.hooks[ev.event] = Array.isArray(cfg.hooks[ev.event]) ? cfg.hooks[ev.event] : []
349
- const wantCmd = ev.cmd
350
- // 找已存在的 capture.mjs 条目(可能是本脚本旧路径,或其它包的同脚本名)
351
- const idx = arr.findIndex((h) => JSON.stringify(h).includes('capture.mjs'))
352
- if (idx >= 0) {
353
- const entry = ev.codexStyle ? (arr[idx].hooks || []).find((x) => x.command && x.command.includes('capture.mjs')) : arr[idx]
354
- if (entry && entry.command === wantCmd) continue // 已正确
355
- if (entry) { entry.command = wantCmd; changed = true } // 路径迁移
356
- else { arr[idx] = ev.codexStyle ? { hooks: [{ type: 'command', command: wantCmd, timeout: 60, statusMessage: 'memory: save session' }] } : { command: wantCmd }; changed = true }
357
- } else {
358
- arr.push(ev.codexStyle ? { hooks: [{ type: 'command', command: wantCmd, timeout: 60, statusMessage: 'memory: save session' }] } : { command: wantCmd })
359
- changed = true
360
- }
361
- }
362
- if (!changed) { log(`✓ ${hooksJson} 已是最新,跳过`); return { ok: true, skipped: true } }
363
- if (dryRun) { log(`[dry-run] 将写入 ${hooksJson}`); return { ok: true, dry: true } }
364
- if (await exists(hooksJson)) await backupOnce(hooksJson)
365
- await fs.mkdir(path.dirname(hooksJson), { recursive: true })
366
- await fs.writeFile(hooksJson, JSON.stringify(cfg, null, 2), 'utf8')
367
- log(`✓ 会话结束 hook 已写入 ${hooksJson}`)
368
- return { ok: true }
369
- }
370
-
371
- /** Codex:用户级 ~/.codex/hooks.json 的 Stop 事件(CLI 与 Desktop 都可用)。 */
372
- export async function connectCodexHooks(opts = {}) {
373
- return writeHooksJson(path.join(home, '.codex', 'hooks.json'), [{ event: 'Stop', codexStyle: true, cmd: captureCmd('codex') }], opts)
374
- }
375
-
376
- /** Cursor:~/.cursor/hooks.json 的 stop / sessionEnd 事件。 */
377
- export async function connectCursorHooks(opts = {}) {
378
- return writeHooksJson(path.join(home, '.cursor', 'hooks.json'), [{ event: 'stop', cmd: captureCmd('cursor') }, { event: 'sessionEnd', cmd: captureCmd('cursor') }], opts)
379
- }
380
-
381
- /** 统一入口:claude 复用 setupClaude 的 SessionEnd(capture.mjs);codex/cursor 写 capture.mjs hooks。 */
382
- export async function connectAgentHooks(agent, opts = {}) {
383
- if (agent === 'claude' || agent === 'claude-code') {
384
- const results = await setupClaude({ nodePath: process.execPath, withHooks: true, log: opts.log })
385
- return { ok: results.every((r) => r.ok), results }
386
- }
387
- if (agent === 'codex') return connectCodexHooks(opts)
388
- if (agent === 'cursor') return connectCursorHooks(opts)
389
- return { ok: false, error: `未知 agent: ${agent}` }
390
- }
391
-
392
- // -- 单智能体 MCP 安装 / 卸载 ----------------------------------------------------
393
-
394
- /** 卸载 Claude Code 的 memory MCP + SessionEnd hook。 */
395
- export async function removeClaudeMcp({ dryRun = false, log = () => {} } = {}) {
396
- const claudeJson = path.join(home, '.claude.json')
397
- let removed = false
398
- if (await exists(claudeJson)) {
399
- try {
400
- const cfg = JSON.parse(await fs.readFile(claudeJson, 'utf8'))
401
- if (cfg.mcpServers && cfg.mcpServers.memory) {
402
- if (!dryRun) { await backupOnce(claudeJson); delete cfg.mcpServers.memory; await fs.writeFile(claudeJson, JSON.stringify(cfg, null, 2), 'utf8') }
403
- removed = true
404
- }
405
- } catch {}
406
- }
407
- // 移除 SessionEnd hook
408
- const settingsJson = path.join(home, '.claude', 'settings.json')
409
- if (await exists(settingsJson)) {
410
- try {
411
- const scfg = JSON.parse(await fs.readFile(settingsJson, 'utf8'))
412
- if (scfg.hooks?.SessionEnd) {
413
- const kept = scfg.hooks.SessionEnd.filter((h) => !(h.hooks || []).some((c) => c?.command?.includes('capture.mjs')))
414
- if (kept.length !== scfg.hooks.SessionEnd.length) {
415
- if (!dryRun) { await backupOnce(settingsJson); scfg.hooks.SessionEnd = kept; await fs.writeFile(settingsJson, JSON.stringify(scfg, null, 2), 'utf8') }
416
- removed = true
417
- }
418
- }
419
- } catch {}
420
- }
421
- log(removed ? (dryRun ? '[dry-run] 将卸载 Claude Code MCP' : '✓ 已卸载 Claude Code MCP') : '✓ Claude Code MCP 未配置')
422
- return { agent: 'claude-code', ok: true, removed }
423
- }
424
-
425
- /** 卸载 Codex 的 memory MCP 段。 */
426
- export async function removeCodexMcp({ dryRun = false, log = () => {} } = {}) {
427
- const codexToml = path.join(home, '.codex', 'config.toml')
428
- if (!(await exists(codexToml))) return { agent: 'codex', ok: false, reason: 'not installed' }
429
- const text = await fs.readFile(codexToml, 'utf8')
430
- const sectionRe = /^\s*\[mcp_servers\.memory\]\s*$/m
431
- if (!sectionRe.test(text)) { log('✓ Codex MCP 未配置'); return { agent: 'codex', ok: true, removed: false } }
432
- if (!dryRun) {
433
- const newText = text.replace(/^\s*# memory-eternal.*\n?/m, '').replace(/^\s*\[mcp_servers\.memory\]\s*\n(?:command\s*=.*\n|args\s*=.*\n)+\s*/m, '')
434
- await backupOnce(codexToml)
435
- await fs.writeFile(codexToml, newText.replace(/\n{3,}/g, '\n\n'), 'utf8')
436
- }
437
- log(dryRun ? '[dry-run] 将卸载 Codex MCP' : '✓ 已卸载 Codex MCP')
438
- return { agent: 'codex', ok: true, removed: true }
439
- }
440
-
441
- /** 卸载 Cursor 的 memory MCP。 */
442
- export async function removeCursorMcp({ dryRun = false, log = () => {} } = {}) {
443
- const cursorJson = path.join(home, '.cursor', 'mcp.json')
444
- if (!(await exists(cursorJson))) return { agent: 'cursor', ok: false, reason: 'not installed' }
445
- try {
446
- const cfg = JSON.parse(await fs.readFile(cursorJson, 'utf8'))
447
- if (cfg.mcpServers && cfg.mcpServers.memory) {
448
- if (!dryRun) { await backupOnce(cursorJson); delete cfg.mcpServers.memory; await fs.writeFile(cursorJson, JSON.stringify(cfg, null, 2), 'utf8') }
449
- log(dryRun ? '[dry-run] 将卸载 Cursor MCP' : '✓ 已卸载 Cursor MCP')
450
- return { agent: 'cursor', ok: true, removed: true }
451
- }
452
- } catch {}
453
- log('✓ Cursor MCP 未配置')
454
- return { agent: 'cursor', ok: true, removed: false }
455
- }
456
-
457
- /** 对单个智能体执行安装/卸载 MCP。action: 'install' | 'uninstall'。agent: 'claude-code'|'codex'|'cursor' */
458
- export async function mcpAgentAction(agent, action, { dryRun = false, log = () => {} } = {}) {
459
- if (action === 'install') {
460
- const nodePath = process.execPath
461
- if (agent === 'claude-code') return { ok: true, results: await setupClaude({ nodePath, dryRun, log }) }
462
- if (agent === 'codex') return { ok: true, results: await setupCodex({ nodePath, dryRun, log }) }
463
- if (agent === 'cursor') return { ok: true, results: await setupCursor({ nodePath, dryRun, log }) }
464
- } else if (action === 'uninstall') {
465
- if (agent === 'claude-code') return { ok: true, results: [await removeClaudeMcp({ dryRun, log })] }
466
- if (agent === 'codex') return { ok: true, results: [await removeCodexMcp({ dryRun, log })] }
467
- if (agent === 'cursor') return { ok: true, results: [await removeCursorMcp({ dryRun, log })] }
468
- }
469
- return { ok: false, error: `未知 agent/action: ${agent}/${action}` }
470
- }
1
+ // 记忆核心 · 一键自动挂载器。
2
+ //
3
+ // 安装/激活时自动检测本机已装的 MCP 宿主(Claude Code / Codex CLI / Cursor),
4
+ // 幂等写入各自 MCP 配置,指向本包 bin/dsh-memory.mjs —— 实现「装完即用」。
5
+ //
6
+ // 安全约定:
7
+ // - 幂等:目标配置已存在且内容一致 → 跳过;不一致 → 先备份(.memory-eternal-bak-<ts>)再覆盖
8
+ // - 透明:每一步动作输出到 stdout
9
+ // - 逃生:环境变量 MEMORY_ETERNAL_SKIP_AUTO=1 或 opts.enabled=false 时完全不动外部文件
10
+ // - 只碰自己的键(mcpServers.memory / hooks 里 id 标记的条目),不改其他内容
11
+
12
+ import { promises as fs } from 'node:fs'
13
+ import path from 'node:path'
14
+ import os from 'node:os'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
18
+ export const PACKAGE_ROOT = path.join(__dirname, '..')
19
+ export const BIN_PATH = path.join(PACKAGE_ROOT, 'bin', 'dsh-memory.mjs')
20
+
21
+ const home = os.homedir()
22
+
23
+ async function exists(p) {
24
+ try { await fs.access(p); return true } catch { return false }
25
+ }
26
+
27
+ async function backupOnce(file) {
28
+ const bak = `${file}.memory-eternal-bak-${Date.now()}`
29
+ await fs.copyFile(file, bak)
30
+ return bak
31
+ }
32
+
33
+ export function mcpCommand(nodePath = process.execPath) {
34
+ return { command: nodePath, args: [BIN_PATH, 'mcp'] }
35
+ }
36
+
37
+ function sameMcpEntry(existing, want) {
38
+ if (!existing || typeof existing !== 'object') return false
39
+ if (existing.type === 'stdio' || existing.type === undefined) {
40
+ return existing.command === want.command
41
+ && JSON.stringify(existing.args ?? []) === JSON.stringify(want.args)
42
+ }
43
+ return false
44
+ }
45
+
46
+ // 是否已写入我们(capture.mjs)的会话结束 hook。
47
+ // marker 用于区分宿主:codex 走 hooks.json 的 Stop;cursor 走 hooks.json 的 stop/sessionEnd。
48
+ async function hookState(hooksJson, agent, cursorStyle = false) {
49
+ try {
50
+ const cfg = JSON.parse(await fs.readFile(hooksJson, 'utf8'))
51
+ const hooks = cfg.hooks || {}
52
+ // 遍历所有事件下命令里是否含 capture.mjs
53
+ const all = Object.values(hooks)
54
+ for (const arr of all) {
55
+ if (!Array.isArray(arr)) continue
56
+ for (const h of arr) {
57
+ const cmd = typeof h === 'object' ? (h.command || h.hooks?.map((x) => x.command).join(' ')) : String(h)
58
+ if (cmd && cmd.includes('capture.mjs')) return 'configured'
59
+ }
60
+ }
61
+ return 'missing'
62
+ } catch {
63
+ return 'missing'
64
+ }
65
+ }
66
+
67
+ // -- Claude Code:~/.claude.json 的 mcpServers + ~/.claude/settings.json 的 hooks --
68
+
69
+ export async function setupClaude({ nodePath, withHooks = true, dryRun = false, log = () => {} } = {}) {
70
+ const results = []
71
+ const claudeJson = path.join(home, '.claude.json')
72
+ if (await exists(claudeJson)) {
73
+ const want = mcpCommand(nodePath)
74
+ let cfg
75
+ try { cfg = JSON.parse(await fs.readFile(claudeJson, 'utf8')) } catch { cfg = null }
76
+ if (cfg && typeof cfg === 'object') {
77
+ cfg.mcpServers = cfg.mcpServers ?? {}
78
+ if (sameMcpEntry(cfg.mcpServers.memory, want)) {
79
+ log('✓ Claude Code MCP 已是最新,跳过')
80
+ } else if (!dryRun) {
81
+ if (cfg.mcpServers.memory) await backupOnce(claudeJson)
82
+ cfg.mcpServers.memory = { type: 'stdio', ...want }
83
+ await fs.writeFile(claudeJson, JSON.stringify(cfg, null, 2), 'utf8')
84
+ log('✓ Claude Code MCP 已写入 ~/.claude.json')
85
+ } else {
86
+ log('[dry-run] 将写入 Claude Code MCP ~/.claude.json')
87
+ }
88
+ results.push({ agent: 'claude-code', ok: true })
89
+ }
90
+ } else {
91
+ log('✗ 未检测到 Claude Code(~/.claude.json 不存在)')
92
+ results.push({ agent: 'claude-code', ok: false, reason: 'not installed' })
93
+ }
94
+
95
+ if (withHooks) results.push(await setupClaudeHooks({ nodePath, dryRun, log }))
96
+ return results
97
+ }
98
+
99
+ async function setupClaudeHooks({ nodePath, dryRun, log }) {
100
+ const hookScript = path.join(PACKAGE_ROOT, 'hooks', 'capture.mjs')
101
+ const guardScript = path.join(PACKAGE_ROOT, 'hooks', 'secret-guard.mjs')
102
+ if (!(await exists(hookScript))) {
103
+ log('✗ hooks/capture.mjs 缺失,跳过 hooks')
104
+ return { agent: 'claude-hooks', ok: false, reason: 'hook script missing' }
105
+ }
106
+ const settingsJson = path.join(home, '.claude', 'settings.json')
107
+ let cfg = {}
108
+ if (await exists(settingsJson)) {
109
+ try { cfg = JSON.parse(await fs.readFile(settingsJson, 'utf8')) } catch { cfg = {} }
110
+ }
111
+ cfg.hooks = cfg.hooks ?? {}
112
+ const written = []
113
+ let changed = false
114
+
115
+ // ① SessionEnd:会话结束自动沉淀
116
+ const want = {
117
+ matcher: '',
118
+ hooks: [{ type: 'command', command: `${JSON.stringify(nodePath)} ${JSON.stringify(hookScript)} claude` }],
119
+ }
120
+ const hookKey = 'SessionEnd'
121
+ cfg.hooks[hookKey] = Array.isArray(cfg.hooks[hookKey]) ? cfg.hooks[hookKey] : []
122
+ const existingCapture = cfg.hooks[hookKey].find((h) => h?.hooks?.some((c) => c?.command?.includes('capture.mjs')))
123
+ if (existingCapture) {
124
+ // 已存在也要核对**路径**:插件可能换了安装副本(web profile → desktop profile、
125
+ // 或 npm 版本装到别处),旧实现只判「有没有 capture.mjs」就跳过,于是 hook 永远
126
+ // 指着那个旧副本(本机实测:Claude Code 一直在跑 web profile 的 0.9.23)。
127
+ // 与 Codex 分支同样的口径:只重写自己那条命令,其它 hook 一律不动。
128
+ const wanted = `${JSON.stringify(nodePath)} ${JSON.stringify(hookScript)} claude`
129
+ let rewrote = false
130
+ for (const c of existingCapture.hooks ?? []) {
131
+ if (typeof c?.command === 'string' && c.command.includes('capture.mjs') && c.command !== wanted) {
132
+ c.command = wanted
133
+ rewrote = true
134
+ }
135
+ }
136
+ if (rewrote) {
137
+ written.push(hookKey + '(path)')
138
+ changed = true
139
+ log('↻ Claude Code SessionEnd hook 路径已更新为当前副本')
140
+ } else {
141
+ log('✓ Claude Code SessionEnd hook 已存在,跳过')
142
+ }
143
+ } else {
144
+ cfg.hooks[hookKey].push(want)
145
+ written.push(hookKey)
146
+ changed = true
147
+ }
148
+
149
+ // ② PreToolUse:凭据读取守卫(拒读 .credentials.yaml / .env / *-token,指路 secrets 加载器)。
150
+ // 没装守卫脚本就跳过 —— 老版本插件目录里没有这个文件,不能凭空注册一个不存在的命令。
151
+ if (await exists(guardScript)) {
152
+ const guardKey = 'PreToolUse'
153
+ cfg.hooks[guardKey] = Array.isArray(cfg.hooks[guardKey]) ? cfg.hooks[guardKey] : []
154
+ if (cfg.hooks[guardKey].some((h) => h?.hooks?.some((c) => c?.command?.includes('secret-guard.mjs')))) {
155
+ log('✓ Claude Code PreToolUse 凭据守卫已存在,跳过')
156
+ } else {
157
+ // 只追加一个独立条目,保留用户已有的 PreToolUse 配置
158
+ cfg.hooks[guardKey].push({
159
+ matcher: 'Read|Bash|Grep|Glob',
160
+ hooks: [{ type: 'command', command: `${JSON.stringify(nodePath)} ${JSON.stringify(guardScript)}` }],
161
+ })
162
+ written.push(guardKey)
163
+ changed = true
164
+ }
165
+ }
166
+
167
+ if (!changed) return { agent: 'claude-hooks', ok: true }
168
+ if (!dryRun) {
169
+ if (Object.keys(cfg).length > 0) await backupOnce(settingsJson).catch(() => {})
170
+ await fs.mkdir(path.dirname(settingsJson), { recursive: true })
171
+ await fs.writeFile(settingsJson, JSON.stringify(cfg, null, 2), 'utf8')
172
+ log('✓ Claude Code hooks 已写入 ~/.claude/settings.json:' + written.join(' / '))
173
+ } else {
174
+ log('[dry-run] 将写入 Claude Code hooks:' + written.join(' / '))
175
+ }
176
+ return { agent: 'claude-hooks', ok: true }
177
+ }
178
+
179
+ // -- Codex CLI:~/.codex/config.toml 的 [mcp_servers.memory] --
180
+
181
+ export async function setupCodex({ nodePath, dryRun = false, log = () => {} } = {}) {
182
+ const codexToml = path.join(home, '.codex', 'config.toml')
183
+ if (!(await exists(codexToml))) {
184
+ log('✗ 未检测到 Codex CLI(~/.codex/config.toml 不存在)')
185
+ return [{ agent: 'codex', ok: false, reason: 'not installed' }]
186
+ }
187
+ const want = mcpCommand(nodePath)
188
+ const text = await fs.readFile(codexToml, 'utf8')
189
+ const sectionRe = /^\[mcp_servers\.memory\]$/m
190
+ if (sectionRe.test(text)) {
191
+ // 已存在:检查 command 是否与当前 node 一致,不一致则重写为当前 node(修复 dsh-desktop vs node 路径漂移)
192
+ const curCmd = /command\s*=\s*"([^"]*)"/.exec(text)?.[1]
193
+ if (curCmd && curCmd === want.command) {
194
+ log('✓ Codex MCP 已是最新,跳过')
195
+ return [{ agent: 'codex', ok: true, reason: 'section exists' }]
196
+ }
197
+ // node 路径不一致 → 重写 command 行
198
+ const newText = text.replace(/command\s*=\s*"[^"]*"/, `command = ${JSON.stringify(want.command)}`)
199
+ if (!dryRun) {
200
+ await backupOnce(codexToml)
201
+ await fs.writeFile(codexToml, newText, 'utf8')
202
+ log(`✓ Codex MCP node 路径已更新:${want.command}`)
203
+ } else {
204
+ log('[dry-run] 将更新 Codex MCP node 路径')
205
+ }
206
+ return [{ agent: 'codex', ok: true, reason: 'node path updated' }]
207
+ }
208
+ const block = [
209
+ '',
210
+ '# memory-eternal(自动写入;移除可运行 dsh-memory setup --codex-only --remove)',
211
+ '[mcp_servers.memory]',
212
+ `command = ${JSON.stringify(want.command)}`,
213
+ `args = ${JSON.stringify(want.args)}`,
214
+ '',
215
+ ].join('\n')
216
+ if (!dryRun) {
217
+ await backupOnce(codexToml)
218
+ await fs.writeFile(codexToml, text.replace(/\s*$/, '\n') + block, 'utf8')
219
+ log('✓ Codex MCP 已写入 ~/.codex/config.toml')
220
+ } else {
221
+ log('[dry-run] 将写入 Codex MCP ~/.codex/config.toml')
222
+ }
223
+ return [{ agent: 'codex', ok: true }]
224
+ }
225
+
226
+ // -- Cursor:~/.cursor/mcp.json --
227
+
228
+ export async function setupCursor({ nodePath, dryRun = false, log = () => {} } = {}) {
229
+ const cursorJson = path.join(home, '.cursor', 'mcp.json')
230
+ let cfg = { mcpServers: {} }
231
+ if (await exists(cursorJson)) {
232
+ try { cfg = JSON.parse(await fs.readFile(cursorJson, 'utf8')) } catch {
233
+ log('✗ ~/.cursor/mcp.json 解析失败,跳过')
234
+ return [{ agent: 'cursor', ok: false, reason: 'parse error' }]
235
+ }
236
+ } else {
237
+ // Cursor 目录不存在 = 未安装(保守判断:只报提示,不写文件)
238
+ const cursorDir = path.join(home, '.cursor')
239
+ if (!(await exists(cursorDir))) {
240
+ log('✗ 未检测到 Cursor(~/.cursor 不存在)')
241
+ return [{ agent: 'cursor', ok: false, reason: 'not installed' }]
242
+ }
243
+ }
244
+ const want = mcpCommand(nodePath)
245
+ cfg.mcpServers = cfg.mcpServers ?? {}
246
+ if (sameMcpEntry(cfg.mcpServers.memory, want)) {
247
+ log('✓ Cursor MCP 已是最新,跳过')
248
+ return [{ agent: 'cursor', ok: true }]
249
+ }
250
+ if (!dryRun) {
251
+ if (await exists(cursorJson)) await backupOnce(cursorJson)
252
+ cfg.mcpServers.memory = want
253
+ await fs.mkdir(path.dirname(cursorJson), { recursive: true })
254
+ await fs.writeFile(cursorJson, JSON.stringify(cfg, null, 2), 'utf8')
255
+ log('✓ Cursor MCP 已写入 ~/.cursor/mcp.json')
256
+ } else {
257
+ log('[dry-run] 将写入 Cursor MCP ~/.cursor/mcp.json')
258
+ }
259
+ return [{ agent: 'cursor', ok: true }]
260
+ }
261
+
262
+ // -- 总入口 -------------------------------------------------------------------
263
+
264
+ export async function runSetup(opts = {}) {
265
+ const {
266
+ only = [], // ['claude','codex','cursor'];空 = 全部
267
+ withHooks = true,
268
+ dryRun = false,
269
+ log = (m) => console.log(m),
270
+ enabled = process.env.MEMORY_ETERNAL_SKIP_AUTO !== '1',
271
+ } = opts
272
+ if (!enabled) {
273
+ log('⏭ MEMORY_ETERNAL_SKIP_AUTO=1,跳过自动挂载')
274
+ return { ok: true, results: [] }
275
+ }
276
+ const all = []
277
+ const want = (name) => only.length === 0 || only.includes(name)
278
+ if (want('claude')) all.push(...await setupClaude({ nodePath: process.execPath, withHooks, dryRun, log }))
279
+ if (want('codex')) all.push(...await setupCodex({ nodePath: process.execPath, dryRun, log }))
280
+ if (want('cursor')) all.push(...await setupCursor({ nodePath: process.execPath, dryRun, log }))
281
+ return { ok: true, results: all }
282
+ }
283
+
284
+ /**
285
+ * 只读查询各 agent 的 MCP 配置状态。不修改任何文件。
286
+ * @returns {Promise<{ok:true, agents: Array, lastCheckedAt: number}>}
287
+ */
288
+ export async function getSetupStatus({ nodePath = process.execPath } = {}) {
289
+ const agents = []
290
+ const want = mcpCommand(nodePath)
291
+ // claude-code
292
+ const claudeJson = path.join(home, '.claude.json')
293
+ if (!(await exists(claudeJson))) {
294
+ agents.push({ name: 'claude-code', installed: false, mcpConfigured: false, hook: 'unknown' })
295
+ } else {
296
+ let cfg = null
297
+ try { cfg = JSON.parse(await fs.readFile(claudeJson, 'utf8')) } catch {}
298
+ const entry = cfg?.mcpServers?.memory
299
+ const mcpExists = !!entry
300
+ agents.push({
301
+ name: 'claude-code',
302
+ installed: true,
303
+ mcpConfigured: mcpExists,
304
+ mcpMatchesCurrentNode: mcpExists && sameMcpEntry(entry, want),
305
+ currentNodePath: nodePath,
306
+ configuredNodePath: entry?.command || '',
307
+ mcpPath: claudeJson,
308
+ })
309
+ // hook
310
+ const settingsJson = path.join(home, '.claude', 'settings.json')
311
+ if (await exists(settingsJson)) {
312
+ try {
313
+ const scfg = JSON.parse(await fs.readFile(settingsJson, 'utf8'))
314
+ const list = scfg?.hooks?.SessionEnd || []
315
+ const has = list.some((h) => h?.hooks?.some((c) => c?.command?.includes('capture.mjs')))
316
+ agents[agents.length - 1].hook = has ? 'configured' : 'missing'
317
+ } catch { agents[agents.length - 1].hook = 'unknown' }
318
+ } else {
319
+ agents[agents.length - 1].hook = 'missing'
320
+ }
321
+ }
322
+ // codex
323
+ const codexToml = path.join(home, '.codex', 'config.toml')
324
+ if (!(await exists(codexToml))) {
325
+ agents.push({ name: 'codex', installed: false, mcpConfigured: false })
326
+ } else {
327
+ const text = await fs.readFile(codexToml, 'utf8')
328
+ const has = /^\[mcp_servers\.memory\]$/m.test(text)
329
+ let configuredNodePath = ''
330
+ if (has) { const m = /command\s*=\s*"([^"]+)"/.exec(text); if (m) configuredNodePath = m[1] }
331
+ agents.push({
332
+ name: 'codex',
333
+ installed: true,
334
+ mcpConfigured: has,
335
+ mcpMatchesCurrentNode: has && configuredNodePath === nodePath,
336
+ currentNodePath: nodePath,
337
+ configuredNodePath,
338
+ mcpPath: codexToml,
339
+ hook: await hookState(path.join(home, '.codex', 'hooks.json'), 'codex'),
340
+ })
341
+ }
342
+ // cursor
343
+ const cursorJson = path.join(home, '.cursor', 'mcp.json')
344
+ const cursorDir = path.join(home, '.cursor')
345
+ if (!(await exists(cursorDir))) {
346
+ agents.push({ name: 'cursor', installed: false, mcpConfigured: false, reason: '~/.cursor 不存在' })
347
+ } else if (!(await exists(cursorJson))) {
348
+ agents.push({ name: 'cursor', installed: true, mcpConfigured: false, mcpPath: cursorJson })
349
+ } else {
350
+ try {
351
+ const cfg = JSON.parse(await fs.readFile(cursorJson, 'utf8'))
352
+ const entry = cfg?.mcpServers?.memory
353
+ const mcpExists = !!entry
354
+ agents.push({
355
+ name: 'cursor',
356
+ installed: true,
357
+ mcpConfigured: mcpExists,
358
+ mcpMatchesCurrentNode: mcpExists && sameMcpEntry(entry, want),
359
+ currentNodePath: nodePath,
360
+ configuredNodePath: entry?.command || '',
361
+ mcpPath: cursorJson,
362
+ hook: await hookState(path.join(home, '.cursor', 'hooks.json'), 'cursor', true),
363
+ })
364
+ } catch { agents.push({ name: 'cursor', installed: true, mcpConfigured: false, mcpPath: cursorJson, reason: 'JSON 解析失败' }) }
365
+ }
366
+ return { ok: true, agents, lastCheckedAt: Date.now(), want: { command: nodePath, args: want.args } }
367
+ }
368
+
369
+ // -- 会话结束自动沉淀 hook(connect <agent>,agentmemory 的 `connect --with-hooks` 兜底路径)--
370
+ // 不依赖 plugin(Codex Desktop 也适用),只写「用户级 hooks.json + 绝对路径指向 capture.mjs」,
371
+ // 幂等 + 备份,只碰 capture.mjs 标记的条目,绝不动其它用户条目。
372
+
373
+ /** capture.mjs 的完整调用(绝对路径,避免升级后路径内嵌版本号而失效)。 */
374
+ function captureCmd(agent) {
375
+ const node = process.execPath
376
+ const script = path.join(PACKAGE_ROOT, 'hooks', 'capture.mjs')
377
+ return `${JSON.stringify(node)} ${JSON.stringify(script)} ${agent}`
378
+ }
379
+
380
+ /** 写一个幂等 hooks.json:events → [{command}](cursor 风格)或 {hooks:[{type,command}]}(codex 风格)。
381
+ * 幂等 + 路径迁移:已有 capture.mjs 条目但路径不同 → 就地改为当前包路径;没有 → 追加;正确 → 跳过。 */
382
+ async function writeHooksJson(hooksJson, evEntries, { dryRun = false, log = () => {} } = {}) {
383
+ let cfg = { version: 1, hooks: {} }
384
+ if (await exists(hooksJson)) {
385
+ try { cfg = JSON.parse(await fs.readFile(hooksJson, 'utf8')) } catch {
386
+ log(`✗ ${hooksJson} 解析失败,跳过`)
387
+ return { ok: false, reason: 'parse error' }
388
+ }
389
+ }
390
+ cfg.hooks = cfg.hooks ?? {}
391
+ let changed = false
392
+ for (const ev of evEntries) {
393
+ const arr = cfg.hooks[ev.event] = Array.isArray(cfg.hooks[ev.event]) ? cfg.hooks[ev.event] : []
394
+ const wantCmd = ev.cmd
395
+ // 找已存在的 capture.mjs 条目(可能是本脚本旧路径,或其它包的同脚本名)
396
+ const idx = arr.findIndex((h) => JSON.stringify(h).includes('capture.mjs'))
397
+ if (idx >= 0) {
398
+ const entry = ev.codexStyle ? (arr[idx].hooks || []).find((x) => x.command && x.command.includes('capture.mjs')) : arr[idx]
399
+ if (entry && entry.command === wantCmd) continue // 已正确
400
+ if (entry) { entry.command = wantCmd; changed = true } // 路径迁移
401
+ else { arr[idx] = ev.codexStyle ? { hooks: [{ type: 'command', command: wantCmd, timeout: 60, statusMessage: 'memory: save session' }] } : { command: wantCmd }; changed = true }
402
+ } else {
403
+ arr.push(ev.codexStyle ? { hooks: [{ type: 'command', command: wantCmd, timeout: 60, statusMessage: 'memory: save session' }] } : { command: wantCmd })
404
+ changed = true
405
+ }
406
+ }
407
+ if (!changed) { log(`✓ ${hooksJson} 已是最新,跳过`); return { ok: true, skipped: true } }
408
+ if (dryRun) { log(`[dry-run] 将写入 ${hooksJson}`); return { ok: true, dry: true } }
409
+ if (await exists(hooksJson)) await backupOnce(hooksJson)
410
+ await fs.mkdir(path.dirname(hooksJson), { recursive: true })
411
+ await fs.writeFile(hooksJson, JSON.stringify(cfg, null, 2), 'utf8')
412
+ log(`✓ 会话结束 hook 已写入 ${hooksJson}`)
413
+ return { ok: true }
414
+ }
415
+
416
+ /** Codex:用户级 ~/.codex/hooks.json 的 Stop 事件(CLI 与 Desktop 都可用)。 */
417
+ export async function connectCodexHooks(opts = {}) {
418
+ return writeHooksJson(path.join(home, '.codex', 'hooks.json'), [{ event: 'Stop', codexStyle: true, cmd: captureCmd('codex') }], opts)
419
+ }
420
+
421
+ /** Cursor:~/.cursor/hooks.json 的 stop / sessionEnd 事件。 */
422
+ export async function connectCursorHooks(opts = {}) {
423
+ return writeHooksJson(path.join(home, '.cursor', 'hooks.json'), [{ event: 'stop', cmd: captureCmd('cursor') }, { event: 'sessionEnd', cmd: captureCmd('cursor') }], opts)
424
+ }
425
+
426
+ /** 统一入口:claude 复用 setupClaude 的 SessionEnd(capture.mjs);codex/cursor 写 capture.mjs hooks。 */
427
+ export async function connectAgentHooks(agent, opts = {}) {
428
+ if (agent === 'claude' || agent === 'claude-code') {
429
+ const results = await setupClaude({ nodePath: process.execPath, withHooks: true, log: opts.log })
430
+ return { ok: results.every((r) => r.ok), results }
431
+ }
432
+ if (agent === 'codex') return connectCodexHooks(opts)
433
+ if (agent === 'cursor') return connectCursorHooks(opts)
434
+ return { ok: false, error: `未知 agent: ${agent}` }
435
+ }
436
+
437
+ // -- 单智能体 MCP 安装 / 卸载 ----------------------------------------------------
438
+
439
+ /** 卸载 Claude Code 的 memory MCP + SessionEnd hook。 */
440
+ export async function removeClaudeMcp({ dryRun = false, log = () => {} } = {}) {
441
+ const claudeJson = path.join(home, '.claude.json')
442
+ let removed = false
443
+ if (await exists(claudeJson)) {
444
+ try {
445
+ const cfg = JSON.parse(await fs.readFile(claudeJson, 'utf8'))
446
+ if (cfg.mcpServers && cfg.mcpServers.memory) {
447
+ if (!dryRun) { await backupOnce(claudeJson); delete cfg.mcpServers.memory; await fs.writeFile(claudeJson, JSON.stringify(cfg, null, 2), 'utf8') }
448
+ removed = true
449
+ }
450
+ } catch {}
451
+ }
452
+ // 移除 SessionEnd hook
453
+ const settingsJson = path.join(home, '.claude', 'settings.json')
454
+ if (await exists(settingsJson)) {
455
+ try {
456
+ const scfg = JSON.parse(await fs.readFile(settingsJson, 'utf8'))
457
+ if (scfg.hooks?.SessionEnd) {
458
+ const kept = scfg.hooks.SessionEnd.filter((h) => !(h.hooks || []).some((c) => c?.command?.includes('capture.mjs')))
459
+ if (kept.length !== scfg.hooks.SessionEnd.length) {
460
+ if (!dryRun) { await backupOnce(settingsJson); scfg.hooks.SessionEnd = kept; await fs.writeFile(settingsJson, JSON.stringify(scfg, null, 2), 'utf8') }
461
+ removed = true
462
+ }
463
+ }
464
+ } catch {}
465
+ }
466
+ log(removed ? (dryRun ? '[dry-run] 将卸载 Claude Code MCP' : '✓ 已卸载 Claude Code MCP') : '✓ Claude Code MCP 未配置')
467
+ return { agent: 'claude-code', ok: true, removed }
468
+ }
469
+
470
+ /** 卸载 Codex 的 memory MCP 段。 */
471
+ export async function removeCodexMcp({ dryRun = false, log = () => {} } = {}) {
472
+ const codexToml = path.join(home, '.codex', 'config.toml')
473
+ if (!(await exists(codexToml))) return { agent: 'codex', ok: false, reason: 'not installed' }
474
+ const text = await fs.readFile(codexToml, 'utf8')
475
+ const sectionRe = /^\s*\[mcp_servers\.memory\]\s*$/m
476
+ if (!sectionRe.test(text)) { log('✓ Codex MCP 未配置'); return { agent: 'codex', ok: true, removed: false } }
477
+ if (!dryRun) {
478
+ const newText = text.replace(/^\s*# memory-eternal.*\n?/m, '').replace(/^\s*\[mcp_servers\.memory\]\s*\n(?:command\s*=.*\n|args\s*=.*\n)+\s*/m, '')
479
+ await backupOnce(codexToml)
480
+ await fs.writeFile(codexToml, newText.replace(/\n{3,}/g, '\n\n'), 'utf8')
481
+ }
482
+ log(dryRun ? '[dry-run] 将卸载 Codex MCP' : '✓ 已卸载 Codex MCP')
483
+ return { agent: 'codex', ok: true, removed: true }
484
+ }
485
+
486
+ /** 卸载 Cursor 的 memory MCP。 */
487
+ export async function removeCursorMcp({ dryRun = false, log = () => {} } = {}) {
488
+ const cursorJson = path.join(home, '.cursor', 'mcp.json')
489
+ if (!(await exists(cursorJson))) return { agent: 'cursor', ok: false, reason: 'not installed' }
490
+ try {
491
+ const cfg = JSON.parse(await fs.readFile(cursorJson, 'utf8'))
492
+ if (cfg.mcpServers && cfg.mcpServers.memory) {
493
+ if (!dryRun) { await backupOnce(cursorJson); delete cfg.mcpServers.memory; await fs.writeFile(cursorJson, JSON.stringify(cfg, null, 2), 'utf8') }
494
+ log(dryRun ? '[dry-run] 将卸载 Cursor MCP' : '✓ 已卸载 Cursor MCP')
495
+ return { agent: 'cursor', ok: true, removed: true }
496
+ }
497
+ } catch {}
498
+ log('✓ Cursor MCP 未配置')
499
+ return { agent: 'cursor', ok: true, removed: false }
500
+ }
501
+
502
+ /** 对单个智能体执行安装/卸载 MCP。action: 'install' | 'uninstall'。agent: 'claude-code'|'codex'|'cursor' */
503
+ export async function mcpAgentAction(agent, action, { dryRun = false, log = () => {} } = {}) {
504
+ if (action === 'install') {
505
+ const nodePath = process.execPath
506
+ if (agent === 'claude-code') return { ok: true, results: await setupClaude({ nodePath, dryRun, log }) }
507
+ if (agent === 'codex') return { ok: true, results: await setupCodex({ nodePath, dryRun, log }) }
508
+ if (agent === 'cursor') return { ok: true, results: await setupCursor({ nodePath, dryRun, log }) }
509
+ } else if (action === 'uninstall') {
510
+ if (agent === 'claude-code') return { ok: true, results: [await removeClaudeMcp({ dryRun, log })] }
511
+ if (agent === 'codex') return { ok: true, results: [await removeCodexMcp({ dryRun, log })] }
512
+ if (agent === 'cursor') return { ok: true, results: [await removeCursorMcp({ dryRun, log })] }
513
+ }
514
+ return { ok: false, error: `未知 agent/action: ${agent}/${action}` }
515
+ }