slidemind 0.1.3 → 0.1.5

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/bin/slidemind.mjs CHANGED
@@ -8,6 +8,7 @@
8
8
  // (`install` is a back-compat alias)
9
9
  // slidemind new <name> scaffold ./decks/<name>/{plan.md,slides/}
10
10
  // slidemind build <deck> build self-contained HTML deck
11
+ // slidemind review <deck> spatial preview-review gate (blocks for approve/comments)
11
12
  // slidemind build-pptx <deck> render the deck as native .pptx (via the add-in)
12
13
  // slidemind uninstall --for <target>
13
14
  // slidemind update [--for <target>] [--ref <version>]
@@ -44,12 +45,34 @@ function printHelp() {
44
45
  `Usage:\n` +
45
46
  ` slidemind new <name> Scaffold ./decks/<name>/\n` +
46
47
  ` slidemind build <deck> Build self-contained HTML\n` +
48
+ ` slidemind review <deck> Open a spatial preview review; blocks until the user\n` +
49
+ ` approves or leaves comments (host-free)\n` +
50
+ ` --no-open don't auto-open the browser\n` +
51
+ ` --port <n> bind a fixed port (default: ephemeral)\n` +
47
52
  ` slidemind build-pptx <deck> Export native .pptx via the add-in (requires an open host)\n` +
48
53
  ` --to <presId> target a specific window (required if >1 open)\n` +
49
54
  ` --out <path> output path (default <deck>/<deck>.pptx)\n` +
50
- ` slidemind search "<query>" Search the brand engine for icons/images (requires an open host)\n` +
51
- ` --engine <id> engine to search (default: first image engine)\n` +
55
+ ` slidemind search "<query>" Search an engine for images or slides (requires an open host)\n` +
56
+ ` --engine <id> engine to search (default: first image engine; required for slides)\n` +
57
+ ` --intent <i> auto | content | visual (slide engines)\n` +
58
+ ` --top <N> max results\n` +
52
59
  ` --to <presId> target a specific window (required if >1 open)\n` +
60
+ ` --json print the raw result payload\n` +
61
+ ` slidemind ls engines List the live engine catalog for the active corporate design\n` +
62
+ ` --type <t> collection | search\n` +
63
+ ` --subtype <s> image | slide | template | content\n` +
64
+ ` --json print the raw engine list\n` +
65
+ ` slidemind insert-slide [opts] Insert reused slide search results live into a window (requires a host)\n` +
66
+ ` --search <id> searchId from 'slidemind search'\n` +
67
+ ` --pick <n,..> 1-based selection number(s)\n` +
68
+ ` --to <presId> target window (required if >1 open)\n` +
69
+ ` --at <N> insert after slide N (0-based); default = append\n` +
70
+ ` slidemind add-slide [opts] Fold reused slide search results into a deck as native .pptx\n` +
71
+ ` --search <id> searchId from 'slidemind search'\n` +
72
+ ` --pick <n,..> 1-based selection number(s)\n` +
73
+ ` --deck <deck> deck to add the slides to\n` +
74
+ ` --to <presId> target the window the search ran on (required if >1 open)\n` +
75
+ ` --at <N> numeric prefix to start at (default: append)\n` +
53
76
  ` slidemind download-bundle [deck] Materialize the active corporate design's bundle locally\n` +
54
77
  ` --to <presId> target a specific window\n` +
55
78
  ` --force re-download even if the cached bundle is current\n` +
@@ -65,8 +88,8 @@ function printHelp() {
65
88
  ` slidemind whoami Show every paired environment (* = default)\n` +
66
89
  ` slidemind use <url|env> Set the default environment\n` +
67
90
  ` slidemind logout [url|env] Unpair one environment (--all to unpair every one)\n` +
68
- ` slidemind init --for <t> Set up slidemind in a harness (registers the MCP server + skill)\n` +
69
- ` slidemind uninstall --for <t> Remove a previously-installed skill\n` +
91
+ ` slidemind init --for <t> Set up slidemind in a harness (registers the MCP server + skill; clears the bundle cache)\n` +
92
+ ` slidemind uninstall --for <t> Remove a previously-installed skill (clears the bundle cache)\n` +
70
93
  ` slidemind update [--for <t>] Fetch the latest release and redeploy\n` +
71
94
  ` --for <t> harness to redeploy into (default: all)\n` +
72
95
  ` --ref <ver> pin to a release version (e.g. v0.1.0)\n` +
@@ -144,6 +167,42 @@ async function cmdBuild(rest) {
144
167
  ]);
145
168
  }
