slidemind 0.0.27
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/plugin.json +23 -0
- package/CLAUDE.md +137 -0
- package/README.md +100 -0
- package/bin/slidemind.mjs +603 -0
- package/package.json +62 -0
- package/skills/create-slides/SKILL.md +108 -0
- package/src/bridge/build-and-push.mjs +232 -0
- package/src/bridge/bundle.mjs +373 -0
- package/src/bridge/client.mjs +338 -0
- package/src/builders/build.mjs +724 -0
- package/src/builders/lint-slides.mjs +796 -0
- package/src/builders/primitives.json +19 -0
- package/src/builders/resolve-skill.mjs +205 -0
- package/src/builders/runtime-applier.js +275 -0
- package/src/installers/brand-theme.mjs +38 -0
- package/src/installers/claude.mjs +141 -0
- package/src/installers/codex.mjs +229 -0
- package/src/installers/lib.mjs +435 -0
- package/src/installers/opencode.mjs +181 -0
- package/src/installers/tui.mjs +125 -0
- package/src/mcp/recolor.mjs +111 -0
- package/src/mcp/server.mjs +476 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Client for the slidemind PowerPoint add-in's local bridge.
|
|
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.
|
|
11
|
+
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import https from "node:https";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
19
|
+
const DEFAULT_PORT = 3001;
|
|
20
|
+
const TOKEN_HEADER = "x-slidemind-bridge-token";
|
|
21
|
+
|
|
22
|
+
export function bridgeStateDir() {
|
|
23
|
+
if (process.platform === "win32") {
|
|
24
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
25
|
+
return path.join(base, "slidemind");
|
|
26
|
+
}
|
|
27
|
+
const base = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state");
|
|
28
|
+
return path.join(base, "slidemind");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function bridgeTokenPath() {
|
|
32
|
+
return path.join(bridgeStateDir(), "bridge-token");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readBridgeToken() {
|
|
36
|
+
const file = bridgeTokenPath();
|
|
37
|
+
let raw;
|
|
38
|
+
try {
|
|
39
|
+
raw = fs.readFileSync(file, "utf8");
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (err.code === "ENOENT") {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`slidemind bridge token not found at ${file}. ` +
|
|
44
|
+
`Start the slidemind PowerPoint add-in first — it generates the token on first run.`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
const token = raw.trim();
|
|
50
|
+
if (!token) {
|
|
51
|
+
throw new Error(`slidemind bridge token at ${file} is empty.`);
|
|
52
|
+
}
|
|
53
|
+
return token;
|
|
54
|
+
}
|
|
55
|
+
|
|
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}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function bridgeFetch(pathPart, init = {}) {
|
|
67
|
+
const url = `${bridgeBaseUrl()}${pathPart}`;
|
|
68
|
+
const token = readBridgeToken();
|
|
69
|
+
const headers = {
|
|
70
|
+
accept: "application/json",
|
|
71
|
+
...init.headers,
|
|
72
|
+
[TOKEN_HEADER]: token,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await nodeRequest(url, { ...init, headers });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`slidemind bridge unreachable at ${url}. ` +
|
|
82
|
+
`Is the slidemind PowerPoint add-in running? (npm run dev:backend in src/ui)`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
if (response.status < 200 || response.status >= 300) {
|
|
88
|
+
let parsed = null;
|
|
89
|
+
try {
|
|
90
|
+
parsed = response.body ? JSON.parse(response.body) : null;
|
|
91
|
+
} catch {
|
|
92
|
+
/* non-JSON error body — fall back to the raw text */
|
|
93
|
+
}
|
|
94
|
+
// Prefer the machine-readable `code` (e.g. "pane_suspended",
|
|
95
|
+
// "engine_auth_expired"); fall back to the human `error` string for routes
|
|
96
|
+
// that don't emit a discrete code yet. Keep the human text in the message.
|
|
97
|
+
const machineCode =
|
|
98
|
+
parsed && typeof parsed.code === "string" ? parsed.code : null;
|
|
99
|
+
const humanError =
|
|
100
|
+
parsed && typeof parsed.error === "string" ? parsed.error : null;
|
|
101
|
+
const detail =
|
|
102
|
+
humanError ?? (response.body ? response.body.slice(0, 400) : "");
|
|
103
|
+
const err = new Error(
|
|
104
|
+
`slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
|
|
105
|
+
(detail ? `: ${detail}` : ""),
|
|
106
|
+
);
|
|
107
|
+
err.status = response.status;
|
|
108
|
+
const code = machineCode ?? humanError;
|
|
109
|
+
if (code) err.code = code;
|
|
110
|
+
if (parsed) err.body = parsed;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
json: async () => (response.body ? JSON.parse(response.body) : null),
|
|
115
|
+
text: async () => response.body,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function nodeRequest(urlString, init = {}) {
|
|
120
|
+
const url = new URL(urlString);
|
|
121
|
+
const isHttps = url.protocol === "https:";
|
|
122
|
+
const transport = isHttps ? https : http;
|
|
123
|
+
const body = init.body ?? null;
|
|
124
|
+
const headers = { ...(init.headers ?? {}) };
|
|
125
|
+
if (body && headers["content-length"] === undefined && headers["Content-Length"] === undefined) {
|
|
126
|
+
headers["content-length"] = Buffer.byteLength(body);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const timeoutMs =
|
|
130
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000;
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
const req = transport.request(
|
|
133
|
+
url,
|
|
134
|
+
{
|
|
135
|
+
method: init.method ?? "GET",
|
|
136
|
+
headers,
|
|
137
|
+
rejectUnauthorized: isHttps ? false : undefined,
|
|
138
|
+
},
|
|
139
|
+
(res) => {
|
|
140
|
+
const chunks = [];
|
|
141
|
+
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
142
|
+
res.on("end", () => {
|
|
143
|
+
resolve({
|
|
144
|
+
status: res.statusCode ?? 0,
|
|
145
|
+
statusText: res.statusMessage ?? "",
|
|
146
|
+
body: Buffer.concat(chunks).toString("utf8"),
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
// Default 35s > the add-in's 30s emitWithAck, so a suspended pane surfaces a
|
|
152
|
+
// typed `pane_suspended` rather than a client timeout. A real timeout is
|
|
153
|
+
// tagged `bridge_timeout` so non-idempotent callers (insert) know the request
|
|
154
|
+
// may already have been delivered and must NOT auto-retry it.
|
|
155
|
+
req.setTimeout(timeoutMs, () => {
|
|
156
|
+
const err = new Error(
|
|
157
|
+
`slidemind bridge request timed out after ${timeoutMs}ms`,
|
|
158
|
+
);
|
|
159
|
+
err.code = "bridge_timeout";
|
|
160
|
+
req.destroy(err);
|
|
161
|
+
});
|
|
162
|
+
req.on("error", reject);
|
|
163
|
+
if (body) req.write(body);
|
|
164
|
+
req.end();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function listOpenPresentations() {
|
|
169
|
+
const res = await bridgeFetch("/local/presentations");
|
|
170
|
+
const data = await res.json();
|
|
171
|
+
return Array.isArray(data?.presentations) ? data.presentations : [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Probe the bridge for an open PowerPoint host. Returns the live registry, or
|
|
175
|
+
// throws a single actionable message — the shared precondition for every
|
|
176
|
+
// author/render command now that the design truth lives in the add-in.
|
|
177
|
+
export async function requireHost() {
|
|
178
|
+
let registry;
|
|
179
|
+
try {
|
|
180
|
+
registry = await listOpenPresentations();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
"Open PowerPoint with the slidemind add-in and an active corporate design, then retry.\n" +
|
|
184
|
+
` (bridge: ${err.message})`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
if (!Array.isArray(registry) || registry.length === 0) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
"No PowerPoint window is registered with the slidemind add-in.\n" +
|
|
190
|
+
"Open PowerPoint with the slidemind add-in and an active corporate design, then retry.",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return registry;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// GET the full design bundle (tokens, css, layouts, designRules, signed asset
|
|
197
|
+
// URLs, engines) for the presentation's active corporate design.
|
|
198
|
+
export async function getDesignBundle(presId) {
|
|
199
|
+
if (!presId) throw new Error("getDesignBundle: presId is required");
|
|
200
|
+
const res = await bridgeFetch(
|
|
201
|
+
`/local/presentations/${encodeURIComponent(presId)}/design-bundle`,
|
|
202
|
+
);
|
|
203
|
+
return res.json();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Server-side OOXML render: ship authored slide JSX (+ deck-local assets) and
|
|
207
|
+
// get back a native .pptx (base64) rendered against the presentation's own CD.
|
|
208
|
+
// `slides`: { "<file>.jsx": "<jsx source>" }. `deckAssets`: { "assets/<f>": "<base64>" }.
|
|
209
|
+
export async function renderPptx({
|
|
210
|
+
presId,
|
|
211
|
+
slides,
|
|
212
|
+
deckAssets = null,
|
|
213
|
+
etag = null,
|
|
214
|
+
}) {
|
|
215
|
+
if (!presId) throw new Error("renderPptx: presId is required");
|
|
216
|
+
if (!slides || Object.keys(slides).length === 0) {
|
|
217
|
+
throw new Error("renderPptx: slides is required");
|
|
218
|
+
}
|
|
219
|
+
const body = { slides };
|
|
220
|
+
if (deckAssets && Object.keys(deckAssets).length > 0) {
|
|
221
|
+
body.deckAssets = deckAssets;
|
|
222
|
+
}
|
|
223
|
+
if (etag) body.etag = etag;
|
|
224
|
+
const res = await bridgeFetch(
|
|
225
|
+
`/local/presentations/${encodeURIComponent(presId)}/render-pptx`,
|
|
226
|
+
{
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "content-type": "application/json" },
|
|
229
|
+
body: JSON.stringify(body),
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Brand-engine search, proxied through the open task pane. Engine-agnostic
|
|
236
|
+
// (image/icon engines today; slide engines later). Returns a searchId + a
|
|
237
|
+
// numbered set of result thumbnails for the agent to choose from.
|
|
238
|
+
export async function engineSearch({ presId, engineId, query }) {
|
|
239
|
+
if (!presId) throw new Error("engineSearch: presId is required");
|
|
240
|
+
if (!engineId) throw new Error("engineSearch: engineId is required");
|
|
241
|
+
if (!query) throw new Error("engineSearch: query is required");
|
|
242
|
+
const res = await bridgeFetch(
|
|
243
|
+
`/local/presentations/${encodeURIComponent(presId)}/engine/search`,
|
|
244
|
+
{
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { "content-type": "application/json" },
|
|
247
|
+
body: JSON.stringify({ engineId, query }),
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
return res.json(); // { ok, searchId, resultCount, gridDataUri, thumbnails }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Fetch a chosen engine result's bytes (proxied through the task pane). For
|
|
254
|
+
// image engines this returns SVG/PNG bytes (+ optional recoloured svgMarkup)
|
|
255
|
+
// WITHOUT inserting onto a slide.
|
|
256
|
+
export async function engineDownload({
|
|
257
|
+
presId,
|
|
258
|
+
searchId,
|
|
259
|
+
selection,
|
|
260
|
+
iconColor = null,
|
|
261
|
+
}) {
|
|
262
|
+
if (!presId) throw new Error("engineDownload: presId is required");
|
|
263
|
+
if (typeof searchId !== "number") {
|
|
264
|
+
throw new Error("engineDownload: searchId (number) is required");
|
|
265
|
+
}
|
|
266
|
+
if (typeof selection !== "number") {
|
|
267
|
+
throw new Error("engineDownload: selection (number) is required");
|
|
268
|
+
}
|
|
269
|
+
const body = { searchId, selection };
|
|
270
|
+
if (iconColor) body.iconColor = iconColor;
|
|
271
|
+
const res = await bridgeFetch(
|
|
272
|
+
`/local/presentations/${encodeURIComponent(presId)}/engine/download`,
|
|
273
|
+
{
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: { "content-type": "application/json" },
|
|
276
|
+
body: JSON.stringify(body),
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
return res.json(); // { ok, base64, mimeType, svgMarkup }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function insertSlides({ presId, base64, insertAt = null, sourceSlideIds = null }) {
|
|
283
|
+
if (!presId) throw new Error("insertSlides: presId is required");
|
|
284
|
+
if (!base64) throw new Error("insertSlides: base64 is required");
|
|
285
|
+
const body = { base64, insertAt, sourceSlideIds };
|
|
286
|
+
const res = await bridgeFetch(
|
|
287
|
+
`/local/presentations/${encodeURIComponent(presId)}/insert`,
|
|
288
|
+
{
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: { "content-type": "application/json" },
|
|
291
|
+
body: JSON.stringify(body),
|
|
292
|
+
},
|
|
293
|
+
);
|
|
294
|
+
return res.json().catch(() => ({}));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function replaceSlides({ presId, base64 }) {
|
|
298
|
+
if (!presId) throw new Error("replaceSlides: presId is required");
|
|
299
|
+
if (!base64) throw new Error("replaceSlides: base64 is required");
|
|
300
|
+
const res = await bridgeFetch(
|
|
301
|
+
`/local/presentations/${encodeURIComponent(presId)}/replace`,
|
|
302
|
+
{
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: { "content-type": "application/json" },
|
|
305
|
+
body: JSON.stringify({ base64 }),
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
return res.json().catch(() => ({}));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Pick a target presentation given the live registry and an optional explicit
|
|
312
|
+
// presId. Returns the presId to push to, or throws with a helpful message if
|
|
313
|
+
// the choice is ambiguous.
|
|
314
|
+
export function pickPresentation(registry, explicitPresId) {
|
|
315
|
+
if (explicitPresId) {
|
|
316
|
+
const match = registry.find((p) => p.presId === explicitPresId);
|
|
317
|
+
if (!match) {
|
|
318
|
+
const ids = registry.map((p) => p.presId).join(", ") || "(none registered)";
|
|
319
|
+
throw new Error(
|
|
320
|
+
`No open PowerPoint window registered with presId='${explicitPresId}'. Live: ${ids}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
return match.presId;
|
|
324
|
+
}
|
|
325
|
+
if (registry.length === 0) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
"No PowerPoint windows registered with the slidemind add-in. " +
|
|
328
|
+
"Open a presentation, ensure the add-in is loaded, and try again.",
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (registry.length === 1) return registry[0].presId;
|
|
332
|
+
const list = registry
|
|
333
|
+
.map((p) => ` ${p.presId}\t${p.title || p.fileName || "(untitled)"}`)
|
|
334
|
+
.join("\n");
|
|
335
|
+
throw new Error(
|
|
336
|
+
`Multiple PowerPoint windows are open. Pick one with --to <presId>:\n${list}`,
|
|
337
|
+
);
|
|
338
|
+
}
|