slidemind 0.2.2 → 0.2.4
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 +1 -1
- package/.codex-plugin/plugin.json +8 -1
- package/AGENTS.md +88 -21
- package/README.md +82 -34
- package/assets/app-icon.png +0 -0
- package/bin/slidemind.mjs +1497 -298
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +32 -28
- package/skills/search/SKILL.md +28 -22
- package/src/bridge/build-and-push.mjs +19 -6
- package/src/bridge/client.mjs +897 -107
- package/src/bridge/config.mjs +2 -2
- package/src/bridge/engine-download.mjs +61 -8
- package/src/bridge/{pair.mjs → http-json.mjs} +9 -24
- package/src/bridge/setup-claim.mjs +127 -0
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/installers/claude.mjs +24 -0
- package/src/installers/codex.mjs +60 -4
- package/src/installers/lib.mjs +49 -19
- package/src/installers/opencode.mjs +16 -0
- package/src/installers/tui.mjs +21 -10
- package/src/pptx/fill/engine.mjs +112 -134
package/src/bridge/config.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// CLI session config:
|
|
2
|
-
//
|
|
1
|
+
// CLI session config: connecting stores the cloud relay endpoint(s) + the
|
|
2
|
+
// opaque session token each was issued. The CLI talks to
|
|
3
3
|
// <backendURL>/api/cli/* with `Authorization: Bearer <sessionToken>`; no
|
|
4
4
|
// localhost, no cert.
|
|
5
5
|
//
|
|
@@ -141,6 +141,25 @@ export function resolveAssetExt(mimeType, buf) {
|
|
|
141
141
|
return extFromBytes(buf) ?? declared;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// The recolour descriptor, with a pane-free TINT REFUSAL folded in: the bytes
|
|
145
|
+
// on disk are then the original ones, so `applied` has to read false and the
|
|
146
|
+
// note has to name the one route that does bake a colour in.
|
|
147
|
+
function describeRecolorWithRefusal(markup, mimeType, iconColor, refusedColor) {
|
|
148
|
+
const described = describeRecolor(
|
|
149
|
+
markup,
|
|
150
|
+
mimeType,
|
|
151
|
+
refusedColor ? null : (iconColor ?? null),
|
|
152
|
+
);
|
|
153
|
+
if (!refusedColor) return described;
|
|
154
|
+
return {
|
|
155
|
+
...described,
|
|
156
|
+
note:
|
|
157
|
+
`Saved in its ORIGINAL colours — recolouring to ${refusedColor} runs in the slidemind add-in. ` +
|
|
158
|
+
"Re-run this download with --to <presId> (a PowerPoint window with the slidemind pane open) " +
|
|
159
|
+
"to bake the colour in.",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
// Stream one result into assetsDir via the add-in's streaming route.
|
|
145
164
|
//
|
|
146
165
|
// Returns the same shape as `downloadOneResult`, or null when the paired add-in
|
|
@@ -156,21 +175,29 @@ async function streamOneResult({
|
|
|
156
175
|
iconColor,
|
|
157
176
|
}) {
|
|
158
177
|
if (typeof client.engineDownloadToFile !== "function") return null;
|
|
178
|
+
// `presId: null` + a resolved backendURL is the pane-free route (the client
|
|
179
|
+
// picks the direct stream); a presId relays through that window's pane.
|
|
159
180
|
// Write under a provisional name: the true extension is only knowable from the
|
|
160
181
|
// bytes, and buffering the image just to sniff its header would defeat the
|
|
161
182
|
// purpose of streaming.
|
|
162
183
|
const tempPath = path.join(assetsDir, `.engine-${searchId}-${selection}.part`);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
res = await client.engineDownloadToFile({
|
|
184
|
+
const fetchOnce = (color) =>
|
|
185
|
+
client.engineDownloadToFile({
|
|
166
186
|
presId,
|
|
167
187
|
backendURL,
|
|
168
188
|
searchId,
|
|
169
189
|
selection,
|
|
170
190
|
destPath: tempPath,
|
|
171
|
-
iconColor:
|
|
191
|
+
iconColor: color ?? null,
|
|
172
192
|
onProgress: createTransferProgress(`downloading result ${selection}`),
|
|
173
193
|
});
|
|
194
|
+
let res;
|
|
195
|
+
// Set when the pane-free route refused the TINT (not the asset): the download
|
|
196
|
+
// then repeats without a colour so the caller still gets the icon, and the
|
|
197
|
+
// recolour is reported as not applied instead of silently claimed.
|
|
198
|
+
let recolorRefused = null;
|
|
199
|
+
try {
|
|
200
|
+
res = await fetchOnce(iconColor);
|
|
174
201
|
} catch (err) {
|
|
175
202
|
fs.rmSync(tempPath, { force: true });
|
|
176
203
|
// Route absent → this add-in predates streaming; let the caller fall back.
|
|
@@ -178,7 +205,13 @@ async function streamOneResult({
|
|
|
178
205
|
// for a stale selection), not Express saying "no such path" — retrying that
|
|
179
206
|
// on the capped path would just fail again, more slowly.
|
|
180
207
|
if ((err?.status === 404 && !err?.code) || err?.status === 501) return null;
|
|
181
|
-
|
|
208
|
+
// The recolour classifier is DOM-based and lives in the add-in, so the
|
|
209
|
+
// pane-free route refuses a tint rather than guessing at one. Refusing the
|
|
210
|
+
// whole DOWNLOAD over it would be worse: the icon is fetchable, only the
|
|
211
|
+
// colour is not. Take the bytes and say so.
|
|
212
|
+
if (err?.code !== "recolor_needs_host" || !iconColor) throw err;
|
|
213
|
+
recolorRefused = iconColor;
|
|
214
|
+
res = await fetchOnce(null);
|
|
182
215
|
}
|
|
183
216
|
|
|
184
217
|
try {
|
|
@@ -193,7 +226,12 @@ async function streamOneResult({
|
|
|
193
226
|
path: target,
|
|
194
227
|
reference: `./assets/${filename}`,
|
|
195
228
|
mimeType: res.mimeType ?? "image/svg+xml",
|
|
196
|
-
recolor:
|
|
229
|
+
recolor: describeRecolorWithRefusal(
|
|
230
|
+
res.svgMarkup,
|
|
231
|
+
res.mimeType,
|
|
232
|
+
iconColor,
|
|
233
|
+
recolorRefused,
|
|
234
|
+
),
|
|
197
235
|
size: svgSizeFromMarkup(res.svgMarkup),
|
|
198
236
|
};
|
|
199
237
|
}
|
|
@@ -222,7 +260,12 @@ async function streamOneResult({
|
|
|
222
260
|
path: target,
|
|
223
261
|
reference: `./assets/${filename}`,
|
|
224
262
|
mimeType: res.mimeType ?? null,
|
|
225
|
-
recolor:
|
|
263
|
+
recolor: describeRecolorWithRefusal(
|
|
264
|
+
null,
|
|
265
|
+
res.mimeType,
|
|
266
|
+
iconColor,
|
|
267
|
+
recolorRefused,
|
|
268
|
+
),
|
|
226
269
|
size: imageSizeFromBuffer(probe),
|
|
227
270
|
};
|
|
228
271
|
} catch (err) {
|
|
@@ -237,7 +280,7 @@ async function streamOneResult({
|
|
|
237
280
|
// the rest.
|
|
238
281
|
export async function downloadOneResult({
|
|
239
282
|
client,
|
|
240
|
-
presId,
|
|
283
|
+
presId = null,
|
|
241
284
|
backendURL = null,
|
|
242
285
|
searchId,
|
|
243
286
|
assetsDir,
|
|
@@ -259,6 +302,16 @@ export async function downloadOneResult({
|
|
|
259
302
|
});
|
|
260
303
|
if (streamed) return streamed;
|
|
261
304
|
|
|
305
|
+
// The capped base64 fallback below is a PANE route. On the pane-free path
|
|
306
|
+
// there is no window to relay through — and an add-in that serves the direct
|
|
307
|
+
// routes always serves the direct stream — so a missing stream there is a real
|
|
308
|
+
// failure, not a reason to reach for a window that may not exist.
|
|
309
|
+
if (!presId) {
|
|
310
|
+
throw new Error(
|
|
311
|
+
"the add-in's engine routes returned no bytes for that result — re-run the search, then pick again",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
262
315
|
// Fallback for an add-in that predates the streaming route: bytes ride the
|
|
263
316
|
// pane's Socket.IO ack, capped by its 10 MB frame limit.
|
|
264
317
|
const res = await client.engineDownload({
|
|
@@ -1,18 +1,14 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// one-time secret for an opaque session token, stored by config.mjs. whoami/
|
|
5
|
-
// logout call the session-gated endpoints with that token.
|
|
1
|
+
// Minimal JSON-over-HTTP transport shared by setup-claim redemption and session
|
|
2
|
+
// management. Validates real TLS certificates; loopback development may use a
|
|
3
|
+
// self-signed HTTPS certificate or plain HTTP.
|
|
6
4
|
|
|
7
5
|
import http from "node:http";
|
|
8
6
|
import https from "node:https";
|
|
9
7
|
|
|
10
|
-
function isLoopbackHostname(
|
|
11
|
-
return
|
|
8
|
+
function isLoopbackHostname(hostname) {
|
|
9
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
|
|
12
10
|
}
|
|
13
11
|
|
|
14
|
-
// Minimal JSON request. Validates the cloud's real cert; skips only for a
|
|
15
|
-
// loopback dev backend (self-signed).
|
|
16
12
|
export function httpJson(method, urlStr, { body = null, token = null } = {}) {
|
|
17
13
|
const url = new URL(urlStr);
|
|
18
14
|
const transport = url.protocol === "https:" ? https : http;
|
|
@@ -30,11 +26,13 @@ export function httpJson(method, urlStr, { body = null, token = null } = {}) {
|
|
|
30
26
|
method,
|
|
31
27
|
headers,
|
|
32
28
|
rejectUnauthorized:
|
|
33
|
-
url.protocol === "https:"
|
|
29
|
+
url.protocol === "https:"
|
|
30
|
+
? !isLoopbackHostname(url.hostname)
|
|
31
|
+
: undefined,
|
|
34
32
|
},
|
|
35
33
|
(res) => {
|
|
36
34
|
const chunks = [];
|
|
37
|
-
res.on("data", (
|
|
35
|
+
res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
38
36
|
res.on("end", () => {
|
|
39
37
|
const text = Buffer.concat(chunks).toString("utf8");
|
|
40
38
|
let json = null;
|
|
@@ -52,16 +50,3 @@ export function httpJson(method, urlStr, { body = null, token = null } = {}) {
|
|
|
52
50
|
req.end();
|
|
53
51
|
});
|
|
54
52
|
}
|
|
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
|
-
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// One-command setup claims — the CLI half of "click Connect CLI in PowerPoint,
|
|
2
|
+
// paste one command".
|
|
3
|
+
//
|
|
4
|
+
// Why the add-in mints this rather than the CLI: the CLI cannot discover WHICH slidemind
|
|
5
|
+
// deployment a user belongs to. Every customer runs its own backend, so an
|
|
6
|
+
// anonymous CLI has no address to start an approval at. The authenticated
|
|
7
|
+
// add-in already knows its deployment, so it mints a claim that carries the
|
|
8
|
+
// backend URL alongside a one-time secret; the user pastes it once and the CLI
|
|
9
|
+
// learns both at the same moment. This is the ONLY way a CLI gets a session.
|
|
10
|
+
//
|
|
11
|
+
// Wire shape: `smc1_<base64url({ v: 1, u: <backendURL>, s: <secret> })>`.
|
|
12
|
+
// The secret is the credential — it is one-time, short-lived, and bound
|
|
13
|
+
// server-side to the identity that minted it. Nothing here is a bearer token
|
|
14
|
+
// for anything else, and the claim is worthless once redeemed.
|
|
15
|
+
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
|
|
18
|
+
import { httpJson } from "./http-json.mjs";
|
|
19
|
+
|
|
20
|
+
export const SETUP_CLAIM_PREFIX = "smc1_";
|
|
21
|
+
|
|
22
|
+
function isLoopback(hostname) {
|
|
23
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function invalid(detail) {
|
|
27
|
+
return new Error(
|
|
28
|
+
`${detail}\n\n` +
|
|
29
|
+
"Open PowerPoint → slidemind → Settings → General → Connect CLI and copy a fresh setup command.",
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decode + validate a setup claim into { backendURL, secret }.
|
|
35
|
+
*
|
|
36
|
+
* Tolerates the shapes a copied terminal command actually arrives in: wrapping
|
|
37
|
+
* whitespace, surrounding quotes, and the whole `npx --yes slidemind@latest
|
|
38
|
+
* setup <claim>` line pasted as one argument.
|
|
39
|
+
*/
|
|
40
|
+
export function decodeSetupClaim(raw) {
|
|
41
|
+
let text = String(raw ?? "").trim();
|
|
42
|
+
// A user who quotes the argument, or pastes the entire command as one shell
|
|
43
|
+
// word, still gets what they meant.
|
|
44
|
+
text = text.replace(/^['"]|['"]$/g, "").trim();
|
|
45
|
+
if (text.includes(SETUP_CLAIM_PREFIX) && !text.startsWith(SETUP_CLAIM_PREFIX)) {
|
|
46
|
+
const match = text.match(/smc1_[A-Za-z0-9_-]+/);
|
|
47
|
+
if (match) text = match[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!text.startsWith(SETUP_CLAIM_PREFIX)) {
|
|
51
|
+
throw invalid(
|
|
52
|
+
"that does not look like a slidemind setup command (it should end with a smc1_… claim).",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const body = text.slice(SETUP_CLAIM_PREFIX.length);
|
|
57
|
+
let parsed;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
60
|
+
} catch {
|
|
61
|
+
throw invalid("this setup command is damaged — it was probably copied only in part.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!parsed || parsed.v !== 1) {
|
|
65
|
+
throw invalid(
|
|
66
|
+
"this setup command was made by a newer slidemind add-in than this CLI understands. Run `npm install -g slidemind@latest` and try again.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const secret = typeof parsed.s === "string" ? parsed.s : "";
|
|
71
|
+
if (!secret) throw invalid("this setup command carries no connection secret.");
|
|
72
|
+
|
|
73
|
+
let url;
|
|
74
|
+
try {
|
|
75
|
+
url = new URL(String(parsed.u ?? ""));
|
|
76
|
+
} catch {
|
|
77
|
+
throw invalid("this setup command carries no valid slidemind address.");
|
|
78
|
+
}
|
|
79
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
|
|
80
|
+
throw invalid(
|
|
81
|
+
"this setup command points at a non-HTTPS slidemind address (HTTP is allowed only for loopback development).",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { backendURL: url.toString().replace(/\/$/, ""), secret };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Spend the one-time secret at its own deployment and receive the CLI session.
|
|
90
|
+
* The claim is consumed server-side whether or not this process survives, so a
|
|
91
|
+
* failure here always means "get a fresh command", never "retry".
|
|
92
|
+
*/
|
|
93
|
+
export async function redeemSetupClaim({ backendURL, secret, cliVersion }) {
|
|
94
|
+
let result;
|
|
95
|
+
try {
|
|
96
|
+
result = await httpJson("POST", `${backendURL}/api/cli/connect/redeem`, {
|
|
97
|
+
body: {
|
|
98
|
+
secret,
|
|
99
|
+
deviceName: os.hostname() || "CLI device",
|
|
100
|
+
platform: `${process.platform}-${process.arch}`,
|
|
101
|
+
cliVersion,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`could not reach slidemind to complete setup: ${error?.message || error}`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (result.status === 200 && result.json?.sessionToken) {
|
|
111
|
+
return String(result.json.sessionToken);
|
|
112
|
+
}
|
|
113
|
+
// Discriminate on the error CODE, not the status. A deployment that predates
|
|
114
|
+
// one-command setup has no such route, so the request falls through to that
|
|
115
|
+
// router's session guard and comes back 401 `no_session` — the same status a
|
|
116
|
+
// spent claim returns. Only the body tells the two apart.
|
|
117
|
+
if (result.status === 401 && result.json?.error === "setup_claim_invalid_or_expired") {
|
|
118
|
+
throw invalid("this setup command has already been used or has expired.");
|
|
119
|
+
}
|
|
120
|
+
if (result.status === 401 || result.status === 404 || result.status === 405) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
"this slidemind add-in is too old for one-command setup — it needs an add-in update before the CLI can connect to it.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const detail = result.json?.error || result.text || `HTTP ${result.status}`;
|
|
126
|
+
throw new Error(`slidemind could not complete setup: ${detail}`);
|
|
127
|
+
}
|
|
@@ -68,8 +68,11 @@ function firstExistingOr(candidates, fallback) {
|
|
|
68
68
|
return firstExisting(candidates) ?? fallback;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export function resolveSkillForDeck(deckDir) {
|
|
71
|
+
export function resolveSkillForDeck(deckDir, opts = {}) {
|
|
72
72
|
const planMeta = readPlanFrontmatter(deckDir);
|
|
73
|
+
if (opts.packDir) {
|
|
74
|
+
return resolveTemplatePack(opts.packDir, planMeta, { deckDir });
|
|
75
|
+
}
|
|
73
76
|
const skillName = planMeta.skill || DEFAULT_SKILL;
|
|
74
77
|
const bundleDir = deckDir ? resolveBundleDirForDeck(deckDir) : null;
|
|
75
78
|
return resolveSkill(skillName, planMeta, { deckDir, bundleDir });
|
|
@@ -80,7 +83,12 @@ export function resolveSkillForDeck(deckDir) {
|
|
|
80
83
|
// fill-template) must not pay resolveSkill's design-root requirement — a
|
|
81
84
|
// compose deck with an unmaterialized bundle self-heals inside the render
|
|
82
85
|
// path, and an early resolver throw here would break that.
|
|
83
|
-
export function resolveDeckMode(deckDir) {
|
|
86
|
+
export function resolveDeckMode(deckDir, opts = {}) {
|
|
87
|
+
if (opts.packDir) {
|
|
88
|
+
const planMeta = readPlanFrontmatter(deckDir);
|
|
89
|
+
const pack = resolveTemplatePack(opts.packDir, planMeta, { deckDir });
|
|
90
|
+
return { skillName: pack.skillName, mode: pack.mode, skillDir: pack.skillDir };
|
|
91
|
+
}
|
|
84
92
|
try {
|
|
85
93
|
const planMeta = readPlanFrontmatter(deckDir);
|
|
86
94
|
const skillName = planMeta.skill || DEFAULT_SKILL;
|
|
@@ -92,6 +100,60 @@ export function resolveDeckMode(deckDir) {
|
|
|
92
100
|
}
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
// Resolve a runtime template pack from an explicit directory. This path is
|
|
104
|
+
// deliberately independent of agent/harness installation and of the compose
|
|
105
|
+
// design-bundle resolver below. A plugin can locate its own pack and pass this
|
|
106
|
+
// path to the CLI without the CLI knowing the plugin exists.
|
|
107
|
+
export function resolveTemplatePack(packDir, planMeta = {}, opts = {}) {
|
|
108
|
+
const skillDir = path.resolve(String(packDir));
|
|
109
|
+
if (!fs.existsSync(skillDir) || !fs.statSync(skillDir).isDirectory()) {
|
|
110
|
+
throw new Error(`template pack directory does not exist: ${skillDir}`);
|
|
111
|
+
}
|
|
112
|
+
const manifest = readManifest(skillDir);
|
|
113
|
+
if (!manifest || typeof manifest !== "object" || !manifest.mode) {
|
|
114
|
+
throw new Error(`template pack ${skillDir} has no valid skill.json manifest`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let templateKey = null;
|
|
118
|
+
let templatePath = null;
|
|
119
|
+
if (manifest.templates) {
|
|
120
|
+
templateKey =
|
|
121
|
+
planMeta.template ||
|
|
122
|
+
manifest.defaultTemplate ||
|
|
123
|
+
Object.keys(manifest.templates)[0];
|
|
124
|
+
const rel = templateKey ? manifest.templates[templateKey] : null;
|
|
125
|
+
if (rel) {
|
|
126
|
+
const candidate = path.resolve(skillDir, rel);
|
|
127
|
+
const relative = path.relative(skillDir, candidate);
|
|
128
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
129
|
+
throw new Error(`template path escapes pack directory: ${rel}`);
|
|
130
|
+
}
|
|
131
|
+
if (fs.existsSync(candidate)) templatePath = candidate;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
skillName: manifest.name || path.basename(skillDir),
|
|
137
|
+
skillDir,
|
|
138
|
+
manifest,
|
|
139
|
+
mode: manifest.mode || "compose",
|
|
140
|
+
// Fill-template callers only need the pack payload. These legacy design
|
|
141
|
+
// fields remain null so callers can distinguish this path from compose
|
|
142
|
+
// skill resolution without paying a design-root requirement.
|
|
143
|
+
cssFile: null,
|
|
144
|
+
layoutsDir: null,
|
|
145
|
+
layoutsCssFile: null,
|
|
146
|
+
assetsDir: opts.deckDir ? path.join(opts.deckDir, "assets") : null,
|
|
147
|
+
brand: null,
|
|
148
|
+
templateKey,
|
|
149
|
+
templatePath,
|
|
150
|
+
locale: planMeta.locale || null,
|
|
151
|
+
planMeta,
|
|
152
|
+
packageRoot: PACKAGE_ROOT,
|
|
153
|
+
bundleDir: opts.deckDir ? resolveBundleDirForDeck(opts.deckDir) : null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
95
157
|
// Skill packs can live outside the npm package: private, org-internal packs
|
|
96
158
|
// (templates, brand-specific assets) are distributed on their own and this
|
|
97
159
|
// package never carries them. Lookup order:
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import fs from "node:fs";
|
|
15
15
|
import path from "node:path";
|
|
16
16
|
import {
|
|
17
|
+
binaryOnPath,
|
|
17
18
|
expandHome,
|
|
18
19
|
installDir,
|
|
19
20
|
isForce,
|
|
@@ -91,6 +92,29 @@ function pluginInstallDetected() {
|
|
|
91
92
|
}
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
// Is Claude Code on this machine, and would deploying here duplicate a plugin?
|
|
96
|
+
// `--for auto` (what `slidemind setup` uses) installs only where the harness
|
|
97
|
+
// actually exists AND its plugin has not already supplied the skills — copying
|
|
98
|
+
// over a plugin's skills plants a second set that drifts on the next plugin
|
|
99
|
+
// update, which is the one thing plugin users must not do.
|
|
100
|
+
//
|
|
101
|
+
// Two signals, because either alone misses a real install: the config dir is
|
|
102
|
+
// created the first time the agent RUNS (so a just-installed, never-launched
|
|
103
|
+
// Claude Code has none), while the binary is missing for a desktop-app-only
|
|
104
|
+
// install. Present if either says so.
|
|
105
|
+
export function detect() {
|
|
106
|
+
const root = claudeRoot();
|
|
107
|
+
const configured = fs.existsSync(root);
|
|
108
|
+
const onPath = binaryOnPath("claude");
|
|
109
|
+
return {
|
|
110
|
+
root,
|
|
111
|
+
configured,
|
|
112
|
+
onPath,
|
|
113
|
+
present: configured || onPath,
|
|
114
|
+
pluginInstalled: pluginInstallDetected(),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
94
118
|
export async function install({ packageRoot, flags }) {
|
|
95
119
|
const link = isLinkMode(flags);
|
|
96
120
|
const force = isForce(flags);
|
package/src/installers/codex.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import fs from "node:fs";
|
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import { spawnSync } from "node:child_process";
|
|
27
27
|
import {
|
|
28
|
+
binaryOnPath,
|
|
28
29
|
expandHome,
|
|
29
30
|
installDir,
|
|
30
31
|
isForce,
|
|
@@ -81,6 +82,33 @@ function agentsFile() {
|
|
|
81
82
|
return path.join(codexRoot(), "AGENTS.md");
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// `$CODEX_HOME/plugins/cache/<marketplace>/slidemind/<version>/` — a non-empty
|
|
86
|
+
// plugin directory means the Codex plugin is also installed, so the skills
|
|
87
|
+
// copied by `init` duplicate the ones the plugin already carries (and will
|
|
88
|
+
// drift the next time either side updates). Mirrors the Claude installer's
|
|
89
|
+
// check. A marketplace-only `cache/slidemind/` (added but never installed from)
|
|
90
|
+
// holds no `slidemind` child, so it correctly reads as "not installed".
|
|
91
|
+
export function pluginInstallDetected() {
|
|
92
|
+
const cacheRoot = path.join(codexRoot(), "plugins", "cache");
|
|
93
|
+
try {
|
|
94
|
+
return fs.readdirSync(cacheRoot).some((mp) => {
|
|
95
|
+
const dir = path.join(cacheRoot, mp, NAMESPACE);
|
|
96
|
+
try {
|
|
97
|
+
return (
|
|
98
|
+
fs.statSync(dir).isDirectory() &&
|
|
99
|
+
fs
|
|
100
|
+
.readdirSync(dir)
|
|
101
|
+
.some((entry) => !entry.startsWith("."))
|
|
102
|
+
);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
function configTomlFile() {
|
|
85
113
|
return path.join(codexRoot(), "config.toml");
|
|
86
114
|
}
|
|
@@ -119,13 +147,18 @@ function writeAgentsBlock() {
|
|
|
119
147
|
const block =
|
|
120
148
|
`${AGENTS_MARK_BEGIN}\n` +
|
|
121
149
|
`## slidemind\n` +
|
|
122
|
-
`Slide-deck authoring
|
|
150
|
+
`Slide-deck authoring and slide-reuse skills installed via slidemind.\n` +
|
|
123
151
|
`\n` +
|
|
124
152
|
`When the user asks to create, plan, or edit a slide deck or presentation, load\n` +
|
|
125
153
|
`the \`slidemind-create-slides\` skill (auto-discovered from\n` +
|
|
126
|
-
`${skillTarget("create-slides")}/SKILL.md) and follow it
|
|
127
|
-
|
|
128
|
-
`
|
|
154
|
+
`${skillTarget("create-slides")}/SKILL.md) and follow it.\n` +
|
|
155
|
+
`\n` +
|
|
156
|
+
`When the user asks to find or reuse an existing slide or brand image — from the\n` +
|
|
157
|
+
`corporate design's engines or from a \`.pptx\` on disk — load the\n` +
|
|
158
|
+
`\`slidemind-search\` skill (${skillTarget("search")}/SKILL.md) instead.\n` +
|
|
159
|
+
`\n` +
|
|
160
|
+
`Both drive the slidemind PowerPoint add-in and read every brand rule — tokens,\n` +
|
|
161
|
+
`layout palette, brand imagery — from the design bundle the add-in serves.\n` +
|
|
129
162
|
`\n` +
|
|
130
163
|
`Build with: \`slidemind build <deck>\` (HTML) or \`slidemind build-pptx <deck>\` (PPTX).\n` +
|
|
131
164
|
`${AGENTS_MARK_END}\n`;
|
|
@@ -155,6 +188,20 @@ function clearAgentsBlock() {
|
|
|
155
188
|
fs.writeFileSync(file, existing);
|
|
156
189
|
}
|
|
157
190
|
|
|
191
|
+
// See claude.mjs `detect()` — same two-signal contract, Codex roots.
|
|
192
|
+
export function detect() {
|
|
193
|
+
const root = codexRoot();
|
|
194
|
+
const configured = fs.existsSync(root);
|
|
195
|
+
const onPath = binaryOnPath("codex");
|
|
196
|
+
return {
|
|
197
|
+
root,
|
|
198
|
+
configured,
|
|
199
|
+
onPath,
|
|
200
|
+
present: configured || onPath,
|
|
201
|
+
pluginInstalled: pluginInstallDetected(),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
export async function install({ packageRoot, flags }) {
|
|
159
206
|
const link = isLinkMode(flags);
|
|
160
207
|
const force = isForce(flags);
|
|
@@ -202,6 +249,15 @@ export async function install({ packageRoot, flags }) {
|
|
|
202
249
|
ui.step({ status: "remove", label: "mcp server", value: mcpNote });
|
|
203
250
|
}
|
|
204
251
|
|
|
252
|
+
if (pluginInstallDetected()) {
|
|
253
|
+
ui.step({
|
|
254
|
+
status: "info",
|
|
255
|
+
label: "plugin",
|
|
256
|
+
value:
|
|
257
|
+
"the slidemind Codex plugin is also installed — these copied skills duplicate its skills; plugin users only need `npm install -g slidemind` and can drop them with `slidemind uninstall --for codex`",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
205
261
|
ui.note("restart Codex to pick up the skill");
|
|
206
262
|
}
|
|
207
263
|
|