slidemind 0.1.5 → 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.
@@ -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
  }
@@ -10,10 +10,20 @@
10
10
  // Lists PowerPoint windows registered with the local bridge, including
11
11
  // retained entries whose task-pane socket is temporarily disconnected.
12
12
  //
13
+ // slidemind_read_presentation
14
+ // Pulls the current content of an open PowerPoint window — a per-slide
15
+ // outline (summary) or each slide's full plain text (raw) — so the agent can
16
+ // inspect the live deck before editing it.
17
+ //
13
18
  // slidemind_push_deck
14
19
  // Renders a deck server-side via the add-in and pushes its slides into one
15
20
  // open PowerPoint window.
16
21
  //
22
+ // slidemind_view_draft
23
+ // Renders the deck's authored slides to per-slide preview images locally and
24
+ // returns them WITHOUT pushing — the agent's "look at my own draft" check
25
+ // before push / the human review gate.
26
+ //
17
27
  // slidemind_list_engines
18
28
  // Lists the live engine catalog (image + slide) for the active corporate
19
29
  // design, filterable by type/subtype.
@@ -151,6 +161,9 @@ const server = new McpServer(
151
161
  "with an active corporate design is required, and all brand rules come from that " +
152
162
  "design's bundle (design.md), never hardcoded here. " +
153
163
  "Always call slidemind_list_open_presentations first to learn which presentation_id to target. " +
164
+ "If connection/readiness is unclear, call slidemind_connection_status before asking the user for help. " +
165
+ "To inspect what's already in a window before editing, call slidemind_read_presentation (summary outline or raw plain text). " +
166
+ "After authoring or editing slides, call slidemind_view_draft to LOOK at your own draft (per-slide images, nothing pushed) and self-correct before slidemind_push_deck. " +
154
167
  "The CLI may be paired with several add-in environments (local/dev/prod) at once; the list spans " +
155
168
  "all of them and each entry is tagged with its env, and routing to the right backend is automatic — " +
156
169
  "just pass the presentation_id back. " +
@@ -183,6 +196,85 @@ server.registerTool(
183
196
  },
184
197
  );
185
198
 
