slidemind 0.1.9 → 0.2.1

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
@@ -26,6 +26,13 @@ import os from "node:os";
26
26
  import path from "node:path";
27
27
  import { fileURLToPath, pathToFileURL } from "node:url";
28
28
 
29
+ // The only eager `src/` import: node-builtins only, and the version-floor
30
+ // ladder below is sync, so it cannot be lazily loaded like the rest.
31
+ import {
32
+ isClaudePluginInstall,
33
+ updateCliInstruction,
34
+ } from "../src/install-channel.mjs";
35
+
29
36
  const __filename = fileURLToPath(import.meta.url);
30
37
  const __dirname = path.dirname(__filename);
31
38
  const PACKAGE_ROOT = path.resolve(__dirname, "..");
@@ -68,7 +75,7 @@ function printHelp() {
68
75
  ` slidemind review <deck> Open a spatial preview review; blocks until the user\n` +
69
76
  ` approves or leaves comments (host-free)\n` +
70
77
  ` --port <n> bind a fixed port (default: ephemeral)\n` +
71
- ` slidemind build-pptx <deck> Export native .pptx via the add-in (requires an open host)\n` +
78
+ ` slidemind build-pptx <deck> Export native .pptx — via the add-in for compose decks (host), LOCALLY for template packs (host-free)\n` +
72
79
  ` --to <presId> target a specific window (required if >1 open)\n` +
73
80
  ` --out <path> output path (default <deck>/<deck>.pptx)\n` +
74
81
  ` slidemind view-draft <deck> Render the deck's slides to per-slide PNGs (no push, no .pptx)\n` +
@@ -77,8 +84,10 @@ function printHelp() {
77
84
  ` --to <presId> window to fetch the bundle from (only if not cached)\n` +
78
85
  ` --out <dir> output dir (default <deck>/.draft-preview/)\n` +
79
86
  ` --open open each rendered PNG\n` +
80
- ` slidemind search "<query>" Search an engine for images or slides (requires an open host)\n` +
81
- ` --engine <id> engine to search (default: first image engine; required for slides)\n` +
87
+ ` slidemind search "<query>" Search an engine or scope for images or slides (requires an open host)\n` +
88
+ ` --engine <id> engine, or scope:all | scope:sharepoint to search unfiltered\n` +
89
+ ` (default: first image engine; scope:all for slide intents)\n` +
90
+ ` --result-type <t> slide | image — required for a scope:* target\n` +
82
91
  ` --intent <i> auto | content | visual (slide engines)\n` +
83
92
  ` --top <N> max results\n` +
84
93
  ` --to <presId> target a specific window (required if >1 open)\n` +
@@ -88,8 +97,8 @@ function printHelp() {
88
97
  ` --pick <n> 1-based selection number to view\n` +
89
98
  ` --to <presId> the window the search ran on (required if >1 open)\n` +
90
99
  ` --out <dir> output dir (default: a slidemind temp dir)\n` +
91
- ` slidemind ls engines List the live engine catalog for an active pane\n` +
92
- ` --type <t> collection | search\n` +
100
+ ` slidemind ls engines List the live engine catalog + search scopes for an active pane\n` +
101
+ ` --type <t> collection | search | scope\n` +
93
102
  ` --subtype <s> image | slide | template | content\n` +
94
103
  ` --to <presId> target a specific window/backend\n` +
95
104
  ` --all include every paired backend\n` +
@@ -112,6 +121,12 @@ function printHelp() {
112
121
  ` --deck <deck> deck to write the asset(s) into\n` +
113
122
  ` --to <presId> the window the search ran on (required if >1 open)\n` +
114
123
  ` --icon-color <hex[,hex,..]> bake a colour into SVG result(s) (one for all, or one per --pick)\n` +
124
+ ` slidemind imagen "<prompt>" Generate ONE image with the add-in's image model into a deck's assets/ (paired backend, pane-free)\n` +
125
+ ` --deck <deck> bound deck to write the image into (required)\n` +
126
+ ` --out <name> file name under assets/ (default imagen-N.png)\n` +
127
+ ` --size <s> auto|1024x1024|1536x1024|1024x1536 (default auto)\n` +
128
+ ` --quality <q> low|medium|high|auto\n` +
129
+ ` --brand-style condition on the CD's reference samples\n` +
115
130
  ` slidemind download-bundle [deck] Materialize the active corporate design's bundle locally\n` +
116
131
  ` --to <presId> target a specific window\n` +
117
132
  ` --force re-download even if the cached bundle is current\n` +
@@ -306,6 +321,103 @@ function renderProgressPrinter() {
306
321
  };
307
322
  }
308
323
 
324
+ // Generate one regular image via the paired add-in's image model and write it
325
+ // into the deck's assets/. Pane-free: routes by the deck's bound design
326
+ // (.bundle-ref.json), so it works with PowerPoint backgrounded or closed.
327
+ async function cmdImagen(rest) {
328
+ let prompt = null;
329
+ let deck = null;
330
+ let outName = null;
331
+ let size = null;
332
+ let quality = null;
333
+ let brandStyle = false;
334
+ for (let i = 0; i < rest.length; i++) {
335
+ const a = rest[i];
336
+ if (a === "--deck") {
337
+ deck = rest[++i];
338
+ if (!deck) fail("--deck expects a deck");
339
+ } else if (a === "--out" || a === "-o") {
340
+ outName = rest[++i];
341
+ if (!outName) fail("--out expects a file name");
342
+ } else if (a === "--size") {
343
+ size = rest[++i];
344
+ if (!["auto", "1024x1024", "1536x1024", "1024x1536"].includes(size ?? "")) {
345
+ fail("--size expects auto|1024x1024|1536x1024|1024x1536");
346
+ }
347
+ } else if (a === "--quality") {
348
+ quality = rest[++i];
349
+ if (!["low", "medium", "high", "auto"].includes(quality ?? "")) {
350
+ fail("--quality expects low|medium|high|auto");
351
+ }
352
+ } else if (a === "--brand-style") {
353
+ brandStyle = true;
354
+ } else if (a.startsWith("-")) {
355
+ fail(`unknown flag: ${a}`);
356
+ } else if (prompt === null) {
357
+ prompt = a;
358
+ } else {
359
+ fail(`unexpected argument: ${a} (quote the prompt as one argument)`);
360
+ }
361
+ }
362
+ if (!prompt || !prompt.trim() || !deck) {
363
+ fail(
364
+ 'usage: slidemind imagen "<prompt>" --deck <deck> [--out <name.png>] ' +
365
+ "[--size auto|1024x1024|1536x1024|1024x1536] [--quality low|medium|high|auto] [--brand-style]",
366
+ );
367
+ }
368
+ const deckDir = resolveDeckArg(deck);
369
+ const bundleMod = await import(
370
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
371
+ );
372
+ const ref = bundleMod.readDeckBundleRef(deckDir);
373
+ if (!ref?.presFP) {
374
+ fail(
375
+ `deck ${path.basename(deckDir)} is not bound to a design yet — run ` +
376
+ `'slidemind ready ${deck}' first (imagen routes by the deck's bound design).`,
377
+ );
378
+ }
379
+ const client = await import(
380
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
381
+ );
382
+ process.stdout.write(" generating image (this can take a minute)…\n");
383
+ let result;
384
+ try {
385
+ result = await client.generateImageForBrand(ref.presFP, {
386
+ prompt,
387
+ size,
388
+ quality,
389
+ brandStyle,
390
+ });
391
+ } catch (err) {
392
+ if (err?.code === "imagen_route_unavailable") {
393
+ fail(
394
+ `${err.message}\n The paired add-in does not offer image generation yet — ` +
395
+ `it ships with the next add-in update; until then add pictures to assets/ by hand ` +
396
+ `or via 'slidemind download-result'.`,
397
+ );
398
+ }
399
+ throw err;
400
+ }
401
+ const m = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(result?.image ?? "");
402
+ if (!m) fail("backend returned no image data");
403
+ const ext = m[1] === "png" ? "png" : "jpeg";
404
+ const assetsDir = path.join(deckDir, "assets");
405
+ fs.mkdirSync(assetsDir, { recursive: true });
406
+ let file = outName;
407
+ if (!file) {
408
+ let n = 1;
409
+ while (fs.existsSync(path.join(assetsDir, `imagen-${n}.${ext}`))) n++;
410
+ file = `imagen-${n}.${ext}`;
411
+ }
412
+ const outPath = path.join(assetsDir, file);
413
+ fs.writeFileSync(outPath, Buffer.from(m[2], "base64"));
414
+ const styled = result.styleReferences > 0 ? ` (styled on ${result.styleReferences} brand sample(s))` : "";
415
+ process.stdout.write(
416
+ `Wrote ${path.relative(process.cwd(), outPath)}${styled}\n` +
417
+ ` Reference it from slide props as "assets/${file}" (e.g. summaryImage, illustration).\n`,
418
+ );
419
+ }
420
+
309
421
  async function cmdBuildPptx(rest) {
310
422
  const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
311
423
  let to = null;
@@ -328,6 +440,57 @@ async function cmdBuildPptx(rest) {
328
440
  if (!fs.existsSync(path.join(deckDir, "slides"))) {
329
441
  fail(`expected 'slides/' folder in ${deckDir}`);
330
442
  }
443
+
444
+ // Template packs (mode: fill-template) build LOCALLY: the deck's slides are
445
+ // declarative fills over a template .pptx that ships in the pack, so no
446
+ // add-in render is involved and no PowerPoint host is needed. Compose decks
447
+ // take the bridge path below UNTOUCHED — the mode probe never throws, so a
448
+ // compose deck with an unmaterialized bundle still self-heals in the render.
449
+ const resolveMod = await import(
450
+ pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
451
+ );
452
+ if (resolveMod.resolveDeckMode(deckDir).mode === "fill-template") {
453
+ const skill = resolveMod.resolveSkillForDeck(deckDir);
454
+ if (to) {
455
+ fail(
456
+ "--to targets a PowerPoint window, but fill-template decks build " +
457
+ "locally from the pack's template — drop --to (use 'slidemind push' " +
458
+ "to get slides into a window).",
459
+ );
460
+ }
461
+ // In-deck freeform slides (create-slides JSX next to the master-backed
462
+ // ones) render to sibling NN.pptx artifacts first — the only step that
463
+ // talks to the add-in; unchanged freeform slides keep the build offline.
464
+ const freeform = await import(
465
+ pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "freeform.mjs")).href
466
+ );
467
+ const ff = await freeform.ensureFreeformRenders({
468
+ deckDir,
469
+ locale: skill.locale ?? "en",
470
+ srcDir: SRC_DIR,
471
+ onLog: (line) => process.stdout.write(` ${line}\n`),
472
+ });
473
+ for (const w of ff.warnings) process.stderr.write(` warning: ${w}\n`);
474
+
475
+ const engine = await import(
476
+ pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "engine.mjs")).href
477
+ );
478
+ const result = await engine.buildFillTemplateDeck({
479
+ deckDir,
480
+ skill,
481
+ outPath,
482
+ onLog: (line) => process.stdout.write(` ${line}\n`),
483
+ });
484
+ process.stdout.write(
485
+ `Wrote ${result.outPath} (${result.slideCount} slides) from template ` +
486
+ `'${skill.templateKey}' — host-free\n`,
487
+ );
488
+ for (const w of result.warnings ?? []) {
489
+ process.stderr.write(` warning: ${w}\n`);
490
+ }
491
+ return;
492
+ }
493
+
331
494
  // The .pptx is rendered server-side by the add-in (one exporter, one truth).
332
495
  // A bound deck renders pane-free over the brand routes — no PowerPoint
333
496
  // window needed; unbound decks (or --to) resolve through the pane registry.
@@ -433,10 +596,96 @@ async function cmdViewDraft(rest) {
433
596
  }
434
597
  }
