slidemind 0.1.4 → 0.1.6

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.
@@ -28,6 +28,26 @@ function listTimeoutMs() {
28
28
  );
29
29
  }
30
30
 
31
+ // Engine ops (list / search / download / extract) are RELAYED through the task
32
+ // pane: the server waits up to ~25s for a suspended pane to reconnect, then up
33
+ // to 60s for the pane's ack. Budget above that sum so a typed pane error
34
+ // (pane_suspended / pane_timeout) wins over a client timeout. Note `ls engines`
35
+ // is pane-relayed too — using the 8s list timeout there masked the real
36
+ // pane_suspended as a bare "timed out after 8000ms".
37
+ function engineTimeoutMs() {
38
+ return (
39
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
40
+ 90000
41
+ );
42
+ }
43
+
44
+ function readinessTimeoutMs() {
45
+ return (
46
+ parseInt(process.env.SLIDEMIND_BRIDGE_READY_TIMEOUT_MS || "20000", 10) ||
47
+ 20000
48
+ );
49
+ }
50
+
31
51
  const NOT_PAIRED_MESSAGE =
32
52
  'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
33
53
  "and run the shown `slidemind pair …` command, then retry.";
@@ -238,6 +258,69 @@ async function listPresentationsForBackend(backendURL) {
238
258
  });
239
259
  }
240
260
 
261
+ async function readinessForBackend(backendURL) {
262
+ const env = deriveEnvLabel(backendURL);
263
+ try {
264
+ const res = await bridgeFetch(
265
+ "/api/cli/readiness",
266
+ { timeoutMs: listTimeoutMs() },
267
+ backendURL,
268
+ );
269
+ const data = (await res.json()) ?? {};
270
+ const presentations = Array.isArray(data.presentations)
271
+ ? data.presentations.map((p) => ({ ...p, backendURL, env }))
272
+ : [];
273
+ return {
274
+ env,
275
+ backendURL,
276
+ ok: true,
277
+ readinessV1: data.readinessV1 === true,
278
+ capabilities: data.capabilities ?? null,
279
+ presentations,
280
+ summary: data.summary ?? null,
281
+ };
282
+ } catch (err) {
283
+ if (err?.status !== 404) throw err;
284
+ // Older add-ins do not serve /readiness. Fall back to the legacy
285
+ // presentations route and mark the result as a compatibility response.
286
+ const presentations = await listPresentationsForBackend(backendURL);
287
+ return {
288
+ env,
289
+ backendURL,
290
+ ok: true,
291
+ readinessV1: false,
292
+ capabilities: null,
293
+ presentations,
294
+ summary: null,
295
+ };
296
+ }
297
+ }
298
+
299
+ export async function connectionStatus() {
300
+ const sessions = sessionsInScope();
301
+ const settled = await Promise.allSettled(
302
+ sessions.map((s) => readinessForBackend(s.backendURL)),
303
+ );
304
+ const backends = settled.map((r, i) => {
305
+ const backendURL = sessions[i].backendURL;
306
+ const env = deriveEnvLabel(backendURL);
307
+ if (r.status === "fulfilled") return r.value;
308
+ return {
309
+ env,
310
+ backendURL,
311
+ ok: false,
312
+ error: r.reason?.message ? r.reason.message : String(r.reason),
313
+ presentations: [],
314
+ };
315
+ });
316
+ return {
317
+ paired: sessions.length > 0,
318
+ backendCount: sessions.length,
319
+ backends,
320
+ presentations: backends.flatMap((b) => b.presentations ?? []),
321
+ };
322
+ }
323
+
241
324
  // Fan out across every backend in scope, concurrently. Never rejects: each
242
325
  // backend's outcome is partitioned into merged presentations vs. a failure
243
326
  // record, so one unreachable environment can't hide the others.
@@ -339,6 +422,93 @@ export async function requireHost() {
339
422
  );
340
423
  }
341
424
 
