@plur-ai/cli 0.9.11 → 0.9.12

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.
@@ -2,7 +2,16 @@
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { homedir, platform } from "os";
5
+ function findMcpShim() {
6
+ const name = platform() === "win32" ? "plur-mcp.cmd" : "plur-mcp";
7
+ const path = join(homedir(), ".plur", "bin", name);
8
+ return existsSync(path) ? path : null;
9
+ }
5
10
  function buildMcpServerEntry() {
11
+ const shim = findMcpShim();
12
+ if (shim) {
13
+ return { command: shim, args: [] };
14
+ }
6
15
  if (platform() === "win32") {
7
16
  return {
8
17
  command: "cmd.exe",
@@ -4,7 +4,7 @@ import {
4
4
  hasPlurMcp,
5
5
  knownConfigFiles,
6
6
  readConfig
7
- } from "./chunk-57GFJSEE.js";
7
+ } from "./chunk-OAIEWP3Q.js";
8
8
  import {
9
9
  outputJson,
10
10
  outputText,
@@ -38,6 +38,30 @@ function hasStaleNpxHooks(config) {
38
38
  }
39
39
  return false;
40
40
  }
41
+ function hasStaleNpxMcp(config) {
42
+ const servers = config.mcpServers ?? {};
43
+ const plur = servers.plur;
44
+ if (!plur) return false;
45
+ if (plur.command && plur.command.includes("npx")) return true;
46
+ const argsBlob = (plur.args ?? []).join(" ");
47
+ return argsBlob.includes("npx") && argsBlob.includes("@plur-ai/mcp");
48
+ }
49
+ function validateMcpShim() {
50
+ const name = platform() === "win32" ? "plur-mcp.cmd" : "plur-mcp";
51
+ const path = join(homedir(), ".plur", "bin", name);
52
+ if (!existsSync(path)) {
53
+ return { valid: false, shimPath: path, error: "shim not found \u2014 run `plur init` to create it (requires @plur-ai/mcp installed alongside CLI)" };
54
+ }
55
+ const content = readFileSync(path, "utf-8");
56
+ const match = content.match(/"([^"]+index\.js)"/);
57
+ if (!match) {
58
+ return { valid: false, shimPath: path, error: "shim has unexpected format" };
59
+ }
60
+ if (!existsSync(match[1])) {
61
+ return { valid: false, shimPath: path, error: `entrypoint missing: ${match[1]} \u2014 run \`plur init\` to fix` };
62
+ }
63
+ return { valid: true, shimPath: path };
64
+ }
41
65
  function validateHookShim() {
42
66
  const name = platform() === "win32" ? "plur-hook.cmd" : "plur-hook";
43
67
  const path = join(homedir(), ".plur", "bin", name);
@@ -248,11 +272,17 @@ function buildReport(skipHandshake, flags) {
248
272
  const config = readConfig(c.path);
249
273
  return hasStaleNpxHooks(config);
250
274
  });
275
+ const staleNpxMcp = configs.some((c) => {
276
+ if (!c.exists) return false;
277
+ const config = readConfig(c.path);
278
+ return hasStaleNpxMcp(config);
279
+ });
251
280
  const hookShim = validateHookShim();
281
+ const mcpShim = validateMcpShim();
252
282
  const handshakePromise = skipHandshake ? Promise.resolve({ ok: false, error: "skipped (--no-handshake)" }) : mcpHandshake();
253
283
  return Promise.all([handshakePromise, checkEmbedder(flags)]).then(([handshake, embedder]) => {
254
284
  const overall = hooksInstalled && mcpRegistered && (skipHandshake || handshake.ok) ? "ok" : "fail";
255
- return { configs, hooksInstalled, mcpRegistered, datacoreCollision, staleNpxHooks, hookShim, handshake, embedder, overall };
285
+ return { configs, hooksInstalled, mcpRegistered, datacoreCollision, staleNpxHooks, staleNpxMcp, hookShim, mcpShim, handshake, embedder, overall };
256
286
  });
257
287
  }
