slidemind 0.1.9 → 0.2.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.
@@ -9,8 +9,16 @@
9
9
  import fs from "node:fs";
10
10
  import path from "node:path";
11
11
 
12
+ import {
13
+ DIMENSION_PROBE_BYTES,
14
+ imageSizeFromBuffer,
15
+ svgSizeFromMarkup,
16
+ } from "./image-dimensions.mjs";
12
17
  import { describeRecolor } from "./recolor.mjs";
13
18
 
19
+ // Every image format an engine accepts, plus the alias spellings seen in the
20
+ // wild. Mirrors the add-in's `searchImage` set (lib/image-formats.ts there);
21
+ // `extFromMime`'s `image/*` fallback below still covers anything newer.
14
22
  const MIME_EXT = {
15
23
  "image/svg+xml": "svg",
16
24
  "image/png": "png",
@@ -18,6 +26,9 @@ const MIME_EXT = {
18
26
  "image/jpg": "jpg",
19
27
  "image/webp": "webp",
20
28
  "image/gif": "gif",
29
+ "image/bmp": "bmp",
30
+ "image/x-ms-bmp": "bmp",
31
+ "image/tiff": "tiff",
21
32
  };
22
33
 
23
34
  // Derive a usable file extension from an engine result's MIME type. The brand
@@ -42,8 +53,186 @@ export function extFromMime(mimeType) {
42
53
  return "bin";
43
54
  }
44
55
 
56
+ // Below this, a transfer finishes faster than a progress line is worth reading.
57
+ // Most brand icons and slide slices are well under it, so the common case stays
58
+ // silent.
59
+ const PROGRESS_MIN_BYTES = 2 * 1024 * 1024;
60
+ /** Emit at most one line per this much progress. */
61
+ const PROGRESS_STEP_BYTES = 4 * 1024 * 1024;
62
+
63
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
64
+
65
+ // Progress reporter for a streamed transfer.
66
+ //
67
+ // Deliberately NOT an in-place bar: this CLI's convention (see
68
+ // `renderProgressPrinter` in bin/slidemind.mjs — "one stderr line per state
69
+ // change — works in plain logs and TTYs alike") is milestone lines, because the
70
+ // primary consumer is an agent reading piped stdout, where `\r` repainting is
71
+ // noise rather than motion. So: nothing for small files, and a line every few MB
72
+ // for large ones, on stderr so it never pollutes parseable stdout.
73
+ export function createTransferProgress(label, write = process.stderr.write.bind(process.stderr)) {
74
+ let nextAt = PROGRESS_STEP_BYTES;
75
+ let announced = false;
76
+ return (bytesWritten, total) => {
77
+ if (total != null && total < PROGRESS_MIN_BYTES) return;
78
+ if (bytesWritten < nextAt) return;
79
+ nextAt = bytesWritten + PROGRESS_STEP_BYTES;
80
+ if (total != null && total > 0) {
81
+ const pct = Math.min(99, Math.floor((bytesWritten / total) * 100));
82
+ write(` ${label}: ${mb(bytesWritten)}/${mb(total)} MB (${pct}%)…\n`);
83
+ } else {
84
+ // No content-length (a decoded/chunked stream): report absolute progress.
85
+ write(` ${label}: ${mb(bytesWritten)} MB…\n`);
86
+ }
87
+ announced = true;
88
+ return announced;
89
+ };
90
+ }
91
+
92
+ const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
93
+
94
+ // Magic-number signatures for the image formats an engine ACCEPTS — the add-in's
95
+ // `searchImage` set (lib/image-formats.ts there, EngineFileExtensions.search_image
96
+ // in its API). An engine cannot hold a format it refuses at ingest, so this list
97
+ // is complete by construction; keep it in step with that set.
98
+ //
99
+ // `svg` is absent deliberately: it is XML with no signature, so it can only be
100
+ // trusted from its declared type.
101
+ const MAGIC_SIGNATURES = [
102
+ { ext: "png", parts: [[0, [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a]]] },
103
+ { ext: "jpg", parts: [[0, [0xff, 0xd8, 0xff]]] },
104
+ { ext: "gif", parts: [[0, ascii("GIF8")]] },
105
+ { ext: "bmp", parts: [[0, ascii("BM")]] },
106
+ // Endian-dependent: "II" + 42 little, "MM" + 42 big.
107
+ { ext: "tiff", parts: [[0, [...ascii("II"), 0x2a, 0x00]]] },
108
+ { ext: "tiff", parts: [[0, [...ascii("MM"), 0x00, 0x2a]]] },
109
+ // RIFF<4-byte size>WEBP — the format tag sits past the container header.
110
+ { ext: "webp", parts: [[0, ascii("RIFF")], [8, ascii("WEBP")]] },
111
+ ];
112
+
113
+ // Identify an image from its BYTES, ignoring any declared MIME type. Returns null
114
+ // when nothing matches (an SVG, or a format this system never ingests) so the
115
+ // caller keeps its MIME-derived guess rather than inventing one.
116
+ //
117
+ // Why sniff at all: the declared type is only as good as the upstream
118
+ // `Content-Type`, and it is demonstrably wrong in the wild — a 1024x1024 PNG in
119
+ // the corpus arrived as `data:image/jpeg`, so the CLI wrote PNG bytes into a
120
+ // `.jpg` file. A wrong extension is not cosmetic here: the asset is referenced
121
+ // from slide JSX as `<img src>` and the renderer picks its decoder from the file
122
+ // type, so a mislabelled asset can fail to render in the .pptx.
123
+ export function extFromBytes(buf) {
124
+ if (!buf || buf.length < 2) return null;
125
+ for (const { ext, parts } of MAGIC_SIGNATURES) {
126
+ const matched = parts.every(([offset, bytes]) => {
127
+ if (buf.length < offset + bytes.length) return false;
128
+ return bytes.every((b, i) => buf[offset + i] === b);
129
+ });
130
+ if (matched) return ext;
131
+ }
132
+ return null;
133
+ }
134
+
135
+ // The extension to write bytes under: what the bytes ARE beats what the wire
136
+ // claims. SVG is exempt — it is XML with no magic number, and the SVG path is
137
+ // already gated on `svgMarkup` being present.
138
+ export function resolveAssetExt(mimeType, buf) {
139
+ const declared = extFromMime(mimeType);
140
+ if (declared === "svg") return declared;
141
+ return extFromBytes(buf) ?? declared;
142
+ }
143
+
144
+ // Stream one result into assetsDir via the add-in's streaming route.
145
+ //
146
+ // Returns the same shape as `downloadOneResult`, or null when the paired add-in
147
+ // has no streaming route (404/501) so the caller can fall back to the capped
148
+ // ack path. A real failure (bad selection, dead source) throws.
149
+ async function streamOneResult({
150
+ client,
151
+ presId,
152
+ backendURL,
153
+ searchId,
154
+ assetsDir,
155
+ selection,
156
+ iconColor,
157
+ }) {
158
+ if (typeof client.engineDownloadToFile !== "function") return null;
159
+ // Write under a provisional name: the true extension is only knowable from the
160
+ // bytes, and buffering the image just to sniff its header would defeat the
161
+ // purpose of streaming.
162
+ const tempPath = path.join(assetsDir, `.engine-${searchId}-${selection}.part`);
163
+ let res;
164
+ try {
165
+ res = await client.engineDownloadToFile({
166
+ presId,
167
+ backendURL,
168
+ searchId,
169
+ selection,
170
+ destPath: tempPath,
171
+ iconColor: iconColor ?? null,
172
+ onProgress: createTransferProgress(`downloading result ${selection}`),
173
+ });
174
+ } catch (err) {
175
+ fs.rmSync(tempPath, { force: true });
176
+ // Route absent → this add-in predates streaming; let the caller fall back.
177
+ // A 404 that carries a machine code is the ROUTE talking (`unknown_search`
178
+ // for a stale selection), not Express saying "no such path" — retrying that
179
+ // on the capped path would just fail again, more slowly.
180
+ if ((err?.status === 404 && !err?.code) || err?.status === 501) return null;
181
+ throw err;
182
+ }
183
+
184
+ try {
185
+ if (res?.svgMarkup) {
186
+ // SVG arrives inline, not as a stream.
187
+ fs.rmSync(tempPath, { force: true });
188
+ const filename = `engine-${searchId}-${selection}.svg`;
189
+ const target = path.join(assetsDir, filename);
190
+ fs.writeFileSync(target, res.svgMarkup, "utf8");
191
+ return {
192
+ selection,
193
+ path: target,
194
+ reference: `./assets/${filename}`,
195
+ mimeType: res.mimeType ?? "image/svg+xml",
196
+ recolor: describeRecolor(res.svgMarkup, res.mimeType, iconColor ?? null),
197
+ size: svgSizeFromMarkup(res.svgMarkup),
198
+ };
199
+ }
200
+
201
+ if (!res?.bytesWritten) {
202
+ throw new Error("stream produced no bytes");
203
+ }
204
+ // Read only the leading header block, not the whole file: enough to sniff
205
+ // the format AND to reach a JPEG's SOF segment past its EXIF/ICC blocks.
206
+ const fd = fs.openSync(tempPath, "r");
207
+ const probeBytes = Math.min(DIMENSION_PROBE_BYTES, res.bytesWritten);
208
+ const header = Buffer.alloc(probeBytes);
209
+ let read = 0;
210
+ try {
211
+ read = fs.readSync(fd, header, 0, probeBytes, 0);
212
+ } finally {
213
+ fs.closeSync(fd);
214
+ }
215
+ const probe = header.subarray(0, read);
216
+ const ext = resolveAssetExt(res.mimeType, probe);
217
+ const filename = `engine-${searchId}-${selection}.${ext}`;
218
+ const target = path.join(assetsDir, filename);
219
+ fs.renameSync(tempPath, target);
220
+ return {
221
+ selection,
222
+ path: target,
223
+ reference: `./assets/${filename}`,
224
+ mimeType: res.mimeType ?? null,
225
+ recolor: describeRecolor(null, res.mimeType, iconColor ?? null),
226
+ size: imageSizeFromBuffer(probe),
227
+ };
228
+ } catch (err) {
229
+ fs.rmSync(tempPath, { force: true });
230
+ throw err;
231
+ }
232
+ }
233
+
45
234
  // Download one chosen engine result via the bridge and write it into assetsDir.
46
- // Returns { selection, path, reference, mimeType, recolor }. Throws on failure
235
+ // Returns { selection, path, reference, mimeType, recolor, size }. Throws on failure
47
236
  // so callers can Promise.allSettled a batch without one bad selection sinking
48
237
  // the rest.
49
238
  export async function downloadOneResult({
@@ -55,6 +244,23 @@ export async function downloadOneResult({
55
244
  selection,
56
245
  iconColor,
57
246
  }) {
247
+ // Preferred path: stream via the add-in server, which is unbounded in image
248
+ // size. The extension can only be decided once bytes exist, so stream to a
249
+ // temp name first and rename — cheaper than buffering a 100 MB image to sniff
250
+ // its header.
251
+ const streamed = await streamOneResult({
252
+ client,
253
+ presId,
254
+ backendURL,
255
+ searchId,
256
+ assetsDir,
257
+ selection,
258
+ iconColor,
259
+ });
260
+ if (streamed) return streamed;
261
+
262
+ // Fallback for an add-in that predates the streaming route: bytes ride the
263
+ // pane's Socket.IO ack, capped by its 10 MB frame limit.
58
264
  const res = await client.engineDownload({
59
265
  presId,
60
266
  backendURL,
@@ -63,16 +269,28 @@ export async function downloadOneResult({
63
269
  iconColor: iconColor ?? null,
64
270
  });
65
271
  if (!res?.ok) {
66
- throw new Error(res?.error ?? "download failed");
272
+ // `payload_too_large` is the add-in refusing an ack the relay's Socket.IO
273
+ // frame cap would otherwise DROP — which previously surfaced as an
274
+ // unexplained "task pane did not respond" timeout. Its message already
275
+ // carries the sizes, so pass it through as the cause rather than restating.
276
+ const err = new Error(res?.error ?? "download failed");
277
+ if (res?.code) err.code = res.code;
278
+ throw err;
67
279
  }
68
- const ext = extFromMime(res.mimeType);
280
+ // Decode first so the extension can be decided by the BYTES, not by a
281
+ // Content-Type the engine inherited from upstream and may have wrong.
282
+ const bytes =
283
+ typeof res.base64 === "string" && res.base64.length > 0
284
+ ? Buffer.from(res.base64, "base64")
285
+ : null;
286
+ const ext = resolveAssetExt(res.mimeType, bytes);
69
287
  const filename = `engine-${searchId}-${selection}.${ext}`;
70
288
  const target = path.join(assetsDir, filename);
71
289
  if (res.svgMarkup && ext === "svg") {
72
290
  // The (possibly recoloured) SVG markup is the canonical asset.
73
291
  fs.writeFileSync(target, res.svgMarkup, "utf8");
74
- } else if (typeof res.base64 === "string" && res.base64.length > 0) {
75
- fs.writeFileSync(target, Buffer.from(res.base64, "base64"));
292
+ } else if (bytes) {
293
+ fs.writeFileSync(target, bytes);
76
294
  } else if (res.svgMarkup) {
77
295
  // SVG bytes arrived only as markup (mimeType lacked an svg hint).
78
296
  fs.writeFileSync(target, res.svgMarkup, "utf8");
@@ -85,5 +303,11 @@ export async function downloadOneResult({
85
303
  reference: `./assets/${filename}`,
86
304
  mimeType: res.mimeType,
87
305
  recolor: describeRecolor(res.svgMarkup ?? null, res.mimeType, iconColor ?? null),
306
+ // Measured from the asset actually written: SVG user units for markup,
307
+ // pixels for a raster. Null when the format carries no readable header.
308
+ size:
309
+ ext === "svg"
310
+ ? svgSizeFromMarkup(res.svgMarkup ?? null)
311
+ : imageSizeFromBuffer(bytes),
88
312
  };
89
313
  }
@@ -0,0 +1,181 @@
1
+ // Intrinsic pixel dimensions of an image, read from its own header bytes.
2
+ //
3
+ // Why here and not from the add-in: the engine wire carries a MIME type and the
4
+ // bytes, never a size, and both transfer paths (the streaming route and the
5
+ // capped ack fallback) end with the file already on disk. Measuring locally
6
+ // therefore works against every add-in version with no wire change, and it
7
+ // measures the asset the deck will actually reference rather than a thumbnail.
8
+ //
9
+ // Consumers: `slidemind download-result` reports the size so the agent can size
10
+ // an `<img>` box to the real aspect ratio instead of guessing one.
11
+
12
+ // Scan far enough into a JPEG to reach its first SOF segment past the EXIF/ICC
13
+ // blocks that routinely occupy the first few dozen KB.
14
+ export const DIMENSION_PROBE_BYTES = 128 * 1024;
15
+
16
+ const ascii = (s) => [...s].map((c) => c.charCodeAt(0));
17
+
18
+ const startsWith = (buf, offset, bytes) =>
19
+ buf.length >= offset + bytes.length && bytes.every((b, i) => buf[offset + i] === b);
20
+
21
+ function pngSize(buf) {
22
+ // 8-byte signature, then the IHDR chunk: length(4) type(4) width(4) height(4).
23
+ if (!startsWith(buf, 12, ascii("IHDR"))) return null;
24
+ if (buf.length < 24) return null;
25
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
26
+ }
27
+
28
+ function gifSize(buf) {
29
+ if (buf.length < 10) return null;
30
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
31
+ }
32
+
33
+ function bmpSize(buf) {
34
+ if (buf.length < 26) return null;
35
+ const headerSize = buf.readUInt32LE(14);
36
+ // BITMAPCOREHEADER is 16-bit; every later DIB header is 32-bit signed, with a
37
+ // negative height meaning a top-down bitmap.
38
+ if (headerSize === 12) {
39
+ return { width: buf.readUInt16LE(18), height: buf.readUInt16LE(20) };
40
+ }
41
+ if (buf.length < 27) return null;
42
+ return { width: Math.abs(buf.readInt32LE(18)), height: Math.abs(buf.readInt32LE(22)) };
43
+ }
44
+
45
+ function webpSize(buf) {
46
+ // RIFF<size>WEBP, then one of three chunk flavours.
47
+ if (buf.length < 30) return null;
48
+ const chunk = String.fromCharCode(buf[12], buf[13], buf[14], buf[15]);
49
+ if (chunk === "VP8 ") {
50
+ // Lossy: 3-byte frame tag, 3-byte sync code, then 14-bit width/height.
51
+ return {
52
+ width: buf.readUInt16LE(26) & 0x3fff,
53
+ height: buf.readUInt16LE(28) & 0x3fff,
54
+ };
55
+ }
56
+ if (chunk === "VP8L") {
57
+ // Lossless: signature byte 0x2f, then 14 bits width-1 and 14 bits height-1.
58
+ if (buf[20] !== 0x2f) return null;
59
+ const bits = buf.readUInt32LE(21);
60
+ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
61
+ }
62
+ if (chunk === "VP8X") {
63
+ // Extended: canvas size as two 24-bit little-endian values, minus one.
64
+ return {
65
+ width: buf.readUIntLE(24, 3) + 1,
66
+ height: buf.readUIntLE(27, 3) + 1,
67
+ };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // Markers that carry no payload and so must not be length-skipped.
73
+ const JPEG_STANDALONE = new Set([0x01, 0xd8, 0xd9, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7]);
74
+ // Every Start-Of-Frame flavour (baseline, progressive, lossless, …). The three
75
+ // holes are DHT (0xc4), JPG extension (0xc8) and DAC (0xcc), which are not SOFs.
76
+ const isJpegSof = (m) => m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc;
77
+
78
+ function jpegSize(buf) {
79
+ let pos = 2; // past SOI
80
+ while (pos + 3 < buf.length) {
81
+ if (buf[pos] !== 0xff) {
82
+ // Resync: fill bytes (0xff) and the odd stray byte are legal between
83
+ // segments; anything else means the header is not parseable.
84
+ pos++;
85
+ continue;
86
+ }
87
+ let marker = buf[pos + 1];
88
+ // Any number of 0xff fill bytes may precede the marker itself.
89
+ let markerPos = pos + 1;
90
+ while (marker === 0xff && markerPos + 1 < buf.length) {
91
+ marker = buf[++markerPos];
92
+ }
93
+ if (JPEG_STANDALONE.has(marker)) {
94
+ pos = markerPos + 1;
95
+ continue;
96
+ }
97
+ if (markerPos + 3 >= buf.length) return null;
98
+ const length = buf.readUInt16BE(markerPos + 1);
99
+ if (isJpegSof(marker)) {
100
+ // SOF payload: precision(1) height(2) width(2).
101
+ if (markerPos + 7 >= buf.length) return null;
102
+ return {
103
+ height: buf.readUInt16BE(markerPos + 4),
104
+ width: buf.readUInt16BE(markerPos + 6),
105
+ };
106
+ }
107
+ if (length < 2) return null;
108
+ pos = markerPos + 1 + length;
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Intrinsic pixel size of a raster image, read from its header bytes.
115
+ *
116
+ * Takes the same "the bytes beat the declared type" stance as `extFromBytes`
117
+ * (engine-download.mjs): the format is sniffed, never taken from a MIME type.
118
+ * Returns null for anything unrecognised (TIFF, a truncated header, an SVG) so
119
+ * the caller reports "unknown" rather than a fabricated size.
120
+ */
121
+ export function imageSizeFromBuffer(buf) {
122
+ if (!buf || buf.length < 16) return null;
123
+ let size = null;
124
+ if (startsWith(buf, 0, [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a])) size = pngSize(buf);
125
+ else if (startsWith(buf, 0, [0xff, 0xd8, 0xff])) size = jpegSize(buf);
126
+ else if (startsWith(buf, 0, ascii("GIF8"))) size = gifSize(buf);
127
+ else if (startsWith(buf, 0, ascii("BM"))) size = bmpSize(buf);
128
+ else if (startsWith(buf, 0, ascii("RIFF")) && startsWith(buf, 8, ascii("WEBP"))) size = webpSize(buf);
129
+ if (!size) return null;
130
+ if (!Number.isFinite(size.width) || !Number.isFinite(size.height)) return null;
131
+ if (size.width <= 0 || size.height <= 0) return null;
132
+ return size;
133
+ }
134
+
135
+ /**
136
+ * Intrinsic size of an SVG, in its own user units.
137
+ *
138
+ * `width`/`height` win when both are plain lengths; otherwise the viewBox
139
+ * supplies the aspect ratio (the common case for icon sets, which size
140
+ * themselves from CSS and carry only a viewBox). Percentage widths are ignored
141
+ * for the same reason — they describe the container, not the graphic.
142
+ */
143
+ export function svgSizeFromMarkup(markup) {
144
+ if (typeof markup !== "string" || markup.length === 0) return null;
145
+ // Only the opening <svg …> tag may contribute; a nested <symbol width=…>
146
+ // further down must not be mistaken for the root size.
147
+ const openTag = /<svg\b[^>]*>/i.exec(markup)?.[0];
148
+ if (!openTag) return null;
149
+
150
+ const length = (name) => {
151
+ const m = new RegExp(`\\b${name}\\s*=\\s*["']?\\s*([\\d.]+)\\s*([a-z%]*)`, "i").exec(openTag);
152
+ if (!m) return null;
153
+ // Only unitless user units and `px` are a pixel count. `%` sizes the
154
+ // container, not the graphic, and physical units (mm/pt/in) would need a DPI
155
+ // — both fall through to the viewBox, which always states the true ratio.
156
+ const unit = m[2].toLowerCase();
157
+ if (unit !== "" && unit !== "px") return null;
158
+ const n = Number.parseFloat(m[1]);
159
+ return Number.isFinite(n) && n > 0 ? n : null;
160
+ };
161
+
162
+ const width = length("width");
163
+ const height = length("height");
164
+ if (width && height) return { width, height };
165
+
166
+ const viewBox = /\bviewBox\s*=\s*["']?\s*([-\d.\s,]+)/i.exec(openTag);
167
+ if (viewBox) {
168
+ const parts = viewBox[1].trim().split(/[\s,]+/).map(Number.parseFloat);
169
+ if (parts.length === 4 && parts.every(Number.isFinite) && parts[2] > 0 && parts[3] > 0) {
170
+ return { width: width ?? parts[2], height: height ?? parts[3] };
171
+ }
172
+ }
173
+ return null;
174
+ }
175
+
176
+ /** `1200×800` for display, or null when the size could not be read. */
177
+ export function formatSize(size) {
178
+ if (!size) return null;
179
+ const round = (n) => (Number.isInteger(n) ? n : Math.round(n * 100) / 100);
180
+ return `${round(size.width)}×${round(size.height)}`;
181
+ }
@@ -22,6 +22,7 @@
22
22
  // declare: `mode`, `templates`, `defaultTemplate`, `designSystem.css`.
23
23
 
24
24
  import fs from "node:fs";
25
+ import os from "node:os";
25
26
  import path from "node:path";
26
27
  import { fileURLToPath } from "node:url";
27
28
 
@@ -74,8 +75,50 @@ export function resolveSkillForDeck(deckDir) {
74
75
  return resolveSkill(skillName, planMeta, { deckDir, bundleDir });
75
76
  }
76
77
 
78
+ // Cheap, NEVER-throwing mode probe: plan frontmatter → skill manifest → mode.
79
+ // Callers that only need to know WHICH pipeline a deck belongs to (compose vs
80
+ // fill-template) must not pay resolveSkill's design-root requirement — a
81
+ // compose deck with an unmaterialized bundle self-heals inside the render
82
+ // path, and an early resolver throw here would break that.
83
+ export function resolveDeckMode(deckDir) {
84
+ try {
85
+ const planMeta = readPlanFrontmatter(deckDir);
86
+ const skillName = planMeta.skill || DEFAULT_SKILL;
87
+ const skillDir = locateSkillDir(skillName);
88
+ const manifest = fs.existsSync(skillDir) ? readManifest(skillDir) : {};
89
+ return { skillName, mode: manifest.mode || "compose" };
90
+ } catch {
91
+ return { skillName: DEFAULT_SKILL, mode: "compose" };
92
+ }
93
+ }
94
+
95
+ // Skill packs can live outside the npm package: confidential template packs
96
+ // (e.g. proposal masters) install to the user-level skills dir and are found
97
+ // FIRST, so the public package never has to carry them. Lookup order:
98
+ // 1. $SLIDEMIND_SKILLS_DIR/<name> (tests / non-standard installs)
99
+ // 2. ~/.config/slidemind/skills/<name>
100
+ // 3. <package>/skills/<name>
101
+ export function userSkillsRoot() {
102
+ return (
103
+ process.env.SLIDEMIND_SKILLS_DIR ||
104
+ path.join(
105
+ process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"),
106
+ "slidemind",
107
+ "skills",
108
+ )
109
+ );
110
+ }
111
+
112
+ function locateSkillDir(skillName) {
113
+ const candidates = [
114
+ path.join(userSkillsRoot(), skillName),
115
+ path.join(PACKAGE_ROOT, "skills", skillName),
116
+ ];
117
+ return firstExisting(candidates) ?? candidates[candidates.length - 1];
118
+ }
119
+
77
120
  export function resolveSkill(skillName, planMeta = {}, opts = {}) {
78
- const skillDir = path.join(PACKAGE_ROOT, "skills", skillName);
121
+ const skillDir = locateSkillDir(skillName);
79
122
  const manifest = fs.existsSync(skillDir) ? readManifest(skillDir) : {};
80
123
  const cssName = manifest.designSystem?.css || "corporate-design.css";
81
124
 
@@ -167,6 +210,7 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
167
210
  templateKey,
168
211
  templatePath,
169
212
  locale: planMeta.locale || null,
213
+ planMeta,
170
214
  packageRoot: PACKAGE_ROOT,
171
215
  bundleDir,
172
216
  };
@@ -56,7 +56,10 @@ const __filename = fileURLToPath(import.meta.url);
56
56
  const __dirname = path.dirname(__filename);
57
57
  const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
58
58
  const REVIEW_STATE_FILE = "review.json";
59
- const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
59
+ // Keep the review preview available for a working session, then let the
60
+ // existing finally block close the local server when the decision or timeout
61
+ // resolves.
62
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 60 * 1000;
60
63
 
61
64
  function clamp01(n) {
62
65
  return n < 0 ? 0 : n > 1 ? 1 : n;
@@ -0,0 +1,61 @@
1
+ // Which channel installed the CLI that is running right now?
2
+ //
3
+ // npm (`npm install -g slidemind`, or `npx`) is the default and the one every
4
+ // "your CLI is too old" remediation used to assume. Claude Code's plugin
5
+ // channel breaks that assumption: the plugin ships its OWN copy of the CLI and
6
+ // puts it on the plugin session's PATH, so `npm install -g slidemind@latest`
7
+ // installs a second, unrelated global and the session keeps running the old
8
+ // plugin copy. A plugin user following the npm remediation would watch the
9
+ // update succeed and stay blocked on the add-in's version floor forever.
10
+ //
11
+ // Detection is by the path this file lives at — where the running code came
12
+ // from — not by env, so it holds for any process the plugin session spawns.
13
+ // `CLAUDE_PLUGIN_ROOT` is honoured as a secondary signal when Claude Code
14
+ // exports it.
15
+ //
16
+ // Codex is deliberately NOT detected: Codex plugins carry the skills only (they
17
+ // cannot put a bin on PATH), so a Codex-plugin session always runs the npm CLI
18
+ // and the npm remediation is already correct there.
19
+
20
+ import path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const PACKAGE_ROOT = path.resolve(
24
+ path.dirname(fileURLToPath(import.meta.url)),
25
+ "..",
26
+ );
27
+
28
+ // `~/.claude/plugins/cache/<marketplace>/<plugin>/…` — a `plugins` segment
29
+ // directly under `.claude`. Matching the pair (not either half alone) keeps a
30
+ // user's own `~/dev/.claude/plugins`-shaped checkout from being the only thing
31
+ // that can false-positive.
32
+ function underClaudePlugins(root) {
33
+ const segs = path.resolve(root).split(/[\\/]+/);
34
+ for (let i = 1; i < segs.length; i += 1) {
35
+ if (segs[i] === "plugins" && segs[i - 1] === ".claude") return true;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ export function isClaudePluginInstall(root = PACKAGE_ROOT) {
41
+ const resolved = path.resolve(root);
42
+ if (underClaudePlugins(resolved)) return true;
43
+ const declared = process.env.CLAUDE_PLUGIN_ROOT;
44
+ if (declared) {
45
+ const rel = path.relative(path.resolve(declared), resolved);
46
+ if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
47
+ return true;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+
53
+ // The one sentence every version-floor / too-old-CLI message ends with, so the
54
+ // remediation always names the updater that actually replaces THIS binary.
55
+ export function updateCliInstruction() {
56
+ return isClaudePluginInstall()
57
+ ? "This CLI came from the slidemind Claude Code plugin, so update it there: " +
58
+ "`/plugin` → slidemind → update (a global `npm install -g slidemind@latest` " +
59
+ "installs a separate copy and will NOT change the `slidemind` this session runs)."
60
+ : "Run `slidemind update` (or `npm install -g slidemind@latest`), then retry.";
61
+ }
@@ -78,6 +78,19 @@ function unregisterMcpServer() {
78
78
  return false;
79
79
  }
80
80
 
81
+ // ~/.claude/plugins/cache/<marketplace>/slidemind/ — presence means the Claude
82
+ // Code plugin is also installed; skills copied by `init` duplicate its skills.
83
+ function pluginInstallDetected() {
84
+ const cacheRoot = path.join(claudeRoot(), "plugins", "cache");
85
+ try {
86
+ return fs
87
+ .readdirSync(cacheRoot)
88
+ .some((mp) => fs.existsSync(path.join(cacheRoot, mp, NAMESPACE)));
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+
81
94
  export async function install({ packageRoot, flags }) {
82
95
  const link = isLinkMode(flags);
83
96
  const force = isForce(flags);
@@ -108,6 +121,15 @@ export async function install({ packageRoot, flags }) {
108
121
  if (unregisterMcpServer()) {
109
122
  ui.step({ status: "remove", label: "mcp server", value: "removed legacy registration (slidemind is a CLI)" });
110
123
  }
124
+
125
+ if (pluginInstallDetected()) {
126
+ ui.step({
127
+ status: "info",
128
+ label: "plugin",
129
+ value:
130
+ "the slidemind Claude Code plugin is also installed — these copied skills duplicate its skills; plugin users can drop them with `slidemind uninstall --for claude`",
131
+ });
132
+ }
111
133
  }
112
134
 
113
135
  export async function uninstall() {