slidemind 0.0.28 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/slidemind.mjs CHANGED
@@ -59,6 +59,9 @@ 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
+ ` slidemind whoami Show the paired account\n` +
64
+ ` slidemind logout Unpair this machine\n` +
62
65
  ` slidemind init --for <t> Set up slidemind in a harness (registers the MCP server + skill)\n` +
63
66
  ` slidemind uninstall --for <t> Remove a previously-installed skill\n` +
64
67
  ` slidemind update [--for <t>] Fetch the latest release and redeploy\n` +
@@ -321,6 +324,82 @@ async function cmdPresentations(rest) {
321
324
  }
322
325
  }
323
326
 
327
+ async function loadBridgeModule(name) {
328
+ return import(pathToFileURL(path.join(SRC_DIR, "bridge", `${name}.mjs`)).href);
329
+ }
330
+
331
+ async function cmdPair(rest) {
332
+ const code = rest.find((a) => !a.startsWith("--"));
333
+ if (!code) {
334
+ fail('usage: slidemind pair <code> (copy the command from the add-in\'s "Connect CLI")');
335
+ }
336
+ const pairMod = await loadBridgeModule("pair");
337
+ const decoded = pairMod.decodePairingCode(code);
338
+ if (!decoded) {
339
+ fail("invalid pairing code — copy the exact `slidemind pair …` command shown in the add-in");
340
+ }
341
+ const res = await pairMod.httpJson(
342
+ "POST",
343
+ `${decoded.backendURL}/api/cli/pair/redeem`,
344
+ { body: { secret: decoded.secret } },
345
+ );
346
+ if (res.status !== 200 || !res.json?.sessionToken) {
347
+ fail(
348
+ `pairing failed (${res.status}): ${res.json?.error || "no session returned"}\n` +
349
+ "the code may have expired — generate a fresh one in the add-in's \"Connect CLI\"",
350
+ );
351
+ }
352
+ const cfg = await loadBridgeModule("config");
353
+ cfg.writeSessionConfig({
354
+ backendURL: decoded.backendURL,
355
+ sessionToken: res.json.sessionToken,
356
+ });
357
+ process.stdout.write(
358
+ `Paired with ${decoded.backendURL}. Run 'slidemind presentations' to verify.\n`,
359
+ );
360
+ }
361
+
362
+ async function cmdWhoami() {
363
+ const cfg = await loadBridgeModule("config");
364
+ const session = cfg.readSessionConfig();
365
+ if (!session) {
366
+ process.stderr.write(
367
+ 'Not paired. Run `slidemind pair <code>` using the add-in\'s "Connect CLI".\n',
368
+ );
369
+ return;
370
+ }
371
+ const pairMod = await loadBridgeModule("pair");
372
+ const res = await pairMod.httpJson("GET", `${session.backendURL}/api/cli/whoami`, {
373
+ token: session.sessionToken,
374
+ });
375
+ if (res.status === 200 && res.json) {
376
+ process.stdout.write(
377
+ `Paired with ${session.backendURL}\n user: ${res.json.oid}\n tenant: ${res.json.tid ?? "(n/a)"}\n`,
378
+ );
379
+ } else {
380
+ process.stderr.write(
381
+ `Session invalid (${res.status}). Re-pair via the add-in's "Connect CLI".\n`,
382
+ );
383
+ }
384
+ }
385
+
386
+ async function cmdLogout() {
387
+ const cfg = await loadBridgeModule("config");
388
+ const session = cfg.readSessionConfig();
389
+ if (session) {
390
+ try {
391
+ const pairMod = await loadBridgeModule("pair");
392
+ await pairMod.httpJson("POST", `${session.backendURL}/api/cli/logout`, {
393
+ token: session.sessionToken,
394
+ });
395
+ } catch {
396
+ /* best-effort server-side revoke; we still clear the local session */
397
+ }
398
+ }
399
+ const cleared = cfg.clearSessionConfig();
400
+ process.stdout.write(cleared ? "Logged out.\n" : "Already logged out.\n");
401
+ }
402
+
324
403
  async function cmdPush(rest) {
325
404
  const { deck, passthrough } = splitDeckArg(rest, "push");
326
405
  let to = null;
@@ -572,6 +651,15 @@ async function main(argv) {
572
651
  case "new":
573
652
  await cmdNew(rest);
574
653
  return 0;
654
+ case "pair":
655
+ await cmdPair(rest);
656
+ break;
657
+ case "whoami":
658
+ await cmdWhoami(rest);
659
+ break;
660
+ case "logout":
661
+ await cmdLogout(rest);
662
+ break;
575
663
  case "presentations":
576
664
  await cmdPresentations(rest);
577
665
  return 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.0.28",
3
+ "version": "0.1.0",
4
4
  "description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,18 +13,15 @@ This skill is **thin workflow glue for the local harness**. Everything that dete
13
13
 
14
14
  So this file owns only what is genuinely CLI-specific: the **host precondition**, a compact **intake/plan**, the **create flow**, and the **command mapping** that translates the add-in's in-app tool names (used throughout the bundle's `workflow/*.md`) into your `slidemind` commands and MCP tools. Read the workflow + brand from the bundle; never restate or duplicate them here.
15
15
 
16
- ## Make sure the slidemind CLI is installed (npm)
16
+ ## Bootstrap (set yourself up — don't make the user do it)
17
17
 
18
- This skill drives the `slidemind` CLI and its MCP server (`slidemind_list_open_presentations`, `slidemind_push_deck`, `slidemind_search_engine`, `slidemind_download_result`). If `slidemind` isn't on `PATH` — or those `slidemind_*` MCP tools aren't available — install the package from npm, then register it with the harness:
18
+ This skill drives the `slidemind` CLI + its MCP server (`slidemind_list_open_presentations`, `slidemind_push_deck`, `slidemind_search_engine`, `slidemind_download_result`). The CLI reaches PowerPoint through the add-in's **cloud relay** — it doesn't run any local server. Patch what's missing yourself, but the **pairing** step needs the user once (they hold the add-in's identity):
19
19
 
