@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.
- package/assets/hooks/README.md +132 -0
- package/assets/hooks/memory-session-end.js +238 -0
- package/package.json +2 -1
- package/src/index.js +266 -29
- package/src/mcp-config.js +138 -0
|
@@ -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
|
+
"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 =
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
277
|
+
process.stdout.write(`${ANSI.bold}Would wire ${MCP_CONFIG} (dry-run skipped)${ANSI.reset}\n\n`);
|
|
273
278
|
return;
|
|
274
279
|
}
|
|
275
|
-
|
|
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) && !
|
|
281
|
-
|
|
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 (
|
|
310
|
+
} else if (servers.mnestra) {
|
|
291
311
|
keptExisting.push('mnestra');
|
|
292
312
|
}
|
|
293
313
|
|
|
294
|
-
if (installedTiers.has(4) && !
|
|
295
|
-
|
|
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 (
|
|
324
|
+
} else if (servers.supabase) {
|
|
304
325
|
keptExisting.push('supabase');
|
|
305
326
|
}
|
|
306
327
|
|
|
307
|
-
|
|
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
|
|
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)
|
|
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
|
+
};
|