@jhizzard/termdeck-stack 0.3.3 → 0.4.0

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.
@@ -0,0 +1,73 @@
1
+ # TermDeck session-end memory hook
2
+
3
+ The `@jhizzard/termdeck-stack` installer can drop `memory-session-end.js`
4
+ into `~/.claude/hooks/` and wire it into `~/.claude/settings.json` under
5
+ `hooks.Stop`. The installer prompts you before doing this; default is yes.
6
+
7
+ ## What the hook does
8
+
9
+ On every Claude Code session close, Claude Code fires its `Stop` hook with
10
+ a JSON payload on stdin:
11
+
12
+ ```json
13
+ { "transcript_path": "/path/to/session-transcript.jsonl", "cwd": "/path/where/you/were/working" }
14
+ ```
15
+
16
+ The hook:
17
+
18
+ 1. Skips transcripts smaller than 5 KB (no signal in tiny sessions).
19
+ 2. Detects the project from `cwd` against a built-in regex table; falls
20
+ back to `"global"` when nothing matches.
21
+ 3. Spawns a detached ingester (`process-session.ts` from `rag-system`),
22
+ which reads the transcript and writes a session summary into Mnestra.
23
+ 4. Logs every step to `~/.claude/hooks/memory-hook.log`.
24
+
25
+ The spawn is detached + unref'd, so Claude Code's session close is not
26
+ blocked waiting for ingestion — the 30-second `timeout` in
27
+ `settings.json` is a backstop, not a target.
28
+
29
+ ## Dependency on `rag-system`
30
+
31
+ The hook delegates ingestion to a script inside the `rag-system` repo:
32
+
33
+ ```
34
+ ${RAG_DIR}/src/scripts/process-session.ts
35
+ ```
36
+
37
+ `RAG_DIR` resolves in this order:
38
+
39
+ 1. `process.env.TERMDECK_RAG_DIR` (if set)
40
+ 2. `~/Documents/Graciella/rag-system` (default — Joshua's layout)
41
+
42
+ **If the resolved `RAG_DIR` does not exist on disk, the hook logs that
43
+ fact and exits cleanly.** It does not error, does not block session
44
+ close, and does not leak a spawn. Fresh users who installed the stack
45
+ but do not have `rag-system` checked out will see this skip-message in
46
+ the log and nothing else — as if no hook were installed.
47
+
48
+ A future TermDeck sprint will rewrite the hook to call Mnestra's MCP
49
+ tools directly so the `rag-system` dependency drops away. Until then,
50
+ this hook is most useful for users who already have `rag-system`
51
+ available.
52
+
53
+ ## How to disable
54
+
55
+ Two options:
56
+
57
+ 1. Edit `~/.claude/settings.json` and remove the entry under `hooks.Stop`
58
+ that references `memory-session-end.js`. Leave the file in place; it
59
+ simply won't fire.
60
+ 2. Or delete `~/.claude/hooks/memory-session-end.js` and remove the
61
+ `settings.json` entry. (Removing only the file leaves a broken
62
+ `command` in settings — Claude Code will log a missing-file error on
63
+ every session close.)
64
+
65
+ Re-running `npx @jhizzard/termdeck-stack` after disabling will re-prompt
66
+ to install. Decline at the prompt to stay opted out.
67
+
68
+ ## Log file
69
+
70
+ `~/.claude/hooks/memory-hook.log` accumulates one line per session-close
71
+ event. The hook never rotates it. If it grows unwieldy you can truncate
72
+ it (`: > ~/.claude/hooks/memory-hook.log`) without affecting hook
73
+ behavior.
@@ -0,0 +1,113 @@
1
+ /**
2
+ * TermDeck session-end memory hook.
3
+ *
4
+ * Vendored from Joshua's ~/.claude/hooks/memory-session-end.js (2026-03-11).
5
+ * Installed by `@jhizzard/termdeck-stack` into ~/.claude/hooks/ and wired
6
+ * into ~/.claude/settings.json under hooks.Stop. Fires on every Claude Code
7
+ * session close.
8
+ *
9
+ * Behavior:
10
+ * 1. Reads {transcript_path, cwd} from stdin (Claude Code's Stop payload).
11
+ * 2. Skips small transcripts (<5KB).
12
+ * 3. Detects project from cwd against PROJECT_MAP (else "global").
13
+ * 4. Spawns the rag-system ingester detached, returns immediately.
14
+ * 5. Logs to ~/.claude/hooks/memory-hook.log.
15
+ *
16
+ * Path resolution (parameterized for portability — was hardcoded in source):
17
+ * RAG_DIR := process.env.TERMDECK_RAG_DIR
18
+ * || path.join(os.homedir(), 'Documents/Graciella/rag-system')
19
+ *
20
+ * If the resolved RAG_DIR doesn't exist on disk, the hook logs and exits
21
+ * cleanly. Fresh users who haven't installed rag-system get a no-op hook
22
+ * rather than a spawn error. See assets/hooks/README.md for the full story.
23
+ */
24
+
25
+ const { spawn } = require('child_process');
26
+ const { existsSync, statSync, appendFileSync } = require('fs');
27
+ const { join } = require('path');
28
+ const os = require('os');
29
+
30
+ const RAG_DIR = process.env.TERMDECK_RAG_DIR
31
+ || join(os.homedir(), 'Documents', 'Graciella', 'rag-system');
32
+ const PROCESS_SCRIPT = join(RAG_DIR, 'src', 'scripts', 'process-session.ts');
33
+ const LOG_FILE = join(os.homedir(), '.claude', 'hooks', 'memory-hook.log');
34
+
35
+ const PROJECT_MAP = [
36
+ { pattern: /\/PVB\//i, project: 'pvb' },
37
+ { pattern: /chopin-scheduler|chopin_scheduler/i, project: 'chopin-scheduler' },
38
+ { pattern: /ChopinNashville|ChopinInBohemia/i, project: 'chopin-nashville' },
39
+ { pattern: /rag-system/i, project: 'rag-system' },
40
+ { pattern: /PianoCameraAI/i, project: 'piano-camera' },
41
+ { pattern: /Practice Piano Network/i, project: 'ppn' },
42
+ { pattern: /StanczakJosh/i, project: 'stanczak' },
43
+ { pattern: /JoshIzPiano/i, project: 'joshizpiano' },
44
+ { pattern: /AutumnArtist/i, project: 'autumn-artist' },
45
+ { pattern: /Crosswords/i, project: 'crosswords' },
46
+ { pattern: /gorgias/i, project: 'gorgias' },
47
+ { pattern: /imessage-reader/i, project: 'imessage-reader' },
48
+ { pattern: /antigravity/i, project: 'antigravity' },
49
+ ];
50
+
51
+ function detectProject(cwd) {
52
+ for (const { pattern, project } of PROJECT_MAP) {
53
+ if (pattern.test(cwd)) return project;
54
+ }
55
+ return 'global';
56
+ }
57
+
58
+ function log(msg) {
59
+ try {
60
+ appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
61
+ } catch (_) {}
62
+ }
63
+
64
+ let input = '';
65
+ process.stdin.setEncoding('utf8');
66
+ process.stdin.on('data', (chunk) => { input += chunk; });
67
+ process.stdin.on('end', () => {
68
+ try {
69
+ const data = JSON.parse(input);
70
+ const transcriptPath = data.transcript_path;
71
+ const cwd = data.cwd || '';
72
+
73
+ if (!transcriptPath) {
74
+ log('No transcript_path in input, skipping');
75
+ return;
76
+ }
77
+
78
+ try {
79
+ const stat = statSync(transcriptPath);
80
+ if (stat.size < 5000) {
81
+ log(`Skipping small transcript (${stat.size} bytes): ${transcriptPath}`);
82
+ return;
83
+ }
84
+ } catch (e) {
85
+ log(`Cannot stat transcript: ${transcriptPath} — ${e.message}`);
86
+ return;
87
+ }
88
+
89
+ if (!existsSync(PROCESS_SCRIPT)) {
90
+ log(`RAG_DIR not present (${RAG_DIR}); skipping ingestion. Set TERMDECK_RAG_DIR or install rag-system to enable.`);
91
+ return;
92
+ }
93
+
94
+ const project = detectProject(cwd);
95
+ log(`Processing session for project "${project}" from ${transcriptPath}`);
96
+
97
+ const child = spawn(
98
+ 'npx',
99
+ ['tsx', PROCESS_SCRIPT, transcriptPath, '--project', project],
100
+ {
101
+ cwd: RAG_DIR,
102
+ detached: true,
103
+ stdio: 'ignore',
104
+ env: { ...process.env, DOTENV_CONFIG_PATH: join(RAG_DIR, '.env') },
105
+ }
106
+ );
107
+ child.unref();
108
+
109
+ log(`Spawned process-session (pid ${child.pid}) for project "${project}"`);
110
+ } catch (e) {
111
+ log(`Error: ${e.message}`);
112
+ }
113
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck-stack",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "One-command installer for the TermDeck developer memory stack: TermDeck + Mnestra + Rumen + Supabase MCP",
5
5
  "bin": {
6
6
  "termdeck-stack": "./src/index.js"
@@ -8,6 +8,7 @@
8
8
  "main": "./src/index.js",
9
9
  "files": [
10
10
  "src/**",
11
+ "assets/**",
11
12
  "README.md",
12
13
  "CHANGELOG.md",
13
14
  "LICENSE"
package/src/index.js CHANGED
@@ -29,6 +29,16 @@ const path = require('node:path');
29
29
  const readline = require('node:readline/promises');
30
30
  const { spawn, spawnSync } = require('node:child_process');
31
31
 
32
+ const mcpConfigLib = require('./mcp-config');
33
+ const {
34
+ CLAUDE_MCP_PATH_CANONICAL,
35
+ CLAUDE_MCP_PATH_LEGACY,
36
+ readMcpServers,
37
+ mergeMcpServers,
38
+ writeMcpServers,
39
+ migrateLegacyIfPresent,
40
+ } = mcpConfigLib;
41
+
32
42
  const ANSI = {
33
43
  green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', blue: '\x1b[34m',
34
44
  cyan: '\x1b[36m', magenta: '\x1b[35m', dim: '\x1b[2m', bold: '\x1b[1m',
@@ -36,7 +46,13 @@ const ANSI = {
36
46
  };
37
47
 
38
48
  const HOME = os.homedir();
39
- const MCP_CONFIG = path.join(HOME, '.claude', 'mcp.json');
49
+ const MCP_CONFIG = CLAUDE_MCP_PATH_CANONICAL;
50
+ const SETTINGS_JSON = path.join(HOME, '.claude', 'settings.json');
51
+ const HOOK_DEST_DIR = path.join(HOME, '.claude', 'hooks');
52
+ const HOOK_DEST = path.join(HOOK_DEST_DIR, 'memory-session-end.js');
53
+ const HOOK_SOURCE = path.join(__dirname, '..', 'assets', 'hooks', 'memory-session-end.js');
54
+ const HOOK_COMMAND = 'node ~/.claude/hooks/memory-session-end.js';
55
+ const HOOK_TIMEOUT_SECONDS = 30;
40
56
 
41
57
  const LAYERS = [
42
58
  {
@@ -249,36 +265,40 @@ async function installLayers(plan, opts) {
249
265
  return failures;
250
266
  }
251
267
 
252
- // ── ~/.claude/mcp.json wiring ───────────────────────────────────────
253
-
254
- function readMcpConfig() {
255
- if (!fs.existsSync(MCP_CONFIG)) return { mcpServers: {} };
256
- try {
257
- const parsed = JSON.parse(fs.readFileSync(MCP_CONFIG, 'utf8'));
258
- if (!parsed.mcpServers) parsed.mcpServers = {};
259
- return parsed;
260
- } catch (_e) {
261
- return { mcpServers: {} };
262
- }
263
- }
264
-
265
- function writeMcpConfig(cfg) {
266
- fs.mkdirSync(path.dirname(MCP_CONFIG), { recursive: true });
267
- fs.writeFileSync(MCP_CONFIG, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
268
- }
268
+ // ── ~/.claude.json wiring ───────────────────────────────────────────
269
+ //
270
+ // Sprint 36 T2: writes go to ~/.claude.json (the path Claude Code v2.1.119+
271
+ // actually reads). On install, any entries living in the legacy
272
+ // ~/.claude/mcp.json are merged forward — the legacy file is left in place
273
+ // so users who pin other tooling to it keep working.
269
274
 
270
275
  function wireMcpEntries(plan, opts) {
271
276
  if (opts.dryRun) {
272
- process.stdout.write(`${ANSI.bold}Would wire ~/.claude/mcp.json (dry-run skipped)${ANSI.reset}\n\n`);
277
+ process.stdout.write(`${ANSI.bold}Would wire ${MCP_CONFIG} (dry-run skipped)${ANSI.reset}\n\n`);
273
278
  return;
274
279
  }
275
- const cfg = readMcpConfig();
280
+
281
+ // Step 1: forward-migrate any legacy entries, current always wins.
282
+ const migration = migrateLegacyIfPresent({ canonicalPath: MCP_CONFIG, legacyPath: CLAUDE_MCP_PATH_LEGACY });
283
+
284
+ // Step 2: re-read the canonical file (may have just been written by the
285
+ // migration) and apply our additions.
286
+ const current = readMcpServers(MCP_CONFIG);
287
+ if (current.malformed) {
288
+ process.stdout.write(
289
+ `${ANSI.red}✗${ANSI.reset} ${MCP_CONFIG} is malformed (${current.error || 'parse error'}); ` +
290
+ `not modified — fix the JSON and re-run.\n\n`
291
+ );
292
+ return;
293
+ }
294
+ const servers = { ...current.servers };
276
295
  const installedTiers = new Set(plan.map((l) => l.tier));
277
296
  const additions = [];
278
297
  const keptExisting = [];
279
298
 
280
- if (installedTiers.has(2) && !cfg.mcpServers.mnestra) {
281
- cfg.mcpServers.mnestra = {
299
+ if (installedTiers.has(2) && !servers.mnestra) {
300
+ servers.mnestra = {
301
+ type: 'stdio',
282
302
  command: 'mnestra',
283
303
  env: {
284
304
  SUPABASE_URL: '${SUPABASE_URL}',
@@ -287,12 +307,13 @@ function wireMcpEntries(plan, opts) {
287
307
  },
288
308
  };
289
309
  additions.push('mnestra');
290
- } else if (cfg.mcpServers.mnestra) {
310
+ } else if (servers.mnestra) {
291
311
  keptExisting.push('mnestra');
292
312
  }
293
313
 
294
- if (installedTiers.has(4) && !cfg.mcpServers.supabase) {
295
- cfg.mcpServers.supabase = {
314
+ if (installedTiers.has(4) && !servers.supabase) {
315
+ servers.supabase = {
316
+ type: 'stdio',
296
317
  command: 'npx',
297
318
  args: ['-y', '@supabase/mcp-server-supabase@latest'],
298
319
  env: {
@@ -300,19 +321,215 @@ function wireMcpEntries(plan, opts) {
300
321
  },
301
322
  };
302
323
  additions.push('supabase');
303
- } else if (cfg.mcpServers.supabase) {
324
+ } else if (servers.supabase) {
304
325
  keptExisting.push('supabase');
305
326
  }
306
327
 
307
- if (additions.length === 0 && keptExisting.length === 0) return;
328
+ const migrated = (migration && migration.migrated) || [];
329
+ if (additions.length === 0 && keptExisting.length === 0 && migrated.length === 0) return;
308
330
 
309
- process.stdout.write(`${ANSI.bold}Wiring ~/.claude/mcp.json...${ANSI.reset}\n`);
331
+ process.stdout.write(`${ANSI.bold}Wiring ${MCP_CONFIG}...${ANSI.reset}\n`);
332
+ if (migrated.length > 0) {
333
+ statusLine(
334
+ `${ANSI.cyan}↑${ANSI.reset}`,
335
+ `migrated ${migrated.length} entr${migrated.length === 1 ? 'y' : 'ies'} from legacy`,
336
+ `${migrated.join(', ')} (legacy ${CLAUDE_MCP_PATH_LEGACY} left in place)`,
337
+ );
338
+ }
310
339
  for (const name of additions) statusLine(`${ANSI.green}+${ANSI.reset}`, `${name} entry`, 'added');
311
340
  for (const name of keptExisting) statusLine(`${ANSI.dim}=${ANSI.reset}`, `${name} entry`, 'already present, kept as-is');
312
- if (additions.length > 0) writeMcpConfig(cfg);
341
+ if (additions.length > 0) writeMcpServers(MCP_CONFIG, servers);
313
342
  process.stdout.write('\n');
314
343
  }
315
344
 
345
+ // Test hook — exposed so unit tests can drive the merge primitives without
346
+ // spawning a full installer. Not part of the public CLI surface.
347
+ const _mcpInternals = {
348
+ readMcpServers,
349
+ mergeMcpServers,
350
+ writeMcpServers,
351
+ migrateLegacyIfPresent,
352
+ };
353
+
354
+ // ── Session-end hook bundling ───────────────────────────────────────
355
+
356
+ // Returns true if the given hook-entry's `command` string references our
357
+ // session-end hook file. Substring match is robust to `~` vs `$HOME` vs
358
+ // absolute paths.
359
+ function _isSessionEndHookEntry(entry) {
360
+ return entry && typeof entry.command === 'string'
361
+ && entry.command.includes('memory-session-end.js');
362
+ }
363
+
364
+ // Pure: merges our Stop entry into the given settings object. Idempotent.
365
+ // Returns { settings, status } where status is 'already-installed' or
366
+ // 'installed'. Mutates the input.
367
+ function _mergeSessionEndHookEntry(settings, opts = {}) {
368
+ const command = opts.command || HOOK_COMMAND;
369
+ const timeout = opts.timeout != null ? opts.timeout : HOOK_TIMEOUT_SECONDS;
370
+
371
+ if (!settings.hooks || typeof settings.hooks !== 'object') settings.hooks = {};
372
+ if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
373
+
374
+ for (const group of settings.hooks.Stop) {
375
+ if (!group || !Array.isArray(group.hooks)) continue;
376
+ if (group.hooks.some(_isSessionEndHookEntry)) {
377
+ return { settings, status: 'already-installed' };
378
+ }
379
+ }
380
+
381
+ const entry = { type: 'command', command, timeout };
382
+ const emptyMatcher = settings.hooks.Stop.find(
383
+ (g) => g && g.matcher === '' && Array.isArray(g.hooks)
384
+ );
385
+ if (emptyMatcher) {
386
+ emptyMatcher.hooks.push(entry);
387
+ } else {
388
+ settings.hooks.Stop.push({ matcher: '', hooks: [entry] });
389
+ }
390
+ return { settings, status: 'installed' };
391
+ }
392
+
393
+ function _readSettingsJson(filePath) {
394
+ if (!fs.existsSync(filePath)) {
395
+ return { settings: {}, status: 'no-file' };
396
+ }
397
+ try {
398
+ const raw = fs.readFileSync(filePath, 'utf8');
399
+ if (raw.trim() === '') return { settings: {}, status: 'empty' };
400
+ const parsed = JSON.parse(raw);
401
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
402
+ return { settings: {}, status: 'malformed', error: 'top-level must be an object' };
403
+ }
404
+ return { settings: parsed, status: 'ok' };
405
+ } catch (e) {
406
+ return { settings: {}, status: 'malformed', error: e.message };
407
+ }
408
+ }
409
+
410
+ function _writeSettingsJson(filePath, settings) {
411
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
412
+ const tmp = filePath + '.tmp';
413
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
414
+ fs.renameSync(tmp, filePath);
415
+ }
416
+
417
+ // Compares two file contents byte-for-byte. Returns 'identical', 'different',
418
+ // or 'missing-dest'.
419
+ function _compareHookFiles(srcPath, destPath) {
420
+ if (!fs.existsSync(destPath)) return 'missing-dest';
421
+ const a = fs.readFileSync(srcPath);
422
+ const b = fs.readFileSync(destPath);
423
+ return a.equals(b) ? 'identical' : 'different';
424
+ }
425
+
426
+ async function promptYesNo({ question, defaultYes = true }) {
427
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
428
+ const suffix = defaultYes ? '(Y/n)' : '(y/N)';
429
+ const ans = (await rl.question(` ${question} ${suffix} `)).trim().toLowerCase();
430
+ rl.close();
431
+ if (ans === '') return defaultYes;
432
+ return ans === 'y' || ans === 'yes';
433
+ }
434
+
435
+ // Orchestrator: prompt → file copy → settings.json merge.
436
+ // Exposed so tests can drive it with explicit paths and a stub prompt.
437
+ async function installSessionEndHook(opts = {}) {
438
+ const dryRun = !!opts.dryRun;
439
+ const sourcePath = opts.sourcePath || HOOK_SOURCE;
440
+ const destPath = opts.destPath || HOOK_DEST;
441
+ const settingsPath = opts.settingsPath || SETTINGS_JSON;
442
+ // promptInstall: () => Promise<boolean>; defaults to Y.
443
+ // promptOverwrite: () => Promise<boolean>; defaults to N.
444
+ const promptInstall = opts.promptInstall
445
+ || (() => promptYesNo({ question: "Install TermDeck's session-end memory hook?", defaultYes: true }));
446
+ const promptOverwrite = opts.promptOverwrite
447
+ || (() => promptYesNo({
448
+ question: `Existing hook found at ${destPath}. Overwrite?`,
449
+ defaultYes: false,
450
+ }));
451
+
452
+ rule();
453
+ process.stdout.write(`${ANSI.bold}Session-end memory hook${ANSI.reset}\n`);
454
+ process.stdout.write(`${ANSI.dim} Fires on every Claude Code session close to summarize the session into Mnestra.${ANSI.reset}\n\n`);
455
+
456
+ const userWantsInstall = opts.assumeYes ? true
457
+ : opts.assumeNo ? false
458
+ : await promptInstall();
459
+
460
+ if (!userWantsInstall) {
461
+ statusLine(`${ANSI.dim}─${ANSI.reset}`, 'session-end hook', 'skipped (user declined)');
462
+ process.stdout.write('\n');
463
+ return { fileStatus: 'declined', settingsStatus: 'declined' };
464
+ }
465
+
466
+ // 1. File copy.
467
+ let fileStatus;
468
+ const cmp = _compareHookFiles(sourcePath, destPath);
469
+ if (cmp === 'missing-dest') {
470
+ if (dryRun) {
471
+ statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would copy hook to ${destPath}`);
472
+ fileStatus = 'would-copy';
473
+ } else {
474
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
475
+ fs.copyFileSync(sourcePath, destPath);
476
+ fs.chmodSync(destPath, 0o644);
477
+ statusLine(`${ANSI.green}+${ANSI.reset}`, 'hook file', `copied to ${destPath}`);
478
+ fileStatus = 'copied';
479
+ }
480
+ } else if (cmp === 'identical') {
481
+ statusLine(`${ANSI.dim}=${ANSI.reset}`, 'hook file', 'already present, identical contents');
482
+ fileStatus = 'already-current';
483
+ } else {
484
+ // different
485
+ const overwrite = opts.assumeYes ? false // --yes preserves existing on overwrite
486
+ : opts.forceOverwrite ? true
487
+ : await promptOverwrite();
488
+ if (!overwrite) {
489
+ statusLine(`${ANSI.dim}=${ANSI.reset}`, 'hook file', `existing kept (differs from vendored copy)`);
490
+ fileStatus = 'kept-existing';
491
+ } else if (dryRun) {
492
+ statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would overwrite ${destPath}`);
493
+ fileStatus = 'would-overwrite';
494
+ } else {
495
+ fs.copyFileSync(sourcePath, destPath);
496
+ fs.chmodSync(destPath, 0o644);
497
+ statusLine(`${ANSI.green}↻${ANSI.reset}`, 'hook file', `overwrote ${destPath}`);
498
+ fileStatus = 'overwritten';
499
+ }
500
+ }
501
+
502
+ // 2. Settings.json merge.
503
+ const read = _readSettingsJson(settingsPath);
504
+ let settingsStatus;
505
+ if (read.status === 'malformed') {
506
+ statusLine(`${ANSI.red}✗${ANSI.reset}`, 'settings.json', `malformed (${read.error}); not modified`);
507
+ settingsStatus = 'malformed';
508
+ } else {
509
+ const merged = _mergeSessionEndHookEntry(read.settings);
510
+ if (merged.status === 'already-installed') {
511
+ statusLine(`${ANSI.dim}=${ANSI.reset}`, 'settings.json Stop hook', 'already installed');
512
+ settingsStatus = 'already-installed';
513
+ } else if (dryRun) {
514
+ statusLine(`${ANSI.yellow}↩${ANSI.reset}`, '(dry-run)', `would merge Stop hook into ${settingsPath}`);
515
+ settingsStatus = 'would-install';
516
+ } else {
517
+ _writeSettingsJson(settingsPath, merged.settings);
518
+ statusLine(`${ANSI.green}+${ANSI.reset}`, 'settings.json Stop hook', 'merged');
519
+ settingsStatus = 'installed';
520
+ }
521
+ }
522
+
523
+ process.stdout.write('\n');
524
+ if (!dryRun && (fileStatus === 'copied' || settingsStatus === 'installed')) {
525
+ process.stdout.write(` ${ANSI.dim}Hook installed at ${destPath}.${ANSI.reset}\n`);
526
+ process.stdout.write(` ${ANSI.dim}It runs on every Claude Code session close to summarize the session into Mnestra.${ANSI.reset}\n`);
527
+ process.stdout.write(` ${ANSI.dim}See assets/hooks/README.md in @jhizzard/termdeck-stack for details.${ANSI.reset}\n\n`);
528
+ }
529
+
530
+ return { fileStatus, settingsStatus };
531
+ }
532
+
316
533
  // ── Next steps ──────────────────────────────────────────────────────
317
534
 
318
535
  function printNextSteps(plan, opts) {
@@ -405,6 +622,13 @@ async function main(argv) {
405
622
  // "already had everything but never set up Claude Code MCP" case.
406
623
  wireMcpEntries(wantedLayers, { dryRun: args.dryRun });
407
624
 
625
+ // Bundle the session-end memory hook (default-on, opt-in via prompt).
626
+ // --yes accepts the install but preserves any existing differing hook.
627
+ await installSessionEndHook({
628
+ dryRun: args.dryRun,
629
+ assumeYes: args.yes,
630
+ });
631
+
408
632
  printNextSteps(wantedLayers, { dryRun: args.dryRun });
409
633
 
410
634
  if (failures > 0) {
@@ -422,3 +646,16 @@ if (require.main === module) {
422
646
  }
423
647
 
424
648
  module.exports = main;
649
+ module.exports._mergeSessionEndHookEntry = _mergeSessionEndHookEntry;
650
+ module.exports._readSettingsJson = _readSettingsJson;
651
+ module.exports._writeSettingsJson = _writeSettingsJson;
652
+ module.exports._isSessionEndHookEntry = _isSessionEndHookEntry;
653
+ module.exports._compareHookFiles = _compareHookFiles;
654
+ module.exports.installSessionEndHook = installSessionEndHook;
655
+ module.exports.HOOK_COMMAND = HOOK_COMMAND;
656
+ module.exports.HOOK_TIMEOUT_SECONDS = HOOK_TIMEOUT_SECONDS;
657
+ module.exports.HOOK_SOURCE = HOOK_SOURCE;
658
+ module.exports._mcpInternals = _mcpInternals;
659
+ module.exports.MCP_CONFIG_PATH = MCP_CONFIG;
660
+ module.exports.CLAUDE_MCP_PATH_CANONICAL = CLAUDE_MCP_PATH_CANONICAL;
661
+ module.exports.CLAUDE_MCP_PATH_LEGACY = CLAUDE_MCP_PATH_LEGACY;
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+
3
+ // Canonical schema/CRUD for the Claude Code MCP server config.
4
+ //
5
+ // SIBLING COPY of packages/cli/src/mcp-config.js. Two physical copies
6
+ // exist so each published npm package (@jhizzard/termdeck and
7
+ // @jhizzard/termdeck-stack) stays self-contained — the stack-installer's
8
+ // `files` field publishes only `src/**` and cannot require() into the
9
+ // CLI package. Same exports, same semantics. Keep in sync.
10
+
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+
15
+ const CLAUDE_MCP_PATH_CANONICAL = path.join(os.homedir(), '.claude.json');
16
+ const CLAUDE_MCP_PATH_LEGACY = path.join(os.homedir(), '.claude', 'mcp.json');
17
+
18
+ function readMcpServers(filePath) {
19
+ if (!fs.existsSync(filePath)) {
20
+ return { servers: {}, raw: {}, missing: true, malformed: false };
21
+ }
22
+ let text;
23
+ try {
24
+ text = fs.readFileSync(filePath, 'utf8');
25
+ } catch (err) {
26
+ return { servers: {}, raw: {}, missing: false, malformed: true, error: err.message };
27
+ }
28
+ if (text.trim() === '') {
29
+ return { servers: {}, raw: {}, missing: false, malformed: false };
30
+ }
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(text);
34
+ } catch (err) {
35
+ return { servers: {}, raw: {}, missing: false, malformed: true, error: err.message };
36
+ }
37
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
38
+ return { servers: {}, raw: {}, missing: false, malformed: true, error: 'top-level must be an object' };
39
+ }
40
+ const servers = (parsed.mcpServers && typeof parsed.mcpServers === 'object' && !Array.isArray(parsed.mcpServers))
41
+ ? parsed.mcpServers
42
+ : {};
43
+ return { servers, raw: parsed, missing: false, malformed: false };
44
+ }
45
+
46
+ function mergeMcpServers(currentServers, legacyServers) {
47
+ const out = {};
48
+ const legacy = (legacyServers && typeof legacyServers === 'object') ? legacyServers : {};
49
+ const current = (currentServers && typeof currentServers === 'object') ? currentServers : {};
50
+ for (const [name, entry] of Object.entries(legacy)) {
51
+ out[name] = entry;
52
+ }
53
+ for (const [name, entry] of Object.entries(current)) {
54
+ out[name] = entry;
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function writeMcpServers(filePath, servers) {
60
+ const existing = readMcpServers(filePath);
61
+ const next = (existing.malformed || existing.missing)
62
+ ? {}
63
+ : { ...existing.raw };
64
+ next.mcpServers = servers || {};
65
+
66
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
67
+ const tmp = `${filePath}.tmp.${process.pid}`;
68
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
69
+ fs.renameSync(tmp, filePath);
70
+ try { fs.chmodSync(filePath, 0o600); } catch (_e) { /* best-effort */ }
71
+ }
72
+
73
+ function migrateLegacyIfPresent(opts = {}) {
74
+ const dryRun = !!opts.dryRun;
75
+ const canonicalPath = opts.canonicalPath || CLAUDE_MCP_PATH_CANONICAL;
76
+ const legacyPath = opts.legacyPath || CLAUDE_MCP_PATH_LEGACY;
77
+
78
+ const canonical = readMcpServers(canonicalPath);
79
+ const legacy = readMcpServers(legacyPath);
80
+
81
+ const malformed = {};
82
+ if (canonical.malformed) malformed.canonical = canonical.error || true;
83
+ if (legacy.malformed) malformed.legacy = legacy.error || true;
84
+
85
+ if (legacy.missing || legacy.malformed) {
86
+ return {
87
+ migrated: [],
88
+ kept: [],
89
+ wrote: false,
90
+ canonicalPath,
91
+ legacyPath,
92
+ malformed: Object.keys(malformed).length ? malformed : undefined,
93
+ };
94
+ }
95
+
96
+ const migrated = [];
97
+ const kept = [];
98
+ const merged = { ...canonical.servers };
99
+ for (const [name, entry] of Object.entries(legacy.servers)) {
100
+ if (Object.prototype.hasOwnProperty.call(canonical.servers, name)) {
101
+ kept.push(name);
102
+ } else {
103
+ merged[name] = entry;
104
+ migrated.push(name);
105
+ }
106
+ }
107
+
108
+ if (migrated.length === 0) {
109
+ return {
110
+ migrated: [],
111
+ kept,
112
+ wrote: false,
113
+ canonicalPath,
114
+ legacyPath,
115
+ malformed: Object.keys(malformed).length ? malformed : undefined,
116
+ };
117
+ }
118
+
119
+ if (!dryRun) writeMcpServers(canonicalPath, merged);
120
+
121
+ return {
122
+ migrated,
123
+ kept,
124
+ wrote: !dryRun,
125
+ canonicalPath,
126
+ legacyPath,
127
+ malformed: Object.keys(malformed).length ? malformed : undefined,
128
+ };
129
+ }
130
+
131
+ module.exports = {
132
+ CLAUDE_MCP_PATH_CANONICAL,
133
+ CLAUDE_MCP_PATH_LEGACY,
134
+ readMcpServers,
135
+ mergeMcpServers,
136
+ writeMcpServers,
137
+ migrateLegacyIfPresent,
138
+ };