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 +7 -1
- package/README.md +27 -0
- package/bin/slidemind.mjs +222 -10
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +10 -11
- package/src/bridge/build-and-push.mjs +25 -14
- package/src/bridge/bundle.mjs +13 -2
- package/src/bridge/client.mjs +319 -94
- package/src/bridge/config.mjs +214 -0
- package/src/bridge/pair.mjs +67 -0
- 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
|
@@ -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
|
|
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
|
|
280
|
-
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
|
+
});
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
##
|
|
16
|
+
## Bootstrap (set yourself up — don't make the user do it)
|
|
17
17
|
|
|
18
|
-
This skill drives the `slidemind` CLI
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
slidemind
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|