@plur-ai/mcp 0.20.1 → 0.21.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/index.d.ts CHANGED
@@ -27,5 +27,56 @@ declare function compareSemver(a: string, b: string): number;
27
27
  * anchors to start-of-line so a leading space breaks the match.
28
28
  */
29
29
  declare function extractManifestVersion(skillMdPath: string): string | null;
30
+ /**
31
+ * Hook set for `plur-mcp init`. Mirrors `buildInjectionHooks` in
32
+ * @plur-ai/cli's init.ts, which this package cannot import. The rehydrate
33
+ * entry must stay identical to the cli's; test/init-hooks.test.ts fails if
34
+ * the two diverge (#1279).
35
+ */
36
+ declare function buildPlurHooks(cli: string): Record<string, HookEntry[]>;
37
+ interface Settings {
38
+ hooks?: Record<string, HookEntry[]>;
39
+ [key: string]: unknown;
40
+ }
41
+ interface HookEntry {
42
+ matcher?: string;
43
+ hooks: Array<{
44
+ type: string;
45
+ command: string;
46
+ args?: string[];
47
+ timeout?: number;
48
+ async?: boolean;
49
+ }>;
50
+ }
51
+ /**
52
+ * Write the PLUR section into CLAUDE.md with core's `upsertInstructionSection`
53
+ * — the same logic as `plur init`: only a section PLUR shipped is replaced,
54
+ * one the user wrote or edited is kept and the new one added beside it, and a
55
+ * timestamped backup precedes any change to an existing file.
56
+ */
57
+ declare function installClaudeMd(claudeMdPath?: string): Promise<string>;
58
+ /**
59
+ * Merge PLUR hooks into Claude Code settings (#1279). No I/O apart from
60
+ * reading plur-hook.meta.json for the exec-form check.
61
+ * A hook is PLUR's only when isPlurHookSpec says so: the whole command is
62
+ * PLUR's launcher (the shim, with any slash direction or quoting, or the npx
63
+ * fallback, or on Windows the exec form `plur init` writes) followed by a
64
+ * `hook-*` subcommand and plain arguments (decisions H2, F4).
65
+ * - No PLUR hook present: append the full set ('installed').
66
+ * - PLUR hooks present: remove PLUR's hooks from PostCompact, which cannot
67
+ * carry context (#1274), one hook at a time, dropping an entry only when
68
+ * it has no hooks left. If a PLUR rehydrate was among them, add the
69
+ * SessionStart(compact) entry unless one is there ('healed'). A file with
70
+ * PLUR hooks but no rehydrate (the global file `plur init --project` writes) gets
71
+ * none added. Nothing else changes, so a fuller
72
+ * `plur init` install is not replaced by this smaller set.
73
+ * - Otherwise 'already', and the settings come back unchanged.
74
+ * User hooks are never removed or reordered, including a user hook that
75
+ * shares an entry with a PLUR hook.
76
+ */
77
+ declare function applyPlurHooks(settings: Settings, hooksMap: Record<string, HookEntry[]>): {
78
+ settings: Settings;
79
+ status: 'installed' | 'healed' | 'already';
80
+ };
30
81
 
31
- export { compareSemver, extractManifestVersion };
82
+ export { type HookEntry, type Settings, applyPlurHooks, buildPlurHooks, compareSemver, extractManifestVersion, installClaudeMd };
package/dist/index.js CHANGED
@@ -1,13 +1,72 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  VERSION
4
- } from "./chunk-445S5QNU.js";
4
+ } from "./chunk-O2NJFMNP.js";
5
5
 
6
6
  // src/index.ts
7
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "fs";
8
- import { join } from "path";
7
+ import { existsSync, readFileSync as readFileSync2, writeFileSync, mkdirSync, readdirSync, statSync } from "fs";
8
+ import { join as join2 } from "path";
9
9
  import { fileURLToPath } from "url";
