@jhizzard/termdeck 0.16.1 → 0.17.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jhizzard/termdeck",
3
- "version": "0.16.1",
3
+ "version": "0.17.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"
@@ -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 = { createServer, loadConfig };
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
+ };