199
+ server.registerTool(
200
+ "slidemind_connection_status",
201
+ {
202
+ title: "Diagnose CLI/add-in connection readiness",
203
+ description:
204
+ "Return paired backend reachability and per-window readiness. Use this when a slidemind tool fails or before asking the user for manual action.",
205
+ inputSchema: {},
206
+ },
207
+ async () => {
208
+ try {
209
+ const client = await loadBridgeClient();
210
+ const status = await client.connectionStatus();
211
+ return textResult({
212
+ paired: status.paired,
213
+ backendCount: status.backendCount,
214
+ backends: (status.backends ?? []).map((b) => ({
215
+ env: b.env,
216
+ ok: b.ok,
217
+ readinessV1: b.readinessV1 === true,
218
+ capabilities: b.capabilities ?? null,
219
+ error: b.error,
220
+ summary: b.summary ?? null,
221
+ presentations: (b.presentations ?? []).map(client.toPublicPresentation),
222
+ })),
223
+ presentations: (status.presentations ?? []).map(client.toPublicPresentation),
224
+ });
225
+ } catch (err) {
226
+ return errorResult(err);
227
+ }
228
+ },
229
+ );
230
+
231
+ server.registerTool(
232
+ "slidemind_read_presentation",
233
+ {
234
+ title: "Read the current content of an open presentation",
235
+ description:
236
+ "Pull the CURRENT content of an open PowerPoint window so you can inspect the live deck before editing it. " +
237
+ "mode='summary' returns a per-slide outline (index, layout, title, subtitle); mode='raw' (default) returns each slide's full plain text. " +
238
+ "This is plain text content, NOT editing markup. " +
239
+ "presentation_id is the presId from slidemind_list_open_presentations; it's optional when exactly one window is open, but required to disambiguate when several are open.",
240
+ inputSchema: {
241
+ presentation_id: z
242
+ .string()
243
+ .nullable()
244
+ .optional()
245
+ .describe("presId from slidemind_list_open_presentations. Optional when only one window is open; required to pick one when several are."),
246
+ mode: z
247
+ .enum(["summary", "raw"])
248
+ .nullable()
249
+ .optional()
250
+ .describe("'summary' = per-slide outline; 'raw' (default) = each slide's full plain text."),
251
+ },
252
+ },
253
+ async ({ presentation_id, mode }) => {
254
+ try {
255
+ const client = await loadBridgeClient();
256
+ const registry = await client.requirePaneHost();
257
+ const entry = client.pickPresentationEntry(registry, presentation_id ?? null);
258
+ const res = await client.getPresentationContent({
259
+ presId: entry.presId,
260
+ mode: mode ?? "raw",
261
+ backendURL: entry.backendURL ?? null,
262
+ });
263
+ if (!res?.ok) {
264
+ return errorResult(new Error(res?.error ?? "read presentation failed"));
265
+ }
266
+ return textResult({
267
+ presentation_id: entry.presId,
268
+ mode: res.mode,
269
+ slideCount: res.slideCount,
270
+ slides: res.slides ?? [],
271
+ });
272
+ } catch (err) {
273
+ return errorResult(err);
274
+ }
275
+ },
276
+ );
277
+
186
278
  server.registerTool(
187
279
  "slidemind_push_deck",
188
280
  {
@@ -316,7 +408,7 @@ server.registerTool(
316
408
  const client = await loadBridgeClient();
317
409
  // All engine tools hard-require an open host (the truth lives in the
318
410
  // add-in); surface the actionable "open PowerPoint…" message up front.
319
- const registry = await client.requireHost();
411
+ const registry = await client.requirePaneHost();
320
412
  // The engine is per-user, not window-bound: attach to any connected window
321
413
  // when the caller didn't pin one. The chosen presId is echoed back so the
322
414
  // agent threads it to the follow-up tools (the searchId is bound to it).
@@ -412,6 +504,62 @@ server.registerTool(
412
504
  },
413
505
  );
414
506
 
507
+ server.registerTool(
508
+ "slidemind_view_draft",
509
+ {
510
+ title: "Look at a deck's draft slides (no push)",
511
+ description:
512
+ "Render the deck's authored slides/*.jsx to per-slide preview images and return them to you WITHOUT pushing anything — the 'look at my own draft before the user sees it' tool. " +
513
+ "Use this AFTER authoring/editing slides and BEFORE slidemind_push_deck (and before the human slidemind review gate): look at each screenshot, fix anything visibly wrong in the JSX, then view again until each slide is sound. " +
514
+ "Renders locally against the materialized bundle, so it needs no awake task pane (and no open host at all once the bundle has been downloaded once). " +
515
+ "Returns one image per freeform slide; purpose-built layout slides (covers/dividers) and reused native .pptx slides have no draft preview and report as a status line. Nothing is pushed, nothing is written to the deck.",
516
+ inputSchema: {
517
+ deck_path: z
518
+ .string()
519
+ .describe("Path to the deck folder (must contain slides/). Absolute or relative to CWD."),
520
+ presentation_id: z
521
+ .string()
522
+ .nullable()
523
+ .optional()
524
+ .describe("Optional presId — only used to fetch the corporate-design bundle if it isn't cached yet. Omit when the deck's bundle is already materialized (the normal case after authoring)."),
525
+ },
526
+ },
527
+ async ({ deck_path, presentation_id }) => {
528
+ try {
529
+ const { viewDraft } = await loadBuildAndPush();
530
+ const deckDir = path.resolve(process.cwd(), deck_path);
531
+ const result = await viewDraft(deckDir, {
532
+ presentationId: presentation_id ?? null,
533
+ });
534
+ const images = [];
535
+ const lines = [];
536
+ for (const slide of result.slides ?? []) {
537
+ const image = slide.dataUri ? imageContentFromDataUri(slide.dataUri) : null;
538
+ if (image) {
539
+ images.push(image);
540
+ lines.push(
541
+ `${slide.fileName} — preview attached (image ${images.length})` +
542
+ (slide.status === "degraded" && slide.note ? ` — ${slide.note}` : ""),
543
+ );
544
+ } else {
545
+ lines.push(`${slide.fileName} — ${slide.note ?? slide.status}`);
546
+ }
547
+ }
548
+ const header =
549
+ images.length > 0
550
+ ? `Rendered ${images.length} draft preview image(s) — NOT pushed to PowerPoint, nothing written to the deck. ` +
551
+ `Judge each screenshot with your eyes, fix what is visibly wrong in the slide JSX, view the draft again, ` +
552
+ `then push with slidemind_push_deck (or build-pptx) once each slide is visually sound.`
553
+ : `No draft preview images were produced — NOTHING was pushed.`;
554
+ const warnLines = (result.warnings ?? []).map((w) => ` warning: ${w}`);
555
+ const text = [header, ...lines, ...warnLines].join("\n");
556
+ return { content: [{ type: "text", text }, ...images] };
557
+ } catch (err) {
558
+ return errorResult(err);
559
+ }
560
+ },
561
+ );
562
+
415
563
  server.registerTool(
416
564
  "slidemind_list_engines",
417
565
  {
@@ -479,7 +627,7 @@ server.registerTool(
479
627
  async ({ presentation_id, search_id, selection }) => {
480
628
  try {
481
629
  const client = await loadBridgeClient();
482
- await client.requireHost();
630
+ await client.requirePaneHost();
483
631
  const res = await client.viewSlide({
484
632
  presId: presentation_id,
485
633
  searchId: search_id,
@@ -532,7 +680,7 @@ server.registerTool(
532
680
  async ({ presentation_id, search_id, selections, at }) => {
533
681
  try {
534
682
  const client = await loadBridgeClient();
535
- await client.requireHost();
683
+ await client.requirePaneHost();
536
684
  const res = await client.insertSlideLive({
537
685
  presId: presentation_id,
538
686
  searchId: search_id,
@@ -590,7 +738,7 @@ server.registerTool(
590
738
  async ({ presentation_id, deck_path, search_id, selections, at }) => {
591
739
  try {
592
740
  const client = await loadBridgeClient();
593
- await client.requireHost();
741
+ await client.requirePaneHost();
594
742
  const deckDir = path.resolve(process.cwd(), deck_path);
595
743
  const slidesDir = path.join(deckDir, "slides");
596
744
  fs.mkdirSync(slidesDir, { recursive: true });
@@ -754,7 +902,7 @@ server.registerTool(
754
902
  }
755
903
 
756
904
  const client = await loadBridgeClient();
757
- await client.requireHost();
905
+ await client.requirePaneHost();
758
906
  const deckDir = path.resolve(process.cwd(), deck_path);
759
907
  const assetsDir = path.join(deckDir, "assets");
760
908
  fs.mkdirSync(assetsDir, { recursive: true });