10
- import { homedir, platform } from "os";
10
+ import { homedir as homedir2, platform } from "os";
11
+
12
+ // src/hook-command.ts
13
+ import { homedir } from "os";
14
+ import { readFileSync } from "fs";
15
+ import { join } from "path";
16
+ function isPlurHookSpec(spec) {
17
+ const command = typeof spec.command === "string" ? spec.command : "";
18
+ const args = Array.isArray(spec.args) ? spec.args.filter((a) => typeof a === "string") : [];
19
+ if (args.length === 0) return isPlurHookCommand(command);
20
+ const norm = (p) => p.replace(/\\/g, "/").replace(/"/g, "").toLowerCase();
21
+ const cmd = norm(command);
22
+ if (/(^|\/)cmd(\.exe)?$/.test(cmd)) return isPlurHookCommand(args.filter((a) => a.toLowerCase() !== "/c").join(" "));
23
+ if (!/(^|\/)node(\.exe)?$/.test(cmd)) return false;
24
+ if (!/^hook-[a-z0-9][a-z0-9-]*$/.test(args[1] ?? "")) return false;
25
+ const target = normEntry(args[0]);
26
+ return recordedCliEntries().some((e) => normEntry(e) === target);
27
+ }
28
+ function normEntry(p) {
29
+ return p.replace(/\\/g, "/").replace(/"/g, "").toLowerCase();
30
+ }
31
+ function entriesOf(meta) {
32
+ if (!meta || typeof meta !== "object") return [];
33
+ const m = meta;
34
+ if (Array.isArray(m.entrypoints)) return m.entrypoints.filter((e) => typeof e === "string" && e.length > 0);
35
+ return typeof m.entrypoint === "string" && m.entrypoint.length > 0 ? [m.entrypoint] : [];
36
+ }
37
+ function recordedCliEntries() {
38
+ try {
39
+ return entriesOf(JSON.parse(readFileSync(join(homedir(), ".plur", "bin", "plur-hook.meta.json"), "utf8")));
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+ var PLAIN = "[^\\s\"'`$&;|<>()]";
45
+ var HOOK_TAIL = `[ \\t]+hook-[a-z0-9][a-z0-9-]*(?:[ \\t]+${PLAIN}+)*[ \\t]*$`;
46
+ var SHIM_FILE = "(?:plur-hook(?:\\.cmd)?|(?<=/(?:\\.plur|plur~\\d+)/bin/)plur-h~\\d+\\.cmd)";
47
+ var SHIM_PATH = [
48
+ `(?:${PLAIN}*/)?${SHIM_FILE}`,
49
+ `"(?:[^"]*/)?${SHIM_FILE}"`
50
+ ].join("|");
51
+ var SHIM_FORM = new RegExp(`^(?:&[ \\t]+)?(?:${SHIM_PATH})${HOOK_TAIL}`);
52
+ var NPX_FORM = new RegExp(`^npx(?:[ \\t]+-y)?[ \\t]+@plur-ai/cli(?:@${PLAIN}+)?${HOOK_TAIL}`);
53
+ var HOME_SHIM_TAIL = new RegExp(`^${HOOK_TAIL}`);
54
+ function homeShimPaths() {
55
+ const bin = `${homedir().replace(/\\/g, "/").toLowerCase().replace(/\/+$/, "")}/.plur/bin/`;
56
+ if (/[\r\n]/.test(bin)) return [];
57
+ return [`${bin}plur-hook.cmd`, `${bin}plur-hook`];
58
+ }
59
+ var MAX_HOOK_COMMAND_LENGTH = 8192;
60
+ function isPlurHookCommand(command) {
61
+ return command.length <= MAX_HOOK_COMMAND_LENGTH && matchesPlurHookLauncher(command);
62
+ }
63
+ function matchesPlurHookLauncher(command) {
64
+ const normalised = command.trim().replace(/\\/g, "/").toLowerCase();
65
+ if (SHIM_FORM.test(normalised) || NPX_FORM.test(normalised)) return true;
66
+ return homeShimPaths().some((p) => normalised.startsWith(p) && HOME_SHIM_TAIL.test(normalised.slice(p.length)));
67
+ }
68
+
69
+ // src/index.ts
11
70
  var HELP = `plur-mcp v${VERSION} \u2014 persistent memory for AI agents
12
71
 
13
72
  Usage:
@@ -49,7 +108,7 @@ function compareSemver(a, b) {
49
108
  }
50
109
  function extractManifestVersion(skillMdPath) {
51
110
  try {
52
- const content = readFileSync(skillMdPath, "utf8");
111
+ const content = readFileSync2(skillMdPath, "utf8");
53
112
  const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/m);
54
113
  if (!fmMatch) return null;
55
114
  const versionMatch = fmMatch[1].match(/^version:\s*"?([^"\n]+)"?\s*$/m);
@@ -59,40 +118,59 @@ function extractManifestVersion(skillMdPath) {
59
118
  }
60
119
  }
61
120
  var _shimName = platform() === "win32" ? "plur-hook.cmd" : "plur-hook";
62
- var _shimCandidate = join(homedir(), ".plur", "bin", _shimName);
121
+ var _shimCandidate = join2(homedir2(), ".plur", "bin", _shimName);
63
122
  var CLI = existsSync(_shimCandidate) ? _shimCandidate : "npx @plur-ai/cli";
64
- var PLUR_HOOKS = {
65
- // --- Session lifecycle ---
66
- UserPromptSubmit: [{
67
- hooks: [{ type: "command", command: `${CLI} hook-inject`, timeout: 15 }]
68
- }],
69
- PostCompact: [{
70
- matcher: "auto|manual",
71
- hooks: [{ type: "command", command: `${CLI} hook-inject --rehydrate`, timeout: 15 }]
72
- }],
73
- // Auto-close the memory lifecycle at session end (Claude Code SessionEnd,
74
- // shipped v1.0.85) — captures a closing episode and cleans up the session
75
- // checkpoint even if the agent forgot to call plur_session_end (#217).
76
- SessionEnd: [{
77
- hooks: [{ type: "command", command: `${CLI} hook-session-end`, timeout: 5 }]
78
- }],
79
- // --- Contextual injection ---
80
- PreToolUse: [
81
- { matcher: "EnterPlanMode", hooks: [{ type: "command", command: `${CLI} hook-inject --event plan_mode`, timeout: 10 }] },
82
- { matcher: "Skill", hooks: [{ type: "command", command: `${CLI} hook-inject --event skill`, timeout: 10 }] },
83
- { matcher: "Agent", hooks: [{ type: "command", command: `${CLI} hook-inject --event agent`, timeout: 10 }] },
84
- { matcher: "Bash|Edit|Write|Agent", hooks: [{ type: "command", command: `${CLI} hook-observe`, timeout: 3 }] }
85
- ],
86
- PostToolUse: [
87
- { matcher: "Bash|Edit|Write|Agent", hooks: [{ type: "command", command: `${CLI} hook-observe --post`, timeout: 3 }] }
88
- ],
89
- SubagentStart: [
90
- { matcher: ".*", hooks: [{ type: "command", command: `${CLI} hook-inject --event subagent`, timeout: 10 }] }
91
- ],
92
- Stop: [
93
- { matcher: "*", hooks: [{ type: "command", command: `${CLI} hook-learn-check`, timeout: 2 }] }
94
- ]
95
- };
123
+ function buildPlurHooks(cli) {
124
+ return {
125
+ // --- Session lifecycle ---
126
+ // Sync with a 20s budget, like `plur init` (#1313): async context reaches
127
+ // Claude Code only at the next safe point, so a first reply without tool
128
+ // calls had no memory. hook-inject exits by itself after 15s, so the
129
+ // timeout sits above that. Keep in step with CLAUDE_INJECT_TIMEOUT_S in
130
+ // @plur-ai/cli's lib/claude-inject-budget.ts.
131
+ UserPromptSubmit: [{
132
+ hooks: [{ type: "command", command: `${cli} hook-inject`, timeout: 20 }]
133
+ }],
134
+ // Re-inject after compaction. SessionStart with matcher "compact" fires
135
+ // right after compaction and can carry context; PostCompact cannot
136
+ // (#1274, #1279). Re-running init moves an old PostCompact entry here.
137
+ SessionStart: [{
138
+ matcher: "compact",
139
+ hooks: [{ type: "command", command: `${cli} hook-inject --rehydrate`, timeout: 20 }]
140
+ }, {
141
+ // `claude --resume` keeps the session id, and SessionEnd below deleted
142
+ // its folder-question nonces: the resumed session is asked again with a
143
+ // fresh nonce (#1347, option C). Identical to `plur init`'s entry.
144
+ matcher: "resume",
145
+ hooks: [{ type: "command", command: `${cli} hook-session-resume`, timeout: 3 }]
146
+ }],
147
+ // Auto-close the memory lifecycle at session end (Claude Code SessionEnd,
148
+ // shipped v1.0.85) — captures a closing episode and cleans up the session
149
+ // checkpoint even if the agent forgot to call plur_session_end (#217).
150
+ SessionEnd: [{
151
+ hooks: [{ type: "command", command: `${cli} hook-session-end`, timeout: 5 }]
152
+ }],
153
+ // --- Contextual injection ---
154
+ PreToolUse: [
155
+ { matcher: "EnterPlanMode", hooks: [{ type: "command", command: `${cli} hook-inject --event plan_mode`, timeout: 10 }] },
156
+ { matcher: "Skill", hooks: [{ type: "command", command: `${cli} hook-inject --event skill`, timeout: 10 }] },
157
+ { matcher: "Agent", hooks: [{ type: "command", command: `${cli} hook-inject --event agent`, timeout: 10 }] },
158
+ { matcher: "Bash|Edit|Write|Agent", hooks: [{ type: "command", command: `${cli} hook-observe`, timeout: 3 }] }
159
+ ],
160
+ PostToolUse: [
161
+ { matcher: "Bash|Edit|Write|Agent", hooks: [{ type: "command", command: `${cli} hook-observe --post`, timeout: 3 }] }
162
+ ],
163
+ SubagentStart: [
164
+ { matcher: ".*", hooks: [{ type: "command", command: `${cli} hook-inject --event subagent`, timeout: 10 }] }
165
+ ],
166
+ Stop: [
167
+ { matcher: "*", hooks: [{ type: "command", command: `${cli} hook-learn-check`, timeout: 2 }] }
168
+ ]
169
+ };
170
+ }
171
+ var PLUR_HOOKS = buildPlurHooks(CLI);
172
+ var PLUR_INSTRUCTIONS_MARKER = "<!-- plur-instructions-v4 -->";
173
+ var MEMORY_FOOTER_RULE = "End every reply with one short line: `Memory \u2014 recalled N \xB7 used: ENG-\u2026, ENG-\u2026 \xB7 written: ENG-\u2026` (recalled as a count; used and written as ids only, no statements), or `Memory \u2014 none`. Only count/list ids you actually saw this turn; never invent an id. Give details only if the user asks.";
96
174
  var CLAUDE_MD_SECTION = `## PLUR Memory
97
175
 
98
176
  You have persistent memory via PLUR. Corrections, preferences, and conventions persist across sessions as engrams.
@@ -113,42 +191,64 @@ Hooks inject engrams automatically on every first message \u2014 you do not need
113
191
 
114
192
  Do not ask permission to use these tools \u2014 they are your memory system.
115
193
 
194
+ ### Memory line on every reply
195
+
196
+ ${MEMORY_FOOTER_RULE}
197
+
116
198
  ### When corrected
117
199
 
118
200
  When the user corrects you ("no, use X not Y", "that's wrong"):
119
201
  1. Call \`plur_learn\` immediately \u2014 before continuing the task
120
202
  2. Call \`plur_feedback\` with negative signal on the wrong engram if one was injected
121
203
  3. Then continue with the corrected approach
122
- `;
123
- function installClaudeMd() {
124
- const marker = "## PLUR Memory";
125
- const projectClaudeMd = join(process.cwd(), "CLAUDE.md");
126
- const globalClaudeMd = join(homedir(), "CLAUDE.md");
127
- const claudeMdPath = existsSync(projectClaudeMd) ? projectClaudeMd : existsSync(globalClaudeMd) ? globalClaudeMd : projectClaudeMd;
128
- if (existsSync(claudeMdPath)) {
129
- const content = readFileSync(claudeMdPath, "utf8");
130
- if (content.includes(marker)) {
131
- return `already in ${claudeMdPath}`;
132
- }
133
- writeFileSync(claudeMdPath, content.trimEnd() + "\n\n" + CLAUDE_MD_SECTION);
134
- return `added to ${claudeMdPath}`;
135
- }
136
- writeFileSync(claudeMdPath, `# CLAUDE.md
137
204
 
138
- ${CLAUDE_MD_SECTION}`);
139
- return `created ${claudeMdPath}`;
205
+ ${PLUR_INSTRUCTIONS_MARKER}
206
+ `;
207
+ function defaultClaudeMdPath() {
208
+ const projectClaudeMd = join2(process.cwd(), "CLAUDE.md");
209
+ const globalClaudeMd = join2(homedir2(), "CLAUDE.md");
210
+ return existsSync(projectClaudeMd) ? projectClaudeMd : existsSync(globalClaudeMd) ? globalClaudeMd : projectClaudeMd;
140
211
  }
141
- function findMcpConfig() {
142
- const projectMcp = join(process.cwd(), ".mcp.json");
143
- if (existsSync(projectMcp)) return projectMcp;
144
- const globalMcp = join(homedir(), ".claude", "mcp.json");
145
- if (existsSync(globalMcp)) return globalMcp;
146
- return projectMcp;
212
+ async function installClaudeMd(claudeMdPath = defaultClaudeMdPath()) {
213
+ const { upsertInstructionSection, writeWithBackup, SHIPPED_PLUR_SECTIONS } = await import("@plur-ai/core");
214
+ const existing = existsSync(claudeMdPath) ? readFileSync2(claudeMdPath, "utf8") : null;
215
+ const r = upsertInstructionSection(existing, {
216
+ section: CLAUDE_MD_SECTION,
217
+ title: "# CLAUDE.md",
218
+ heading: "## PLUR Memory",
219
+ marker: PLUR_INSTRUCTIONS_MARKER,
220
+ shipped: SHIPPED_PLUR_SECTIONS
221
+ });
222
+ if (r.status === "skipped") return `not written to ${claudeMdPath}: ${r.skipReason}`;
223
+ let backup;
224
+ try {
225
+ backup = r.status === "already" ? null : writeWithBackup(claudeMdPath, r.content, existing);
226
+ } catch (err) {
227
+ if (err.code === "PLUR_REFUSED") return `not written to ${claudeMdPath}: ${err.message}`;
228
+ throw err;
229
+ }
230
+ const head = {
231
+ created: `created ${claudeMdPath}`,
232
+ added: `added to ${claudeMdPath}`,
233
+ upgraded: `upgraded in ${claudeMdPath}`,
234
+ already: `already in ${claudeMdPath}`
235
+ }[r.status];
236
+ const notes = [];
237
+ if (backup) notes.push(`backup: ${backup}`);
238
+ if (r.keptSections > 0) {
239
+ notes.push(
240
+ `left ${r.keptSections} older "## PLUR Memory" section${r.keptSections === 1 ? "" : "s"} untouched because ${r.keptSections === 1 ? "it has" : "they have"} text PLUR did not write \u2014 remove it yourself once you have kept what you need`
241
+ );
242
+ }
243
+ if (r.closedOpenBlock) {
244
+ notes.push(`closed the ${r.closedOpenBlock === "fence" ? "code block" : "HTML comment"} left open at the end of the file before adding the section`);
245
+ }
246
+ return notes.length ? `${head} (${notes.join("; ")})` : head;
147
247
  }
148
248
  function readJsonObjectForWrite(path) {
149
249
  if (!existsSync(path)) return { data: {}, ok: true };
150
250
  try {
151
- const parsed = JSON.parse(readFileSync(path, "utf8"));
251
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
152
252
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
153
253
  return { data: parsed, ok: true };
154
254
  }
@@ -156,64 +256,125 @@ function readJsonObjectForWrite(path) {
156
256
  }
157
257
  return { data: {}, ok: false };
158
258
  }
159
- function writeMcpConfig(configPath) {
160
- const { data, ok } = readJsonObjectForWrite(configPath);
161
- if (!ok) {
162
- return `skipped \u2014 ${configPath} exists but is not a JSON object; writing would discard your other MCP servers. Fix it by hand, then re-run \`plur-mcp init\``;
163
- }
164
- const config = data;
165
- const servers = config.mcpServers ?? {};
259
+ function healRaceyEntry(servers) {
166
260
  const existing = servers.plur;
167
- if (existing) {
168
- const args = existing.args ?? [];
169
- const spec = args.filter((a) => a !== "-y" && !a.startsWith("-"))[0] ?? "";
170
- if (existing.command === "npx" && /^@plur-ai\/mcp(@latest)?$/.test(spec)) {
171
- const healed = { ...existing, ...MCP_SERVER_CONFIG };
172
- if (JSON.stringify(healed) !== JSON.stringify(existing)) {
173
- servers.plur = healed;
174
- config.mcpServers = servers;
175
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
176
- return `upgraded stale npx entry in ${configPath}`;
177
- }
261
+ if (!existing || typeof existing !== "object") return false;
262
+ const args = existing.args ?? [];
263
+ const spec = args.filter((a) => a !== "-y" && !a.startsWith("-"))[0] ?? "";
264
+ if (existing.command !== "npx" || !/^@plur-ai\/mcp(@latest)?$/.test(spec)) return false;
265
+ const healed = { ...existing, ...MCP_SERVER_CONFIG };
266
+ if (JSON.stringify(healed) === JSON.stringify(existing)) return false;
267
+ servers.plur = healed;
268
+ return true;
269
+ }
270
+ async function installClaudeUserMcp() {
271
+ const { writeWithBackup, registerClaudeUserMcp } = await import("@plur-ai/core");
272
+ const legacyPath = join2(homedir2(), ".claude", "mcp.json");
273
+ const legacyRaw = existsSync(legacyPath) ? readFileSync2(legacyPath, "utf8") : null;
274
+ const legacy = readJsonObjectForWrite(legacyPath);
275
+ const oldServers = legacy.ok ? legacy.data.mcpServers : void 0;
276
+ const oldEntry = oldServers && typeof oldServers === "object" && !Array.isArray(oldServers) ? oldServers.plur : void 0;
277
+ const r = registerClaudeUserMcp({
278
+ userPath: join2(homedir2(), ".claude.json"),
279
+ entry: () => ({ ...MCP_SERVER_CONFIG }),
280
+ heal: (config) => healRaceyEntry(config.mcpServers) ? "upgraded stale npx entry" : null,
281
+ legacyEntry: oldEntry,
282
+ legacyPath,
283
+ rerunCommand: "plur-mcp init"
284
+ });
285
+ if (!r.ok || oldEntry === void 0) return { ok: r.ok, status: r.message };
286
+ let status = r.message;
287
+ const next = { ...legacy.data };
288
+ const rest = { ...oldServers };
289
+ delete rest.plur;
290
+ if (Object.keys(rest).length > 0) next.mcpServers = rest;
291
+ else delete next.mcpServers;
292
+ try {
293
+ const backup = writeWithBackup(legacyPath, JSON.stringify(next, null, 2) + "\n", legacyRaw);
294
+ status += `; removed the unused entry from ${legacyPath}${backup ? ` (backup: ${backup})` : ""}`;
295
+ } catch (err) {
296
+ status += `; could not remove the unused entry from ${legacyPath} (${err.message}) \u2014 Claude Code ignores it, remove it by hand`;
297
+ }
298
+ return { ok: true, status };
299
+ }
300
+ function isPlurCommandHook(h) {
301
+ return typeof h.command === "string" && isPlurHookSpec({ command: h.command, args: h.args });
302
+ }
303
+ function isPlurHook(entry) {
304
+ return (entry.hooks ?? []).some(isPlurCommandHook);
305
+ }
306
+ var REHYDRATE = /(?:^|\s)hook-inject\s+--rehydrate(?:\s|$)/;
307
+ function launchLine(h) {
308
+ const args = Array.isArray(h.args) ? h.args.filter((a) => typeof a === "string") : [];
309
+ return [h.command, ...args].join(" ");
310
+ }
311
+ function isPlurRehydrateHook(h) {
312
+ return isPlurCommandHook(h) && REHYDRATE.test(launchLine(h).replace(/\\/g, "/").toLowerCase());
313
+ }
314
+ function isPlurRehydrate(entry) {
315
+ return (entry.hooks ?? []).some(isPlurRehydrateHook);
316
+ }
317
+ function hasPlurSubcommand(entries, subcommand) {
318
+ const re = new RegExp(`(?:^|\\s)${subcommand}(?:\\s|$)`);
319
+ return (entries ?? []).some((e) => (e.hooks ?? []).some((h) => isPlurCommandHook(h) && re.test(h.command.replace(/\\/g, "/").toLowerCase())));
320
+ }
321
+ var isResumeEntry = (e) => e.matcher === "resume";
322
+ function stripPlurHooks(entries) {
323
+ const kept = [];
324
+ for (const entry of entries) {
325
+ if (!isPlurHook(entry)) {
326
+ kept.push(entry);
327
+ continue;
178
328
  }
179
- return `already configured in ${configPath}`;
329
+ const rest = entry.hooks.filter((h) => !isPlurCommandHook(h));
330
+ if (rest.length > 0) kept.push({ ...entry, hooks: rest });
180
331
  }
181
- servers.plur = MCP_SERVER_CONFIG;
182
- config.mcpServers = servers;
183
- const dir = join(configPath, "..");
184
- mkdirSync(dir, { recursive: true });
185
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
186
- return `added to ${configPath}`;
332
+ return kept;
333
+ }
334
+ function applyPlurHooks(settings, hooksMap) {
335
+ const hooks = { ...settings.hooks ?? {} };
336
+ const installed = Object.values(hooks).some((entries) => (entries ?? []).some(isPlurHook));
337
+ if (!installed) {
338
+ for (const [event, entries] of Object.entries(hooksMap)) {
339
+ hooks[event] = [...hooks[event] ?? [], ...entries];
340
+ }
341
+ return { settings: { ...settings, hooks }, status: "installed" };
342
+ }
343
+ let changed = false;
344
+ let movedRehydrate = false;
345
+ if (hooks.PostCompact?.some(isPlurHook)) {
346
+ movedRehydrate = hooks.PostCompact.some(isPlurRehydrate);
347
+ const kept = stripPlurHooks(hooks.PostCompact);
348
+ if (kept.length > 0) hooks.PostCompact = kept;
349
+ else delete hooks.PostCompact;
350
+ changed = true;
351
+ }
352
+ if (movedRehydrate && !(hooks.SessionStart ?? []).some(isPlurRehydrate)) {
353
+ hooks.SessionStart = [...hooks.SessionStart ?? [], ...(hooksMap.SessionStart ?? []).filter((e) => !isResumeEntry(e))];
354
+ changed = true;
355
+ }
356
+ if (hasPlurSubcommand(hooks.SessionEnd, "hook-session-end") && !hasPlurSubcommand(hooks.SessionStart, "hook-session-resume")) {
357
+ const resume = (hooksMap.SessionStart ?? []).filter(isResumeEntry);
358
+ if (resume.length > 0) {
359
+ hooks.SessionStart = [...hooks.SessionStart ?? [], ...resume];
360
+ changed = true;
361
+ }
362
+ }
363
+ return changed ? { settings: { ...settings, hooks }, status: "healed" } : { settings, status: "already" };
187
364
  }
188
365
  function installHooks() {
189
- const projectSettings = join(process.cwd(), ".claude", "settings.json");
190
- const globalSettings = join(homedir(), ".claude", "settings.json");
191
- const settingsPath = existsSync(join(process.cwd(), ".claude")) ? projectSettings : globalSettings;
366
+ const settingsPath = join2(homedir2(), ".claude", "settings.json");
192
367
  const settingsRead = readJsonObjectForWrite(settingsPath);
193
368
  if (!settingsRead.ok) {
194
369
  return `skipped \u2014 ${settingsPath} exists but is not a JSON object; writing would discard your other settings. Fix it by hand, then re-run \`plur-mcp init\``;
195
370
  }
196
371
  const settings = settingsRead.data;
197
- const hooks = settings.hooks ?? {};
198
- for (const entries of Object.values(hooks)) {
199
- for (const entry of entries) {
200
- for (const h of entry.hooks ?? []) {
201
- if (h.command.includes("@plur-ai/cli")) {
202
- return `already installed in ${settingsPath}`;
203
- }
204
- }
205
- }
206
- }
207
- const existing = settings.hooks ?? {};
208
- const merged = { ...existing };
209
- for (const [event, newEntries] of Object.entries(PLUR_HOOKS)) {
210
- merged[event] = [...merged[event] ?? [], ...newEntries];
211
- }
212
- settings.hooks = merged;
213
- const dir = join(settingsPath, "..");
372
+ const { settings: next, status } = applyPlurHooks(settings, PLUR_HOOKS);
373
+ if (status === "already") return `already installed in ${settingsPath}`;
374
+ const dir = join2(settingsPath, "..");
214
375
  mkdirSync(dir, { recursive: true });
215
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
216
- return `installed in ${settingsPath}`;
376
+ writeFileSync(settingsPath, JSON.stringify(next, null, 2) + "\n");
377
+ return status === "healed" ? `updated PLUR hooks (SessionStart compact/resume) in ${settingsPath}` : `installed in ${settingsPath}`;
217
378
  }
218
379
  async function runInit() {
219
380
  const results = [];
@@ -231,36 +392,37 @@ async function runInit() {
231
392
  } catch {
232
393
  }
233
394
  results.push(`Search: ${searchMode}`);
234
- const mcpConfigPath = findMcpConfig();
235
- const mcpStatus = writeMcpConfig(mcpConfigPath);
395
+ const mcp = await installClaudeUserMcp();
396
+ const mcpStatus = mcp.status;
397
+ if (!mcp.ok) process.exitCode = 1;
236
398
  results.push(`MCP: ${mcpStatus}`);
237
399
  const hooksStatus = installHooks();
238
400
  results.push(`Hooks: ${hooksStatus}`);
239
- const claudeMdStatus = installClaudeMd();
401
+ const claudeMdStatus = await installClaudeMd();
240
402
  results.push(`CLAUDE.md: ${claudeMdStatus}`);
241
403
  const { Plur } = await import("@plur-ai/core");
242
404
  const plur = new Plur({ path: paths.root });
243
- const bundledPacksDir = join(fileURLToPath(import.meta.url), "..", "..", "packs");
405
+ const bundledPacksDir = join2(fileURLToPath(import.meta.url), "..", "..", "packs");
244
406
  let packsStatus = "no bundled packs found";
245
407
  if (existsSync(bundledPacksDir)) {
246
408
  const installed = plur.listPacks();
247
409
  const installedByName = new Map(installed.map((p) => [p.name, p.manifest?.version]));
248
- const entries = readdirSync(bundledPacksDir).filter((e) => statSync(join(bundledPacksDir, e)).isDirectory());
410
+ const entries = readdirSync(bundledPacksDir).filter((e) => statSync(join2(bundledPacksDir, e)).isDirectory());
249
411
  const newPacks = [];
250
412
  const upgradedPacks = [];
251
413
  for (const entry of entries) {
252
- const bundledManifestPath = join(bundledPacksDir, entry, "SKILL.md");
414
+ const bundledManifestPath = join2(bundledPacksDir, entry, "SKILL.md");
253
415
  const bundledVersion = existsSync(bundledManifestPath) ? extractManifestVersion(bundledManifestPath) : null;
254
416
  const installedVersion = installedByName.get(entry);
255
417
  if (!installedByName.has(entry)) {
256
418
  try {
257
- await plur.installPack(join(bundledPacksDir, entry));
419
+ await plur.installPack(join2(bundledPacksDir, entry));
258
420
  newPacks.push(entry);
259
421
  } catch {
260
422
  }
261
423
  } else if (bundledVersion && (!installedVersion || compareSemver(bundledVersion, installedVersion) > 0)) {
262
424
  try {
263
- await plur.installPack(join(bundledPacksDir, entry));
425
+ await plur.installPack(join2(bundledPacksDir, entry));
264
426
  upgradedPacks.push(
265
427
  `${entry} ${installedVersion ?? "unknown"}\u2192${bundledVersion}`
266
428
  );
@@ -295,7 +457,7 @@ async function runInit() {
295
457
  }
296
458
  }
297
459
  async function runPacks() {
298
- const plurPath = process.env.PLUR_PATH ?? join(homedir(), ".plur");
460
+ const plurPath = process.env.PLUR_PATH ?? join2(homedir2(), ".plur");
299
461
  const { Plur } = await import("@plur-ai/core");
300
462
  const { packsCommand } = await import("./packs-cli-XXZTSBHW.js");
301
463
  const plur = new Plur({ path: plurPath });
@@ -316,14 +478,14 @@ if (arg === "--version" || arg === "-v") {
316
478
  }
317
479
  if (arg === "init") {
318
480
  await runInit();
319
- process.exit(0);
481
+ process.exit(process.exitCode ?? 0);
320
482
  }
321
483
  if (arg === "packs") {
322
484
  await runPacks();
323
485
  process.exit(0);
324
486
  }
325
487
  if (arg === "serve" || arg === void 0) {
326
- const { runStdio } = await import("./server-LODRVEQB.js");
488
+ const { runStdio } = await import("./server-EHKHU4JH.js");
327
489
  runStdio().catch((err) => {
328
490
  console.error("Failed to start PLUR MCP server:", err);
329
491
  process.exit(1);
@@ -334,6 +496,9 @@ Run plur-mcp --help for usage.`);
334
496
  process.exit(1);
335
497
  }
336
498
  export {
499
+ applyPlurHooks,
500
+ buildPlurHooks,
337
501
  compareSemver,
338
- extractManifestVersion
502
+ extractManifestVersion,
503
+ installClaudeMd
339
504
  };