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.
- package/CLAUDE.md +6 -2
- package/README.md +13 -5
- package/bin/slidemind.mjs +323 -13
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +28 -4
- package/skills/search/SKILL.md +6 -3
- package/src/bridge/build-and-push.mjs +195 -19
- package/src/bridge/client.mjs +305 -14
- package/src/installers/codex.mjs +96 -28
- package/src/mcp/server.mjs +165 -11
package/src/bridge/client.mjs
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1438
|
+
registry.find(isPaneReadyPresentation) ??
|
|
1439
|
+
registry.find((p) => p.connected !== false && !p.suspendedAt) ??
|
|
1440
|
+
registry[0]
|
|
1150
1441
|
);
|
|
1151
1442
|
}
|
|
1152
1443
|
|
package/src/installers/codex.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 =
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
198
|
-
ui.step({ status: "ok", label: "mcp server", value:
|
|
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
|
-
|
|
228
|
-
ui.step({ status: "info", label: "mcp server", value:
|
|
295
|
+
const mcpNote = unregisterMcpServer();
|
|
296
|
+
ui.step({ status: "info", label: "mcp server", value: mcpNote });
|
|
229
297
|
}
|