435
598
 
599
+ // Turn the add-in's deliberately terse search failures into something actionable.
600
+ // It withholds detail on purpose: the raw message used to carry the built OData
601
+ // filter and physical index names, and "Engine not found" doubles as a permission
602
+ // denial so engine existence stays undisclosed. So the CLI can't diagnose — it can
603
+ // only name the next move.
604
+ function searchFailureHint(error, engineId) {
605
+ const raw = error ?? "unknown error";
606
+ if (/engine not found/i.test(raw)) {
607
+ return (
608
+ `${raw} — '${engineId}' is unknown or not visible to you. ` +
609
+ `Re-run 'slidemind ls engines' (ids can be retired) and pick from the live list, ` +
610
+ `or search 'scope:all' to skip engines entirely.`
611
+ );
612
+ }
613
+ if (/^search failed$/i.test(raw)) {
614
+ return (
615
+ `${raw} — the add-in withholds the reason (it can leak index internals); ` +
616
+ `the detail is in the add-in's logs. Retry once, then try 'scope:all'.`
617
+ );
618
+ }
619
+ return raw;
620
+ }
621
+
622
+ // Zero results is now a routine, ambiguous outcome: the add-in answers "success,
623
+ // nothing" for a corpus that isn't connected, one that is still embedding, and a
624
+ // folder filter that matches nothing — all indistinguishable on the wire. Say that
625
+ // instead of letting an empty list read as "this content does not exist".
626
+ function writeEmptySearchDiagnostic(engineId, bundleMod) {
627
+ const isScope = bundleMod.isSearchScopeId(engineId);
628
+ const lines = [
629
+ ` No results — this does NOT mean the content doesn't exist. Any of these looks identical here:`,
630
+ ` · nothing is connected yet, so there is no corpus to search`,
631
+ ` · the corpus is still indexing (content appears progressively)`,
632
+ ];
633
+ if (!isScope) {
634
+ lines.push(
635
+ ` · this engine is a saved FOLDER FILTER and the content sits outside it`,
636
+ );
637
+ }
638
+ lines.push(` Next:`);
639
+ if (!isScope) {
640
+ lines.push(
641
+ ` · widen to the unfiltered corpus: slidemind search "<query>" --engine ${bundleMod.SCOPE_ALL} --result-type slide`,
642
+ );
643
+ }
644
+ lines.push(
645
+ ` · re-word the query (content search matches phrasing, so a synonym can decide a hit)`,
646
+ ` · slidemind ls engines — see every engine and scope you can reach`,
647
+ );
648
+ if (isScope) {
649
+ lines.push(
650
+ ` · if the unfiltered corpus is also empty, report it as "not found" and name what you searched — don't author a replacement as if reuse had been tried`,
651
+ );
652
+ }
653
+ process.stdout.write(`${lines.join("\n")}\n`);
654
+ }
655
+
656
+ // Write the search's numbered collage to a file and tell the agent to look at it.
657
+ //
658
+ // The add-in composes ONE bounded 800x600 image showing every candidate with its
659
+ // selection number burned in — the same picture the in-app agent sees. That is the
660
+ // intended way to choose a visual result: one look at one file, rather than N
661
+ // per-result previews (which is also why the relay ack carries no per-result bytes
662
+ // — a corpus image is multi-megabyte and N of them can't cross the relay).
663
+ //
664
+ // Best-effort by design: a missing/…unparseable grid must never fail a search that
665
+ // otherwise returned usable results. Content-intent searches have no grid at all —
666
+ // their signal is the printed text.
667
+ function writeSearchGrid(gridDataUri, searchId, resolveExt) {
668
+ if (typeof gridDataUri !== "string" || !gridDataUri.startsWith("data:")) {
669
+ return null;
670
+ }
671
+ try {
672
+ const m = /^data:([^;]+);base64,(.+)$/.exec(gridDataUri);
673
+ if (!m) return null;
674
+ const bytes = Buffer.from(m[2], "base64");
675
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-grid-"));
676
+ const target = path.join(dir, `grid-${searchId}.${resolveExt(m[1], bytes)}`);
677
+ fs.writeFileSync(target, bytes);
678
+ return target;
679
+ } catch {
680
+ return null;
681
+ }
682
+ }
683
+
436
684
  async function cmdSearch(rest) {
437
685
  let asJson = false;
438
686
  let to = null;
439
687
  let engineId = null;
688
+ let resultType = null;
440
689
  let intent = null;
441
690
  let top = null;
442
691
  const queryParts = [];
@@ -450,6 +699,12 @@ async function cmdSearch(rest) {
450
699
  } else if (a === "--engine" || a === "-e" || a === "--engineId") {
451
700
  engineId = rest[++i];
452
701
  if (!engineId) fail("--engine expects an engineId");
702
+ } else if (a === "--result-type" || a === "--resultType") {
703
+ resultType = rest[++i];
704
+ if (!resultType) fail("--result-type expects slide|image");
705
+ if (!["slide", "image"].includes(resultType)) {
706
+ fail(`--result-type expects slide|image, got '${resultType}'`);
707
+ }
453
708
  } else if (a === "--intent") {
454
709
  intent = rest[++i];
455
710
  if (!intent) fail("--intent expects auto|content|visual");
@@ -472,7 +727,7 @@ async function cmdSearch(rest) {
472
727
  const query = queryParts.join(" ").trim();
473
728
  if (!query) {
474
729
  fail(
475
- `usage: slidemind search "<query>" [--engine <id>] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
730
+ `usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
476
731
  );
477
732
  }
478
733
 
@@ -484,6 +739,9 @@ async function cmdSearch(rest) {
484
739
  const bundle = await import(
485
740
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
486
741
  );
742
+ const { resolveAssetExt } = await import(
743
+ pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
744
+ );
487
745
  const registry = await client.requirePaneHost();
488
746
  // The engine is per-user, not window-bound — so search just attaches to any
489
747
  // connected window (preferring a live one). No "which window?" prompt; pass
@@ -493,42 +751,64 @@ async function cmdSearch(rest) {
493
751
  const presId = entry.presId;
494
752
  const backendURL = entry.backendURL ?? null;
495
753
 
496
- // Slide search needs an explicit engine — the bundle's image-engine default
497
- // only applies to image/visual intent. content/visual on a slide engine is
498
- // the caller's choice via --engine.
754
+ // An explicit content/visual intent means "search slides", not imagery.
499
755
  const slideIntent = intent === "content" || intent === "visual";
500
756
  // Thread the engine subtype on the wire whenever the CLI can determine it:
501
757
  // - the pickImageEngine default branch is, by construction, an image engine;
502
- // - an explicit slide intent (--intent content|visual) targets a slide
503
- // engine.
758
+ // - an explicit slide intent (--intent content|visual) targets slides.
504
759
  // This is belt-and-suspenders for an add-in regression where a missing
505
760
  // subtype no longer defaulted to "image"; when the subtype is unknown we omit
506
761
  // it and let the add-in resolve it from the snapshot.
507
762
  let subtype = slideIntent ? "slide" : null;
508
763
  if (!engineId) {
509
764
  if (slideIntent) {
510
- fail(
511
- "slide search requires --engine <id>. Run 'slidemind ls engines --type search --subtype slide' to find one.",
512
- );
513
- }
514
- const mat = await bundle.materializeBundle(presId, { backendURL });
515
- let pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
516
- if (!pick) {
517
- // New backends no longer ship engines[] on the bundle wire — discovery
518
- // is live-only. Fall back to the catalog the add-in's own design agent
519
- // uses (its list_engines tool ≙ `slidemind ls engines`).
765
+ // Slide search with no engine named: search the corpus UNFILTERED rather
766
+ // than refusing. An engine is a saved folder filter over the add-in's
767
+ // shared corpus, so a workspace can hold a full corpus and zero slide
768
+ // engines — demanding --engine here dead-ended exactly that case.
769
+ engineId = bundle.SCOPE_ALL;
770
+ resultType = resultType ?? "slide";
771
+ } else {
772
+ // Imagery: resolve a real image engine (a scope is not a brand image
773
+ // source). Ask the LIVE catalog first — the same one the add-in's own
774
+ // design agent reads — and fall back to the materialized bundle only when
775
+ // the host can't answer. Bundle-first would be stale: engine ids can be
776
+ // retired server-side, and a dead id surfaces as "Engine not found".
520
777
  const live = await client
521
778
  .listEngines({ subtype: "image", backendURL })
522
779
  .catch(() => ({ engines: [] }));
523
- pick = bundle.pickImageEngine(live.engines);
524
- }
525
- if (!pick) {
526
- fail(
527
- "This corporate design has no image engines to search. Run 'slidemind ls engines' to see what exists, or ask the add-in owner to wire an image/icon engine.",
528
- );
780
+ let pick = bundle.pickImageEngine(live.engines);
781
+ if (!pick) {
782
+ // Best-effort: a bundle that can't be materialized (e.g. an incomplete
783
+ // one) must not replace the actionable "wire an image engine" message
784
+ // below with a bundle error — the live catalog is the real answer here.
785
+ const mat = await bundle
786
+ .materializeBundle(presId, { backendURL })
787
+ .catch(() => null);
788
+ if (mat) pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
789
+ }
790
+ if (!pick) {
791
+ fail(
792
+ "This corporate design has no image engines to search. Run 'slidemind ls engines' to see what exists, or ask the add-in owner to wire an image/icon engine.",
793
+ );
794
+ }
795
+ engineId = pick.id;
796
+ subtype = "image";
529
797
  }
530
- engineId = pick.id;
531
- subtype = "image";
798
+ }
799
+
800
+ // A scope has no subtype of its own, so the index must be named explicitly —
801
+ // the add-in rejects a scope search without it. Default to slides: reusing an
802
+ // existing slide is what an unfiltered corpus search is overwhelmingly for.
803
+ if (bundle.isSearchScopeId(engineId)) {
804
+ resultType = resultType ?? "slide";
805
+ subtype = resultType;
806
+ } else if (resultType) {
807
+ // Harmless but meaningless on an engine, whose own subtype is authoritative.
808
+ process.stderr.write(
809
+ `note: --result-type is ignored for engine '${engineId}' (it applies to scope:* targets); the engine's own type decides.\n`,
810
+ );
811
+ resultType = null;
532
812
  }
533
813
 
534
814
  // Window-agnostic: tries the picked pane, then fails over to any other live
@@ -540,6 +820,7 @@ async function cmdSearch(rest) {
540
820
  engineId,
541
821
  query,
542
822
  subtype,
823
+ resultType,
543
824
  intent: intent ?? null,
544
825
  top: top ?? null,
545
826
  });
@@ -550,7 +831,7 @@ async function cmdSearch(rest) {
550
831
  return;
551
832
  }
552
833
  if (!res?.ok) {
553
- process.stderr.write(`search failed: ${res?.error ?? "unknown error"}\n`);
834
+ process.stderr.write(`search failed: ${searchFailureHint(res?.error, engineId)}\n`);
554
835
  return;
555
836
  }
556
837
  // Prefer the new `results` array (image + slide engines); fall back to the
@@ -559,11 +840,18 @@ async function cmdSearch(rest) {
559
840
  ? res.results
560
841
  : (res.thumbnails ?? []).map((t, i) => ({ selection: i + 1, title: t.title }));
561
842
  const count = res.resultCount ?? results.length;
843
+ const targetLabel = bundle.isSearchScopeId(engineId)
844
+ ? `scope ${engineId}`
845
+ : `engine ${engineId}`;
562
846
  process.stdout.write(
563
- `searchId ${res.searchId} — ${count} result(s) from engine ${engineId}` +
847
+ `searchId ${res.searchId} — ${count} result(s) from ${targetLabel}` +
564
848
  (res.intent ? ` (${res.intent})` : "") +
565
849
  `\n`,
566
850
  );
851
+ if (count === 0) {
852
+ writeEmptySearchDiagnostic(engineId, bundle);
853
+ return;
854
+ }
567
855
  const isContent = res.intent === "content";
568
856
  results.forEach((r, i) => {
569
857
  const sel = r.selection ?? i + 1;
@@ -577,10 +865,27 @@ async function cmdSearch(rest) {
577
865
  }
578
866
  }
579
867
  } else {
580
- // Visual / image results: the numbered list.
581
- process.stdout.write(` ${sel}. ${r.title ?? "(untitled)"}\n`);
868
+ // Visual / image results: the numbered list. An image result carries its
869
+ // intrinsic size from the engine index — show it, so the box can be sized
870
+ // to the real aspect ratio at authoring time rather than after the fetch.
871
+ const dims =
872
+ r.kind === "image" && Number.isFinite(r.width) && Number.isFinite(r.height)
873
+ ? ` ${r.width}×${r.height}`
874
+ : "";
875
+ process.stdout.write(` ${sel}. ${r.title ?? "(untitled)"}${dims}\n`);
582
876
  }
583
877
  });
878
+ // Visual results: point at the ONE collage rather than making the caller pull
879
+ // each preview separately. The numbers burned into it match the list above.
880
+ if (!isContent) {
881
+ const gridPath = writeSearchGrid(res.gridDataUri, res.searchId, resolveAssetExt);
882
+ if (gridPath) {
883
+ process.stdout.write(
884
+ `Contact sheet of all ${count} result(s) — LOOK AT THIS FILE to choose, ` +
885
+ `don't fetch previews one by one: ${gridPath}\n`,
886
+ );
887
+ }
888
+ }
584
889
  // Tailor the next-step hint to the result kind: slide verbs (insert-slide /
585
890
  // add-slide) for slide results, the image-download hint otherwise. The result
586
891
  // subtype is authoritative; fall back to a per-result `kind` for older
@@ -609,7 +914,7 @@ async function cmdSearch(rest) {
609
914
  async function cmdLs(rest) {
610
915
  const [sub, ...subRest] = rest;
611
916
  if (sub !== "engines") {
612
- fail("usage: slidemind ls engines [--type collection|search] [--subtype image|slide|template|content] [--json]");
917
+ fail("usage: slidemind ls engines [--type collection|search|scope] [--subtype image|slide|template|content] [--json]");
613
918
  }
614
919
  let type = null;
615
920
  let subtype = null;
@@ -620,9 +925,10 @@ async function cmdLs(rest) {
620
925
  const a = subRest[i];
621
926
  if (a === "--type") {
622
927
  type = subRest[++i];
623
- if (!type) fail("--type expects collection|search");
624
- if (!["collection", "search"].includes(type)) {
625
- fail(`--type expects collection|search, got '${type}'`);
928
+ if (!type) fail("--type expects collection|search|scope");
929
+ // "scope" narrows to the non-engine targets (scope:all / scope:sharepoint).
930
+ if (!["collection", "search", "scope"].includes(type)) {
931
+ fail(`--type expects collection|search|scope, got '${type}'`);
626
932
  }
627
933
  } else if (a === "--subtype") {
628
934
  subtype = subRest[++i];
@@ -659,7 +965,18 @@ async function cmdLs(rest) {
659
965
  return;
660
966
  }
661
967
  if (engines.length === 0) {
662
- process.stderr.write("No engines available for the active corporate design.\n");
968
+ // Deliberately not "nothing to search": an engine is a saved folder filter
969
+ // over the add-in's shared corpus, so an empty catalog says only that nobody
970
+ // saved a filter. An older add-in that predates scopes lands here too.
971
+ const { SCOPE_ALL } = await import(
972
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
973
+ );
974
+ process.stderr.write(
975
+ "No engines or scopes matched. An engine is a saved folder filter over the corpus, " +
976
+ "so an empty list does NOT mean there is nothing to search — try " +
977
+ `'slidemind search "<query>" --engine ${SCOPE_ALL} --result-type slide' ` +
978
+ "to search it unfiltered.\n",
979
+ );
663
980
  return;
664
981
  }
665
982
  // Show the env tag only when more than one environment contributed engines.
@@ -668,7 +985,15 @@ async function cmdLs(rest) {
668
985
  const kind = [e.type, e.subtype].filter(Boolean).join("/");
669
986
  const env = multiEnv && e.env ? `[${e.env}] ` : "";
670
987
  const desc = e.description ? ` — ${e.description}` : "";
671
- process.stdout.write(`${env}${e.id} · ${e.label ?? e.id} · ${kind}${desc}\n`);
988
+ // A scope takes --result-type instead of having a subtype of its own; spell
989
+ // that out so the agent doesn't have to infer it from the missing subtype.
990
+ const hint =
991
+ e.type === "scope"
992
+ ? ` (needs --result-type ${(e.capabilities?.resultTypes ?? ["slide", "image"]).join("|")})`
993
+ : "";
994
+ process.stdout.write(
995
+ `${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${desc}\n`,
996
+ );
672
997
  }
673
998
  }
674
999
 
@@ -867,18 +1192,61 @@ async function cmdAddSlide(rest) {
867
1192
  const reused = await import(
868
1193
  pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href
869
1194
  );
1195
+ const { createTransferProgress } = await import(
1196
+ pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
1197
+ );
1198
+ // Stream each pick to a provisional file: the play-order filename can only be
1199
+ // computed once every pick is in hand, and buffering a multi-megabyte slide in
1200
+ // memory just to name it would defeat the streaming path.
870
1201
  const extracted = [];
871
- for (const pick of picks) {
872
- const res = await client.extractSlide({
873
- presId,
874
- searchId,
875
- selection: pick,
876
- backendURL,
877
- });
878
- if (!res?.ok || !res.base64) {
879
- fail(`add-slide failed for selection ${pick}: ${res?.error ?? "no slide bytes returned"}`);
1202
+ const cleanupTemps = () => {
1203
+ for (const s of extracted) {
1204
+ if (s.tempPath) fs.rmSync(s.tempPath, { force: true });
1205
+ }
1206
+ };
1207
+ try {
1208
+ for (const pick of picks) {
1209
+ const tempPath = path.join(slidesDir, `.reused-${searchId}-${pick}.part`);
1210
+ if (typeof client.extractSlideToFile === "function") {
1211
+ try {
1212
+ const res = await client.extractSlideToFile({
1213
+ presId,
1214
+ searchId,
1215
+ selection: pick,
1216
+ destPath: tempPath,
1217
+ backendURL,
1218
+ onProgress: createTransferProgress(`extracting slide ${pick}`),
1219
+ });
1220
+ if (!res?.bytesWritten) throw new Error("stream produced no bytes");
1221
+ extracted.push({ selection: pick, slideId: res.slideId ?? null, tempPath });
1222
+ continue;
1223
+ } catch (err) {
1224
+ fs.rmSync(tempPath, { force: true });
1225
+ // Route absent (bodyless 404 / 501) → this add-in predates the streaming
1226
+ // route; fall through to the capped base64 ack path below.
1227
+ const routeMissing =
1228
+ (err?.status === 404 && !err?.code) || err?.status === 501;
1229
+ if (!routeMissing) {
1230
+ cleanupTemps();
1231
+ fail(`add-slide failed for selection ${pick}: ${err.message}`);
1232
+ }
1233
+ }
1234
+ }
1235
+ const res = await client.extractSlide({
1236
+ presId,
1237
+ searchId,
1238
+ selection: pick,
1239
+ backendURL,
1240
+ });
1241
+ if (!res?.ok || !res.base64) {
1242
+ cleanupTemps();
1243
+ fail(`add-slide failed for selection ${pick}: ${res?.error ?? "no slide bytes returned"}`);
1244
+ }
1245
+ extracted.push({ selection: pick, slideId: res.slideId ?? null, base64: res.base64 });
880
1246
  }
881
- extracted.push({ selection: pick, slideId: res.slideId ?? null, base64: res.base64 });
1247
+ } catch (err) {
1248
+ cleanupTemps();
1249
+ throw err;
882
1250
  }
883
1251
  const existing = fs
884
1252
  .readdirSync(slidesDir)
@@ -887,7 +1255,11 @@ async function cmdAddSlide(rest) {
887
1255
  const written = [];
888
1256
  extracted.forEach((slide, i) => {
889
1257
  const target = path.join(slidesDir, targets[i].filename);
890
- fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
1258
+ if (slide.tempPath) {
1259
+ fs.renameSync(slide.tempPath, target);
1260
+ } else {
1261
+ fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
1262
+ }
891
1263
  written.push(path.relative(process.cwd(), target));
892
1264
  });
893
1265
  process.stdout.write(
@@ -957,7 +1329,7 @@ async function cmdViewSlide(rest) {
957
1329
  const client = await import(
958
1330
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
959
1331
  );
960
- const { extFromMime } = await import(
1332
+ const { resolveAssetExt } = await import(
961
1333
  pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
962
1334
  );
963
1335
  const registry = await client.requirePaneHost();
@@ -979,8 +1351,15 @@ async function cmdViewSlide(rest) {
979
1351
  ? path.resolve(process.cwd(), outDir)
980
1352
  : fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-view-"));
981
1353
  fs.mkdirSync(dir, { recursive: true });
982
- const target = path.join(dir, `slide-${searchId}-${pick}.${extFromMime(m[1])}`);
983
- fs.writeFileSync(target, Buffer.from(m[2], "base64"));
1354
+ // Extension from the BYTES, not the declared MIME — engines inherit a
1355
+ // Content-Type from upstream and it is demonstrably wrong for some corpus
1356
+ // images (PNG served as image/jpeg).
1357
+ const bytes = Buffer.from(m[2], "base64");
1358
+ const target = path.join(
1359
+ dir,
1360
+ `slide-${searchId}-${pick}.${resolveAssetExt(m[1], bytes)}`,
1361
+ );
1362
+ fs.writeFileSync(target, bytes);
984
1363
  process.stdout.write(
985
1364
  `Wrote preview of result ${pick} (searchId ${searchId}) to ${target}\n`,
986
1365
  );
@@ -1058,7 +1437,11 @@ async function cmdDownloadResult(rest) {
1058
1437
  ? r.value
1059
1438
  : { selection: picks[i], error: r.reason?.message ?? String(r.reason) },
1060
1439
  );
1440
+ const { formatSize } = await import(
1441
+ pathToFileURL(path.join(SRC_DIR, "bridge", "image-dimensions.mjs")).href
1442
+ );
1061
1443
  let ok = 0;
1444
+ let anySized = false;
1062
1445
  const lines = [];
1063
1446
  for (const r of results) {
1064
1447
  if (r.error) {
@@ -1067,12 +1450,21 @@ async function cmdDownloadResult(rest) {
1067
1450
  }
1068
1451
  ok++;
1069
1452
  const note = r.recolor?.note ? ` — ${r.recolor.note}` : "";
1070
- lines.push(` ${r.reference} (${r.recolor?.mode ?? "?"})${note}`);
1453
+ // The intrinsic size is the agent's only handle on the asset's aspect
1454
+ // ratio — without it the <img> box is a guess and the render stretches.
1455
+ const dims = formatSize(r.size);
1456
+ anySized ||= Boolean(dims);
1457
+ const size = dims ? ` ${dims}` : " (size unknown)";
1458
+ lines.push(` ${r.reference}${size} (${r.recolor?.mode ?? "?"})${note}`);
1071
1459
  }
1072
1460
  process.stdout.write(
1073
1461
  `Downloaded ${ok}/${results.length} result(s) into ${path.relative(process.cwd(), assetsDir)}:\n` +
1074
1462
  lines.join("\n") +
1075
- `\nReference each from slide JSX as <img src="./assets/...">.\n`,
1463
+ `\nReference each from slide JSX as <img src="./assets/...">.\n` +
1464
+ (anySized
1465
+ ? `Sizes above are the assets' intrinsic width×height — give each <img> a box ` +
1466
+ `with that aspect ratio, or the render stretches it to whatever box you wrote.\n`
1467
+ : ""),
1076
1468
  );
1077
1469
  if (ok === 0) process.exit(1);
1078
1470
  }
@@ -1392,9 +1784,13 @@ function publicConnectionStatus(status, client) {
1392
1784
  // unmet precondition, or null when the connection is fully ready. `code` is the
1393
1785
  // machine-copeable reason an agent switches on; `actor` says who can clear it —
1394
1786
  // only `cli_update_required` is the AGENT's to fix (it can run `slidemind
1395
- // update`), every other rung needs a human at the add-in. The `message` strings
1396
- // are BYTE-IDENTICAL to the old connectionNextAction ladder so doctor's printed
1397
- // output never churns (connectionNextAction is now a thin projection over this).
1787
+ // update`), every other rung needs a human at the add-in. The one exception is
1788
+ // a plugin-installed CLI: `/plugin` is the only updater that replaces the
1789
+ // binary on the session's PATH, and the agent cannot drive it, so that rung
1790
+ // hands back to the user (see src/install-channel.mjs). The `message` strings
1791
+ // are otherwise BYTE-IDENTICAL to the old connectionNextAction ladder so
1792
+ // doctor's printed output never churns (connectionNextAction is now a thin
1793
+ // projection over this).
1398
1794
  // `slidemind ready` layers `ambiguous_design` on top and treats `pane_suspended`
1399
1795
  // as non-blocking (a render-ready-but-suspended window is still a design source).
1400
1796
  function connectionBlocker(pub) {
@@ -1420,11 +1816,11 @@ function connectionBlocker(pub) {
1420
1816
  if (outdated) {
1421
1817
  return {
1422
1818
  code: "cli_update_required",
1423
- actor: "agent",
1819
+ actor: isClaudePluginInstall() ? "user" : "agent",
1424
1820
  message:
1425
1821
  `This CLI is older than the minimum the '${outdated.env}' add-in supports` +
1426
1822
  `${outdated.minCliVersion ? ` (${outdated.minCliVersion})` : ""}. ` +
1427
- "Run `slidemind update` (or `npm install -g slidemind@latest`), then retry.",
1823
+ updateCliInstruction(),
1428
1824
  };
1429
1825
  }
1430
1826
  if ((pub.presentations ?? []).length === 0) {
@@ -1561,6 +1957,10 @@ async function cmdReady(rest) {
1561
1957
  const client = await import(
1562
1958
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1563
1959
  );
1960
+ // Same opt-out as doctor: `ready` OWNS the cli_update_required blocker (it is
1961
+ // terminal, `actor: "agent"`, with the update command as its nextAction), so
1962
+ // the raw header throw would only replace a structured answer with a worse one.
1963
+ client.setDiagnosticMode(true);
1564
1964
 
1565
1965
  // --- Network (one parallel round): readiness ladder + the merged registry. ---
1566
1966
  const [statusRaw, detailed] = await Promise.all([
@@ -1878,6 +2278,9 @@ async function cmdDoctor(rest) {
1878
2278
  const client = await import(
1879
2279
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1880
2280
  );
2281
+ // doctor diagnoses skew — it must never be killed BY skew. The version floor
2282
+ // still reaches the reader, as the `cli_update_required` rung of the ladder.
2283
+ client.setDiagnosticMode(true);
1881
2284
  if (sessions.length === 0) {
1882
2285
  const pub = {
1883
2286
  paired: false,
@@ -2534,6 +2937,20 @@ async function cmdUpdate(rest) {
2534
2937
  const ui = await loadInstallerUi();
2535
2938
  ui.banner(pkg.version, "update");
2536
2939
 
2940
+ // A plugin-installed CLI cannot update itself this way: npm would drop a
2941
+ // second copy into the global prefix while the plugin session keeps running
2942
+ // its own. Say so instead of letting the user believe they are now current —
2943
+ // the global install is still worth doing (it is what a plain terminal
2944
+ // outside Claude Code picks up), so this warns and continues.
2945
+ if (isClaudePluginInstall()) {
2946
+ ui.step({
2947
+ status: "warn",
2948
+ label: "plugin",
2949
+ value:
2950
+ "this slidemind came from the Claude Code plugin — update it with `/plugin` → slidemind → update; the global install below does NOT replace it inside plugin sessions",
2951
+ });
2952
+ }
2953
+
2537
2954
  // Upgrade the global install straight from public npm. `@latest` (or a pinned
2538
2955
  // version) lets npm resolve + verify the tarball — no SAS, no checksum dance.
2539
2956
  const spec = ref
@@ -2610,6 +3027,9 @@ async function main(rawArgv) {
2610
3027
  case "review":
2611
3028
  await cmdReview(rest);
2612
3029
  return 0;
3030
+ case "imagen":
3031
+ await cmdImagen(rest);
3032
+ break;
2613
3033
  case "build-pptx":
2614
3034
  await cmdBuildPptx(rest);
2615
3035
  return 0;