20
- ```bash
21
- npm install -g slidemind
22
- slidemind init --for <claude|codex|opencode|all> # registers the MCP server + (re)deploys this skill
23
- ```
20
+ - **CLI missing** (`slidemind` not on `PATH`, or the `slidemind_*` MCP tools absent): `npm install -g slidemind`, then `slidemind init --for <claude|codex|opencode|all>` (registers the MCP server + (re)deploys this skill). The harness may need a restart for the `slidemind_*` tools to appear.
21
+ - **Not paired** (a command says *slidemind is not paired*): this is the one step you can't do for the user. Tell them: in the slidemind add-in click **"Connect CLI"**, then paste the shown `slidemind pair …` command into this terminal. It's one-time per machine (the code carries the backend URL + a session token — no hardcoded URLs, no separate login; the add-in's own session is the auth). `slidemind whoami` confirms the pairing.
22
+ - `npx slidemind <command>` runs one-off without a global install; `slidemind --version` confirms PATH; `slidemind update` upgrades (`--ref <version>` to pin).
24
23
 
25
- - For a one-off command without a global install, `npx slidemind <command>` works.
26
- - `slidemind --version` confirms it's on `PATH`. `slidemind update` upgrades to the latest npm release (`--ref <version>` to pin).
27
- - If `slidemind init` just registered the MCP server, the harness may need a restart before the `slidemind_*` tools appear.
24
+ After pairing once: **CLI paired + the add-in open ≡ it just works**, against whatever environment (dev / prod / a client deployment) the add-in is wired to.
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.
@@ -358,16 +358,27 @@ export function loadEnginesForDeck(deckDir) {
358
358
  return readEnginesFromDir(resolveBundleDirForDeck(deckDir));
359
359
  }
360
360
 
361
- // Pick the default engine to search: the first image/icon engine, else the
362
- // first engine. Single source of truth for the heuristic shared by the CLI
363
- // dispatcher (`slidemind search`) and the MCP `slidemind_search_engine` tool.
361
+ // Image-producing engine kinds — the modality "image" plus the finer image
362
+ // content kinds an authored bundle may carry. Excludes "slide" (a slide-search
363
+ // engine returns whole slides, not insertable bytes). Mirrors the add-in's
364
+ // local-bridge `engine_not_image` gate so the CLI never picks an engine the
365
+ // bridge would reject.
366
+ const IMAGE_ENGINE_KIND_RE = /^(image|icon|photo|illustration|vector)$/i;
367
+
368
+ // True when an engine produces insertable image bytes. Gates on the engine's
369
+ // real `kind` (its modality from the design bundle) — NOT a guess over its
370
+ // label/description.
371
+ export function isImageEngine(engine) {
372
+ return IMAGE_ENGINE_KIND_RE.test(engine?.kind ?? "");
373
+ }
374
+
375
+ // Pick the default engine to search: the first image engine, by its real
376
+ // `kind`. Returns null when the CD exposes no image engine (e.g. only a
377
+ // slide-search engine) — callers surface a "wire an image engine" message
378
+ // rather than falling back to an engine the bridge can't insert. Single source
379
+ // of truth for the heuristic shared by the CLI dispatcher (`slidemind search`)
380
+ // and the MCP `slidemind_search_engine` tool.
364
381
  export function pickImageEngine(engines) {
365
382
  const list = Array.isArray(engines) ? engines : [];
366
- return (
367
- list.find((e) =>
368
- /image|icon/i.test(`${e.kind ?? ""} ${e.label ?? ""} ${e.description ?? ""}`),
369
- ) ||
370
- list[0] ||
371
- null
372
- );
383
+ return list.find(isImageEngine) ?? null;
373
384
  }
@@ -1,13 +1,9 @@
1
- // Client for the slidemind PowerPoint add-in's local bridge.
1
+ // Client for the slidemind cloud relay.
2
2
  //
3
- // The add-in's Express middle-tier (port 3001 by default) exposes a localhost-
4
- // only, token-gated `/local/*` surface that lets a sibling local process —
5
- // this CLI, the bundled MCP server, anything running on the same machine —
6
- // enumerate the open PowerPoint windows and push slides into a specific one.
7
- //
8
- // The shared token is generated on add-in start and written to a state file.
9
- // Reading it is sufficient auth; the add-in's middleware also rejects any
10
- // non-loopback caller, so this is intentionally a same-machine-only surface.
3
+ // The CLI talks to `<backendURL>/api/cli/*` with a Bearer session token — both
4
+ // established once by `slidemind pair` (see config.mjs). The add-in's cloud
5
+ // backend runs render/bundle in-process and relays insert/replace/engine to the
6
+ // open PowerPoint pane. No localhost, no shared loopback token, no cert.
11
7
 
12
8
  import fs from "node:fs";
13
9
  import http from "node:http";
@@ -15,9 +11,11 @@ import os from "node:os";
15
11
  import path from "node:path";
16
12
  import https from "node:https";
17
13
 
18
- const DEFAULT_HOST = "127.0.0.1";
19
- const DEFAULT_PORT = 3001;
20
- const TOKEN_HEADER = "x-slidemind-bridge-token";
14
+ import { readSessionConfig } from "./config.mjs";
15
+
16
+ function isLoopbackHostname(hostname) {
17
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
18
+ }
21
19
 
22
20
  export function bridgeStateDir() {
23
21
  if (process.platform === "win32") {
@@ -53,23 +51,26 @@ export function readBridgeToken() {
53
51
  return token;
54
52
  }
55
53
 
56
- function bridgeBaseUrl() {
57
- const host = process.env.SLIDEMIND_BRIDGE_HOST || DEFAULT_HOST;
58
- const port = parseInt(process.env.SLIDEMIND_BRIDGE_PORT || `${DEFAULT_PORT}`, 10);
59
- // The add-in's dev backend uses HTTPS with self-signed certs; production
60
- // (Azure Container Apps) terminates TLS upstream and exposes HTTP. Default
61
- // to HTTPS but allow override.
62
- const scheme = process.env.SLIDEMIND_BRIDGE_SCHEME || "https";
63
- return `${scheme}://${host}:${port}`;
54
+ // The CLI talks to the cloud relay endpoint stored by `slidemind pair`. The
55
+ // session token is the credential — no localhost, no shared loopback token.
56
+ function requireSession() {
57
+ const session = readSessionConfig();
58
+ if (!session) {
59
+ throw new Error(
60
+ 'slidemind is not paired. In the slidemind add-in, click "Connect CLI" ' +
61
+ "and run the shown `slidemind pair …` command, then retry.",
62
+ );
63
+ }
64
+ return session;
64
65
  }
65
66
 
66
67
  async function bridgeFetch(pathPart, init = {}) {
67
- const url = `${bridgeBaseUrl()}${pathPart}`;
68
- const token = readBridgeToken();
68
+ const session = requireSession();
69
+ const url = `${session.backendURL}${pathPart}`;
69
70
  const headers = {
70
71
  accept: "application/json",
71
72
  ...init.headers,
72
- [TOKEN_HEADER]: token,
73
+ authorization: `Bearer ${session.sessionToken}`,
73
74
  };
74
75
 
75
76
  let response;
@@ -78,12 +79,18 @@ async function bridgeFetch(pathPart, init = {}) {
78
79
  } catch (err) {
79
80
  if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
80
81
  throw new Error(
81
- `slidemind bridge unreachable at ${url}. ` +
82
- `Is the slidemind PowerPoint add-in running? (npm run dev:backend in src/ui)`,
82
+ `slidemind relay unreachable at ${url}. Check your connection; ` +
83
+ "if it persists, re-pair with `slidemind pair`.",
83
84
  );
84
85
  }
85
86
  throw err;
86
87
  }
88
+ if (response.status === 401) {
89
+ throw new Error(
90
+ "slidemind session is invalid or expired. Re-pair: open the add-in, " +
91
+ 'click "Connect CLI", and run the shown `slidemind pair …`.',
92
+ );
93
+ }
87
94
  if (response.status < 200 || response.status >= 300) {
88
95
  let parsed = null;
89
96
  try {
@@ -135,7 +142,9 @@ function nodeRequest(urlString, init = {}) {
135
142
  {
136
143
  method: init.method ?? "GET",
137
144
  headers,
138
- rejectUnauthorized: isHttps ? false : undefined,
145
+ // Validate the cloud's real cert; only skip for a loopback dev backend
146
+ // (self-signed) when pointing the CLI at a local add-in for development.
147
+ rejectUnauthorized: isHttps ? !isLoopbackHostname(url.hostname) : undefined,
139
148
  },
140
149
  (res) => {
141
150
  const chunks = [];
@@ -167,7 +176,7 @@ function nodeRequest(urlString, init = {}) {
167
176
  }
168
177
 
169
178
  export async function listOpenPresentations() {
170
- const res = await bridgeFetch("/local/presentations");
179
+ const res = await bridgeFetch("/api/cli/presentations");
171
180
  const data = await res.json();
172
181
  return Array.isArray(data?.presentations) ? data.presentations : [];
173
182
  }
@@ -199,7 +208,7 @@ export async function requireHost() {
199
208
  export async function getDesignBundle(presId) {
200
209
  if (!presId) throw new Error("getDesignBundle: presId is required");
201
210
  const res = await bridgeFetch(
202
- `/local/presentations/${encodeURIComponent(presId)}/design-bundle`,
211
+ `/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
203
212
  );
204
213
  return res.json();
205
214
  }
@@ -223,7 +232,7 @@ export async function renderPptx({
223
232
  }
224
233
  if (etag) body.etag = etag;
225
234
  const res = await bridgeFetch(
226
- `/local/presentations/${encodeURIComponent(presId)}/render-pptx`,
235
+ `/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
227
236
  {
228
237
  method: "POST",
229
238
  headers: { "content-type": "application/json" },
@@ -241,7 +250,7 @@ export async function engineSearch({ presId, engineId, query }) {
241
250
  if (!engineId) throw new Error("engineSearch: engineId is required");
242
251
  if (!query) throw new Error("engineSearch: query is required");
243
252
  const res = await bridgeFetch(
244
- `/local/presentations/${encodeURIComponent(presId)}/engine/search`,
253
+ `/api/cli/presentations/${encodeURIComponent(presId)}/engine/search`,
245
254
  {
246
255
  method: "POST",
247
256
  headers: { "content-type": "application/json" },
@@ -270,7 +279,7 @@ export async function engineDownload({
270
279
  const body = { searchId, selection };
271
280
  if (iconColor) body.iconColor = iconColor;
272
281
  const res = await bridgeFetch(
273
- `/local/presentations/${encodeURIComponent(presId)}/engine/download`,
282
+ `/api/cli/presentations/${encodeURIComponent(presId)}/engine/download`,
274
283
  {
275
284
  method: "POST",
276
285
  headers: { "content-type": "application/json" },
@@ -291,7 +300,7 @@ export async function insertSlides({
291
300
  if (!base64) throw new Error("insertSlides: base64 is required");
292
301
  const body = { base64, insertAt, sourceSlideIds, operationId };
293
302
  const res = await bridgeFetch(
294
- `/local/presentations/${encodeURIComponent(presId)}/insert`,
303
+ `/api/cli/presentations/${encodeURIComponent(presId)}/insert`,
295
304
  {
296
305
  method: "POST",
297
306
  headers: { "content-type": "application/json" },
@@ -308,7 +317,7 @@ export async function replaceSlides({ presId, base64, operationId = null }) {
308
317
  if (!presId) throw new Error("replaceSlides: presId is required");
309
318
  if (!base64) throw new Error("replaceSlides: base64 is required");
310
319
  const res = await bridgeFetch(
311
- `/local/presentations/${encodeURIComponent(presId)}/replace`,
320
+ `/api/cli/presentations/${encodeURIComponent(presId)}/replace`,
312
321
  {
313
322
  method: "POST",
314
323
  headers: { "content-type": "application/json" },
@@ -0,0 +1,71 @@
1
+ // CLI session config: where `slidemind pair` stores the cloud relay endpoint +
2
+ // the opaque session token it was issued. The CLI talks to <backendURL>/api/cli/*
3
+ // with `Authorization: Bearer <sessionToken>`; no localhost, no cert.
4
+ //
5
+ // Stored under XDG_CONFIG_HOME (config, not cache/state) so it persists and is
6
+ // clearly user-scoped. 0600 — it holds a bearer credential.
7
+
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ export function sessionConfigDir() {
13
+ if (process.platform === "win32") {
14
+ const base =
15
+ process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
16
+ return path.join(base, "slidemind");
17
+ }
18
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
19
+ return path.join(base, "slidemind");
20
+ }
21
+
22
+ export function sessionConfigPath() {
23
+ return path.join(sessionConfigDir(), "session.json");
24
+ }
25
+
26
+ // Returns { backendURL, sessionToken } or null if not paired / unreadable.
27
+ export function readSessionConfig() {
28
+ let raw;
29
+ try {
30
+ raw = fs.readFileSync(sessionConfigPath(), "utf8");
31
+ } catch {
32
+ return null; // ENOENT or unreadable → treat as not paired
33
+ }
34
+ try {
35
+ const obj = JSON.parse(raw);
36
+ if (
37
+ obj &&
38
+ typeof obj.backendURL === "string" &&
39
+ typeof obj.sessionToken === "string" &&
40
+ obj.backendURL &&
41
+ obj.sessionToken
42
+ ) {
43
+ return { backendURL: obj.backendURL, sessionToken: obj.sessionToken };
44
+ }
45
+ } catch {
46
+ /* malformed JSON */
47
+ }
48
+ return null;
49
+ }
50
+
51
+ export function writeSessionConfig({ backendURL, sessionToken }) {
52
+ if (!backendURL || !sessionToken) {
53
+ throw new Error("writeSessionConfig: backendURL and sessionToken are required");
54
+ }
55
+ fs.mkdirSync(sessionConfigDir(), { recursive: true });
56
+ fs.writeFileSync(
57
+ sessionConfigPath(),
58
+ `${JSON.stringify({ backendURL, sessionToken }, null, 2)}\n`,
59
+ { mode: 0o600 },
60
+ );
61
+ }
62
+
63
+ // Remove the stored session. Returns true if a file was deleted.
64
+ export function clearSessionConfig() {
65
+ try {
66
+ fs.rmSync(sessionConfigPath());
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
@@ -0,0 +1,67 @@
1
+ // Pairing + session helpers for the CLI side of the cloud relay.
2
+ //
3
+ // `slidemind pair <blob>` decodes {u: backendURL, s: secret} and redeems the
4
+ // one-time secret for an opaque session token, stored by config.mjs. whoami/
5
+ // logout call the session-gated endpoints with that token.
6
+
7
+ import http from "node:http";
8
+ import https from "node:https";
9
+
10
+ function isLoopbackHostname(h) {
11
+ return h === "127.0.0.1" || h === "localhost" || h === "::1";
12
+ }
13
+
14
+ // Minimal JSON request. Validates the cloud's real cert; skips only for a
15
+ // loopback dev backend (self-signed).
16
+ export function httpJson(method, urlStr, { body = null, token = null } = {}) {
17
+ const url = new URL(urlStr);
18
+ const transport = url.protocol === "https:" ? https : http;
19
+ const payload = body != null ? JSON.stringify(body) : null;
20
+ const headers = { accept: "application/json" };
21
+ if (payload) {
22
+ headers["content-type"] = "application/json";
23
+ headers["content-length"] = Buffer.byteLength(payload);
24
+ }
25
+ if (token) headers.authorization = `Bearer ${token}`;
26
+ return new Promise((resolve, reject) => {
27
+ const req = transport.request(
28
+ url,
29
+ {
30
+ method,
31
+ headers,
32
+ rejectUnauthorized:
33
+ url.protocol === "https:" ? !isLoopbackHostname(url.hostname) : undefined,
34
+ },
35
+ (res) => {
36
+ const chunks = [];
37
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
38
+ res.on("end", () => {
39
+ const text = Buffer.concat(chunks).toString("utf8");
40
+ let json = null;
41
+ try {
42
+ json = text ? JSON.parse(text) : null;
43
+ } catch {
44
+ /* non-JSON body */
45
+ }
46
+ resolve({ status: res.statusCode ?? 0, json, text });
47
+ });
48
+ },
49
+ );
50
+ req.on("error", reject);
51
+ if (payload) req.write(payload);
52
+ req.end();
53
+ });
54
+ }
55
+
56
+ // Decode a `slidemind pair <blob>` code → { backendURL, secret } or null.
57
+ export function decodePairingCode(code) {
58
+ try {
59
+ const obj = JSON.parse(Buffer.from(code, "base64url").toString("utf8"));
60
+ if (obj && typeof obj.u === "string" && typeof obj.s === "string") {
61
+ return { backendURL: obj.u, secret: obj.s };
62
+ }
63
+ } catch {
64
+ /* malformed */
65
+ }
66
+ return null;
67
+ }
@@ -44,6 +44,26 @@ function firstExisting(candidates) {
44
44
  return null;
45
45
  }
46
46
 
47
+ // Does a design root actually carry brand files? A root qualifies if it has the
48
+ // corporate-design CSS or a layouts dir that holds at least one real layout
49
+ // (a `*.jsx` or `layouts.css`). A bare/junk-only `layouts/` — e.g. one left
50
+ // behind on disk holding only a macOS `.DS_Store` — must NOT count, or it would
51
+ // mask the "materialize the bundle first" guidance for a deck that hasn't
52
+ // downloaded one yet.
53
+ function carriesDesign(root, cssName) {
54
+ if (!root) return false;
55
+ if (fs.existsSync(path.join(root, cssName))) return true;
56
+ const layoutsDir = path.join(root, "layouts");
57
+ if (!fs.existsSync(layoutsDir)) return false;
58
+ try {
59
+ return fs
60
+ .readdirSync(layoutsDir)
61
+ .some((f) => f.endsWith(".jsx") || f === "layouts.css");
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
47
67
  // First existing candidate, or `fallback` when none exist. Encodes the
48
68
  // deck-override → bundle → skill precedence in one place so every resolved path
49
69
  // shares the same lookup rule.
@@ -73,12 +93,7 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
73
93
  const designRoots = [
74
94
  bundleDir,
75
95
  fs.existsSync(skillDir) ? skillDir : null,
76
- ].filter(
77
- (r) =>
78
- r &&
79
- (fs.existsSync(path.join(r, cssName)) ||
80
- fs.existsSync(path.join(r, "layouts"))),
81
- );
96
+ ].filter((r) => carriesDesign(r, cssName));
82
97
  if (designRoots.length === 0) {
83
98
  throw new Error(
84
99
  `No design source for skill "${skillName}". The corporate design now lives ` +