slidemind 0.1.0 → 0.1.2
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 +8 -2
- package/README.md +238 -46
- package/bin/slidemind.mjs +180 -42
- package/package.json +2 -2
- package/skills/create-slides/SKILL.md +4 -4
- package/src/bridge/build-and-push.mjs +25 -14
- package/src/bridge/bundle.mjs +13 -2
- package/src/bridge/client.mjs +292 -76
- package/src/bridge/config.mjs +168 -25
- package/src/builders/build.mjs +9 -5
- package/src/installers/lib.mjs +17 -3
- package/src/lib/slide-order.mjs +28 -0
- package/src/mcp/server.mjs +10 -7
package/bin/slidemind.mjs
CHANGED
|
@@ -60,8 +60,11 @@ function printHelp() {
|
|
|
60
60
|
` --replace delete existing slides first (destructive)\n` +
|
|
61
61
|
` --slides <ids> comma-separated source slide ids to push\n` +
|
|
62
62
|
` slidemind pair <code> Pair with an add-in (copy the code from its "Connect CLI")\n` +
|
|
63
|
-
`
|
|
64
|
-
`
|
|
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` +
|
|
65
68
|
` slidemind init --for <t> Set up slidemind in a harness (registers the MCP server + skill)\n` +
|
|
66
69
|
` slidemind uninstall --for <t> Remove a previously-installed skill\n` +
|
|
67
70
|
` slidemind update [--for <t>] Fetch the latest release and redeploy\n` +
|
|
@@ -72,7 +75,8 @@ function printHelp() {
|
|
|
72
75
|
`\n` +
|
|
73
76
|
`Install targets: claude | codex | opencode | all\n` +
|
|
74
77
|
`Decks live in ./decks/ relative to your current directory.\n` +
|
|
75
|
-
`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`,
|
|
76
80
|
);
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -221,10 +225,12 @@ async function cmdSearch(rest) {
|
|
|
221
225
|
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
222
226
|
);
|
|
223
227
|
const registry = await client.requireHost();
|
|
224
|
-
const
|
|
228
|
+
const entry = client.pickPresentationEntry(registry, to);
|
|
229
|
+
const presId = entry.presId;
|
|
230
|
+
const backendURL = entry.backendURL ?? null;
|
|
225
231
|
|
|
226
232
|
if (!engineId) {
|
|
227
|
-
const mat = await bundle.materializeBundle(presId);
|
|
233
|
+
const mat = await bundle.materializeBundle(presId, { backendURL });
|
|
228
234
|
const pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
|
|
229
235
|
if (!pick) {
|
|
230
236
|
fail(
|
|
@@ -234,7 +240,7 @@ async function cmdSearch(rest) {
|
|
|
234
240
|
engineId = pick.id;
|
|
235
241
|
}
|
|
236
242
|
|
|
237
|
-
const res = await client.engineSearch({ presId, engineId, query });
|
|
243
|
+
const res = await client.engineSearch({ presId, engineId, query, backendURL });
|
|
238
244
|
if (asJson) {
|
|
239
245
|
process.stdout.write(`${JSON.stringify(res, null, 2)}\n`);
|
|
240
246
|
return;
|
|
@@ -279,8 +285,12 @@ async function cmdDownloadBundle(rest) {
|
|
|
279
285
|
pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
|
|
280
286
|
);
|
|
281
287
|
const registry = await client.requireHost();
|
|
282
|
-
const
|
|
283
|
-
const
|
|
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
|
+
});
|
|
284
294
|
|
|
285
295
|
// Optionally bind a deck to this bundle so build-pptx/push pass the right
|
|
286
296
|
// etag and the local HTML/editor read the right design files.
|
|
@@ -309,7 +319,9 @@ async function cmdPresentations(rest) {
|
|
|
309
319
|
);
|
|
310
320
|
const list = await mod.listOpenPresentations();
|
|
311
321
|
if (asJson) {
|
|
312
|
-
process.stdout.write(
|
|
322
|
+
process.stdout.write(
|
|
323
|
+
JSON.stringify(list.map(mod.toPublicPresentation), null, 2) + "\n",
|
|
324
|
+
);
|
|
313
325
|
return;
|
|
314
326
|
}
|
|
315
327
|
if (list.length === 0) {
|
|
@@ -320,7 +332,8 @@ async function cmdPresentations(rest) {
|
|
|
320
332
|
}
|
|
321
333
|
for (const p of list) {
|
|
322
334
|
const label = p.title || p.fileName || "(untitled)";
|
|
323
|
-
|
|
335
|
+
const env = p.env ? `[${p.env}]\t` : "";
|
|
336
|
+
process.stdout.write(`${p.presId}\t${env}${label}\n`);
|
|
324
337
|
}
|
|
325
338
|
}
|
|
326
339
|
|
|
@@ -329,7 +342,17 @@ async function loadBridgeModule(name) {
|
|
|
329
342
|
}
|
|
330
343
|
|
|
331
344
|
async function cmdPair(rest) {
|
|
332
|
-
|
|
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
|
+
}
|
|
333
356
|
if (!code) {
|
|
334
357
|
fail('usage: slidemind pair <code> (copy the command from the add-in\'s "Connect CLI")');
|
|
335
358
|
}
|
|
@@ -350,54 +373,126 @@ async function cmdPair(rest) {
|
|
|
350
373
|
);
|
|
351
374
|
}
|
|
352
375
|
const cfg = await loadBridgeModule("config");
|
|
353
|
-
cfg.
|
|
376
|
+
const sessions = cfg.upsertSession({
|
|
354
377
|
backendURL: decoded.backendURL,
|
|
355
378
|
sessionToken: res.json.sessionToken,
|
|
379
|
+
label,
|
|
356
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
|
+
: "";
|
|
357
387
|
process.stdout.write(
|
|
358
|
-
`Paired with ${
|
|
388
|
+
`Paired with ${label || env}${suffix}. Run 'slidemind presentations' to verify.\n`,
|
|
359
389
|
);
|
|
360
390
|
}
|
|
361
391
|
|
|
362
392
|
async function cmdWhoami() {
|
|
363
393
|
const cfg = await loadBridgeModule("config");
|
|
364
|
-
const
|
|
365
|
-
if (
|
|
394
|
+
const sessions = cfg.readSessions();
|
|
395
|
+
if (sessions.length === 0) {
|
|
366
396
|
process.stderr.write(
|
|
367
397
|
'Not paired. Run `slidemind pair <code>` using the add-in\'s "Connect CLI".\n',
|
|
368
398
|
);
|
|
369
399
|
return;
|
|
370
400
|
}
|
|
371
401
|
const pairMod = await loadBridgeModule("pair");
|
|
372
|
-
const
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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),
|
|
378
450
|
);
|
|
451
|
+
if (toRemove.length === 0) {
|
|
452
|
+
fail(`not paired with '${target}'. Run 'slidemind whoami' to list environments.`);
|
|
453
|
+
}
|
|
379
454
|
} else {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
);
|
|
455
|
+
const def = cfg.getDefaultBackend();
|
|
456
|
+
toRemove = sessions.filter((s) => s.backendURL === def);
|
|
383
457
|
}
|
|
384
|
-
}
|
|
385
458
|
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
const session = cfg.readSessionConfig();
|
|
389
|
-
if (session) {
|
|
459
|
+
const pairMod = await loadBridgeModule("pair");
|
|
460
|
+
for (const s of toRemove) {
|
|
390
461
|
try {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
token: session.sessionToken,
|
|
462
|
+
await pairMod.httpJson("POST", `${s.backendURL}/api/cli/logout`, {
|
|
463
|
+
token: s.sessionToken,
|
|
394
464
|
});
|
|
395
465
|
} catch {
|
|
396
|
-
/* best-effort server-side revoke; we still
|
|
466
|
+
/* best-effort server-side revoke; we still drop the local session */
|
|
397
467
|
}
|
|
468
|
+
cfg.removeSession(s.backendURL);
|
|
398
469
|
}
|
|
399
|
-
const
|
|
400
|
-
process.stdout.write(
|
|
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
|
+
);
|
|
401
496
|
}
|
|
402
497
|
|
|
403
498
|
async function cmdPush(rest) {
|
|
@@ -449,6 +544,12 @@ async function cmdPush(rest) {
|
|
|
449
544
|
replace,
|
|
450
545
|
slideIds: slides,
|
|
451
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
|
+
}
|
|
452
553
|
if (result.paneSuspended) {
|
|
453
554
|
process.stdout.write(
|
|
454
555
|
`PowerPoint is backgrounded — the deck rendered but couldn't be pushed.\n` +
|
|
@@ -480,7 +581,8 @@ async function cmdNew(rest) {
|
|
|
480
581
|
path.join(deckDir, "plan.md"),
|
|
481
582
|
`# ${name}\n\n` +
|
|
482
583
|
`Ask your agent (Claude Code / Codex / opencode) to plan and author this deck —\n` +
|
|
483
|
-
`the slidemind-create-slides skill fills this in, then writes slides under ./slides
|
|
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`,
|
|
484
586
|
);
|
|
485
587
|
process.stdout.write(`Created ${path.relative(process.cwd(), deckDir)}\n`);
|
|
486
588
|
process.stdout.write(
|
|
@@ -544,13 +646,27 @@ async function cmdUninstall(rest) {
|
|
|
544
646
|
ui.done(target === "all" ? "removed from all harnesses" : `removed from ${target}`);
|
|
545
647
|
}
|
|
546
648
|
|
|
649
|
+
// On Windows we must spawn through the shell (Node refuses to exec .cmd
|
|
650
|
+
// shims directly since CVE-2024-27980), and `shell: true` joins file + args
|
|
651
|
+
// into one cmd.exe command string WITHOUT quoting — so an unquoted
|
|
652
|
+
// `C:\Program Files\nodejs\npm.cmd` splits at the space and cmd tries to run
|
|
653
|
+
// `C:\Program`. Quote anything containing whitespace before handing it over.
|
|
654
|
+
function cmdQuote(s) {
|
|
655
|
+
return /\s/.test(s) ? `"${s}"` : s;
|
|
656
|
+
}
|
|
657
|
+
|
|
547
658
|
function spawnInherit(file, args) {
|
|
659
|
+
const useShell = process.platform === "win32";
|
|
548
660
|
return new Promise((resolve, reject) => {
|
|
549
|
-
const child = spawn(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
661
|
+
const child = spawn(
|
|
662
|
+
useShell ? cmdQuote(file) : file,
|
|
663
|
+
useShell ? args.map(cmdQuote) : args,
|
|
664
|
+
{
|
|
665
|
+
stdio: "inherit",
|
|
666
|
+
env: process.env,
|
|
667
|
+
shell: useShell,
|
|
668
|
+
},
|
|
669
|
+
);
|
|
554
670
|
child.on("error", reject);
|
|
555
671
|
child.on("exit", (code) => {
|
|
556
672
|
if (code === 0) resolve();
|
|
@@ -625,7 +741,26 @@ async function cmdUpdate(rest) {
|
|
|
625
741
|
|
|
626
742
|
// ---------- main ----------
|
|
627
743
|
|
|
628
|
-
|
|
744
|
+
// Pull a global `--backend <url|env>` out of argv (it may appear anywhere) and
|
|
745
|
+
// expose it as SLIDEMIND_BACKEND, which scopes the fan-out across paired
|
|
746
|
+
// backends down to one — for speed or to disambiguate a presId that's open in
|
|
747
|
+
// more than one environment. Returns argv with the flag (and its value) removed.
|
|
748
|
+
function extractBackendScope(argv) {
|
|
749
|
+
const out = [];
|
|
750
|
+
for (let i = 0; i < argv.length; i++) {
|
|
751
|
+
if (argv[i] === "--backend") {
|
|
752
|
+
const val = argv[++i];
|
|
753
|
+
if (!val) fail("--backend expects a url or env name");
|
|
754
|
+
process.env.SLIDEMIND_BACKEND = val;
|
|
755
|
+
} else {
|
|
756
|
+
out.push(argv[i]);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function main(rawArgv) {
|
|
763
|
+
const argv = extractBackendScope(rawArgv);
|
|
629
764
|
const [cmd, ...rest] = argv;
|
|
630
765
|
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
631
766
|
printHelp();
|
|
@@ -660,6 +795,9 @@ async function main(argv) {
|
|
|
660
795
|
case "logout":
|
|
661
796
|
await cmdLogout(rest);
|
|
662
797
|
break;
|
|
798
|
+
case "use":
|
|
799
|
+
await cmdUse(rest);
|
|
800
|
+
break;
|
|
663
801
|
case "presentations":
|
|
664
802
|
await cmdPresentations(rest);
|
|
665
803
|
return 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidemind",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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": {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"build-pptx": "node bin/slidemind.mjs build-pptx",
|
|
28
28
|
"lint:parity": "node scripts/check-skill-parity.mjs",
|
|
29
29
|
"release": "node scripts/release.mjs",
|
|
30
|
-
"test": "node --test
|
|
30
|
+
"test": "node --test"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@babel/parser": "^7.26.5",
|
|
@@ -18,10 +18,10 @@ So this file owns only what is genuinely CLI-specific: the **host precondition**
|
|
|
18
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
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
|
|
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
22
|
- `npx slidemind <command>` runs one-off without a global install; `slidemind --version` confirms PATH; `slidemind update` upgrades (`--ref <version>` to pin).
|
|
23
23
|
|
|
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.
|
|
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.)
|
|
25
25
|
|
|
26
26
|
## Precondition gate (check this first, every time)
|
|
27
27
|
|
|
@@ -55,7 +55,7 @@ Keep planning about intent and content. Don't materialize the bundle or read bra
|
|
|
55
55
|
- `corporate-design.css`, `layouts/*.jsx`, `layouts/layouts.css`, `assets/`, `samples/` (the brand's own reference slides), `engines.json`.
|
|
56
56
|
- `workflow/` — the add-in-owned authoring craft: `create.md`, `edit.md`, `composition.md`, `layouts.md`, `authoring.md`, `build-and-export.md`.
|
|
57
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.
|
|
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
|
|
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.
|
|
59
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).
|
|
60
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.
|
|
61
61
|
7. **Sanity pass.** Run the checks in `workflow/create.md` before reporting done.
|
|
@@ -79,7 +79,7 @@ The bundle's `workflow/*.md` are written for the in-app design agent and name **
|
|
|
79
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 |
|
|
80
80
|
| `engine_search` | MCP `slidemind_search_engine({ presentation_id, query })` → returns a `searchId` + numbered results |
|
|
81
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*) |
|
|
82
|
-
| writing `slides/<topic>.jsx` in the chat workspace
|
|
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 |
|
|
83
83
|
| reading workspace files (`design.md`, `layouts/`, `workflow/*`) | read them from the materialized bundle dir (`<bundle>/…`) and your `decks/<slug>/` |
|
|
84
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) |
|
|
85
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
|
-
|
|
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 =
|
|
34
|
-
.readdirSync(slidesDir)
|
|
35
|
-
|
|
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
|
|
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
|
|
166
|
-
const
|
|
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"));
|
package/src/bridge/bundle.mjs
CHANGED
|
@@ -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(
|
|
124
|
-
|
|
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
|
}
|