146
169
 
170
+ async function cmdReview(rest) {
171
+ const { deck, passthrough } = splitDeckArg(rest, "review");
172
+ await runNode(path.join(BUILDERS_DIR, "review.mjs"), [
173
+ "--deck",
174
+ resolveDeckArg(deck),
175
+ ...passthrough,
176
+ ]);
177
+ }
178
+
179
+ // Live render progress for build-pptx/push (the job protocol streams it; the
180
+ // sync fallback emits nothing). One stderr line per state change — works in
181
+ // plain logs and TTYs alike.
182
+ function renderProgressPrinter() {
183
+ let last = "";
184
+ let queuedAt = null;
185
+ return (p) => {
186
+ let line = null;
187
+ if (p.state === "queued") {
188
+ queuedAt ??= Date.now();
189
+ if (Date.now() - queuedAt > 5000) {
190
+ line = " render queued behind another render…";
191
+ }
192
+ } else if (p.state === "restarted") {
193
+ line = " render restarted (backend replica was replaced)…";
194
+ } else if (p.stage === "assembling") {
195
+ line = " assembling deck…";
196
+ } else if (p.stage === "rendering" && typeof p.total === "number") {
197
+ line = ` rendering slide ${Math.min((p.done ?? 0) + 1, p.total)}/${p.total}…`;
198
+ }
199
+ if (line && line !== last) {
200
+ last = line;
201
+ process.stderr.write(`${line}\n`);
202
+ }
203
+ };
204
+ }
205
+
147
206
  async function cmdBuildPptx(rest) {
148
207
  const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
149
208
  let to = null;
@@ -175,6 +234,7 @@ async function cmdBuildPptx(rest) {
175
234
  deckDir,
176
235
  presentationId: to,
177
236
  outPath,
237
+ onProgress: renderProgressPrinter(),
178
238
  });
