slidemind 0.0.29 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -64,7 +64,7 @@ slidemind/
64
64
  ├── .claude-plugin/
65
65
  │ └── plugin.json Claude Code plugin manifest
66
66
  ├── bin/
67
- │ └── slidemind.mjs CLI dispatcher (presentations, download-bundle, init, build, build-pptx, push, search, install)
67
+ │ └── slidemind.mjs CLI dispatcher (presentations, download-bundle, init, build, build-pptx, push, search, install, pair/whoami/use/logout)
68
68
  ├── skills/ canonical, installable skill payload — thin CLI workflow glue only
69
69
  │ └── create-slides/
70
70
  │ └── SKILL.md the whole skill: host gate + intake/plan + create flow + CLI⇄in-app command map.
@@ -95,12 +95,18 @@ slidemind build <deck> # build self-contained HTML preview
95
95
  slidemind build-pptx <deck> # render native .pptx via the add-in (HOST REQUIRED)
96
96
  slidemind push <deck> # render + push slides into a running PowerPoint (HOST REQUIRED)
97
97
  slidemind search "<query>" # search the brand engine for imagery (HOST REQUIRED)
98
+ slidemind pair <code> # pair with an add-in environment (copy from its "Connect CLI")
99
+ slidemind whoami # list every paired environment (* = default)
100
+ slidemind use <url|env> # set the default environment
101
+ slidemind logout [url|env] # unpair one environment (--all unpairs every one)
98
102
  slidemind init --for <target> # install skill into claude | codex | opencode | all
99
103
  slidemind uninstall --for <target>