425
+ function hasReadinessFields(p) {
426
+ return (
427
+ p &&
428
+ (typeof p.connectionState === "string" ||
429
+ typeof p.designState === "string" ||
430
+ (p.capabilities && typeof p.capabilities === "object"))
431
+ );
432
+ }
433
+
434
+ export function isRenderReadyPresentation(p) {
435
+ if (!p) return false;
436
+ if (!hasReadinessFields(p)) return true;
437
+ if (p.capabilities?.render === true) return true;
438
+ if (p.designState === "ready" && p.capabilities?.render !== false) return true;
439
+ return false;
440
+ }
441
+
442
+ export function isPaneReadyPresentation(p) {
443
+ if (!p) return false;
444
+ if (!hasReadinessFields(p)) return p.connected !== false && !p.suspendedAt;
445
+ if (p.capabilities?.paneOps === true) return true;
446
+ return (
447
+ p.connectionState === "connected" &&
448
+ p.connected !== false &&
449
+ !p.suspendedAt &&
450
+ p.connectionState !== "stale"
451
+ );
452
+ }
453
+
454
+ async function waitForReadyPresentation(predicate, { timeoutMs = null } = {}) {
455
+ const waitMs = timeoutMs ?? readinessTimeoutMs();
456
+ const pollMs = envInt("SLIDEMIND_BRIDGE_READY_POLL_MS", 1500);
457
+ const deadline = Date.now() + waitMs;
458
+ for (;;) {
459
+ const { presentations, unreachable } = await listOpenPresentationsDetailed();
460
+ if (presentations.some(predicate)) return presentations;
461
+ // Do not wait when nothing is registered at all. Waiting helps only for an
462
+ // already-open pane that is still hydrating brand/readiness metadata.
463
+ if (presentations.length === 0 || Date.now() >= deadline) {
464
+ return { presentations, unreachable, timedOut: presentations.length > 0 };
465
+ }
466
+ await sleep(pollMs);
467
+ }
468
+ }
469
+
470
+ export async function requireRenderHost({ timeoutMs = null } = {}) {
471
+ const first = await listOpenPresentationsDetailed();
472
+ if (first.presentations.some(isRenderReadyPresentation)) {
473
+ return first.presentations;
474
+ }
475
+ if (first.presentations.length > 0) {
476
+ const waited = await waitForReadyPresentation(isRenderReadyPresentation, {
477
+ timeoutMs,
478
+ });
479
+ if (Array.isArray(waited)) return waited;
480
+ if (waited.presentations.some(isRenderReadyPresentation)) {
481
+ return waited.presentations;
482
+ }
483
+ throw new Error(
484
+ "PowerPoint is registered with the slidemind add-in, but no window is render-ready yet. " +
485
+ "Wait for the corporate design to finish matching, or pick/create a corporate design in the add-in, then retry.",
486
+ );
487
+ }
488
+ return requireHost();
489
+ }
490
+
491
+ export async function requirePaneHost({ timeoutMs = null } = {}) {
492
+ const first = await listOpenPresentationsDetailed();
493
+ if (first.presentations.some(isPaneReadyPresentation)) {
494
+ return first.presentations;
495
+ }
496
+ if (first.presentations.length > 0) {
497
+ const waited = await waitForReadyPresentation(isPaneReadyPresentation, {
498
+ timeoutMs,
499
+ });
500
+ if (Array.isArray(waited)) return waited;
501
+ if (waited.presentations.some(isPaneReadyPresentation)) {
502
+ return waited.presentations;
503
+ }
504
+ throw new Error(
505
+ "PowerPoint is registered with the slidemind add-in, but the task pane is not ready for pane operations. " +
506
+ "Bring PowerPoint forward and click or reopen the slidemind pane, then retry.",
507
+ );
508
+ }
509
+ return requireHost();
510
+ }
511
+
342
512
  // Tie-break a multi-backend presId collision without bothering the user when
343
513
  // only one candidate can actually serve the deck:
344
514
  // (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