179
239
  process.stdout.write(
180
240
  `Wrote ${result.outPath} (${result.slideCount ?? "?"} slides) via the slidemind add-in\n`,
@@ -191,6 +251,8 @@ async function cmdSearch(rest) {
191
251
  let asJson = false;
192
252
  let to = null;
193
253
  let engineId = null;
254
+ let intent = null;
255
+ let top = null;
194
256
  const queryParts = [];
195
257
  for (let i = 0; i < rest.length; i++) {
196
258
  const a = rest[i];
@@ -199,9 +261,22 @@ async function cmdSearch(rest) {
199
261
  } else if (a === "--to") {
200
262
  to = rest[++i];
201
263
  if (!to) fail("--to expects a presId");
202
- } else if (a === "--engine" || a === "-e") {
264
+ } else if (a === "--engine" || a === "-e" || a === "--engineId") {
203
265
  engineId = rest[++i];
204
266
  if (!engineId) fail("--engine expects an engineId");
267
+ } else if (a === "--intent") {
268
+ intent = rest[++i];
269
+ if (!intent) fail("--intent expects auto|content|visual");
270
+ if (!["auto", "content", "visual"].includes(intent)) {
271
+ fail(`--intent expects auto|content|visual, got '${intent}'`);
272
+ }
273
+ } else if (a === "--top") {
274
+ const next = rest[++i];
275
+ const n = parseInt(next, 10);
276
+ if (!Number.isFinite(n) || n <= 0) {
277
+ fail(`--top expects a positive integer, got '${next}'`);
278
+ }
279
+ top = n;
205
280
  } else if (a.startsWith("-")) {
206
281
  fail(`unknown flag: ${a}`);
207
282
  } else {
@@ -211,13 +286,12 @@ async function cmdSearch(rest) {
211
286
  const query = queryParts.join(" ").trim();
212
287
  if (!query) {
213
288
  fail(
214
- `usage: slidemind search "<query>" [--engine <id>] [--to <presId>] [--json]`,
289
+ `usage: slidemind search "<query>" [--engine <id>] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
215
290
  );
216
291
  }
217
292
 
218
- // Icon/image search now runs against the add-in's live brand engine over the
219
- // bridge — an open host is required, and the engine comes from the active
220
- // corporate design's bundle.
293
+ // Engine search runs against the add-in's live engine over the bridge — an
294
+ // open host is required.
221
295
  const client = await import(
222
296
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
223
297
  );
@@ -225,11 +299,32 @@ async function cmdSearch(rest) {
225
299
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
226
300
  );
227
301
  const registry = await client.requireHost();
228
- const entry = client.pickPresentationEntry(registry, to);
302
+ // The engine is per-user, not window-bound — so search just attaches to any
303
+ // connected window (preferring a live one). No "which window?" prompt; pass
304
+ // --to only to pin a specific one. The chosen window is printed below so
305
+ // follow-up insert/add-slide can target the same pane the searchId lives on.
306
+ const entry = client.pickAnyPresentationEntry(registry, to);
229
307
  const presId = entry.presId;
230
308
  const backendURL = entry.backendURL ?? null;
231
309
 
310
+ // Slide search needs an explicit engine — the bundle's image-engine default
311
+ // only applies to image/visual intent. content/visual on a slide engine is
312
+ // the caller's choice via --engine.
313
+ const slideIntent = intent === "content" || intent === "visual";
314
+ // Thread the engine subtype on the wire whenever the CLI can determine it:
315
+ // - the pickImageEngine default branch is, by construction, an image engine;
316
+ // - an explicit slide intent (--intent content|visual) targets a slide
317
+ // engine.
318
+ // This is belt-and-suspenders for an add-in regression where a missing
319
+ // subtype no longer defaulted to "image"; when the subtype is unknown we omit
320
+ // it and let the add-in resolve it from the snapshot.
321
+ let subtype = slideIntent ? "slide" : null;
232
322
  if (!engineId) {
323
+ if (slideIntent) {
324
+ fail(
325
+ "slide search requires --engine <id>. Run 'slidemind ls engines --type search --subtype slide' to find one.",
326
+ );
327
+ }
233
328
  const mat = await bundle.materializeBundle(presId, { backendURL });
234
329
  const pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
235
330
  if (!pick) {
@@ -238,9 +333,23 @@ async function cmdSearch(rest) {
238
333
  );
239
334
  }
240
335
  engineId = pick.id;
336
+ subtype = "image";
241
337
  }
242
338
 
243
- const res = await client.engineSearch({ presId, engineId, query, backendURL });
339
+ // Window-agnostic: tries the picked pane, then fails over to any other live
340
+ // window on the same backend if it's suspended/frozen. The answering window
341
+ // (res.presId) is what reuse must target — the searchId is bound to it.
342
+ const res = await client.engineSearchAnyPane({
343
+ registry,
344
+ explicitPresId: to,
345
+ engineId,
346
+ query,
347
+ subtype,
348
+ intent: intent ?? null,
349
+ top: top ?? null,
350
+ });
351
+ const ranPresId = res?.presId ?? presId;
352
+ const ranEntry = registry.find((p) => p.presId === ranPresId) ?? entry;
244
353
  if (asJson) {
245
354
  process.stdout.write(`${JSON.stringify(res, null, 2)}\n`);
246
355
  return;
@@ -249,15 +358,272 @@ async function cmdSearch(rest) {
249
358
  process.stderr.write(`search failed: ${res?.error ?? "unknown error"}\n`);
250
359
  return;
251
360
  }
252
- const count = res.resultCount ?? (res.thumbnails?.length ?? 0);
361
+ // Prefer the new `results` array (image + slide engines); fall back to the
362
+ // legacy `thumbnails` shape older add-ins still return for image engines.
363
+ const results = Array.isArray(res.results)
364
+ ? res.results
365
+ : (res.thumbnails ?? []).map((t, i) => ({ selection: i + 1, title: t.title }));
366
+ const count = res.resultCount ?? results.length;
367
+ process.stdout.write(
368
+ `searchId ${res.searchId} — ${count} result(s) from engine ${engineId}` +
369
+ (res.intent ? ` (${res.intent})` : "") +
370
+ `\n`,
371
+ );
372
+ const isContent = res.intent === "content";
373
+ results.forEach((r, i) => {
374
+ const sel = r.selection ?? i + 1;
375
+ if (isContent) {
376
+ // Content results: per-result header + the bare extracted text block.
377
+ const score = typeof r.score === "number" ? ` · ${r.score.toFixed(3)}` : "";
378
+ process.stdout.write(` ${sel}. ${r.title ?? "(untitled)"}${score}\n`);
379
+ if (r.text) {
380
+ for (const line of String(r.text).split("\n")) {
381
+ process.stdout.write(` ${line}\n`);
382
+ }
383
+ }
384
+ } else {
385
+ // Visual / image results: the numbered list.
386
+ process.stdout.write(` ${sel}. ${r.title ?? "(untitled)"}\n`);
387
+ }
388
+ });
389
+ // Tailor the next-step hint to the result kind: slide verbs (insert-slide /
390
+ // add-slide) for slide results, the image-download hint otherwise. The result
391
+ // subtype is authoritative; fall back to a per-result `kind` for older
392
+ // add-ins that don't echo a top-level subtype.
393
+ const isSlideResults =
394
+ res.subtype === "slide" || results.some((r) => r.kind === "slide");
395
+ const envTag = ranEntry.env ? ` [${ranEntry.env}]` : "";
396
+ if (isSlideResults) {
397
+ // The searchId is bound to the window the search ran on — reuse must target
398
+ // that same window, so spell out its presId in the next-step commands.
399
+ process.stdout.write(
400
+ `searchId ${res.searchId} ran on window ${ranPresId}${envTag} — reuse from THIS window:\n` +
401
+ ` slidemind insert-slide --search ${res.searchId} --pick <n> --to ${ranPresId}\n` +
402
+ ` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck> --to ${ranPresId}\n`,
403
+ );
404
+ } else {
405
+ process.stdout.write(
406
+ `Download an image result for a deck via the agent (slidemind_download_result, search_id ${res.searchId}).\n`,
407
+ );
408
+ }
409
+ }
410
+
411
+ async function cmdLs(rest) {
412
+ const [sub, ...subRest] = rest;
413
+ if (sub !== "engines") {
414
+ fail("usage: slidemind ls engines [--type collection|search] [--subtype image|slide|template|content] [--json]");
415
+ }
416
+ let type = null;
417
+ let subtype = null;
418
+ let to = null;
419
+ let asJson = false;
420
+ for (let i = 0; i < subRest.length; i++) {
421
+ const a = subRest[i];
422
+ if (a === "--type") {
423
+ type = subRest[++i];
424
+ if (!type) fail("--type expects collection|search");
425
+ if (!["collection", "search"].includes(type)) {
426
+ fail(`--type expects collection|search, got '${type}'`);
427
+ }
428
+ } else if (a === "--subtype") {
429
+ subtype = subRest[++i];
430
+ if (!subtype) fail("--subtype expects image|slide|template|content");
431
+ if (!["image", "slide", "template", "content"].includes(subtype)) {
432
+ fail(`--subtype expects image|slide|template|content, got '${subtype}'`);
433
+ }
434
+ } else if (a === "--to") {
435
+ // Accepted for symmetry with other commands; engines are backend-scoped,
436
+ // not presId-scoped, so it does not change the catalog.
437
+ to = subRest[++i];
438
+ if (!to) fail("--to expects a presId");
439
+ } else if (a === "--json") {
440
+ asJson = true;
441
+ } else if (a.startsWith("-")) {
442
+ fail(`unknown flag: ${a}`);
443
+ } else {
444
+ fail(`unexpected argument: ${a}`);
445
+ }
446
+ }
447
+ void to;
448
+ const client = await import(
449
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
450
+ );
451
+ const { engines } = await client.listEngines({ type, subtype });
452
+ if (asJson) {
453
+ process.stdout.write(`${JSON.stringify(engines, null, 2)}\n`);
454
+ return;
455
+ }
456
+ if (engines.length === 0) {
457
+ process.stderr.write("No engines available for the active corporate design.\n");
458
+ return;
459
+ }
460
+ // Show the env tag only when more than one environment contributed engines.
461
+ const multiEnv = new Set(engines.map((e) => e.env)).size > 1;
462
+ for (const e of engines) {
463
+ const kind = [e.type, e.subtype].filter(Boolean).join("/");
464
+ const env = multiEnv && e.env ? `[${e.env}] ` : "";
465
+ const desc = e.description ? ` — ${e.description}` : "";
466
+ process.stdout.write(`${env}${e.id} · ${e.label ?? e.id} · ${kind}${desc}\n`);
467
+ }
468
+ }
469
+
470
+ function parsePicks(raw, flag = "--pick") {
471
+ if (!raw) fail(`${flag} expects a comma-separated list of selection numbers`);
472
+ // Tolerate a trailing/empty segment ("1," or "1,,2") by dropping blank parts
473
+ // rather than turning them into NaN that fails the whole call; dedupe so the
474
+ // same selection isn't extracted twice.
475
+ const segments = raw
476
+ .split(",")
477
+ .map((s) => s.trim())
478
+ .filter((s) => s.length > 0);
479
+ if (segments.length === 0) {
480
+ fail(`${flag} expects a comma-separated list of selection numbers`);
481
+ }
482
+ const picks = [...new Set(segments.map((s) => parseInt(s, 10)))];
483
+ if (picks.some((n) => !Number.isFinite(n) || n <= 0)) {
484
+ fail(`${flag} expects positive selection numbers, got '${raw}'`);
485
+ }
486
+ return picks;
487
+ }
488
+
489
+ async function cmdInsertSlide(rest) {
490
+ let searchId = null;
491
+ let picks = null;
492
+ let to = null;
493
+ let at = null;
494
+ for (let i = 0; i < rest.length; i++) {
495
+ const a = rest[i];
496
+ if (a === "--search") {
497
+ const next = rest[++i];
498
+ const n = parseInt(next, 10);
499
+ if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
500
+ searchId = n;
501
+ } else if (a === "--pick") {
502
+ picks = parsePicks(rest[++i]);
503
+ } else if (a === "--to") {
504
+ to = rest[++i];
505
+ if (!to) fail("--to expects a presId");
506
+ } else if (a === "--at") {
507
+ const next = rest[++i];
508
+ const n = parseInt(next, 10);
509
+ if (!Number.isFinite(n) || n < 0) {
510
+ fail(`--at expects a non-negative integer, got '${next}'`);
511
+ }
512
+ at = n;
513
+ } else if (a.startsWith("-")) {
514
+ fail(`unknown flag: ${a}`);
515
+ } else {
516
+ fail(`unexpected argument: ${a}`);
517
+ }
518
+ }
519
+ if (searchId === null || !picks) {
520
+ fail("usage: slidemind insert-slide --search <id> --pick <n[,n,...]> --to <presId> [--at <N>]");
521
+ }
522
+ const client = await import(
523
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
524
+ );
525
+ const registry = await client.requireHost();
526
+ const entry = client.pickPresentationEntry(registry, to);
527
+ const res = await client.insertSlideLive({
528
+ presId: entry.presId,
529
+ searchId,
530
+ selections: picks,
531
+ at,
532
+ backendURL: entry.backendURL ?? null,
533
+ });
534
+ if (!res?.ok) {
535
+ fail(`insert-slide failed: ${res?.error ?? "unknown error"}`);
536
+ }
253
537
  process.stdout.write(
254
- `searchId ${res.searchId} — ${count} result(s) from engine ${engineId}\n`,
538
+ `Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${entry.presId}\n`,
255
539
  );
256
- (res.thumbnails ?? []).forEach((t, i) => {
257
- process.stdout.write(` ${i + 1}. ${t.title ?? "(untitled)"}\n`);
540
+ }
541
+
542
+ async function cmdAddSlide(rest) {
543
+ let searchId = null;
544
+ let picks = null;
545
+ let deck = null;
546
+ let at = null;
547
+ let to = null;
548
+ for (let i = 0; i < rest.length; i++) {
549
+ const a = rest[i];
550
+ if (a === "--search") {
551
+ const next = rest[++i];
552
+ const n = parseInt(next, 10);
553
+ if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
554
+ searchId = n;
555
+ } else if (a === "--pick") {
556
+ picks = parsePicks(rest[++i]);
557
+ } else if (a === "--deck") {
558
+ deck = rest[++i];
559
+ if (!deck) fail("--deck expects a deck name or path");
560
+ } else if (a === "--to") {
561
+ to = rest[++i];
562
+ if (!to) fail("--to expects a presId");
563
+ } else if (a === "--at") {
564
+ const next = rest[++i];
565
+ const n = parseInt(next, 10);
566
+ if (!Number.isFinite(n) || n < 0) {
567
+ fail(`--at expects a non-negative integer, got '${next}'`);
568
+ }
569
+ at = n;
570
+ } else if (a.startsWith("-")) {
571
+ fail(`unknown flag: ${a}`);
572
+ } else {
573
+ fail(`unexpected argument: ${a}`);
574
+ }
575
+ }
576
+ if (searchId === null || !picks || !deck) {
577
+ fail("usage: slidemind add-slide --search <id> --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>]");
578
+ }
579
+ const deckDir = resolveDeckArg(deck);
580
+ const slidesDir = path.join(deckDir, "slides");
581
+ fs.mkdirSync(slidesDir, { recursive: true });
582
+
583
+ const client = await import(
584
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
585
+ );
586
+ const registry = await client.requireHost();
587
+ // The searchId is presentation-scoped, so --to targets the window the search
588
+ // ran on. Without it, a registry with >1 open window is a dead-end.
589
+ const entry = client.pickPresentationEntry(registry, to);
590
+ const presId = entry.presId;
591
+ const backendURL = entry.backendURL ?? null;
592
+
593
+ // Extract each picked slide's bytes over the bridge, then let the shared
594
+ // helper compute the play-order filenames (NN-reused-<shortid>.pptx). The
595
+ // prefix math is the single shared unit (src/lib/reused-slides.mjs) so the
596
+ // CLI and the MCP tool never diverge. Existing files are not renumbered.
597
+ const reused = await import(
598
+ pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href
599
+ );
600
+ const extracted = [];
601
+ for (const pick of picks) {
602
+ const res = await client.extractSlide({
603
+ presId,
604
+ searchId,
605
+ selection: pick,
606
+ backendURL,
607
+ });
608
+ if (!res?.ok || !res.base64) {
609
+ fail(`add-slide failed for selection ${pick}: ${res?.error ?? "no slide bytes returned"}`);
610
+ }
611
+ extracted.push({ selection: pick, slideId: res.slideId ?? null, base64: res.base64 });
612
+ }
613
+ const existing = fs
614
+ .readdirSync(slidesDir)
615
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
616
+ const targets = reused.reusedSlideTargets(existing, extracted, at);
617
+ const written = [];
618
+ extracted.forEach((slide, i) => {
619
+ const target = path.join(slidesDir, targets[i].filename);
620
+ fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
621
+ written.push(path.relative(process.cwd(), target));
258
622
  });
259
623
  process.stdout.write(
260
- `Download a result by selection number via the agent (slidemind_download_result).\n`,
624
+ `Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
625
+ written.map((w) => ` ${w}`).join("\n") +
626
+ `\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
261
627
  );
262
628
  }
263
629
 
@@ -284,8 +650,16 @@ async function cmdDownloadBundle(rest) {
284
650
  const bundle = await import(
285
651
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
286
652
  );
653
+ // When a deck is being (re-)bound, its existing bundle ref breaks ties for
654
+ // a presId open on several paired backends (prefer the one whose connected
655
+ // pane has THIS corporate design active).
656
+ const deckRef = positional[0]
657
+ ? bundle.readDeckBundleRef(resolveDeckArg(positional[0]))
658
+ : null;
287
659
  const registry = await client.requireHost();
288
- const entry = client.pickPresentationEntry(registry, to);
660
+ const entry = client.pickPresentationEntry(registry, to, {
661
+ preferBrandId: deckRef?.presFP ?? null,
662
+ });
289
663
  const presId = entry.presId;
290
664
  const result = await bundle.materializeBundle(presId, {
291
665
  force,
@@ -303,8 +677,15 @@ async function cmdDownloadBundle(rest) {
303
677
  });
304
678
  }
305
679
  process.stdout.write(
306
- `${result.cached ? "Bundle already current" : "Materialized bundle"} for ${result.presFP} (etag ${result.etag ?? "?"})\n ${result.dir}\n`,
680
+ `Synced bundle for ${result.presFP} (etag ${result.etag ?? "?"}) — ` +
681
+ `${result.downloaded} downloaded, ${result.pruned} pruned\n ${result.dir}\n`,
307
682
  );
683
+ if (result.engineUpdated) {
684
+ process.stdout.write(" lint engine updated\n");
685
+ }
686
+ if (result.captureEngineUpdated) {
687
+ process.stdout.write(" capture engine updated\n");
688
+ }
308
689
  // Asset downloads are best-effort (a signed URL may have expired); surface any
309
690
  // failures so the user knows the local preview bundle is incomplete.
310
691
  for (const w of result.warnings ?? []) {
@@ -543,6 +924,7 @@ async function cmdPush(rest) {
543
924
  insertAt: at,
544
925
  replace,
545
926
  slideIds: slides,
927
+ onProgress: renderProgressPrinter(),
546
928
  });
547
929
  for (const w of result.warnings ?? []) {
548
930
  process.stderr.write(` warning: ${w}\n`);
@@ -631,6 +1013,22 @@ async function cmdInstall(rest) {
631
1013
  const ui = await loadInstallerUi();
632
1014
  ui.banner(readPackageJson().version, "init");
633
1015
  await runInstaller(target, "install", rest);
1016
+
1017
+ // Force every design bundle to re-download from the add-in on the next
1018
+ // build/push/download by wiping the materialized cache. init is the
1019
+ // deliberate refresh point (and `slidemind update` redeploys through it), so
1020
+ // a stale binary or an orphaned per-presentation dir never outlives a re-init.
1021
+ const bundle = await import(
1022
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
1023
+ );
1024
+ const cleared = bundle.clearBundleCache();
1025
+ await ui.section("cache", "design bundles");
1026
+ ui.step({
1027
+ label: "clear",
1028
+ value: cleared ? `${cleared} bundle${cleared === 1 ? "" : "s"}` : "empty",
1029
+ note: "re-downloads on next build",
1030
+ });
1031
+
634
1032
  ui.done(
635
1033
  target === "all"
636
1034
  ? "ready — ask your agent to plan, create, or edit a deck"
@@ -643,6 +1041,20 @@ async function cmdUninstall(rest) {
643
1041
  const ui = await loadInstallerUi();
644
1042
  ui.banner(readPackageJson().version, "uninstall");
645
1043
  await runInstaller(target, "uninstall", rest);
1044
+
1045
+ // Drop the materialized bundle cache too — uninstall is a teardown, so leaving
1046
+ // a per-presentation dir (with brand assets) behind would be stale residue.
1047
+ const bundle = await import(
1048
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
1049
+ );
1050
+ const cleared = bundle.clearBundleCache();
1051
+ await ui.section("cache", "design bundles");
1052
+ ui.step({
1053
+ status: "remove",
1054
+ label: "clear",
1055
+ value: cleared ? `${cleared} bundle${cleared === 1 ? "" : "s"}` : "empty",
1056
+ });
1057
+
646
1058
  ui.done(target === "all" ? "removed from all harnesses" : `removed from ${target}`);
647
1059
  }
648
1060
 
@@ -774,12 +1186,24 @@ async function main(rawArgv) {
774
1186
  case "build":
775
1187
  await cmdBuild(rest);
776
1188
  return 0;
1189
+ case "review":
1190
+ await cmdReview(rest);
1191
+ return 0;
777
1192
  case "build-pptx":
778
1193
  await cmdBuildPptx(rest);
779
1194
  return 0;
780
1195
  case "search":
781
1196
  await cmdSearch(rest);
782
1197
  return 0;
1198
+ case "ls":
1199
+ await cmdLs(rest);
1200
+ return 0;
1201
+ case "insert-slide":
1202
+ await cmdInsertSlide(rest);
1203
+ return 0;
1204
+ case "add-slide":
1205
+ await cmdAddSlide(rest);
1206
+ return 0;
783
1207
  case "download-bundle":
784
1208
  await cmdDownloadBundle(rest);
785
1209
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,13 +30,9 @@
30
30
  "test": "node --test"
31
31
  },
32
32
  "dependencies": {
33
- "@babel/parser": "^7.26.5",
34
- "@babel/traverse": "^7.26.5",
35
- "@babel/types": "^7.26.5",
36
33
  "@clack/prompts": "^1.3.0",
37
34
  "@modelcontextprotocol/sdk": "^1.29.0",
38
- "js-yaml": "^4.1.1",
39
- "recast": "^0.23.9",
35
+ "puppeteer": "24.43.0",
40
36
  "zod": "^3.23.8"
41
37
  },
42
38
  "engines": {