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.
@@ -1,5 +1,5 @@
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
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
- let res;
164
- try {
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: iconColor ?? null,
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
- throw err;
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: describeRecolor(res.svgMarkup, res.mimeType, iconColor ?? null),
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: describeRecolor(null, res.mimeType, iconColor ?? null),
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
- // 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.
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(h) {
11
- return h === "127.0.0.1" || h === "localhost" || h === "::1";
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:" ? !isLoopbackHostname(url.hostname) : undefined,
29
+ url.protocol === "https:"
30
+ ? !isLoopbackHostname(url.hostname)
31
+ : undefined,
34
32
  },
35
33
  (res) => {
36
34
  const chunks = [];
37
- res.on("data", (c) => chunks.push(Buffer.from(c)));
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);
@@ -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 skill installed via slidemind.\n` +
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. The skill drives the\n` +
127
- `slidemind PowerPoint add-in and reads every brand rule — tokens, layout\n` +
128
- `palette, brand imagery — from the design bundle the add-in serves.\n` +
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