slidemind 0.1.0 → 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 +7 -1
- package/README.md +27 -0
- package/bin/slidemind.mjs +161 -37
- package/package.json +1 -1
- 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/lib/slide-order.mjs +28 -0
- package/src/mcp/server.mjs +10 -7
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
|
@@ -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(
|
|
@@ -625,7 +727,26 @@ async function cmdUpdate(rest) {
|
|
|
625
727
|
|
|
626
728
|
// ---------- main ----------
|
|
627
729
|
|
|
628
|
-
|
|
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);
|
|
629
750
|
const [cmd, ...rest] = argv;
|
|
630
751
|
if (!cmd || cmd === "--help" || cmd === "-h" || cmd === "help") {
|
|
631
752
|
printHelp();
|
|
@@ -660,6 +781,9 @@ async function main(argv) {
|
|
|
660
781
|
case "logout":
|
|
661
782
|
await cmdLogout(rest);
|
|
662
783
|
break;
|
|
784
|
+
case "use":
|
|
785
|
+
await cmdUse(rest);
|
|
786
|
+
break;
|
|
663
787
|
case "presentations":
|
|
664
788
|
await cmdPresentations(rest);
|
|
665
789
|
return 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slidemind",
|
|
3
|
-
"version": "0.1.
|
|
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": {
|
|
@@ -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
|
}
|