@jhizzard/termdeck-stack 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
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
6
+ yes.
7
+
8
+ ## What the hook does
9
+
10
+ On every Claude Code session close, Claude Code fires its `Stop` hook
11
+ with a JSON payload on stdin:
12
+
13
+ ```json
14
+ { "transcript_path": "/path/to/session.jsonl", "cwd": "/path/where/you/were/working", "session_id": "..." }
15
+ ```
16
+
17
+ The hook:
18
+
19
+ 1. Skips transcripts smaller than 5 KB (no signal in tiny sessions —
20
+ override via `TERMDECK_HOOK_MIN_BYTES`).
21
+ 2. Validates env vars (`SUPABASE_URL`, `SUPABASE_SERVICE_KEY`,
22
+ `OPENAI_API_KEY`); if any are missing, logs the missing list and
23
+ exits cleanly without blocking the session close.
24
+ 3. Detects the project from `cwd` against a built-in regex table; falls
25
+ back to `"global"` when nothing matches. **The default table is
26
+ intentionally empty** — see "Customizing the project map" below to
27
+ add your own entries.
28
+ 4. Builds a coarse session summary from the last ~30 messages of the
29
+ transcript (~7 KB cap to stay inside OpenAI's embedding-input
30
+ budget).
31
+ 5. Embeds the summary via OpenAI `text-embedding-3-small` (1,536-dim).
32
+ 6. POSTs **one row** to Supabase `/rest/v1/memory_items` with
33
+ `source_type='session_summary'`.
34
+ 7. Logs every step to `~/.claude/hooks/memory-hook.log`.
35
+
36
+ The hook is **fail-soft**: any error (network, parse, env-var-missing,
37
+ malformed transcript) is logged and the hook exits 0. Claude Code's
38
+ session close is never blocked.
39
+
40
+ ## Required environment
41
+
42
+ The hook needs three env vars at run time:
43
+
44
+ | Var | What | How to set |
45
+ |---|---|---|
46
+ | `SUPABASE_URL` | Your Supabase project URL (e.g. `https://abc.supabase.co`) | `~/.termdeck/secrets.env` (Tier 2) |
47
+ | `SUPABASE_SERVICE_KEY` | Service-role key with INSERT on `memory_items`. **Not the anon key.** | `~/.termdeck/secrets.env` |
48
+ | `OPENAI_API_KEY` | OpenAI key for embedding inference | `~/.termdeck/secrets.env` or your shell |
49
+
50
+ Claude Code propagates the parent shell's environment into hook
51
+ processes, so anything in your shell init or
52
+ `~/.termdeck/secrets.env` (sourced by `scripts/start.sh` /
53
+ `npx @jhizzard/termdeck`) is visible to the hook.
54
+
55
+ If any of the three is missing the log line will name them:
56
+
57
+ ```
58
+ [2026-04-27T21:30:00.000Z] env-var-missing: OPENAI_API_KEY — set these in ~/.termdeck/secrets.env or your shell to enable Mnestra ingestion. Skipping.
59
+ ```
60
+
61
+ ## Customizing the project map
62
+
63
+ The hook ships with an **empty `PROJECT_MAP`** by default — every
64
+ session lands under `project: 'global'` until you add entries. To add
65
+ your own:
66
+
67
+ 1. Open `~/.claude/hooks/memory-session-end.js` after the installer
68
+ has dropped it.
69
+ 2. Find the `PROJECT_MAP` array near the top of the file.
70
+ 3. Add one entry per project; each entry is `{ pattern, project }`
71
+ where `pattern` is a regex matched against `cwd`:
72
+
73
+ ```js
74
+ const PROJECT_MAP = [
75
+ { pattern: /\/PVB\//i, project: 'pvb' },
76
+ { pattern: /\/my-startup\//i, project: 'my-startup' },
77
+ { pattern: /chopin-nashville/i, project: 'chopin-nashville' },
78
+ ];
79
+ ```
80
+
81
+ First match wins. Iteration order is array order, so put more specific
82
+ patterns first. Anything that doesn't match falls through to
83
+ `'global'`.
84
+
85
+ The map is local-only — it's never sent to any service. Editing it
86
+ takes effect on the next Claude Code session close (no restart
87
+ needed).
88
+
89
+ ## Coexistence with Joshua's `rag-system` hook
90
+
91
+ If you have Joshua's private `rag-system` repo and his rag-system-based
92
+ session hook installed, this bundled hook and that one can coexist:
93
+
94
+ - The bundled hook writes `source_type='session_summary'` — one row
95
+ per session, summary-only.
96
+ - The `rag-system` hook writes `source_type='fact'` — multiple rows
97
+ per session via Claude Haiku fact extraction + dedup.
98
+
99
+ Different `source_type` values mean the two paths don't dedup against
100
+ each other. If both are installed at the same path
101
+ (`~/.claude/hooks/memory-session-end.js`) the installer will prompt
102
+ before overwriting; choose accordingly.
103
+
104
+ ## How to disable
105
+
106
+ Two options:
107
+
108
+ 1. Edit `~/.claude/settings.json` and remove the entry under
109
+ `hooks.Stop` that references `memory-session-end.js`. Leave the
110
+ file in place; it simply won't fire.
111
+ 2. Or delete `~/.claude/hooks/memory-session-end.js` AND remove the
112
+ `settings.json` entry. (Removing only the file leaves a broken
113
+ `command` in settings — Claude Code will log a missing-file error
114
+ on every session close.)
115
+
116
+ Re-running `npx @jhizzard/termdeck-stack` after disabling will
117
+ re-prompt to install. Decline at the prompt to stay opted out.
118
+
119
+ ## Optional flags
120
+
121
+ | Env var | Effect |
122
+ |---|---|
123
+ | `TERMDECK_HOOK_DEBUG=1` | Verbose `[debug]` lines in the log |
124
+ | `TERMDECK_HOOK_MIN_BYTES=10000` | Override the 5 KB skip threshold |
125
+
126
+ ## Log file
127
+
128
+ `~/.claude/hooks/memory-hook.log` accumulates one line per session
129
+ event (skips, errors, ingests). The hook never rotates it. If it
130
+ grows unwieldy you can truncate it
131
+ (`: > ~/.claude/hooks/memory-hook.log`) without affecting hook
132
+ behavior.
@@ -0,0 +1,238 @@
1
+ /**
2
+ * TermDeck session-end memory hook (Mnestra-direct, no rag-system dependency).
3
+ *
4
+ * Vendored into ~/.claude/hooks/memory-session-end.js by @jhizzard/termdeck-stack.
5
+ * Wired into ~/.claude/settings.json under hooks.Stop. Fires on Claude Code Stop event.
6
+ *
7
+ * Behavior:
8
+ * 1. Reads {transcript_path, cwd, session_id} from stdin (Claude Code Stop payload).
9
+ * 2. Skips small transcripts (< MIN_TRANSCRIPT_BYTES, default 5KB).
10
+ * 3. Validates env vars; logs and exits cleanly if any required key is missing.
11
+ * 4. Detects project from cwd against PROJECT_MAP (else "global"). Extend the
12
+ * map by editing the array below — see assets/hooks/README.md for guidance.
13
+ * 5. Builds a coarse session summary from the transcript (last ~30 message excerpts).
14
+ * 6. Embeds the summary via OpenAI text-embedding-3-small.
15
+ * 7. POSTs ONE row to Supabase /rest/v1/memory_items with source_type='session_summary'.
16
+ * 8. Logs every step to ~/.claude/hooks/memory-hook.log.
17
+ *
18
+ * Required env vars (validated at entry):
19
+ * - SUPABASE_URL e.g. https://luvvbrpaopnblvxdxwzb.supabase.co
20
+ * - SUPABASE_SERVICE_KEY service-role key (NOT the anon key — needs INSERT on memory_items)
21
+ * - OPENAI_API_KEY sk-... for text-embedding-3-small
22
+ *
23
+ * Optional:
24
+ * - TERMDECK_HOOK_DEBUG=1 verbose logging
25
+ * - TERMDECK_HOOK_MIN_BYTES=5000 transcript size threshold
26
+ *
27
+ * Fail-soft contract: any error (network, parse, env-var-missing, malformed transcript)
28
+ * logs and exits 0. Never blocks Claude Code session close.
29
+ *
30
+ * Co-existence with Joshua's personal rag-system hook: this bundled hook writes
31
+ * source_type='session_summary' (one row per session). Joshua's personal hook
32
+ * writes source_type='fact' (multiple rows from extractFacts pipeline). Different
33
+ * source_types coexist in memory_items without dedup collisions.
34
+ */
35
+
36
+ 'use strict';
37
+
38
+ const { existsSync, statSync, appendFileSync, readFileSync } = require('fs');
39
+ const { join } = require('path');
40
+ const os = require('os');
41
+
42
+ const LOG_FILE = join(os.homedir(), '.claude', 'hooks', 'memory-hook.log');
43
+
44
+ // PROJECT_MAP — minimal default. Users extend by adding entries to this array.
45
+ // Patterns match against the cwd reported by Claude Code at Stop time.
46
+ // First match wins; falls through to "global".
47
+ const PROJECT_MAP = [
48
+ // Example entries — uncomment + edit, or add your own:
49
+ // { pattern: /\/myproject\//i, project: 'my-project' },
50
+ // { pattern: /work-stuff/i, project: 'work' },
51
+ ];
52
+
53
+ const MIN_TRANSCRIPT_BYTES = parseInt(process.env.TERMDECK_HOOK_MIN_BYTES || '5000', 10);
54
+ const DEBUG = process.env.TERMDECK_HOOK_DEBUG === '1';
55
+
56
+ function log(msg) {
57
+ try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${msg}\n`); }
58
+ catch (_) { /* fail-soft */ }
59
+ }
60
+ function debug(msg) { if (DEBUG) log(`[debug] ${msg}`); }
61
+
62
+ function detectProject(cwd) {
63
+ for (const { pattern, project } of PROJECT_MAP) {
64
+ if (pattern.test(cwd)) return project;
65
+ }
66
+ return 'global';
67
+ }
68
+
69
+ function readEnv() {
70
+ const required = ['SUPABASE_URL', 'SUPABASE_SERVICE_KEY', 'OPENAI_API_KEY'];
71
+ const missing = required.filter((k) => !process.env[k]);
72
+ if (missing.length) {
73
+ log(`env-var-missing: ${missing.join(', ')} — set these in ~/.termdeck/secrets.env or your shell to enable Mnestra ingestion. Skipping.`);
74
+ return null;
75
+ }
76
+ return {
77
+ supabaseUrl: process.env.SUPABASE_URL.replace(/\/$/, ''),
78
+ supabaseKey: process.env.SUPABASE_SERVICE_KEY,
79
+ openaiKey: process.env.OPENAI_API_KEY,
80
+ };
81
+ }
82
+
83
+ function buildSummary(transcriptPath) {
84
+ let raw;
85
+ try { raw = readFileSync(transcriptPath, 'utf8'); }
86
+ catch (e) { log(`read-transcript-failed: ${e.message}`); return null; }
87
+
88
+ const lines = raw.split('\n').filter(Boolean);
89
+ const messages = [];
90
+ for (const line of lines) {
91
+ let msg;
92
+ try { msg = JSON.parse(line); } catch (_) { continue; }
93
+ const role = msg?.message?.role;
94
+ if (role !== 'user' && role !== 'assistant') continue;
95
+ const content = msg.message.content;
96
+ let text = '';
97
+ if (typeof content === 'string') text = content;
98
+ else if (Array.isArray(content)) {
99
+ text = content.filter((c) => c && c.type === 'text').map((c) => c.text).join(' ');
100
+ }
101
+ if (text) messages.push({ role, content: text.slice(0, 400) });
102
+ }
103
+
104
+ if (messages.length < 5) {
105
+ debug(`session-too-short: ${messages.length} messages, skipping`);
106
+ return null;
107
+ }
108
+
109
+ const tail = messages.slice(-30);
110
+ const summary =
111
+ `Session with ${messages.length} messages.\n\n` +
112
+ tail.map((m) => `[${m.role}] ${m.content}`).join('\n');
113
+ // OpenAI text-embedding-3-small accepts up to 8192 tokens (~32K chars).
114
+ // 7000 chars is a safe headroom that survives multibyte expansion.
115
+ return summary.slice(0, 7000);
116
+ }
117
+
118
+ async function embedText(text, openaiKey) {
119
+ try {
120
+ const res = await fetch('https://api.openai.com/v1/embeddings', {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/json',
124
+ 'Authorization': `Bearer ${openaiKey}`,
125
+ },
126
+ body: JSON.stringify({ model: 'text-embedding-3-small', input: text }),
127
+ });
128
+ if (!res.ok) {
129
+ const body = await res.text().catch(() => '');
130
+ log(`openai-embed-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
131
+ return null;
132
+ }
133
+ const data = await res.json();
134
+ return data?.data?.[0]?.embedding || null;
135
+ } catch (e) {
136
+ log(`openai-embed-exception: ${e.message}`);
137
+ return null;
138
+ }
139
+ }
140
+
141
+ async function postMemoryItem({ supabaseUrl, supabaseKey, content, embedding, project, sessionId }) {
142
+ try {
143
+ const res = await fetch(`${supabaseUrl}/rest/v1/memory_items`, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'Content-Type': 'application/json',
147
+ 'apikey': supabaseKey,
148
+ 'Authorization': `Bearer ${supabaseKey}`,
149
+ 'Prefer': 'return=minimal',
150
+ },
151
+ body: JSON.stringify({
152
+ content,
153
+ embedding: `[${embedding.join(',')}]`,
154
+ source_type: 'session_summary',
155
+ category: 'workflow',
156
+ project,
157
+ source_session_id: sessionId || null,
158
+ }),
159
+ });
160
+ if (!res.ok) {
161
+ const body = await res.text().catch(() => '');
162
+ log(`supabase-insert-failed: HTTP ${res.status} ${body.slice(0, 200)}`);
163
+ return false;
164
+ }
165
+ return true;
166
+ } catch (e) {
167
+ log(`supabase-insert-exception: ${e.message}`);
168
+ return false;
169
+ }
170
+ }
171
+
172
+ async function processStdinPayload(input) {
173
+ let data;
174
+ try { data = JSON.parse(input); }
175
+ catch (e) { log(`parse-stdin-failed: ${e.message}`); return; }
176
+
177
+ const transcriptPath = data.transcript_path;
178
+ const cwd = data.cwd || '';
179
+ const sessionId =
180
+ data.session_id ||
181
+ (transcriptPath ? transcriptPath.split('/').pop().replace('.jsonl', '') : null);
182
+
183
+ if (!transcriptPath) { log('no-transcript-path: skipping'); return; }
184
+
185
+ let stat;
186
+ try { stat = statSync(transcriptPath); }
187
+ catch (e) { log(`cannot-stat-transcript: ${transcriptPath} — ${e.message}`); return; }
188
+
189
+ if (stat.size < MIN_TRANSCRIPT_BYTES) {
190
+ debug(`small-transcript: ${stat.size} bytes < ${MIN_TRANSCRIPT_BYTES}, skipping`);
191
+ return;
192
+ }
193
+
194
+ const env = readEnv();
195
+ if (!env) return;
196
+
197
+ const project = detectProject(cwd);
198
+ debug(`project="${project}", session=${sessionId}`);
199
+
200
+ const summary = buildSummary(transcriptPath);
201
+ if (!summary) return;
202
+
203
+ const embedding = await embedText(summary, env.openaiKey);
204
+ if (!embedding) return;
205
+
206
+ const ok = await postMemoryItem({
207
+ supabaseUrl: env.supabaseUrl,
208
+ supabaseKey: env.supabaseKey,
209
+ content: summary,
210
+ embedding,
211
+ project,
212
+ sessionId,
213
+ });
214
+
215
+ if (ok) log(`ingested: project="${project}" session=${sessionId} bytes=${summary.length}`);
216
+ }
217
+
218
+ // Module-export contract for testability. When run as a script (require.main === module),
219
+ // read stdin and process. When require()d (tests), expose helpers.
220
+ if (require.main === module) {
221
+ let input = '';
222
+ process.stdin.setEncoding('utf8');
223
+ process.stdin.on('data', (chunk) => { input += chunk; });
224
+ process.stdin.on('end', () => {
225
+ processStdinPayload(input).catch((e) => log(`hook-error: ${e.message}`));
226
+ });
227
+ } else {
228
+ module.exports = {
229
+ PROJECT_MAP,
230
+ detectProject,
231
+ readEnv,
232
+ buildSummary,
233
+ embedText,
234
+ postMemoryItem,
235
+ processStdinPayload,
236
+ LOG_FILE,
237
+ };
238
+ }
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.1",
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
+ };