100
104
  ```
101
105
 
102
106
  `<deck>` resolves as: absolute path → `./<arg>` if it exists → `./decks/<arg>`. The decks directory is `./decks/` by default; override with `SLIDEMIND_DECKS=/abs/path`.
103
107
 
108
+ **Multi-environment pairing.** The CLI stores one session **per backend** (`~/.config/slidemind/session.json`, v2 `{version, sessions[], default}`; the legacy single-record v1 shape is migrated on read). Pair once per environment (local / dev / prod) and every command **fans out** across all paired backends, tags each open window with its `env`, and **routes by the presentation's origin backend** automatically — no manual switching. Scope a command to one backend with `--backend <url|env>` (or `SLIDEMIND_BACKEND`). This is a developer convenience; a normal user pairs exactly one. See the memory note *CLI↔add-in: cloud relay + pairing*.
109
+
104
110
  **The design truth lives in the add-in, not the CLI.** The `.pptx` is rendered server-side over the local bridge (no local exporter), the corporate design is fetched as a bundle, and brand imagery comes from the add-in's engine. So `create`/`build-pptx`/`push`/`search` all hard-require an **open PowerPoint host with the slidemind add-in and an active corporate design**. See `docs/slidemind-cli-connect/`.
105
111
 
106
112
  ## Design system ground rules
package/README.md CHANGED
@@ -62,6 +62,33 @@ slidemind push my-deck --slides 02,05,07 # push only a subset of source slides
62
62
 
63
63
  If `slidemind presentations` returns nothing, the add-in isn't running — open PowerPoint with the slidemind add-in and an active corporate design, then retry. (Rendering is server-side now, so `build-pptx` needs a host too.)
64
64
 
65
+ ## Pairing
66
+
67
+ The CLI reaches PowerPoint through the add-in's cloud relay. Pair once: in the add-in click **"Connect CLI"** and run the shown command.
68
+
69
+ ```bash
70
+ slidemind pair <code> # copy the exact command from the add-in's "Connect CLI"
71
+ slidemind whoami # show the paired account
72
+ slidemind logout # unpair this machine
73
+ ```
74
+
75
+ ### Pairing multiple environments
76
+
77
+ For slidemind developers who run the add-in against more than one backend (local / dev / prod), the CLI is **multi-environment**: pair once per backend and every command automatically discovers your open PowerPoint across all of them — no manual switching.
78
+
79
+ ```bash
80
+ slidemind pair <local-code> --label local
81
+ slidemind pair <dev-code> --label dev
82
+ slidemind whoami # lists every paired environment (* = default)
83
+ slidemind presentations # windows from all environments, each tagged [env]
84
+ slidemind push my-deck --to <presId> # routed to whichever environment owns that window
85
+ slidemind use dev # set the default environment
86
+ slidemind logout dev # unpair one (or --all to unpair every one)
87
+ slidemind --backend local presentations # scope a single command to one environment
88
+ ```
89
+
90
+ Each "Connect CLI" code carries its own backend URL and session token, so pairing is per-environment and routing is automatic. A normal user just pairs one.
91
+
65
92
  ## Export formats at a glance
66
93
 
67
94
  | Command | Output | When to use |
package/bin/slidemind.mjs CHANGED
@@ -59,6 +59,12 @@ function printHelp() {
59
59
  ` --at <index> insert after slide N (0-based); default = append\n` +
60
60
  ` --replace delete existing slides first (destructive)\n` +
61
61
  ` --slides <ids> comma-separated source slide ids to push\n` +
62
+ ` slidemind pair <code> Pair with an add-in (copy the code from its "Connect CLI")\n` +
63
+ ` --label <name> name this environment (default: derived from URL)\n` +
64
+ ` Pair once per environment; commands fan out across all of them.\n` +
65
+ ` slidemind whoami Show every paired environment (* = default)\n` +
66
+ ` slidemind use <url|env> Set the default environment\n` +
67
+ ` slidemind logout [url|env] Unpair one environment (--all to unpair every one)\n` +
62
68
  ` slidemind init --for <t> Set up slidemind in a harness (registers the MCP server + skill)\n` +
63
69
  ` slidemind uninstall --for <t> Remove a previously-installed skill\n` +
64
70
  ` slidemind update [--for <t>] Fetch the latest release and redeploy\n` +
@@ -69,7 +75,8 @@ function printHelp() {
69
75
  `\n` +
70
76
  `Install targets: claude | codex | opencode | all\n` +
71
77
  `Decks live in ./decks/ relative to your current directory.\n` +
72
- `Override with env: SLIDEMIND_DECKS=/abs/path/to/decks\n`,
78
+ `Override with env: SLIDEMIND_DECKS=/abs/path/to/decks\n` +
79
+ `Global: --backend <url|env> scope a command to one paired environment (also SLIDEMIND_BACKEND)\n`,
73
80
  );
74
81
  }
75
82
 
@@ -218,10 +225,12 @@ async function cmdSearch(rest) {
218
225
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
219
226
  );
220
227
  const registry = await client.requireHost();
221
- const presId = client.pickPresentation(registry, to);
228
+ const entry = client.pickPresentationEntry(registry, to);
229
+ const presId = entry.presId;
230
+ const backendURL = entry.backendURL ?? null;
222
231
 
223
232
  if (!engineId) {
224
- const mat = await bundle.materializeBundle(presId);
233
+ const mat = await bundle.materializeBundle(presId, { backendURL });
225
234
  const pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
226
235
  if (!pick) {
227
236
  fail(
@@ -231,7 +240,7 @@ async function cmdSearch(rest) {
231
240
  engineId = pick.id;
232
241
  }
233
242
 
234
- const res = await client.engineSearch({ presId, engineId, query });
243
+ const res = await client.engineSearch({ presId, engineId, query, backendURL });
235
244
  if (asJson) {
236
245
  process.stdout.write(`${JSON.stringify(res, null, 2)}\n`);
237
246
  return;
@@ -276,8 +285,12 @@ async function cmdDownloadBundle(rest) {
276
285
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
277
286
  );
278
287
  const registry = await client.requireHost();
279
- const presId = client.pickPresentation(registry, to);
280
- const result = await bundle.materializeBundle(presId, { force });
288
+ const entry = client.pickPresentationEntry(registry, to);
289
+ const presId = entry.presId;
290
+ const result = await bundle.materializeBundle(presId, {
291
+ force,
292
+ backendURL: entry.backendURL ?? null,
293
+ });
281
294
 
282
295
  // Optionally bind a deck to this bundle so build-pptx/push pass the right
283
296
  // etag and the local HTML/editor read the right design files.
@@ -306,7 +319,9 @@ async function cmdPresentations(rest) {
306
319
  );
307
320
  const list = await mod.listOpenPresentations();
308
321
  if (asJson) {
309
- process.stdout.write(JSON.stringify(list, null, 2) + "\n");
322
+ process.stdout.write(
323
+ JSON.stringify(list.map(mod.toPublicPresentation), null, 2) + "\n",
324
+ );
310
325
  return;
311
326
  }
312
327
  if (list.length === 0) {
@@ -317,8 +332,167 @@ async function cmdPresentations(rest) {
317
332
  }
318
333
  for (const p of list) {
319
334
  const label = p.title || p.fileName || "(untitled)";
320
- process.stdout.write(`${p.presId}\t${label}\n`);
335
+ const env = p.env ? `[${p.env}]\t` : "";
336
+ process.stdout.write(`${p.presId}\t${env}${label}\n`);
337
+ }
338
+ }
339
+
340
+ async function loadBridgeModule(name) {
341
+ return import(pathToFileURL(path.join(SRC_DIR, "bridge", `${name}.mjs`)).href);
342
+ }
343
+
344
+ async function cmdPair(rest) {
345
+ let code = null;
346
+ let label = null;
347
+ for (let i = 0; i < rest.length; i++) {
348
+ const a = rest[i];
349
+ if (a === "--label") {
350
+ label = rest[++i];
351
+ if (!label) fail("--label expects a name");
352
+ } else if (!a.startsWith("--") && !code) {
353
+ code = a;
354
+ }
355
+ }
356
+ if (!code) {
357
+ fail('usage: slidemind pair <code> (copy the command from the add-in\'s "Connect CLI")');
358
+ }
359
+ const pairMod = await loadBridgeModule("pair");
360
+ const decoded = pairMod.decodePairingCode(code);
361
+ if (!decoded) {
362
+ fail("invalid pairing code — copy the exact `slidemind pair …` command shown in the add-in");
363
+ }
364
+ const res = await pairMod.httpJson(
365
+ "POST",
366
+ `${decoded.backendURL}/api/cli/pair/redeem`,
367
+ { body: { secret: decoded.secret } },
368
+ );
369
+ if (res.status !== 200 || !res.json?.sessionToken) {
370
+ fail(
371
+ `pairing failed (${res.status}): ${res.json?.error || "no session returned"}\n` +
372
+ "the code may have expired — generate a fresh one in the add-in's \"Connect CLI\"",
373
+ );
374
+ }
375
+ const cfg = await loadBridgeModule("config");
376
+ const sessions = cfg.upsertSession({
377
+ backendURL: decoded.backendURL,
378
+ sessionToken: res.json.sessionToken,
379
+ label,
380
+ });
381
+ const env = cfg.deriveEnvLabel(decoded.backendURL);
382
+ const others = sessions.length - 1;
383
+ const suffix =
384
+ others > 0
385
+ ? ` (${sessions.length} environments connected — commands now fan out across all of them)`
386
+ : "";
387
+ process.stdout.write(
388
+ `Paired with ${label || env}${suffix}. Run 'slidemind presentations' to verify.\n`,
389
+ );
390
+ }
391
+
392
+ async function cmdWhoami() {
393
+ const cfg = await loadBridgeModule("config");
394
+ const sessions = cfg.readSessions();
395
+ if (sessions.length === 0) {
396
+ process.stderr.write(
397
+ 'Not paired. Run `slidemind pair <code>` using the add-in\'s "Connect CLI".\n',
398
+ );
399
+ return;
400
+ }
401
+ const pairMod = await loadBridgeModule("pair");
402
+ const def = cfg.getDefaultBackend();
403
+ const lines = await Promise.all(
404
+ sessions.map(async (s) => {
405
+ const marker = s.backendURL === def ? "*" : " ";
406
+ let detail;
407
+ try {
408
+ const res = await pairMod.httpJson(
409
+ "GET",
410
+ `${s.backendURL}/api/cli/whoami`,
411
+ { token: s.sessionToken },
412
+ );
413
+ detail =
414
+ res.status === 200 && res.json
415
+ ? `user ${res.json.oid} · tenant ${res.json.tid ?? "(n/a)"}`
416
+ : `session invalid (${res.status}) — re-pair`;
417
+ } catch {
418
+ detail = "unreachable — check your connection or re-pair";
419
+ }
420
+ return `${marker} ${s.label}\n ${detail}`;
421
+ }),
422
+ );
423
+ process.stdout.write(
424
+ `Paired environments (${sessions.length}):\n${lines.join("\n")}\n`,
425
+ );
426
+ }
427
+
428
+ async function cmdLogout(rest) {
429
+ const cfg = await loadBridgeModule("config");
430
+ const all = rest.includes("--all");
431
+ const target = rest.find((a) => !a.startsWith("--")) ?? null;
432
+
433
+ // Which sessions to revoke + remove: all, one (by url/env/label), or the
434
+ // default (the bare `slidemind logout`).
435
+ const sessions = cfg.readSessions();
436
+ if (sessions.length === 0) {
437
+ process.stdout.write("Already logged out.\n");
438
+ return;
439
+ }
440
+ let toRemove;
441
+ if (all) {
442
+ toRemove = sessions;
443
+ } else if (target) {
444
+ toRemove = sessions.filter(
445
+ (s) =>
446
+ s.backendURL === target ||
447
+ s.label === target ||
448
+ cfg.deriveEnvLabel(s.backendURL) === target ||
449
+ s.backendURL.includes(target),
450
+ );
451
+ if (toRemove.length === 0) {
452
+ fail(`not paired with '${target}'. Run 'slidemind whoami' to list environments.`);
453
+ }
454
+ } else {
455
+ const def = cfg.getDefaultBackend();
456
+ toRemove = sessions.filter((s) => s.backendURL === def);
457
+ }
458
+
459
+ const pairMod = await loadBridgeModule("pair");
460
+ for (const s of toRemove) {
461
+ try {
462
+ await pairMod.httpJson("POST", `${s.backendURL}/api/cli/logout`, {
463
+ token: s.sessionToken,
464
+ });
465
+ } catch {
466
+ /* best-effort server-side revoke; we still drop the local session */
467
+ }
468
+ cfg.removeSession(s.backendURL);
321
469
  }
470
+ const remaining = cfg.readSessions().length;
471
+ process.stdout.write(
472
+ `Logged out of ${toRemove.map((s) => s.label).join(", ")}.` +
473
+ (remaining > 0 ? ` ${remaining} environment(s) still paired.\n` : "\n"),
474
+ );
475
+ }
476
+
477
+ async function cmdUse(rest) {
478
+ const target = rest.find((a) => !a.startsWith("--")) ?? null;
479
+ if (!target) fail("usage: slidemind use <url|env>");
480
+ const cfg = await loadBridgeModule("config");
481
+ const sessions = cfg.readSessions();
482
+ const match = sessions.find(
483
+ (s) =>
484
+ s.backendURL === target ||
485
+ s.label === target ||
486
+ cfg.deriveEnvLabel(s.backendURL) === target ||
487
+ s.backendURL.includes(target),
488
+ );
489
+ if (!match) {
490
+ fail(`not paired with '${target}'. Run 'slidemind whoami' to list environments.`);
491
+ }
492
+ cfg.setDefaultBackend(match.backendURL);
493
+ process.stdout.write(
494
+ `Default environment set to ${match.label}.\n`,
495
+ );
322
496
  }
323
497
 
324
498
  async function cmdPush(rest) {
@@ -370,6 +544,12 @@ async function cmdPush(rest) {
370
544
  replace,
371
545
  slideIds: slides,
372
546
  });
547
+ for (const w of result.warnings ?? []) {
548
+ process.stderr.write(` warning: ${w}\n`);
549
+ }
550
+ for (const s of result.skippedSlides ?? []) {
551
+ process.stderr.write(` skipped ${s.stem}: ${s.reason}\n`);
552
+ }
373
553
  if (result.paneSuspended) {
374
554
  process.stdout.write(
375
555
  `PowerPoint is backgrounded — the deck rendered but couldn't be pushed.\n` +