@@ -348,7 +518,7 @@ export async function requireHost() {
348
518
  // e.g. a local dev backend connected without a CD next to prod with one.
349
519
  // Returns the winning entry or null when genuinely ambiguous.
350
520
  function tieBreakMatches(matches, preferBrandId) {
351
- const live = matches.filter((m) => m.connected !== false && !m.suspendedAt);
521
+ const live = matches.filter(isPaneReadyPresentation);
352
522
  if (preferBrandId) {
353
523
  const want = live.filter((m) => m.brandId === preferBrandId);
354
524
  if (want.length === 1) return want[0];
@@ -420,7 +590,7 @@ export async function waitForPaneReady(
420
590
  /* transient list failure — poll again until the deadline */
421
591
  }
422
592
  const connected = list.filter(
423
- (p) => p.connected !== false && !p.suspendedAt,
593
+ (p) => isPaneReadyPresentation(p),
424
594
  );
425
595
  const same = connected.find((p) => p.presId === presId);
426
596
  if (same) return same;
@@ -483,7 +653,9 @@ async function listEnginesForBackend(backendURL, { type = null, subtype = null }
483
653
  const qs = params.toString();
484
654
  const res = await bridgeFetch(
485
655
  `/api/cli/engines${qs ? `?${qs}` : ""}`,
486
- { timeoutMs: listTimeoutMs() },
656
+ // Pane-relayed on the add-in side — needs the engine budget, not the 8s
657
+ // list timeout (which bailed before the pane could ever answer).
658
+ { timeoutMs: engineTimeoutMs() },
487
659
  backendURL,
488
660
  );
489
661
  const data = await res.json();
@@ -837,6 +1009,11 @@ export async function engineSearch({
837
1009
  intent = null,
838
1010
  top = null,
839
1011
  backendURL = null,
1012
+ // When the chosen pane is suspended, wait CLIENT-side for THIS window to wake
1013
+ // (the single-window default). engineSearchAnyPane sets this false for all but
1014
+ // the last candidate so it can fail over to another live window instead.
1015
+ waitOnSuspend = true,
1016
+ timeoutMs = null,
840
1017
  }) {
841
1018
  if (!presId) throw new Error("engineSearch: presId is required");
842
1019
  if (!engineId) throw new Error("engineSearch: engineId is required");
@@ -846,24 +1023,96 @@ export async function engineSearch({
846
1023
  if (subtype) body.subtype = subtype;
847
1024
  if (intent) body.intent = intent;
848
1025
  if (typeof top === "number") body.top = top;
849
- return engineOpWithPaneWait(presId, be, async (pid, b) => {
1026
+ const run = async (pid, b) => {
850
1027
  const res = await bridgeFetch(
851
1028
  `/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
852
1029
  {
853
1030
  method: "POST",
854
1031
  headers: { "content-type": "application/json" },
855
1032
  body: JSON.stringify(body),
856
- // Engine ops relay through the pane: the server may wait up to ~25s for
857
- // a suspended pane to reconnect, then up to 60s for the pane's ack —
858
- // stay above that sum so typed errors win over client timeouts.
859
- timeoutMs:
860
- parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
861
- 90000,
1033
+ timeoutMs: timeoutMs ?? engineTimeoutMs(),
862
1034
  },
863
1035
  b,
864
1036
  );
865
1037
  return res.json(); // { ok, searchId, engineId, subtype, intent, resultCount, gridDataUri?, results }
866
- });
1038
+ };
1039
+ return waitOnSuspend ? engineOpWithPaneWait(presId, be, run) : run(presId, be);
1040
+ }
1041
+
1042
+ // A suspended/throttled background webview must not strand a window-AGNOSTIC
1043
+ // engine search: the catalog is per-user, so any connected pane can answer it.
1044
+ // Try the picked pane first, then every other connected pane on the same
1045
+ // backend (non-suspended first), failing fast past each unresponsive one; the
1046
+ // LAST candidate keeps the normal "click the pane to wake it" wait so a genuine
1047
+ // single-window/all-asleep case still surfaces the actionable prompt. Returns
1048
+ // the search result tagged with the presId/backendURL that actually answered —
1049
+ // the searchId is bound to THAT window, so callers must thread it into
1050
+ // follow-up view/insert/add-slide. An explicit presId pins the window (no
1051
+ // failover — the caller asked for that specific one).
1052
+ export async function engineSearchAnyPane({
1053
+ registry,
1054
+ explicitPresId = null,
1055
+ engineId,
1056
+ query,
1057
+ subtype = null,
1058
+ intent = null,
1059
+ top = null,
1060
+ }) {
1061
+ const primary = pickAnyPresentationEntry(registry, explicitPresId);
1062
+ const primaryBackend = primary.backendURL ?? null;
1063
+ let candidates;
1064
+ if (explicitPresId) {
1065
+ candidates = [primary];
1066
+ } else {
1067
+ const others = (Array.isArray(registry) ? registry : [])
1068
+ .filter(
1069
+ (p) =>
1070
+ p.presId !== primary.presId &&
1071
+ p.connected !== false &&
1072
+ (p.backendURL ?? null) === primaryBackend,
1073
+ )
1074
+ .sort((a, b) => (a.suspendedAt ? 1 : 0) - (b.suspendedAt ? 1 : 0));
1075
+ candidates = [primary, ...others];
1076
+ }
1077
+ const failoverTimeout = envInt(
1078
+ "SLIDEMIND_BRIDGE_ENGINE_FAILOVER_TIMEOUT_MS",
1079
+ 25000,
1080
+ );
1081
+ let lastErr = null;
1082
+ for (let i = 0; i < candidates.length; i++) {
1083
+ const cand = candidates[i];
1084
+ const isLast = i === candidates.length - 1;
1085
+ try {
1086
+ const res = await engineSearch({
1087
+ presId: cand.presId,
1088
+ engineId,
1089
+ query,
1090
+ subtype,
1091
+ intent,
1092
+ top,
1093
+ backendURL: cand.backendURL ?? primaryBackend,
1094
+ waitOnSuspend: isLast,
1095
+ timeoutMs: isLast ? null : failoverTimeout,
1096
+ });
1097
+ return {
1098
+ ...res,
1099
+ presId: cand.presId,
1100
+ backendURL: cand.backendURL ?? primaryBackend,
1101
+ };
1102
+ } catch (err) {
1103
+ lastErr = err;
1104
+ // A suspended/frozen/slow pane is recoverable by trying the next window;
1105
+ // anything else (bad engineId, auth, 4xx) is real — surface it now.
1106
+ const recoverable =
1107
+ err?.code === "pane_suspended" ||
1108
+ err?.code === "pane_timeout" ||
1109
+ err?.code === "bridge_timeout" ||
1110
+ err?.status === 503 ||
1111
+ err?.status === 504;
1112
+ if (isLast || !recoverable) throw err;
1113
+ }
1114
+ }
1115
+ throw lastErr ?? new Error("engineSearchAnyPane: no candidate panes");
867
1116
  }
868
1117
 
869
1118
  // Fetch a chosen engine result's bytes through the local bridge. For image
@@ -1016,6 +1265,46 @@ export async function viewSlide({
1016
1265
  });
1017
1266
  }
1018
1267
 
1268
+ // Read the CURRENT content of an open presentation (the "pull content" path
1269
+ // behind `slidemind read` / slidemind_read_presentation). `mode` selects the
1270
+ // depth: "summary" returns a per-slide outline (index, layout, title, subtitle);
1271
+ // "raw" (default) returns each slide's full plain text — NOT the add-in's editing
1272
+ // markup. Unlike engine ops, content is WINDOW-BOUND: it reads THAT presentation,
1273
+ // so we resolve the origin backend from the presId and relay through that
1274
+ // window's pane. It's an idempotent read, so engineOpWithPaneWait's single
1275
+ // pane_suspended client-wait + retry against the live entry is safe (like viewSlide).
1276
+ export async function getPresentationContent({
1277
+ presId,
1278
+ mode = "raw",
1279
+ backendURL = null,
1280
+ }) {
1281
+ if (!presId) throw new Error("getPresentationContent: presId is required");
1282
+ const be = backendURL || (await resolveBackendForPres(presId));
1283
+ const body = { mode: mode === "summary" ? "summary" : "raw" };
1284
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
1285
+ const res = await bridgeFetch(
1286
+ `/api/cli/presentations/${encodeURIComponent(pid)}/content`,
1287
+ {
1288
+ method: "POST",
1289
+ headers: { "content-type": "application/json" },
1290
+ body: JSON.stringify(body),
1291
+ timeoutMs: engineTimeoutMs(),
1292
+ },
1293
+ b,
1294
+ );
1295
+ return res.json(); // { ok, mode, slideCount, slides }
1296
+ });
1297
+ }
1298
+
1299
+ function ensurePaneAckOk(data, operation) {
1300
+ if (!data || data.ok !== false) return data ?? {};
1301
+ const detail = data.error || `${operation} failed`;
1302
+ const err = new Error(detail);
1303
+ err.code = data.code || `${operation}_failed`;
1304
+ err.body = data;
1305
+ throw err;
1306
+ }
1307
+
1019
1308
  export async function insertSlides({
1020
1309
  presId,
1021
1310
  base64,
@@ -1040,7 +1329,7 @@ export async function insertSlides({
1040
1329
  },
1041
1330
  be,
1042
1331
  );
1043
- return res.json().catch(() => ({}));
1332
+ return ensurePaneAckOk(await res.json().catch(() => ({})), "insert");
1044
1333
  }
1045
1334
 
1046
1335
  export async function replaceSlides({
@@ -1064,7 +1353,7 @@ export async function replaceSlides({
1064
1353
  },
1065
1354
  be,
1066
1355
  );
1067
- return res.json().catch(() => ({}));
1356
+ return ensurePaneAckOk(await res.json().catch(() => ({})), "replace");
1068
1357
  }
1069
1358
 
1070
1359
  // Pick a target presentation given the (possibly multi-backend) registry and an
@@ -1146,7 +1435,9 @@ export function pickAnyPresentationEntry(registry, explicitPresId, opts = {}) {
1146
1435
  );
1147
1436
  }
1148
1437
  return (
1149
- registry.find((p) => p.connected !== false && !p.suspendedAt) ?? registry[0]
1438
+ registry.find(isPaneReadyPresentation) ??
1439
+ registry.find((p) => p.connected !== false && !p.suspendedAt) ??
1440
+ registry[0]
1150
1441
  );
1151
1442
  }
1152
1443
 
@@ -23,6 +23,7 @@
23
23
 
24
24
  import fs from "node:fs";
25
25
  import path from "node:path";
26
+ import { spawnSync } from "node:child_process";
26
27
  import {
27
28
  expandHome,
28
29
  installDir,
@@ -40,7 +41,10 @@ const TOML_MARK_BEGIN = "# slidemind:begin";
40
41
  const TOML_MARK_END = "# slidemind:end";
41
42
 
42
43
  function codexRoot() {
43
- return expandHome("~/.codex");
44
+ // Codex reads everything (skills, AGENTS.md, config.toml) from $CODEX_HOME
45
+ // when set, defaulting to ~/.codex — mirror that so our artifacts land where
46
+ // it actually looks.
47
+ return process.env.CODEX_HOME || expandHome("~/.codex");
44
48
  }
45
49
 
46
50
  function promptsDir() {
@@ -81,38 +85,102 @@ function configTomlFile() {
81
85
  return path.join(codexRoot(), "config.toml");
82
86
  }
83
87
 
84
- function writeMcpServerBlock(packageRoot) {
88
+ // Register the slidemind MCP server. Prefer Codex's own `codex mcp add`, which
89
+ // is TOML-aware and idempotent: it sets `[mcp_servers.slidemind]` by key, so a
90
+ // re-install/upgrade — or a pre-existing entry added manually or by an earlier
91
+ // `codex mcp add` — is REPLACED in place rather than duplicated, while unrelated
92
+ // tables, top-level keys, and comments survive.
93
+ //
94
+ // The previous hand-rolled writer wrapped its block in `# slidemind:begin/end`
95
+ // comment markers and deduped by them; a `[mcp_servers.slidemind]` table that
96
+ // lacked those markers (manual edit, or a Codex-side config rewrite that dropped
97
+ // the comments) was invisible to it, so it appended a SECOND table — a
98
+ // duplicate-key TOML that broke config.toml. Delegating to Codex removes that
99
+ // whole failure mode.
100
+ //
101
+ // `codex mcp` landed around Codex 0.128. If the binary is missing or too old
102
+ // (spawn error / non-zero exit) we fall back to a minimal, non-destructive
103
+ // writer that never duplicates.
104
+ export function registerMcpServer(packageRoot) {
85
105
  const mcpServer = path.join(packageRoot, "src", "mcp", "server.mjs");
86
- const block =
87
- `${TOML_MARK_BEGIN}\n` +
88
- `[mcp_servers.slidemind]\n` +
106
+ const viaCli = spawnSync(
107
+ "codex",
108
+ ["mcp", "add", NAMESPACE, "--", "node", mcpServer],
109
+ { stdio: "ignore", shell: process.platform === "win32" },
110
+ );
111
+ if (!viaCli.error && viaCli.status === 0) {
112
+ sweepLegacyMarkers();
113
+ return "registered via codex mcp";
114
+ }
115
+ return fallbackWriteMcpServerBlock(mcpServer);
116
+ }
117
+
118
+ export function unregisterMcpServer() {
119
+ const viaCli = spawnSync(
120
+ "codex",
121
+ ["mcp", "remove", NAMESPACE],
122
+ { stdio: "ignore", shell: process.platform === "win32" },
123
+ );
124
+ if (!viaCli.error && viaCli.status === 0) return "unregistered via codex mcp";
125
+ return fallbackClearMcpServerBlock();
126
+ }
127
+
128
+ function mcpServerBlock(mcpServer) {
129
+ return (
130
+ `[mcp_servers.${NAMESPACE}]\n` +
89
131
  `command = "node"\n` +
90
- `args = ["${mcpServer.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]\n` +
91
- `${TOML_MARK_END}\n`;
132
+ `args = ["${mcpServer.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]\n`
133
+ );
134
+ }
92
135
 
136
+ // Fallback for when `codex mcp` is unavailable. Minimal and non-destructive:
137
+ // never append a second entry — if a `[mcp_servers.slidemind]` already exists
138
+ // (ours or the user's) we leave it untouched; otherwise we append our block.
139
+ export function fallbackWriteMcpServerBlock(mcpServer) {
93
140
  const file = configTomlFile();
94
- let existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
95
- const re = new RegExp(
96
- `${TOML_MARK_BEGIN}[\\s\\S]*?${TOML_MARK_END}\\n?`,
97
- "g",
98
- );
99
- if (re.test(existing)) {
100
- existing = existing.replace(re, "");
141
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
142
+ if (existing.includes(`[mcp_servers.${NAMESPACE}]`)) {
143
+ return "already registered (left as-is)";
101
144
  }
102
- const sep = existing.length && !existing.endsWith("\n") ? "\n\n" : existing.length ? "\n" : "";
103
- fs.writeFileSync(file, existing + sep + block);
145
+ const sep =
146
+ existing.length && !existing.endsWith("\n") ? "\n\n" : existing.length ? "\n" : "";
147
+ fs.mkdirSync(path.dirname(file), { recursive: true });
148
+ fs.writeFileSync(file, existing + sep + mcpServerBlock(mcpServer));
149
+ return "registered (fallback)";
104
150
  }
105
151
 
106
- function clearMcpServerBlock() {
152
+ // Fallback uninstall: strip a legacy marker-wrapped block if one is present;
153
+ // otherwise point the user at `codex mcp remove` (we won't guess at the bounds
154
+ // of a marker-less table without Codex's TOML parser).
155
+ export function fallbackClearMcpServerBlock() {
156
+ const file = configTomlFile();
157
+ if (!fs.existsSync(file)) return "unregistered";
158
+ const existing = fs.readFileSync(file, "utf8");
159
+ const re = new RegExp(`${TOML_MARK_BEGIN}[\\s\\S]*?${TOML_MARK_END}\\n?`, "g");
160
+ const stripped = existing.replace(re, "");
161
+ if (stripped !== existing) {
162
+ fs.writeFileSync(file, stripped);
163
+ return "unregistered (fallback)";
164
+ }
165
+ return "run `codex mcp remove slidemind` to remove the entry";
166
+ }
167
+
168
+ // After Codex rewrites the `[mcp_servers.slidemind]` table, an upgrader's old
169
+ // slidemind block can leave orphan `# slidemind:begin` / `# slidemind:end`
170
+ // comment lines behind. Drop just those literal lines (valid TOML either way —
171
+ // purely cosmetic).
172
+ function sweepLegacyMarkers() {
107
173
  const file = configTomlFile();
108
174
  if (!fs.existsSync(file)) return;
109
- let existing = fs.readFileSync(file, "utf8");
110
- const re = new RegExp(
111
- `${TOML_MARK_BEGIN}[\\s\\S]*?${TOML_MARK_END}\\n?`,
112
- "g",
113
- );
114
- existing = existing.replace(re, "");
115
- fs.writeFileSync(file, existing);
175
+ const before = fs.readFileSync(file, "utf8");
176
+ const after = before
177
+ .split("\n")
178
+ .filter(
179
+ (line) =>
180
+ line.trim() !== TOML_MARK_BEGIN && line.trim() !== TOML_MARK_END,
181
+ )
182
+ .join("\n");
183
+ if (after !== before) fs.writeFileSync(file, after);
116
184
  }
117
185
 
118
186
  function writeAgentsBlock() {
@@ -194,8 +262,8 @@ export async function install({ packageRoot, flags }) {
194
262
 
195
263
  // Register the slidemind MCP server so agents can list/push into open
196
264
  // PowerPoint windows via slidemind_list_open_presentations / slidemind_push_deck.
197
- writeMcpServerBlock(packageRoot);
198
- ui.step({ status: "ok", label: "mcp server", value: configTomlFile() });
265
+ const mcpNote = registerMcpServer(packageRoot);
266
+ ui.step({ status: "ok", label: "mcp server", value: mcpNote });
199
267
 
200
268
  ui.note("restart Codex to pick up the skill + MCP server");
201
269
  }
@@ -224,6 +292,6 @@ export async function uninstall() {
224
292
  }
225
293
  clearAgentsBlock();
226
294
  ui.step({ status: "remove", label: "AGENTS.md", value: agentsFile() });
227
- clearMcpServerBlock();
228
- ui.step({ status: "info", label: "mcp server", value: "unregistered" });
295
+ const mcpNote = unregisterMcpServer();
296
+ ui.step({ status: "info", label: "mcp server", value: mcpNote });
229
297
  }