slidemind 0.2.3 → 0.3.0

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
@@ -29,12 +29,19 @@ import os from "node:os";
29
29
  import path from "node:path";
30
30
  import { fileURLToPath, pathToFileURL } from "node:url";
31
31
 
32
- // The only eager `src/` import: node-builtins only, and the version-floor
33
- // ladder below is sync, so it cannot be lazily loaded like the rest.
32
+ // The eager `src/` imports. Everything else is lazy-loaded for startup speed;
33
+ // these cannot be. `install-channel` because the version-floor ladder below is
34
+ // sync; `secrets` because it wraps the output streams before the first line can
35
+ // be written — a scrubber installed after the leak is not a scrubber; and the
36
+ // env-label helpers because they are called from string builders all over this
37
+ // file, most of which have no `cfg` handle. All three are node-builtins only
38
+ // (fs / os / path / crypto / child_process), so none of them costs startup.
34
39
  import {
35
40
  isClaudePluginInstall,
36
41
  updateCliInstruction,
37
42
  } from "../src/install-channel.mjs";
43
+ import { register as registerSecret, scrub } from "../src/bridge/secrets.mjs";
44
+ import { envPhrase, envTag, isMultiEnv } from "../src/bridge/config.mjs";
38
45
 
39
46
  const __filename = fileURLToPath(import.meta.url);
40
47
  const __dirname = path.dirname(__filename);