@@ -401,7 +581,8 @@ async function cmdNew(rest) {
401
581
  path.join(deckDir, "plan.md"),
402
582
  `# ${name}\n\n` +
403
583
  `Ask your agent (Claude Code / Codex / opencode) to plan and author this deck —\n` +
404
- `the slidemind-create-slides skill fills this in, then writes slides under ./slides/.\n`,
584
+ `the slidemind-create-slides skill fills this in, then writes slides under ./slides/\n` +
585
+ `as NN-<topic>.jsx (zero-padded prefix — slide order is filename order).\n`,
405
586
  );
406
587
  process.stdout.write(`Created ${path.relative(process.cwd(), deckDir)}\n`);
407
588
  process.stdout.write(
@@ -546,7 +727,26 @@ async function cmdUpdate(rest) {
546
727
 
547
728
  // ---------- main ----------
548
729
 
549
- async function main(argv) {
730
+ // Pull a global `--backend <url|env>` out of argv (it may appear anywhere) and
731
+ // expose it as SLIDEMIND_BACKEND, which scopes the fan-out across paired
732
+ // backends down to one — for speed or to disambiguate a presId that's open in
733
+ // more than one environment. Returns argv with the flag (and its value) removed.
734
+ function extractBackendScope(argv) {
735
+ const out = [];
736
+ for (let i = 0; i < argv.length; i++) {
737
+ if (argv[i] === "--backend") {
738
+ const val = argv[++i];
739
+ if (!val) fail("--backend expects a url or env name");
740
+ process.env.SLIDEMIND_BACKEND = val;
741
+ } else {
742
+ out.push(argv[i]);
743
+ }
744
+ }
745
+ return out;
746
+ }
747
+
748
+ async function main(rawArgv) {
749
+ const argv = extractBackendScope(rawArgv);
550
750
  const [cmd, ...rest] = argv;
551
751
  if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
552
752
  printHelp();
@@ -572,6 +772,18 @@ async function main(argv) {
572
772
  case "new":
573
773
  await cmdNew(rest);
574
774
  return 0;
775
+ case "pair":
776
+ await cmdPair(rest);
777
+ break;
778
+ case "whoami":
779
+ await cmdWhoami(rest);
780
+ break;
781
+ case "logout":
782
+ await cmdLogout(rest);
783
+ break;
784
+ case "use":
785
+ await cmdUse(rest);
786
+ break;
575
787
  case "presentations":
576
788
  await cmdPresentations(rest);
577
789
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.0.29",
3
+ "version": "0.1.1",
4
4
  "description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,18 +13,15 @@ This skill is **thin workflow glue for the local harness**. Everything that dete
13
13
 
14
14
  So this file owns only what is genuinely CLI-specific: the **host precondition**, a compact **intake/plan**, the **create flow**, and the **command mapping** that translates the add-in's in-app tool names (used throughout the bundle's `workflow/*.md`) into your `slidemind` commands and MCP tools. Read the workflow + brand from the bundle; never restate or duplicate them here.
15
15
 
16
- ## Make sure the slidemind CLI is installed (npm)
16
+ ## Bootstrap (set yourself up — don't make the user do it)
17
17
 
18
- This skill drives the `slidemind` CLI and its MCP server (`slidemind_list_open_presentations`, `slidemind_push_deck`, `slidemind_search_engine`, `slidemind_download_result`). If `slidemind` isn't on `PATH` — or those `slidemind_*` MCP tools aren't available — install the package from npm, then register it with the harness:
18
+ This skill drives the `slidemind` CLI + its MCP server (`slidemind_list_open_presentations`, `slidemind_push_deck`, `slidemind_search_engine`, `slidemind_download_result`). The CLI reaches PowerPoint through the add-in's **cloud relay** — it doesn't run any local server. Patch what's missing yourself, but the **pairing** step needs the user once (they hold the add-in's identity):
19
19
 
20
- ```bash
21
- npm install -g slidemind
22
- slidemind init --for <claude|codex|opencode|all> # registers the MCP server + (re)deploys this skill
23
- ```
20
+ - **CLI missing** (`slidemind` not on `PATH`, or the `slidemind_*` MCP tools absent): `npm install -g slidemind`, then `slidemind init --for <claude|codex|opencode|all>` (registers the MCP server + (re)deploys this skill). The harness may need a restart for the `slidemind_*` tools to appear.
21
+ - **Not paired** (a command says *slidemind is not paired*): this is the one step you can't do for the user. Tell them: in the slidemind add-in click **"Connect CLI"**, then paste the shown `slidemind pair …` command into this terminal. It's one-time per environment (the code carries that backend's URL + a session token — no hardcoded URLs, no separate login; the add-in's own session is the auth). `slidemind whoami` lists every paired environment.
22
+ - `npx slidemind <command>` runs one-off without a global install; `slidemind --version` confirms PATH; `slidemind update` upgrades (`--ref <version>` to pin).
24
23
 
25
- - For a one-off command without a global install, `npx slidemind <command>` works.
26
- - `slidemind --version` confirms it's on `PATH`. `slidemind update` upgrades to the latest npm release (`--ref <version>` to pin).
27
- - If `slidemind init` just registered the MCP server, the harness may need a restart before the `slidemind_*` tools appear.
24
+ After pairing once: **CLI paired + the add-in open ≡ it just works**, against whatever environment (dev / prod / a client deployment) the add-in is wired to. The CLI is **multi-environment**: pair once per backend (local / dev / prod) and every command fans out across all of them, tags each open window with its `env`, and routes to the right backend automatically — no manual switching. (Scope to one with `--backend <url|env>` when you need to.)
28
25
 
29
26
  ## Precondition gate (check this first, every time)
30
27
 
@@ -34,6 +31,8 @@ Authoring and rendering hard-require an **open PowerPoint host with the slidemin
34
31
  2. One registered → that's your target `presentation_id`. **Several → ask the user which**, by `title`/`fileName` (never raw ids) — once for **which presentation's corporate design to download**, and again at render time for **which presentation to push into** (the CD source and the push target may be different presentations).
35
32
  3. **None registered → stop.** Tell the user: *open PowerPoint, load the slidemind add-in, and pick or create a corporate design, then retry.*
36
33
 
34
+ > **Empty list vs. not paired — different fixes.** An *empty* registry means no PowerPoint is connected (step 3 → ask the user to open it / enable Connect CLI). A *not paired* error means this machine has no session — ask the user to pair once via the add-in's **"Connect CLI"** (see Bootstrap); don't ask them to open PowerPoint for that.
35
+
37
36
  **Backgrounded PowerPoint is expected to work.** Do not pre-emptively ask the user to focus PowerPoint or the task pane. The bridge retains the registered presentation and pane-free routes keep working; live push/replace waits for the task-pane socket to reconnect if it truly dropped. Only if a command fails with *no host registered* (or a bridge wait timeout) do you ask the user to click the slidemind task pane once so it reconnects, then retry the same command — the deck and bundle are already prepared. Reserve the full "open PowerPoint…" instruction in (3) for when nothing is registered at all.
38
37
 
39
38
  Local HTML preview (`slidemind build`) runs offline, but it reads a **previously materialized bundle** — so the host must have been available at least once.
@@ -56,7 +55,7 @@ Keep planning about intent and content. Don't materialize the bundle or read bra
56
55
  - `corporate-design.css`, `layouts/*.jsx`, `layouts/layouts.css`, `assets/`, `samples/` (the brand's own reference slides), `engines.json`.
57
56
  - `workflow/` — the add-in-owned authoring craft: `create.md`, `edit.md`, `composition.md`, `layouts.md`, `authoring.md`, `build-and-export.md`.
58
57
  3. **Study brand samples (before composing).** The materialized bundle's `samples/` dir holds the brand's own reference slides. If it's non-empty, pick **3 at random** and **Read** each (the Read tool renders images) *before* writing any JSX — this is the local-harness stand-in for the in-app persona's reference thumbnails, and it is what keeps a deck looking on-brand rather than merely compliant. Study them for whitespace rhythm, shape density, hierarchy, accent use, and layering, then compose in that spirit (never copy a sample 1:1). This is `composition.md`'s samples discipline; skip only when `samples/` is empty.
59
- 4. **Read, then author.** Read the four create-stage references together — `workflow/create.md` (the procedure), `workflow/composition.md` (the composition baseline), `workflow/layouts.md` (palette + working area), and `workflow/authoring.md` (the substrate rules) — plus the deck's `design.md` and the specific `layouts/*.jsx` the plan uses. (That is the read set `workflow/create.md` → Context loading prescribes; follow it as written.) Then write `decks/<slug>/slides/*.jsx` — one default export per slide, composing one bundle layout (Mode A) or a blank-content layout with freeform children (Mode B), exactly as those docs prescribe.
58
+ 4. **Read, then author.** Read the four create-stage references together — `workflow/create.md` (the procedure), `workflow/composition.md` (the composition baseline), `workflow/layouts.md` (palette + working area), and `workflow/authoring.md` (the substrate rules) — plus the deck's `design.md` and the specific `layouts/*.jsx` the plan uses. (That is the read set `workflow/create.md` → Context loading prescribes; follow it as written.) Then write `decks/<slug>/slides/NN-<topic>.jsx` — zero-padded numeric prefix from the first file on (play order is filename order here; see the slide-file row in the command mapping), one default export per slide, composing one bundle layout (Mode A) or a blank-content layout with freeform children (Mode B), exactly as those docs prescribe.
60
59
  5. **Imagery from the engine.** Per the `### Brand Image Sources` block in `design.md`, search the brand engine and place results (see the command mapping — this is `engine_search`/`engine_add` in the workflow docs).
61
60
  6. **Render / push.** `slidemind push <deck>` (into PowerPoint) or `slidemind build-pptx <deck>` (to a file) — both render server-side. This is `build_and_push` in `workflow/build-and-export.md`. If several presentations are open, confirm **which one to push into** first (it can differ from the CD source). **Run the push without asking otherwise**; it should work while PowerPoint is backgrounded. If it times out waiting for a true task-pane reconnect, ask the user to click the slidemind task pane once and re-run `push` without rebuilding.
62
61
  7. **Sanity pass.** Run the checks in `workflow/create.md` before reporting done.
@@ -80,7 +79,7 @@ The bundle's `workflow/*.md` are written for the in-app design agent and name **
80
79
  | `build_and_push` | `slidemind push <deck>` (into the open PowerPoint) · `slidemind build-pptx <deck>` (to a `.pptx` file) — both render server-side via the bridge |
81
80
  | `engine_search` | MCP `slidemind_search_engine({ presentation_id, query })` → returns a `searchId` + numbered results |
82
81
  | `engine_add` / `insert_image` | MCP `slidemind_download_result({ presentation_id, deck_path, search_id, selection, icon_color? })` — or batch many at once with `selections: [{ selection, icon_color? }, …]`. Writes each asset into `decks/<slug>/assets/`, returns a `./assets/<file>` reference for `<img src=...>` plus a `recolor` descriptor (see *Recolouring engine icons*) |
83
- | writing `slides/<topic>.jsx` in the chat workspace | write files under `decks/<slug>/slides/*.jsx` on disk |
82
+ | writing `slides/<topic>.jsx` in the chat workspace — unprefixed kebab-case names; play order = the ordered list passed to `build_and_push` | write `decks/<slug>/slides/NN-<topic>.jsx` on disk with a **zero-padded numeric prefix** (`01-cover.jsx`, `02-agenda.jsx`, …) — over the CLI play order is **numeric-aware filename order**; there is no ordered-list channel, so reordering = renaming files. The bundle's "don't pad with index prefixes" rule is in-app-only; here it inverts |
84
83
  | reading workspace files (`design.md`, `layouts/`, `workflow/*`) | read them from the materialized bundle dir (`<bundle>/…`) and your `decks/<slug>/` |
85
84
  | reference-slide thumbnails injected at `add_slide` (in-app persona) | **Read** 3 random images from the materialized bundle's `samples/` dir — the Read tool renders them (see *The create flow* step 3) |
86
85
  | `present_plan_for_review` (manager-owned) | **you** run intake → plan with the user (above); persist `decks/<slug>/plan.md` for decks |
@@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto";
14
14
 
15
15
  import {
16
16
  insertSlides,
17
- pickPresentation,
17
+ pickPresentationEntry,
18
18
  renderPptx,
19
19
  replaceSlides,
20
20
  requireHost,
@@ -24,16 +24,16 @@ import {
24
24
  readDeckBundleRef,
25
25
  writeDeckBundleRef,
26
26
  } from "./bundle.mjs";
27
+ import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
27
28
 
28
29
  function gatherSlides(deckDir) {
29
30
  const slidesDir = path.join(deckDir, "slides");
30
31
  if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
31
32
  throw new Error(`Expected 'slides/' folder in ${deckDir}`);
32
33
  }
33
- const files = fs
34
- .readdirSync(slidesDir)
35
- .filter((f) => f.endsWith(".jsx"))
36
- .sort();
34
+ const files = sortSlideFilenames(
35
+ fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")),
36
+ );
37
37
  if (files.length === 0) {
38
38
  throw new Error(`No .jsx slides under ${slidesDir}`);
39
39
  }
@@ -41,7 +41,7 @@ function gatherSlides(deckDir) {
41
41
  for (const f of files) {
42
42
  slides[f] = fs.readFileSync(path.join(slidesDir, f), "utf8");
43
43
  }
44
- return slides;
44
+ return { slides, orderWarning: slideOrderWarning(files) };
45
45
  }
46
46
 
47
47
  // Collect deck-local assets (icons the agent downloaded, deck photos) as
@@ -68,8 +68,8 @@ function gatherDeckAssets(deckDir) {
68
68
 
69
69
  // Render a deck to base64 .pptx over the bridge. Pass the deck's authored-against
70
70
  // bundle etag (when recorded) so the server can detect CD skew.
71
- export async function renderDeckToBase64(deckDir, { presId }) {
72
- const slides = gatherSlides(deckDir);
71
+ export async function renderDeckToBase64(deckDir, { presId, backendURL = null }) {
72
+ const { slides, orderWarning } = gatherSlides(deckDir);
73
73
  const deckAssets = gatherDeckAssets(deckDir);
74
74
  // Bind the deck to a materialized bundle if it isn't already. Without a
75
75
  // `.bundle-ref.json` the etag below is null (server-side skew detection
@@ -79,7 +79,7 @@ export async function renderDeckToBase64(deckDir, { presId }) {
79
79
  let ref = readDeckBundleRef(deckDir);
80
80
  if (!ref?.etag) {
81
81
  try {
82
- const mat = await materializeBundle(presId);
82
+ const mat = await materializeBundle(presId, { backendURL });
83
83
  writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
84
84
  ref = readDeckBundleRef(deckDir);
85
85
  } catch {
@@ -93,10 +93,14 @@ export async function renderDeckToBase64(deckDir, { presId }) {
93
93
  slides,
94
94
  deckAssets,
95
95
  etag: ref?.etag ?? null,
96
+ backendURL,
96
97
  });
97
98
  if (!result?.base64) {
98
99
  throw new Error("render-pptx returned no pptx bytes");
99
100
  }
101
+ if (orderWarning) {
102
+ result.warnings = [orderWarning, ...(result.warnings ?? [])];
103
+ }
100
104
  return result; // { ok, base64, slideCount, warnings, skippedSlides }
101
105
  }
102
106
 
@@ -122,9 +126,11 @@ export async function buildAndPush({
122
126
  }
123
127
 
124
128
  const registry = await requireHost();
125
- const presId = pickPresentation(registry, presentationId);
129
+ const entry = pickPresentationEntry(registry, presentationId);
130
+ const presId = entry.presId;
131
+ const backendURL = entry.backendURL ?? null;
126
132
 
127
- const result = await renderDeckToBase64(deckDir, { presId });
133
+ const result = await renderDeckToBase64(deckDir, { presId, backendURL });
128
134
  const base64 = result.base64;
129
135
  const bytes = Buffer.byteLength(base64, "base64");
130
136
  const mode = replace ? "replace" : "insert";
@@ -132,13 +138,14 @@ export async function buildAndPush({
132
138
 
133
139
  const doPush = () =>
134
140
  replace
135
- ? replaceSlides({ presId, base64, operationId })
141
+ ? replaceSlides({ presId, base64, operationId, backendURL })
136
142
  : insertSlides({
137
143
  presId,
138
144
  base64,
139
145
  insertAt,
140
146
  sourceSlideIds: slideIds,
141
147
  operationId,
148
+ backendURL,
142
149
  });
143
150
 
144
151
  const resp = await doPush();
@@ -162,8 +169,12 @@ export async function buildPptxFile({
162
169
  }) {
163
170
  if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
164
171
  const registry = await requireHost();
165
- const presId = pickPresentation(registry, presentationId);
166
- const result = await renderDeckToBase64(deckDir, { presId });
172
+ const entry = pickPresentationEntry(registry, presentationId);
173
+ const presId = entry.presId;
174
+ const result = await renderDeckToBase64(deckDir, {
175
+ presId,
176
+ backendURL: entry.backendURL ?? null,
177
+ });
167
178
  const out =
168
179
  outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
169
180
  fs.writeFileSync(out, Buffer.from(result.base64, "base64"));
@@ -8,6 +8,14 @@
8
8
  // the presentation fingerprint (`presFP`) so multiple decks sharing a CD share
9
9
  // one materialized bundle.
10
10
  //
11
+ // NOTE (multi-backend): with the CLI paired to several backends (local/dev/prod)
12
+ // the cache is shared across them and keyed ONLY by `presFP`. That assumes a
13
+ // backend contract: `presFP` must be globally unique across deployments (it is a
14
+ // corporate-design fingerprint, deliberately shared across decks/presentations
15
+ // on the same CD — so it must NOT be keyed by backendURL, which would break that
16
+ // sharing). If two backends ever mint the same presFP for different content,
17
+ // they would collide here.
18
+ //
11
19
  // ${XDG_CACHE_HOME:-~/.cache}/slidemind/bundles/<presFP>/
12
20
  // ├── corporate-design.css (from wire.css)
13
21
  // ├── design.md (from wire.designRules)
@@ -120,8 +128,11 @@ function sampleExt(contentType) {
120
128
  // Fetch + write the bundle for `presId`. Returns { dir, presFP, etag, version,
121
129
  // cached }. Skips the re-download when the cached bundle's etag already matches
122
130
  // (unless `force`).
123
- export async function materializeBundle(presId, { force = false } = {}) {
124
- const wire = await getDesignBundle(presId);
131
+ export async function materializeBundle(
132
+ presId,
133
+ { force = false, backendURL = null } = {},
134
+ ) {
135
+ const wire = await getDesignBundle(presId, { backendURL });
125
136
  if (!wire || !wire.presFP) {
126
137
  throw new Error("design-bundle response is missing presFP");
127
138
  }