slidemind 0.0.29 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +7 -1
- package/README.md +27 -0
- package/bin/slidemind.mjs +222 -10
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +10 -11
- package/src/bridge/build-and-push.mjs +25 -14
- package/src/bridge/bundle.mjs +13 -2
- package/src/bridge/client.mjs +319 -94
- package/src/bridge/config.mjs +214 -0
- package/src/bridge/pair.mjs +67 -0
- package/src/builders/build.mjs +9 -5
- package/src/lib/slide-order.mjs +28 -0
- package/src/mcp/server.mjs +10 -7
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// CLI session config: where `slidemind pair` stores the cloud relay endpoint(s)
|
|
2
|
+
// + the opaque session token each was issued. The CLI talks to
|
|
3
|
+
// <backendURL>/api/cli/* with `Authorization: Bearer <sessionToken>`; no
|
|
4
|
+
// localhost, no cert.
|
|
5
|
+
//
|
|
6
|
+
// Multi-environment by design: a slidemind developer can pair the CLI with
|
|
7
|
+
// several backends (local / dev / prod) at once. Each is one record in
|
|
8
|
+
// `sessions[]`; commands fan out across all of them and route by the
|
|
9
|
+
// presentation's origin backend (see client.mjs). One `default` backend is the
|
|
10
|
+
// fallback for the rare single-backend accessor.
|
|
11
|
+
//
|
|
12
|
+
// On-disk shape (v2):
|
|
13
|
+
// { version: 2, sessions: [{ backendURL, sessionToken, label }], default }
|
|
14
|
+
// The legacy v1 shape ({ backendURL, sessionToken }) is migrated transparently
|
|
15
|
+
// on read, so older installs and the raw-file test writers keep working.
|
|
16
|
+
//
|
|
17
|
+
// Stored under XDG_CONFIG_HOME (config, not cache/state) so it persists and is
|
|
18
|
+
// clearly user-scoped. 0600 — it holds bearer credentials.
|
|
19
|
+
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
export function sessionConfigDir() {
|
|
25
|
+
if (process.platform === "win32") {
|
|
26
|
+
const base =
|
|
27
|
+
process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
28
|
+
return path.join(base, "slidemind");
|
|
29
|
+
}
|
|
30
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
31
|
+
return path.join(base, "slidemind");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sessionConfigPath() {
|
|
35
|
+
return path.join(sessionConfigDir(), "session.json");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A short, human env label for a backend URL: localhost → "local", a hostname
|
|
39
|
+
// containing "dev"/"staging" → that word, otherwise the bare hostname. Used in
|
|
40
|
+
// listings and to let `--backend <env>` / `slidemind use <env>` match by name.
|
|
41
|
+
export function deriveEnvLabel(backendURL) {
|
|
42
|
+
let host;
|
|
43
|
+
try {
|
|
44
|
+
host = new URL(backendURL).hostname;
|
|
45
|
+
} catch {
|
|
46
|
+
return String(backendURL || "");
|
|
47
|
+
}
|
|
48
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
|
|
49
|
+
return "local";
|
|
50
|
+
}
|
|
51
|
+
if (/(^|[.-])dev([.-]|$)/i.test(host) || /dev/i.test(host)) return "dev";
|
|
52
|
+
if (/stag(ing|e)?|stg/i.test(host)) return "staging";
|
|
53
|
+
if (/prod/i.test(host)) return "prod";
|
|
54
|
+
return host;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isValidRecord(o) {
|
|
58
|
+
return (
|
|
59
|
+
o &&
|
|
60
|
+
typeof o.backendURL === "string" &&
|
|
61
|
+
typeof o.sessionToken === "string" &&
|
|
62
|
+
o.backendURL &&
|
|
63
|
+
o.sessionToken
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readRaw() {
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = fs.readFileSync(sessionConfigPath(), "utf8");
|
|
71
|
+
} catch {
|
|
72
|
+
return null; // ENOENT or unreadable → treat as not paired
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(raw);
|
|
76
|
+
} catch {
|
|
77
|
+
return null; // malformed JSON
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// All paired sessions as [{ backendURL, sessionToken, label }]. Migrates the v1
|
|
82
|
+
// single-record shape transparently. Returns [] when not paired / unreadable.
|
|
83
|
+
export function readSessions() {
|
|
84
|
+
const obj = readRaw();
|
|
85
|
+
if (!obj) return [];
|
|
86
|
+
// v2: { version, sessions: [...], default }
|
|
87
|
+
if (Array.isArray(obj.sessions)) {
|
|
88
|
+
return obj.sessions.filter(isValidRecord).map((s) => ({
|
|
89
|
+
backendURL: s.backendURL,
|
|
90
|
+
sessionToken: s.sessionToken,
|
|
91
|
+
label: typeof s.label === "string" && s.label ? s.label : deriveEnvLabel(s.backendURL),
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
// v1: { backendURL, sessionToken }
|
|
95
|
+
if (isValidRecord(obj)) {
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
backendURL: obj.backendURL,
|
|
99
|
+
sessionToken: obj.sessionToken,
|
|
100
|
+
label: deriveEnvLabel(obj.backendURL),
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The backendURL marked default, or null. Falls back to the first session when
|
|
108
|
+
// the stored default is missing/stale.
|
|
109
|
+
export function getDefaultBackend() {
|
|
110
|
+
const obj = readRaw();
|
|
111
|
+
const sessions = readSessions();
|
|
112
|
+
if (sessions.length === 0) return null;
|
|
113
|
+
const stored = obj && typeof obj.default === "string" ? obj.default : null;
|
|
114
|
+
if (stored && sessions.some((s) => s.backendURL === stored)) return stored;
|
|
115
|
+
return sessions[0].backendURL;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeAll(sessions, defaultBackend) {
|
|
119
|
+
fs.mkdirSync(sessionConfigDir(), { recursive: true });
|
|
120
|
+
const payload = {
|
|
121
|
+
version: 2,
|
|
122
|
+
sessions: sessions.map((s) => ({
|
|
123
|
+
backendURL: s.backendURL,
|
|
124
|
+
sessionToken: s.sessionToken,
|
|
125
|
+
label: s.label || deriveEnvLabel(s.backendURL),
|
|
126
|
+
})),
|
|
127
|
+
};
|
|
128
|
+
const def =
|
|
129
|
+
defaultBackend && sessions.some((s) => s.backendURL === defaultBackend)
|
|
130
|
+
? defaultBackend
|
|
131
|
+
: sessions[0]?.backendURL;
|
|
132
|
+
if (def) payload.default = def;
|
|
133
|
+
fs.writeFileSync(sessionConfigPath(), `${JSON.stringify(payload, null, 2)}\n`, {
|
|
134
|
+
mode: 0o600,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Add or replace a session (keyed by backendURL). Becomes the default if none
|
|
139
|
+
// is set yet. Preserves the existing default otherwise.
|
|
140
|
+
export function upsertSession({ backendURL, sessionToken, label = null }) {
|
|
141
|
+
if (!backendURL || !sessionToken) {
|
|
142
|
+
throw new Error("upsertSession: backendURL and sessionToken are required");
|
|
143
|
+
}
|
|
144
|
+
const sessions = readSessions().filter((s) => s.backendURL !== backendURL);
|
|
145
|
+
sessions.push({
|
|
146
|
+
backendURL,
|
|
147
|
+
sessionToken,
|
|
148
|
+
label: label || deriveEnvLabel(backendURL),
|
|
149
|
+
});
|
|
150
|
+
const def = getDefaultBackend() || backendURL;
|
|
151
|
+
writeAll(sessions, def);
|
|
152
|
+
return sessions;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Remove one session by backendURL. Returns true if a record was removed. If it
|
|
156
|
+
// was the default, the first remaining session becomes the new default.
|
|
157
|
+
export function removeSession(backendURL) {
|
|
158
|
+
const sessions = readSessions();
|
|
159
|
+
const next = sessions.filter((s) => s.backendURL !== backendURL);
|
|
160
|
+
if (next.length === sessions.length) return false;
|
|
161
|
+
if (next.length === 0) {
|
|
162
|
+
clearSessionConfig();
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
let def = getDefaultBackend();
|
|
166
|
+
if (def === backendURL) def = next[0].backendURL;
|
|
167
|
+
writeAll(next, def);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function setDefaultBackend(backendURL) {
|
|
172
|
+
const sessions = readSessions();
|
|
173
|
+
if (!sessions.some((s) => s.backendURL === backendURL)) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`setDefaultBackend: not paired with '${deriveEnvLabel(backendURL)}'`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
writeAll(sessions, backendURL);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Back-compat single-session accessor: the default session as
|
|
182
|
+
// { backendURL, sessionToken }, or null. Load-bearing for the bridgeFetch
|
|
183
|
+
// fallback and any caller that wants "the one session".
|
|
184
|
+
export function readSessionConfig() {
|
|
185
|
+
const sessions = readSessions();
|
|
186
|
+
if (sessions.length === 0) return null;
|
|
187
|
+
const def = getDefaultBackend();
|
|
188
|
+
const pick = sessions.find((s) => s.backendURL === def) || sessions[0];
|
|
189
|
+
return { backendURL: pick.backendURL, sessionToken: pick.sessionToken };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// The full session record (incl. token) for a specific backend, or null.
|
|
193
|
+
export function getSessionFor(backendURL) {
|
|
194
|
+
return readSessions().find((s) => s.backendURL === backendURL) || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Back-compat writer: upsert a single session (used by tests and any v1 caller).
|
|
198
|
+
export function writeSessionConfig({ backendURL, sessionToken }) {
|
|
199
|
+
if (!backendURL || !sessionToken) {
|
|
200
|
+
throw new Error("writeSessionConfig: backendURL and sessionToken are required");
|
|
201
|
+
}
|
|
202
|
+
upsertSession({ backendURL, sessionToken });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Remove the stored session file entirely (all sessions). Returns true if a
|
|
206
|
+
// file was deleted.
|
|
207
|
+
export function clearSessionConfig() {
|
|
208
|
+
try {
|
|
209
|
+
fs.rmSync(sessionConfigPath());
|
|
210
|
+
return true;
|
|
211
|
+
} catch {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -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
|
+
}
|
package/src/builders/build.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
34
34
|
|
|
35
35
|
import { lintSlidesOrThrow } from "./lint-slides.mjs";
|
|
36
36
|
import { resolveSkillForDeck } from "./resolve-skill.mjs";
|
|
37
|
+
import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
|
|
37
38
|
|
|
38
39
|
const __filename = fileURLToPath(import.meta.url);
|
|
39
40
|
const __dirname = path.dirname(__filename);
|
|
@@ -606,14 +607,17 @@ export function buildDeck(deckDir, opts = {}) {
|
|
|
606
607
|
`${path.basename(deckDir)}' to produce the editable .pptx.`,
|
|
607
608
|
);
|
|
608
609
|
}
|
|
609
|
-
const
|
|
610
|
-
.readdirSync(slidesDir)
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
.map((f) => path.join(slidesDir, f));
|
|
610
|
+
const slideNames = sortSlideFilenames(
|
|
611
|
+
fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")),
|
|
612
|
+
);
|
|
613
|
+
const slideFiles = slideNames.map((f) => path.join(slidesDir, f));
|
|
614
614
|
if (slideFiles.length === 0) {
|
|
615
615
|
throw new Error(`No .jsx files in ${slidesDir}`);
|
|
616
616
|
}
|
|
617
|
+
const orderWarning = slideOrderWarning(slideNames);
|
|
618
|
+
if (orderWarning) {
|
|
619
|
+
process.stderr.write(`warning: ${orderWarning}\n`);
|
|
620
|
+
}
|
|
617
621
|
|
|
618
622
|
// Layout discovery: brand layouts first, then the active skill's layouts
|
|
619
623
|
// override on basename collision — so a sibling skill could reach for the
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Slide play order over the CLI is FILENAME order. The in-app design agent
|
|
2
|
+
// passes an explicit ordered list to its `build_and_push` tool; the CLI has no
|
|
3
|
+
// such channel, so decks encode order with zero-padded numeric prefixes
|
|
4
|
+
// (01-cover.jsx, 02-agenda.jsx, …) — see skills/create-slides/SKILL.md
|
|
5
|
+
// (command mapping). The sort is numeric-aware so unpadded prefixes still
|
|
6
|
+
// order correctly (2-foo.jsx before 10-bar.jsx).
|
|
7
|
+
|
|
8
|
+
const collator = new Intl.Collator("en", { numeric: true });
|
|
9
|
+
|
|
10
|
+
export function sortSlideFilenames(filenames) {
|
|
11
|
+
return [...filenames].sort(collator.compare);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Warn (never fail) when a multi-slide deck has slide files without a numeric
|
|
15
|
+
// prefix: their play order is accidental-alphabetical, which is the #1 way a
|
|
16
|
+
// deck comes out shuffled.
|
|
17
|
+
export function slideOrderWarning(filenames) {
|
|
18
|
+
if (filenames.length < 2) return null;
|
|
19
|
+
const unprefixed = filenames.filter((f) => !/^\d+[-_]/.test(f));
|
|
20
|
+
if (unprefixed.length === 0) return null;
|
|
21
|
+
const sample = unprefixed.slice(0, 3).join(", ");
|
|
22
|
+
const more = unprefixed.length > 3 ? ", …" : "";
|
|
23
|
+
return (
|
|
24
|
+
`slide order is filename order, but ${unprefixed.length} of ` +
|
|
25
|
+
`${filenames.length} slide files lack a numeric prefix (${sample}${more}) — ` +
|
|
26
|
+
`rename to NN-<topic>.jsx (01-cover.jsx, 02-…) so play order matches intent`
|
|
27
|
+
);
|
|
28
|
+
}
|
package/src/mcp/server.mjs
CHANGED
|
@@ -22,10 +22,9 @@
|
|
|
22
22
|
// Fetches a chosen search result's bytes and writes them into a deck's
|
|
23
23
|
// assets/ folder for the slide JSX to reference.
|
|
24
24
|
//
|
|
25
|
-
// Everything routes through the add-in's
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// (Windows: `%LOCALAPPDATA%\\slidemind\\bridge-token`). See src/bridge/client.mjs.
|
|
25
|
+
// Everything routes through the add-in's cloud relay at
|
|
26
|
+
// `<backendURL>/api/cli/*` with the Bearer session token established by
|
|
27
|
+
// `slidemind pair`. See src/bridge/client.mjs.
|
|
29
28
|
|
|
30
29
|
import fs from "node:fs";
|
|
31
30
|
import path from "node:path";
|
|
@@ -132,6 +131,9 @@ const server = new McpServer(
|
|
|
132
131
|
"with an active corporate design is required, and all brand rules come from that " +
|
|
133
132
|
"design's bundle (design.md), never hardcoded here. " +
|
|
134
133
|
"Always call slidemind_list_open_presentations first to learn which presentation_id to target. " +
|
|
134
|
+
"The CLI may be paired with several add-in environments (local/dev/prod) at once; the list spans " +
|
|
135
|
+
"all of them and each entry is tagged with its env, and routing to the right backend is automatic — " +
|
|
136
|
+
"just pass the presentation_id back. " +
|
|
135
137
|
"For brand imagery, slidemind_search_engine then slidemind_download_result.",
|
|
136
138
|
},
|
|
137
139
|
);
|
|
@@ -142,15 +144,16 @@ server.registerTool(
|
|
|
142
144
|
title: "List open PowerPoint windows",
|
|
143
145
|
description:
|
|
144
146
|
"List PowerPoint windows registered with the slidemind local bridge, including retained entries whose task-pane socket is temporarily disconnected. " +
|
|
145
|
-
"
|
|
147
|
+
"Spans every paired add-in environment (local/dev/prod); each entry carries an `env` tag and a stable presId — pass the presId as `presentation_id` to the other tools and routing to that env is automatic. " +
|
|
146
148
|
"If the list is empty the user has no PowerPoint open with the add-in loaded.",
|
|
147
149
|
inputSchema: {},
|
|
148
150
|
},
|
|
149
151
|
async () => {
|
|
150
152
|
try {
|
|
151
|
-
const { listOpenPresentations } =
|
|
153
|
+
const { listOpenPresentations, toPublicPresentation } =
|
|
154
|
+
await loadBridgeClient();
|
|
152
155
|
const list = await listOpenPresentations();
|
|
153
|
-
return textResult({ presentations: list });
|
|
156
|
+
return textResult({ presentations: list.map(toPublicPresentation) });
|
|
154
157
|
} catch (err) {
|
|
155
158
|
return errorResult(err);
|
|
156
159
|
}
|