slidemind 0.1.6 → 0.1.7

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
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Subcommands:
5
5
  // slidemind init [--for <target>] set up slidemind in a coding harness
6
- // (register the MCP server + skill);
6
+ // (deploy the skill);
7
7
  // targets: claude | codex | opencode | all
8
8
  // (`install` is a back-compat alias)
9
9
  // slidemind new <name> scaffold ./decks/<name>/{plan.md,slides/}
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { spawn } from "node:child_process";
24
24
  import fs from "node:fs";
25
+ import os from "node:os";
25
26
  import path from "node:path";
26
27
  import { fileURLToPath, pathToFileURL } from "node:url";
27
28
 
@@ -44,11 +45,20 @@ function printHelp() {
44
45
  `slidemind ${pkg.version} — drive the slidemind PowerPoint add-in to build native, on-brand decks\n` +
45
46
  `\n` +
46
47
  `Usage:\n` +
47
- ` slidemind new <name> Scaffold ./decks/<name>/\n` +
48
+ ` slidemind ready [deck] One-command session setup — run this FIRST each session.\n` +
49
+ ` Verifies pairing/connection/CLI version, resolves the open\n` +
50
+ ` design, materializes its bundle, and (with a deck) binds it.\n` +
51
+ ` Exit 0 = ready; non-zero prints one actionable blocker.\n` +
52
+ ` --to <presId> bind to a specific open window\n` +
53
+ ` --force re-download the bundle even if current\n` +
54
+ ` --connect-only check the connection only (no design/bundle)\n` +
55
+ ` --json machine-readable readiness payload\n` +
56
+ ` slidemind new <name> Scaffold ./decks/<name>/ (auto-binds to the open design)\n` +
57
+ ` --to <presId> bind to a specific open window\n` +
58
+ ` --no-bind skip the auto-bind\n` +
48
59
  ` slidemind build <deck> Build self-contained HTML\n` +
49
60
  ` slidemind review <deck> Open a spatial preview review; blocks until the user\n` +
50
61
  ` approves or leaves comments (host-free)\n` +
51
- ` --no-open don't auto-open the browser\n` +
52
62
  ` --port <n> bind a fixed port (default: ephemeral)\n` +
53
63
  ` slidemind build-pptx <deck> Export native .pptx via the add-in (requires an open host)\n` +
54
64
  ` --to <presId> target a specific window (required if >1 open)\n` +
@@ -65,9 +75,16 @@ function printHelp() {
65
75
  ` --top <N> max results\n` +
66
76
  ` --to <presId> target a specific window (required if >1 open)\n` +
67
77
  ` --json print the raw result payload\n` +
68
- ` slidemind ls engines List the live engine catalog for the active corporate design\n` +
78
+ ` slidemind view-slide [opts] Fetch a slide search result's preview image to a file (requires a host)\n` +
79
+ ` --search <id> searchId from 'slidemind search'\n` +
80
+ ` --pick <n> 1-based selection number to view\n` +
81
+ ` --to <presId> the window the search ran on (required if >1 open)\n` +
82
+ ` --out <dir> output dir (default: a slidemind temp dir)\n` +
83
+ ` slidemind ls engines List the live engine catalog for an active pane\n` +
69
84
  ` --type <t> collection | search\n` +
70
85
  ` --subtype <s> image | slide | template | content\n` +
86
+ ` --to <presId> target a specific window/backend\n` +
87
+ ` --all include every paired backend\n` +
71
88
  ` --json print the raw engine list\n` +
72
89
  ` slidemind insert-slide [opts] Insert reused slide search results live into a window (requires a host)\n` +
73
90
  ` --search <id> searchId from 'slidemind search'\n` +
@@ -80,10 +97,17 @@ function printHelp() {
80
97
  ` --deck <deck> deck to add the slides to\n` +
81
98
  ` --to <presId> target the window the search ran on (required if >1 open)\n` +
82
99
  ` --at <N> numeric prefix to start at (default: append)\n` +
100
+ ` slidemind download-result [opts] Download image/icon search result(s) into a deck's assets/ (requires a host)\n` +
101
+ ` --search <id> searchId from 'slidemind search'\n` +
102
+ ` --pick <n,..> 1-based selection number(s)\n` +
103
+ ` --deck <deck> deck to write the asset(s) into\n` +
104
+ ` --to <presId> the window the search ran on (required if >1 open)\n` +
105
+ ` --icon-color <hex[,hex,..]> bake a colour into SVG result(s) (one for all, or one per --pick)\n` +
83
106
  ` slidemind download-bundle [deck] Materialize the active corporate design's bundle locally\n` +
84
107
  ` --to <presId> target a specific window\n` +
85
108
  ` --force re-download even if the cached bundle is current\n` +
86
- ` slidemind presentations List PowerPoint windows registered with the add-in\n` +
109
+ ` slidemind presentations List active PowerPoint panes registered with the add-in\n` +
110
+ ` --all include retained suspended entries\n` +
87
111
  ` slidemind doctor Diagnose CLI/add-in pairing, backend, and pane readiness\n` +
88
112
  ` --json print machine-readable status\n` +
89
113
  ` slidemind read [presId] Pull the current content of an open presentation (requires a host)\n` +
@@ -101,7 +125,7 @@ function printHelp() {
101
125
  ` slidemind whoami Show every paired environment (* = default)\n` +
102
126
  ` slidemind use <url|env> Set the default environment\n` +
103
127
  ` slidemind logout [url|env] Unpair one environment (--all to unpair every one)\n` +
104
- ` slidemind init --for <t> Set up slidemind in a harness (registers the MCP server + skill; clears the bundle cache)\n` +
128
+ ` slidemind init --for <t> Set up slidemind in a harness (deploys the skill; clears the bundle cache)\n` +
105
129
  ` slidemind uninstall --for <t> Remove a previously-installed skill (clears the bundle cache)\n` +
106
130
  ` slidemind update [--for <t>] Fetch the latest release and redeploy\n` +
107
131
  ` --for <t> harness to redeploy into (default: all)\n` +
@@ -140,6 +164,31 @@ function resolveDeckArg(arg) {
140
164
  return path.join(decksDir, arg);
141
165
  }
142
166
 
167
+ // Best-effort human name of a materialized design: the first `# ` heading in
168
+ // the bundle's design.md, with the add-in's trailing " — design rules" suffix
169
+ // stripped. Returns null on any failure (missing file, no heading) so callers
170
+ // fall back to the raw presFP. Shared by `ready` and `new`'s auto-bind.
171
+ function readDesignName(bundleDir) {
172
+ if (!bundleDir) return null;
173
+ try {
174
+ const md = fs.readFileSync(path.join(bundleDir, "design.md"), "utf8");
175
+ for (const raw of md.split("\n")) {
176
+ const line = raw.trim();
177
+ if (line.startsWith("# ")) {
178
+ const name = line
179
+ .slice(2)
180
+ .trim()
181
+ .replace(/\s+[—-]\s+design rules\s*$/i, "")
182
+ .trim();
183
+ return name || null;
184
+ }
185
+ }
186
+ return null;
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
143
192
  function runNode(script, args) {
144
193
  return new Promise((resolve, reject) => {
145
194
  const child = spawn(process.execPath, [script, ...args], {
@@ -238,8 +287,9 @@ async function cmdBuildPptx(rest) {
238
287
  if (!fs.existsSync(path.join(deckDir, "slides"))) {
239
288
  fail(`expected 'slides/' folder in ${deckDir}`);
240
289
  }
241
- // The .pptx is rendered server-side by the add-in (one exporter, one truth);
242
- // this requires an open PowerPoint host with an active corporate design.
290
+ // The .pptx is rendered server-side by the add-in (one exporter, one truth).
291
+ // A bound deck renders pane-free over the brand routes — no PowerPoint
292
+ // window needed; unbound decks (or --to) resolve through the pane registry.
243
293
  const mod = await import(
244
294
  pathToFileURL(path.join(SRC_DIR, "bridge", "build-and-push.mjs")).href
245
295
  );
@@ -250,7 +300,8 @@ async function cmdBuildPptx(rest) {
250
300
  onProgress: renderProgressPrinter(),
251
301
  });
252
302
  process.stdout.write(
253
- `Wrote ${result.outPath} (${result.slideCount ?? "?"} slides) via the slidemind add-in\n`,
303
+ `Wrote ${result.outPath} (${result.slideCount ?? "?"} slides) via the slidemind add-in` +
304
+ `${result.windowless ? " (no PowerPoint window needed)" : ""}\n`,
254
305
  );
255
306
  for (const w of result.warnings ?? []) {
256
307
  process.stderr.write(` warning: ${w}\n`);
@@ -496,8 +547,11 @@ async function cmdSearch(rest) {
496
547
  ` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck> --to ${ranPresId}\n`,
497
548
  );
498
549
  } else {
550
+ // The searchId is bound to the window the search ran on — download must
551
+ // target that same window, so spell out its presId in the next-step command.
499
552
  process.stdout.write(
500
- `Download an image result for a deck via the agent (slidemind_download_result, search_id ${res.searchId}).\n`,
553
+ `searchId ${res.searchId} ran on window ${ranPresId}${envTag} — download image result(s) into a deck:\n` +
554
+ ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> --to ${ranPresId} [--icon-color <hex>]\n`,
501
555
  );
502
556
  }
503
557
  }
@@ -510,6 +564,7 @@ async function cmdLs(rest) {
510
564
  let type = null;
511
565
  let subtype = null;
512
566
  let to = null;
567
+ let all = false;
513
568
  let asJson = false;
514
569
  for (let i = 0; i < subRest.length; i++) {
515
570
  const a = subRest[i];
@@ -526,10 +581,10 @@ async function cmdLs(rest) {
526
581
  fail(`--subtype expects image|slide|template|content, got '${subtype}'`);
527
582
  }
528
583
  } else if (a === "--to") {
529
- // Accepted for symmetry with other commands; engines are backend-scoped,
530
- // not presId-scoped, so it does not change the catalog.
531
584
  to = subRest[++i];
532
585
  if (!to) fail("--to expects a presId");
586
+ } else if (a === "--all") {
587
+ all = true;
533
588
  } else if (a === "--json") {
534
589
  asJson = true;
535
590
  } else if (a.startsWith("-")) {
@@ -538,11 +593,17 @@ async function cmdLs(rest) {
538
593
  fail(`unexpected argument: ${a}`);
539
594
  }
540
595
  }
541
- void to;
542
596
  const client = await import(
543
597
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
544
598
  );
545
- const { engines } = await client.listEngines({ type, subtype });
599
+ if (all && to) fail("--all cannot be combined with --to");
600
+ let backendURL = null;
601
+ if (!all) {
602
+ const registry = await client.requirePaneHost();
603
+ const entry = client.pickAnyPresentationEntry(registry, to);
604
+ backendURL = entry.backendURL ?? null;
605
+ }
606
+ const { engines } = await client.listEngines({ type, subtype, backendURL });
546
607
  if (asJson) {
547
608
  process.stdout.write(`${JSON.stringify(engines, null, 2)}\n`);
548
609
  return;
@@ -686,8 +747,8 @@ async function cmdAddSlide(rest) {
686
747
 
687
748
  // Extract each picked slide's bytes over the bridge, then let the shared
688
749
  // helper compute the play-order filenames (NN-reused-<shortid>.pptx). The
689
- // prefix math is the single shared unit (src/lib/reused-slides.mjs) so the
690
- // CLI and the MCP tool never diverge. Existing files are not renumbered.
750
+ // prefix math lives in one shared unit (src/lib/reused-slides.mjs). Existing
751
+ // files are not renumbered.
691
752
  const reused = await import(
692
753
  pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href
693
754
  );
@@ -721,6 +782,186 @@ async function cmdAddSlide(rest) {
721
782
  );
722
783
  }
723
784
 
785
+ // Map a `--icon-color` value onto the `--pick` list. Accepts either one colour
786
+ // (applied to every pick) or exactly one per pick (positional); an empty
787
+ // segment means "no colour for this pick". Returns an array aligned to picks.
788
+ function parseIconColors(raw, pickCount) {
789
+ if (!raw) return new Array(pickCount).fill(null);
790
+ const colors = raw
791
+ .split(",")
792
+ .map((s) => s.trim())
793
+ .map((s) => (s.length ? s : null));
794
+ for (const c of colors) {
795
+ if (c !== null && !/^#[0-9a-fA-F]{3,8}$/.test(c)) {
796
+ fail(`--icon-color expects hex colours like #0A7E8C, got '${c}'`);
797
+ }
798
+ }
799
+ if (colors.length === 1) return new Array(pickCount).fill(colors[0]);
800
+ if (colors.length !== pickCount) {
801
+ fail(
802
+ `--icon-color takes one colour (applied to all) or exactly ${pickCount} (one per --pick); got ${colors.length}`,
803
+ );
804
+ }
805
+ return colors;
806
+ }
807
+
808
+ async function cmdViewSlide(rest) {
809
+ let searchId = null;
810
+ let pick = null;
811
+ let to = null;
812
+ let outDir = null;
813
+ for (let i = 0; i < rest.length; i++) {
814
+ const a = rest[i];
815
+ if (a === "--search") {
816
+ const next = rest[++i];
817
+ const n = parseInt(next, 10);
818
+ if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
819
+ searchId = n;
820
+ } else if (a === "--pick") {
821
+ const next = rest[++i];
822
+ const n = parseInt(next, 10);
823
+ if (!Number.isFinite(n) || n <= 0) {
824
+ fail(`--pick expects a positive selection number, got '${next}'`);
825
+ }
826
+ pick = n;
827
+ } else if (a === "--to") {
828
+ to = rest[++i];
829
+ if (!to) fail("--to expects a presId");
830
+ } else if (a === "--out" || a === "-o") {
831
+ outDir = rest[++i];
832
+ if (!outDir) fail("--out expects a directory");
833
+ } else if (a.startsWith("-")) {
834
+ fail(`unknown flag: ${a}`);
835
+ } else {
836
+ fail(`unexpected argument: ${a}`);
837
+ }
838
+ }
839
+ if (searchId === null || pick === null) {
840
+ fail("usage: slidemind view-slide --search <id> --pick <n> --to <presId> [--out <dir>]");
841
+ }
842
+ const client = await import(
843
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
844
+ );
845
+ const { extFromMime } = await import(
846
+ pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
847
+ );
848
+ const registry = await client.requirePaneHost();
849
+ // The searchId is presentation-scoped, so --to targets the window the search
850
+ // ran on. Without it, a registry with >1 open window is a dead-end.
851
+ const entry = client.pickPresentationEntry(registry, to);
852
+ const res = await client.viewSlide({
853
+ presId: entry.presId,
854
+ searchId,
855
+ selection: pick,
856
+ backendURL: entry.backendURL ?? null,
857
+ });
858
+ if (!res?.ok || !res.dataUri) {
859
+ fail(`view-slide failed: ${res?.error ?? "no image returned"}`);
860
+ }
861
+ const m = /^data:([^;]+);base64,(.+)$/.exec(res.dataUri);
862
+ if (!m) fail("view-slide returned an unparseable data URI");
863
+ const dir = outDir
864
+ ? path.resolve(process.cwd(), outDir)
865
+ : fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-view-"));
866
+ fs.mkdirSync(dir, { recursive: true });
867
+ const target = path.join(dir, `slide-${searchId}-${pick}.${extFromMime(m[1])}`);
868
+ fs.writeFileSync(target, Buffer.from(m[2], "base64"));
869
+ process.stdout.write(
870
+ `Wrote preview of result ${pick} (searchId ${searchId}) to ${target}\n`,
871
+ );
872
+ }
873
+
874
+ async function cmdDownloadResult(rest) {
875
+ let searchId = null;
876
+ let picks = null;
877
+ let deck = null;
878
+ let to = null;
879
+ let iconColorsRaw = null;
880
+ for (let i = 0; i < rest.length; i++) {
881
+ const a = rest[i];
882
+ if (a === "--search") {
883
+ const next = rest[++i];
884
+ const n = parseInt(next, 10);
885
+ if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
886
+ searchId = n;
887
+ } else if (a === "--pick") {
888
+ picks = parsePicks(rest[++i]);
889
+ } else if (a === "--deck") {
890
+ deck = rest[++i];
891
+ if (!deck) fail("--deck expects a deck name or path");
892
+ } else if (a === "--to") {
893
+ to = rest[++i];
894
+ if (!to) fail("--to expects a presId");
895
+ } else if (a === "--icon-color" || a === "--icon-colour") {
896
+ iconColorsRaw = rest[++i];
897
+ if (!iconColorsRaw) fail("--icon-color expects a hex colour (or comma-separated list aligned to --pick)");
898
+ } else if (a.startsWith("-")) {
899
+ fail(`unknown flag: ${a}`);
900
+ } else {
901
+ fail(`unexpected argument: ${a}`);
902
+ }
903
+ }
904
+ if (searchId === null || !picks || !deck) {
905
+ fail(
906
+ "usage: slidemind download-result --search <id> --pick <n[,n,...]> --deck <deck> --to <presId> [--icon-color <hex[,hex,...]>]",
907
+ );
908
+ }
909
+ const iconColors = parseIconColors(iconColorsRaw, picks.length);
910
+ const deckDir = resolveDeckArg(deck);
911
+ const assetsDir = path.join(deckDir, "assets");
912
+ fs.mkdirSync(assetsDir, { recursive: true });
913
+
914
+ const client = await import(
915
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
916
+ );
917
+ // Shared image/icon download logic (src/bridge/engine-download.mjs).
918
+ const { downloadOneResult } = await import(
919
+ pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
920
+ );
921
+ const registry = await client.requirePaneHost();
922
+ // The searchId is presentation-scoped, so --to targets the window the search
923
+ // ran on. Without it, a registry with >1 open window is a dead-end.
924
+ const entry = client.pickPresentationEntry(registry, to);
925
+
926
+ // Concurrent — each download is its own bridge round-trip; allSettled so one
927
+ // bad selection doesn't sink the rest of the batch.
928
+ const settled = await Promise.allSettled(
929
+ picks.map((selection, i) =>
930
+ downloadOneResult({
931
+ client,
932
+ presId: entry.presId,
933
+ backendURL: entry.backendURL ?? null,
934
+ searchId,
935
+ assetsDir,
936
+ selection,
937
+ iconColor: iconColors[i],
938
+ }),
939
+ ),
940
+ );
941
+ const results = settled.map((r, i) =>
942
+ r.status === "fulfilled"
943
+ ? r.value
944
+ : { selection: picks[i], error: r.reason?.message ?? String(r.reason) },
945
+ );
946
+ let ok = 0;
947
+ const lines = [];
948
+ for (const r of results) {
949
+ if (r.error) {
950
+ lines.push(` ✗ selection ${r.selection}: ${r.error}`);
951
+ continue;
952
+ }
953
+ ok++;
954
+ const note = r.recolor?.note ? ` — ${r.recolor.note}` : "";
955
+ lines.push(` ${r.reference} (${r.recolor?.mode ?? "?"})${note}`);
956
+ }
957
+ process.stdout.write(
958
+ `Downloaded ${ok}/${results.length} result(s) into ${path.relative(process.cwd(), assetsDir)}:\n` +
959
+ lines.join("\n") +
960
+ `\nReference each from slide JSX as <img src="./assets/...">.\n`,
961
+ );
962
+ if (ok === 0) process.exit(1);
963
+ }
964
+
724
965
  async function cmdDownloadBundle(rest) {
725
966
  let to = null;
726
967
  let force = false;
@@ -789,23 +1030,38 @@ async function cmdDownloadBundle(rest) {
789
1030
 
790
1031
  async function cmdPresentations(rest) {
791
1032
  const asJson = rest.includes("--json");
1033
+ const showAll = rest.includes("--all");
792
1034
  const mod = await import(
793
1035
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
794
1036
  );
795
- const list = await mod.listOpenPresentations();
1037
+ // `--all` is a raw immediate inspection snapshot (no wait). Otherwise wait out
1038
+ // the brief re-registration window after a pane reload / PowerPoint wake so a
1039
+ // freshly-suspended pane doesn't read as an empty list. Either way we LIST
1040
+ // every entry with its status — suspended panes are honest render/push targets
1041
+ // and hiding them made the CLI say "nothing" during the flaky window.
1042
+ const list = showAll
1043
+ ? await mod.listOpenPresentations()
1044
+ : (await mod.listReadyPresentationsWithWait()).all;
1045
+ // Connected/pane-ready panes first, then suspended/stale ones (stable sort
1046
+ // keeps each group's incoming order).
1047
+ const sorted = [...list].sort(
1048
+ (a, b) =>
1049
+ (mod.isPaneReadyPresentation(b) ? 1 : 0) -
1050
+ (mod.isPaneReadyPresentation(a) ? 1 : 0),
1051
+ );
796
1052
  if (asJson) {
797
1053
  process.stdout.write(
798
- JSON.stringify(list.map(mod.toPublicPresentation), null, 2) + "\n",
1054
+ JSON.stringify(sorted.map(mod.toPublicPresentation), null, 2) + "\n",
799
1055
  );
800
1056
  return;
801
1057
  }
802
- if (list.length === 0) {
1058
+ if (sorted.length === 0) {
803
1059
  process.stderr.write(
804
- "No PowerPoint windows registered with the slidemind add-in.\n",
1060
+ "No active PowerPoint panes registered with the slidemind add-in.\n",
805
1061
  );
806
1062
  return;
807
1063
  }
808
- for (const p of list) {
1064
+ for (const p of sorted) {
809
1065
  const label = p.title || p.fileName || "(untitled)";
810
1066
  const env = p.env ? `[${p.env}]\t` : "";
811
1067
  const state =
@@ -813,6 +1069,16 @@ async function cmdPresentations(rest) {
813
1069
  (p.connected === false || p.suspendedAt ? "suspended" : "connected");
814
1070
  const design = p.designState ? `\tcd:${p.designState}` : "";
815
1071
  process.stdout.write(`${p.presId}\t${env}${label}\t${state}${design}\n`);
1072
+ // A pane that can't take live ops yet (suspended/stale/hydrating) is still
1073
+ // listed — with the one actionable line to wake it — so the reader sees an
1074
+ // honest state instead of an empty registry.
1075
+ if (!mod.isPaneReadyPresentation(p)) {
1076
+ const hint =
1077
+ p.nextAction && typeof p.nextAction.message === "string"
1078
+ ? p.nextAction.message
1079
+ : "bring PowerPoint forward and click the slidemind pane";
1080
+ process.stdout.write(`\t${hint}\n`);
1081
+ }
816
1082
  }
817
1083
  }
818
1084
 
@@ -825,6 +1091,8 @@ function publicConnectionStatus(status, client) {
825
1091
  ok: b.ok,
826
1092
  readinessV1: b.readinessV1 === true,
827
1093
  capabilities: b.capabilities ?? null,
1094
+ minCliVersion: b.minCliVersion ?? null,
1095
+ cliUpdateRequired: b.cliUpdateRequired === true,
828
1096
  error: b.error,
829
1097
  summary: b.summary ?? null,
830
1098
  presentations: (b.presentations ?? []).map(client.toPublicPresentation),
@@ -833,15 +1101,52 @@ function publicConnectionStatus(status, client) {
833
1101
  };
834
1102
  }
835
1103
 
836
- function connectionNextAction(pub) {
1104
+ // The ordered connection-readiness ladder as a STRUCTURED blocker: the first
1105
+ // unmet precondition, or null when the connection is fully ready. `code` is the
1106
+ // machine-copeable reason an agent switches on; `actor` says who can clear it —
1107
+ // only `cli_update_required` is the AGENT's to fix (it can run `slidemind
1108
+ // update`), every other rung needs a human at the add-in. The `message` strings
1109
+ // are BYTE-IDENTICAL to the old connectionNextAction ladder so doctor's printed
1110
+ // output never churns (connectionNextAction is now a thin projection over this).
1111
+ // `slidemind ready` layers `ambiguous_design` on top and treats `pane_suspended`
1112
+ // as non-blocking (a render-ready-but-suspended window is still a design source).
1113
+ function connectionBlocker(pub) {
837
1114
  if (!pub.paired) {
838
- return 'Not paired. In the add-in open Settings → General → Connect CLI, press Pair, then run the shown `slidemind pair <code>`.';
1115
+ return {
1116
+ code: "not_paired",
1117
+ actor: "user",
1118
+ message:
1119
+ "Not paired. In the add-in open Settings → General → Connect CLI, press Pair, then run the shown `slidemind pair <code>`.",
1120
+ };
839
1121
  }
840
1122
  if ((pub.backends ?? []).every((b) => !b.ok)) {
841
- return "Every paired backend is unreachable. Check network/VPN/backend status, or re-pair if the environment moved.";
1123
+ return {
1124
+ code: "all_unreachable",
1125
+ actor: "user",
1126
+ message:
1127
+ "Every paired backend is unreachable. Check network/VPN/backend status, or re-pair if the environment moved.",
1128
+ };
1129
+ }
1130
+ // Version skew explains most downstream failures — surface it before pane
1131
+ // readiness so the user fixes the right thing first.
1132
+ const outdated = (pub.backends ?? []).find((b) => b.cliUpdateRequired);
1133
+ if (outdated) {
1134
+ return {
1135
+ code: "cli_update_required",
1136
+ actor: "agent",
1137
+ message:
1138
+ `This CLI is older than the minimum the '${outdated.env}' add-in supports` +
1139
+ `${outdated.minCliVersion ? ` (${outdated.minCliVersion})` : ""}. ` +
1140
+ "Run `slidemind update` (or `npm install -g slidemind@latest`), then retry.",
1141
+ };
842
1142
  }
843
1143
  if ((pub.presentations ?? []).length === 0) {
844
- return "No PowerPoint window is registered. Open PowerPoint, load the slidemind add-in, and activate or create a corporate design.";
1144
+ return {
1145
+ code: "no_window",
1146
+ actor: "user",
1147
+ message:
1148
+ "No PowerPoint window is registered. If PowerPoint is already open with the slidemind pane, bring it to the foreground (click the pane) and re-run. Otherwise open PowerPoint, load the slidemind add-in, and activate or create a corporate design.",
1149
+ };
845
1150
  }
846
1151
  const renderReady = pub.presentations.some(
847
1152
  (p) =>
@@ -850,7 +1155,12 @@ function connectionNextAction(pub) {
850
1155
  p.designState === "ready",
851
1156
  );
852
1157
  if (!renderReady) {
853
- return "PowerPoint is connected, but no window is render-ready. Wait for corporate design matching or activate/create a corporate design.";
1158
+ return {
1159
+ code: "not_render_ready",
1160
+ actor: "user",
1161
+ message:
1162
+ "PowerPoint is connected, but no window is render-ready. Wait for corporate design matching or activate/create a corporate design.",
1163
+ };
854
1164
  }
855
1165
  const paneReady = pub.presentations.some(
856
1166
  (p) =>
@@ -860,9 +1170,418 @@ function connectionNextAction(pub) {
860
1170
  !p.suspendedAt),
861
1171
  );
862
1172
  if (!paneReady) {
863
- return "Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push/search/read.";
1173
+ return {
1174
+ code: "pane_suspended",
1175
+ actor: "user",
1176
+ message:
1177
+ "Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push/search/read.",
1178
+ };
1179
+ }
1180
+ return null;
1181
+ }
1182
+
1183
+ // Back-compat projection for doctor: just the blocker's message, or "Ready."
1184
+ // when the ladder is clear. Keeps the ladder authored in ONE place.
1185
+ function connectionNextAction(pub) {
1186
+ return connectionBlocker(pub)?.message ?? "Ready.";
1187
+ }
1188
+
1189
+ // The one-command agent preflight. Collapses the old 4-call session start
1190
+ // (presentations → pick → download-bundle → download-bundle-to-bind) into a
1191
+ // single verb: check pairing/reachability/CLI-version/windows, resolve the OPEN
1192
+ // design (the add-in is the source of truth), materialize its bundle, bind the
1193
+ // deck, and print exactly one actionable blocker when it can't. Exit 0 iff
1194
+ // ready; every blocker → exit 1 with a machine-copeable code. Never prints a
1195
+ // backendURL (env label only).
1196
+ async function cmdReady(rest) {
1197
+ // --- Local arg parse: unknown flags fail BEFORE any network (cli-dispatch
1198
+ // convention). ---
1199
+ let to = null;
1200
+ let asJson = false;
1201
+ let force = false;
1202
+ let connectOnly = false;
1203
+ const positional = [];
1204
+ for (let i = 0; i < rest.length; i++) {
1205
+ const a = rest[i];
1206
+ if (a === "--to") {
1207
+ to = rest[++i];
1208
+ if (!to) fail("--to expects a presId");
1209
+ } else if (a === "--json") {
1210
+ asJson = true;
1211
+ } else if (a === "--force") {
1212
+ force = true;
1213
+ } else if (a === "--connect-only") {
1214
+ connectOnly = true;
1215
+ } else if (a.startsWith("-")) {
1216
+ fail(`unknown flag: ${a}`);
1217
+ } else {
1218
+ positional.push(a);
1219
+ }
1220
+ }
1221
+
1222
+ // --- Local: resolve the deck dir (never create it) and read its existing
1223
+ // bundle ref. deckPresFP feeds ONLY the mirror tie-break + `suggested`; it
1224
+ // never auto-picks a design. ---
1225
+ const deckArg = positional[0] ?? null;
1226
+ const bundle = await import(
1227
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
1228
+ );
1229
+ let deckDir = null;
1230
+ let deckSlug = null;
1231
+ let deckPresFP = null;
1232
+ if (deckArg) {
1233
+ deckDir = resolveDeckArg(deckArg);
1234
+ if (!fs.existsSync(deckDir)) {
1235
+ fail(
1236
+ `deck '${deckArg}' not found — run \`slidemind ready\` (no deck) to set up, then \`slidemind new ${deckArg}\``,
1237
+ );
1238
+ }
1239
+ deckSlug = path.basename(deckDir);
1240
+ deckPresFP = bundle.readDeckBundleRef(deckDir)?.presFP ?? null;
1241
+ }
1242
+
1243
+ // Emit a structured blocker (JSON to stdout / human to stderr) and exit 1.
1244
+ // `humanLines` overrides the default `slidemind: <message>` for the richer
1245
+ // ambiguous / unknown-window shapes. Mirrors doctor's stream conventions:
1246
+ // machine JSON on stdout, human prose on stderr.
1247
+ const finishBlocker = ({ blocker, extras = {}, humanLines = null }) => {
1248
+ if (asJson) {
1249
+ process.stdout.write(
1250
+ JSON.stringify(
1251
+ { ready: false, blocker, nextAction: blocker.message, ...extras },
1252
+ null,
1253
+ 2,
1254
+ ) + "\n",
1255
+ );
1256
+ process.exit(1);
1257
+ }
1258
+ if (humanLines) {
1259
+ process.stderr.write(
1260
+ humanLines.endsWith("\n") ? humanLines : `${humanLines}\n`,
1261
+ );
1262
+ process.exit(1);
1263
+ }
1264
+ fail(blocker.message);
1265
+ };
1266
+
1267
+ // --- Local: not paired short-circuits before any network. ---
1268
+ const cfg = await loadBridgeModule("config");
1269
+ if (cfg.readSessions().length === 0) {
1270
+ finishBlocker({ blocker: connectionBlocker({ paired: false }) });
1271
+ return;
1272
+ }
1273
+
1274
+ const client = await import(
1275
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1276
+ );
1277
+
1278
+ // --- Network (one parallel round): readiness ladder + the merged registry. ---
1279
+ const [statusRaw, detailed] = await Promise.all([
1280
+ client.connectionStatus(),
1281
+ client.listOpenPresentationsDetailed(),
1282
+ ]);
1283
+ const pub = publicConnectionStatus(statusRaw, client);
1284
+ let registry = detailed.presentations;
1285
+
1286
+ // Terminal blockers: waiting can't fix an unreachable backend or a stale CLI.
1287
+ const terminal = connectionBlocker(pub);
1288
+ if (
1289
+ terminal &&
1290
+ (terminal.code === "all_unreachable" || terminal.code === "cli_update_required")
1291
+ ) {
1292
+ finishBlocker({ blocker: terminal });
1293
+ return;
1294
+ }
1295
+
1296
+ // --- Bounded hydration wait when nothing is render-ready yet: covers the
1297
+ // just-clicked / just-woke pane still hydrating brand metadata. Polls even on
1298
+ // an empty registry (that's the flaky window this papers over). ---
1299
+ const isRR = client.isRenderReadyPresentation;
1300
+ if (!registry.some(isRR)) {
1301
+ const waitMs =
1302
+ parseInt(process.env.SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS || "6000", 10) ||
1303
+ 6000;
1304
+ const pollMs =
1305
+ parseInt(process.env.SLIDEMIND_BRIDGE_READY_POLL_MS || "1500", 10) || 1500;
1306
+ const deadline = Date.now() + waitMs;
1307
+ while (!registry.some(isRR) && Date.now() < deadline) {
1308
+ await new Promise((r) => setTimeout(r, pollMs));
1309
+ registry = (await client.listOpenPresentationsDetailed()).presentations;
1310
+ }
1311
+ }
1312
+ if (!registry.some(isRR)) {
1313
+ // --- Pane-free rung: a BOUND deck readies over the brand routes with no
1314
+ // window at all — the design identity (presFP == brandId) lives in the
1315
+ // deck's .bundle-ref.json, and the render half never needed the pane.
1316
+ // Degraded: build-pptx/build/review/view-draft work; push/search/read
1317
+ // still need a pane. Skipped for --connect-only (search needs a pane) and
1318
+ // --to (an explicit window must resolve through the registry). Any miss
1319
+ // (old add-in, legacy ref, revoked grant) falls through to the ladder.
1320
+ if (deckDir && deckPresFP && !to && !connectOnly) {
1321
+ let result = null;
1322
+ try {
1323
+ result = await bundle.materializeBundle(null, {
1324
+ force,
1325
+ brandId: deckPresFP,
1326
+ });
1327
+ } catch {
1328
+ /* fall through to the standard no_window / not_render_ready blocker */
1329
+ }
1330
+ if (result) {
1331
+ bundle.writeDeckBundleRef(deckDir, {
1332
+ presFP: result.presFP,
1333
+ etag: result.etag,
1334
+ });
1335
+ const designName = readDesignName(result.dir);
1336
+ const env = result.backendURL
1337
+ ? cfg.deriveEnvLabel(result.backendURL)
1338
+ : null;
1339
+ const nextAction =
1340
+ "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane).";
1341
+ if (asJson) {
1342
+ process.stdout.write(
1343
+ JSON.stringify(
1344
+ {
1345
+ ready: true,
1346
+ windowless: true,
1347
+ deck: deckSlug,
1348
+ bound: true,
1349
+ env,
1350
+ presId: null,
1351
+ presFP: result.presFP,
1352
+ designName,
1353
+ bundleDir: result.dir,
1354
+ bundleEtag: result.etag ?? null,
1355
+ paneSuspended: true,
1356
+ warnings: result.warnings ?? [],
1357
+ nextAction,
1358
+ },
1359
+ null,
1360
+ 2,
1361
+ ) + "\n",
1362
+ );
1363
+ return;
1364
+ }
1365
+ const designLabel = designName
1366
+ ? `${designName} (${result.presFP})`
1367
+ : result.presFP;
1368
+ process.stdout.write(
1369
+ `Ready — design ${designLabel}${env ? ` [${env}]` : ""} (no PowerPoint window)\n`,
1370
+ );
1371
+ process.stdout.write(
1372
+ ` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
1373
+ );
1374
+ process.stdout.write(` deck ${deckSlug} bound\n`);
1375
+ process.stdout.write(
1376
+ " Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n",
1377
+ );
1378
+ for (const w of result.warnings ?? []) {
1379
+ process.stderr.write(` warning: ${w}\n`);
1380
+ }
1381
+ return;
1382
+ }
1383
+ }
1384
+ // Only the no-window / not-render-ready rungs are reachable here (terminal
1385
+ // blockers already returned; a render-ready window would have exited the
1386
+ // poll). Reuse the ladder's exact strings over the freshly-polled registry.
1387
+ const latePub = {
1388
+ paired: true,
1389
+ backendCount: pub.backendCount,
1390
+ backends: pub.backends,
1391
+ presentations: registry.map(client.toPublicPresentation),
1392
+ };
1393
+ const b =
1394
+ connectionBlocker(latePub) ?? {
1395
+ code: "not_render_ready",
1396
+ actor: "user",
1397
+ message:
1398
+ "PowerPoint is connected, but no window is render-ready. Wait for corporate design matching or activate/create a corporate design.",
1399
+ };
1400
+ finishBlocker({ blocker: b });
1401
+ return;
1402
+ }
1403
+
1404
+ // --- --connect-only: the connection is up with ≥1 render-ready window; the
1405
+ // search skill needs nothing more (no design resolution, no bundle). ---
1406
+ if (connectOnly) {
1407
+ if (asJson) {
1408
+ process.stdout.write(
1409
+ JSON.stringify(
1410
+ {
1411
+ ready: true,
1412
+ connectOnly: true,
1413
+ nextAction: "Ready.",
1414
+ backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
1415
+ },
1416
+ null,
1417
+ 2,
1418
+ ) + "\n",
1419
+ );
1420
+ } else {
1421
+ const windows = registry.filter(isRR).length;
1422
+ process.stdout.write(
1423
+ `Connected. ${pub.backendCount} backend(s), ${windows} window(s) registered.\n`,
1424
+ );
1425
+ }
1426
+ return;
1427
+ }
1428
+
1429
+ // --- Local: resolve WHICH design to bind against. ---
1430
+ const resolution = client.resolveDesignSource(registry, {
1431
+ explicitPresId: to,
1432
+ deckPresFP,
1433
+ });
1434
+
1435
+ if (resolution.status === "none") {
1436
+ // Defensive: a render-ready window exists (poll passed) so this is rare.
1437
+ finishBlocker({
1438
+ blocker: {
1439
+ code: "not_render_ready",
1440
+ actor: "user",
1441
+ message:
1442
+ "PowerPoint is connected, but no window is render-ready. Wait for corporate design matching or activate/create a corporate design.",
1443
+ },
1444
+ });
1445
+ return;
1446
+ }
1447
+
1448
+ if (resolution.status === "unknown_presid") {
1449
+ const windows = registry.filter(isRR).map(client.toPublicPresentation);
1450
+ const candidates = windows.map((p) => ({
1451
+ presId: p.presId,
1452
+ env: p.env ?? null,
1453
+ title: p.title ?? null,
1454
+ fileName: p.fileName ?? null,
1455
+ }));
1456
+ const blocker = {
1457
+ code: "unknown_presid",
1458
+ actor: "user",
1459
+ message: `No render-ready PowerPoint window has presId='${to}'. Re-run 'slidemind ready --to <presId>' with one of the open windows.`,
1460
+ };
1461
+ const rows = windows.map(
1462
+ (p) =>
1463
+ ` ${p.presId} ${p.env ? `[${p.env}] ` : ""}${p.title || p.fileName || "(untitled)"}`,
1464
+ );
1465
+ finishBlocker({
1466
+ blocker,
1467
+ extras: { candidates },
1468
+ humanLines:
1469
+ `slidemind: ${blocker.message}\n` +
1470
+ `${rows.join("\n")}`,
1471
+ });
1472
+ return;
1473
+ }
1474
+
1475
+ if (resolution.status === "ambiguous") {
1476
+ const cand = resolution.candidates;
1477
+ const blocker = {
1478
+ code: "ambiguous_design",
1479
+ actor: "user",
1480
+ message:
1481
+ "Multiple corporate designs are open — ask the user which to build against, then re-run: slidemind ready --to <presId>.",
1482
+ };
1483
+ const rows = cand.map((c) => {
1484
+ const env = c.env ? ` [${c.env}]` : "";
1485
+ const label = c.title || c.fileName || "(untitled)";
1486
+ return ` ${c.presId}${env} ${label} (design ${c.brandId ?? "?"})`;
1487
+ });
1488
+ let human =
1489
+ "slidemind: multiple corporate designs are open — ask the user which to build against:\n" +
1490
+ `${rows.join("\n")}\n`;
1491
+ if (resolution.suggested) {
1492
+ const s = cand.find((c) => c.presId === resolution.suggested);
1493
+ const sLabel = s?.title || s?.fileName || "(untitled)";
1494
+ human += `(this deck was last built against ${s?.brandId ?? deckPresFP} — ${sLabel})\n`;
1495
+ }
1496
+ human += "Then re-run: slidemind ready --to <presId>";
1497
+ finishBlocker({
1498
+ blocker,
1499
+ extras: {
1500
+ candidates: cand.map((c) => ({
1501
+ presId: c.presId,
1502
+ env: c.env ?? null,
1503
+ title: c.title ?? null,
1504
+ fileName: c.fileName ?? null,
1505
+ presFP: c.brandId ?? null,
1506
+ })),
1507
+ suggested: resolution.suggested,
1508
+ },
1509
+ humanLines: human,
1510
+ });
1511
+ return;
1512
+ }
1513
+
1514
+ // --- Network: materialize the resolved design's bundle. Always called —
1515
+ // incremental etag-gated download makes an unchanged bundle near-free and a
1516
+ // changed one refreshes. ---
1517
+ const entry = resolution.entry;
1518
+ const result = await bundle.materializeBundle(entry.presId, {
1519
+ force,
1520
+ backendURL: entry.backendURL ?? null,
1521
+ preferBrandId: entry.brandId ?? null,
1522
+ });
1523
+
1524
+ // --- Local: bind the deck (kills the second download-bundle). ---
1525
+ let bound = false;
1526
+ if (deckDir) {
1527
+ bundle.writeDeckBundleRef(deckDir, {
1528
+ presFP: result.presFP,
1529
+ etag: result.etag,
1530
+ });
1531
+ bound = true;
1532
+ }
1533
+
1534
+ const designName = readDesignName(result.dir);
1535
+ const paneSuspended = !client.isPaneReadyPresentation(entry);
1536
+
1537
+ if (asJson) {
1538
+ process.stdout.write(
1539
+ JSON.stringify(
1540
+ {
1541
+ ready: true,
1542
+ deck: deckSlug,
1543
+ bound,
1544
+ env: entry.env ?? null,
1545
+ presId: entry.presId,
1546
+ presFP: result.presFP,
1547
+ designName,
1548
+ bundleDir: result.dir,
1549
+ bundleEtag: result.etag ?? null,
1550
+ presentation: {
1551
+ title: entry.title ?? null,
1552
+ fileName: entry.fileName ?? null,
1553
+ },
1554
+ paneSuspended,
1555
+ warnings: result.warnings ?? [],
1556
+ nextAction: "Ready.",
1557
+ },
1558
+ null,
1559
+ 2,
1560
+ ) + "\n",
1561
+ );
1562
+ return;
1563
+ }
1564
+
1565
+ // --- Human success: compact multi-line summary on stdout. ---
1566
+ const envTag = entry.env ? ` [${entry.env}]` : "";
1567
+ const presLabel = entry.title || entry.fileName || "(untitled)";
1568
+ const designLabel = designName
1569
+ ? `${designName} (${result.presFP})`
1570
+ : result.presFP;
1571
+ process.stdout.write(`Ready — design ${designLabel}\n`);
1572
+ process.stdout.write(` presentation: ${presLabel}${envTag}\n`);
1573
+ process.stdout.write(
1574
+ ` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
1575
+ );
1576
+ if (bound) process.stdout.write(` deck ${deckSlug} bound\n`);
1577
+ if (paneSuspended) {
1578
+ process.stdout.write(
1579
+ " Note: pane operations (push/search/read) will wait for a pane click.\n",
1580
+ );
1581
+ }
1582
+ for (const w of result.warnings ?? []) {
1583
+ process.stderr.write(` warning: ${w}\n`);
864
1584
  }
865
- return "Ready.";
866
1585
  }
867
1586
 
868
1587
  async function cmdDoctor(rest) {
@@ -1228,8 +1947,8 @@ async function cmdPush(rest) {
1228
1947
  }
1229
1948
  if (result.paneSuspended) {
1230
1949
  process.stdout.write(
1231
- `PowerPoint is backgrounded — the deck rendered but couldn't be pushed.\n` +
1232
- `Click the slidemind task pane once to bring PowerPoint to the foreground, ` +
1950
+ `The deck rendered, but no PowerPoint window was ready to receive it.\n` +
1951
+ `Open PowerPoint with the slidemind pane (or click the pane once to wake it), ` +
1233
1952
  `then re-run: slidemind push ${deck}\n` +
1234
1953
  `The rendered deck was saved to ${path.relative(process.cwd(), result.fallbackFile)} in the meantime.\n`,
1235
1954
  );
@@ -1241,7 +1960,22 @@ async function cmdPush(rest) {
1241
1960
  }
1242
1961
 
1243
1962
  async function cmdNew(rest) {
1244
- const positional = rest.filter((a) => !a.startsWith("--"));
1963
+ let to = null;
1964
+ let noBind = false;
1965
+ const positional = [];
1966
+ for (let i = 0; i < rest.length; i++) {
1967
+ const a = rest[i];
1968
+ if (a === "--to") {
1969
+ to = rest[++i];
1970
+ if (!to) fail("--to expects a presId");
1971
+ } else if (a === "--no-bind") {
1972
+ noBind = true;
1973
+ } else if (a.startsWith("-")) {
1974
+ fail(`unknown flag: ${a}`);
1975
+ } else {
1976
+ positional.push(a);
1977
+ }
1978
+ }
1245
1979
  const name = positional[0];
1246
1980
  if (!name) fail("usage: slidemind new <name>");
1247
1981
  if (!/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
@@ -1264,6 +1998,48 @@ async function cmdNew(rest) {
1264
1998
  process.stdout.write(
1265
1999
  `Next: ask your agent to author "${name}", then \`slidemind build ${name}\`\n`,
1266
2000
  );
2001
+
2002
+ // Stateless auto-bind: bind the fresh deck to whatever design is OPEN so the
2003
+ // agent skips a follow-up `download-bundle`. Best-effort — a bind miss (no
2004
+ // host, ambiguous designs, bundle not yet materialized, any network error)
2005
+ // NEVER fails the scaffold; it just drops a soft hint. Fully offline when
2006
+ // unpaired: no network attempted, no hint. `--no-bind` opts out entirely.
2007
+ if (noBind) return;
2008
+ const cfg = await loadBridgeModule("config");
2009
+ if (cfg.readSessions().length === 0) return;
2010
+ let boundMsg = null;
2011
+ try {
2012
+ const client = await import(
2013
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
2014
+ );
2015
+ const bundle = await import(
2016
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
2017
+ );
2018
+ const { presentations } = await client.listOpenPresentationsDetailed();
2019
+ const resolution = client.resolveDesignSource(presentations, {
2020
+ explicitPresId: to,
2021
+ });
2022
+ if (resolution.status === "resolved") {
2023
+ const entry = resolution.entry;
2024
+ const meta = bundle.readMaterializedBundleMeta(entry.brandId);
2025
+ if (meta?.presFP) {
2026
+ bundle.writeDeckBundleRef(deckDir, {
2027
+ presFP: meta.presFP,
2028
+ etag: meta.etag ?? null,
2029
+ });
2030
+ const label =
2031
+ readDesignName(bundle.bundleDirForPresFP(meta.presFP)) ?? meta.presFP;
2032
+ boundMsg = `bound to design ${label}`;
2033
+ }
2034
+ }
2035
+ } catch {
2036
+ /* best-effort — fall through to the soft hint */
2037
+ }
2038
+ process.stdout.write(
2039
+ boundMsg
2040
+ ? `${boundMsg}\n`
2041
+ : `not bound to a design yet — run \`slidemind ready ${name}\` when a window is open\n`,
2042
+ );
1267
2043
  }
1268
2044
 
1269
2045
  function parseTargetFlag(rest) {
@@ -1384,9 +2160,9 @@ function spawnInherit(file, args) {
1384
2160
  // ---------- distribution (public npm) ----------
1385
2161
  //
1386
2162
  // slidemind is published to public npm as `slidemind`. First install is
1387
- // `npm install -g slidemind` (then `slidemind init` to register the MCP
1388
- // server + skill; `install` stays as a back-compat alias); `slidemind update`
1389
- // is a thin wrapper over `npm install -g slidemind@latest`.
2163
+ // `npm install -g slidemind` (then `slidemind init` to deploy the skill;
2164
+ // `install` stays as a back-compat alias); `slidemind update` is a thin
2165
+ // wrapper over `npm install -g slidemind@latest`.
1390
2166
 
1391
2167
  // Resolve the `npm` that sits next to the node running this process, so an
1392
2168
  // update lands the global into the same prefix slidemind was launched from.
@@ -1495,15 +2271,24 @@ async function main(rawArgv) {
1495
2271
  case "ls":
1496
2272
  await cmdLs(rest);
1497
2273
  return 0;
2274
+ case "view-slide":
2275
+ await cmdViewSlide(rest);
2276
+ return 0;
1498
2277
  case "insert-slide":
1499
2278
  await cmdInsertSlide(rest);
1500
2279
  return 0;
1501
2280
  case "add-slide":
1502
2281
  await cmdAddSlide(rest);
1503
2282
  return 0;
2283
+ case "download-result":
2284
+ await cmdDownloadResult(rest);
2285
+ return 0;
1504
2286
  case "download-bundle":
1505
2287
  await cmdDownloadBundle(rest);
1506
2288
  return 0;
2289
+ case "ready":
2290
+ await cmdReady(rest);
2291
+ return 0;
1507
2292
  case "new":
1508
2293
  await cmdNew(rest);
1509
2294
  return 0;
@@ -1535,7 +2320,7 @@ async function main(rawArgv) {
1535
2320
  return 0;
1536
2321
  case "init":
1537
2322
  case "install":
1538
- // Set slidemind up in a coding harness (register the MCP server + skill).
2323
+ // Set slidemind up in a coding harness (deploy the skill).
1539
2324
  // `install` stays as a back-compat alias for the documented `init`.
1540
2325
  await cmdInstall(rest);
1541
2326
  return 0;