@@ -63,12 +70,15 @@ function printHelp() {
63
70
  ` coding agent found on this machine; none to skip)\n` +
64
71
  ` --no-install skip the global npm install (already installed)\n` +
65
72
  ` --no-ready skip the closing readiness check\n` +
73
+ ` --no-login skip the Microsoft 365 browser sign-in offer\n` +
66
74
  ` --json machine-readable result\n` +
67
75
  ` slidemind ready [deck] One-command session setup — run this FIRST each session.\n` +
68
76
  ` Verifies authorization/connection/CLI version, resolves the open\n` +
69
77
  ` design, materializes its bundle, and (with a deck) binds it.\n` +
70
78
  ` Exit 0 = ready; non-zero prints one actionable blocker.\n` +
79
+ ` A deck already bound to a design readies with PowerPoint closed.\n` +
71
80
  ` --to <presId> bind to a specific open window\n` +
81
+ ` --brand <id> bind to a design by id (presFP) — no window needed\n` +
72
82
  ` --force re-download the bundle even if current\n` +
73
83
  ` --connect-only check the connection only (no design/bundle)\n` +
74
84
  ` --json machine-readable readiness payload\n` +
@@ -80,7 +90,8 @@ function printHelp() {
80
90
  ` blocks until the user decides, prints DECISION: approved | comments\n` +
81
91
  ` --port <n> bind a fixed port (default: ephemeral)\n` +
82
92
  ` slidemind concepts <deck> After plan sign-off, generate one concept mockup PNG per planned\n` +
83
- ` slide into <deck>/concepts/concept-slide-NN.png (requires a host).\n` +
93
+ ` slide into <deck>/concepts/concept-slide-NN.png (bound deck,\n` +
94
+ ` pane-free — no PowerPoint window needed).\n` +
84
95
  ` Read each as a design target, then rebuild it as slide JSX.\n` +
85
96
  ` Optional aid — skips cleanly (exit 0) if unavailable.\n` +
86
97
  ` slidemind build <deck> Build self-contained HTML\n` +
@@ -88,31 +99,47 @@ function printHelp() {
88
99
  ` approves or leaves comments (host-free)\n` +
89
100
  ` --port <n> bind a fixed port (default: ephemeral)\n` +
90
101
  ` slidemind build-pptx <deck> Export native .pptx — via the add-in for compose decks (host), LOCALLY for template packs (host-free)\n` +
102
+ ` --pack <dir> explicit template-pack directory (plugin integrations)\n` +
91
103
  ` --to <presId> target a specific window (required if >1 open)\n` +
92
104
  ` --out <path> output path (default <deck>/<deck>.pptx)\n` +
105
+ ` --force discard manual PowerPoint edits instead of reconciling them\n` +
106
+ ` slidemind inspect-template-deck <deck> Inspect a template deck as JSON for pack-owned validation\n` +
107
+ ` --pack <dir> explicit template-pack directory (required)\n` +
108
+ ` --allow-unrendered-freeform include JSX inserts without .pptx artifacts\n` +
109
+ ` --json print the normalized deck model\n` +
93
110
  ` slidemind view-draft <deck> Render the deck's slides to per-slide PNGs (no push, no .pptx)\n` +
94
111
  ` so the agent can look before review; host-free once the\n` +
95
112
  ` bundle is materialized\n` +
96
113
  ` --to <presId> window to fetch the bundle from (only if not cached)\n` +
97
114
  ` --out <dir> output dir (default <deck>/.draft-preview/)\n` +
98
115
  ` --open open each rendered PNG\n` +
99
- ` slidemind search "<query>" Search an engine or scope for images or slides (requires an open host)\n` +
116
+ ` slidemind search "<query>" Search an engine or scope for images or slides (no window needed on\n` +
117
+ ` add-ins that serve the pane-free engine routes; older ones relay\n` +
118
+ ` through an open host)\n` +
100
119
  ` --engine <id> engine, or scope:all | scope:sharepoint to search unfiltered\n` +
101
120
  ` (default: first image engine; scope:all for slide intents)\n` +
102
121
  ` --result-type <t> slide | image — required for a scope:* target\n` +
103
122
  ` --intent <i> auto | content | visual (slide engines)\n` +
104
123
  ` --top <N> max results\n` +
105
- ` --to <presId> target a specific window (required if >1 open)\n` +
106
- ` --json print the raw result payload\n` +
107
- ` slidemind view-slide [opts] Fetch a slide search result's preview image to a file (requires a host)\n` +
124
+ ` --deck <deck> route by this deck's bound design (multi-environment)\n` +
125
+ ` --to <presId> pin a window on the relayed path (ignored pane-free)\n` +
126
+ ` --json print the raw result payload (carries the searchId)\n` +
127
+ ` slidemind view-slide [opts] Fetch a slide search result's preview image to a file\n` +
108
128
  ` --search <id> searchId from 'slidemind search'\n` +
109
129
  ` --pick <n> 1-based selection number to view\n` +
110
- ` --to <presId> the window the search ran on (required if >1 open)\n` +
130
+ ` --to <presId> the window the search ran on (relayed path only)\n` +
111
131
  ` --out <dir> output dir (default: a slidemind temp dir)\n` +
112
- ` slidemind ls engines List the live engine catalog + search scopes for an active pane\n` +
132
+ ` slidemind read-source [opts] Read the ORIGINAL deck a slide search result came from\n` +
133
+ ` --search <id> searchId from 'slidemind search'\n` +
134
+ ` --pick <n> 1-based selection number\n` +
135
+ ` --from-slide <n> first slide to read (1-based)\n` +
136
+ ` --to-slide <n> last slide to read (1-based)\n` +
137
+ ` --to <presId> the window the search ran on (relayed path only)\n` +
138
+ ` --out <file> write the Markdown to a file instead of stdout\n` +
139
+ ` slidemind ls engines List the live engine catalog + search scopes\n` +
113
140
  ` --type <t> collection | search | scope\n` +
114
141
  ` --subtype <s> image | slide | template | content\n` +
115
- ` --to <presId> target a specific window/backend\n` +
142
+ ` --to <presId> target a specific window/backend (relayed path only)\n` +
116
143
  ` --all include every paired backend\n` +
117
144
  ` --json print the raw engine list\n` +
118
145
  ` slidemind insert-slide [opts] Insert reused slide search results live into a window (requires a host)\n` +
@@ -121,17 +148,23 @@ function printHelp() {
121
148
  ` --to <presId> target window (required if >1 open)\n` +
122
149
  ` --at <N> insert after slide N (0-based); default = append\n` +
123
150
  ` slidemind add-slide [opts] Fold reused slide(s) into a deck as native .pptx\n` +
124
- ` --search <id> searchId from 'slidemind search' (requires a host)\n` +
151
+ ` --search <id> searchId from 'slidemind search'\n` +
125
152
  ` --from <file.pptx> fold slide(s) from a local .pptx instead (host-free)\n` +
126
153
  ` --pick <n,..> 1-based selection number(s)\n` +
127
154
  ` --deck <deck> deck to add the slides to\n` +
128
- ` --to <presId> target the window the search ran on (required if >1 open; ignored with --from)\n` +
155
+ ` --to <presId> the window the search ran on (relayed path only; ignored with --from)\n` +
129
156
  ` --at <N> numeric prefix to start at (default: append)\n` +
130
- ` slidemind download-result [opts] Download image/icon search result(s) into a deck's assets/ (requires a host)\n` +
157
+ ` --json machine-readable result (carries the searchId)\n` +
158
+ ` slidemind adopt <file.pptx> Rebuild a deck workspace from a file's embedded source; foreign\n` +
159
+ ` slides are converted to editable jsx where the add-in offers it\n` +
160
+ ` --deck <slug> deck folder to write (default: the file's name)\n` +
161
+ ` --brand <presFP> design to convert against (when the file names none)\n` +
162
+ ` --no-convert fold every untagged slide as native .pptx instead\n` +
163
+ ` slidemind download-result [opts] Download image/icon search result(s) into a deck's assets/\n` +
131
164
  ` --search <id> searchId from 'slidemind search'\n` +
132
165
  ` --pick <n,..> 1-based selection number(s)\n` +
133
166
  ` --deck <deck> deck to write the asset(s) into\n` +
134
- ` --to <presId> the window the search ran on (required if >1 open)\n` +
167
+ ` --to <presId> the window the search ran on (relayed path only)\n` +
135
168
  ` --icon-color <hex[,hex,..]> bake a colour into SVG result(s) (one for all, or one per --pick)\n` +
136
169
  ` slidemind imagen "<prompt>" Generate ONE image with the add-in's image model into a deck's assets/ (paired backend, pane-free)\n` +
137
170
  ` --deck <deck> bound deck to write the image into (required)\n` +
@@ -144,7 +177,9 @@ function printHelp() {
144
177
  ` --force re-download even if the cached bundle is current\n` +
145
178
  ` slidemind presentations List active PowerPoint panes registered with the add-in\n` +
146
179
  ` --all include retained suspended entries\n` +
147
- ` slidemind doctor Diagnose CLI/add-in authorization, backend, and pane readiness\n` +
180
+ ` slidemind doctor Diagnose two independent ladders: backend authoring readiness\n` +
181
+ ` (session, backend, CLI version, engine routes) and live\n` +
182
+ ` PowerPoint readiness (windows, pane)\n` +
148
183
  ` --json print machine-readable status\n` +
149
184
  ` slidemind read [presId|file.pptx] Pull the content of an open presentation (requires a host),\n` +
150
185
  ` or of a local .pptx path (read host-free from disk)\n` +
@@ -156,6 +191,11 @@ function printHelp() {
156
191
  ` --at <index> insert after slide N (0-based); default = append\n` +
157
192
  ` --replace delete existing slides first (destructive)\n` +
158
193
  ` --slides <ids> comma-separated source slide ids to push\n` +
194
+ ` --accept-conversions replace even when a converted slide is unconfirmed\n` +
195
+ ` --force discard manual PowerPoint edits instead of reconciling them\n` +
196
+ ` slidemind login Sign in to Microsoft 365 in your browser so SharePoint-backed\n` +
197
+ ` slides and images download without an open PowerPoint window\n` +
198
+ ` --backend <url|env> environment to sign in for (default: the default one)\n` +
159
199
  ` slidemind whoami Show every connected environment (* = default)\n` +
160
200
  ` slidemind use <url|env> Set the default environment\n` +
161
201
  ` slidemind logout [url|env] Unpair one environment (--all to unpair every one)\n` +
@@ -429,15 +469,89 @@ async function cmdImagen(rest) {
429
469
  );
430
470
  }
431
471
 
472
+ async function cmdInspectTemplateDeck(rest) {
473
+ const { deck, passthrough } = splitDeckArg(rest, "inspect-template-deck");
474
+ let packDir = null;
475
+ let json = false;
476
+ let allowUnrenderedFreeform = false;
477
+ for (let i = 0; i < passthrough.length; i++) {
478
+ const a = passthrough[i];
479
+ if (a === "--pack") {
480
+ packDir = passthrough[++i];
481
+ if (!packDir) fail("--pack expects a directory");
482
+ } else if (a === "--json") {
483
+ json = true;
484
+ } else if (a === "--allow-unrendered-freeform") {
485
+ allowUnrenderedFreeform = true;
486
+ } else if (a.startsWith("-")) {
487
+ fail(`unknown flag: ${a}`);
488
+ } else {
489
+ fail(`unexpected argument: ${a}`);
490
+ }
491
+ }
492
+ if (!packDir) fail("inspect-template-deck requires --pack <dir>");
493
+
494
+ const deckDir = resolveDeckArg(deck);
495
+ const resolveMod = await import(
496
+ pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
497
+ );
498
+ const skill = resolveMod.resolveSkillForDeck(deckDir, {
499
+ packDir: path.resolve(packDir),
500
+ });
501
+ if (skill.mode !== "fill-template") {
502
+ fail(`pack ${skill.skillDir} is mode ${skill.mode}, not fill-template`);
503
+ }
504
+ const engine = await import(
505
+ pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "engine.mjs")).href
506
+ );
507
+ const result = engine.inspectFillTemplateDeck({
508
+ deckDir,
509
+ skill,
510
+ allowUnrenderedFreeform,
511
+ });
512
+ const payload = {
513
+ version: 1,
514
+ pack: {
515
+ name: skill.skillName,
516
+ mode: skill.mode,
517
+ directory: skill.skillDir,
518
+ template: result.templateKey,
519
+ locale: result.locale,
520
+ },
521
+ slides: result.sequence,
522
+ warnings: result.warnings,
523
+ problems: result.problems,
524
+ };
525
+ if (json) {
526
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
527
+ } else {
528
+ for (const item of result.sequence) {
529
+ const suffix = item.kind === "master" ? ` → template ${item.no}` : "";
530
+ process.stdout.write(`${item.file}: ${item.kind}${suffix}\n`);
531
+ }
532
+ for (const warning of result.warnings) process.stderr.write(` warning: ${warning}\n`);
533
+ }
534
+ if (result.problems.length) {
535
+ fail(`inspection failed:\n - ${result.problems.join("\n - ")}`);
536
+ }
537
+ }
538
+
432
539
  async function cmdBuildPptx(rest) {
433
540
  const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
434
541
  let to = null;
435
542
  let outPath = null;
543
+ let packDir = null;
544
+ let force = false;
436
545
  for (let i = 0; i < passthrough.length; i++) {
437
546
  const a = passthrough[i];
438
- if (a === "--to") {
547
+ if (a === "--force") {
548
+ force = true;
549
+ } else if (a === "--to") {
439
550
  to = passthrough[++i];
440
551
  if (!to) fail("--to expects a presId");
552
+ } else if (a === "--pack") {
553
+ packDir = passthrough[++i];
554
+ if (!packDir) fail("--pack expects a directory");
441
555
  } else if (a === "--out" || a === "-o") {
442
556
  outPath = passthrough[++i];
443
557
  if (!outPath) fail("--out expects a path");
@@ -460,8 +574,13 @@ async function cmdBuildPptx(rest) {
460
574
  const resolveMod = await import(
461
575
  pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
462
576
  );
463
- if (resolveMod.resolveDeckMode(deckDir).mode === "fill-template") {
464
- const skill = resolveMod.resolveSkillForDeck(deckDir);
577
+ const resolveOpts = packDir ? { packDir: path.resolve(packDir) } : {};
578
+ const deckMode = resolveMod.resolveDeckMode(deckDir, resolveOpts);
579
+ if (packDir && deckMode.mode !== "fill-template") {
580
+ fail(`explicit --pack ${path.resolve(packDir)} is mode ${deckMode.mode}, not fill-template`);
581
+ }
582
+ if (deckMode.mode === "fill-template") {
583
+ const skill = resolveMod.resolveSkillForDeck(deckDir, resolveOpts);
465
584
  if (to) {
466
585
  fail(
467
586
  "--to targets a PowerPoint window, but fill-template decks build " +
@@ -519,6 +638,7 @@ async function cmdBuildPptx(rest) {
519
638
  deckDir,
520
639
  presentationId: to,
521
640
  outPath,
641
+ force,
522
642
  onProgress: renderProgressPrinter(),
523
643
  });
524
644
  process.stdout.write(
@@ -699,9 +819,211 @@ function writeSearchGrid(gridDataUri, searchId, resolveExt) {
699
819
  }
700
820
  }
701
821
 
822
+ // Where an engine op runs. Newer add-ins serve the engine surface off the CLI
823
+ // session itself (`/api/cli/engines`, `/api/cli/searches/*`), so discovery,
824
+ // search, download and extraction need no PowerPoint window at all; older ones
825
+ // relay every one of them through a task-pane webview, which is what
826
+ // requirePaneHost gates. The DIRECT path is preferred whenever it exists and the
827
+ // pane path stays as the compat fallback, so both add-in generations work.
828
+ //
829
+ // `--to <presId>` stops being a REQUIREMENT — the searchId is server-owned
830
+ // rather than bound to a window, so nothing has to be targeted — but it is still
831
+ // honoured, and naming a window means "relay through THAT pane". That is the
832
+ // escape hatch for the two things the pane-free routes refuse (`asset_needs_host`
833
+ // for SharePoint-hosted bytes, `recolor_needs_host` for an icon tint), both of
834
+ // which the pane can do; ignoring --to made their own remediation unreachable.
835
+ // `brandId` is the deck's bound design — the only thing that routes an engine op
836
+ // in a multi-environment session when no window is involved.
837
+ // No connected environment holds a server-owned search record. Authored once
838
+ // because every consumer of an opaque searchId reaches the same dead end and
839
+ // the same way out — the record's own environment is the only thing that can
840
+ // serve it, so a re-search is the fix regardless of who asked.
841
+ function expiredSearchMessage(searchId) {
842
+ return (
843
+ `search ${searchId} was not found on any connected environment — server-side ` +
844
+ "search records expire after ~2 hours. Re-run 'slidemind search' and pick again."
845
+ );
846
+ }
847
+
848
+ async function resolveEngineRouting(client, { to = null, brandId = null, pick = "one", searchId = null } = {}) {
849
+ // An opaque (server-minted) searchId pins the route by itself: the record
850
+ // lives on the environment that minted it, and the pane relay can never
851
+ // serve it — its handler only knows its own per-window numeric ids. So the
852
+ // record's origin outranks the deck's design AND --to, and when no connected
853
+ // environment holds the record the only honest answer is "search again",
854
+ // not a pane fallback that is guaranteed to 4xx.
855
+ if (client.isOpaqueSearchId?.(searchId)) {
856
+ if (to) {
857
+ fail(
858
+ `searchId ${searchId} is a pane-free search — it cannot be relayed through a window. ` +
859
+ `To go through the pane, re-run 'slidemind search "<query>" --to ${to}' and use ITS searchId.`,
860
+ );
861
+ }
862
+ let origin = null;
863
+ try {
864
+ origin = await client.resolveSearchBackend(searchId);
865
+ } catch (err) {
866
+ // The probe distinguishes "no environment holds this record" from the
867
+ // failures that only LOOK like it — a dead session, a missing sign-in —
868
+ // and hands the latter back for their own remediation.
869
+ await failOnEngineBlocker(client, err, { to });
870
+ }
871
+ if (origin) {
872
+ return { direct: true, backendURL: origin, presId: null, registry: null, entry: null };
873
+ }
874
+ fail(expiredSearchMessage(searchId));
875
+ }
876
+ let backendURL = null;
877
+ if (!to) {
878
+ backendURL = await client.resolveEngineBackend({ brandId });
879
+ }
880
+ if (backendURL) {
881
+ return { direct: true, backendURL, presId: null, registry: null, entry: null };
882
+ }
883
+ const registry = await client.requirePaneHost();
884
+ const entry =
885
+ pick === "any"
886
+ ? client.pickAnyPresentationEntry(registry, to)
887
+ : client.pickPresentationEntry(registry, to);
888
+ return {
889
+ direct: false,
890
+ backendURL: entry.backendURL ?? null,
891
+ presId: entry.presId,
892
+ registry,
893
+ entry,
894
+ };
895
+ }
896
+
897
+ // The design a deck is bound to (presFP == brandId), or null when it isn't bound
898
+ // / isn't a deck. Routing state only — never printed.
899
+ async function deckBrandId(deckDir) {
900
+ if (!deckDir) return null;
901
+ const bundleMod = await import(
902
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
903
+ );
904
+ return bundleMod.readDeckBundleRef(deckDir)?.presFP ?? null;
905
+ }
906
+
907
+ // A pane-ready window, or null — never a throw, and never a wait. Used by the
908
+ // login_required paths below, where an open pane is an OPTION rather than a
909
+ // precondition. `to` is honoured only when it names a window that is actually
910
+ // registered; a stale --to must not cost the caller its fallback.
911
+ async function openPaneEntry(client, to = null) {
912
+ const registry = (await client.paneReadyPresentations?.()) ?? [];
913
+ if (registry.length === 0) return null;
914
+ const explicit = registry.some((p) => p.presId === to) ? to : null;
915
+ try {
916
+ return { registry, entry: client.pickAnyPresentationEntry(registry, explicit) };
917
+ } catch {
918
+ // Several windows and no way to choose — no automatic fallback from here.
919
+ return null;
920
+ }
921
+ }
922
+
923
+ // The ONE rendering of a corpus-identity refusal: the pane-free routes run as
924
+ // the USER and answer `login_required` without the delegated tokens `slidemind
925
+ // login` mints (the CLI attempts that sign-in itself before ever sending a
926
+ // token-less request, so reaching this means the browser step was refused or
927
+ // abandoned). The refusal backs the PANE-FREE path only: an open task pane
928
+ // signs every request with a live browser token, so when a pane-ready window
929
+ // is already open, that window can serve a fresh search right now — say so.
930
+ // Returns the message, or null for any other failure.
931
+ async function loginRequiredMessage(client, err, { to = null } = {}) {
932
+ const blocker = client.loginRequiredBlocker?.(err);
933
+ if (!blocker) return null;
934
+ const pane = await openPaneEntry(client, to);
935
+ if (!pane) return blocker.message;
936
+ const env = envTag(pane.entry.env);
937
+ return (
938
+ `${blocker.message} A slidemind pane is already open${env} and signs each request live, so a fresh ` +
939
+ `search through that window works now: slidemind search "<query>" --to ${pane.entry.presId}.`
940
+ );
941
+ }
942
+
943
+ // Engine-route failures the USER has to clear get one actionable line instead of
944
+ // a relay error. `login_required` means the corpus routes want the user's own
945
+ // Microsoft 365 sign-in. Only corpus search stops; render and bundle don't.
946
+ // Anything else is re-thrown for the top-level handler.
947
+ async function failOnEngineBlocker(client, err, { to = null } = {}) {
948
+ const message = await loginRequiredMessage(client, err, { to });
949
+ if (message) fail(message);
950
+ throw err;
951
+ }
952
+
953
+ // A login_required refusal that an OPEN PANE can simply route around. Only an
954
+ // operation that can be replayed pane-side qualifies — creating a NEW search,
955
+ // whose id the pane mints itself. A record-bound op on an opaque searchId
956
+ // cannot: the pane's handler only understands its own per-window numeric ids,
957
+ // so those get loginRequiredMessage's extended blocker instead. Returns
958
+ // { registry, entry } or null.
959
+ async function paneRelayForLoginRequired(client, err, { to = null } = {}) {
960
+ if (!client.loginRequiredBlocker?.(err)) return null;
961
+ return openPaneEntry(client, to);
962
+ }
963
+
964
+ // The pane-free routes refuse exactly two things outright: bytes that live in
965
+ // SharePoint (`asset_needs_host`, which needs a delegated token only a browser
966
+ // can mint) and an icon tint (`recolor_needs_host`, whose classifier is
967
+ // DOM-based). An open pane does both, so the remediation is the RELAY — name it,
968
+ // rather than echoing a 409 whose "retry" would take the identical route.
969
+ // Returns null for anything else.
970
+ function describeHostOnlyEngineFailure(err) {
971
+ const code = err?.code ?? err?.body?.code ?? null;
972
+ if (code !== "asset_needs_host" && code !== "recolor_needs_host") return null;
973
+ const detail =
974
+ typeof err?.body?.error === "string" ? err.body.error : (err?.message ?? "");
975
+ return (
976
+ `${detail} Run 'slidemind search "<query>" --to <presId>' against an open window and ` +
977
+ "re-pick, then repeat this download with the same --to — that relays it through the pane."
978
+ );
979
+ }
980
+
981
+ // The other half of the same boundary: a SharePoint-backed SLIDE. The extract
982
+ // route fetches it with the caller's identity, so without a delegated token the
983
+ // add-in falls back to a managed identity that is not configured for the corpus
984
+ // site and the fetch dies inside the provider. That route answers a plain 400
985
+ // with no discrete code, so the provider text is the only signal there is.
986
+ function isSharePointProviderFailure(err) {
987
+ const text = `${err?.body?.error ?? ""} ${err?.message ?? ""}`;
988
+ return /sharepoint provider/i.test(text);
989
+ }
990
+
991
+ // `slidemind login` as the WINDOW-FREE way past both — named only on a backend
992
+ // that honours the delegated token and only while no sign-in is stored, so the
993
+ // sentence never appears where following it would change nothing. Null
994
+ // otherwise, which keeps every pre-existing message byte-identical.
995
+ async function graphLoginRemediation(client, backendURL) {
996
+ if (!backendURL) return null;
997
+ try {
998
+ if (!(await client.backendSupportsGraphDelegated?.(backendURL))) return null;
999
+ const graph = await loadBridgeModule("graph-auth");
1000
+ if (graph.hasGraphSession(backendURL)) return null;
1001
+ } catch {
1002
+ // A capability probe that fails is not a reason to withhold the download's
1003
+ // real error — just say nothing extra.
1004
+ return null;
1005
+ }
1006
+ return (
1007
+ "Or run 'slidemind login' once: it signs this CLI in to Microsoft 365 with your own " +
1008
+ "identity, and SharePoint-backed content then downloads with no window at all."
1009
+ );
1010
+ }
1011
+
1012
+ // The user-facing text for an engine byte fetch that failed for want of an
1013
+ // identity — either refusal shape. Returns null when the failure is something
1014
+ // else entirely (the caller keeps its own message).
1015
+ async function describeEngineByteFailure(client, err, { backendURL = null } = {}) {
1016
+ const paneAdvice = describeHostOnlyEngineFailure(err);
1017
+ if (!paneAdvice && !isSharePointProviderFailure(err)) return null;
1018
+ const login = await graphLoginRemediation(client, backendURL);
1019
+ if (!login) return paneAdvice;
1020
+ return paneAdvice ? `${paneAdvice} ${login}` : `${err.message} ${login}`;
1021
+ }
1022
+
702
1023
  async function cmdSearch(rest) {
703
1024
  let asJson = false;
704
1025
  let to = null;
1026
+ let deck = null;
705
1027
  let engineId = null;
706
1028
  let resultType = null;
707
1029
  let intent = null;
@@ -714,6 +1036,9 @@ async function cmdSearch(rest) {
714
1036
  } else if (a === "--to") {
715
1037
  to = rest[++i];
716
1038
  if (!to) fail("--to expects a presId");
1039
+ } else if (a === "--deck") {
1040
+ deck = rest[++i];
1041
+ if (!deck) fail("--deck expects a deck name or path");
717
1042
  } else if (a === "--engine" || a === "-e" || a === "--engineId") {
718
1043
  engineId = rest[++i];
719
1044
  if (!engineId) fail("--engine expects an engineId");
@@ -745,12 +1070,13 @@ async function cmdSearch(rest) {
745
1070
  const query = queryParts.join(" ").trim();
746
1071
  if (!query) {
747
1072
  fail(
748
- `usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
1073
+ `usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--deck <deck>] [--to <presId>] [--json]`,
749
1074
  );
750
1075
  }
751
1076
 
752
- // Engine search runs against the add-in's live engine over the bridge — an
753
- // open host is required.
1077
+ // Engine search runs against the add-in's live engine over the bridge —
1078
+ // pane-free where the add-in serves the direct routes, relayed through an open
1079
+ // window (host required) where it doesn't.
754
1080
  const client = await import(
755
1081
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
756
1082
  );
@@ -760,14 +1086,23 @@ async function cmdSearch(rest) {
760
1086
  const { resolveAssetExt } = await import(
761
1087
  pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
762
1088
  );
763
- const registry = await client.requirePaneHost();
764
- // The engine is per-user, not window-bound — so search just attaches to any
765
- // connected window (preferring a live one). No "which window?" prompt; pass
766
- // --to only to pin a specific one. The chosen window is printed below so
767
- // follow-up insert/add-slide can target the same pane the searchId lives on.
768
- const entry = client.pickAnyPresentationEntry(registry, to);
769
- const presId = entry.presId;
770
- const backendURL = entry.backendURL ?? null;
1089
+ // The engine catalog is per-user, not window-bound. On the pane path search
1090
+ // attaches to any connected window (preferring a live one) and the chosen
1091
+ // window is printed below, because the searchId lives on that pane; on the
1092
+ // direct path the searchId is the backend's and no window is involved.
1093
+ const routing = await resolveEngineRouting(client, {
1094
+ to,
1095
+ brandId: await deckBrandId(deck ? resolveDeckArg(deck) : null),
1096
+ pick: "any",
1097
+ });
1098
+ const presId = routing.presId;
1099
+ const backendURL = routing.backendURL;
1100
+ // Mutable, because a login_required refusal can push a direct search onto
1101
+ // the pane relay below — and once it does, the searchId belongs to a WINDOW
1102
+ // and every next-step hint has to name it.
1103
+ let direct = routing.direct;
1104
+ let registry = routing.registry;
1105
+ let entry = routing.entry;
771
1106
 
772
1107
  // An explicit content/visual intent means "search slides", not imagery.
773
1108
  const slideIntent = intent === "content" || intent === "visual";
@@ -776,7 +1111,7 @@ async function cmdSearch(rest) {
776
1111
  // - an explicit slide intent (--intent content|visual) targets slides.
777
1112
  // This is belt-and-suspenders for an add-in regression where a missing
778
1113
  // subtype no longer defaulted to "image"; when the subtype is unknown we omit
779
- // it and let the add-in resolve it from the snapshot.
1114
+ // it and let the add-in resolve the caller's groups itself.
780
1115
  let subtype = slideIntent ? "slide" : null;
781
1116
  if (!engineId) {
782
1117
  if (slideIntent) {
@@ -800,9 +1135,18 @@ async function cmdSearch(rest) {
800
1135
  // Best-effort: a bundle that can't be materialized (e.g. an incomplete
801
1136
  // one) must not replace the actionable "wire an image engine" message
802
1137
  // below with a bundle error — the live catalog is the real answer here.
803
- const mat = await bundle
804
- .materializeBundle(presId, { backendURL })
805
- .catch(() => null);
1138
+ // Pane path: fetch by the window's presId. Direct path: there is no
1139
+ // window, so only a bound deck (--deck) can name the design; without one
1140
+ // there is nothing to fall back to and the live catalog's answer stands.
1141
+ const brandId = presId
1142
+ ? null
1143
+ : await deckBrandId(deck ? resolveDeckArg(deck) : null);
1144
+ const mat =
1145
+ presId || brandId
1146
+ ? await bundle
1147
+ .materializeBundle(presId, { backendURL, brandId })
1148
+ .catch(() => null)
1149
+ : null;
806
1150
  if (mat) pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
807
1151
  }
808
1152
  if (!pick) {
@@ -829,23 +1173,64 @@ async function cmdSearch(rest) {
829
1173
  resultType = null;
830
1174
  }
831
1175
 
832
- // Window-agnostic: tries the picked pane, then fails over to any other live
833
- // window on the same backend if it's suspended/frozen. The answering window
834
- // (res.presId) is what reuse must target — the searchId is bound to it.
835
- const res = await client.engineSearchAnyPane({
836
- registry,
837
- explicitPresId: to,
838
- engineId,
839
- query,
840
- subtype,
841
- resultType,
842
- intent: intent ?? null,
843
- top: top ?? null,
844
- });
845
- const ranPresId = res?.presId ?? presId;
846
- const ranEntry = registry.find((p) => p.presId === ranPresId) ?? entry;
1176
+ // Direct: one pane-free call against the backend that owns the searchId.
1177
+ // Pane: window-agnostic — tries the picked pane, then fails over to any other
1178
+ // live window on the same backend if it's suspended/frozen. The answering
1179
+ // window (res.presId) is what reuse must target there, since the searchId is
1180
+ // bound to it.
1181
+ const relaySearch = (reg) =>
1182
+ client.engineSearchAnyPane({
1183
+ registry: reg,
1184
+ explicitPresId: to,
1185
+ engineId,
1186
+ query,
1187
+ subtype,
1188
+ resultType,
1189
+ intent: intent ?? null,
1190
+ top: top ?? null,
1191
+ });
1192
+ let res;
1193
+ try {
1194
+ res = direct
1195
+ ? await client.engineSearch({
1196
+ engineId,
1197
+ query,
1198
+ subtype,
1199
+ resultType,
1200
+ intent: intent ?? null,
1201
+ top: top ?? null,
1202
+ backendURL,
1203
+ })
1204
+ : await relaySearch(routing.registry);
1205
+ } catch (err) {
1206
+ // login_required stops the PANE-FREE path only — an open pane signs each
1207
+ // request with the browser's live token. Creating a new search is exactly
1208
+ // the operation the pane can replay (it mints its own searchId), so relay
1209
+ // it rather than parking the search on a sign-in the user hasn't finished.
1210
+ const relay = direct ? await paneRelayForLoginRequired(client, err, { to }) : null;
1211
+ if (!relay) await failOnEngineBlocker(client, err, { to });
1212
+ process.stderr.write(
1213
+ `note: this CLI is not signed in to Microsoft 365, so the search ran through the open ` +
1214
+ `slidemind pane${envTag(relay.entry.env)} instead (it signs each request live).\n`,
1215
+ );
1216
+ direct = false;
1217
+ registry = relay.registry;
1218
+ entry = relay.entry;
1219
+ try {
1220
+ res = await relaySearch(registry);
1221
+ } catch (relayErr) {
1222
+ await failOnEngineBlocker(client, relayErr, { to });
1223
+ }
1224
+ }
1225
+ const ranPresId = res?.presId ?? entry?.presId ?? presId;
1226
+ const ranEntry = direct
1227
+ ? { env: res?.env ?? null }
1228
+ : (registry.find((p) => p.presId === ranPresId) ?? entry);
847
1229
  if (asJson) {
848
- process.stdout.write(`${JSON.stringify(res, null, 2)}\n`);
1230
+ // The searchId rides the JSON so an interrupted agent can resume its picks
1231
+ // without re-searching. The backendURL never does — env label only.
1232
+ const { backendURL: _routingOnly, ...pub } = res ?? {};
1233
+ process.stdout.write(`${JSON.stringify(pub, null, 2)}\n`);
849
1234
  return;
850
1235
  }
851
1236
  if (!res?.ok) {
@@ -902,6 +1287,16 @@ async function cmdSearch(rest) {
902
1287
  `Contact sheet of all ${count} result(s) — LOOK AT THIS FILE to choose, ` +
903
1288
  `don't fetch previews one by one: ${gridPath}\n`,
904
1289
  );
1290
+ } else if (direct) {
1291
+ // The pane composes the collage in a DOM; the pane-free route may not
1292
+ // have one to hand back (`gridUnavailable`, or simply no grid field on an
1293
+ // add-in that hasn't grown it yet). Say where previews come from instead
1294
+ // — otherwise an agent that has learned to expect a contact sheet quietly
1295
+ // issues N thumbnail fetches to rebuild it.
1296
+ process.stdout.write(
1297
+ `No contact sheet on this path — preview candidates one at a time instead: ` +
1298
+ `slidemind view-slide --search ${res.searchId} --pick <n>\n`,
1299
+ );
905
1300
  }
906
1301
  }
907
1302
  // Tailor the next-step hint to the result kind: slide verbs (insert-slide /
@@ -910,12 +1305,28 @@ async function cmdSearch(rest) {
910
1305
  // add-ins that don't echo a top-level subtype.
911
1306
  const isSlideResults =
912
1307
  res.subtype === "slide" || results.some((r) => r.kind === "slide");
913
- const envTag = ranEntry.env ? ` [${ranEntry.env}]` : "";
914
- if (isSlideResults) {
1308
+ const envLabel = envTag(ranEntry?.env);
1309
+ if (direct) {
1310
+ // The searchId belongs to the BACKEND here, not to a window: picks work with
1311
+ // PowerPoint closed, so the follow-up commands carry no --to. Live insert is
1312
+ // the one step that still names a window (it types into an open deck).
1313
+ if (isSlideResults) {
1314
+ process.stdout.write(
1315
+ `searchId ${res.searchId}${envLabel} is held by the add-in — reuse it with no PowerPoint window:\n` +
1316
+ ` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck>\n` +
1317
+ ` slidemind insert-slide --search ${res.searchId} --pick <n> --to <presId> (live insert needs an open window)\n`,
1318
+ );
1319
+ } else {
1320
+ process.stdout.write(
1321
+ `searchId ${res.searchId}${envLabel} is held by the add-in — download image result(s) into a deck (no window needed):\n` +
1322
+ ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> [--icon-color <hex>]\n`,
1323
+ );
1324
+ }
1325
+ } else if (isSlideResults) {
915
1326
  // The searchId is bound to the window the search ran on — reuse must target
916
1327
  // that same window, so spell out its presId in the next-step commands.
917
1328
  process.stdout.write(
918
- `searchId ${res.searchId} ran on window ${ranPresId}${envTag} — reuse from THIS window:\n` +
1329
+ `searchId ${res.searchId} ran on window ${ranPresId}${envLabel} — reuse from THIS window:\n` +
919
1330
  ` slidemind insert-slide --search ${res.searchId} --pick <n> --to ${ranPresId}\n` +
920
1331
  ` slidemind add-slide --search ${res.searchId} --pick <n> --deck <deck> --to ${ranPresId}\n`,
921
1332
  );
@@ -923,7 +1334,7 @@ async function cmdSearch(rest) {
923
1334
  // The searchId is bound to the window the search ran on — download must
924
1335
  // target that same window, so spell out its presId in the next-step command.
925
1336
  process.stdout.write(
926
- `searchId ${res.searchId} ran on window ${ranPresId}${envTag} — download image result(s) into a deck:\n` +
1337
+ `searchId ${res.searchId} ran on window ${ranPresId}${envLabel} — download image result(s) into a deck:\n` +
927
1338
  ` slidemind download-result --search ${res.searchId} --pick <n[,n,...]> --deck <deck> --to ${ranPresId} [--icon-color <hex>]\n`,
928
1339
  );
929
1340
  }
@@ -973,16 +1384,58 @@ async function cmdLs(rest) {
973
1384
  if (all && to) fail("--all cannot be combined with --to");
974
1385
  let backendURL = null;
975
1386
  if (!all) {
976
- const registry = await client.requirePaneHost();
977
- const entry = client.pickAnyPresentationEntry(registry, to);
978
- backendURL = entry.backendURL ?? null;
1387
+ // Pane-free where the add-in serves the direct routes; otherwise the catalog
1388
+ // is relayed through a window, so a live pane is still the precondition.
1389
+ try {
1390
+ backendURL = (await resolveEngineRouting(client, { to, pick: "any" }))
1391
+ .backendURL;
1392
+ } catch (err) {
1393
+ // Listing is the one engine op that can simply show EVERY environment
1394
+ // (each engine is tagged with its env), so an ambiguous multi-environment
1395
+ // session fans out here rather than demanding --backend.
1396
+ if (err?.code !== "engine_backend_ambiguous") throw err;
1397
+ backendURL = null;
1398
+ }
1399
+ }
1400
+ let engines;
1401
+ // The pane-free route's summary of the catalog BEFORE the type/subtype
1402
+ // filter; null on older add-ins and on the pane-relay path.
1403
+ let catalog = null;
1404
+ // Which environments answered. The catalog is POOLED, and pooling dedupes the
1405
+ // scopes every environment repeats — so a second environment can contribute
1406
+ // no row of its own, and the surviving rows can no longer be counted to learn
1407
+ // whether more than one answered.
1408
+ let envs = [];
1409
+ try {
1410
+ ({ engines, catalog, envs = [] } = await client.listEngines({
1411
+ type,
1412
+ subtype,
1413
+ backendURL,
1414
+ }));
1415
+ } catch (err) {
1416
+ // No pane fallback here: `GET /api/cli/engines` is the catalog route on both
1417
+ // add-in generations (newer ones answer it directly, older ones relay it
1418
+ // themselves), so there is no second route to retry on. The message does
1419
+ // name the open pane, because searching THROUGH it still works.
1420
+ await failOnEngineBlocker(client, err, { to });
979
1421
  }
980
- const { engines } = await client.listEngines({ type, subtype, backendURL });
981
1422
  if (asJson) {
982
- process.stdout.write(`${JSON.stringify(engines, null, 2)}\n`);
1423
+ process.stdout.write(`${JSON.stringify({ engines, catalog, envs }, null, 2)}\n`);
983
1424
  return;
984
1425
  }
985
1426
  if (engines.length === 0) {
1427
+ // A filter that matched nothing is NOT an empty deployment — the summary is
1428
+ // the only thing that tells the two apart, so say which one this is and the
1429
+ // next move follows (drop the filter vs. search the corpus unfiltered).
1430
+ const unfiltered = catalog ? catalog.totalEngines + catalog.totalScopes : 0;
1431
+ if (catalog?.filtered && unfiltered > 0) {
1432
+ process.stderr.write(
1433
+ `No engine or scope matched this filter, but ${catalog.totalEngines} engine(s) ` +
1434
+ `and ${catalog.totalScopes} scope(s) exist. Re-run 'slidemind ls engines' ` +
1435
+ "without --type/--subtype to see them.\n",
1436
+ );
1437
+ return;
1438
+ }
986
1439
  // Deliberately not "nothing to search": an engine is a saved folder filter
987
1440
  // over the add-in's shared corpus, so an empty catalog says only that nobody
988
1441
  // saved a filter. An older add-in that predates scopes lands here too.
@@ -997,8 +1450,12 @@ async function cmdLs(rest) {
997
1450
  );
998
1451
  return;
999
1452
  }
1000
- // Show the env tag only when more than one environment contributed engines.
1001
- const multiEnv = new Set(engines.map((e) => e.env)).size > 1;
1453
+ // Show the env tag only when more than one environment contributed. Two
1454
+ // environments can carry identically-named engines, and without the tag the
1455
+ // pooled list gives no way to tell them apart. Deduped scope rows carry no env
1456
+ // of their own (they exist on every environment), so they stay untagged.
1457
+ const multiEnv =
1458
+ isMultiEnv() || envs.length > 1 || new Set(engines.map((e) => e.env)).size > 1;
1002
1459
  for (const e of engines) {
1003
1460
  const kind = [e.type, e.subtype].filter(Boolean).join("/");
1004
1461
  const env = multiEnv && e.env ? `[${e.env}] ` : "";
@@ -1009,12 +1466,28 @@ async function cmdLs(rest) {
1009
1466
  e.type === "scope"
1010
1467
  ? ` (needs --result-type ${(e.capabilities?.resultTypes ?? ["slide", "image"]).join("|")})`
1011
1468
  : "";
1469
+ // An engine that exists but has nothing indexed yet returns zero hits for
1470
+ // every query; say so here so an empty search isn't read as a bad engine id.
1471
+ const state =
1472
+ e.content?.state === "empty"
1473
+ ? " (nothing indexed yet)"
1474
+ : e.content?.state === "indexing"
1475
+ ? " (indexing)"
1476
+ : "";
1012
1477
  process.stdout.write(
1013
- `${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${desc}\n`,
1478
+ `${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${state}${desc}\n`,
1014
1479
  );
1015
1480
  }
1016
1481
  }
1017
1482
 
1483
+ // A searchId is a number when the pane minted it (the relay path) and an opaque
1484
+ // server-minted id on the pane-free engine routes, where the search record lives
1485
+ // on the backend. Accept both so a pick round-trips whichever search produced it.
1486
+ function parseSearchIdArg(raw, flag = "--search") {
1487
+ if (!raw || raw.startsWith("-")) fail(`${flag} expects a searchId, got '${raw}'`);
1488
+ return /^\d+$/.test(raw) ? parseInt(raw, 10) : raw;
1489
+ }
1490
+
1018
1491
  function parsePicks(raw, flag = "--pick") {
1019
1492
  if (!raw) fail(`${flag} expects a comma-separated list of selection numbers`);
1020
1493
  // Tolerate a trailing/empty segment ("1," or "1,,2") by dropping blank parts
@@ -1034,6 +1507,45 @@ function parsePicks(raw, flag = "--pick") {
1034
1507
  return picks;
1035
1508
  }
1036
1509
 
1510
+ /**
1511
+ * Turn a live-insert failure into the action that actually fixes it. The client
1512
+ * has already re-listed the registry and retried ONCE against the live window
1513
+ * (a pane reconnect churns the presId), so a pane error that reaches here is one
1514
+ * only the user can clear — and the way around it is the window-free fold.
1515
+ *
1516
+ * A login_required refusal is NOT among them: it is authored once, in
1517
+ * loginRequiredMessage, which the caller consults first.
1518
+ */
1519
+ function describeInsertSlideFailure(err, searchId) {
1520
+ const code = err?.code ?? err?.body?.code ?? null;
1521
+ if (code === "unknown_search") {
1522
+ return `searchId ${searchId} is no longer available. Run 'slidemind search' again, then re-pick.`;
1523
+ }
1524
+ if (code === "searchId_required") {
1525
+ // The pane was handed an id it cannot resolve — its search context is
1526
+ // per-window and numeric, and a server-minted id was never in it.
1527
+ return (
1528
+ `searchId ${searchId} belongs to the add-in, not to that PowerPoint window. ` +
1529
+ `Re-run 'slidemind search' (the CLI picks the reuse route from where the search ran), then re-pick.`
1530
+ );
1531
+ }
1532
+ const paneAsleep =
1533
+ code === "pane_suspended" ||
1534
+ code === "pane_timeout" ||
1535
+ // A relay 404 about the presId itself carries prose and no machine code,
1536
+ // and bridgeFetch falls `code` back to that prose — so read the PARSED one.
1537
+ (err?.status === 404 &&
1538
+ (!err?.body?.code || err.body.code === "unknown_presentation"));
1539
+ if (paneAsleep) {
1540
+ return (
1541
+ "the slidemind task pane in that PowerPoint window is not responding (already retried once). " +
1542
+ "Foreground PowerPoint and click the slidemind pane to wake it, then re-run — or reuse the same " +
1543
+ `result with no window at all: slidemind add-slide --search ${searchId} --pick <n> --deck <deck>.`
1544
+ );
1545
+ }
1546
+ return `insert-slide failed: ${err?.message ?? "unknown error"}`;
1547
+ }
1548
+
1037
1549
  async function cmdInsertSlide(rest) {
1038
1550
  let searchId = null;
1039
1551
  let picks = null;
@@ -1042,10 +1554,7 @@ async function cmdInsertSlide(rest) {
1042
1554
  for (let i = 0; i < rest.length; i++) {
1043
1555
  const a = rest[i];
1044
1556
  if (a === "--search") {
1045
- const next = rest[++i];
1046
- const n = parseInt(next, 10);
1047
- if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
1048
- searchId = n;
1557
+ searchId = parseSearchIdArg(rest[++i]);
1049
1558
  } else if (a === "--pick") {
1050
1559
  picks = parsePicks(rest[++i]);
1051
1560
  } else if (a === "--to") {
@@ -1070,20 +1579,97 @@ async function cmdInsertSlide(rest) {
1070
1579
  const client = await import(
1071
1580
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1072
1581
  );
1582
+ // Live insert types into an OPEN presentation, so it stays window- and
1583
+ // pane-bound even where the rest of the engine surface has moved off the pane.
1073
1584
  const registry = await client.requirePaneHost();
1074
1585
  const entry = client.pickPresentationEntry(registry, to);
1075
- const res = await client.insertSlideLive({
1076
- presId: entry.presId,
1077
- searchId,
1078
- selections: picks,
1079
- at,
1080
- backendURL: entry.backendURL ?? null,
1081
- });
1586
+ // Window identity for the client's single retry: a pane reconnect mints a NEW
1587
+ // presId for the same window, and the design fingerprint re-matches it — but
1588
+ // only against windows we did NOT already see next to the target, or a second
1589
+ // deck on the same corporate design would take the insert.
1590
+ const identity = {
1591
+ fileName: entry.fileName ?? entry.title ?? null,
1592
+ brandId: entry.brandId ?? null,
1593
+ otherPresIds: registry
1594
+ .map((p) => p.presId)
1595
+ .filter((id) => id && id !== entry.presId),
1596
+ };
1597
+ // WHERE the searchId lives decides how the reuse runs. A number came from
1598
+ // that window's own pane search and the pane resolves it; an opaque id is the
1599
+ // backend's (the pane-free search) and the pane has never seen it — so those
1600
+ // picks are extracted pane-free and inserted as bytes instead.
1601
+ //
1602
+ // Which backend extracts them is the RECORD's, not the window's. Those are
1603
+ // different environments as soon as two are connected, and spending a
1604
+ // server-owned id against the window's origin 404s as `unknown_search` — read
1605
+ // as "the search expired", which sends the agent round a re-search loop that
1606
+ // can never resolve. The window stays the INSERT target either way.
1607
+ let engineBackendURL = null;
1608
+ if (client.isOpaqueSearchId?.(searchId)) {
1609
+ try {
1610
+ engineBackendURL = await client.resolveSearchBackend(searchId);
1611
+ } catch (err) {
1612
+ await failOnEngineBlocker(client, err, { to: entry.presId });
1613
+ }
1614
+ if (!engineBackendURL) fail(expiredSearchMessage(searchId));
1615
+ }
1616
+ let res;
1617
+ try {
1618
+ res = engineBackendURL
1619
+ ? await client.insertSlidesFromSearch({
1620
+ presId: entry.presId,
1621
+ searchId,
1622
+ selections: picks,
1623
+ at,
1624
+ backendURL: entry.backendURL ?? null,
1625
+ engineBackendURL,
1626
+ ...identity,
1627
+ })
1628
+ : await client.insertSlideLive({
1629
+ presId: entry.presId,
1630
+ searchId,
1631
+ selections: picks,
1632
+ at,
1633
+ backendURL: entry.backendURL ?? null,
1634
+ ...identity,
1635
+ });
1636
+ } catch (err) {
1637
+ fail(
1638
+ (await loginRequiredMessage(client, err, { to: entry.presId })) ??
1639
+ describeInsertSlideFailure(err, searchId),
1640
+ );
1641
+ }
1082
1642
  if (!res?.ok) {
1083
1643
  fail(`insert-slide failed: ${res?.error ?? "unknown error"}`);
1084
1644
  }
1085
1645
  process.stdout.write(
1086
- `Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${entry.presId}\n`,
1646
+ `Inserted ${res.insertedCount ?? picks.length} slide(s) into presentation ${res.presId ?? entry.presId}\n`,
1647
+ );
1648
+ }
1649
+
1650
+ // One line per written slide, plus the machine-readable twin. The searchId is
1651
+ // echoed on purpose: an agent interrupted between picks can resume from the JSON
1652
+ // without re-running the search (the record lives on the backend now).
1653
+ function writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides }) {
1654
+ if (asJson) {
1655
+ process.stdout.write(
1656
+ `${JSON.stringify(
1657
+ {
1658
+ ok: true,
1659
+ searchId,
1660
+ deck: path.relative(process.cwd(), deckDir),
1661
+ slides,
1662
+ },
1663
+ null,
1664
+ 2,
1665
+ )}\n`,
1666
+ );
1667
+ return;
1668
+ }
1669
+ process.stdout.write(
1670
+ `Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
1671
+ written.map((w) => ` ${w}`).join("\n") +
1672
+ `\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`,
1087
1673
  );
1088
1674
  }
1089
1675
 
@@ -1094,13 +1680,13 @@ async function cmdAddSlide(rest) {
1094
1680
  let at = null;
1095
1681
  let to = null;
1096
1682
  let from = null;
1683
+ let asJson = false;
1097
1684
  for (let i = 0; i < rest.length; i++) {
1098
1685
  const a = rest[i];
1099
- if (a === "--search") {
1100
- const next = rest[++i];
1101
- const n = parseInt(next, 10);
1102
- if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
1103
- searchId = n;
1686
+ if (a === "--json") {
1687
+ asJson = true;
1688
+ } else if (a === "--search") {
1689
+ searchId = parseSearchIdArg(rest[++i]);
1104
1690
  } else if (a === "--from") {
1105
1691
  from = rest[++i];
1106
1692
  if (!from) fail("--from expects a path to a .pptx file");
@@ -1173,21 +1759,30 @@ async function cmdAddSlide(rest) {
1173
1759
  .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
1174
1760
  const targets = reused.reusedSlideTargets(existing, results, at);
1175
1761
  const written = [];
1762
+ const slides = [];
1176
1763
  results.forEach((r, i) => {
1177
1764
  const target = path.join(slidesDir, targets[i].filename);
1178
1765
  fs.writeFileSync(target, Buffer.from(r.bytes));
1179
1766
  written.push(path.relative(process.cwd(), target));
1767
+ slides.push({
1768
+ selection: picks[i] ?? null,
1769
+ slideId: r.slideId ?? null,
1770
+ file: path.relative(process.cwd(), target),
1771
+ });
1772
+ });
1773
+ writeAddSlideResult({
1774
+ asJson,
1775
+ searchId: null,
1776
+ deckDir,
1777
+ deck,
1778
+ written,
1779
+ slides,
1180
1780
  });
1181
- process.stdout.write(
1182
- `Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
1183
- written.map((w) => ` ${w}`).join("\n") +
1184
- `\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`,
1185
- );
1186
1781
  return;
1187
1782
  }
1188
1783
 
1189
1784
  if (searchId === null || !picks || !deck) {
1190
- fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>]");
1785
+ fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>] [--json]");
1191
1786
  }
1192
1787
  const deckDir = resolveDeckArg(deck);
1193
1788
  const slidesDir = path.join(deckDir, "slides");
@@ -1196,12 +1791,17 @@ async function cmdAddSlide(rest) {
1196
1791
  const client = await import(
1197
1792
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1198
1793
  );
1199
- const registry = await client.requirePaneHost();
1200
- // The searchId is presentation-scoped, so --to targets the window the search
1201
- // ran on. Without it, a registry with >1 open window is a dead-end.
1202
- const entry = client.pickPresentationEntry(registry, to);
1203
- const presId = entry.presId;
1204
- const backendURL = entry.backendURL ?? null;
1794
+ // Direct path: an opaque searchId routes to the environment that minted the
1795
+ // record; the deck's bound design only decides when the id can't. Pane path:
1796
+ // the searchId is presentation-scoped, so --to targets the window the search
1797
+ // ran on (without it, a registry with >1 open window is a dead-end).
1798
+ const routing = await resolveEngineRouting(client, {
1799
+ to,
1800
+ brandId: await deckBrandId(deckDir),
1801
+ searchId,
1802
+ });
1803
+ const presId = routing.presId;
1804
+ const backendURL = routing.backendURL;
1205
1805
 
1206
1806
  // Extract each picked slide's bytes over the bridge, then let the shared
1207
1807
  // helper compute the play-order filenames (NN-reused-<shortid>.pptx). The
@@ -1222,9 +1822,12 @@ async function cmdAddSlide(rest) {
1222
1822
  if (s.tempPath) fs.rmSync(s.tempPath, { force: true });
1223
1823
  }
1224
1824
  };
1825
+ // A searchId can be an opaque server id on the direct path — keep it usable as
1826
+ // a file name component.
1827
+ const searchKey = String(searchId).replace(/[^A-Za-z0-9._-]/g, "_");
1225
1828
  try {
1226
1829
  for (const pick of picks) {
1227
- const tempPath = path.join(slidesDir, `.reused-${searchId}-${pick}.part`);
1830
+ const tempPath = path.join(slidesDir, `.reused-${searchKey}-${pick}.part`);
1228
1831
  if (typeof client.extractSlideToFile === "function") {
1229
1832
  try {
1230
1833
  const res = await client.extractSlideToFile({
@@ -1240,13 +1843,22 @@ async function cmdAddSlide(rest) {
1240
1843
  continue;
1241
1844
  } catch (err) {
1242
1845
  fs.rmSync(tempPath, { force: true });
1846
+ const stale = await loginRequiredMessage(client, err, { to });
1847
+ if (stale) {
1848
+ cleanupTemps();
1849
+ fail(stale);
1850
+ }
1243
1851
  // Route absent (bodyless 404 / 501) → this add-in predates the streaming
1244
- // route; fall through to the capped base64 ack path below.
1852
+ // route; fall through to the capped base64 ack path below. On the
1853
+ // pane-free path there is no ack path to fall back to.
1245
1854
  const routeMissing =
1246
1855
  (err?.status === 404 && !err?.code) || err?.status === 501;
1247
- if (!routeMissing) {
1856
+ if (!routeMissing || !presId) {
1857
+ const described = await describeEngineByteFailure(client, err, {
1858
+ backendURL,
1859
+ });
1248
1860
  cleanupTemps();
1249
- fail(`add-slide failed for selection ${pick}: ${err.message}`);
1861
+ fail(`add-slide failed for selection ${pick}: ${described ?? err.message}`);
1250
1862
  }
1251
1863
  }
1252
1864
  }
@@ -1271,6 +1883,7 @@ async function cmdAddSlide(rest) {
1271
1883
  .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
1272
1884
  const targets = reused.reusedSlideTargets(existing, extracted, at);
1273
1885
  const written = [];
1886
+ const slides = [];
1274
1887
  extracted.forEach((slide, i) => {
1275
1888
  const target = path.join(slidesDir, targets[i].filename);
1276
1889
  if (slide.tempPath) {
@@ -1279,12 +1892,13 @@ async function cmdAddSlide(rest) {
1279
1892
  fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
1280
1893
  }
1281
1894
  written.push(path.relative(process.cwd(), target));
1895
+ slides.push({
1896
+ selection: slide.selection,
1897
+ slideId: slide.slideId ?? null,
1898
+ file: path.relative(process.cwd(), target),
1899
+ });
1282
1900
  });
1283
- process.stdout.write(
1284
- `Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
1285
- written.map((w) => ` ${w}`).join("\n") +
1286
- `\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`,
1287
- );
1901
+ writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides });
1288
1902
  }
1289
1903
 
1290
1904
  // Map a `--icon-color` value onto the `--pick` list. Accepts either one colour
@@ -1318,10 +1932,7 @@ async function cmdViewSlide(rest) {
1318
1932
  for (let i = 0; i < rest.length; i++) {
1319
1933
  const a = rest[i];
1320
1934
  if (a === "--search") {
1321
- const next = rest[++i];
1322
- const n = parseInt(next, 10);
1323
- if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
1324
- searchId = n;
1935
+ searchId = parseSearchIdArg(rest[++i]);
1325
1936
  } else if (a === "--pick") {
1326
1937
  const next = rest[++i];
1327
1938
  const n = parseInt(next, 10);
@@ -1342,7 +1953,7 @@ async function cmdViewSlide(rest) {
1342
1953
  }
1343
1954
  }
1344
1955
  if (searchId === null || pick === null) {
1345
- fail("usage: slidemind view-slide --search <id> --pick <n> --to <presId> [--out <dir>]");
1956
+ fail("usage: slidemind view-slide --search <id> --pick <n> [--to <presId>] [--out <dir>]");
1346
1957
  }
1347
1958
  const client = await import(
1348
1959
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
@@ -1350,16 +1961,18 @@ async function cmdViewSlide(rest) {
1350
1961
  const { resolveAssetExt } = await import(
1351
1962
  pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
1352
1963
  );
1353
- const registry = await client.requirePaneHost();
1354
- // The searchId is presentation-scoped, so --to targets the window the search
1355
- // ran on. Without it, a registry with >1 open window is a dead-end.
1356
- const entry = client.pickPresentationEntry(registry, to);
1357
- const res = await client.viewSlide({
1358
- presId: entry.presId,
1359
- searchId,
1360
- selection: pick,
1361
- backendURL: entry.backendURL ?? null,
1362
- });
1964
+ const routing = await resolveEngineRouting(client, { to, searchId });
1965
+ let res;
1966
+ try {
1967
+ res = await client.viewSlide({
1968
+ presId: routing.presId,
1969
+ searchId,
1970
+ selection: pick,
1971
+ backendURL: routing.backendURL,
1972
+ });
1973
+ } catch (err) {
1974
+ await failOnEngineBlocker(client, err, { to });
1975
+ }
1363
1976
  if (!res?.ok || !res.dataUri) {
1364
1977
  fail(`view-slide failed: ${res?.error ?? "no image returned"}`);
1365
1978
  }
@@ -1383,6 +1996,135 @@ async function cmdViewSlide(rest) {
1383
1996
  );
1384
1997
  }
1385
1998
 
1999
+ async function cmdReadSource(rest) {
2000
+ let searchId = null;
2001
+ let pick = null;
2002
+ let fromSlide = null;
2003
+ let toSlide = null;
2004
+ let to = null;
2005
+ let outFile = null;
2006
+ for (let i = 0; i < rest.length; i++) {
2007
+ const a = rest[i];
2008
+ if (a === "--search") {
2009
+ searchId = parseSearchIdArg(rest[++i]);
2010
+ } else if (a === "--pick") {
2011
+ const next = rest[++i];
2012
+ const n = parseInt(next, 10);
2013
+ if (!Number.isFinite(n) || n <= 0) {
2014
+ fail(`--pick expects a positive selection number, got '${next}'`);
2015
+ }
2016
+ pick = n;
2017
+ } else if (a === "--from-slide") {
2018
+ const next = rest[++i];
2019
+ const n = parseInt(next, 10);
2020
+ if (!Number.isFinite(n) || n <= 0) {
2021
+ fail(`--from-slide expects a positive slide number, got '${next}'`);
2022
+ }
2023
+ fromSlide = n;
2024
+ } else if (a === "--to-slide") {
2025
+ const next = rest[++i];
2026
+ const n = parseInt(next, 10);
2027
+ if (!Number.isFinite(n) || n <= 0) {
2028
+ fail(`--to-slide expects a positive slide number, got '${next}'`);
2029
+ }
2030
+ toSlide = n;
2031
+ } else if (a === "--to") {
2032
+ // Deliberately the WINDOW, matching every other search-scoped command.
2033
+ // The slide window is --from-slide/--to-slide.
2034
+ to = rest[++i];
2035
+ if (!to) fail("--to expects a presId");
2036
+ } else if (a === "--out" || a === "-o") {
2037
+ outFile = rest[++i];
2038
+ if (!outFile) fail("--out expects a file path");
2039
+ } else if (a.startsWith("-")) {
2040
+ fail(`unknown flag: ${a}`);
2041
+ } else {
2042
+ fail(`unexpected argument: ${a}`);
2043
+ }
2044
+ }
2045
+ if (searchId === null || pick === null) {
2046
+ fail(
2047
+ "usage: slidemind read-source --search <id> --pick <n> [--from-slide <n>] [--to-slide <n>] [--to <presId>] [--out <file>]",
2048
+ );
2049
+ }
2050
+ const client = await import(
2051
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
2052
+ );
2053
+ // Pane path: the searchId is presentation-scoped, so --to targets the window
2054
+ // the search ran on — same rule as view-slide. Direct path: the record is the
2055
+ // backend's, so no window is involved.
2056
+ const routing = await resolveEngineRouting(client, { to, searchId });
2057
+ let res;
2058
+ try {
2059
+ res = await client.readSourcePresentation({
2060
+ presId: routing.presId,
2061
+ searchId,
2062
+ selection: pick,
2063
+ fromSlide,
2064
+ toSlide,
2065
+ backendURL: routing.backendURL,
2066
+ });
2067
+ } catch (err) {
2068
+ // The login_required line is authored once and knows about an open pane;
2069
+ // everything else is this command's own vocabulary.
2070
+ fail(
2071
+ (await loginRequiredMessage(client, err, { to })) ??
2072
+ describeReadSourceFailure(err, searchId, pick),
2073
+ );
2074
+ }
2075
+ if (!res?.markdown) {
2076
+ fail("read-source returned no content. Try the search again, then re-pick.");
2077
+ }
2078
+ if (outFile) {
2079
+ const target = path.resolve(process.cwd(), outFile);
2080
+ fs.mkdirSync(path.dirname(target), { recursive: true });
2081
+ fs.writeFileSync(target, res.markdown, "utf8");
2082
+ process.stdout.write(
2083
+ `Wrote the source presentation of result ${pick} (searchId ${searchId}) to ${target}\n`,
2084
+ );
2085
+ } else {
2086
+ process.stdout.write(res.markdown.endsWith("\n") ? res.markdown : `${res.markdown}\n`);
2087
+ }
2088
+ // Truncation is the one thing a reader must not miss: the body they just got
2089
+ // is a slide map, not the deck.
2090
+ if (res.truncated) {
2091
+ process.stderr.write(
2092
+ `note: this deck was too long to return in full — the output is its slide map. ` +
2093
+ `Re-run with --from-slide/--to-slide to read a range.\n`,
2094
+ );
2095
+ } else if (res.totalSlideCount && res.slideCount < res.totalSlideCount) {
2096
+ process.stderr.write(
2097
+ `note: showing ${res.slideCount} of ${res.totalSlideCount} slides.\n`,
2098
+ );
2099
+ }
2100
+ }
2101
+
2102
+ /**
2103
+ * Turn a source-presentation failure into the action that actually fixes it.
2104
+ * A login_required refusal is handled one level up, in loginRequiredMessage —
2105
+ * it is the one blocker only the USER can clear and it reads identically
2106
+ * across every command.
2107
+ */
2108
+ function describeReadSourceFailure(err, searchId, pick) {
2109
+ const code = err?.code ?? err?.body?.code ?? null;
2110
+ switch (code) {
2111
+ case "unknown_search":
2112
+ return `searchId ${searchId} is no longer available on that window. Run 'slidemind search' again, then re-pick.`;
2113
+ case "wrong_result_type":
2114
+ return `result ${pick} has no source presentation — only slide search results come from a deck (image and collection results do not).`;
2115
+ case "access_denied":
2116
+ return "you do not have access to that result's source presentation.";
2117
+ case "no_indexed_content":
2118
+ return "that presentation has no indexed slides yet. It may still be syncing — try again later.";
2119
+ case "invalid_source":
2120
+ return `result ${pick} carries no source file reference. Re-run the search, or pick a different result.`;
2121
+ case "source_presentation_unsupported":
2122
+ return err.message;
2123
+ default:
2124
+ return `read-source failed: ${err?.message ?? "unknown error"}`;
2125
+ }
2126
+ }
2127
+
1386
2128
  async function cmdDownloadResult(rest) {
1387
2129
  let searchId = null;
1388
2130
  let picks = null;
@@ -1392,10 +2134,7 @@ async function cmdDownloadResult(rest) {
1392
2134
  for (let i = 0; i < rest.length; i++) {
1393
2135
  const a = rest[i];
1394
2136
  if (a === "--search") {
1395
- const next = rest[++i];
1396
- const n = parseInt(next, 10);
1397
- if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
1398
- searchId = n;
2137
+ searchId = parseSearchIdArg(rest[++i]);
1399
2138
  } else if (a === "--pick") {
1400
2139
  picks = parsePicks(rest[++i]);
1401
2140
  } else if (a === "--deck") {
@@ -1415,7 +2154,7 @@ async function cmdDownloadResult(rest) {
1415
2154
  }
1416
2155
  if (searchId === null || !picks || !deck) {
1417
2156
  fail(
1418
- "usage: slidemind download-result --search <id> --pick <n[,n,...]> --deck <deck> --to <presId> [--icon-color <hex[,hex,...]>]",
2157
+ "usage: slidemind download-result --search <id> --pick <n[,n,...]> --deck <deck> [--to <presId>] [--icon-color <hex[,hex,...]>]",
1419
2158
  );
1420
2159
  }
1421
2160
  const iconColors = parseIconColors(iconColorsRaw, picks.length);
@@ -1430,10 +2169,15 @@ async function cmdDownloadResult(rest) {
1430
2169
  const { downloadOneResult } = await import(
1431
2170
  pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
1432
2171
  );
1433
- const registry = await client.requirePaneHost();
1434
- // The searchId is presentation-scoped, so --to targets the window the search
1435
- // ran on. Without it, a registry with >1 open window is a dead-end.
1436
- const entry = client.pickPresentationEntry(registry, to);
2172
+ // Direct path: an opaque searchId routes to the record's own environment,
2173
+ // else the deck's bound design decides; no window needed. Pane path: the
2174
+ // searchId is presentation-scoped, so --to targets the window the search ran
2175
+ // on (without it, >1 open window is a dead-end).
2176
+ const routing = await resolveEngineRouting(client, {
2177
+ to,
2178
+ brandId: await deckBrandId(deckDir),
2179
+ searchId,
2180
+ });
1437
2181
 
1438
2182
  // Concurrent — each download is its own bridge round-trip; allSettled so one
1439
2183
  // bad selection doesn't sink the rest of the batch.
@@ -1441,8 +2185,8 @@ async function cmdDownloadResult(rest) {
1441
2185
  picks.map((selection, i) =>
1442
2186
  downloadOneResult({
1443
2187
  client,
1444
- presId: entry.presId,
1445
- backendURL: entry.backendURL ?? null,
2188
+ presId: routing.presId,
2189
+ backendURL: routing.backendURL,
1446
2190
  searchId,
1447
2191
  assetsDir,
1448
2192
  selection,
@@ -1450,10 +2194,26 @@ async function cmdDownloadResult(rest) {
1450
2194
  }),
1451
2195
  ),
1452
2196
  );
1453
- const results = settled.map((r, i) =>
1454
- r.status === "fulfilled"
1455
- ? r.value
1456
- : { selection: picks[i], error: r.reason?.message ?? String(r.reason) },
2197
+ // One user-clearable blocker sinks the whole batch identically — every pick
2198
+ // would fail the same way, and the fix is one action, not N error lines.
2199
+ const stale = settled.find(
2200
+ (r) => r.status === "rejected" && client.loginRequiredBlocker?.(r.reason),
2201
+ );
2202
+ if (stale) fail(await loginRequiredMessage(client, stale.reason, { to }));
2203
+ const results = await Promise.all(
2204
+ settled.map(async (r, i) =>
2205
+ r.status === "fulfilled"
2206
+ ? r.value
2207
+ : {
2208
+ selection: picks[i],
2209
+ error:
2210
+ (await describeEngineByteFailure(client, r.reason, {
2211
+ backendURL: routing.backendURL,
2212
+ })) ??
2213
+ r.reason?.message ??
2214
+ String(r.reason),
2215
+ },
2216
+ ),
1457
2217
  );
1458
2218
  const { formatSize } = await import(
1459
2219
  pathToFileURL(path.join(SRC_DIR, "bridge", "image-dimensions.mjs")).href
@@ -1539,7 +2299,9 @@ async function cmdConceptImages(rest) {
1539
2299
  pathToFileURL(path.join(BUILDERS_DIR, "plan.mjs")).href
1540
2300
  );
1541
2301
  const planState = plan.loadPlanState(deckDir);
1542
- if (planState?.decision !== "approved") {
2302
+ // "approve" is the gate's own persisted vocabulary (stdout says "approved");
2303
+ // comparing against "approved" here made the note fire on every approved deck.
2304
+ if (planState?.decision !== "approve") {
1543
2305
  process.stderr.write(
1544
2306
  `slidemind: note — ${deck}'s plan is not signed off yet ` +
1545
2307
  `(run \`slidemind plan ${deck}\`); generating concept mockups from the current plan.md anyway.\n`,
@@ -1760,7 +2522,7 @@ async function cmdPresentations(rest) {
1760
2522
  }
1761
2523
  for (const p of sorted) {
1762
2524
  const label = p.title || p.fileName || "(untitled)";
1763
- const env = p.env ? `[${p.env}]\t` : "";
2525
+ const env = p.env && isMultiEnv() ? `[${p.env}]\t` : "";
1764
2526
  const state =
1765
2527
  p.connectionState ||
1766
2528
  (p.connected === false || p.suspendedAt ? "suspended" : "connected");
@@ -1798,20 +2560,22 @@ function publicConnectionStatus(status, client) {
1798
2560
  };
1799
2561
  }
1800
2562
 
1801
- // The ordered connection-readiness ladder as a STRUCTURED blocker: the first
1802
- // unmet precondition, or null when the connection is fully ready. `code` is the
1803
- // machine-copeable reason an agent switches on; `actor` says who can clear it —
1804
- // only `cli_update_required` is the AGENT's to fix (it can run `slidemind
1805
- // update`), every other rung needs a human at the add-in. The one exception is
1806
- // a plugin-installed CLI: `/plugin` is the only updater that replaces the
1807
- // binary on the session's PATH, and the agent cannot drive it, so that rung
1808
- // hands back to the user (see src/install-channel.mjs). The `message` strings
1809
- // are otherwise BYTE-IDENTICAL to the old connectionNextAction ladder so
1810
- // doctor's printed output never churns (connectionNextAction is now a thin
1811
- // projection over this).
1812
- // `slidemind ready` layers `ambiguous_design` on top and treats `pane_suspended`
1813
- // as non-blocking (a render-ready-but-suspended window is still a design source).
1814
- function connectionBlocker(pub) {
2563
+ // The readiness ladder splits in two, because the two halves fail
2564
+ // independently: BACKEND AUTHORING readiness (a session, a reachable add-in
2565
+ // backend, a supported CLI — everything render, bundle and the pane-free engine
2566
+ // routes need) and LIVE POWERPOINT readiness (a registered window, render-ready
2567
+ // metadata, an awake pane — what push/insert-slide and reading an open deck
2568
+ // need). Keeping them apart is the whole point of doc 13: a dead pane is no
2569
+ // longer "cannot work", it only defers the last mile.
2570
+ //
2571
+ // Each rung is a STRUCTURED blocker: `code` is the machine-copeable reason an
2572
+ // agent switches on; `actor` says who can clear it — only `cli_update_required`
2573
+ // is the AGENT's to fix (it can run `slidemind update`), every other rung needs
2574
+ // a human at the add-in. The one exception is a plugin-installed CLI:
2575
+ // `/plugin` is the only updater that replaces the binary on the session's PATH,
2576
+ // and the agent cannot drive it, so that rung hands back to the user (see
2577
+ // src/install-channel.mjs).
2578
+ function backendBlocker(pub) {
1815
2579
  if (!pub.paired) {
1816
2580
  return {
1817
2581
  code: "not_paired",
@@ -1841,6 +2605,15 @@ function connectionBlocker(pub) {
1841
2605
  updateCliInstruction(),
1842
2606
  };
1843
2607
  }
2608
+ return null;
2609
+ }
2610
+
2611
+ // The live-PowerPoint half. Never gates render, bundle or the pane-free engine
2612
+ // routes — it only says whether the ops that genuinely type into an open deck
2613
+ // (push's insert, insert-slide, reading an unsaved presentation) can run now.
2614
+ // The `message` strings are BYTE-IDENTICAL to the pre-split ladder so doctor's
2615
+ // printed output never churns.
2616
+ function paneBlocker(pub) {
1844
2617
  if ((pub.presentations ?? []).length === 0) {
1845
2618
  return {
1846
2619
  code: "no_window",
@@ -1875,12 +2648,20 @@ function connectionBlocker(pub) {
1875
2648
  code: "pane_suspended",
1876
2649
  actor: "user",
1877
2650
  message:
1878
- "Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push/search/read.",
2651
+ "Rendering is available, but pane operations are suspended. Bring PowerPoint forward and click/reopen the slidemind pane before push, insert-slide, or reading this window.",
1879
2652
  };
1880
2653
  }
1881
2654
  return null;
1882
2655
  }
1883
2656
 
2657
+ // The two ladders end-to-end, in the order a user hits them: nothing about a
2658
+ // window matters while the backend half is unmet. `slidemind ready` layers
2659
+ // `ambiguous_design` on top and treats `pane_suspended` as non-blocking (a
2660
+ // render-ready-but-suspended window is still a design source).
2661
+ function connectionBlocker(pub) {
2662
+ return backendBlocker(pub) ?? paneBlocker(pub);
2663
+ }
2664
+
1884
2665
  // Back-compat projection for doctor: just the blocker's message, or "Ready."
1885
2666
  // when the ladder is clear. Keeps the ladder authored in ONE place.
1886
2667
  function connectionNextAction(pub) {
@@ -1908,6 +2689,7 @@ async function cmdSetup(rest) {
1908
2689
  let target = "auto";
1909
2690
  let doInstall = true;
1910
2691
  let doReady = true;
2692
+ let doLogin = true;
1911
2693
  let asJson = false;
1912
2694
  for (let i = 0; i < rest.length; i++) {
1913
2695
  const a = rest[i];
@@ -1921,6 +2703,8 @@ async function cmdSetup(rest) {
1921
2703
  doInstall = false;
1922
2704
  } else if (a === "--no-ready") {
1923
2705
  doReady = false;
2706
+ } else if (a === "--no-login") {
2707
+ doLogin = false;
1924
2708
  } else if (a === "--json") {
1925
2709
  asJson = true;
1926
2710
  } else if (a.startsWith("-")) {
@@ -1955,14 +2739,24 @@ async function cmdSetup(rest) {
1955
2739
  ui.banner(pkg.version, "setup");
1956
2740
 
1957
2741
  // --- 1. spend the claim -------------------------------------------------
1958
- await ui.section("connect", envLabel);
2742
+ // Whether to name the environment at all. Decided BEFORE the claim is spent
2743
+ // so every line in this run agrees: a first connection has nothing to
2744
+ // disambiguate against and stays quiet, while adding a second (or third)
2745
+ // environment names each one. Computing it per-line would flip mid-run, since
2746
+ // `upsertSession` below changes the count.
2747
+ const showEnv = cfg.readSessions().length > 0;
2748
+ await ui.section("connect", showEnv ? envLabel : "");
1959
2749
  const sessionToken = await claims.redeemSetupClaim({
1960
2750
  backendURL,
1961
2751
  secret,
1962
2752
  cliVersion: pkg.version,
1963
2753
  });
1964
2754
  cfg.upsertSession({ backendURL, sessionToken, label: envLabel });
1965
- ui.step({ label: "authorize", value: envLabel, note: "session saved" });
2755
+ ui.step({
2756
+ label: "authorize",
2757
+ value: showEnv ? envLabel : "connected",
2758
+ note: "session saved",
2759
+ });
1966
2760
 
1967
2761
  // --- 2. make this CLI permanent ----------------------------------------
1968
2762
  // Without this the whole thing evaporates with npx's cache: the user would be
@@ -2046,6 +2840,52 @@ async function cmdSetup(rest) {
2046
2840
  bundleMod.clearBundleCache();
2047
2841
  }
2048
2842
 
2843
+ // --- 4. Microsoft 365 sign-in (best-effort) -----------------------------
2844
+ // The pasted-claim moment is the one time the user is reliably at the
2845
+ // keyboard in consent headspace, so offer the browser sign-in NOW and no
2846
+ // later command has to interrupt a task for it. Strictly best-effort and
2847
+ // interactive-only: --json or no TTY (an agent ran setup — a browser tab it
2848
+ // cannot see must never block onboarding), --no-login, a backend that does
2849
+ // not honour the token, a decline or a timeout all skip WITHOUT failing
2850
+ // setup — the closing `ready` and the failure-time remediations still name
2851
+ // `slidemind login`.
2852
+ if (doLogin && !asJson && process.stdout.isTTY) {
2853
+ try {
2854
+ const client = await loadBridgeModule("client");
2855
+ if (await client.backendSupportsGraphDelegated(backendURL)) {
2856
+ await ui.section("sign in", "Microsoft 365");
2857
+ process.stderr.write(
2858
+ "Opening your browser to sign in to Microsoft 365 — this lets SharePoint-backed\n" +
2859
+ "content download with no PowerPoint window open.\n",
2860
+ );
2861
+ const graph = await loadBridgeModule("graph-auth");
2862
+ const res = await graph.loginGraph({
2863
+ backendURL,
2864
+ // Shorter than `slidemind login`'s: the user is mid-setup, not
2865
+ // parked on a sign-in they asked for — a walked-away browser tab
2866
+ // should not stall onboarding for five minutes.
2867
+ timeoutMs: 120_000,
2868
+ onUrl: (url) =>
2869
+ process.stderr.write(
2870
+ `If no browser opens, paste this link into one:\n ${url}\n`,
2871
+ ),
2872
+ });
2873
+ ui.step({
2874
+ label: "Microsoft 365",
2875
+ value: res.username ?? "signed in",
2876
+ ...(showEnv ? { note: envLabel } : {}),
2877
+ });
2878
+ }
2879
+ } catch {
2880
+ ui.step({
2881
+ status: "warn",
2882
+ label: "Microsoft 365",
2883
+ value: "skipped",
2884
+ note: "run 'slidemind login' anytime for window-free SharePoint downloads",
2885
+ });
2886
+ }
2887
+ }
2888
+
2049
2889
  if (asJson) {
2050
2890
  const globalInstall =
2051
2891
  doInstall && !pluginInstall
@@ -2078,8 +2918,8 @@ async function cmdSetup(rest) {
2078
2918
 
2079
2919
  ui.done(
2080
2920
  target === "none" || (skills.length === 0 && skippedPlugins.length === 0)
2081
- ? `connected to ${envLabel} — run 'slidemind ready' to start`
2082
- : `connected to ${envLabel} and ready to use`,
2921
+ ? `connected${showEnv ? ` to ${envLabel}` : ""} — run 'slidemind ready' to start`
2922
+ : `connected${showEnv ? ` to ${envLabel}` : ""} and ready to use`,
2083
2923
  );
2084
2924
 
2085
2925
  if (installError) {
@@ -2097,7 +2937,7 @@ async function cmdSetup(rest) {
2097
2937
  return;
2098
2938
  }
2099
2939
 
2100
- // --- 4. finish the session the user actually came for -------------------
2940
+ // --- 5. finish the session the user actually came for -------------------
2101
2941
  // `ready` exits non-zero for every legitimate not-yet blocker (PowerPoint
2102
2942
  // closed, no design activated). Setup already succeeded at that point, so its
2103
2943
  // blocker is information, not a failure — run it for the message and the
@@ -2121,6 +2961,7 @@ async function cmdReady(rest) {
2121
2961
  let asJson = false;
2122
2962
  let force = false;
2123
2963
  let connectOnly = false;
2964
+ let brand = null;
2124
2965
  const positional = [];
2125
2966
  for (let i = 0; i < rest.length; i++) {
2126
2967
  const a = rest[i];
@@ -2133,12 +2974,18 @@ async function cmdReady(rest) {
2133
2974
  force = true;
2134
2975
  } else if (a === "--connect-only") {
2135
2976
  connectOnly = true;
2977
+ } else if (a === "--brand") {
2978
+ brand = rest[++i];
2979
+ if (!brand) fail("--brand expects a design id (presFP)");
2136
2980
  } else if (a.startsWith("-")) {
2137
2981
  fail(`unknown flag: ${a}`);
2138
2982
  } else {
2139
2983
  positional.push(a);
2140
2984
  }
2141
2985
  }
2986
+ if (brand && to) {
2987
+ fail("--brand and --to are mutually exclusive (a design id or a window, not both)");
2988
+ }
2142
2989
 
2143
2990
  // --- Local: resolve the deck dir (never create it) and read its existing
2144
2991
  // bundle ref. deckPresFP feeds ONLY the mirror tie-break + `suggested`; it
@@ -2228,10 +3075,213 @@ async function cmdReady(rest) {
2228
3075
  return;
2229
3076
  }
2230
3077
 
3078
+ // --- Backend authoring readiness beyond the connection itself: which
3079
+ // environments serve the pane-free engine routes. Probed at most once (and
3080
+ // only when a payload actually reports it), and never fatal — an add-in
3081
+ // without them still authors, it just relays search through a window. ---
3082
+ let engineInfo = null;
3083
+ const engines = async () => {
3084
+ if (!engineInfo) {
3085
+ try {
3086
+ engineInfo = await client.engineReachability({
3087
+ backends: statusRaw.backends,
3088
+ });
3089
+ } catch {
3090
+ engineInfo = { available: false, envs: [] };
3091
+ }
3092
+ }
3093
+ return engineInfo;
3094
+ };
3095
+
3096
+ // Microsoft 365 sign-in, surfaced HERE because ready is the agent's
3097
+ // session-start preflight and browser SSO is the one step an agent cannot do
3098
+ // itself — it has to ask its human, and this is where it learns to. Reported
3099
+ // only when a backend advertises graphDelegated (older backends have no
3100
+ // window-free SharePoint path, so there is nothing to sign in FOR).
3101
+ // On a cliLoginV1 backend the sign-in is REQUIRED for the whole pane-free
3102
+ // engine surface (catalog, search, downloads, extraction answer 401
3103
+ // login_required without it) — so the degradation says that plainly and up
3104
+ // front, before the agent's first search meets the 401. Render and bundle
3105
+ // still never need it. null = no backend advertises it.
3106
+ let m365Info;
3107
+ const m365 = async () => {
3108
+ if (m365Info !== undefined) return m365Info;
3109
+ const advertising = (statusRaw.backends ?? []).filter(
3110
+ (b) => b.ok && b.capabilities?.graphDelegated === true && b.backendURL,
3111
+ );
3112
+ if (advertising.length === 0) {
3113
+ m365Info = null;
3114
+ return m365Info;
3115
+ }
3116
+ const graph = await loadBridgeModule("graph-auth");
3117
+ const signedOut = advertising.filter(
3118
+ (b) => !graph.hasGraphSession(b.backendURL),
3119
+ );
3120
+ const signedOutEnvs = signedOut.map((b) => cfg.deriveEnvLabel(b.backendURL));
3121
+ m365Info =
3122
+ signedOut.length === 0
3123
+ ? { signedIn: true, degradation: null, line: "Microsoft 365: signed in" }
3124
+ : {
3125
+ signedIn: false,
3126
+ degradation: {
3127
+ code: "login_required",
3128
+ actor: "user",
3129
+ envs: signedOutEnvs,
3130
+ message:
3131
+ "Microsoft 365: not signed in — engine search, downloads and slide " +
3132
+ "extraction run as the USER. The first engine command opens a browser " +
3133
+ "sign-in AUTOMATICALLY and waits; tell the user to complete it there " +
3134
+ "(you cannot). Render, build and bundle work without it.",
3135
+ },
3136
+ line:
3137
+ `Microsoft 365: not signed in${envTag(signedOutEnvs.join(", "))} — the next engine ` +
3138
+ "command opens a browser sign-in for the user to complete (automatic; " +
3139
+ "`slidemind login` also works any time).",
3140
+ };
3141
+ return m365Info;
3142
+ };
3143
+
3144
+ // --- A design resolved WITHOUT a window — the deck's own binding, an
3145
+ // explicit --brand, or the backend's brand list. Same payload either way:
3146
+ // ready, `windowless: true`, and a pane status that is INFORMATIONAL. What
3147
+ // the missing window actually costs depends on the engine routes: with them
3148
+ // only push/insert-slide and reading an open presentation need PowerPoint.
3149
+ //
3150
+ // "Windowless" is about how the DESIGN was resolved, not about the registry:
3151
+ // --brand runs before the registry is consulted at all, and the later rungs
3152
+ // fire on "nothing render-ready", which a connected-but-still-matching window
3153
+ // satisfies. So the pane status is DERIVED from the registry as it stands at
3154
+ // call time (the hydration wait reassigns it), and the prose only claims
3155
+ // "no PowerPoint window" when there genuinely is none. ---
3156
+ const finishWindowless = async (result) => {
3157
+ if (deckDir) {
3158
+ bundle.writeDeckBundleRef(deckDir, {
3159
+ presFP: result.presFP,
3160
+ etag: result.etag,
3161
+ });
3162
+ }
3163
+ const designName = readDesignName(result.dir);
3164
+ const env = result.backendURL ? cfg.deriveEnvLabel(result.backendURL) : null;
3165
+ const eng = await engines();
3166
+ const m = await m365();
3167
+ const paneReady = registry.some(client.isPaneReadyPresentation);
3168
+ const noWindow = registry.length === 0;
3169
+ const nextAction = noWindow
3170
+ ? eng.available
3171
+ ? "Ready (no PowerPoint window — authoring, engine search and build-pptx all work; only push/insert-slide and reading an open presentation need one)."
3172
+ : "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane)."
3173
+ : paneReady
3174
+ ? "Ready (design resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide)."
3175
+ : eng.available
3176
+ ? "Ready (design resolved without targeting a window; pane operations — push/insert-slide/read of a window — will wait for a pane click; engine search does not)."
3177
+ : "Ready (design resolved without targeting a window; pane operations — push/search/read — will wait for a pane click).";
3178
+ if (asJson) {
3179
+ process.stdout.write(
3180
+ JSON.stringify(
3181
+ {
3182
+ ready: true,
3183
+ windowless: true,
3184
+ deck: deckSlug,
3185
+ bound: Boolean(deckDir),
3186
+ env,
3187
+ presId: null,
3188
+ presFP: result.presFP,
3189
+ designName,
3190
+ bundleDir: result.dir,
3191
+ bundleEtag: result.etag ?? null,
3192
+ paneSuspended: !paneReady,
3193
+ engines: eng,
3194
+ ...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
3195
+ warnings: result.warnings ?? [],
3196
+ nextAction,
3197
+ },
3198
+ null,
3199
+ 2,
3200
+ ) + "\n",
3201
+ );
3202
+ return;
3203
+ }
3204
+ const designLabel = designName
3205
+ ? `${designName} (${result.presFP})`
3206
+ : result.presFP;
3207
+ process.stdout.write(
3208
+ `Ready — design ${designLabel}${envTag(env)}${noWindow ? " (no PowerPoint window)" : ""}\n`,
3209
+ );
3210
+ process.stdout.write(` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`);
3211
+ if (deckDir) process.stdout.write(` deck ${deckSlug} bound\n`);
3212
+ process.stdout.write(
3213
+ noWindow
3214
+ ? eng.available
3215
+ ? " Note: authoring, engine search and build-pptx need no window; push/insert-slide and reading an open presentation do.\n"
3216
+ : " Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n"
3217
+ : paneReady
3218
+ ? " Note: the design was resolved without targeting a window; a registered PowerPoint window is available for push/insert-slide.\n"
3219
+ : eng.available
3220
+ ? " Note: pane operations (push/insert-slide/read of a window) will wait for a pane click; engine search does not.\n"
3221
+ : " Note: pane operations (push/search/read) will wait for a pane click.\n",
3222
+ );
3223
+ if (m) process.stdout.write(` ${m.line}\n`);
3224
+ for (const w of result.warnings ?? []) {
3225
+ process.stderr.write(` warning: ${w}\n`);
3226
+ }
3227
+ };
3228
+
3229
+ // Materialize a design by id over the pane-free brand route. Returns null on
3230
+ // any miss (old add-in, unknown/revoked design) so the caller can fall
3231
+ // through to the window ladder instead of dead-ending.
3232
+ const materializeBrand = async (brandId) => {
3233
+ try {
3234
+ return await bundle.materializeBundle(null, { force, brandId });
3235
+ } catch {
3236
+ return null;
3237
+ }
3238
+ };
3239
+
3240
+ // --- --brand <presFP>: the user (or the agent, after an ambiguous brand
3241
+ // list) named the design outright. It resolves with or without a window, so
3242
+ // it runs before the registry is consulted at all. ---
3243
+ if (brand) {
3244
+ const result = await materializeBrand(brand);
3245
+ if (!result) {
3246
+ finishBlocker({
3247
+ blocker: {
3248
+ code: "unknown_brand",
3249
+ actor: "user",
3250
+ message: `No connected slidemind add-in serves design '${brand}'. Run 'slidemind ready' with no --brand to see which designs are available.`,
3251
+ },
3252
+ });
3253
+ return;
3254
+ }
3255
+ await finishWindowless(result);
3256
+ return;
3257
+ }
3258
+
3259
+ const isRR = client.isRenderReadyPresentation;
3260
+
3261
+ // --- Bound-deck ladder: session → bundle freshness, no pane precondition.
3262
+ // The design identity (presFP == brandId) lives in the deck's
3263
+ // .bundle-ref.json and the bundle syncs over the pane-free brand route, so
3264
+ // with PowerPoint CLOSED (an empty registry) a bound deck readies straight
3265
+ // away instead of sitting out the hydration wait for a window that isn't
3266
+ // coming. A registry that holds windows still hydrates first — the open
3267
+ // add-in stays the source of truth whenever there IS one. Skipped for
3268
+ // --connect-only (which resolves no design at all) and for --to (an explicit
3269
+ // window must resolve through the registry); a miss (old add-in, legacy ref,
3270
+ // revoked grant) falls through to the window ladder. ---
3271
+ const boundDeckLadder = deckDir && deckPresFP && !to && !connectOnly;
3272
+ let boundRungTried = false;
3273
+ if (boundDeckLadder && registry.length === 0) {
3274
+ boundRungTried = true;
3275
+ const result = await materializeBrand(deckPresFP);
3276
+ if (result) {
3277
+ await finishWindowless(result);
3278
+ return;
3279
+ }
3280
+ }
3281
+
2231
3282
  // --- Bounded hydration wait when nothing is render-ready yet: covers the
2232
3283
  // just-clicked / just-woke pane still hydrating brand metadata. Polls even on
2233
3284
  // an empty registry (that's the flaky window this papers over). ---
2234
- const isRR = client.isRenderReadyPresentation;
2235
3285
  if (!registry.some(isRR)) {
2236
3286
  const waitMs =
2237
3287
  parseInt(process.env.SLIDEMIND_BRIDGE_PRESENTATIONS_WAIT_MS || "6000", 10) ||
@@ -2245,74 +3295,92 @@ async function cmdReady(rest) {
2245
3295
  }
2246
3296
  }
2247
3297
  if (!registry.some(isRR)) {
2248
- // --- Pane-free rung: a BOUND deck readies over the brand routes with no
2249
- // window at all — the design identity (presFP == brandId) lives in the
2250
- // deck's .bundle-ref.json, and the render half never needed the pane.
2251
- // Degraded: build-pptx/build/review/view-draft work; push/search/read
2252
- // still need a pane. Skipped for --connect-only (search needs a pane) and
2253
- // --to (an explicit window must resolve through the registry). Any miss
2254
- // (old add-in, legacy ref, revoked grant) falls through to the ladder.
2255
- if (deckDir && deckPresFP && !to && !connectOnly) {
2256
- let result = null;
3298
+ // --connect-only asks one question — "can I work?" — and since the engine
3299
+ // routes answer off the session, a backend that serves them IS the answer:
3300
+ // discovery, search and every per-result fetch run with PowerPoint closed.
3301
+ // Without them search is still relayed through a pane, so the window rung
3302
+ // below stands.
3303
+ if (connectOnly && (await engines()).available) {
3304
+ const eng = await engines();
3305
+ const m = await m365();
3306
+ if (asJson) {
3307
+ process.stdout.write(
3308
+ JSON.stringify(
3309
+ {
3310
+ ready: true,
3311
+ connectOnly: true,
3312
+ windowless: true,
3313
+ engines: eng,
3314
+ ...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
3315
+ nextAction:
3316
+ "Ready (no PowerPoint window — engine search runs off the connection; only insert-slide needs one).",
3317
+ backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
3318
+ },
3319
+ null,
3320
+ 2,
3321
+ ) + "\n",
3322
+ );
3323
+ } else {
3324
+ process.stdout.write(
3325
+ `Connected. ${pub.backendCount} backend(s), 0 window(s) registered — ` +
3326
+ `engine search runs without one${envTag(eng.envs.join(", "))}.\n`,
3327
+ );
3328
+ if (m) process.stdout.write(` ${m.line}\n`);
3329
+ }
3330
+ return;
3331
+ }
3332
+ // The bound-deck ladder again, for the windows-registered-but-never-ready
3333
+ // case the pre-poll rung deliberately left to the hydration wait.
3334
+ if (boundDeckLadder && !boundRungTried) {
3335
+ boundRungTried = true;
3336
+ const result = await materializeBrand(deckPresFP);
3337
+ if (result) {
3338
+ await finishWindowless(result);
3339
+ return;
3340
+ }
3341
+ }
3342
+ // --- Unbound + no window: ask the backend which designs this user may
3343
+ // build against. One is the answer; several genuinely need a human choice,
3344
+ // which --brand records. A backend without the route (or a deck already
3345
+ // bound to a design the brand route just refused) falls through unchanged. ---
3346
+ if (!connectOnly && !to && !deckPresFP && !boundRungTried) {
3347
+ let brands = [];
2257
3348
  try {
2258
- result = await bundle.materializeBundle(null, {
2259
- force,
2260
- brandId: deckPresFP,
2261
- });
3349
+ brands = await client.listBrands();
2262
3350
  } catch {
2263
- /* fall through to the standard no_window / not_render_ready blocker */
3351
+ /* no brand list — the window ladder below is the answer */
2264
3352
  }
2265
- if (result) {
2266
- bundle.writeDeckBundleRef(deckDir, {
2267
- presFP: result.presFP,
2268
- etag: result.etag,
2269
- });
2270
- const designName = readDesignName(result.dir);
2271
- const env = result.backendURL
2272
- ? cfg.deriveEnvLabel(result.backendURL)
2273
- : null;
2274
- const nextAction =
2275
- "Ready (no PowerPoint window — build-pptx/build/review/view-draft work; push/search/read need a pane).";
2276
- if (asJson) {
2277
- process.stdout.write(
2278
- JSON.stringify(
2279
- {
2280
- ready: true,
2281
- windowless: true,
2282
- deck: deckSlug,
2283
- bound: true,
2284
- env,
2285
- presId: null,
2286
- presFP: result.presFP,
2287
- designName,
2288
- bundleDir: result.dir,
2289
- bundleEtag: result.etag ?? null,
2290
- paneSuspended: true,
2291
- warnings: result.warnings ?? [],
2292
- nextAction,
2293
- },
2294
- null,
2295
- 2,
2296
- ) + "\n",
2297
- );
3353
+ if (brands.length === 1) {
3354
+ const result = await materializeBrand(brands[0].presFP);
3355
+ if (result) {
3356
+ await finishWindowless(result);
2298
3357
  return;
2299
3358
  }
2300
- const designLabel = designName
2301
- ? `${designName} (${result.presFP})`
2302
- : result.presFP;
2303
- process.stdout.write(
2304
- `Ready — design ${designLabel}${env ? ` [${env}]` : ""} (no PowerPoint window)\n`,
2305
- );
2306
- process.stdout.write(
2307
- ` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
2308
- );
2309
- process.stdout.write(` deck ${deckSlug} bound\n`);
2310
- process.stdout.write(
2311
- " Note: build-pptx/build/review/view-draft work without a window; push/search/read need a pane.\n",
3359
+ } else if (brands.length > 1) {
3360
+ const rows = brands.map(
3361
+ (b) => ` ${b.presFP}${envTag(b.env)} ${b.name ?? ""}`.trimEnd(),
2312
3362
  );
2313
- for (const w of result.warnings ?? []) {
2314
- process.stderr.write(` warning: ${w}\n`);
2315
- }
3363
+ const blocker = {
3364
+ code: "ambiguous_brand",
3365
+ actor: "user",
3366
+ message:
3367
+ "Several corporate designs are available and no PowerPoint window says which — ask the user which to build against, then re-run: slidemind ready" +
3368
+ `${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>.`,
3369
+ };
3370
+ finishBlocker({
3371
+ blocker,
3372
+ extras: {
3373
+ brands: brands.map((b) => ({
3374
+ presFP: b.presFP,
3375
+ name: b.name ?? null,
3376
+ env: b.env ?? null,
3377
+ })),
3378
+ },
3379
+ humanLines:
3380
+ "slidemind: several corporate designs are available — ask the user which to build against:\n" +
3381
+ `${rows.join("\n")}\n` +
3382
+ `Then re-run: slidemind ready${deckSlug ? ` ${deckSlug}` : ""} --brand <presFP>`,
3383
+ });
2316
3384
  return;
2317
3385
  }
2318
3386
  }
@@ -2339,12 +3407,15 @@ async function cmdReady(rest) {
2339
3407
  // --- --connect-only: the connection is up with ≥1 render-ready window; the
2340
3408
  // search skill needs nothing more (no design resolution, no bundle). ---
2341
3409
  if (connectOnly) {
3410
+ const m = await m365();
2342
3411
  if (asJson) {
2343
3412
  process.stdout.write(
2344
3413
  JSON.stringify(
2345
3414
  {
2346
3415
  ready: true,
2347
3416
  connectOnly: true,
3417
+ engines: await engines(),
3418
+ ...(m ? { m365: { signedIn: m.signedIn, degradation: m.degradation } } : {}),
2348
3419
  nextAction: "Ready.",
2349
3420
  backends: (pub.backends ?? []).map((b) => ({ env: b.env, ok: b.ok })),
2350
3421
  },
@@ -2357,6 +3428,7 @@ async function cmdReady(rest) {
2357
3428
  process.stdout.write(
2358
3429
  `Connected. ${pub.backendCount} backend(s), ${windows} window(s) registered.\n`,
2359
3430
  );
3431
+ if (m) process.stdout.write(` ${m.line}\n`);
2360
3432
  }
2361
3433
  return;
2362
3434
  }
@@ -2395,7 +3467,7 @@ async function cmdReady(rest) {
2395
3467
  };
2396
3468
  const rows = windows.map(
2397
3469
  (p) =>
2398
- ` ${p.presId} ${p.env ? `[${p.env}] ` : ""}${p.title || p.fileName || "(untitled)"}`,
3470
+ ` ${p.presId} ${envTag(p.env)} ${p.title || p.fileName || "(untitled)"}`,
2399
3471
  );
2400
3472
  finishBlocker({
2401
3473
  blocker,
@@ -2416,7 +3488,7 @@ async function cmdReady(rest) {
2416
3488
  "Multiple corporate designs are open — ask the user which to build against, then re-run: slidemind ready --to <presId>.",
2417
3489
  };
2418
3490
  const rows = cand.map((c) => {
2419
- const env = c.env ? ` [${c.env}]` : "";
3491
+ const env = envTag(c.env) ? ` ${envTag(c.env)}` : "";
2420
3492
  const label = c.title || c.fileName || "(untitled)";
2421
3493
  return ` ${c.presId}${env} ${label} (design ${c.brandId ?? "?"})`;
2422
3494
  });
@@ -2468,6 +3540,8 @@ async function cmdReady(rest) {
2468
3540
 
2469
3541
  const designName = readDesignName(result.dir);
2470
3542
  const paneSuspended = !client.isPaneReadyPresentation(entry);
3543
+ const engineInfoForWindow = await engines();
3544
+ const m365ForWindow = await m365();
2471
3545
 
2472
3546
  if (asJson) {
2473
3547
  process.stdout.write(
@@ -2487,6 +3561,15 @@ async function cmdReady(rest) {
2487
3561
  fileName: entry.fileName ?? null,
2488
3562
  },
2489
3563
  paneSuspended,
3564
+ engines: engineInfoForWindow,
3565
+ ...(m365ForWindow
3566
+ ? {
3567
+ m365: {
3568
+ signedIn: m365ForWindow.signedIn,
3569
+ degradation: m365ForWindow.degradation,
3570
+ },
3571
+ }
3572
+ : {}),
2490
3573
  warnings: result.warnings ?? [],
2491
3574
  nextAction: "Ready.",
2492
3575
  },
@@ -2498,22 +3581,25 @@ async function cmdReady(rest) {
2498
3581
  }
2499
3582
 
2500
3583
  // --- Human success: compact multi-line summary on stdout. ---
2501
- const envTag = entry.env ? ` [${entry.env}]` : "";
3584
+ const entryEnv = envTag(entry.env);
2502
3585
  const presLabel = entry.title || entry.fileName || "(untitled)";
2503
3586
  const designLabel = designName
2504
3587
  ? `${designName} (${result.presFP})`
2505
3588
  : result.presFP;
2506
3589
  process.stdout.write(`Ready — design ${designLabel}\n`);
2507
- process.stdout.write(` presentation: ${presLabel}${envTag}\n`);
3590
+ process.stdout.write(` presentation: ${presLabel}${entryEnv}\n`);
2508
3591
  process.stdout.write(
2509
3592
  ` bundle: ${result.dir} (etag ${result.etag ?? "?"})\n`,
2510
3593
  );
2511
3594
  if (bound) process.stdout.write(` deck ${deckSlug} bound\n`);
2512
3595
  if (paneSuspended) {
2513
3596
  process.stdout.write(
2514
- " Note: pane operations (push/search/read) will wait for a pane click.\n",
3597
+ engineInfoForWindow.available
3598
+ ? " Note: pane operations (push/insert-slide/read of this window) will wait for a pane click; engine search does not.\n"
3599
+ : " Note: pane operations (push/search/read) will wait for a pane click.\n",
2515
3600
  );
2516
3601
  }
3602
+ if (m365ForWindow) process.stdout.write(` ${m365ForWindow.line}\n`);
2517
3603
  for (const w of result.warnings ?? []) {
2518
3604
  process.stderr.write(` warning: ${w}\n`);
2519
3605
  }
@@ -2530,11 +3616,14 @@ async function cmdDoctor(rest) {
2530
3616
  // still reaches the reader, as the `cli_update_required` rung of the ladder.
2531
3617
  client.setDiagnosticMode(true);
2532
3618
  if (sessions.length === 0) {
3619
+ const blocker = backendBlocker({ paired: false });
2533
3620
  const pub = {
2534
3621
  paired: false,
2535
3622
  backendCount: 0,
2536
3623
  backends: [],
2537
3624
  presentations: [],
3625
+ authoring: { ready: false, blocker, degraded: null, engines: null },
3626
+ host: { ready: false, blocker: paneBlocker({ presentations: [] }) },
2538
3627
  nextAction: connectionNextAction({ paired: false }),
2539
3628
  };
2540
3629
  process[asJson ? "stdout" : "stderr"].write(
@@ -2542,7 +3631,29 @@ async function cmdDoctor(rest) {
2542
3631
  );
2543
3632
  return;
2544
3633
  }
2545
- const status = publicConnectionStatus(await client.connectionStatus(), client);
3634
+ const statusRaw = await client.connectionStatus();
3635
+ const status = publicConnectionStatus(statusRaw, client);
3636
+ // The two ladders are INDEPENDENT: authoring (session → reachable backend →
3637
+ // CLI version → engine reachability) is what render, bundle and the pane-free
3638
+ // engine routes ride on, while the live-PowerPoint ladder only governs
3639
+ // push/insert-slide and reading an open presentation. Reporting them
3640
+ // separately is the point — a dead pane is not "cannot work".
3641
+ const authoringBlocker = backendBlocker(status);
3642
+ let engines = { available: false, envs: [] };
3643
+ if (!authoringBlocker) {
3644
+ try {
3645
+ engines = await client.engineReachability({ backends: statusRaw.backends });
3646
+ } catch {
3647
+ /* leave it as unavailable — never fail a diagnosis on a probe */
3648
+ }
3649
+ }
3650
+ status.authoring = {
3651
+ ready: !authoringBlocker,
3652
+ blocker: authoringBlocker,
3653
+ engines,
3654
+ };
3655
+ const hostBlocker = paneBlocker(status);
3656
+ status.host = { ready: !hostBlocker, blocker: hostBlocker };
2546
3657
  status.nextAction = connectionNextAction(status);
2547
3658
  if (asJson) {
2548
3659
  process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
@@ -2556,6 +3667,41 @@ async function cmdDoctor(rest) {
2556
3667
  `\n`,
2557
3668
  );
2558
3669
  }
3670
+ process.stdout.write(
3671
+ `Backend authoring ready: ${status.authoring.ready ? "yes" : "no"}` +
3672
+ `${status.authoring.ready ? "" : ` — ${authoringBlocker.message}`}\n`,
3673
+ );
3674
+ process.stdout.write(
3675
+ ` engine search (no window needed): ${
3676
+ engines.available
3677
+ ? `yes [${engines.envs.join(", ")}]`
3678
+ : "no — relayed through an open pane"
3679
+ }\n`,
3680
+ );
3681
+ // Human output only — doctor's --json shape is a contract and the m365 state
3682
+ // already rides `ready --json`. Printed only when a backend can actually
3683
+ // spend a sign-in (graphDelegated), so old deployments stay noise-free.
3684
+ const graphAdvertising = (statusRaw.backends ?? []).filter(
3685
+ (b) => b.ok && b.capabilities?.graphDelegated === true && b.backendURL,
3686
+ );
3687
+ if (graphAdvertising.length > 0) {
3688
+ const graph = await loadBridgeModule("graph-auth");
3689
+ const signedOut = graphAdvertising.filter(
3690
+ (b) => !graph.hasGraphSession(b.backendURL),
3691
+ );
3692
+ const signedOutEnvs = signedOut.map((b) => cfg.deriveEnvLabel(b.backendURL));
3693
+ process.stdout.write(
3694
+ signedOut.length === 0
3695
+ ? " Microsoft 365: signed in\n"
3696
+ : ` Microsoft 365: not signed in [${signedOutEnvs.join(", ")}] — engine search and ` +
3697
+ "downloads run as the user; the next engine command opens a browser " +
3698
+ "sign-in for them to complete (or run 'slidemind login' now)\n",
3699
+ );
3700
+ }
3701
+ process.stdout.write(
3702
+ `Live PowerPoint ready: ${status.host.ready ? "yes" : "no"}` +
3703
+ `${status.host.ready ? "" : ` — ${hostBlocker.message}`}\n`,
3704
+ );
2559
3705
  if (status.presentations.length === 0) {
2560
3706
  process.stdout.write("Open presentations: none\n");
2561
3707
  } else {
@@ -2697,16 +3843,88 @@ async function loadBridgeModule(name) {
2697
3843
  return import(pathToFileURL(path.join(SRC_DIR, "bridge", `${name}.mjs`)).href);
2698
3844
  }
2699
3845
 
3846
+ // The one line every "you are not connected" path speaks, so the remedy reads
3847
+ // the same wherever a user meets it.
3848
+ const NOT_CONNECTED_LINE =
3849
+ "Not connected. In PowerPoint open slidemind → Settings → General → Connect CLI, press the link button, and run the command it shows.";
3850
+
3851
+ // Match a `--backend`-style argument against a paired session the way logout and
3852
+ // use do: exact URL, stored label, derived env label, or a substring of the URL.
3853
+ function matchesBackend(cfg, session, want) {
3854
+ return (
3855
+ session.backendURL === want ||
3856
+ session.label === want ||
3857
+ cfg.deriveEnvLabel(session.backendURL) === want ||
3858
+ session.backendURL.includes(want)
3859
+ );
3860
+ }
3861
+
3862
+ // `slidemind login [--backend <url|env>]` — sign this CLI in to Microsoft 365 so
3863
+ // the pane-free engine routes can fetch SharePoint-backed content with the
3864
+ // user's OWN identity (docs/slidemind-cli-connect/15-graph-delegated-cli.md).
3865
+ // Separate from the CLI session on purpose: that one is minted by the add-in for
3866
+ // whoever pressed Connect CLI, this one is an Entra token for whoever completes
3867
+ // the browser sign-in, and only the second may read a user's SharePoint content.
3868
+ // Everything else — render, bundle, whoami — is unaffected either way.
3869
+ async function cmdLogin(rest) {
3870
+ const stray = rest.find((a) => a.startsWith("-"));
3871
+ if (stray) {
3872
+ fail(`unexpected argument: ${stray}\nusage: slidemind login [--backend <url|env>]`);
3873
+ }
3874
+ const cfg = await loadBridgeModule("config");
3875
+ const sessions = cfg.readSessions();
3876
+ if (sessions.length === 0) fail(NOT_CONNECTED_LINE);
3877
+ // A browser flow targets ONE environment — there is nothing to fan out. The
3878
+ // scope flag picks it; otherwise the default environment does, and the closing
3879
+ // line names the others so nothing looks silently skipped.
3880
+ const want = (process.env.SLIDEMIND_BACKEND || "").trim();
3881
+ let target;
3882
+ if (want) {
3883
+ target = sessions.find((s) => matchesBackend(cfg, s, want));
3884
+ if (!target) {
3885
+ fail(`not paired with '${want}'. Run 'slidemind whoami' to list environments.`);
3886
+ }
3887
+ } else {
3888
+ const def = cfg.getDefaultBackend();
3889
+ target = sessions.find((s) => s.backendURL === def) ?? sessions[0];
3890
+ }
3891
+ const graph = await loadBridgeModule("graph-auth");
3892
+ // This command is routinely typed by an AI agent on the human's machine, and
3893
+ // the browser is where the agent's reach ends — say the handoff out loud.
3894
+ process.stderr.write(
3895
+ `Opening your browser to sign in to Microsoft 365${envPhrase(target.label, "for")}…\n` +
3896
+ "A HUMAN must complete this step (agents: ask your user to finish the " +
3897
+ "sign-in in the browser window that just opened).\n",
3898
+ );
3899
+ const res = await graph.loginGraph({
3900
+ backendURL: target.backendURL,
3901
+ // Printed before the opener runs: on a machine where no opener works this
3902
+ // link is the only way through, and it is useless after the wait times out.
3903
+ onUrl: (url) =>
3904
+ process.stderr.write(`If no browser opens, paste this link into one:\n ${url}\n`),
3905
+ });
3906
+ process.stdout.write(
3907
+ `Signed in to Microsoft 365 as ${res.username ?? "your account"}${envPhrase(target.label, "for")}.\n` +
3908
+ "SharePoint-backed slides and images now download with no PowerPoint window open.\n",
3909
+ );
3910
+ const others = sessions.filter((s) => s.backendURL !== target.backendURL);
3911
+ if (others.length > 0) {
3912
+ process.stdout.write(
3913
+ `Still signed out: ${others.map((s) => s.label).join(", ")} ` +
3914
+ "(each needs its own 'slidemind login --backend <env>').\n",
3915
+ );
3916
+ }
3917
+ }
3918
+
2700
3919
  async function cmdWhoami() {
2701
3920
  const cfg = await loadBridgeModule("config");
2702
3921
  const sessions = cfg.readSessions();
2703
3922
  if (sessions.length === 0) {
2704
- process.stderr.write(
2705
- "Not connected. In PowerPoint open slidemind → Settings → General → Connect CLI, press the link button, and run the command it shows.\n",
2706
- );
3923
+ process.stderr.write(`${NOT_CONNECTED_LINE}\n`);
2707
3924
  return;
2708
3925
  }
2709
3926
  const { httpJson } = await loadBridgeModule("http-json");
3927
+ const graph = await loadBridgeModule("graph-auth");
2710
3928
  const def = cfg.getDefaultBackend();
2711
3929
  const lines = await Promise.all(
2712
3930
  sessions.map(async (s) => {
@@ -2725,7 +3943,12 @@ async function cmdWhoami() {
2725
3943
  } catch {
2726
3944
  detail = "unreachable — check your connection or authorize again";
2727
3945
  }
2728
- return `${marker} ${s.label}\n ${detail}`;
3946
+ // Storage-only — no probe. The Graph sign-in is a SECOND identity, so it
3947
+ // is reported next to the session rather than folded into it.
3948
+ const graphLine = graph.hasGraphSession(s.backendURL)
3949
+ ? "Microsoft 365: signed in"
3950
+ : "Microsoft 365: not signed in — needed only for window-free SharePoint downloads (`slidemind login`)";
3951
+ return `${marker} ${s.label}\n ${detail}\n ${graphLine}`;
2729
3952
  }),
2730
3953
  );
2731
3954
  process.stdout.write(
@@ -2765,6 +3988,10 @@ async function cmdLogout(rest) {
2765
3988
  }
2766
3989
 
2767
3990
  const { httpJson } = await loadBridgeModule("http-json");
3991
+ // Unpairing takes the WHOLE record, the Microsoft 365 refresh token included —
3992
+ // it was stored for this environment's session and must not outlive it. Said
3993
+ // out loud because signing back in is a second command, not part of `setup`.
3994
+ const droppedGraph = toRemove.filter((s) => s.msGraphRefreshToken);
2768
3995
  for (const s of toRemove) {
2769
3996
  try {
2770
3997
  await httpJson("POST", `${s.backendURL}/api/cli/logout`, {
@@ -2773,12 +4000,21 @@ async function cmdLogout(rest) {
2773
4000
  } catch {
2774
4001
  /* best-effort server-side revoke; we still drop the local session */
2775
4002
  }
4003
+ // Through the token setter, not just the record removal: the refresh token
4004
+ // may live in the macOS keychain (config.mjs), which removing the session
4005
+ // record alone would orphan.
4006
+ cfg.setGraphRefreshToken(s.backendURL, null);
2776
4007
  cfg.removeSession(s.backendURL);
2777
4008
  }
2778
4009
  const remaining = cfg.readSessions().length;
2779
4010
  process.stdout.write(
2780
4011
  `Logged out of ${toRemove.map((s) => s.label).join(", ")}.` +
2781
- (remaining > 0 ? ` ${remaining} environment(s) still connected.\n` : "\n"),
4012
+ (remaining > 0 ? ` ${remaining} environment(s) still connected.\n` : "\n") +
4013
+ (droppedGraph.length > 0
4014
+ ? `The Microsoft 365 sign-in for ${droppedGraph
4015
+ .map((s) => s.label)
4016
+ .join(", ")} was dropped too — run 'slidemind login' after reconnecting.\n`
4017
+ : ""),
2782
4018
  );
2783
4019
  }
2784
4020
 
@@ -2803,12 +4039,114 @@ async function cmdUse(rest) {
2803
4039
  );
2804
4040
  }
2805
4041
 
4042
+ // `slidemind adopt <file.pptx> [--deck <slug>] [--brand <presFP>] [--no-convert]`
4043
+ // — reconstruct a local deck workspace from a presentation's deck-embedded
4044
+ // source tags (slides authored with slidemind carry their jsx inside the file).
4045
+ // Slides with no source are handed to the add-in's deterministic PPTX→JSX
4046
+ // conversion route where it is available (`docs/slidemind-cli-connect/
4047
+ // 14-foreign-slide-conversion.md`); whatever it declines folds in as native
4048
+ // reused .pptx. See also intern-slidemind docs/deck-embedded-source-spec.md.
4049
+ async function cmdAdopt(rest) {
4050
+ const args = [...rest];
4051
+ let deckSlug = null;
4052
+ let brand = null;
4053
+ let convert = true;
4054
+ const positional = [];
4055
+ for (let i = 0; i < args.length; i++) {
4056
+ const a = args[i];
4057
+ if (a === "--deck") {
4058
+ deckSlug = args[++i] ?? null;
4059
+ if (!deckSlug) fail("--deck expects a deck slug");
4060
+ } else if (a === "--brand") {
4061
+ brand = args[++i] ?? null;
4062
+ if (!brand) fail("--brand expects a design id (presFP)");
4063
+ } else if (a === "--no-convert") {
4064
+ convert = false;
4065
+ } else if (a.startsWith("-")) {
4066
+ fail(`unknown flag: ${a}`);
4067
+ } else {
4068
+ positional.push(a);
4069
+ }
4070
+ }
4071
+ const pptxArg = positional[0];
4072
+ if (!pptxArg) {
4073
+ fail("usage: slidemind adopt <file.pptx> [--deck <slug>] [--brand <presFP>] [--no-convert]");
4074
+ }
4075
+ // v1 is FILE decks only. A presId (or any non-file argument) would mean
4076
+ // converting a LIVE presentation, which needs slide-export and tag-read
4077
+ // events the relay does not have — say so instead of "no such file".
4078
+ const {
4079
+ LIVE_DECK_UNSUPPORTED,
4080
+ adoptDeckFromPptx,
4081
+ convertUntaggedSlides,
4082
+ readSourceDeck,
4083
+ } = await import(pathToFileURL(path.join(SRC_DIR, "lib", "adopt.mjs")).href);
4084
+ if (!LOCAL_PPTX_RE.test(pptxArg)) fail(LIVE_DECK_UNSUPPORTED);
4085
+ const pptxPath = path.resolve(pptxArg);
4086
+ if (!fs.existsSync(pptxPath)) fail(`no such file: ${pptxArg}`);
4087
+ const slug = deckSlug ?? path.basename(pptxPath).replace(/\.pptx$/i, "");
4088
+ // The decks dir, not a hardcoded ./decks — otherwise `adopt` writes where no
4089
+ // other subcommand looks whenever SLIDEMIND_DECKS is set. A RELATIVE slug
4090
+ // must stay inside it (an explicit absolute path is the user's own choice) —
4091
+ // every other untrusted name in this feature is escape-guarded, and this one
4092
+ // rides agent-authored command lines.
4093
+ const decksRoot = path.resolve(resolveDecksDir());
4094
+ const deckDir = path.isAbsolute(slug) ? slug : path.resolve(decksRoot, slug);
4095
+ if (
4096
+ !path.isAbsolute(slug) &&
4097
+ (deckDir === decksRoot || !deckDir.startsWith(decksRoot + path.sep))
4098
+ ) {
4099
+ fail(
4100
+ `--deck '${slug}' escapes the decks directory (${decksRoot}) — ` +
4101
+ "pass an absolute path to adopt somewhere else on purpose",
4102
+ );
4103
+ }
4104
+ // ONE read/unzip/tag-decode of the source file, threaded into both halves —
4105
+ // they must see the same snapshot (and a 40 MB deck decodes slowly).
4106
+ const source = readSourceDeck(pptxPath);
4107
+ const converted = convert
4108
+ ? await convertUntaggedSlides({
4109
+ pptxPath,
4110
+ deckDir,
4111
+ brandId: brand,
4112
+ note: (line) => process.stderr.write(`${line}\n`),
4113
+ source,
4114
+ })
4115
+ : null;
4116
+ const result = adoptDeckFromPptx({ pptxPath, deckDir, converted, source });
4117
+ // A decks dir outside the cwd (SLIDEMIND_DECKS) relativizes to ../../.. noise
4118
+ // — print the absolute path there instead.
4119
+ const shown = path.relative(process.cwd(), deckDir);
4120
+ process.stdout.write(
4121
+ `Adopted ${result.adopted.length} slide(s) with embedded source` +
4122
+ (result.converted.length
4123
+ ? `, converted ${result.converted.length} foreign slide(s) to editable jsx`
4124
+ : "") +
4125
+ (result.native.length
4126
+ ? ` and folded ${result.native.length} untagged slide(s) as native .pptx`
4127
+ : "") +
4128
+ ` into ${shown.startsWith("..") ? deckDir : shown}/.\n` +
4129
+ (result.skipped.length
4130
+ ? `${result.skipped.length} slide(s) stayed native: ` +
4131
+ `${result.skipped
4132
+ .map((s) => `#${s.index ?? "?"} ${s.reason ?? "unknown"}`)
4133
+ .join(", ")}\n`
4134
+ : "") +
4135
+ (result.bundle?.presFP && result.bundle.presFP !== "unknown"
4136
+ ? `Deck bound to bundle ${result.bundle.presFP}.\n`
4137
+ : `No bundle reference found in the file — run 'slidemind download-bundle ${slug}' before rendering.\n`) +
4138
+ `Edit slides/*.jsx and run 'slidemind push ${slug}' or 'slidemind build-pptx ${slug}'.\n`,
4139
+ );
4140
+ }
4141
+
2806
4142
  async function cmdPush(rest) {
2807
4143
  const { deck, passthrough } = splitDeckArg(rest, "push");
2808
4144
  let to = null;
2809
4145
  let at = null;
2810
4146
  let replace = false;
2811
4147
  let slides = null;
4148
+ let acceptConversions = false;
4149
+ let force = false;
2812
4150
  for (let i = 0; i < passthrough.length; i++) {
2813
4151
  const a = passthrough[i];
2814
4152
  if (a === "--to") {
@@ -2823,6 +4161,10 @@ async function cmdPush(rest) {
2823
4161
  at = n;
2824
4162
  } else if (a === "--replace") {
2825
4163
  replace = true;
4164
+ } else if (a === "--accept-conversions") {
4165
+ acceptConversions = true;
4166
+ } else if (a === "--force") {
4167
+ force = true;
2826
4168
  } else if (a === "--slides") {
2827
4169
  const next = passthrough[++i];
2828
4170
  if (!next) fail("--slides expects a comma-separated list of slide ids");
@@ -2851,6 +4193,8 @@ async function cmdPush(rest) {
2851
4193
  insertAt: at,
2852
4194
  replace,
2853
4195
  slideIds: slides,
4196
+ acceptConversions,
4197
+ force,
2854
4198
  onProgress: renderProgressPrinter(),
2855
4199
  });
2856
4200
  for (const w of result.warnings ?? []) {
@@ -3241,7 +4585,35 @@ function extractBackendScope(argv) {
3241
4585
  return out;
3242
4586
  }
3243
4587
 
4588
+ // Route every printed byte through the credential scrubber, once, before any
4589
+ // command runs. The alternative is scrubbing at ~80 individual write sites
4590
+ // across `bin/` and `src/` and remembering to do it at the next one — this way
4591
+ // a secret cannot reach a stream no matter who writes it or how it got into the
4592
+ // string (a response body used as an error detail, a `--json` payload spread
4593
+ // from a server response, an argv echo). Only strings are touched: the CLI
4594
+ // writes binary to files, never to a stream, and a non-string chunk passes
4595
+ // through untouched regardless.
4596
+ function scrubOutputStreams() {
4597
+ for (const stream of [process.stdout, process.stderr]) {
4598
+ const write = stream.write.bind(stream);
4599
+ stream.write = (chunk, ...rest) =>
4600
+ write(typeof chunk === "string" ? scrub(chunk) : chunk, ...rest);
4601
+ }
4602
+ }
4603
+
3244
4604
  async function main(rawArgv) {
4605
+ scrubOutputStreams();
4606
+ // A claim pasted WITHOUT the `setup` verb never reaches `decodeSetupClaim`,
4607
+ // so nothing would register it — and `unknown command: <argv>` would print it
4608
+ // whole. Registering straight off argv closes that without the error message
4609
+ // needing to know what a claim is.
4610
+ for (const a of rawArgv) {
4611
+ // The claim, not the whole argument: a user who pastes the entire
4612
+ // `npx … setup smc1_…` line as one word must still have the credential
4613
+ // inside it scrubbed, not just that exact line.
4614
+ const claim = typeof a === "string" ? a.match(/smc1_[A-Za-z0-9_-]+/) : null;
4615
+ if (claim) registerSecret(claim[0]);
4616
+ }
3245
4617
  const argv = extractBackendScope(rawArgv);
3246
4618
  const [cmd, ...rest] = argv;
3247
4619
  if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
@@ -3268,6 +4640,9 @@ async function main(rawArgv) {
3268
4640
  case "build-pptx":
3269
4641
  await cmdBuildPptx(rest);
3270
4642
  return 0;
4643
+ case "inspect-template-deck":
4644
+ await cmdInspectTemplateDeck(rest);
4645
+ return 0;
3271
4646
  case "view-draft":
3272
4647
  await cmdViewDraft(rest);
3273
4648
  return 0;
@@ -3280,6 +4655,9 @@ async function main(rawArgv) {
3280
4655
  case "view-slide":
3281
4656
  await cmdViewSlide(rest);
3282
4657
  return 0;
4658
+ case "read-source":
4659
+ await cmdReadSource(rest);
4660
+ return 0;
3283
4661
  case "insert-slide":
3284
4662
  await cmdInsertSlide(rest);
3285
4663
  return 0;
@@ -3304,6 +4682,9 @@ async function main(rawArgv) {
3304
4682
  case "new":
3305
4683
  await cmdNew(rest);
3306
4684
  return 0;
4685
+ case "login":
4686
+ await cmdLogin(rest);
4687
+ break;
3307
4688
  case "whoami":
3308
4689
  await cmdWhoami(rest);
3309
4690
  break;
@@ -3324,6 +4705,9 @@ async function main(rawArgv) {
3324
4705
  case "read":
3325
4706
  await cmdRead(rest);
3326
4707
  return 0;
4708
+ case "adopt":
4709
+ await cmdAdopt(rest);
4710
+ return 0;
3327
4711
  case "push":
3328
4712
  await cmdPush(rest);
3329
4713
  return 0;