@jhizzard/termdeck 0.16.1 → 0.18.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.
- package/package.json +1 -1
- package/packages/client/public/app.js +7 -12
- package/packages/client/public/flashback-history.html +35 -0
- package/packages/client/public/flashback-history.js +60 -13
- package/packages/client/public/graph.html +5 -0
- package/packages/client/public/graph.js +37 -0
- package/packages/client/public/index.html +2 -3
- package/packages/client/public/launcher-resolver.js +5 -1
- package/packages/client/public/style.css +31 -0
- package/packages/server/src/agent-adapters/claude.js +11 -1
- package/packages/server/src/agent-adapters/codex.js +30 -0
- package/packages/server/src/agent-adapters/gemini.js +55 -0
- package/packages/server/src/agent-adapters/grok.js +139 -0
- package/packages/server/src/index.js +67 -1
- package/packages/server/src/mcp-autowire.js +253 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
const WS_PROTOCOL = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
5
5
|
const WS_BASE = `${WS_PROTOCOL}//${window.location.host}/ws`;
|
|
6
6
|
|
|
7
|
+
// ===== Utilities =====
|
|
8
|
+
function escapeHtml(str) {
|
|
9
|
+
const div = document.createElement('div');
|
|
10
|
+
div.textContent = str;
|
|
11
|
+
return div.innerHTML;
|
|
12
|
+
}
|
|
13
|
+
|
|
7
14
|
// State
|
|
8
15
|
const state = {
|
|
9
16
|
sessions: new Map(), // id → { session, terminal, ws, fitAddon, el }
|
|
@@ -2654,12 +2661,6 @@
|
|
|
2654
2661
|
}
|
|
2655
2662
|
}
|
|
2656
2663
|
|
|
2657
|
-
function escapeHtml(str) {
|
|
2658
|
-
const div = document.createElement('div');
|
|
2659
|
-
div.textContent = str;
|
|
2660
|
-
return div.innerHTML;
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2663
2664
|
function updateGlobalStats(sessions) {
|
|
2664
2665
|
let active = 0, thinking = 0, idle = 0;
|
|
2665
2666
|
for (const s of sessions) {
|
|
@@ -4257,12 +4258,6 @@
|
|
|
4257
4258
|
dropdown.innerHTML = html;
|
|
4258
4259
|
}
|
|
4259
4260
|
|
|
4260
|
-
function escapeHtml(str) {
|
|
4261
|
-
const div = document.createElement('div');
|
|
4262
|
-
div.textContent = str;
|
|
4263
|
-
return div.innerHTML;
|
|
4264
|
-
}
|
|
4265
|
-
|
|
4266
4261
|
function toggleHealthDropdown() {
|
|
4267
4262
|
if (healthState.dropdownOpen) {
|
|
4268
4263
|
closeHealthDropdown();
|
|
@@ -271,6 +271,41 @@
|
|
|
271
271
|
margin-bottom: 16px;
|
|
272
272
|
font-size: 13px;
|
|
273
273
|
}
|
|
274
|
+
|
|
275
|
+
/* Pagination (Sprint 49 T2) */
|
|
276
|
+
.fb-pagination {
|
|
277
|
+
display: flex;
|
|
278
|
+
align-items: center;
|
|
279
|
+
justify-content: center;
|
|
280
|
+
gap: 12px;
|
|
281
|
+
padding: 16px;
|
|
282
|
+
margin-top: 8px;
|
|
283
|
+
}
|
|
284
|
+
.fb-pag-info {
|
|
285
|
+
font-size: 12px;
|
|
286
|
+
color: var(--tg-text-dim);
|
|
287
|
+
font-family: var(--tg-mono);
|
|
288
|
+
}
|
|
289
|
+
.fb-pag-btn {
|
|
290
|
+
background: var(--tg-bg);
|
|
291
|
+
color: var(--tg-text);
|
|
292
|
+
border: 1px solid var(--tg-border);
|
|
293
|
+
border-radius: var(--tg-radius-sm);
|
|
294
|
+
padding: 4px 10px;
|
|
295
|
+
font-size: 12px;
|
|
296
|
+
font-family: var(--tg-mono);
|
|
297
|
+
cursor: pointer;
|
|
298
|
+
transition: all 0.15s;
|
|
299
|
+
}
|
|
300
|
+
.fb-pag-btn:hover:not(:disabled) {
|
|
301
|
+
background: var(--tg-surface-hover);
|
|
302
|
+
border-color: var(--tg-border-active);
|
|
303
|
+
color: var(--tg-text-bright);
|
|
304
|
+
}
|
|
305
|
+
.fb-pag-btn:disabled {
|
|
306
|
+
opacity: 0.3;
|
|
307
|
+
cursor: not-allowed;
|
|
308
|
+
}
|
|
274
309
|
</style>
|
|
275
310
|
</head>
|
|
276
311
|
<body class="fb-page">
|
|
@@ -13,10 +13,13 @@
|
|
|
13
13
|
// Vanilla JS, no framework — matches the rest of public/.
|
|
14
14
|
|
|
15
15
|
(() => {
|
|
16
|
-
|
|
16
|
+
const API = window.location.origin;
|
|
17
|
+
const PAGE_SIZE = 25;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
let _allEvents = [];
|
|
20
|
+
let _currentPage = 1;
|
|
21
|
+
|
|
22
|
+
const els = { windowSel: document.getElementById('fbWindow'),
|
|
20
23
|
refreshBtn: document.getElementById('fbRefresh'),
|
|
21
24
|
errorBanner: document.getElementById('fbErrorBanner'),
|
|
22
25
|
content: document.getElementById('fbContent'),
|
|
@@ -154,8 +157,12 @@
|
|
|
154
157
|
`;
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
function renderTable(events) {
|
|
158
|
-
const
|
|
160
|
+
function renderTable(events, page = 1) {
|
|
161
|
+
const totalPages = Math.ceil(events.length / PAGE_SIZE) || 1;
|
|
162
|
+
const start = (page - 1) * PAGE_SIZE;
|
|
163
|
+
const slice = events.slice(start, start + PAGE_SIZE);
|
|
164
|
+
|
|
165
|
+
const rows = slice.map((e) => {
|
|
159
166
|
const projectCell = e.project
|
|
160
167
|
? `<span class="fb-cell-project">${escapeHtml(e.project)}</span>`
|
|
161
168
|
: `<span class="fb-cell-project" style="color:var(--tg-text-dim)">—</span>`;
|
|
@@ -183,7 +190,7 @@
|
|
|
183
190
|
`;
|
|
184
191
|
}).join('');
|
|
185
192
|
|
|
186
|
-
|
|
193
|
+
let html = `
|
|
187
194
|
<div class="fb-table-wrap">
|
|
188
195
|
<table class="fb-table">
|
|
189
196
|
<thead>
|
|
@@ -200,6 +207,36 @@
|
|
|
200
207
|
</table>
|
|
201
208
|
</div>
|
|
202
209
|
`;
|
|
210
|
+
|
|
211
|
+
if (events.length > PAGE_SIZE) {
|
|
212
|
+
html += `
|
|
213
|
+
<div class="fb-pagination">
|
|
214
|
+
<button type="button" class="fb-pag-btn" id="fbPrev" ${page <= 1 ? 'disabled' : ''}>← Prev</button>
|
|
215
|
+
<span class="fb-pag-info">Page ${page} of ${totalPages}</span>
|
|
216
|
+
<button type="button" class="fb-pag-btn" id="fbNext" ${page >= totalPages ? 'disabled' : ''}>Next →</button>
|
|
217
|
+
</div>
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
els.content.innerHTML = html;
|
|
222
|
+
|
|
223
|
+
// Wire pagination buttons
|
|
224
|
+
const prevBtn = document.getElementById('fbPrev');
|
|
225
|
+
const nextBtn = document.getElementById('fbNext');
|
|
226
|
+
if (prevBtn) {
|
|
227
|
+
prevBtn.onclick = () => {
|
|
228
|
+
_currentPage--;
|
|
229
|
+
localStorage.setItem('fbHistoryPage', String(_currentPage));
|
|
230
|
+
renderTable(_allEvents, _currentPage);
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
if (nextBtn) {
|
|
234
|
+
nextBtn.onclick = () => {
|
|
235
|
+
_currentPage++;
|
|
236
|
+
localStorage.setItem('fbHistoryPage', String(_currentPage));
|
|
237
|
+
renderTable(_allEvents, _currentPage);
|
|
238
|
+
};
|
|
239
|
+
}
|
|
203
240
|
}
|
|
204
241
|
|
|
205
242
|
function showError(msg) {
|
|
@@ -211,7 +248,7 @@
|
|
|
211
248
|
els.errorBanner.textContent = '';
|
|
212
249
|
}
|
|
213
250
|
|
|
214
|
-
async function refresh() {
|
|
251
|
+
async function refresh(resetPage = true) {
|
|
215
252
|
clearError();
|
|
216
253
|
els.content.innerHTML = `<div class="fb-loading">Loading flashback history…</div>`;
|
|
217
254
|
|
|
@@ -219,7 +256,7 @@
|
|
|
219
256
|
const since = sinceFromWindow(winKey);
|
|
220
257
|
const qs = new URLSearchParams();
|
|
221
258
|
if (since) qs.set('since', since);
|
|
222
|
-
qs.set('limit', '
|
|
259
|
+
qs.set('limit', '500'); // Sprint 49: raised from 200 for better pagination scale
|
|
223
260
|
|
|
224
261
|
let data;
|
|
225
262
|
try {
|
|
@@ -235,24 +272,34 @@
|
|
|
235
272
|
return;
|
|
236
273
|
}
|
|
237
274
|
|
|
275
|
+
_allEvents = data.events || [];
|
|
238
276
|
renderFunnel(data.funnel || { fires: 0, dismissed: 0, clicked_through: 0 });
|
|
239
277
|
|
|
240
|
-
if (
|
|
278
|
+
if (_allEvents.length === 0) {
|
|
241
279
|
renderZeroState(winKey);
|
|
242
280
|
return;
|
|
243
281
|
}
|
|
244
282
|
|
|
245
|
-
|
|
283
|
+
if (resetPage) {
|
|
284
|
+
_currentPage = 1;
|
|
285
|
+
localStorage.setItem('fbHistoryPage', '1');
|
|
286
|
+
} else {
|
|
287
|
+
_currentPage = parseInt(localStorage.getItem('fbHistoryPage') || '1', 10);
|
|
288
|
+
const maxPage = Math.ceil(_allEvents.length / PAGE_SIZE) || 1;
|
|
289
|
+
if (_currentPage > maxPage) _currentPage = 1;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
renderTable(_allEvents, _currentPage);
|
|
246
293
|
}
|
|
247
294
|
|
|
248
295
|
// Wire controls
|
|
249
296
|
els.windowSel.addEventListener('change', () => {
|
|
250
297
|
writeStateToUrl();
|
|
251
|
-
refresh();
|
|
298
|
+
refresh(true);
|
|
252
299
|
});
|
|
253
|
-
els.refreshBtn.addEventListener('click', () => refresh());
|
|
300
|
+
els.refreshBtn.addEventListener('click', () => refresh(true));
|
|
254
301
|
|
|
255
302
|
// Boot
|
|
256
303
|
loadStateFromUrl();
|
|
257
|
-
refresh();
|
|
304
|
+
refresh(false);
|
|
258
305
|
})();
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
</div>
|
|
41
41
|
</header>
|
|
42
42
|
|
|
43
|
+
<div class="graph-presets" id="graphPresets" role="toolbar" aria-label="Filter presets">
|
|
44
|
+
<button type="button" class="gf-preset" id="presetAll">All</button>
|
|
45
|
+
<button type="button" class="gf-preset" id="presetNone">None</button>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
43
48
|
<div class="graph-filters" id="graphFilters" role="toolbar" aria-label="Edge type filters"></div>
|
|
44
49
|
|
|
45
50
|
<div class="graph-filters graph-filters-row2" id="graphControls" role="toolbar" aria-label="Graph view controls">
|
|
@@ -410,6 +410,7 @@
|
|
|
410
410
|
const keys = Object.keys(EDGE_COLORS).filter((k) => present.has(k));
|
|
411
411
|
if (keys.length === 0) {
|
|
412
412
|
wrap.style.display = 'none';
|
|
413
|
+
updatePresetButtons();
|
|
413
414
|
return;
|
|
414
415
|
}
|
|
415
416
|
wrap.style.display = '';
|
|
@@ -429,10 +430,27 @@
|
|
|
429
430
|
if (state.activeKinds.has(k)) state.activeKinds.delete(k);
|
|
430
431
|
else state.activeKinds.add(k);
|
|
431
432
|
chip.classList.toggle('active');
|
|
433
|
+
updatePresetButtons();
|
|
432
434
|
applyFilter();
|
|
433
435
|
});
|
|
434
436
|
wrap.appendChild(chip);
|
|
435
437
|
}
|
|
438
|
+
updatePresetButtons();
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function isAllKindsActive() {
|
|
442
|
+
return state.activeKinds.size === Object.keys(EDGE_COLORS).length;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isNoKindsActive() {
|
|
446
|
+
return state.activeKinds.size === 0;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function updatePresetButtons() {
|
|
450
|
+
const allBtn = $('presetAll');
|
|
451
|
+
const noneBtn = $('presetNone');
|
|
452
|
+
if (allBtn) allBtn.disabled = isAllKindsActive();
|
|
453
|
+
if (noneBtn) noneBtn.disabled = isNoKindsActive();
|
|
436
454
|
}
|
|
437
455
|
|
|
438
456
|
function applyFilter() {
|
|
@@ -843,6 +861,25 @@
|
|
|
843
861
|
});
|
|
844
862
|
$('graphFit').addEventListener('click', () => fitToView());
|
|
845
863
|
|
|
864
|
+
// Sprint 49 T3 — chip filter presets (All/None). Wire once; renderFilters()
|
|
865
|
+
// keeps their disabled state in sync with activeKinds boundary conditions.
|
|
866
|
+
const presetAll = $('presetAll');
|
|
867
|
+
const presetNone = $('presetNone');
|
|
868
|
+
if (presetAll) {
|
|
869
|
+
presetAll.addEventListener('click', () => {
|
|
870
|
+
state.activeKinds = new Set(Object.keys(EDGE_COLORS));
|
|
871
|
+
renderFilters();
|
|
872
|
+
applyFilter();
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
if (presetNone) {
|
|
876
|
+
presetNone.addEventListener('click', () => {
|
|
877
|
+
state.activeKinds = new Set();
|
|
878
|
+
renderFilters();
|
|
879
|
+
applyFilter();
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
|
|
846
883
|
// Sprint 43 T1 — graph view-controls. Hydrate input values from state, then
|
|
847
884
|
// wire change handlers that mutate state.controls + URL + re-render from
|
|
848
885
|
// the cached raw fetch so toggling is fast (no API round-trip).
|
|
@@ -58,9 +58,8 @@
|
|
|
58
58
|
<button class="topbar-ql-btn" onclick="quickLaunch('python3 -m http.server 8080')" title="Open a Python HTTP server on :8080">python</button>
|
|
59
59
|
</div>
|
|
60
60
|
<div class="topbar-row-2-spacer"></div>
|
|
61
|
-
<button id="btn-status">status</button>
|
|
62
|
-
<button id="btn-config">config</button>
|
|
63
|
-
<button id="btn-sprint" title="Define and kick off a 4+1 sprint">sprint</button>
|
|
61
|
+
<button id="btn-status" title="Global metrics: session counts, RAG mode, and memory bridge status">status</button>
|
|
62
|
+
<button id="btn-config" title="Configuration: project list, theme defaults, and live RAG-mode toggle">config</button> <button id="btn-sprint" title="Define and kick off a 4+1 sprint">sprint</button>
|
|
64
63
|
<button id="btn-graph" title="Open the knowledge-graph view (memory_items + memory_relationships, force-directed)" onclick="window.open('/graph.html','_blank','noopener')">graph</button>
|
|
65
64
|
<button id="btn-flashback-history" title="Audit dashboard: every Flashback fire, dismiss/click-through funnel" onclick="window.open('/flashback-history.html','_blank','noopener')">flashback history</button>
|
|
66
65
|
<button id="btn-how" title="Walkthrough of every TermDeck feature">how this works</button>
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
root.LauncherResolver = factory();
|
|
25
25
|
}
|
|
26
26
|
})(typeof self !== 'undefined' ? self : this, function () {
|
|
27
|
+
function escapeRegex(s) {
|
|
28
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
29
|
+
}
|
|
30
|
+
|
|
27
31
|
function resolve(command, project, agentAdapters, projects) {
|
|
28
32
|
let resolvedCommand = command;
|
|
29
33
|
let resolvedType = 'shell';
|
|
@@ -36,7 +40,7 @@
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
const adapter = (agentAdapters || []).find((a) =>
|
|
39
|
-
a && a.binary && new RegExp(`^${a.binary}\\b`, 'i').test(canonical)
|
|
43
|
+
a && a.binary && new RegExp(`^${escapeRegex(a.binary)}\\b`, 'i').test(canonical)
|
|
40
44
|
);
|
|
41
45
|
|
|
42
46
|
if (adapter) {
|
|
@@ -3567,6 +3567,37 @@
|
|
|
3567
3567
|
background: rgba(255, 255, 255, 0.08);
|
|
3568
3568
|
}
|
|
3569
3569
|
|
|
3570
|
+
/* Sprint 49 T3 — graph chip filter presets (Surface 2 from Sprint 46 audit).
|
|
3571
|
+
Subtle variant of .gf-chip: smaller, no dot/count, secondary tint,
|
|
3572
|
+
disabled state for boundary (all/none already active). Placed above
|
|
3573
|
+
the chip row. */
|
|
3574
|
+
.graph-presets {
|
|
3575
|
+
display: flex;
|
|
3576
|
+
gap: 6px;
|
|
3577
|
+
padding: 8px 16px 4px;
|
|
3578
|
+
background: var(--tg-surface);
|
|
3579
|
+
border-bottom: 1px solid var(--tg-border);
|
|
3580
|
+
flex-shrink: 0;
|
|
3581
|
+
}
|
|
3582
|
+
.gf-preset {
|
|
3583
|
+
padding: 2px 10px;
|
|
3584
|
+
font-size: 10px;
|
|
3585
|
+
font-family: var(--tg-mono);
|
|
3586
|
+
color: var(--tg-text-dim);
|
|
3587
|
+
background: var(--tg-bg);
|
|
3588
|
+
border: 1px solid var(--tg-border);
|
|
3589
|
+
border-radius: 999px;
|
|
3590
|
+
cursor: pointer;
|
|
3591
|
+
transition: all 150ms;
|
|
3592
|
+
opacity: 0.7;
|
|
3593
|
+
}
|
|
3594
|
+
.gf-preset:hover { opacity: 1; background: var(--tg-surface-hover); }
|
|
3595
|
+
.gf-preset:disabled {
|
|
3596
|
+
opacity: 0.35;
|
|
3597
|
+
cursor: not-allowed;
|
|
3598
|
+
background: transparent;
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3570
3601
|
/* Sprint 43 T1 — second toolbar row: hide-isolated / min-degree / window
|
|
3571
3602
|
/ layout selectors. Mirrors .graph-filters spacing but shifts the
|
|
3572
3603
|
background a notch darker so the two rows are visually distinguishable
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// transcript-parser cut-offs. Sprint 45 adds Codex / Gemini / Grok adapters
|
|
8
8
|
// alongside this one; Sprint 46 wires per-lane agent assignment in 4+1.
|
|
9
9
|
//
|
|
10
|
-
// Contract (memorialization doc § 4 + lane brief T3
|
|
10
|
+
// Contract (memorialization doc § 4 + lane brief T3, extended in Sprint 47
|
|
11
|
+
// T3 with `acceptsPaste` and Sprint 48 T1 with `mcpConfig`):
|
|
11
12
|
// {
|
|
12
13
|
// name: string, // adapter id used in registry
|
|
13
14
|
// sessionType: string, // session.meta.type produced
|
|
@@ -19,6 +20,10 @@
|
|
|
19
20
|
// parseTranscript:(raw) => Memory[], // for memory-session-end hook
|
|
20
21
|
// bootPromptTemplate: (lane, sprint) => string,
|
|
21
22
|
// costBand: 'free' | 'pay-per-token' | 'subscription',
|
|
23
|
+
// acceptsPaste: boolean, // Sprint 47 T3 — bracketed-paste capable
|
|
24
|
+
// mcpConfig: { path, format, mnestraBlock, detectExisting } | null,
|
|
25
|
+
// // Sprint 48 T1 — per-agent MCP auto-wire
|
|
26
|
+
// // null = user-managed (Claude only)
|
|
22
27
|
// }
|
|
23
28
|
//
|
|
24
29
|
// `statusFor` returns null when no pattern matches — preserves the original
|
|
@@ -157,6 +162,11 @@ const claudeAdapter = {
|
|
|
157
162
|
// The two-stage submit pattern (paste then \r alone) is the canonical inject
|
|
158
163
|
// shape for this adapter; chunked-fallback is unnecessary.
|
|
159
164
|
acceptsPaste: true,
|
|
165
|
+
// Sprint 48 T1 — Claude's MCP config (~/.claude.json) is owned by the user
|
|
166
|
+
// and `claude mcp add`. Auto-wiring a Mnestra block here would conflict
|
|
167
|
+
// with that surface. `null` declares the contract field while signalling
|
|
168
|
+
// "user-managed; mcp-autowire.js short-circuits to skipped:no-mcpConfig".
|
|
169
|
+
mcpConfig: null,
|
|
160
170
|
};
|
|
161
171
|
|
|
162
172
|
module.exports = claudeAdapter;
|
|
@@ -197,6 +197,36 @@ const codexAdapter = {
|
|
|
197
197
|
// Sprint 47 T3 — Codex's Ratatui TUI accepts bracketed-paste per the
|
|
198
198
|
// Sprint 45 T1 audit; safe to use the two-stage submit pattern unchanged.
|
|
199
199
|
acceptsPaste: true,
|
|
200
|
+
// Sprint 48 T1 — per-agent MCP auto-wire descriptor consumed by
|
|
201
|
+
// packages/server/src/mcp-autowire.js. Codex reads MCP servers from
|
|
202
|
+
// ~/.codex/config.toml in the canonical `[mcp_servers.NAME]` shape with a
|
|
203
|
+
// sibling `[mcp_servers.NAME.env]` table (snake_case, NOT camelCase — that
|
|
204
|
+
// distinguishes Codex's TOML schema from the JSON-based agents).
|
|
205
|
+
mcpConfig: {
|
|
206
|
+
path: '~/.codex/config.toml',
|
|
207
|
+
format: 'toml',
|
|
208
|
+
mnestraBlock: ({ secrets }) => {
|
|
209
|
+
const lines = ['[mcp_servers.mnestra]', 'command = "mnestra"'];
|
|
210
|
+
const wanted = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
|
|
211
|
+
const env = {};
|
|
212
|
+
for (const k of wanted) {
|
|
213
|
+
if (secrets && typeof secrets[k] === 'string' && secrets[k].length > 0) {
|
|
214
|
+
env[k] = secrets[k];
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (Object.keys(env).length > 0) {
|
|
218
|
+
lines.push('');
|
|
219
|
+
lines.push('[mcp_servers.mnestra.env]');
|
|
220
|
+
for (const [k, v] of Object.entries(env)) {
|
|
221
|
+
// TOML basic-string escaping — backslash + double-quote.
|
|
222
|
+
const escaped = String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
223
|
+
lines.push(`${k} = "${escaped}"`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return lines.join('\n') + '\n';
|
|
227
|
+
},
|
|
228
|
+
detectExisting: (text) => /^\s*\[mcp_servers\.mnestra\]\s*$/m.test(text),
|
|
229
|
+
},
|
|
200
230
|
};
|
|
201
231
|
|
|
202
232
|
module.exports = codexAdapter;
|
|
@@ -126,6 +126,54 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
|
|
|
126
126
|
].join('\n');
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
130
|
+
// mcpConfig — Sprint 48 T2. Declarative description of where Gemini reads
|
|
131
|
+
// its MCP-server registry and how to write a Mnestra entry into it. The
|
|
132
|
+
// shared helper at packages/server/src/mcp-autowire.js (Sprint 48 T1) uses
|
|
133
|
+
// this on panel spawn to ensure `memory_recall` is available out-of-the-box
|
|
134
|
+
// for outside users running mixed 4+1 with a Gemini lane.
|
|
135
|
+
//
|
|
136
|
+
// Schema reference: https://www.geminicli.com/docs/tools/mcp-server
|
|
137
|
+
// (verified 2026-05-02). Top-level key is `mcpServers` (camelCase). Each
|
|
138
|
+
// entry must specify exactly one transport — `command` (stdio), `url`
|
|
139
|
+
// (SSE), or `httpUrl` (HTTP streaming). Mnestra ships as a stdio binary
|
|
140
|
+
// (`mnestra`), so we use `command`.
|
|
141
|
+
//
|
|
142
|
+
// Note (no `type` field): the `type: 'stdio'` field used in the Claude
|
|
143
|
+
// Code config (~/.claude.json `mcp_servers.mnestra.type`) is a Claude-Code
|
|
144
|
+
// extension. Gemini infers transport from which of command/url/httpUrl is
|
|
145
|
+
// set, so we omit `type` here to keep the entry valid against the
|
|
146
|
+
// documented Gemini schema.
|
|
147
|
+
//
|
|
148
|
+
// Note (restart required): Gemini CLI discovers MCP servers at startup, so
|
|
149
|
+
// adding a new entry only takes effect on the next `gemini` launch. The
|
|
150
|
+
// helper still writes immediately on panel spawn — by the time the user
|
|
151
|
+
// types `gemini` in the panel, the entry is in place.
|
|
152
|
+
//
|
|
153
|
+
// Note (env-key omission): empty/missing secrets are intentionally
|
|
154
|
+
// dropped from the env object instead of written as empty strings. This
|
|
155
|
+
// matches stack-installer/src/index.js:336-339 — concrete-or-omit, never
|
|
156
|
+
// placeholder, because Gemini (like Claude Code) does not shell-expand
|
|
157
|
+
// `${VAR}` references in MCP env. Mnestra's own secrets.env fallback
|
|
158
|
+
// loads what's missing at process start.
|
|
159
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
const MNESTRA_ENV_KEYS = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
|
|
162
|
+
|
|
163
|
+
function buildMnestraBlock({ secrets } = {}) {
|
|
164
|
+
const env = {};
|
|
165
|
+
for (const key of MNESTRA_ENV_KEYS) {
|
|
166
|
+
const value = secrets && secrets[key];
|
|
167
|
+
if (value) env[key] = value;
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
mnestra: {
|
|
171
|
+
command: 'mnestra',
|
|
172
|
+
env,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
129
177
|
const geminiAdapter = {
|
|
130
178
|
name: 'gemini',
|
|
131
179
|
sessionType: 'gemini',
|
|
@@ -156,6 +204,13 @@ const geminiAdapter = {
|
|
|
156
204
|
// Sprint 47 T3 — Gemini's CLI is paste-friendly per the single-JSON-object
|
|
157
205
|
// session shape captured in Sprint 45 T2; bracketed-paste injects cleanly.
|
|
158
206
|
acceptsPaste: true,
|
|
207
|
+
// Sprint 48 T2 — see comment block above for schema notes + provenance.
|
|
208
|
+
mcpConfig: {
|
|
209
|
+
path: '~/.gemini/settings.json',
|
|
210
|
+
format: 'json',
|
|
211
|
+
mcpServersKey: 'mcpServers',
|
|
212
|
+
mnestraBlock: buildMnestraBlock,
|
|
213
|
+
},
|
|
159
214
|
};
|
|
160
215
|
|
|
161
216
|
module.exports = geminiAdapter;
|
|
@@ -218,6 +218,136 @@ function bootPromptTemplate(lane = {}, sprint = {}) {
|
|
|
218
218
|
].join('\n');
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
222
|
+
// mcpConfig — Sprint 48 T3. Grok's MCP-server registry lives at
|
|
223
|
+
// `~/.grok/user-settings.json` under the `mcp.servers` key, which is an
|
|
224
|
+
// **ARRAY** of `McpServerConfig` items, NOT a record `mcpServers.NAME` like
|
|
225
|
+
// Codex/Gemini use. Authoritative schema lifted from
|
|
226
|
+
// `/usr/local/lib/node_modules/grok-dev/dist/utils/settings.{d.ts,js}`
|
|
227
|
+
// (Bun-bundled source, package `grok-dev` v1.1.5):
|
|
228
|
+
//
|
|
229
|
+
// interface McpServerConfig {
|
|
230
|
+
// id: string; label: string; enabled: boolean;
|
|
231
|
+
// transport: "http" | "sse" | "stdio";
|
|
232
|
+
// command?, args?, env?, cwd?, url?, headers?
|
|
233
|
+
// }
|
|
234
|
+
// interface McpSettings { servers?: McpServerConfig[] }
|
|
235
|
+
// interface UserSettings { ..., mcp?: McpSettings }
|
|
236
|
+
// function loadMcpServers(): UserSettings.mcp?.servers ?? []
|
|
237
|
+
// function saveMcpServers(servers): saveUserSettings({ mcp: { servers } })
|
|
238
|
+
//
|
|
239
|
+
// Hot-load behavior: agent.js calls `loadMcpServers()` at the start of every
|
|
240
|
+
// agent turn (3 sites: stream / batch / child-agent), so MCP changes are
|
|
241
|
+
// picked up on the next user message — no Grok restart required.
|
|
242
|
+
//
|
|
243
|
+
// Schema-divergence implication: the `mcpServersKey + mnestraBlock` record-
|
|
244
|
+
// merge shape used by gemini.js (Sprint 48 T2) and the TOML-append shape used
|
|
245
|
+
// by codex.js cannot represent Grok's array-with-explicit-id-fields layout.
|
|
246
|
+
// Grok therefore declares a `merge(rawText, { secrets }) -> { changed, output }`
|
|
247
|
+
// escape-hatch on its `mcpConfig`. The shared `mcp-autowire.js` helper
|
|
248
|
+
// (Sprint 48 T1) checks for `mcpConfig.merge` first; if present, the adapter
|
|
249
|
+
// owns parse + mutate + serialize, the helper still owns tilde-expansion +
|
|
250
|
+
// parent-dir creation + atomic write + idempotency reporting. See Sprint 48
|
|
251
|
+
// STATUS.md § T3 FIX-PROPOSED for the coordination decision.
|
|
252
|
+
//
|
|
253
|
+
// Env-key omission discipline matches stack-installer/src/index.js:336-339
|
|
254
|
+
// and the Gemini adapter: empty/missing/`${VAR}`-placeholder values are
|
|
255
|
+
// dropped from the env object instead of written as empty strings, because
|
|
256
|
+
// Grok (like Claude Code and Gemini) does not shell-expand `${VAR}` in MCP
|
|
257
|
+
// env. Mnestra's own secrets.env stdio fallback (mnestra@0.3.4) loads what
|
|
258
|
+
// is missing at process start.
|
|
259
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
const MNESTRA_ENV_KEYS = ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_API_KEY'];
|
|
262
|
+
|
|
263
|
+
function _pickConcreteEnv(secrets) {
|
|
264
|
+
const env = {};
|
|
265
|
+
if (!secrets || typeof secrets !== 'object') return env;
|
|
266
|
+
for (const key of MNESTRA_ENV_KEYS) {
|
|
267
|
+
const value = secrets[key];
|
|
268
|
+
if (typeof value !== 'string') continue;
|
|
269
|
+
if (value.length === 0) continue;
|
|
270
|
+
// Reject literal `${VAR}` placeholders — Grok won't shell-expand them.
|
|
271
|
+
if (/^\$\{[^}]*\}$/.test(value)) continue;
|
|
272
|
+
env[key] = value;
|
|
273
|
+
}
|
|
274
|
+
return env;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function _buildMnestraServer({ secrets } = {}) {
|
|
278
|
+
return {
|
|
279
|
+
id: 'mnestra',
|
|
280
|
+
label: 'Mnestra',
|
|
281
|
+
enabled: true,
|
|
282
|
+
transport: 'stdio',
|
|
283
|
+
command: 'mnestra',
|
|
284
|
+
args: [],
|
|
285
|
+
env: _pickConcreteEnv(secrets),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Deep-equal check scoped to the fields we manage. Unknown extra fields on
|
|
290
|
+
// the existing entry (e.g. user-added `cwd` overrides) are tolerated — we
|
|
291
|
+
// only refresh the entry when one of OUR managed fields drifts. Prevents
|
|
292
|
+
// the helper from clobbering hand-edited Grok customizations on every spawn.
|
|
293
|
+
function _mnestraEntryEqual(existing, desired) {
|
|
294
|
+
if (!existing || typeof existing !== 'object') return false;
|
|
295
|
+
for (const key of ['id', 'label', 'enabled', 'transport', 'command']) {
|
|
296
|
+
if (existing[key] !== desired[key]) return false;
|
|
297
|
+
}
|
|
298
|
+
const a = Array.isArray(existing.args) ? existing.args : [];
|
|
299
|
+
const b = desired.args;
|
|
300
|
+
if (a.length !== b.length) return false;
|
|
301
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
302
|
+
const ea = existing.env && typeof existing.env === 'object' ? existing.env : {};
|
|
303
|
+
const eb = desired.env;
|
|
304
|
+
const eaKeys = Object.keys(ea).sort();
|
|
305
|
+
const ebKeys = Object.keys(eb).sort();
|
|
306
|
+
if (eaKeys.length !== ebKeys.length) return false;
|
|
307
|
+
for (let i = 0; i < eaKeys.length; i += 1) {
|
|
308
|
+
if (eaKeys[i] !== ebKeys[i]) return false;
|
|
309
|
+
if (ea[eaKeys[i]] !== eb[ebKeys[i]]) return false;
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function _mergeMnestraIntoGrokSettings(rawText, { secrets } = {}) {
|
|
315
|
+
let current = {};
|
|
316
|
+
if (typeof rawText === 'string' && rawText.trim().length > 0) {
|
|
317
|
+
try {
|
|
318
|
+
const parsed = JSON.parse(rawText);
|
|
319
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
320
|
+
current = parsed;
|
|
321
|
+
}
|
|
322
|
+
} catch (_) {
|
|
323
|
+
// Malformed JSON → start fresh. Helper's atomic-write contract means
|
|
324
|
+
// we don't risk corrupting the user's file partway through; on read
|
|
325
|
+
// failure the conservative path is to write a clean replacement that
|
|
326
|
+
// preserves the keys we know how to round-trip (none — we only own
|
|
327
|
+
// the mcp branch). User's other settings in a corrupt file are
|
|
328
|
+
// unrecoverable from text anyway.
|
|
329
|
+
current = {};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const next = { ...current };
|
|
333
|
+
next.mcp = next.mcp && typeof next.mcp === 'object' && !Array.isArray(next.mcp)
|
|
334
|
+
? { ...next.mcp }
|
|
335
|
+
: {};
|
|
336
|
+
const servers = Array.isArray(next.mcp.servers) ? [...next.mcp.servers] : [];
|
|
337
|
+
const desired = _buildMnestraServer({ secrets });
|
|
338
|
+
const existingIdx = servers.findIndex((s) => s && s.id === 'mnestra');
|
|
339
|
+
if (existingIdx >= 0 && _mnestraEntryEqual(servers[existingIdx], desired)) {
|
|
340
|
+
return { changed: false, output: rawText };
|
|
341
|
+
}
|
|
342
|
+
if (existingIdx >= 0) {
|
|
343
|
+
servers[existingIdx] = desired;
|
|
344
|
+
} else {
|
|
345
|
+
servers.push(desired);
|
|
346
|
+
}
|
|
347
|
+
next.mcp.servers = servers;
|
|
348
|
+
return { changed: true, output: `${JSON.stringify(next, null, 2)}\n` };
|
|
349
|
+
}
|
|
350
|
+
|
|
221
351
|
// ──────────────────────────────────────────────────────────────────────────
|
|
222
352
|
// Adapter export. spawn.env.GROK_MODEL defaults to the cheap-fast tier;
|
|
223
353
|
// per-lane override is the launcher's job at session-spawn time (Sprint 46
|
|
@@ -260,6 +390,15 @@ const grokAdapter = {
|
|
|
260
390
|
// lane-time test shows the OpenTUI input handler eats the paste markers,
|
|
261
391
|
// flip this to false and the inject helper falls back to chunked stdin.
|
|
262
392
|
acceptsPaste: true,
|
|
393
|
+
// Sprint 48 T3 — see comment block above for schema notes + provenance.
|
|
394
|
+
// Grok deviates from Codex (TOML) and Gemini (JSON record) — its `mcp.servers`
|
|
395
|
+
// is an array with explicit `id`/`label`/`enabled`/`transport` fields, so the
|
|
396
|
+
// adapter declares a `merge` escape-hatch instead of `mcpServersKey + mnestraBlock`.
|
|
397
|
+
mcpConfig: {
|
|
398
|
+
path: '~/.grok/user-settings.json',
|
|
399
|
+
format: 'json',
|
|
400
|
+
merge: _mergeMnestraIntoGrokSettings,
|
|
401
|
+
},
|
|
263
402
|
};
|
|
264
403
|
|
|
265
404
|
module.exports = grokAdapter;
|
|
@@ -81,6 +81,53 @@ const orchestrationPreview = require('./orchestration-preview');
|
|
|
81
81
|
const { createPtyReaper } = require('./pty-reaper');
|
|
82
82
|
const { AGENT_ADAPTERS } = require('./agent-adapters');
|
|
83
83
|
|
|
84
|
+
// Sprint 48 T4 deliverable 2: PTY env-var propagation.
|
|
85
|
+
// Reads ~/.termdeck/secrets.env once per server lifetime so each PTY spawn
|
|
86
|
+
// inherits SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY / OPENAI_API_KEY etc.
|
|
87
|
+
// without depending on the user's shell to have sourced the file.
|
|
88
|
+
//
|
|
89
|
+
// Why this exists: `memory-session-end.js` (the bundled Stop hook installed by
|
|
90
|
+
// `@jhizzard/termdeck-stack`) writes session_summary rows to Mnestra by
|
|
91
|
+
// reading those three vars from `process.env`. When TermDeck spawns a Claude
|
|
92
|
+
// Code panel directly via `pty.spawn`, the child shell inherits the server's
|
|
93
|
+
// `process.env` — but if the *user* didn't source secrets.env in their
|
|
94
|
+
// `.zshrc` before running `termdeck`, those vars are absent and every session
|
|
95
|
+
// close hits `env-var-missing`. Sprint 47 close-out audit confirmed 0
|
|
96
|
+
// session_summary rows had ever landed.
|
|
97
|
+
//
|
|
98
|
+
// Treats `${VAR}` placeholders as unset (Sprint 47.5 hotfix lesson — Claude
|
|
99
|
+
// Code does not shell-expand MCP env values; same trap applies anywhere the
|
|
100
|
+
// secrets file flows through a non-shell consumer).
|
|
101
|
+
let _termdeckSecretsCache = null;
|
|
102
|
+
function readTermdeckSecretsForPty() {
|
|
103
|
+
if (_termdeckSecretsCache !== null) return _termdeckSecretsCache;
|
|
104
|
+
const secretsPath = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
105
|
+
const out = {};
|
|
106
|
+
try {
|
|
107
|
+
const text = fs.readFileSync(secretsPath, 'utf8');
|
|
108
|
+
for (const raw of text.split('\n')) {
|
|
109
|
+
const line = raw.trim();
|
|
110
|
+
if (!line || line.startsWith('#')) continue;
|
|
111
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
112
|
+
if (!m) continue;
|
|
113
|
+
let v = m[2].trim();
|
|
114
|
+
if (v.length >= 2 && (v[0] === '"' || v[0] === "'") && v[v.length - 1] === v[0]) {
|
|
115
|
+
v = v.slice(1, -1);
|
|
116
|
+
}
|
|
117
|
+
if (v.startsWith('${') && v.endsWith('}')) continue;
|
|
118
|
+
if (v === '') continue;
|
|
119
|
+
out[m[1]] = v;
|
|
120
|
+
}
|
|
121
|
+
} catch (_err) {
|
|
122
|
+
// File absent or unreadable — empty merge, hook still hits env-var-missing
|
|
123
|
+
// until the user runs the wizard. Better than a crash on spawn.
|
|
124
|
+
}
|
|
125
|
+
_termdeckSecretsCache = out;
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
// Test hook — clear the cache between tests that mutate the on-disk file.
|
|
129
|
+
function _resetTermdeckSecretsCache() { _termdeckSecretsCache = null; }
|
|
130
|
+
|
|
84
131
|
// Sprint 37 T3 — lazy resolution of T2's CLI modules. The orchestration-preview
|
|
85
132
|
// helper is decoupled from T2's templates.js / init-project.js; we resolve
|
|
86
133
|
// them here and pass them into the helper. If a module is missing (e.g.
|
|
@@ -805,6 +852,18 @@ function createServer(config) {
|
|
|
805
852
|
const args = (cmdTrim && !isPlainShell) ? ['-c', cmdTrim] : [];
|
|
806
853
|
|
|
807
854
|
try {
|
|
855
|
+
// Sprint 48 T4: merge ~/.termdeck/secrets.env into the PTY env so
|
|
856
|
+
// the bundled session-end memory hook (`memory-session-end.js`) sees
|
|
857
|
+
// SUPABASE_URL / SERVICE_ROLE_KEY / OPENAI_API_KEY without depending
|
|
858
|
+
// on the user's shell to have sourced the file. process.env is the
|
|
859
|
+
// base; any concrete value the parent already exported wins.
|
|
860
|
+
const termdeckSecrets = readTermdeckSecretsForPty();
|
|
861
|
+
const secretFallback = {};
|
|
862
|
+
for (const [k, v] of Object.entries(termdeckSecrets)) {
|
|
863
|
+
if (process.env[k] === undefined || process.env[k] === '') {
|
|
864
|
+
secretFallback[k] = v;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
808
867
|
const term = pty.spawn(spawnShell, args, {
|
|
809
868
|
name: 'xterm-256color',
|
|
810
869
|
cols: 120,
|
|
@@ -812,6 +871,7 @@ function createServer(config) {
|
|
|
812
871
|
cwd: resolvedCwd,
|
|
813
872
|
env: {
|
|
814
873
|
...process.env,
|
|
874
|
+
...secretFallback,
|
|
815
875
|
TERMDECK_SESSION: session.id,
|
|
816
876
|
TERMDECK_PROJECT: project || '',
|
|
817
877
|
TERM: 'xterm-256color',
|
|
@@ -2162,4 +2222,10 @@ if (require.main === module) {
|
|
|
2162
2222
|
});
|
|
2163
2223
|
}
|
|
2164
2224
|
|
|
2165
|
-
module.exports = {
|
|
2225
|
+
module.exports = {
|
|
2226
|
+
createServer,
|
|
2227
|
+
loadConfig,
|
|
2228
|
+
// Sprint 48 T4 — exported for unit testing the secrets.env → PTY env merge.
|
|
2229
|
+
readTermdeckSecretsForPty,
|
|
2230
|
+
_resetTermdeckSecretsCache,
|
|
2231
|
+
};
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Sprint 48 T1 — Shared per-agent MCP auto-wire helper.
|
|
2
|
+
//
|
|
3
|
+
// Single export: ensureMnestraBlock(adapter, opts?). Idempotent. T1/T2/T3
|
|
4
|
+
// agent adapters (codex, gemini, grok) each ship an `mcpConfig` field
|
|
5
|
+
// describing where their MCP-server config lives, what format it's in,
|
|
6
|
+
// and how to merge a Mnestra entry into it. This helper is the agent-
|
|
7
|
+
// agnostic glue: read the file, dispatch on shape, render+merge+write
|
|
8
|
+
// using the secrets in ~/.termdeck/secrets.env.
|
|
9
|
+
//
|
|
10
|
+
// Why this exists: cross-project memory recall (Mnestra MCP) was unavailable
|
|
11
|
+
// to non-Claude agents by default in Sprint 47's Grok smoke — those CLIs
|
|
12
|
+
// ship without an MCP block and outside users would hit memory_recall
|
|
13
|
+
// failures the first time they spawned a non-Claude lane. This is the
|
|
14
|
+
// v1.0.0 gate-blocker fix.
|
|
15
|
+
//
|
|
16
|
+
// Three adapter shapes are supported (precedence top → bottom):
|
|
17
|
+
//
|
|
18
|
+
// 1. Escape-hatch (Grok-style — array-shape JSON or anything bespoke):
|
|
19
|
+
// mcpConfig: { path, format, merge: (rawText, {secrets}) =>
|
|
20
|
+
// ({ changed: bool, output: string }) }
|
|
21
|
+
// Adapter owns parse + mutate + serialize entirely. Helper still owns
|
|
22
|
+
// tilde expand, mkdir, read, atomic write, return shape.
|
|
23
|
+
//
|
|
24
|
+
// 2. JSON-record (Gemini-style — `{mcpServers: {NAME: {...}}}`):
|
|
25
|
+
// mcpConfig: { path, format: 'json', mcpServersKey: 'mcpServers',
|
|
26
|
+
// mnestraBlock: ({secrets}) => ({mnestra: {command, env}}) }
|
|
27
|
+
// Helper deep-merges the returned object under `config[mcpServersKey]`.
|
|
28
|
+
// Existence detected by checking `existing[mcpServersKey]?.mnestra`.
|
|
29
|
+
//
|
|
30
|
+
// 3. TOML-append (Codex-style — `[mcp_servers.NAME]` tables):
|
|
31
|
+
// mcpConfig: { path, format: 'toml',
|
|
32
|
+
// mnestraBlock: ({secrets}) => '[mcp_servers.mnestra]\n...',
|
|
33
|
+
// detectExisting: (text) => /\[mcp_servers\.mnestra\]/m.test(text) }
|
|
34
|
+
// Helper appends the rendered string to the file with one blank-line
|
|
35
|
+
// separator. Idempotent via the adapter's `detectExisting` predicate.
|
|
36
|
+
//
|
|
37
|
+
// Claude is intentionally exempt — its MCP config (~/.claude.json) is
|
|
38
|
+
// owned by the user and `claude mcp add`. Adding a Mnestra block here
|
|
39
|
+
// would conflict with that surface. Claude's adapter declares
|
|
40
|
+
// `mcpConfig: null` to satisfy the contract-parity tests.
|
|
41
|
+
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
const fs = require('node:fs');
|
|
45
|
+
const path = require('node:path');
|
|
46
|
+
const os = require('node:os');
|
|
47
|
+
|
|
48
|
+
const SECRETS_PATH = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
49
|
+
|
|
50
|
+
function expandTilde(p) {
|
|
51
|
+
if (typeof p !== 'string') return p;
|
|
52
|
+
if (p === '~') return os.homedir();
|
|
53
|
+
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
|
|
54
|
+
return p;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// dotenv-subset parser. Mirrors stack-installer's readTermdeckSecrets so the
|
|
58
|
+
// two stay byte-equivalent (KEY=value, optional matched single/double quotes,
|
|
59
|
+
// `#` comments, blanks ignored). Returns {} on absent / unreadable file.
|
|
60
|
+
// Rejects literal `${VAR}` placeholder shapes — same defense as the mnestra
|
|
61
|
+
// MCP stdio fallback (Claude Code et al. don't shell-expand them, so writing
|
|
62
|
+
// the literal placeholder is worse than omitting the key entirely).
|
|
63
|
+
function readSecrets(secretsPath = SECRETS_PATH) {
|
|
64
|
+
try {
|
|
65
|
+
const text = fs.readFileSync(secretsPath, 'utf8');
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const raw of text.split('\n')) {
|
|
68
|
+
const line = raw.trim();
|
|
69
|
+
if (!line || line.startsWith('#')) continue;
|
|
70
|
+
const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
71
|
+
if (!m) continue;
|
|
72
|
+
let v = m[2];
|
|
73
|
+
if (
|
|
74
|
+
v.length >= 2
|
|
75
|
+
&& (v[0] === '"' || v[0] === "'")
|
|
76
|
+
&& v[v.length - 1] === v[0]
|
|
77
|
+
) {
|
|
78
|
+
v = v.slice(1, -1);
|
|
79
|
+
}
|
|
80
|
+
if (v.startsWith('${') && v.endsWith('}')) continue;
|
|
81
|
+
out[m[1]] = v;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
} catch (_err) {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// One-level-deep merge sufficient for the `mcpServers.NAME` shape. Nested
|
|
90
|
+
// objects under matching keys are themselves merged shallowly; arrays +
|
|
91
|
+
// primitives are replaced.
|
|
92
|
+
function mergeJson(base, addition) {
|
|
93
|
+
const out = { ...base };
|
|
94
|
+
for (const [k, v] of Object.entries(addition)) {
|
|
95
|
+
if (
|
|
96
|
+
v && typeof v === 'object' && !Array.isArray(v)
|
|
97
|
+
&& out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])
|
|
98
|
+
) {
|
|
99
|
+
out[k] = { ...out[k], ...v };
|
|
100
|
+
} else {
|
|
101
|
+
out[k] = v;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Append a TOML block to existing file content with one blank-line separator
|
|
108
|
+
// (or none if the file is empty). Codex's TOML parser accepts tables in any
|
|
109
|
+
// order so appending is the safe operation; we don't try to surgically
|
|
110
|
+
// rewrite mid-file.
|
|
111
|
+
function appendTomlBlock(existing, block) {
|
|
112
|
+
const trailing = existing.endsWith('\n') ? '' : '\n';
|
|
113
|
+
const sep = existing.length === 0 ? '' : trailing + '\n';
|
|
114
|
+
const blockTail = block.endsWith('\n') ? '' : '\n';
|
|
115
|
+
return existing + sep + block + blockTail;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Detect whether a JSON-shape object already has a Mnestra entry under
|
|
119
|
+
// `mcpServersKey`. Tolerant of the key being absent or non-object.
|
|
120
|
+
function jsonAlreadyHasMnestra(parsedConfig, mcpServersKey) {
|
|
121
|
+
const bag = parsedConfig && parsedConfig[mcpServersKey];
|
|
122
|
+
return !!(bag && typeof bag === 'object' && !Array.isArray(bag) && bag.mnestra);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Idempotent. Returns one of:
|
|
126
|
+
// { skipped: true, reason: '...' } — adapter omits or malforms mcpConfig
|
|
127
|
+
// { unchanged: true, path } — block already present
|
|
128
|
+
// { wrote: true, path, bytes } — block written / appended
|
|
129
|
+
//
|
|
130
|
+
// opts.secretsPath overrides the default ~/.termdeck/secrets.env (used by
|
|
131
|
+
// tests); opts.secrets passes a pre-parsed object directly (also tests).
|
|
132
|
+
function ensureMnestraBlock(adapter, opts = {}) {
|
|
133
|
+
if (!adapter || !adapter.mcpConfig) {
|
|
134
|
+
return { skipped: true, reason: 'no-mcpConfig' };
|
|
135
|
+
}
|
|
136
|
+
const cfg = adapter.mcpConfig;
|
|
137
|
+
if (typeof cfg.path !== 'string') {
|
|
138
|
+
return { skipped: true, reason: 'malformed-mcpConfig' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const useMerge = typeof cfg.merge === 'function';
|
|
142
|
+
const useJsonRecord = !useMerge
|
|
143
|
+
&& cfg.format === 'json'
|
|
144
|
+
&& typeof cfg.mcpServersKey === 'string'
|
|
145
|
+
&& typeof cfg.mnestraBlock === 'function';
|
|
146
|
+
const useTomlAppend = !useMerge && !useJsonRecord
|
|
147
|
+
&& cfg.format === 'toml'
|
|
148
|
+
&& typeof cfg.mnestraBlock === 'function'
|
|
149
|
+
&& typeof cfg.detectExisting === 'function';
|
|
150
|
+
const useJsonAppend = !useMerge && !useJsonRecord && !useTomlAppend
|
|
151
|
+
&& cfg.format === 'json'
|
|
152
|
+
&& typeof cfg.mnestraBlock === 'function'
|
|
153
|
+
&& typeof cfg.detectExisting === 'function';
|
|
154
|
+
|
|
155
|
+
if (!useMerge && !useJsonRecord && !useTomlAppend && !useJsonAppend) {
|
|
156
|
+
return { skipped: true, reason: 'malformed-mcpConfig' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const target = expandTilde(cfg.path);
|
|
160
|
+
const dir = path.dirname(target);
|
|
161
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
|
|
163
|
+
let existing = '';
|
|
164
|
+
try { existing = fs.readFileSync(target, 'utf8'); } catch (_) { existing = ''; }
|
|
165
|
+
|
|
166
|
+
const secrets = opts.secrets || readSecrets(opts.secretsPath || SECRETS_PATH);
|
|
167
|
+
|
|
168
|
+
// 1. Escape-hatch — adapter owns the merge entirely.
|
|
169
|
+
if (useMerge) {
|
|
170
|
+
let result;
|
|
171
|
+
try { result = cfg.merge(existing, { secrets, adapter }); }
|
|
172
|
+
catch (e) { return { skipped: true, reason: `merge-threw-${e.message}` }; }
|
|
173
|
+
if (!result || typeof result !== 'object') {
|
|
174
|
+
return { skipped: true, reason: 'merge-bad-return' };
|
|
175
|
+
}
|
|
176
|
+
if (!result.changed) return { unchanged: true, path: target };
|
|
177
|
+
if (typeof result.output !== 'string') {
|
|
178
|
+
return { skipped: true, reason: 'merge-output-not-string' };
|
|
179
|
+
}
|
|
180
|
+
fs.writeFileSync(target, result.output, { mode: 0o600 });
|
|
181
|
+
return { wrote: true, path: target, bytes: Buffer.byteLength(result.output) };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 2. JSON record-merge (Gemini shape).
|
|
185
|
+
if (useJsonRecord) {
|
|
186
|
+
let parsed = {};
|
|
187
|
+
if (existing.trim() !== '') {
|
|
188
|
+
try { parsed = JSON.parse(existing); }
|
|
189
|
+
catch (_) { return { skipped: true, reason: 'existing-json-malformed' }; }
|
|
190
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {};
|
|
191
|
+
}
|
|
192
|
+
if (jsonAlreadyHasMnestra(parsed, cfg.mcpServersKey)) {
|
|
193
|
+
return { unchanged: true, path: target };
|
|
194
|
+
}
|
|
195
|
+
let block;
|
|
196
|
+
try { block = cfg.mnestraBlock({ secrets, adapter }); }
|
|
197
|
+
catch (e) { return { skipped: true, reason: `mnestraBlock-threw-${e.message}` }; }
|
|
198
|
+
if (!block || typeof block !== 'object' || Array.isArray(block)) {
|
|
199
|
+
return { skipped: true, reason: 'mnestraBlock-not-object' };
|
|
200
|
+
}
|
|
201
|
+
const next = { ...parsed };
|
|
202
|
+
const bag = (next[cfg.mcpServersKey] && typeof next[cfg.mcpServersKey] === 'object'
|
|
203
|
+
&& !Array.isArray(next[cfg.mcpServersKey]))
|
|
204
|
+
? { ...next[cfg.mcpServersKey] }
|
|
205
|
+
: {};
|
|
206
|
+
Object.assign(bag, block);
|
|
207
|
+
next[cfg.mcpServersKey] = bag;
|
|
208
|
+
const serialized = JSON.stringify(next, null, 2) + '\n';
|
|
209
|
+
fs.writeFileSync(target, serialized, { mode: 0o600 });
|
|
210
|
+
return { wrote: true, path: target, bytes: Buffer.byteLength(serialized) };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 3 & 4. detectExisting + mnestraBlock-string paths (TOML or JSON-append).
|
|
214
|
+
if (cfg.detectExisting(existing)) {
|
|
215
|
+
return { unchanged: true, path: target };
|
|
216
|
+
}
|
|
217
|
+
let block;
|
|
218
|
+
try { block = cfg.mnestraBlock({ secrets, adapter }); }
|
|
219
|
+
catch (e) { return { skipped: true, reason: `mnestraBlock-threw-${e.message}` }; }
|
|
220
|
+
if (typeof block !== 'string') {
|
|
221
|
+
return { skipped: true, reason: 'mnestraBlock-not-string' };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let next;
|
|
225
|
+
if (useTomlAppend) {
|
|
226
|
+
next = appendTomlBlock(existing, block);
|
|
227
|
+
} else {
|
|
228
|
+
// useJsonAppend — original brief shape: mnestraBlock returns JSON text,
|
|
229
|
+
// helper deep-merges. Used by adapters that prefer to control the
|
|
230
|
+
// serialization but don't want the escape-hatch's full responsibility.
|
|
231
|
+
let parsed = {};
|
|
232
|
+
if (existing.trim() !== '') {
|
|
233
|
+
try { parsed = JSON.parse(existing); }
|
|
234
|
+
catch (_) { return { skipped: true, reason: 'existing-json-malformed' }; }
|
|
235
|
+
}
|
|
236
|
+
let blockObj;
|
|
237
|
+
try { blockObj = JSON.parse(block); }
|
|
238
|
+
catch (_) { return { skipped: true, reason: 'mnestraBlock-not-parseable-json' }; }
|
|
239
|
+
const merged = mergeJson(parsed, blockObj);
|
|
240
|
+
next = JSON.stringify(merged, null, 2) + '\n';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
fs.writeFileSync(target, next, { mode: 0o600 });
|
|
244
|
+
return { wrote: true, path: target, bytes: Buffer.byteLength(next) };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
module.exports = {
|
|
248
|
+
ensureMnestraBlock,
|
|
249
|
+
readSecrets,
|
|
250
|
+
expandTilde,
|
|
251
|
+
// Internals exposed for unit tests; not part of the public API.
|
|
252
|
+
_internals: { mergeJson, appendTomlBlock, jsonAlreadyHasMnestra, SECRETS_PATH },
|
|
253
|
+
};
|