myagentmemory 0.4.17 → 0.5.1

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/dist/hooks.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type HookMode } from "./core.js";
1
2
  /** Override the detected home directory in deterministic tests. */
2
3
  export declare function _setHookHomeDirForTest(directory: string | null): void;
3
4
  export type HookAgentKey = "claude" | "codex" | "cursor" | "opencode" | "pi";
@@ -14,6 +15,24 @@ export interface DetectedHookTarget extends HookTargetInfo {
14
15
  detected: boolean;
15
16
  detectReason?: string;
16
17
  }
18
+ /**
19
+ * Read-only check whether the SessionStart hook for `key` is already present in
20
+ * the user's config. Mirrors each installer's "already installed" detection so
21
+ * the CLI can avoid prompting for hooks that don't need to be installed.
22
+ */
23
+ export declare function isHookInstalled(homeDir: string, key: HookAgentKey): boolean;
24
+ /**
25
+ * Read-only check whether the per-turn UserPromptSubmit hook is present.
26
+ * Only Claude Code and Codex support a per-prompt hook; cursor/opencode
27
+ * always return false (static rules only).
28
+ */
29
+ export declare function isUserPromptSubmitInstalled(homeDir: string, key: HookAgentKey): boolean;
30
+ /**
31
+ * Read-only check whether the periodic Stop-hook memory-write nudge is present.
32
+ * Claude Code only — Codex/Cursor/opencode don't have a confirmed equivalent
33
+ * block/reason protocol for this event yet.
34
+ */
35
+ export declare function isStopHookInstalled(homeDir: string, key: HookAgentKey): boolean;
17
36
  export declare function detectHookAgents(): {
18
37
  homeDir: string | null;
19
38
  targets: DetectedHookTarget[];
@@ -25,6 +44,7 @@ export interface HookInstallResult {
25
44
  path?: string;
26
45
  backup?: string;
27
46
  reason?: string;
47
+ mode?: HookMode;
28
48
  }
29
49
  export interface InstallHooksReport {
30
50
  ok: boolean;
@@ -32,7 +52,7 @@ export interface InstallHooksReport {
32
52
  results: HookInstallResult[];
33
53
  error?: string;
34
54
  }
35
- export declare function installHooks(agents: Set<HookAgentKey>): InstallHooksReport;
55
+ export declare function installHooks(agents: Set<HookAgentKey>, mode?: HookMode): InstallHooksReport;
36
56
  export interface UninstallHooksReport {
37
57
  ok: boolean;
38
58
  homeDir?: string;
package/dist/hooks.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
+ import { writeHookMode } from "./core.js";
4
5
  let homeDirOverride = null;
5
6
  /** Override the detected home directory in deterministic tests. */
6
7
  export function _setHookHomeDirForTest(directory) {
@@ -35,6 +36,12 @@ const HOOK_MARKER_END = "# END agent-memory hook";
35
36
  function sessionStartHookCommand(agent) {
36
37
  return `agent-memory hook session-start --agent ${agent}`;
37
38
  }
39
+ function userPromptSubmitHookCommand(agent) {
40
+ return `agent-memory hook user-prompt-submit --agent ${agent}`;
41
+ }
42
+ function stopHookCommand(agent) {
43
+ return `agent-memory hook stop --agent ${agent}`;
44
+ }
38
45
  function hookTargets(homeDir) {
39
46
  return [
40
47
  {
@@ -82,6 +89,101 @@ function hookTargets(homeDir) {
82
89
  },
83
90
  ];
84
91
  }
92
+ function hasClaudeHookGroup(homeDir, eventKey, command) {
93
+ const settingsPath = path.join(homeDir, ".claude", "settings.json");
94
+ if (!fs.existsSync(settingsPath))
95
+ return false;
96
+ const settings = readJsonConfig(settingsPath);
97
+ const hooks = settings.hooks ?? {};
98
+ const groups = Array.isArray(hooks[eventKey]) ? hooks[eventKey] : [];
99
+ for (const group of groups) {
100
+ if (!group || typeof group !== "object")
101
+ continue;
102
+ const g = group;
103
+ const list = Array.isArray(g.hooks) ? g.hooks : [];
104
+ for (const hook of list) {
105
+ if (!hook || typeof hook !== "object")
106
+ continue;
107
+ const h = hook;
108
+ if (h[HOOK_MARKER_JSON] === true && h.command === command)
109
+ return true;
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+ /**
115
+ * Read-only check whether the SessionStart hook for `key` is already present in
116
+ * the user's config. Mirrors each installer's "already installed" detection so
117
+ * the CLI can avoid prompting for hooks that don't need to be installed.
118
+ */
119
+ export function isHookInstalled(homeDir, key) {
120
+ try {
121
+ if (key === "claude")
122
+ return hasClaudeHookGroup(homeDir, "SessionStart", sessionStartHookCommand("claude"));
123
+ if (key === "codex") {
124
+ const configPath = path.join(homeDir, ".codex", "config.toml");
125
+ if (!fs.existsSync(configPath))
126
+ return false;
127
+ const existing = fs.readFileSync(configPath, "utf-8");
128
+ if (!existing.includes(HOOK_MARKER_BEGIN))
129
+ return false;
130
+ const command = sessionStartHookCommand("codex");
131
+ return existing.includes(`command = "${command}"`);
132
+ }
133
+ if (key === "cursor") {
134
+ return isCursorSessionStartHookRegistered(homeDir);
135
+ }
136
+ if (key === "opencode") {
137
+ const configPath = path.join(homeDir, ".config", "opencode", "opencode.json");
138
+ if (!fs.existsSync(configPath))
139
+ return false;
140
+ const config = readJsonConfig(configPath);
141
+ const raw = config.instructions;
142
+ const list = Array.isArray(raw) ? raw : [];
143
+ const instructionsPath = path.join(homeDir, ".agent-memory", "hooks", "opencode.md");
144
+ return list.includes(instructionsPath);
145
+ }
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ return false;
151
+ }
152
+ /**
153
+ * Read-only check whether the per-turn UserPromptSubmit hook is present.
154
+ * Only Claude Code and Codex support a per-prompt hook; cursor/opencode
155
+ * always return false (static rules only).
156
+ */
157
+ export function isUserPromptSubmitInstalled(homeDir, key) {
158
+ try {
159
+ if (key === "claude")
160
+ return hasClaudeHookGroup(homeDir, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
161
+ if (key === "codex") {
162
+ const configPath = path.join(homeDir, ".codex", "config.toml");
163
+ if (!fs.existsSync(configPath))
164
+ return false;
165
+ const existing = fs.readFileSync(configPath, "utf-8");
166
+ if (!existing.includes(HOOK_MARKER_BEGIN))
167
+ return false;
168
+ return existing.includes(`command = "${userPromptSubmitHookCommand("codex")}"`);
169
+ }
170
+ }
171
+ catch { }
172
+ return false;
173
+ }
174
+ /**
175
+ * Read-only check whether the periodic Stop-hook memory-write nudge is present.
176
+ * Claude Code only — Codex/Cursor/opencode don't have a confirmed equivalent
177
+ * block/reason protocol for this event yet.
178
+ */
179
+ export function isStopHookInstalled(homeDir, key) {
180
+ try {
181
+ if (key === "claude")
182
+ return hasClaudeHookGroup(homeDir, "Stop", stopHookCommand("claude"));
183
+ }
184
+ catch { }
185
+ return false;
186
+ }
85
187
  export function detectHookAgents() {
86
188
  const homeDir = resolveHomeDir();
87
189
  if (!homeDir)
@@ -130,17 +232,17 @@ function writeJson(filePath, data) {
130
232
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
131
233
  fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
132
234
  }
133
- function installClaudeCodeHook(homeDir) {
134
- const settingsPath = path.join(homeDir, ".claude", "settings.json");
135
- const backup = backupOnce(settingsPath);
136
- const settings = readJsonConfig(settingsPath);
137
- const hooks = settings.hooks ?? {};
138
- const sessionStart = Array.isArray(hooks.SessionStart) ? [...hooks.SessionStart] : [];
139
- // Idempotency: look for any existing entry tagged with our marker.
140
- const command = sessionStartHookCommand("claude");
141
- let managed = 0;
142
- let updated = 0;
143
- for (const group of sessionStart) {
235
+ /**
236
+ * Idempotently upsert the agent-memory-managed hook group for `eventKey`
237
+ * (SessionStart or UserPromptSubmit) with `command`. Returns `{ changed,
238
+ * hadManaged }` so the caller can decide between "installed" / "updated" /
239
+ * "already installed" reasons.
240
+ */
241
+ function upsertClaudeHookGroup(hooks, eventKey, command) {
242
+ const groups = Array.isArray(hooks[eventKey]) ? [...hooks[eventKey]] : [];
243
+ const managedIndexes = [];
244
+ for (let i = 0; i < groups.length; i++) {
245
+ const group = groups[i];
144
246
  if (!group || typeof group !== "object")
145
247
  continue;
146
248
  const g = group;
@@ -148,64 +250,165 @@ function installClaudeCodeHook(homeDir) {
148
250
  for (const hook of list) {
149
251
  if (!hook || typeof hook !== "object")
150
252
  continue;
151
- const managedHook = hook;
152
- if (managedHook[HOOK_MARKER_JSON] !== true)
153
- continue;
154
- managed++;
155
- if (managedHook.command !== command) {
156
- managedHook.command = command;
157
- updated++;
253
+ const h = hook;
254
+ if (h[HOOK_MARKER_JSON] === true) {
255
+ managedIndexes.push(i);
256
+ break;
158
257
  }
159
258
  }
160
259
  }
161
- if (managed && !updated) {
162
- return { key: "claude", label: "Claude Code", installed: false, path: settingsPath, reason: "already installed" };
163
- }
164
- if (updated) {
165
- hooks.SessionStart = sessionStart;
166
- settings.hooks = hooks;
167
- writeJson(settingsPath, settings);
168
- return { key: "claude", label: "Claude Code", installed: true, path: settingsPath, backup, reason: "updated" };
260
+ if (managedIndexes.length > 0) {
261
+ const keep = managedIndexes[0];
262
+ const dupes = managedIndexes.slice(1);
263
+ for (const idx of dupes.reverse())
264
+ groups.splice(idx, 1);
265
+ const group = groups[keep];
266
+ let changed = dupes.length > 0;
267
+ if ("matcher" in group) {
268
+ delete group.matcher;
269
+ changed = true;
270
+ }
271
+ const list = group.hooks;
272
+ for (const h of list) {
273
+ if (h[HOOK_MARKER_JSON] === true && h.command !== command) {
274
+ h.command = command;
275
+ changed = true;
276
+ }
277
+ }
278
+ hooks[eventKey] = groups;
279
+ return { changed, hadManaged: true };
169
280
  }
170
- sessionStart.push({
171
- matcher: "startup|resume",
172
- hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }],
281
+ // No existing managed group — add a fresh one without a matcher so it fires on all harnesses.
282
+ groups.push({ hooks: [{ type: "command", command, [HOOK_MARKER_JSON]: true }] });
283
+ hooks[eventKey] = groups;
284
+ return { changed: true, hadManaged: false };
285
+ }
286
+ /**
287
+ * Remove all agent-memory-managed hook entries for `eventKey`. Used when
288
+ * downgrading from per-turn back to stable (drops UserPromptSubmit).
289
+ * Returns true if anything was removed.
290
+ */
291
+ function removeClaudeHookGroup(hooks, eventKey) {
292
+ const groups = Array.isArray(hooks[eventKey]) ? hooks[eventKey] : [];
293
+ if (groups.length === 0)
294
+ return false;
295
+ let removed = 0;
296
+ const filtered = groups
297
+ .map((group) => {
298
+ if (!group || typeof group !== "object")
299
+ return group;
300
+ const g = { ...group };
301
+ const list = Array.isArray(g.hooks) ? g.hooks : [];
302
+ const kept = list.filter((h) => {
303
+ const isOurs = h && typeof h === "object" && h[HOOK_MARKER_JSON] === true;
304
+ if (isOurs)
305
+ removed++;
306
+ return !isOurs;
307
+ });
308
+ g.hooks = kept;
309
+ return g;
310
+ })
311
+ .filter((group) => {
312
+ if (!group || typeof group !== "object")
313
+ return true;
314
+ const g = group;
315
+ return Array.isArray(g.hooks) && g.hooks.length > 0;
173
316
  });
174
- hooks.SessionStart = sessionStart;
317
+ if (removed === 0)
318
+ return false;
319
+ if (filtered.length === 0)
320
+ delete hooks[eventKey];
321
+ else
322
+ hooks[eventKey] = filtered;
323
+ return true;
324
+ }
325
+ function installClaudeCodeHook(homeDir, mode = "per-turn") {
326
+ const settingsPath = path.join(homeDir, ".claude", "settings.json");
327
+ const backup = backupOnce(settingsPath);
328
+ const settings = readJsonConfig(settingsPath);
329
+ const hooks = settings.hooks ?? {};
330
+ const session = upsertClaudeHookGroup(hooks, "SessionStart", sessionStartHookCommand("claude"));
331
+ let promptChanged = false;
332
+ let promptHadManaged = false;
333
+ if (mode === "per-turn") {
334
+ const prompt = upsertClaudeHookGroup(hooks, "UserPromptSubmit", userPromptSubmitHookCommand("claude"));
335
+ promptChanged = prompt.changed;
336
+ promptHadManaged = prompt.hadManaged;
337
+ }
338
+ else {
339
+ promptChanged = removeClaudeHookGroup(hooks, "UserPromptSubmit");
340
+ }
341
+ // Stop backs the write side of memory with a periodic nudge. It is orthogonal
342
+ // to stable/per-turn context injection, so it is installed unconditionally.
343
+ const stop = upsertClaudeHookGroup(hooks, "Stop", stopHookCommand("claude"));
344
+ // Remove the ineffective PreCompact reminder from pre-release 0.5.0 installs.
345
+ // Claude Code does not inject plain hook stdout for that event.
346
+ const legacyPreCompactRemoved = removeClaudeHookGroup(hooks, "PreCompact");
347
+ if (!session.changed && !promptChanged && !stop.changed && !legacyPreCompactRemoved) {
348
+ return {
349
+ key: "claude",
350
+ label: "Claude Code",
351
+ installed: false,
352
+ path: settingsPath,
353
+ reason: "already installed",
354
+ mode,
355
+ };
356
+ }
175
357
  settings.hooks = hooks;
176
358
  writeJson(settingsPath, settings);
177
- return { key: "claude", label: "Claude Code", installed: true, path: settingsPath, backup };
359
+ const reason = session.hadManaged || promptHadManaged || stop.hadManaged || legacyPreCompactRemoved ? "updated" : undefined;
360
+ return { key: "claude", label: "Claude Code", installed: true, path: settingsPath, backup, mode, reason };
178
361
  }
179
- function installCodexHook(homeDir) {
362
+ function installCodexHook(homeDir, mode = "per-turn") {
180
363
  const configPath = path.join(homeDir, ".codex", "config.toml");
181
364
  const backup = backupOnce(configPath);
182
365
  const existing = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf-8") : "";
183
- const command = sessionStartHookCommand("codex");
184
- const block = [
366
+ const sessionCommand = sessionStartHookCommand("codex");
367
+ const promptCommand = userPromptSubmitHookCommand("codex");
368
+ const lines = [
185
369
  HOOK_MARKER_BEGIN,
186
370
  "[[hooks.SessionStart]]",
187
371
  'matcher = "startup|resume"',
188
372
  "",
189
373
  "[[hooks.SessionStart.hooks]]",
190
374
  'type = "command"',
191
- `command = "${command}"`,
192
- HOOK_MARKER_END,
193
- ].join("\n");
375
+ `command = "${sessionCommand}"`,
376
+ ];
377
+ if (mode === "per-turn") {
378
+ lines.push("", "[[hooks.UserPromptSubmit]]", "", "[[hooks.UserPromptSubmit.hooks]]", 'type = "command"', `command = "${promptCommand}"`);
379
+ }
380
+ lines.push(HOOK_MARKER_END);
381
+ const block = lines.join("\n");
194
382
  if (existing.includes(HOOK_MARKER_BEGIN)) {
195
383
  const escapeRe = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
196
384
  const pattern = new RegExp(`${escapeRe(HOOK_MARKER_BEGIN)}[\\s\\S]*?${escapeRe(HOOK_MARKER_END)}`);
197
385
  const current = existing.match(pattern)?.[0] ?? "";
198
- if (current.includes(`command = "${command}"`)) {
199
- return { key: "codex", label: "Codex", installed: false, path: configPath, reason: "already installed" };
386
+ if (current === block) {
387
+ return {
388
+ key: "codex",
389
+ label: "Codex",
390
+ installed: false,
391
+ path: configPath,
392
+ reason: "already installed",
393
+ mode,
394
+ };
200
395
  }
201
396
  fs.writeFileSync(configPath, existing.replace(pattern, block), "utf-8");
202
- return { key: "codex", label: "Codex", installed: true, path: configPath, backup, reason: "updated" };
397
+ return {
398
+ key: "codex",
399
+ label: "Codex",
400
+ installed: true,
401
+ path: configPath,
402
+ backup,
403
+ reason: "updated",
404
+ mode,
405
+ };
203
406
  }
204
407
  const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
205
408
  const next = `${existing}${separator}${block}\n`;
206
409
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
207
410
  fs.writeFileSync(configPath, next, "utf-8");
208
- return { key: "codex", label: "Codex", installed: true, path: configPath, backup };
411
+ return { key: "codex", label: "Codex", installed: true, path: configPath, backup, mode };
209
412
  }
210
413
  const CURSOR_RULE_BODY = `---
211
414
  description: Load persistent memory context from agent-memory
@@ -223,12 +426,76 @@ scratchpad items, and long-term memory. Prefer it over guessing.
223
426
  function installCursorRule(homeDir) {
224
427
  const rulesDir = path.join(homeDir, ".cursor", "rules");
225
428
  const rulePath = path.join(rulesDir, "agent-memory.mdc");
226
- if (fs.existsSync(rulePath)) {
227
- return { key: "cursor", label: "Cursor", installed: false, path: rulePath, reason: "already installed" };
228
- }
429
+ if (fs.existsSync(rulePath))
430
+ return;
229
431
  fs.mkdirSync(rulesDir, { recursive: true });
230
432
  fs.writeFileSync(rulePath, CURSOR_RULE_BODY, "utf-8");
231
- return { key: "cursor", label: "Cursor", installed: true, path: rulePath };
433
+ }
434
+ // Cursor's `sessionStart` hook (https://cursor.com/docs/agent/hooks) fires automatically when a
435
+ // new conversation is created and can inject `additional_context` without the model choosing to
436
+ // run anything — unlike the static .mdc rule above, this is a real, code-level guarantee.
437
+ const CURSOR_HOOK_SCRIPT_RELATIVE = path.join("hooks", "agent-memory-session-start.js");
438
+ const CURSOR_HOOK_SCRIPT_BODY = `#!/usr/bin/env node
439
+ const { execSync } = require("node:child_process");
440
+ let context = "";
441
+ try {
442
+ context = execSync("agent-memory context --no-search", { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] });
443
+ } catch {
444
+ // agent-memory not on PATH, or the memory dir isn't initialized yet — fail open with no context.
445
+ }
446
+ process.stdout.write(JSON.stringify({ additional_context: context }));
447
+ `;
448
+ function isCursorSessionStartHookRegistered(homeDir) {
449
+ const hooksJsonPath = path.join(homeDir, ".cursor", "hooks.json");
450
+ if (!fs.existsSync(hooksJsonPath))
451
+ return false;
452
+ try {
453
+ const config = readJsonConfig(hooksJsonPath);
454
+ const hooks = config.hooks ?? {};
455
+ const sessionStart = Array.isArray(hooks.sessionStart) ? hooks.sessionStart : [];
456
+ return sessionStart.some((entry) => entry &&
457
+ typeof entry === "object" &&
458
+ entry.command === CURSOR_HOOK_SCRIPT_RELATIVE);
459
+ }
460
+ catch {
461
+ return false;
462
+ }
463
+ }
464
+ function installCursorHook(homeDir) {
465
+ const cursorDir = path.join(homeDir, ".cursor");
466
+ const scriptPath = path.join(cursorDir, CURSOR_HOOK_SCRIPT_RELATIVE);
467
+ const hooksJsonPath = path.join(cursorDir, "hooks.json");
468
+ // Cheap, harmless fallback for Cursor installs where hooks are disabled or unavailable.
469
+ installCursorRule(homeDir);
470
+ fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
471
+ const scriptChanged = !fs.existsSync(scriptPath) || fs.readFileSync(scriptPath, "utf-8") !== CURSOR_HOOK_SCRIPT_BODY;
472
+ if (scriptChanged) {
473
+ fs.writeFileSync(scriptPath, CURSOR_HOOK_SCRIPT_BODY, "utf-8");
474
+ fs.chmodSync(scriptPath, 0o755);
475
+ }
476
+ const alreadyRegistered = isCursorSessionStartHookRegistered(homeDir);
477
+ if (alreadyRegistered && !scriptChanged) {
478
+ return { key: "cursor", label: "Cursor", installed: false, path: hooksJsonPath, reason: "already installed" };
479
+ }
480
+ const backup = backupOnce(hooksJsonPath);
481
+ const config = readJsonConfig(hooksJsonPath);
482
+ if (typeof config.version !== "number")
483
+ config.version = 1;
484
+ const hooks = config.hooks ?? {};
485
+ const sessionStart = Array.isArray(hooks.sessionStart) ? [...hooks.sessionStart] : [];
486
+ if (!alreadyRegistered)
487
+ sessionStart.push({ command: CURSOR_HOOK_SCRIPT_RELATIVE });
488
+ hooks.sessionStart = sessionStart;
489
+ config.hooks = hooks;
490
+ writeJson(hooksJsonPath, config);
491
+ return {
492
+ key: "cursor",
493
+ label: "Cursor",
494
+ installed: true,
495
+ path: hooksJsonPath,
496
+ backup,
497
+ reason: alreadyRegistered ? "updated" : undefined,
498
+ };
232
499
  }
233
500
  const OPENCODE_INSTRUCTIONS_BODY = `# agent-memory
234
501
 
@@ -257,7 +524,7 @@ function installOpencodeInstructions(homeDir) {
257
524
  writeJson(configPath, config);
258
525
  return { key: "opencode", label: "opencode", installed: true, path: configPath, backup };
259
526
  }
260
- export function installHooks(agents) {
527
+ export function installHooks(agents, mode = "per-turn") {
261
528
  const { homeDir, targets } = detectHookAgents();
262
529
  if (!homeDir) {
263
530
  return {
@@ -267,6 +534,7 @@ export function installHooks(agents) {
267
534
  };
268
535
  }
269
536
  const results = [];
537
+ let anyInstalled = false;
270
538
  for (const target of targets) {
271
539
  if (!agents.has(target.key))
272
540
  continue;
@@ -289,14 +557,20 @@ export function installHooks(agents) {
289
557
  continue;
290
558
  }
291
559
  try {
560
+ let result;
292
561
  if (target.key === "claude")
293
- results.push(installClaudeCodeHook(homeDir));
562
+ result = installClaudeCodeHook(homeDir, mode);
294
563
  else if (target.key === "codex")
295
- results.push(installCodexHook(homeDir));
564
+ result = installCodexHook(homeDir, mode);
296
565
  else if (target.key === "cursor")
297
- results.push(installCursorRule(homeDir));
566
+ result = installCursorHook(homeDir);
298
567
  else if (target.key === "opencode")
299
- results.push(installOpencodeInstructions(homeDir));
568
+ result = installOpencodeInstructions(homeDir);
569
+ else
570
+ continue;
571
+ results.push(result);
572
+ if (result.installed)
573
+ anyInstalled = true;
300
574
  }
301
575
  catch (err) {
302
576
  results.push({
@@ -307,6 +581,14 @@ export function installHooks(agents) {
307
581
  });
308
582
  }
309
583
  }
584
+ if (anyInstalled) {
585
+ try {
586
+ writeHookMode(mode);
587
+ }
588
+ catch {
589
+ // Persisting the mode is best-effort — install output is authoritative.
590
+ }
591
+ }
310
592
  return { ok: true, homeDir, results };
311
593
  }
312
594
  function uninstallClaudeCodeHook(homeDir) {
@@ -316,35 +598,13 @@ function uninstallClaudeCodeHook(homeDir) {
316
598
  }
317
599
  const settings = readJsonConfig(settingsPath);
318
600
  const hooks = settings.hooks ?? {};
319
- const sessionStart = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
320
- let removed = 0;
321
- const filtered = sessionStart
322
- .map((group) => {
323
- if (!group || typeof group !== "object")
324
- return group;
325
- const g = { ...group };
326
- const list = Array.isArray(g.hooks) ? g.hooks : [];
327
- const kept = list.filter((h) => {
328
- const isOurs = h && typeof h === "object" && h[HOOK_MARKER_JSON] === true;
329
- if (isOurs)
330
- removed++;
331
- return !isOurs;
332
- });
333
- g.hooks = kept;
334
- return g;
335
- })
336
- .filter((group) => {
337
- if (!group || typeof group !== "object")
338
- return true;
339
- const g = group;
340
- return Array.isArray(g.hooks) && g.hooks.length > 0;
341
- });
342
- if (removed === 0) {
601
+ const sessionRemoved = removeClaudeHookGroup(hooks, "SessionStart");
602
+ const promptRemoved = removeClaudeHookGroup(hooks, "UserPromptSubmit");
603
+ const stopRemoved = removeClaudeHookGroup(hooks, "Stop");
604
+ const legacyPreCompactRemoved = removeClaudeHookGroup(hooks, "PreCompact");
605
+ if (!sessionRemoved && !promptRemoved && !stopRemoved && !legacyPreCompactRemoved) {
343
606
  return { key: "claude", label: "Claude Code", installed: false, reason: "not installed" };
344
607
  }
345
- hooks.SessionStart = filtered;
346
- if (filtered.length === 0)
347
- delete hooks.SessionStart;
348
608
  if (Object.keys(hooks).length === 0)
349
609
  delete settings.hooks;
350
610
  else
@@ -367,19 +627,54 @@ function uninstallCodexHook(homeDir) {
367
627
  fs.writeFileSync(configPath, next, "utf-8");
368
628
  return { key: "codex", label: "Codex", installed: true, path: configPath };
369
629
  }
370
- function uninstallCursorRule(homeDir) {
630
+ function uninstallCursorHook(homeDir) {
371
631
  const rulePath = path.join(homeDir, ".cursor", "rules", "agent-memory.mdc");
372
- if (!fs.existsSync(rulePath)) {
373
- return { key: "cursor", label: "Cursor", installed: false, reason: "not installed" };
632
+ const scriptPath = path.join(homeDir, ".cursor", CURSOR_HOOK_SCRIPT_RELATIVE);
633
+ const hooksJsonPath = path.join(homeDir, ".cursor", "hooks.json");
634
+ let touched = false;
635
+ if (fs.existsSync(rulePath)) {
636
+ fs.unlinkSync(rulePath);
637
+ try {
638
+ fs.rmdirSync(path.dirname(rulePath));
639
+ }
640
+ catch {
641
+ // non-empty; fine
642
+ }
643
+ touched = true;
374
644
  }
375
- fs.unlinkSync(rulePath);
376
- try {
377
- fs.rmdirSync(path.dirname(rulePath));
645
+ if (fs.existsSync(hooksJsonPath)) {
646
+ try {
647
+ const config = readJsonConfig(hooksJsonPath);
648
+ const hooks = config.hooks ?? {};
649
+ const sessionStart = Array.isArray(hooks.sessionStart) ? hooks.sessionStart : [];
650
+ const filtered = sessionStart.filter((entry) => !(entry &&
651
+ typeof entry === "object" &&
652
+ entry.command === CURSOR_HOOK_SCRIPT_RELATIVE));
653
+ if (filtered.length !== sessionStart.length) {
654
+ if (filtered.length === 0)
655
+ delete hooks.sessionStart;
656
+ else
657
+ hooks.sessionStart = filtered;
658
+ if (Object.keys(hooks).length === 0)
659
+ delete config.hooks;
660
+ else
661
+ config.hooks = hooks;
662
+ writeJson(hooksJsonPath, config);
663
+ touched = true;
664
+ }
665
+ }
666
+ catch {
667
+ // invalid hooks.json — leave it for the user to fix rather than guessing.
668
+ }
378
669
  }
379
- catch {
380
- // non-empty; fine
670
+ if (fs.existsSync(scriptPath)) {
671
+ fs.unlinkSync(scriptPath);
672
+ touched = true;
673
+ }
674
+ if (!touched) {
675
+ return { key: "cursor", label: "Cursor", installed: false, reason: "not installed" };
381
676
  }
382
- return { key: "cursor", label: "Cursor", installed: true, path: rulePath };
677
+ return { key: "cursor", label: "Cursor", installed: true, path: hooksJsonPath };
383
678
  }
384
679
  function uninstallOpencodeInstructions(homeDir) {
385
680
  const configPath = path.join(homeDir, ".config", "opencode", "opencode.json");
@@ -427,7 +722,7 @@ export function uninstallHooks(agents) {
427
722
  else if (key === "codex")
428
723
  results.push(uninstallCodexHook(homeDir));
429
724
  else if (key === "cursor")
430
- results.push(uninstallCursorRule(homeDir));
725
+ results.push(uninstallCursorHook(homeDir));
431
726
  else if (key === "opencode")
432
727
  results.push(uninstallOpencodeInstructions(homeDir));
433
728
  }
@@ -0,0 +1,27 @@
1
+ export interface McpToolInputSchema {
2
+ type: "object";
3
+ properties: Record<string, {
4
+ type: string;
5
+ description?: string;
6
+ enum?: string[];
7
+ }>;
8
+ required?: string[];
9
+ }
10
+ export interface McpToolDefinition {
11
+ name: string;
12
+ description: string;
13
+ inputSchema: McpToolInputSchema;
14
+ }
15
+ export type McpToolHandler = (input: Record<string, unknown>) => unknown | Promise<unknown>;
16
+ export declare class StdioMcpServer {
17
+ private readonly version;
18
+ private readonly tools;
19
+ private readonly startupHooks;
20
+ constructor(version?: string);
21
+ addTool(definition: McpToolDefinition, handler: McpToolHandler): void;
22
+ addStartupHook(fn: () => void | Promise<void>): void;
23
+ start(): Promise<void>;
24
+ private handleMessage;
25
+ private respond;
26
+ private respondError;
27
+ }