memtrace 0.6.19 → 0.6.30

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/bin/memtrace.js CHANGED
@@ -36,6 +36,20 @@ const args = process.argv.slice(2);
36
36
  // upgrade completes, we shell out to `memtrace <rest>` via PATH —
37
37
  // which now resolves to the freshly-installed shim, not this old one.
38
38
 
39
+ // ── `memtrace setup` — (re)run the integration phases without npm ───────────
40
+ // The consent layer (memtrace-public #25) can defer configuration when
41
+ // `npm install -g memtrace` runs without a consent signal. This command is
42
+ // the documented way to complete (or re-run) setup afterwards. Flags:
43
+ // --yes/-y, --project-only, --no-hooks (see lib/install-consent.js).
44
+ if (args[0] === "setup") {
45
+ const result = spawnSync(
46
+ process.execPath,
47
+ [path.join(__dirname, "..", "install.js"), ...args.slice(1)],
48
+ { stdio: "inherit" }
49
+ );
50
+ process.exit(result.status ?? 1);
51
+ }
52
+
39
53
  if (args[0] === "install" || args[0] === "update" || args[0] === "upgrade") {
40
54
  // `npm.cmd` on win32 needs `shell: true` since the CVE-2024-27980
41
55
  // mitigation in Node 18.20+ / 20.12+ / 21.7+. The helpers in
@@ -43,6 +57,56 @@ if (args[0] === "install" || args[0] === "update" || args[0] === "upgrade") {
43
57
  const npmCmd = platformBinary("npm", process.platform);
44
58
  const memtraceCmd = platformBinary("memtrace", process.platform);
45
59
 
60
+ // ── Consent gate (memtrace-public #25) ─────────────────────────────────
61
+ // The npm postinstall hook that performs the actual config mutation runs
62
+ // non-interactively, so THIS process — the one that owns the TTY — asks
63
+ // for consent and forwards the grant (plus scoping flags) via env vars.
64
+ // A previously consented install (manifest / MEMTRACE.md / managed hooks
65
+ // present) counts as standing consent so upgrades stay friction-free.
66
+ const consent = require("../lib/install-consent");
67
+ const flags = consent.parseInstallFlags(args.slice(1));
68
+ const home = consent.resolveHome();
69
+ const consentEnv = {};
70
+ if (flags.projectOnly) consentEnv.MEMTRACE_INSTALL_PROJECT_ONLY = "1";
71
+ if (flags.noHooks) consentEnv.MEMTRACE_INSTALL_NO_HOOKS = "1";
72
+
73
+ const decision = consent.decideConsent({
74
+ yes: flags.yes,
75
+ isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
76
+ isNpmLifecycle: false,
77
+ hasPriorInstall: consent.detectPriorInstall(home),
78
+ });
79
+ const mutations = consent.buildMutationList({
80
+ home,
81
+ cwd: process.cwd(),
82
+ projectOnly: flags.projectOnly,
83
+ noHooks: flags.noHooks,
84
+ });
85
+
86
+ if (decision === "abort") {
87
+ console.error(consent.formatMutationList(mutations, flags));
88
+ console.error("");
89
+ console.error("memtrace install: refusing to modify user configuration without consent in a non-interactive session.");
90
+ console.error("memtrace install: re-run with --yes (or -y) to approve these changes.");
91
+ process.exit(consent.EXIT_NO_CONSENT);
92
+ }
93
+ if (decision === "prompt") {
94
+ console.log(consent.formatMutationList(mutations, flags));
95
+ // Synchronous prompt: the remainder of this script is top-level code
96
+ // and would otherwise fall through to the Rust-binary delegation
97
+ // while an async prompt was still pending.
98
+ const ok = consent.promptYesNoSync("\nProceed with these changes? [y/N] ");
99
+ if (!ok) {
100
+ console.log("memtrace install: aborted — no files were modified. Re-run with --yes to skip this prompt.");
101
+ process.exit(consent.EXIT_NO_CONSENT);
102
+ }
103
+ }
104
+ consentEnv.MEMTRACE_INSTALL_YES = "1";
105
+ runInstallBranch(npmCmd, memtraceCmd, flags.rest, consentEnv);
106
+ }
107
+
108
+ function runInstallBranch(npmCmd, memtraceCmd, rest, consentEnv) {
109
+
46
110
  // ── Pre-commit hook drift check (agent-precommit-optin, 2026-05-08) ──
47
111
  // `memtrace install` is the natural place a user re-runs after an
48
112
  // upgrade. If a prior version installed the pre-commit hook (now
@@ -72,7 +136,9 @@ if (args[0] === "install" || args[0] === "update" || args[0] === "upgrade") {
72
136
  ["install", "-g", "memtrace@latest"],
73
137
  spawnOptionsForPlatform(process.platform, {
74
138
  stdio: "inherit",
75
- env: { ...process.env, MEMTRACE_INSTALL_PARENT: "1" },
139
+ // consentEnv carries MEMTRACE_INSTALL_YES / _PROJECT_ONLY / _NO_HOOKS
140
+ // through npm into the postinstall hook (memtrace-public #25).
141
+ env: { ...process.env, MEMTRACE_INSTALL_PARENT: "1", ...consentEnv },
76
142
  })
77
143
  );
78
144
 
@@ -88,7 +154,6 @@ if (args[0] === "install" || args[0] === "update" || args[0] === "upgrade") {
88
154
  process.exit(installResult.status ?? 1);
89
155
  }
90
156
 
91
- const rest = args.slice(1);
92
157
  if (rest.length === 0) {
93
158
  process.stdout.write("memtrace: latest installed.\n");
94
159
  process.exit(0);
package/install.js CHANGED
@@ -236,45 +236,87 @@ function selfHealPlatformPackage() {
236
236
  return true;
237
237
  }
238
238
 
239
- // ── Main postinstall ─────────────────────────────────────────────────────────
240
-
241
- if (require.main === module) {
242
- // 1. Verify binary exists + is executable. If the optional platform
243
- // dep was silently omitted by npm, fetch it directly.
244
- selfHealPlatformPackage();
239
+ // ── Integration phases (post-consent) ───────────────────────────────────────
240
+ //
241
+ // Everything below this line mutates user configuration and therefore only
242
+ // runs AFTER the consent gate in `main()` (memtrace-public #25). The phases
243
+ // are identical to the pre-consent-era behavior; the gate, the scoping
244
+ // flags, and the backup manifest are the only additions.
245
+ //
246
+ // flags: { yes, projectOnly, noHooks } — see lib/install-consent.js.
247
+ function runIntegrationPhases({ home, flags }) {
248
+ const manifestLib = require("./lib/install-manifest");
249
+ const claudeDir = path.join(home, ".claude");
245
250
 
246
- let binaryPath = null;
247
- try {
248
- binaryPath = getBinaryPath();
249
- if (!fs.existsSync(binaryPath)) {
250
- throw new Error(`Binary not found at: ${binaryPath}`);
251
+ // Phase 0 — snapshot every user-global file we may mutate BEFORE any
252
+ // mutation, so uninstall can restore pre-install bytes. --project-only
253
+ // touches nothing under home, so there is nothing to back up.
254
+ let snapshot = null;
255
+ if (!flags.projectOnly) {
256
+ try {
257
+ snapshot = manifestLib.snapshotFiles(home, [
258
+ {
259
+ path: path.join(claudeDir, "settings.json"),
260
+ owned: {
261
+ kind: "json-keys",
262
+ keys: [
263
+ "hooks.* entries with _managed_by=memtrace (UserPromptSubmit, PostToolUse)",
264
+ "hooks.PreToolUse entries whose command contains 'route --hook' (Rail)",
265
+ "mcpServers.memtrace",
266
+ "enabledPlugins[\"memtrace-skills@memtrace\"]",
267
+ "extraKnownMarketplaces.memtrace (+ legacy marketplace containers)",
268
+ ],
269
+ },
270
+ },
271
+ {
272
+ path: path.join(claudeDir, "CLAUDE.md"),
273
+ owned: { kind: "block", markers: ["BEGIN MEMTRACE BLOCK", "END MEMTRACE BLOCK"] },
274
+ },
275
+ {
276
+ path: path.join(claudeDir, "MEMTRACE.md"),
277
+ owned: { kind: "file" },
278
+ },
279
+ ]);
280
+ } catch (e) {
281
+ console.warn(`memtrace: failed to write pre-install backups: ${e.message}`);
251
282
  }
252
- ensureExecutableBits(binaryPath);
253
- console.log(`memtrace: binary installed at ${binaryPath}`);
254
- } catch (e) {
255
- console.warn(`memtrace: ${e.message}`);
256
283
  }
257
284
 
258
- // 2. Delegate to compiled installer (global, all agents, non-interactive)
259
- const bundled = path.join(__dirname, "installer", "dist", "index.js");
260
- if (fs.existsSync(bundled)) {
261
- const result = spawnSync(process.execPath, [bundled, "install", "--yes"], {
262
- stdio: "inherit",
263
- env: { ...process.env, MEMTRACE_POSTINSTALL: "1" },
264
- });
265
- if (result.status !== 0) {
266
- console.warn(`memtrace: installer exited with code ${result.status}; continuing.`);
285
+ // Phase 1 — delegate to compiled installer (skills + MCP + plugin).
286
+ // default: global, all agents, non-interactive
287
+ // --project-only: local scope, Claude only — writes <cwd>/.claude/skills
288
+ // and <cwd>/.mcp.json, never a home path
289
+ // --no-hooks: MEMTRACE_INSTALL_NO_HOOKS=1 tells the bundled
290
+ // installer to skip the Rail PreToolUse hook
291
+ if (process.env.MEMTRACE_INSTALL_SKIP_BUNDLED !== "1") {
292
+ const bundled = path.join(__dirname, "installer", "dist", "index.js");
293
+ if (fs.existsSync(bundled)) {
294
+ const instArgs = ["install", "--yes"];
295
+ if (flags.projectOnly) instArgs.push("--local", "--only", "claude");
296
+ const childEnv = { ...process.env, MEMTRACE_POSTINSTALL: "1" };
297
+ if (flags.noHooks) childEnv.MEMTRACE_INSTALL_NO_HOOKS = "1";
298
+ const result = spawnSync(process.execPath, [bundled, ...instArgs], {
299
+ stdio: "inherit",
300
+ env: childEnv,
301
+ });
302
+ if (result.status !== 0) {
303
+ console.warn(`memtrace: installer exited with code ${result.status}; continuing.`);
304
+ }
305
+ } else {
306
+ console.warn("memtrace: bundled installer not found at installer/dist/index.js");
307
+ console.warn("memtrace: run 'memtrace install' manually after the package is built.");
267
308
  }
268
- } else {
269
- console.warn("memtrace: bundled installer not found at installer/dist/index.js");
270
- console.warn("memtrace: run 'memtrace install' manually after the package is built.");
271
309
  }
272
310
 
273
- // 3. Claude Code adoption layer (v0.3.35+):
311
+ // Phases 2+ are user-global by definition; --project-only stops here.
312
+ if (flags.projectOnly) return;
313
+
314
+ // Phase 2 — Claude Code adoption layer (v0.3.35+):
274
315
  // - write ~/.claude/MEMTRACE.md (sidecar directive we own)
275
316
  // - add a breadcrumb block to ~/.claude/CLAUDE.md if it exists
276
317
  // - register UserPromptSubmit + PostToolUse hooks in
277
318
  // ~/.claude/settings.json under `_managed_by: "memtrace"`
319
+ // (skipped entirely under --no-hooks)
278
320
  //
279
321
  // This layer is what makes Claude Code consistently reach for
280
322
  // Memtrace MCP tools FIRST instead of falling back to Read/Grep
@@ -292,8 +334,7 @@ if (require.main === module) {
292
334
  }
293
335
  }
294
336
  }
295
- const claudeDir = path.join(os.homedir(), ".claude");
296
- const summary = installToFs({ claudeDir, hooksDir });
337
+ const summary = installToFs({ claudeDir, hooksDir, noHooks: flags.noHooks });
297
338
  if (summary.wrote.length > 0) {
298
339
  console.log(
299
340
  `memtrace: configured Claude Code integration (${summary.wrote.length} file(s) updated)`
@@ -303,9 +344,10 @@ if (require.main === module) {
303
344
  console.warn(`memtrace: Claude Code integration setup skipped: ${e.message}`);
304
345
  }
305
346
 
306
- // 5. Persist uninstall script to ~/.memtrace/ (survives `npm uninstall -g`).
347
+ // Phase 3 — persist uninstall script to ~/.memtrace/ (survives
348
+ // `npm uninstall -g`, which skips preuninstall for global packages).
307
349
  try {
308
- const memtraceDir = path.join(os.homedir(), ".memtrace");
350
+ const memtraceDir = path.join(home, ".memtrace");
309
351
  fs.mkdirSync(memtraceDir, { recursive: true });
310
352
  fs.copyFileSync(
311
353
  path.join(__dirname, "uninstall.js"),
@@ -316,11 +358,104 @@ if (require.main === module) {
316
358
  console.warn(`memtrace: failed to persist uninstall script: ${e.message}`);
317
359
  }
318
360
 
361
+ // Phase 4 — record post-install hashes + write the manifest that
362
+ // `memtrace uninstall` restores from.
363
+ if (snapshot) {
364
+ try {
365
+ manifestLib.finalizeManifest(home, snapshot, {
366
+ createdPaths: [
367
+ path.join(claudeDir, "plugins", "cache", "memtrace"),
368
+ path.join(home, ".memtrace", "uninstall.js"),
369
+ ],
370
+ flags: { projectOnly: flags.projectOnly, noHooks: flags.noHooks },
371
+ version: require("./package.json").version,
372
+ });
373
+ console.log(`memtrace: backups + install manifest written to ${manifestLib.backupRoot(home)}`);
374
+ } catch (e) {
375
+ console.warn(`memtrace: failed to write install manifest: ${e.message}`);
376
+ }
377
+ }
378
+ }
379
+
380
+ // ── Main postinstall / `memtrace setup` entry ────────────────────────────────
381
+
382
+ async function main() {
383
+ const consent = require("./lib/install-consent");
384
+
385
+ // Step 1 — verify the platform binary exists + is executable. This is
386
+ // package-internal provisioning (never touches user config), so it runs
387
+ // before the consent gate: even a "defer" install must leave a working
388
+ // binary behind. MEMTRACE_INSTALL_SKIP_SELFHEAL=1 short-circuits it in
389
+ // tests (it can hit the network via `npm install`).
390
+ if (process.env.MEMTRACE_INSTALL_SKIP_SELFHEAL !== "1") {
391
+ selfHealPlatformPackage();
392
+ try {
393
+ const binaryPath = getBinaryPath();
394
+ if (!fs.existsSync(binaryPath)) {
395
+ throw new Error(`Binary not found at: ${binaryPath}`);
396
+ }
397
+ ensureExecutableBits(binaryPath);
398
+ console.log(`memtrace: binary installed at ${binaryPath}`);
399
+ } catch (e) {
400
+ console.warn(`memtrace: ${e.message}`);
401
+ }
402
+ }
403
+
404
+ // Step 2 — consent gate (memtrace-public #25). Print the full mutation
405
+ // list and require explicit approval before touching ANY user config.
406
+ const flags = consent.parseInstallFlags(process.argv.slice(2));
407
+ const home = consent.resolveHome();
408
+ const mutations = consent.buildMutationList({
409
+ home,
410
+ cwd: process.cwd(),
411
+ projectOnly: flags.projectOnly,
412
+ noHooks: flags.noHooks,
413
+ });
414
+ const decision = consent.decideConsent({
415
+ yes: flags.yes,
416
+ isTTY: Boolean(process.stdin.isTTY && process.stdout.isTTY),
417
+ isNpmLifecycle: process.env.npm_lifecycle_event === "postinstall",
418
+ hasPriorInstall: consent.detectPriorInstall(home),
419
+ });
420
+
421
+ if (decision === "prompt") {
422
+ console.log(consent.formatMutationList(mutations, flags));
423
+ const ok = await consent.promptYesNo("\nProceed with these changes? [y/N] ");
424
+ if (!ok) {
425
+ console.log("memtrace: aborted — no files were modified. Re-run with --yes to skip this prompt.");
426
+ process.exit(consent.EXIT_NO_CONSENT);
427
+ }
428
+ } else if (decision === "abort") {
429
+ console.error(consent.formatMutationList(mutations, flags));
430
+ console.error("");
431
+ console.error("memtrace: refusing to modify user configuration without consent in a non-interactive session.");
432
+ console.error("memtrace: re-run with --yes (or set MEMTRACE_INSTALL_YES=1) to approve these changes.");
433
+ process.exit(consent.EXIT_NO_CONSENT);
434
+ } else if (decision === "defer") {
435
+ // npm postinstall without a consent signal: never silently mutate, but
436
+ // never fail the package install either (a non-zero lifecycle exit
437
+ // would abort `npm install -g memtrace` for every fresh CI user).
438
+ console.log(consent.formatMutationList(mutations, flags));
439
+ console.log("");
440
+ console.log("memtrace: skipped agent/Claude Code configuration — no consent signal in npm lifecycle.");
441
+ console.log("memtrace: run `memtrace setup --yes` (or set MEMTRACE_INSTALL_YES=1 before npm install) to complete setup.");
442
+ process.exit(0);
443
+ }
444
+
445
+ runIntegrationPhases({ home, flags });
446
+ }
447
+
448
+ if (require.main === module) {
449
+ main().catch((e) => {
450
+ console.error(`memtrace: install failed: ${e.message}`);
451
+ process.exit(1);
452
+ });
319
453
  }
320
454
 
321
455
  module.exports = {
322
456
  getBinaryPath,
323
457
  ensureExecutableBits,
458
+ runIntegrationPhases,
324
459
  selfHealPlatformPackage,
325
460
  buildSelfHealEnv,
326
461
  buildSelfHealInstallArgs,
@@ -430,11 +430,15 @@ async function registerMcpServer(memtraceBinaryPath) {
430
430
  // hook defaults to observe mode (zero behavior change) until the user opts
431
431
  // into MEMTRACE_RAIL=nudge|rail|strict. Failure here must not block MCP
432
432
  // registration.
433
- try {
434
- registerRailHookInSettingsAt(settingsFile, memtraceBinaryPath);
435
- }
436
- catch (e) {
437
- console.warn(`memtrace: failed to register Rail hook: ${e.message}`);
433
+ // `memtrace install --no-hooks` (memtrace-public #25) propagates here via
434
+ // MEMTRACE_INSTALL_NO_HOOKS=1 — skip ALL settings.json hook registration.
435
+ if (process.env.MEMTRACE_INSTALL_NO_HOOKS !== '1') {
436
+ try {
437
+ registerRailHookInSettingsAt(settingsFile, memtraceBinaryPath);
438
+ }
439
+ catch (e) {
440
+ console.warn(`memtrace: failed to register Rail hook: ${e.message}`);
441
+ }
438
442
  }
439
443
  // Strategy 1 (preferred): claude CLI's own add-json path
440
444
  const viaCli = await tryMcpAddJson(memtraceBinaryPath);
@@ -4,6 +4,7 @@ import path from 'path';
4
4
  import { commandExists, execCommand } from '../utils.js';
5
5
  import { registerSettingsHook, removeSettingsHook, CODEX_MATCHER, } from './rail-hooks.js';
6
6
  const MCP_SERVER_NAME = 'memtrace';
7
+ const CODEX_MCP_SHIM_NAME = 'memtrace-mcp-codex-shim.cjs';
7
8
  function codexRailHooksPath(ctx) {
8
9
  const base = ctx.scope === 'global' ? path.join(os.homedir(), '.codex') : path.join(ctx.cwd, '.codex');
9
10
  return path.join(base, 'hooks.json');
@@ -37,6 +38,82 @@ function writeTextAtomic(filePath, content) {
37
38
  fs.writeFileSync(tmpPath, content);
38
39
  fs.renameSync(tmpPath, filePath);
39
40
  }
41
+ function codexMcpShimSource() {
42
+ return `#!/usr/bin/env node
43
+ const { spawn } = require('node:child_process');
44
+
45
+ const binary = process.argv[2] || 'memtrace';
46
+ const child = spawn(binary, ['mcp'], { stdio: ['pipe', 'pipe', 'pipe'] });
47
+
48
+ process.stdin.pipe(child.stdin);
49
+ child.stderr.pipe(process.stderr);
50
+
51
+ function stripAnnotations(line) {
52
+ let message;
53
+ try {
54
+ message = JSON.parse(line);
55
+ } catch {
56
+ return line;
57
+ }
58
+
59
+ const content = message && message.result && message.result.content;
60
+ if (!Array.isArray(content)) return line;
61
+
62
+ const assistantItems = [];
63
+ const fallbackItems = [];
64
+ for (const item of content) {
65
+ if (!item || typeof item !== 'object') continue;
66
+ const clean = { ...item };
67
+ const annotations = clean.annotations;
68
+ delete clean.annotations;
69
+ fallbackItems.push(clean);
70
+ const audience = annotations && annotations.audience;
71
+ if (Array.isArray(audience) && audience.includes('assistant')) {
72
+ assistantItems.push(clean);
73
+ }
74
+ }
75
+
76
+ message.result.content = assistantItems.length > 0 ? assistantItems : fallbackItems;
77
+ return JSON.stringify(message);
78
+ }
79
+
80
+ let buffer = '';
81
+ child.stdout.on('data', (chunk) => {
82
+ buffer += chunk.toString('utf8');
83
+ let index = buffer.indexOf('\\n');
84
+ while (index !== -1) {
85
+ const line = buffer.slice(0, index);
86
+ buffer = buffer.slice(index + 1);
87
+ process.stdout.write(stripAnnotations(line) + '\\n');
88
+ index = buffer.indexOf('\\n');
89
+ }
90
+ });
91
+
92
+ child.stdout.on('end', () => {
93
+ if (buffer.length > 0) process.stdout.write(stripAnnotations(buffer));
94
+ });
95
+
96
+ child.on('exit', (code, signal) => {
97
+ if (signal) process.kill(process.pid, signal);
98
+ process.exit(code ?? 0);
99
+ });
100
+ `;
101
+ }
102
+ function codexMcpShimPath(configFile) {
103
+ return path.join(path.dirname(configFile), CODEX_MCP_SHIM_NAME);
104
+ }
105
+ function writeCodexMcpShim(configFile) {
106
+ const shimPath = codexMcpShimPath(configFile);
107
+ writeTextAtomic(shimPath, codexMcpShimSource());
108
+ try {
109
+ fs.chmodSync(shimPath, 0o755);
110
+ }
111
+ catch {
112
+ // Windows and locked-down filesystems may reject chmod; Codex invokes it
113
+ // through Node, so executable bits are a convenience, not a requirement.
114
+ }
115
+ return shimPath;
116
+ }
40
117
  function isMemtraceMcpTable(tableName) {
41
118
  return tableName === `mcp_servers.${MCP_SERVER_NAME}`
42
119
  || tableName === `mcp_servers."${MCP_SERVER_NAME}"`
@@ -119,10 +196,11 @@ export function registerCodexMcpAt(configFile, binary) {
119
196
  // after the freshly written parent block for readability.
120
197
  const stripped = stripCodexMcpServer(existing);
121
198
  const { rest, envBlock } = extractMemtraceEnvBlock(stripped);
199
+ const shimPath = writeCodexMcpShim(configFile);
122
200
  const block = [
123
201
  `[mcp_servers.${MCP_SERVER_NAME}]`,
124
- `command = ${tomlString(binary)}`,
125
- `args = [${tomlString('mcp')}]`,
202
+ `command = ${tomlString(process.execPath)}`,
203
+ `args = [${[shimPath, binary].map(tomlString).join(', ')}]`,
126
204
  ].join('\n');
127
205
  const parts = [];
128
206
  if (rest)
@@ -167,15 +245,11 @@ export const codexTransformer = {
167
245
  const warnings = [];
168
246
  const mcpConfigPath = codexConfigPath(ctx);
169
247
  if (!ctx.skipMcp) {
170
- if (ctx.scope === 'global' && await tryCodexMcpAdd(ctx.memtraceBinary)) {
171
- mcpRegistered = true;
172
- }
173
- else {
174
- registerCodexMcpAt(mcpConfigPath, ctx.memtraceBinary);
175
- mcpRegistered = true;
176
- if (ctx.scope === 'global') {
177
- warnings.push('codex CLI MCP registration was unavailable; wrote ~/.codex/config.toml directly.');
178
- }
248
+ const cliRegistered = ctx.scope === 'global' && await tryCodexMcpAdd(ctx.memtraceBinary);
249
+ registerCodexMcpAt(mcpConfigPath, ctx.memtraceBinary);
250
+ mcpRegistered = true;
251
+ if (ctx.scope === 'global' && !cliRegistered) {
252
+ warnings.push('codex CLI MCP registration was unavailable; wrote ~/.codex/config.toml directly.');
179
253
  }
180
254
  }
181
255
  // ─── memtrace-rail ─── Wire the discovery hook into .codex/hooks.json
@@ -339,6 +339,8 @@ function removeSettingsHooks(settings) {
339
339
  * @param {object} [opts]
340
340
  * @param {string} [opts.claudeDir] default `~/.claude`
341
341
  * @param {string} [opts.hooksDir] absolute dir where hook scripts live
342
+ * @param {boolean} [opts.noHooks] skip settings.json hook registration
343
+ * entirely (`memtrace install --no-hooks`)
342
344
  * @returns {object} summary `{ wrote: [...], skipped: [...] }`
343
345
  */
344
346
  function installToFs(opts = {}) {
@@ -372,8 +374,12 @@ function installToFs(opts = {}) {
372
374
  summary.skipped.push(claudeMdPath + " (does not exist; not creating)");
373
375
  }
374
376
 
375
- // 3. Register hooks in settings.json.
377
+ // 3. Register hooks in settings.json (skipped under --no-hooks).
376
378
  const settingsPath = path.join(claudeDir, "settings.json");
379
+ if (opts.noHooks) {
380
+ summary.skipped.push(settingsPath + " (--no-hooks)");
381
+ return summary;
382
+ }
377
383
  let settings = {};
378
384
  if (fs.existsSync(settingsPath)) {
379
385
  try {
@@ -0,0 +1,202 @@
1
+ "use strict";
2
+
3
+ // Consent + scoping layer for `memtrace install` / npm postinstall.
4
+ //
5
+ // memtrace-public issue #25: `memtrace install` mutated user-global
6
+ // Claude Code configuration (~/.claude/settings.json, ~/.claude/CLAUDE.md,
7
+ // ~/.claude/MEMTRACE.md, MCP/plugin/marketplace registries) with no
8
+ // consent prompt, no documented mutation list, and no opt-out flags —
9
+ // silently diverging dotfiles managed by chezmoi / Nix home-manager.
10
+ //
11
+ // This module is the single source of truth for:
12
+ // 1. The full mutation list (what install will touch, with paths)
13
+ // 2. Flag parsing (--yes/-y, --project-only, --no-hooks)
14
+ // 3. The consent decision table (pure, unit-tested)
15
+ //
16
+ // All side effects stay in install.js / uninstall.js / bin/memtrace.js.
17
+ //
18
+ // Environment knobs (set by the bin shim or the Rust `memtrace install`
19
+ // parent after IT obtained consent on the TTY it owns, or by CI):
20
+ // MEMTRACE_INSTALL_YES=1 — consent granted (same as --yes)
21
+ // MEMTRACE_ASSUME_YES=1 — alias for the above
22
+ // MEMTRACE_INSTALL_PROJECT_ONLY=1 — same as --project-only
23
+ // MEMTRACE_INSTALL_NO_HOOKS=1 — same as --no-hooks
24
+ // MEMTRACE_INSTALL_HOME=<dir> — home-dir override (tests)
25
+
26
+ const fs = require("fs");
27
+ const path = require("path");
28
+ const os = require("os");
29
+ const readline = require("readline");
30
+
31
+ /** Exit code used whenever install aborts without consent. */
32
+ const EXIT_NO_CONSENT = 2;
33
+
34
+ function resolveHome(env = process.env) {
35
+ return env.MEMTRACE_INSTALL_HOME || os.homedir();
36
+ }
37
+
38
+ /**
39
+ * Parse install flags out of an argv slice. Unrecognised arguments are
40
+ * preserved in `rest` (the bin shim chains them: `memtrace install start`).
41
+ * Env vars provide the non-interactive channel through `npm install -g`.
42
+ */
43
+ function parseInstallFlags(argv = [], env = process.env) {
44
+ const flags = {
45
+ yes: env.MEMTRACE_INSTALL_YES === "1" || env.MEMTRACE_ASSUME_YES === "1",
46
+ projectOnly: env.MEMTRACE_INSTALL_PROJECT_ONLY === "1",
47
+ noHooks: env.MEMTRACE_INSTALL_NO_HOOKS === "1",
48
+ rest: [],
49
+ };
50
+ for (const a of argv) {
51
+ if (a === "--yes" || a === "-y") flags.yes = true;
52
+ else if (a === "--project-only") flags.projectOnly = true;
53
+ else if (a === "--no-hooks") flags.noHooks = true;
54
+ else flags.rest.push(a);
55
+ }
56
+ return flags;
57
+ }
58
+
59
+ /**
60
+ * The complete list of files/registries `memtrace install` may mutate,
61
+ * as `{ path, change }` entries. Pure. This is what gets printed before
62
+ * any mutation happens and what docs/INSTALL-FOOTPRINT.md documents.
63
+ */
64
+ function buildMutationList({ home, cwd, projectOnly = false, noHooks = false }) {
65
+ const list = [];
66
+ if (projectOnly) {
67
+ list.push(
68
+ { path: path.join(cwd, ".claude", "skills", "memtrace-*"), change: "create/replace Memtrace skill directories (project scope)" },
69
+ { path: path.join(cwd, ".mcp.json"), change: "merge-add mcpServers.memtrace entry (project scope)" },
70
+ );
71
+ return list;
72
+ }
73
+ const claude = path.join(home, ".claude");
74
+ list.push(
75
+ { path: path.join(claude, "MEMTRACE.md"), change: "create/overwrite (file is fully memtrace-owned)" },
76
+ { path: path.join(claude, "CLAUDE.md"), change: "append one marker-delimited breadcrumb block (only if the file already exists)" },
77
+ {
78
+ path: path.join(claude, "settings.json"),
79
+ change: noHooks
80
+ ? "merge-add mcpServers.memtrace, enabledPlugins[\"memtrace-skills@memtrace\"], extraKnownMarketplaces.memtrace (hooks SKIPPED via --no-hooks)"
81
+ : "merge-add hooks (UserPromptSubmit, PostToolUse[mcp__memtrace__.*], PreToolUse Rail[Grep|Glob|Bash]), mcpServers.memtrace, enabledPlugins[\"memtrace-skills@memtrace\"], extraKnownMarketplaces.memtrace",
82
+ },
83
+ { path: path.join(claude, "plugins", "cache", "memtrace", "memtrace-skills") + path.sep, change: "create/replace plugin cache (skills payload)" },
84
+ { path: path.join(claude, "plugins", "marketplaces", "{memtrace,syncable-dev-memtrace*}") + path.sep, change: "remove stale + re-clone marketplace caches (via `claude` CLI when available)" },
85
+ { path: path.join(claude, "plugins", "installed_plugins.json"), change: "plugin registry entry (written by the `claude` CLI when available)" },
86
+ { path: path.join(home, ".claude.json"), change: "possible memtrace MCP entry (written by `claude mcp add-json --scope user` when the `claude` CLI is available)" },
87
+ { path: path.join(home, ".memtrace") + path.sep, change: "create memtrace data dir: persisted uninstall.js + install-backup/ (backups + manifest)" },
88
+ { path: path.join(home, "{.cursor,.codex,.gemini,.codeium,.hermes,.kiro,.copilot,.config/opencode,VS Code user config}"), change: "skills + memtrace MCP registration for other detected agents (same merge-add pattern)" },
89
+ );
90
+ return list;
91
+ }
92
+
93
+ function formatMutationList(list, { projectOnly = false, noHooks = false } = {}) {
94
+ const lines = [];
95
+ lines.push("memtrace install will modify the following files:");
96
+ lines.push("");
97
+ for (const m of list) {
98
+ lines.push(` ${m.path}`);
99
+ lines.push(` ${m.change}`);
100
+ }
101
+ lines.push("");
102
+ lines.push(" Every change is marker-tagged or merge-added; a backup of each");
103
+ lines.push(" pre-existing file plus a manifest is written to ~/.memtrace/install-backup/.");
104
+ lines.push(" `memtrace uninstall` restores from that manifest (surgically removing only");
105
+ lines.push(" memtrace-added blocks if you edited a file after install).");
106
+ lines.push("");
107
+ const flagHints = [];
108
+ if (!projectOnly) flagHints.push("--project-only (never touch ~/.claude or any home path)");
109
+ if (!noHooks) flagHints.push("--no-hooks (skip all settings.json hook registration)");
110
+ flagHints.push("--yes / -y (skip this prompt)");
111
+ lines.push(` Flags: ${flagHints.join(", ")}`);
112
+ lines.push(" Full footprint: docs/INSTALL-FOOTPRINT.md (memtrace repo) / https://github.com/syncable-dev/memtrace-public");
113
+ return lines.join("\n");
114
+ }
115
+
116
+ /**
117
+ * A previous memtrace install on this machine implies prior consent: a
118
+ * re-run is a refresh of state the user already accepted, so upgrades
119
+ * (`memtrace install`, `npm i -g memtrace@latest`) stay friction-free
120
+ * and behave exactly as before this consent layer existed.
121
+ */
122
+ function detectPriorInstall(home) {
123
+ try {
124
+ if (fs.existsSync(path.join(home, ".memtrace", "install-backup", "manifest.json"))) return true;
125
+ if (fs.existsSync(path.join(home, ".claude", "MEMTRACE.md"))) return true;
126
+ const settingsPath = path.join(home, ".claude", "settings.json");
127
+ if (fs.existsSync(settingsPath)) {
128
+ const text = fs.readFileSync(settingsPath, "utf8");
129
+ if (text.includes('"_managed_by": "memtrace"') || text.includes('"_managed_by":"memtrace"')) {
130
+ return true;
131
+ }
132
+ }
133
+ } catch {
134
+ // unreadable home — treat as fresh install (consent required)
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Pure consent decision table.
141
+ *
142
+ * proceed — consent established (--yes / env / prior consented install)
143
+ * prompt — interactive TTY available: print the list and ask
144
+ * defer — npm lifecycle (postinstall) without consent: print the
145
+ * list, SKIP all user-config mutation, exit 0. Exiting 2
146
+ * here would fail the entire `npm install -g memtrace`
147
+ * (lifecycle scripts propagate), which would break every
148
+ * fresh CI install. "Never silently mutate from a pipe" is
149
+ * honored: nothing is mutated and the skip is loud.
150
+ * abort — any other non-TTY invocation without consent: print the
151
+ * list and exit 2. Never silently mutate from a pipe.
152
+ */
153
+ function decideConsent({ yes, isTTY, isNpmLifecycle, hasPriorInstall }) {
154
+ if (yes || hasPriorInstall) return "proceed";
155
+ if (isNpmLifecycle) return "defer";
156
+ if (isTTY) return "prompt";
157
+ return "abort";
158
+ }
159
+
160
+ /**
161
+ * Synchronous y/N prompt (default No) for callers whose remaining
162
+ * top-level code would otherwise race an async prompt (bin/memtrace.js).
163
+ * Blocking read on fd 0; any read error counts as "no".
164
+ */
165
+ function promptYesNoSync(question) {
166
+ process.stdout.write(question);
167
+ const buf = Buffer.alloc(256);
168
+ let n = 0;
169
+ for (;;) {
170
+ try {
171
+ n = fs.readSync(0, buf, 0, 256);
172
+ break;
173
+ } catch (e) {
174
+ if (e.code === "EAGAIN") continue; // non-blocking stdin: retry
175
+ return false;
176
+ }
177
+ }
178
+ return /^y(es)?$/i.test(buf.toString("utf8", 0, n).trim());
179
+ }
180
+
181
+ /** Ask a y/N question on the current TTY. Default: No. */
182
+ function promptYesNo(question) {
183
+ return new Promise((resolve) => {
184
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
185
+ rl.question(question, (answer) => {
186
+ rl.close();
187
+ resolve(/^y(es)?$/i.test(answer.trim()));
188
+ });
189
+ });
190
+ }
191
+
192
+ module.exports = {
193
+ EXIT_NO_CONSENT,
194
+ resolveHome,
195
+ parseInstallFlags,
196
+ buildMutationList,
197
+ formatMutationList,
198
+ detectPriorInstall,
199
+ decideConsent,
200
+ promptYesNo,
201
+ promptYesNoSync,
202
+ };
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+
3
+ // Backup + manifest layer for `memtrace install` (memtrace-public #25).
4
+ //
5
+ // Before install mutates any user-global file it snapshots the file's
6
+ // pre-install bytes into ~/.memtrace/install-backup/<timestamp>/ and,
7
+ // after all phases complete, writes a manifest recording for each file:
8
+ //
9
+ // - whether it existed before install
10
+ // - where the backup copy lives
11
+ // - sha256 BEFORE install (preSha256)
12
+ // - sha256 AFTER install completed (postSha256)
13
+ // - exactly which memtrace-owned blocks/keys the file gained
14
+ //
15
+ // `memtrace uninstall` then has two safe paths per file:
16
+ //
17
+ // current sha == postSha256 → file untouched since install:
18
+ // restore the backup byte-for-byte
19
+ // (or delete the file if we created it)
20
+ // current sha != postSha256 → user edited the file after install:
21
+ // surgically remove ONLY the recorded
22
+ // memtrace-owned blocks/keys, preserving
23
+ // every user byte.
24
+ //
25
+ // The manifest lives at ~/.memtrace/install-backup/manifest.json and is
26
+ // renamed to manifest.consumed-<ts>.json after a successful restore so
27
+ // repeated uninstalls are idempotent no-ops.
28
+
29
+ const fs = require("fs");
30
+ const path = require("path");
31
+ const crypto = require("crypto");
32
+
33
+ const MANIFEST_VERSION = 1;
34
+
35
+ function backupRoot(home) {
36
+ return path.join(home, ".memtrace", "install-backup");
37
+ }
38
+
39
+ function manifestPath(home) {
40
+ return path.join(backupRoot(home), "manifest.json");
41
+ }
42
+
43
+ function sha256OfFile(filePath) {
44
+ try {
45
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
46
+ } catch {
47
+ return null; // missing/unreadable
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Snapshot the pre-install state of `targets` (absolute paths).
53
+ * Copies each existing file into a timestamped backup dir.
54
+ * Returns `{ stamp, dir, entries }`; entries carry `owned` descriptors
55
+ * so uninstall knows exactly what memtrace added to each file.
56
+ *
57
+ * @param {string} home
58
+ * @param {Array<{path: string, owned: object}>} targets
59
+ */
60
+ function snapshotFiles(home, targets, now = new Date()) {
61
+ const stamp = now.toISOString().replace(/[:.]/g, "-");
62
+ const dir = path.join(backupRoot(home), stamp);
63
+ const entries = [];
64
+ let idx = 0;
65
+ for (const t of targets) {
66
+ const existed = fs.existsSync(t.path);
67
+ let backupPath = null;
68
+ if (existed) {
69
+ fs.mkdirSync(dir, { recursive: true });
70
+ backupPath = path.join(dir, `${idx}-${path.basename(t.path)}`);
71
+ fs.copyFileSync(t.path, backupPath);
72
+ }
73
+ entries.push({
74
+ path: t.path,
75
+ owned: t.owned,
76
+ existedBefore: existed,
77
+ backupPath,
78
+ preSha256: existed ? sha256OfFile(t.path) : null,
79
+ postSha256: null, // filled in by finalizeManifest
80
+ });
81
+ idx++;
82
+ }
83
+ return { stamp, dir, entries };
84
+ }
85
+
86
+ /**
87
+ * Record post-install hashes and persist the manifest. Call AFTER every
88
+ * install phase has finished mutating files.
89
+ */
90
+ function finalizeManifest(home, snapshot, { createdPaths = [], flags = {}, version = null } = {}) {
91
+ for (const e of snapshot.entries) {
92
+ e.postSha256 = sha256OfFile(e.path);
93
+ }
94
+ const manifest = {
95
+ version: MANIFEST_VERSION,
96
+ memtraceVersion: version,
97
+ createdAt: new Date().toISOString(),
98
+ backupDir: snapshot.dir,
99
+ flags,
100
+ entries: snapshot.entries,
101
+ // Paths memtrace created wholesale (safe to delete on uninstall).
102
+ createdPaths,
103
+ };
104
+ fs.mkdirSync(backupRoot(home), { recursive: true });
105
+ fs.writeFileSync(manifestPath(home), JSON.stringify(manifest, null, 2) + "\n");
106
+ return manifest;
107
+ }
108
+
109
+ function readManifest(home) {
110
+ try {
111
+ return JSON.parse(fs.readFileSync(manifestPath(home), "utf8"));
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /** Rename the manifest aside so a second uninstall is a no-op. */
118
+ function consumeManifest(home) {
119
+ const p = manifestPath(home);
120
+ if (!fs.existsSync(p)) return;
121
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
122
+ try {
123
+ fs.renameSync(p, path.join(backupRoot(home), `manifest.consumed-${stamp}.json`));
124
+ } catch {
125
+ try { fs.unlinkSync(p); } catch { /* best-effort */ }
126
+ }
127
+ }
128
+
129
+ module.exports = {
130
+ MANIFEST_VERSION,
131
+ backupRoot,
132
+ manifestPath,
133
+ sha256OfFile,
134
+ snapshotFiles,
135
+ finalizeManifest,
136
+ readManifest,
137
+ consumeManifest,
138
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memtrace",
3
- "version": "0.6.19",
3
+ "version": "0.6.30",
4
4
  "description": "Code intelligence graph — MCP server + AI agent skills + visualization UI",
5
5
  "keywords": [
6
6
  "mcp",
@@ -39,12 +39,12 @@
39
39
  "fs-extra": "^11.0.0"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@memtrace/darwin-arm64": "0.6.19",
43
- "@memtrace/linux-x64": "0.6.19",
44
- "@memtrace/linux-arm64": "0.6.19",
45
- "@memtrace/win32-x64": "0.6.19",
46
- "@memtrace/linux-x64-noavx2": "0.6.19",
47
- "@memtrace/win32-x64-noavx2": "0.6.19"
42
+ "@memtrace/darwin-arm64": "0.6.30",
43
+ "@memtrace/linux-x64": "0.6.30",
44
+ "@memtrace/linux-arm64": "0.6.30",
45
+ "@memtrace/win32-x64": "0.6.30",
46
+ "@memtrace/linux-x64-noavx2": "0.6.30",
47
+ "@memtrace/win32-x64-noavx2": "0.6.30"
48
48
  },
49
49
  "engines": {
50
50
  "node": ">=18"
package/uninstall.js CHANGED
@@ -4,6 +4,7 @@
4
4
  const os = require("os");
5
5
  const path = require("path");
6
6
  const fs = require("fs");
7
+ const crypto = require("crypto");
7
8
  const { execSync, spawnSync } = require("child_process");
8
9
 
9
10
  // claude-integration may not be present in old installs running this
@@ -53,6 +54,235 @@ const SKILL_NAMES = [
53
54
  "memtrace-refactoring-guide",
54
55
  ];
55
56
 
57
+ // ── Manifest-driven restore (memtrace-public #25) ───────────────────────────
58
+ //
59
+ // `memtrace install` writes ~/.memtrace/install-backup/manifest.json with a
60
+ // pre-install backup + pre/post sha256 for every user-global file it mutated.
61
+ // Restore strategy per file:
62
+ //
63
+ // current sha == postSha256 → untouched since install → byte-exact restore
64
+ // of the backup (or delete, if install created
65
+ // the file).
66
+ // current sha != postSha256 → user edited it after install → surgically
67
+ // remove ONLY memtrace-owned blocks/keys,
68
+ // preserving every user byte.
69
+ //
70
+ // Everything here is deliberately self-contained (constants inlined, no
71
+ // require of lib/claude-integration) because this script is persisted to
72
+ // ~/.memtrace/uninstall.js, where lib/ does not exist. That missing-lib
73
+ // fallback was exactly how pre-#25 uninstalls left the CLAUDE.md breadcrumb
74
+ // and the managed hooks behind.
75
+
76
+ const MT_BLOCK_BEGIN =
77
+ "<!-- BEGIN MEMTRACE BLOCK · managed by `memtrace install` · remove with `memtrace uninstall` -->";
78
+ const MT_BLOCK_END = "<!-- END MEMTRACE BLOCK -->";
79
+ const MT_HOOK_MANAGED_TAG = "memtrace";
80
+ const MT_HOOK_SCRIPTS = ["userprompt-claude.sh", "posttool-mcp-telemetry.sh"];
81
+ const MT_RAIL_HOOK_MARKER = "route --hook";
82
+
83
+ function installManifestPath(home = os.homedir()) {
84
+ return path.join(home, ".memtrace", "install-backup", "manifest.json");
85
+ }
86
+
87
+ function readInstallManifest(home = os.homedir()) {
88
+ try {
89
+ return JSON.parse(fs.readFileSync(installManifestPath(home), "utf8"));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function consumeInstallManifest(home = os.homedir()) {
96
+ const p = installManifestPath(home);
97
+ if (!fs.existsSync(p)) return;
98
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
99
+ try {
100
+ fs.renameSync(p, path.join(path.dirname(p), `manifest.consumed-${stamp}.json`));
101
+ } catch {
102
+ try { fs.unlinkSync(p); } catch { /* best-effort */ }
103
+ }
104
+ }
105
+
106
+ function sha256OfFile(filePath) {
107
+ try {
108
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /** Remove the memtrace breadcrumb block from CLAUDE.md text. Tolerant:
115
+ * a dangling BEGIN without END leaves the text unchanged. */
116
+ function stripBreadcrumbText(text) {
117
+ const beginIdx = text.indexOf(MT_BLOCK_BEGIN);
118
+ if (beginIdx === -1) return text;
119
+ const endIdx = text.indexOf(MT_BLOCK_END, beginIdx);
120
+ if (endIdx === -1) return text;
121
+ const blockEnd = endIdx + MT_BLOCK_END.length;
122
+ const tailNewline = text.charAt(blockEnd) === "\n" ? 1 : 0;
123
+ return text.slice(0, beginIdx) + text.slice(blockEnd + tailNewline);
124
+ }
125
+
126
+ /** True when an inner hook object is memtrace-owned (managed tag, our hook
127
+ * scripts, or the Rail `route --hook` command). */
128
+ function isMemtraceHook(h) {
129
+ if (!isRecord(h)) return false;
130
+ if (h._managed_by === MT_HOOK_MANAGED_TAG) return true;
131
+ if (typeof h.command === "string") {
132
+ if (h.command.includes(MT_RAIL_HOOK_MARKER)) return true;
133
+ for (const script of MT_HOOK_SCRIPTS) {
134
+ if (h.command.endsWith(script)) return true;
135
+ }
136
+ }
137
+ return false;
138
+ }
139
+
140
+ /** Strip every memtrace-owned hook entry (managed UserPromptSubmit /
141
+ * PostToolUse + Rail PreToolUse) from a parsed settings object. */
142
+ function stripManagedHooksFromSettings(settings) {
143
+ const out = JSON.parse(JSON.stringify(settings || {}));
144
+ if (!isRecord(out.hooks)) return out;
145
+ for (const eventName of Object.keys(out.hooks)) {
146
+ const blocks = out.hooks[eventName];
147
+ if (!Array.isArray(blocks)) continue;
148
+ const cleaned = [];
149
+ for (const block of blocks) {
150
+ if (!isRecord(block) || !Array.isArray(block.hooks)) {
151
+ cleaned.push(block);
152
+ continue;
153
+ }
154
+ const remaining = block.hooks.filter((h) => !isMemtraceHook(h));
155
+ if (remaining.length === 0) continue; // block was entirely ours
156
+ cleaned.push({ ...block, hooks: remaining });
157
+ }
158
+ if (cleaned.length === 0) delete out.hooks[eventName];
159
+ else out.hooks[eventName] = cleaned;
160
+ }
161
+ if (isRecord(out.hooks) && Object.keys(out.hooks).length === 0) delete out.hooks;
162
+ return out;
163
+ }
164
+
165
+ /** Surgically remove memtrace-owned content from one manifest-tracked file.
166
+ * Used when the file changed after install (hand-edited). */
167
+ function surgicalCleanFile(entry) {
168
+ const file = entry.path;
169
+ if (!fs.existsSync(file)) return false;
170
+ const kind = entry.owned && entry.owned.kind;
171
+
172
+ if (kind === "file") {
173
+ // Wholly memtrace-owned (MEMTRACE.md) — always safe to delete.
174
+ fs.unlinkSync(file);
175
+ return true;
176
+ }
177
+
178
+ if (kind === "block") {
179
+ const existing = fs.readFileSync(file, "utf8");
180
+ const updated = stripBreadcrumbText(existing);
181
+ if (updated === existing) return false;
182
+ if (updated === "") fs.unlinkSync(file);
183
+ else fs.writeFileSync(file, updated);
184
+ return true;
185
+ }
186
+
187
+ if (kind === "json-keys") {
188
+ let settings;
189
+ try {
190
+ settings = JSON.parse(fs.readFileSync(file, "utf8"));
191
+ } catch {
192
+ console.warn(`memtrace: ${file} is not valid JSON; leaving it untouched.`);
193
+ return false;
194
+ }
195
+ let out = stripManagedHooksFromSettings(settings);
196
+ if (isRecord(out.mcpServers) && out.mcpServers[MCP_SERVER_NAME]) {
197
+ delete out.mcpServers[MCP_SERVER_NAME];
198
+ if (Object.keys(out.mcpServers).length === 0) delete out.mcpServers;
199
+ }
200
+ if (isRecord(out.enabledPlugins)) {
201
+ for (const key of Object.keys(out.enabledPlugins)) {
202
+ if (key === PLUGIN_KEY || (key.startsWith(`${PLUGIN_NAME}@`) && key.includes("memtrace"))) {
203
+ delete out.enabledPlugins[key];
204
+ }
205
+ }
206
+ if (Object.keys(out.enabledPlugins).length === 0) delete out.enabledPlugins;
207
+ }
208
+ for (const containerName of MARKETPLACE_SETTING_CONTAINERS) {
209
+ const container = out[containerName];
210
+ if (!isRecord(container)) continue;
211
+ for (const key of MARKETPLACE_SETTING_KEYS) {
212
+ if (Object.prototype.hasOwnProperty.call(container, key)) delete container[key];
213
+ }
214
+ if (Object.keys(container).length === 0) delete out[containerName];
215
+ }
216
+ const before = JSON.stringify(settings);
217
+ const after = JSON.stringify(out);
218
+ if (before === after) return false;
219
+ if (Object.keys(out).length === 0) fs.unlinkSync(file);
220
+ else fs.writeFileSync(file, JSON.stringify(out, null, 2) + "\n");
221
+ return true;
222
+ }
223
+
224
+ return false;
225
+ }
226
+
227
+ /**
228
+ * Restore user-global files from the install manifest. Returns a summary
229
+ * object, or null when no manifest exists (pre-#25 install — callers fall
230
+ * back to legacy surgical cleanup). Idempotent: the manifest is renamed
231
+ * aside after a successful pass.
232
+ */
233
+ function restoreFromManifest(home = os.homedir()) {
234
+ const manifest = readInstallManifest(home);
235
+ if (!manifest || !Array.isArray(manifest.entries)) return null;
236
+
237
+ const summary = { restored: [], surgical: [], skipped: [] };
238
+ for (const entry of manifest.entries) {
239
+ try {
240
+ const currentSha = sha256OfFile(entry.path);
241
+ if (currentSha === null && entry.postSha256 === null) {
242
+ summary.skipped.push(`${entry.path} (never existed)`);
243
+ continue;
244
+ }
245
+ if (currentSha !== null && currentSha === entry.postSha256) {
246
+ // Untouched since install — byte-exact restore of pre-install state.
247
+ if (entry.existedBefore && entry.backupPath && fs.existsSync(entry.backupPath)) {
248
+ fs.copyFileSync(entry.backupPath, entry.path);
249
+ summary.restored.push(`${entry.path} (restored pre-install backup)`);
250
+ } else if (!entry.existedBefore) {
251
+ fs.unlinkSync(entry.path);
252
+ summary.restored.push(`${entry.path} (removed — created by install)`);
253
+ } else {
254
+ // Backup missing: fall back to surgical removal.
255
+ if (surgicalCleanFile(entry)) summary.surgical.push(entry.path);
256
+ else summary.skipped.push(`${entry.path} (backup missing, nothing to remove)`);
257
+ }
258
+ } else if (currentSha === null) {
259
+ summary.skipped.push(`${entry.path} (already removed)`);
260
+ } else {
261
+ // Hand-edited after install — remove ONLY memtrace-owned content.
262
+ if (surgicalCleanFile(entry)) {
263
+ summary.surgical.push(`${entry.path} (memtrace blocks removed, user edits preserved)`);
264
+ } else {
265
+ summary.skipped.push(`${entry.path} (no memtrace content found)`);
266
+ }
267
+ }
268
+ } catch (e) {
269
+ console.warn(`memtrace: manifest restore failed for ${entry.path}: ${e.message}`);
270
+ }
271
+ }
272
+
273
+ // Paths install created wholesale (plugin cache etc.) are safe to delete.
274
+ for (const p of manifest.createdPaths || []) {
275
+ try {
276
+ if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
277
+ } catch (e) {
278
+ console.warn(`memtrace: failed to remove ${p}: ${e.message}`);
279
+ }
280
+ }
281
+
282
+ consumeInstallManifest(home);
283
+ return summary;
284
+ }
285
+
56
286
  // ── Remove skills ───────────────────────────────────────────────────────────
57
287
  //
58
288
  // Each cleanup helper takes an optional `home` parameter so we can
@@ -271,14 +501,53 @@ function tryClaudeCliUninstall() {
271
501
 
272
502
  function removeClaudeIntegration(home = os.homedir()) {
273
503
  const ci = loadClaudeIntegration();
274
- if (!ci) return;
504
+ if (ci) {
505
+ try {
506
+ const summary = ci.uninstallFromFs({ claudeDir: path.join(home, ".claude") });
507
+ if (summary.removed.length > 0) {
508
+ console.log(`memtrace: cleaned Claude Code integration (${summary.removed.length} item(s))`);
509
+ }
510
+ return;
511
+ } catch (e) {
512
+ console.warn(`memtrace: Claude Code integration cleanup failed: ${e.message}`);
513
+ // fall through to the inline cleanup below
514
+ }
515
+ }
516
+
517
+ // Inline fallback (memtrace-public #25): when this script runs from its
518
+ // persisted copy at ~/.memtrace/uninstall.js, lib/claude-integration is
519
+ // not on disk. The old behavior silently no-op'd here, leaving the
520
+ // CLAUDE.md breadcrumb and the managed settings.json hooks behind.
275
521
  try {
276
- const summary = ci.uninstallFromFs({ claudeDir: path.join(home, ".claude") });
277
- if (summary.removed.length > 0) {
278
- console.log(`memtrace: cleaned Claude Code integration (${summary.removed.length} item(s))`);
522
+ const claudeDir = path.join(home, ".claude");
523
+
524
+ const memtraceMd = path.join(claudeDir, "MEMTRACE.md");
525
+ if (fs.existsSync(memtraceMd)) fs.unlinkSync(memtraceMd);
526
+
527
+ const claudeMd = path.join(claudeDir, "CLAUDE.md");
528
+ if (fs.existsSync(claudeMd)) {
529
+ const existing = fs.readFileSync(claudeMd, "utf8");
530
+ const updated = stripBreadcrumbText(existing);
531
+ if (updated !== existing) {
532
+ if (updated === "") fs.unlinkSync(claudeMd);
533
+ else fs.writeFileSync(claudeMd, updated);
534
+ }
279
535
  }
536
+
537
+ const settingsFile = settingsPath(home);
538
+ if (fs.existsSync(settingsFile)) {
539
+ try {
540
+ const settings = JSON.parse(fs.readFileSync(settingsFile, "utf8"));
541
+ const updated = stripManagedHooksFromSettings(settings);
542
+ if (JSON.stringify(updated) !== JSON.stringify(settings)) {
543
+ if (Object.keys(updated).length === 0) fs.unlinkSync(settingsFile);
544
+ else fs.writeFileSync(settingsFile, JSON.stringify(updated, null, 2) + "\n");
545
+ }
546
+ } catch { /* malformed settings — leave untouched */ }
547
+ }
548
+ console.log("memtrace: cleaned Claude Code integration (inline fallback)");
280
549
  } catch (e) {
281
- console.warn(`memtrace: Claude Code integration cleanup failed: ${e.message}`);
550
+ console.warn(`memtrace: inline Claude Code cleanup failed: ${e.message}`);
282
551
  }
283
552
  }
284
553
 
@@ -376,6 +645,21 @@ function run() {
376
645
  killRunningMemtraceProcesses();
377
646
  console.log("memtrace: cleaning up...");
378
647
 
648
+ // Manifest-first restore (memtrace-public #25): put every file the
649
+ // install mutated back to its pre-install bytes (or surgically strip
650
+ // memtrace blocks if the user edited it since). The legacy cleanup
651
+ // below still runs afterwards — it is idempotent and catches state
652
+ // from older installer versions that wrote no manifest.
653
+ try {
654
+ const summary = restoreFromManifest(process.env.MEMTRACE_INSTALL_HOME || os.homedir());
655
+ if (summary) {
656
+ const n = summary.restored.length + summary.surgical.length;
657
+ if (n > 0) console.log(`memtrace: restored ${n} file(s) from the install manifest`);
658
+ }
659
+ } catch (e) {
660
+ console.warn(`memtrace: manifest restore failed: ${e.message}`);
661
+ }
662
+
379
663
  // Try delegating to the compiled installer (handles ALL agents via registry)
380
664
  const bundled = path.join(__dirname, "installer", "dist", "index.js");
381
665
  if (fs.existsSync(bundled)) {
@@ -428,6 +712,13 @@ module.exports = {
428
712
  killRunningMemtraceProcesses,
429
713
  removeClaudeIntegration,
430
714
  legacyCleanup,
715
+ // Manifest restore (memtrace-public #25)
716
+ installManifestPath,
717
+ readInstallManifest,
718
+ restoreFromManifest,
719
+ stripBreadcrumbText,
720
+ stripManagedHooksFromSettings,
721
+ surgicalCleanFile,
431
722
  };
432
723
 
433
724
  // Run when called directly (npm preuninstall hook). When require()'d