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.
- package/.claude-plugin/marketplace.json +25 -0
- package/.claude-plugin/plugin.json +6 -3
- package/.codex-plugin/plugin.json +29 -0
- package/CLAUDE.md +25 -5
- package/README.md +24 -0
- package/bin/slidemind +7 -0
- package/bin/slidemind.cmd +2 -0
- package/bin/slidemind.mjs +480 -60
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +6 -5
- package/skills/search/SKILL.md +10 -4
- package/src/bridge/bundle.mjs +23 -4
- package/src/bridge/client.mjs +414 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review.mjs +4 -1
- package/src/install-channel.mjs +61 -0
- package/src/installers/claude.mjs +22 -0
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/ooxml.mjs +10 -3
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
75
|
-
fs.writeFileSync(target,
|
|
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 =
|
|
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
|
};
|
package/src/builders/review.mjs
CHANGED
|
@@ -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
|
-
|
|
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() {
|