258
288
  function printText(report) {
@@ -294,6 +324,18 @@ function printText(report) {
294
324
  outputText("\u26A0 Hooks still use npx \u2014 slow (200-2000ms per hook) and vulnerable to cache corruption.");
295
325
  outputText(" Fix: run `plur init` to migrate to the local hook binary (<5ms per hook).");
296
326
  }
327
+ if (report.mcpShim.valid) {
328
+ outputText(`\u2713 MCP shim: ${report.mcpShim.shimPath}`);
329
+ } else {
330
+ outputText(`\u2717 MCP shim: ${report.mcpShim.error}`);
331
+ }
332
+ if (report.staleNpxMcp) {
333
+ outputText("");
334
+ outputText("\u26A0 plur MCP still launched via npx \u2014 vulnerable to ENOTEMPTY cache corruption on version bumps (#234).");
335
+ outputText(" This is the same bug class as #178 (which fixed hooks). Symptom: Claude Code");
336
+ outputText(" sessions silently lose PLUR memory after a new @plur-ai/mcp publish.");
337
+ outputText(" Fix: run `plur init` to migrate to the local MCP binary (no npx, no race).");
338
+ }
297
339
  outputText("");
298
340
  if (report.handshake.ok) {
299
341
  outputText(`\u2713 MCP handshake: ${report.handshake.serverName} v${report.handshake.serverVersion}`);
@@ -3,10 +3,11 @@ import {
3
3
  } from "./chunk-O6WTH7H7.js";
4
4
 
5
5
  // src/commands/hook-inject.ts
6
- import { existsSync, writeFileSync, readFileSync, appendFileSync, mkdirSync, readSync, statSync } from "fs";
7
- import { dirname, join, resolve } from "path";
6
+ import { existsSync, writeFileSync, readFileSync, appendFileSync, mkdirSync, readSync, statSync, readdirSync, unlinkSync } from "fs";
7
+ import { join } from "path";
8
8
  import { tmpdir, homedir } from "os";
9
9
  import { randomUUID } from "crypto";
10
+ import { readProjectConfig } from "@plur-ai/core";
10
11
  var MAX_REMOTE_TASK_CHARS = 1e3;
11
12
  var MAX_REMOTE_RESPONSE_BYTES = 128 * 1024;
12
13
  var REMOTE_TIMEOUT_MS = 1500;
@@ -22,83 +23,6 @@ function logRemoteAttempt(entry) {
22
23
  }
23
24
  }
24
25
  var REMINDER_INTERVAL_MS = 10 * 60 * 1e3;
25
- function findProjectConfigPath(startDir = process.cwd()) {
26
- const home = resolve(homedir());
27
- let dir = resolve(startDir);
28
- const MAX_DEPTH = 12;
29
- for (let depth = 0; depth < MAX_DEPTH; depth++) {
30
- if (dir !== home) {
31
- const candidate = join(dir, ".plur.yaml");
32
- if (existsSync(candidate)) return candidate;
33
- }
34
- if (existsSync(join(dir, ".git"))) return null;
35
- if (dir === home || dir === "/" || dir === ".") return null;
36
- const parent = dirname(dir);
37
- if (parent === dir) return null;
38
- dir = parent;
39
- }
40
- return null;
41
- }
42
- function unquoteYamlValue(v) {
43
- return v.replace(/^(['"])(.*)\1$/, "$2");
44
- }
45
- function readProjectConfig() {
46
- const configPath = findProjectConfigPath();
47
- if (!configPath) return {};
48
- try {
49
- const content = readFileSync(configPath, "utf8").replace(/^/, "");
50
- const config = {};
51
- let inListKey = null;
52
- let listAcc = [];
53
- const finishList = () => {
54
- if (inListKey === "remote_scopes") {
55
- config.remote_scopes = listAcc;
56
- }
57
- inListKey = null;
58
- listAcc = [];
59
- };
60
- for (const rawLine of content.split("\n")) {
61
- const line = rawLine.replace(/\r$/, "");
62
- const trimmed = line.trim();
63
- if (trimmed.startsWith("#") || !trimmed) continue;
64
- if (inListKey === "remote_scopes" && trimmed.startsWith("-")) {
65
- listAcc.push(unquoteYamlValue(trimmed.slice(1).trim()));
66
- continue;
67
- }
68
- if (inListKey) finishList();
69
- const colonIdx = trimmed.indexOf(":");
70
- if (colonIdx < 0) continue;
71
- const key = trimmed.slice(0, colonIdx).trim();
72
- const value = unquoteYamlValue(trimmed.slice(colonIdx + 1).trim());
73
- switch (key) {
74
- case "domain":
75
- config.domain = value;
76
- break;
77
- case "scope":
78
- config.scope = value;
79
- break;
80
- case "remote_url":
81
- config.remote_url = value;
82
- break;
83
- case "remote_token":
84
- config.remote_token = value;
85
- break;
86
- case "remote_scopes":
87
- if (value === "" || value === "|" || value === ">") {
88
- inListKey = "remote_scopes";
89
- listAcc = [];
90
- } else {
91
- config.remote_scopes = value.split(",").map((s) => unquoteYamlValue(s.trim())).filter(Boolean);
92
- }
93
- break;
94
- }
95
- }
96
- finishList();
97
- return config;
98
- } catch {
99
- return {};
100
- }
101
- }
102
26
  async function tryRemoteInject(config, task) {
103
27
  if (!config.remote_url || !config.remote_token) return null;
104
28
  const startTs = Date.now();
@@ -228,6 +152,44 @@ function extractEventTask(input, event) {
228
152
  return "";
229
153
  }
230
154
  }
155
+ function processDeferredWrapups() {
156
+ const plurDir = process.env.PLUR_PATH ?? join(homedir(), ".plur");
157
+ const sessionsDir = join(plurDir, "sessions");
158
+ if (!existsSync(sessionsDir)) return null;
159
+ const notices = [];
160
+ try {
161
+ const files = readdirSync(sessionsDir).filter((f) => f.endsWith(".checkpoint.json"));
162
+ const now = Date.now();
163
+ const staleMin = parseInt(process.env.PLUR_CHECKPOINT_STALE_MIN ?? "5", 10);
164
+ const STALE_THRESHOLD_MS = Math.max(1, staleMin) * 60 * 1e3;
165
+ for (const file of files) {
166
+ const path = join(sessionsDir, file);
167
+ try {
168
+ const checkpoint = JSON.parse(readFileSync(path, "utf8"));
169
+ const lastCheckpoint = new Date(checkpoint.last_checkpoint).getTime();
170
+ if (now - lastCheckpoint < STALE_THRESHOLD_MS) continue;
171
+ const started = new Date(checkpoint.started_at);
172
+ const ended = new Date(checkpoint.last_checkpoint);
173
+ const durationMin = Math.round((ended.getTime() - started.getTime()) / 6e4);
174
+ const durationStr = durationMin >= 60 ? `${Math.floor(durationMin / 60)}h ${durationMin % 60}m` : `${durationMin}m`;
175
+ notices.push(
176
+ `Previous session (${durationStr}, ${checkpoint.stop_count} responses${checkpoint.cwd ? ", " + checkpoint.cwd.split("/").slice(-2).join("/") : ""}) ended without wrap-up.`
177
+ );
178
+ unlinkSync(path);
179
+ } catch {
180
+ try {
181
+ unlinkSync(path);
182
+ } catch {
183
+ }
184
+ }
185
+ }
186
+ } catch {
187
+ return null;
188
+ }
189
+ if (notices.length === 0) return null;
190
+ return `[PLUR] ${notices.join(" ")}
191
+ Consider running plur_session_end with engram_suggestions when this session ends.`;
192
+ }
231
193
  async function run(args, flags) {
232
194
  const isRehydrate = args.includes("--rehydrate");
233
195
  const eventIdx = args.indexOf("--event");
@@ -359,6 +321,8 @@ ${parts2.join("\n")}` };
359
321
  if (sessionId) parts.push(`Session ID: ${sessionId}`);
360
322
  if (projectConfig.domain) parts.push(`Project domain: ${projectConfig.domain}`);
361
323
  if (projectConfig.scope) parts.push(`Project scope: ${projectConfig.scope} \u2014 use this scope for plur_learn calls`);
324
+ const deferredNotice = processDeferredWrapups();
325
+ if (deferredNotice) parts.push("", deferredNotice);
362
326
  }
363
327
  if (context) {
364
328
  parts.push("");
@@ -1,14 +1,47 @@
1
1
  // src/commands/hook-learn-check.ts
2
2
  import { readSync, readFileSync, writeFileSync, mkdirSync } from "fs";
3
3
  import { join } from "path";
4
- import { tmpdir } from "os";
5
- var INTERVAL = 3;
4
+ import { tmpdir, homedir } from "os";
5
+ var LEARN_INTERVAL = 3;
6
+ var CHECKPOINT_INTERVAL = parseInt(process.env.PLUR_CHECKPOINT_INTERVAL || "10", 10);
7
+ function sessionKey() {
8
+ const raw = process.env.CLAUDE_SESSION_ID || String(process.ppid || "unknown");
9
+ return raw.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "default";
10
+ }
6
11
  function counterPath() {
7
12
  const dir = join(tmpdir(), "plur-sessions");
8
13
  mkdirSync(dir, { recursive: true });
9
- const sessionKey = process.env.CLAUDE_SESSION_ID || String(process.ppid || "unknown");
10
- const safeKey = sessionKey.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "default";
11
- return join(dir, `${safeKey}.stop-count`);
14
+ return join(dir, `${sessionKey()}.stop-count`);
15
+ }
16
+ function plurPath() {
17
+ return process.env.PLUR_PATH ?? join(homedir(), ".plur");
18
+ }
19
+ function checkpointDir() {
20
+ const dir = join(plurPath(), "sessions");
21
+ mkdirSync(dir, { recursive: true });
22
+ return dir;
23
+ }
24
+ function writeCheckpoint(count, cwd) {
25
+ const id = sessionKey();
26
+ const dir = checkpointDir();
27
+ const path = join(dir, `${id}.checkpoint.json`);
28
+ const now = (/* @__PURE__ */ new Date()).toISOString();
29
+ const dateStr = now.slice(0, 10);
30
+ let startedAt = now;
31
+ try {
32
+ const existing = JSON.parse(readFileSync(path, "utf8"));
33
+ if (existing.started_at) startedAt = existing.started_at;
34
+ } catch {
35
+ }
36
+ const checkpoint = {
37
+ session_id: id,
38
+ started_at: startedAt,
39
+ last_checkpoint: now,
40
+ stop_count: count,
41
+ cwd,
42
+ observation_file: `${dateStr}.jsonl`
43
+ };
44
+ writeFileSync(path, JSON.stringify(checkpoint, null, 2) + "\n");
12
45
  }
13
46
  function readStdinRaw() {
14
47
  try {
@@ -31,6 +64,12 @@ function readStdinRaw() {
31
64
  var LEARN_PROMPT = `[PLUR] Did you discover, learn, or get corrected on something in your last response? If yes \u2014 call plur_learn now before moving on. If no \u2014 continue.`;
32
65
  async function run(_args, _flags) {
33
66
  const raw = readStdinRaw();
67
+ let cwd = process.cwd();
68
+ try {
69
+ const data = JSON.parse(raw);
70
+ if (data.cwd) cwd = data.cwd;
71
+ } catch {
72
+ }
34
73
  const cPath = counterPath();
35
74
  let count = 1;
36
75
  try {
@@ -42,7 +81,13 @@ async function run(_args, _flags) {
42
81
  writeFileSync(cPath, String(count));
43
82
  } catch {
44
83
  }
45
- if (count % INTERVAL !== 0) {
84
+ if (count % CHECKPOINT_INTERVAL === 0) {
85
+ try {
86
+ writeCheckpoint(count, cwd);
87
+ } catch {
88
+ }
89
+ }
90
+ if (count % LEARN_INTERVAL !== 0) {
46
91
  process.stdout.write(raw);
47
92
  return;
48
93
  }
@@ -5,7 +5,7 @@ import {
5
5
  mergePlurMcp,
6
6
  readConfig,
7
7
  writeConfig
8
- } from "./chunk-57GFJSEE.js";
8
+ } from "./chunk-OAIEWP3Q.js";
9
9
  import {
10
10
  outputText
11
11
  } from "./chunk-7U4W4J3G.js";
@@ -49,6 +49,51 @@ exec "${nodeBin}" "${entrypoint}" "$@"
49
49
  writeFileSync(join(binDir, "plur-hook.meta.json"), JSON.stringify(meta, null, 2) + "\n");
50
50
  return { shimPath: target, status: "installed" };
51
51
  }
52
+ function resolveMcpEntrypoint() {
53
+ const cliEntry = resolveCliEntrypoint();
54
+ let dir = dirname(cliEntry);
55
+ const MAX_DEPTH = 12;
56
+ for (let depth = 0; depth < MAX_DEPTH; depth++) {
57
+ const candidate = join(dir, "node_modules", "@plur-ai", "mcp", "dist", "index.js");
58
+ if (existsSync(candidate)) return candidate;
59
+ const adjacent = join(dir, "..", "@plur-ai", "mcp", "dist", "index.js");
60
+ if (existsSync(adjacent)) return adjacent;
61
+ const parent = dirname(dir);
62
+ if (parent === dir) break;
63
+ dir = parent;
64
+ }
65
+ return null;
66
+ }
67
+ function mcpShimPath() {
68
+ const name = platform() === "win32" ? "plur-mcp.cmd" : "plur-mcp";
69
+ return join(homedir(), ".plur", "bin", name);
70
+ }
71
+ function installMcpBinary() {
72
+ const binDir = join(homedir(), ".plur", "bin");
73
+ mkdirSync(binDir, { recursive: true });
74
+ const entrypoint = resolveMcpEntrypoint();
75
+ const nodeBin = process.execPath;
76
+ if (!entrypoint) {
77
+ return { shimPath: "", status: "skipped: @plur-ai/mcp not installed alongside CLI" };
78
+ }
79
+ const target = mcpShimPath();
80
+ if (platform() === "win32") {
81
+ writeFileSync(target, `@echo off\r
82
+ "${nodeBin}" "${entrypoint}" %*\r
83
+ `);
84
+ } else {
85
+ writeFileSync(target, `#!/bin/sh
86
+ exec "${nodeBin}" "${entrypoint}" "$@"
87
+ `, { mode: 493 });
88
+ try {
89
+ chmodSync(target, 493);
90
+ } catch {
91
+ }
92
+ }
93
+ const meta = { entrypoint, node: nodeBin, installed: (/* @__PURE__ */ new Date()).toISOString() };
94
+ writeFileSync(join(binDir, "plur-mcp.meta.json"), JSON.stringify(meta, null, 2) + "\n");
95
+ return { shimPath: target, status: "installed" };
96
+ }
52
97
  function buildEnforcementHooks(cmd) {
53
98
  return {
54
99
  SessionStart: [
@@ -320,6 +365,7 @@ function hooksStatusFor(before, after, hadHooks) {
320
365
  async function run(args, flags) {
321
366
  const shim = installHookBinary();
322
367
  const cmd = shim.shimPath || "npx @plur-ai/cli";
368
+ const mcpShim = installMcpBinary();
323
369
  const PLUR_HOOKS_ENFORCEMENT = buildEnforcementHooks(cmd);
324
370
  const PLUR_HOOKS_INJECTION = buildInjectionHooks(cmd);
325
371
  const injectionPath = findSettingsPath(flags, args);
@@ -375,6 +421,7 @@ async function run(args, flags) {
375
421
  outputText("PLUR installed for Claude Code.");
376
422
  outputText("");
377
423
  outputText(`Hook binary: ${shim.status}${shim.shimPath ? ` (${shim.shimPath})` : ""}`);
424
+ outputText(`MCP binary: ${mcpShim.status}${mcpShim.shimPath ? ` (${mcpShim.shimPath})` : ""}`);
378
425
  outputText("");
379
426
  outputText("Architecture: One global engram store (~/.plur/), enforcement hooks global, injection hooks project-scoped.");
380
427
  outputText("Multi-project scoping via domain/scope fields on engrams, not separate installs.");
package/dist/index.js CHANGED
@@ -49,7 +49,7 @@ function createPlur(flags2) {
49
49
  }
50
50
 
51
51
  // src/index.ts
52
- var VERSION = "0.9.11";
52
+ var VERSION = "0.9.12";
53
53
  var argv = process.argv.slice(2);
54
54
  if (argv.includes("--version") || argv.includes("-v")) {
55
55
  console.log(VERSION);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plur-ai/cli",
3
- "version": "0.9.11",
3
+ "version": "0.9.12",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "plur": "dist/index.js"
@@ -10,7 +10,7 @@
10
10
  "dist"
11
11
  ],
12
12
  "dependencies": {
13
- "@plur-ai/core": "0.9.11"
13
+ "@plur-ai/core": "0.9.12"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@types/node": "^25.5.0"