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.
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": 1,
3
+ "description": "The closed registry of components a slide JSX file may compose. The build-time linter rejects any PascalCase JSX tag that is not in this file. Layouts are the only allowed top-level slide component; primitives may appear inside body content of freeform layouts (SlideContent*Blank, SlideQuote*Blank).",
4
+ "layouts": [
5
+ "SlideCover",
6
+ "SlideAgendaBlack",
7
+ "SlideAgendaBlue",
8
+ "SlideChapterBlack",
9
+ "SlideChapterBlue",
10
+ "SlideClosing",
11
+ "SlideContentDarkBlank",
12
+ "SlideContentLightBlank",
13
+ "SlideQuoteBlack",
14
+ "SlideQuoteBlackBlank",
15
+ "SlideQuoteBlue",
16
+ "SlideQuoteBlueBlank"
17
+ ],
18
+ "primitives": []
19
+ }
@@ -0,0 +1,205 @@
1
+ // Discover which skill a deck was authored against, and return the paths the
2
+ // rest of the local preview pipeline (build.mjs, lint-slides.mjs) needs to
3
+ // load layouts, tokens, primitives and assets from.
4
+ //
5
+ // Post-connect, the corporate design (css, layouts, assets) is no longer
6
+ // shipped inside the skill — it lives in the slidemind add-in and is
7
+ // materialized per-CD into a local bundle cache. resolveSkill now resolves
8
+ // those design roots from (in order): the deck-local override, the materialized
9
+ // bundle (keyed by the deck's .bundle-ref.json), then any legacy in-package
10
+ // skill files (back-compat for decks authored before the connect migration).
11
+ //
12
+ // The canonical .pptx render happens SERVER-SIDE in the add-in; these paths
13
+ // only feed the local HTML/editor preview.
14
+ //
15
+ // Lookup order for a deck's skill name:
16
+ // 1. `decks/<slug>/plan.md` frontmatter — `skill:` field.
17
+ // 2. Default: `create-slides`.
18
+ //
19
+ // If a `skills/<skill>/skill.json` manifest exists, optional fields it can
20
+ // declare: `mode`, `templates`, `defaultTemplate`, `designSystem.css`.
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ import { resolveBundleDirForDeck } from "../bridge/bundle.mjs";
27
+
28
+ const __filename = fileURLToPath(import.meta.url);
29
+ const __dirname = path.dirname(__filename);
30
+ const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
31
+
32
+ const DEFAULT_SKILL = "create-slides";
33
+
34
+ // Shipped fallback layout-contract registry for the LOCAL HTML preview.
35
+ // The add-in derives layout contracts from the bundle's layouts directly, so
36
+ // this is only consulted by the local preview when neither the deck nor the
37
+ // materialized bundle carries its own primitives.json.
38
+ const SHIPPED_PRIMITIVES = path.join(__dirname, "primitives.json");
39
+
40
+ function firstExisting(candidates) {
41
+ for (const c of candidates) {
42
+ if (c && fs.existsSync(c)) return c;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ // First existing candidate, or `fallback` when none exist. Encodes the
48
+ // deck-override → bundle → skill precedence in one place so every resolved path
49
+ // shares the same lookup rule.
50
+ function firstExistingOr(candidates, fallback) {
51
+ return firstExisting(candidates) ?? fallback;
52
+ }
53
+
54
+ export function resolveSkillForDeck(deckDir) {
55
+ const planMeta = readPlanFrontmatter(deckDir);
56
+ const skillName = planMeta.skill || DEFAULT_SKILL;
57
+ const bundleDir = deckDir ? resolveBundleDirForDeck(deckDir) : null;
58
+ return resolveSkill(skillName, planMeta, { deckDir, bundleDir });
59
+ }
60
+
61
+ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
62
+ const skillDir = path.join(PACKAGE_ROOT, "skills", skillName);
63
+ const manifest = fs.existsSync(skillDir) ? readManifest(skillDir) : {};
64
+ const cssName = manifest.designSystem?.css || "corporate-design.css";
65
+
66
+ // Design roots in precedence order: materialized bundle → (legacy) in-package
67
+ // skill dir. Deck-local overrides are layered on top per-asset below. A root
68
+ // only counts if it actually carries design files — otherwise the now
69
+ // brand-stripped skill dir would mask the "materialize the bundle first"
70
+ // guidance for a deck that hasn't downloaded one yet.
71
+ const bundleDir =
72
+ opts.bundleDir && fs.existsSync(opts.bundleDir) ? opts.bundleDir : null;
73
+ const designRoots = [
74
+ bundleDir,
75
+ fs.existsSync(skillDir) ? skillDir : null,
76
+ ].filter(
77
+ (r) =>
78
+ r &&
79
+ (fs.existsSync(path.join(r, cssName)) ||
80
+ fs.existsSync(path.join(r, "layouts"))),
81
+ );
82
+ if (designRoots.length === 0) {
83
+ throw new Error(
84
+ `No design source for skill "${skillName}". The corporate design now lives ` +
85
+ `in the slidemind add-in — run 'slidemind download-bundle <deck>' to ` +
86
+ `materialize it, then retry.`,
87
+ );
88
+ }
89
+
90
+ const deckLayouts = opts.deckDir ? path.join(opts.deckDir, "layouts") : null;
91
+ const deckAssets = opts.deckDir ? path.join(opts.deckDir, "assets") : null;
92
+ const deckPrimitives = opts.deckDir
93
+ ? path.join(opts.deckDir, "primitives.json")
94
+ : null;
95
+
96
+ const cssFile = firstExistingOr(
97
+ designRoots.map((r) => path.join(r, cssName)),
98
+ path.join(designRoots[0], cssName),
99
+ );
100
+ const layoutsDir = firstExistingOr(
101
+ [deckLayouts, ...designRoots.map((r) => path.join(r, "layouts"))],
102
+ path.join(designRoots[0], "layouts"),
103
+ );
104
+ const layoutsCssFile = firstExistingOr(
105
+ designRoots.map((r) => path.join(r, "layouts", "layouts.css")),
106
+ path.join(designRoots[0], "layouts", "layouts.css"),
107
+ );
108
+ const assetsDir = firstExistingOr(
109
+ [deckAssets, ...designRoots.map((r) => path.join(r, "assets"))],
110
+ path.join(designRoots[0], "assets"),
111
+ );
112
+ const primitivesPath = firstExistingOr(
113
+ [
114
+ deckPrimitives,
115
+ ...designRoots.map((r) => path.join(r, "primitives.json")),
116
+ SHIPPED_PRIMITIVES,
117
+ ],
118
+ SHIPPED_PRIMITIVES,
119
+ );
120
+ // One truth: brand paths mirror the resolved design roots (the bundle IS the
121
+ // brand source; there is no separate brand skill anymore).
122
+ const brand = {
123
+ skillDir: designRoots[0],
124
+ assetsDir,
125
+ cssFile,
126
+ layoutsDir,
127
+ layoutsCssFile,
128
+ primitivesPath,
129
+ };
130
+
131
+ // The PPTX slide-master template only fed the now-archived local exporter;
132
+ // resolve it only if a manifest still declares one and the file exists.
133
+ const mode = manifest.mode || "compose";
134
+ let templatePath = null;
135
+ let templateKey = null;
136
+ if (manifest.templates) {
137
+ templateKey =
138
+ planMeta.template ||
139
+ manifest.defaultTemplate ||
140
+ Object.keys(manifest.templates)[0];
141
+ const rel = manifest.templates[templateKey];
142
+ const candidate =
143
+ rel && fs.existsSync(skillDir) ? path.join(skillDir, rel) : null;
144
+ if (candidate && fs.existsSync(candidate)) templatePath = candidate;
145
+ }
146
+
147
+ return {
148
+ skillName,
149
+ skillDir,
150
+ manifest,
151
+ mode,
152
+ cssFile,
153
+ layoutsDir,
154
+ assetsDir,
155
+ primitivesPath,
156
+ brand,
157
+ templateKey,
158
+ templatePath,
159
+ locale: planMeta.locale || null,
160
+ packageRoot: PACKAGE_ROOT,
161
+ bundleDir,
162
+ };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // plan.md frontmatter
167
+ // ---------------------------------------------------------------------------
168
+
169
+ function readPlanFrontmatter(deckDir) {
170
+ if (!deckDir) return {};
171
+ const file = path.join(deckDir, "plan.md");
172
+ if (!fs.existsSync(file)) return {};
173
+ const text = fs.readFileSync(file, "utf8");
174
+ if (!text.startsWith("---")) return {};
175
+ const end = text.indexOf("\n---", 3);
176
+ if (end < 0) return {};
177
+ const body = text.slice(3, end);
178
+ const out = {};
179
+ for (const line of body.split("\n")) {
180
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.+?)\s*$/);
181
+ if (m) out[m[1]] = stripQuotes(m[2]);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ function stripQuotes(s) {
187
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
188
+ return s.slice(1, -1);
189
+ }
190
+ return s;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // skill.json manifest
195
+ // ---------------------------------------------------------------------------
196
+
197
+ function readManifest(skillDir) {
198
+ const file = path.join(skillDir, "skill.json");
199
+ if (!fs.existsSync(file)) return {};
200
+ try {
201
+ return JSON.parse(fs.readFileSync(file, "utf8"));
202
+ } catch (err) {
203
+ throw new Error(`Invalid skill.json in ${skillDir}: ${err.message}`);
204
+ }
205
+ }
@@ -0,0 +1,275 @@
1
+ // Runtime applier — consumes decks/<slug>/overrides.json and mutates the
2
+ // live slide DOM to reflect styles, positions, hidden chrome, and
3
+ // user-added free text elements.
4
+ //
5
+ // The overlay loads first (at the end of <body>); this file loads in <head>
6
+ // so its global API is ready by the time overlay.js wires callbacks into it.
7
+
8
+ (() => {
9
+ const DECK_ROOT_SEL = "#stage";
10
+
11
+ // Shape: { version: 1, slides: { "<slideName>": { styles, positions, hidden, freeElements } } }
12
+ let overrides = { version: 1, slides: {} };
13
+ let currentSlide = null;
14
+ let navObserver = null;
15
+
16
+ // We observe the stage so overrides re-apply after every React reconcile.
17
+ let observer = null;
18
+ // Drag/resize gestures pause the observer so optimistic inline-style writes
19
+ // aren't clobbered mid-gesture by an override re-apply triggered by the
20
+ // mutations the gesture itself causes. Counted so nested pauses are safe.
21
+ let observerPaused = 0;
22
+
23
+ function ensureObserver() {
24
+ if (observer) return;
25
+ const stage = document.querySelector(DECK_ROOT_SEL);
26
+ if (!stage) return;
27
+ observer = new MutationObserver(() => {
28
+ if (observerPaused > 0) return;
29
+ if (currentSlide) applySlide(currentSlide);
30
+ });
31
+ observer.observe(stage, { childList: true, subtree: true });
32
+ }
33
+
34
+ function pauseObserver() {
35
+ observerPaused++;
36
+ }
37
+ function resumeObserver() {
38
+ observerPaused = Math.max(0, observerPaused - 1);
39
+ }
40
+
41
+ function registerOverrides(nextOverrides) {
42
+ overrides = nextOverrides || { version: 1, slides: {} };
43
+ if (currentSlide) applySlide(currentSlide);
44
+ }
45
+
46
+ function setCurrentSlide(slideName) {
47
+ currentSlide = slideName;
48
+ ensureObserver();
49
+ applySlide(slideName);
50
+ }
51
+
52
+ function watchDeckNav() {
53
+ if (navObserver) return;
54
+ const nav = document.getElementById("nav");
55
+ if (!nav) return;
56
+ const sync = () => {
57
+ const text = nav.textContent || "";
58
+ const match = text.match(/^\d+\s*\/\s*\d+\s+·\s+(\S+)/);
59
+ if (match) setCurrentSlide(match[1]);
60
+ };
61
+ sync();
62
+ navObserver = new MutationObserver(sync);
63
+ navObserver.observe(nav, { childList: true, characterData: true, subtree: true });
64
+ }
65
+
66
+ function applySlide(slideName, rootArg) {
67
+ const root = rootArg || document.querySelector(DECK_ROOT_SEL);
68
+ if (!root) return;
69
+ const slideEl = root.querySelector(".slide") || root;
70
+ if (!slideEl) return;
71
+ const o = overrides.slides ? overrides.slides[slideName] : null;
72
+ // Strip any free elements that don't belong to this slide (cross-slide
73
+ // leftovers from a previous render). Keep only those in the current set.
74
+ const wantedFreeIds = new Set(
75
+ (o && o.freeElements ? o.freeElements.map((f) => f.id) : []),
76
+ );
77
+ for (const fe of root.querySelectorAll("[data-free-id]")) {
78
+ if (!wantedFreeIds.has(fe.getAttribute("data-free-id"))) {
79
+ fe.remove();
80
+ }
81
+ }
82
+
83
+ // Clear ghost inline styles from elements that no longer have an
84
+ // override. Without this, an undo/clear leaves the element pinned at the
85
+ // old absolute position because we only ever *set* properties.
86
+ for (const el of root.querySelectorAll("[data-edit-id]")) {
87
+ if (el.hasAttribute("data-free")) continue;
88
+ const id = el.getAttribute("data-edit-id");
89
+ const hasPos = !!(o && o.positions && o.positions[id]);
90
+ if (!hasPos) {
91
+ el.style.removeProperty("position");
92
+ el.style.removeProperty("left");
93
+ el.style.removeProperty("top");
94
+ el.style.removeProperty("width");
95
+ el.style.removeProperty("height");
96
+ el.style.removeProperty("z-index");
97
+ }
98
+ const hasStyle = !!(o && o.styles && o.styles[id]);
99
+ if (!hasStyle) {
100
+ el.style.removeProperty("color");
101
+ el.style.removeProperty("font-size");
102
+ el.style.removeProperty("font-weight");
103
+ }
104
+ const hidden = !!(o && o.hidden && o.hidden[id]);
105
+ if (!hidden && el.style.display === "none") {
106
+ el.style.removeProperty("display");
107
+ }
108
+ }
109
+
110
+ if (!o) return;
111
+
112
+ // styles — write what's set, clear what isn't (idempotent).
113
+ if (o.styles) {
114
+ for (const editId of Object.keys(o.styles)) {
115
+ const el = findByEditId(root, editId);
116
+ if (!el) continue;
117
+ const s = o.styles[editId];
118
+ if (s.colorToken) el.style.setProperty("color", `var(${s.colorToken})`);
119
+ else el.style.removeProperty("color");
120
+ if (s.fontSizeToken)
121
+ el.style.setProperty("font-size", `var(${s.fontSizeToken})`);
122
+ else el.style.removeProperty("font-size");
123
+ if (s.fontWeight != null)
124
+ el.style.setProperty("font-weight", String(s.fontWeight));
125
+ else el.style.removeProperty("font-weight");
126
+ }
127
+ }
128
+
129
+ // positions — same idempotency rule. Clear any field that's missing so a
130
+ // partial override (e.g. zIndex-only) can't ghost stale left/top.
131
+ if (o.positions) {
132
+ for (const editId of Object.keys(o.positions)) {
133
+ const el = findByEditId(root, editId);
134
+ if (!el) continue;
135
+ const p = o.positions[editId];
136
+ // We assume any override target is drawn on the absolute-positioned
137
+ // .slide canvas; set position so our coords take effect even if
138
+ // the element was laid out by flex/grid inside a Mode B slide.
139
+ el.style.position = "absolute";
140
+ if (p.left != null) el.style.left = p.left + "px";
141
+ else el.style.removeProperty("left");
142
+ if (p.top != null) el.style.top = p.top + "px";
143
+ else el.style.removeProperty("top");
144
+ if (p.width != null) el.style.width = p.width + "px";
145
+ else el.style.removeProperty("width");
146
+ if (p.height != null) el.style.height = p.height + "px";
147
+ else el.style.removeProperty("height");
148
+ if (p.zIndex != null) el.style.zIndex = String(p.zIndex);
149
+ else el.style.removeProperty("z-index");
150
+ }
151
+ }
152
+
153
+ // hidden chrome (logos, page marks, etc. that we don't want on this slide)
154
+ if (o.hidden) {
155
+ for (const editId of Object.keys(o.hidden)) {
156
+ const el = findByEditId(root, editId);
157
+ if (!el) continue;
158
+ if (o.hidden[editId]) el.style.display = "none";
159
+ else el.style.removeProperty("display");
160
+ }
161
+ }
162
+
163
+ // free elements
164
+ if (o.freeElements && o.freeElements.length) {
165
+ for (const fe of o.freeElements) {
166
+ const wantImage = fe.kind === "image";
167
+ let el = root.querySelector(`[data-free-id="${cssEscape(fe.id)}"]`);
168
+ // If the kind switched (e.g. text -> image) the existing node has the
169
+ // wrong tagName; drop it so we re-create cleanly.
170
+ if (el) {
171
+ const isImg = el.tagName === "IMG";
172
+ if (wantImage !== isImg) {
173
+ el.remove();
174
+ el = null;
175
+ }
176
+ }
177
+ if (!el) {
178
+ el = document.createElement(wantImage ? "img" : "div");
179
+ el.setAttribute("data-free-id", fe.id);
180
+ el.setAttribute("data-edit-id", fe.id);
181
+ el.setAttribute("data-free", "1");
182
+ if (wantImage) {
183
+ el.setAttribute("data-free-kind", "image");
184
+ el.setAttribute("draggable", "false");
185
+ el.setAttribute("alt", "");
186
+ }
187
+ slideEl.appendChild(el);
188
+ } else if (el.parentNode !== slideEl) {
189
+ // re-parent if React re-created the slide element
190
+ slideEl.appendChild(el);
191
+ }
192
+ el.style.position = "absolute";
193
+ el.style.left = (fe.x ?? 100) + "px";
194
+ el.style.top = (fe.y ?? 100) + "px";
195
+ if (fe.width != null) el.style.width = fe.width + "px";
196
+ if (fe.height != null) el.style.height = fe.height + "px";
197
+ el.style.zIndex = String(fe.zIndex ?? 4);
198
+ el.style.pointerEvents = "auto";
199
+ if (wantImage) {
200
+ // Mode B image free element. PPTX export does NOT support these
201
+ // (build-pptx.py has no body-builder for arbitrary uploads); warn so
202
+ // the user knows the deck won't round-trip 1:1 to PowerPoint.
203
+ if (!el.__warnedImagePptx) {
204
+ console.warn(
205
+ "[editor] image free-element will NOT export to .pptx:",
206
+ { id: fe.id, src: fe.src },
207
+ );
208
+ el.__warnedImagePptx = true;
209
+ }
210
+ el.style.objectFit = "contain";
211
+ // Reset any text-mode bleed-through.
212
+ el.style.color = "";
213
+ el.style.fontSize = "";
214
+ el.style.fontWeight = "";
215
+ el.style.lineHeight = "";
216
+ el.style.fontFamily = "";
217
+ el.style.whiteSpace = "";
218
+ if (fe.src && el.getAttribute("src") !== fe.src) {
219
+ el.setAttribute("src", fe.src);
220
+ }
221
+ } else {
222
+ el.style.color = fe.colorToken
223
+ ? `var(${fe.colorToken})`
224
+ : "var(--fg-2)";
225
+ el.style.fontSize = fe.fontSizeToken
226
+ ? `var(${fe.fontSizeToken})`
227
+ : "var(--fs-body)";
228
+ el.style.fontWeight = fe.fontWeight ? String(fe.fontWeight) : "400";
229
+ el.style.lineHeight = "var(--lh-body, 1.45)";
230
+ el.style.fontFamily = "var(--font-sans)";
231
+ el.style.whiteSpace = "pre-line";
232
+ // Only write textContent if the user isn't actively editing this
233
+ // element (overlay sets contenteditable when editing).
234
+ if (!el.hasAttribute("contenteditable")) {
235
+ el.textContent = fe.text || "";
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
241
+
242
+ function findByEditId(root, editId) {
243
+ // Prefer the first visible match. CSS.escape handles slashes and colons
244
+ // in our id scheme.
245
+ return root.querySelector(`[data-edit-id="${cssEscape(editId)}"]`);
246
+ }
247
+
248
+ function cssEscape(s) {
249
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
250
+ return CSS.escape(s);
251
+ }
252
+ return String(s).replace(/["\\]/g, "\\$&");
253
+ }
254
+
255
+ // Public API for overlay.js
256
+ window.SlideEditor = window.SlideEditor || {};
257
+ window.SlideEditor.registerOverrides = registerOverrides;
258
+ window.SlideEditor.setCurrentSlide = setCurrentSlide;
259
+ window.SlideEditor.getOverrides = () => overrides;
260
+ window.SlideEditor.applySlide = applySlide;
261
+ window.SlideEditor.applyOverridesTo = (root, slideName) =>
262
+ applySlide(slideName, root);
263
+ window.SlideEditor.findByEditId = findByEditId;
264
+ window.SlideEditor.pauseObserver = pauseObserver;
265
+ window.SlideEditor.resumeObserver = resumeObserver;
266
+
267
+ if (window.__SLIDE_EDITOR_OVERRIDES__) {
268
+ registerOverrides(window.__SLIDE_EDITOR_OVERRIDES__);
269
+ }
270
+ if (document.readyState === "loading") {
271
+ document.addEventListener("DOMContentLoaded", watchDeckNav);
272
+ } else {
273
+ watchDeckNav();
274
+ }
275
+ })();
@@ -0,0 +1,38 @@
1
+ // Brand truecolor helpers for the installer UI.
2
+ //
3
+ // Tokens lifted directly from skills/create-slides/corporate-design.css
4
+ // so the CLI palette matches the deck output. We bypass clack's own
5
+ // theming (which is locked to util.styleText named colors at link time)
6
+ // by injecting pre-colored glyphs through clack's `log.message({ symbol })`
7
+ // hook instead.
8
+
9
+ const FORCE_COLOR =
10
+ process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true";
11
+ const NO_COLOR =
12
+ !!process.env.NO_COLOR ||
13
+ (!FORCE_COLOR && (!process.stdout.isTTY || process.env.TERM === "dumb"));
14
+
15
+ const tc = (r, g, b) => (s) =>
16
+ NO_COLOR ? String(s) : `\x1b[38;2;${r};${g};${b}m${s}\x1b[39m`;
17
+
18
+ const fmt = (open, close) => (s) =>
19
+ NO_COLOR ? String(s) : `\x1b[${open}m${s}\x1b[${close}m`;
20
+
21
+ // Brand palette (corporate-design.css → :root)
22
+ export const brand = {
23
+ techBlue: tc(0, 0, 255), // #0000FF primary
24
+ techBlue80: tc(51, 51, 255), // #3333FF
25
+ techBlue40: tc(153, 153, 255), // #9999FF
26
+ techBlue20: tc(204, 204, 255), // #CCCCFF
27
+ black: tc(0, 0, 0), // #000000
28
+ ink: tc(40, 52, 64), // #283440 body text / dk2
29
+ slate: tc(108, 125, 140), // #6C7D8C secondary surfaces
30
+ gray: tc(182, 189, 204), // #B6BDCC tertiary
31
+ fog: tc(235, 240, 242), // #EBF0F2
32
+ magenta: tc(254, 13, 108), // #FE0D6C traffic-light / underline
33
+ green: tc(0, 200, 0), // #00C800 emphasis only
34
+ bold: fmt(1, 22),
35
+ dim: fmt(2, 22),
36
+ };
37
+
38
+ export const ANIMATE = !NO_COLOR && process.stdout.isTTY;
@@ -0,0 +1,141 @@
1
+ // Install slidemind into Claude Code.
2
+ //
3
+ // Claude Code auto-discovers user-level skills at
4
+ // `~/.claude/skills/<name>/SKILL.md`. The skill itself owns the user-facing
5
+ // plan / create / edit workflow, so we no longer ship separate slash commands
6
+ // — one skill, one source of truth.
7
+ //
8
+ // Layout after install:
9
+ // ~/.claude/skills/slidemind-create-slides/SKILL.md + assets, layouts, icons, references, …
10
+ //
11
+ // We also actively unregister any prior `commands/slidemind/` directory and
12
+ // any `mcpServers.slidemind` entry from a past install so re-running converges.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import {
17
+ expandHome,
18
+ installDir,
19
+ isForce,
20
+ isLinkMode,
21
+ listSkills,
22
+ rmIfExists,
23
+ ui,
24
+ } from "./lib.mjs";
25
+
26
+ const NAMESPACE = "slidemind";
27
+
28
+ function claudeRoot() {
29
+ return expandHome("~/.claude");
30
+ }
31
+
32
+ function claudeUserConfigFile() {
33
+ // User-scope MCP servers live in ~/.claude.json (NOT ~/.claude/settings.json).
34
+ // The file is large and contains unrelated state, so we read-modify-write
35
+ // exactly the mcpServers.slidemind entry.
36
+ return expandHome("~/.claude.json");
37
+ }
38
+
39
+ function legacyCommandsDir() {
40
+ return path.join(claudeRoot(), "commands", NAMESPACE);
41
+ }
42
+
43
+ function skillTarget(skillName) {
44
+ return path.join(claudeRoot(), "skills", `${NAMESPACE}-${skillName}`);
45
+ }
46
+
47
+ function loadClaudeConfig() {
48
+ const file = claudeUserConfigFile();
49
+ if (!fs.existsSync(file)) return {};
50
+ try {
51
+ return JSON.parse(fs.readFileSync(file, "utf8")) || {};
52
+ } catch {
53
+ throw new Error(
54
+ `Claude config at ${file} is not valid JSON; refusing to overwrite.`,
55
+ );
56
+ }
57
+ }
58
+
59
+ function saveClaudeConfig(cfg) {
60
+ const file = claudeUserConfigFile();
61
+ fs.mkdirSync(path.dirname(file), { recursive: true });
62
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
63
+ }
64
+
65
+ function unregisterMcpServer() {
66
+ const file = claudeUserConfigFile();
67
+ if (!fs.existsSync(file)) return;
68
+ const cfg = loadClaudeConfig();
69
+ if (cfg.mcpServers && cfg.mcpServers[NAMESPACE]) {
70
+ delete cfg.mcpServers[NAMESPACE];
71
+ if (Object.keys(cfg.mcpServers).length === 0) delete cfg.mcpServers;
72
+ saveClaudeConfig(cfg);
73
+ }
74
+ }
75
+
76
+ function registerMcpServer(packageRoot) {
77
+ const mcpServer = path.join(packageRoot, "src", "mcp", "server.mjs");
78
+ const cfg = loadClaudeConfig();
79
+ cfg.mcpServers = cfg.mcpServers || {};
80
+ cfg.mcpServers[NAMESPACE] = {
81
+ type: "stdio",
82
+ command: "node",
83
+ args: [mcpServer],
84
+ };
85
+ saveClaudeConfig(cfg);
86
+ }
87
+
88
+ export async function install({ packageRoot, flags }) {
89
+ const link = isLinkMode(flags);
90
+ const force = isForce(flags);
91
+
92
+ await ui.section("Claude Code", claudeRoot());
93
+
94
+ // Drop the legacy ~/.claude/commands/slidemind/ directory if a prior install
95
+ // created it — the workflow now lives inside the skill.
96
+ if (fs.existsSync(legacyCommandsDir())) {
97
+ rmIfExists(legacyCommandsDir());
98
+ ui.step({ status: "remove", label: "legacy", value: legacyCommandsDir() });
99
+ }
100
+
101
+ for (const skill of listSkills(packageRoot)) {
102
+ const dest = skillTarget(skill.name);
103
+ await ui.spin(
104
+ {
105
+ label: "skill",
106
+ value: dest,
107
+ note: link ? "linking" : "copying",
108
+ finalNote: link ? "linked" : "copied",
109
+ },
110
+ () => installDir({ src: skill.path, dest, link, force }),
111
+ );
112
+ }
113
+
114
+ // Register the slidemind MCP server so agents can list/push into open
115
+ // PowerPoint windows via slidemind_list_open_presentations / slidemind_push_deck.
116
+ registerMcpServer(packageRoot);
117
+ ui.step({ status: "ok", label: "mcp server", value: claudeUserConfigFile() });
118
+ }
119
+
120
+ export async function uninstall() {
121
+ await ui.section("Claude Code", claudeRoot());
122
+
123
+ if (fs.existsSync(legacyCommandsDir())) {
124
+ rmIfExists(legacyCommandsDir());
125
+ ui.step({ status: "remove", label: "legacy", value: legacyCommandsDir() });
126
+ }
127
+
128
+ const skillsRoot = path.join(claudeRoot(), "skills");
129
+ if (fs.existsSync(skillsRoot)) {
130
+ for (const entry of fs.readdirSync(skillsRoot)) {
131
+ if (entry.startsWith(`${NAMESPACE}-`)) {
132
+ const target = path.join(skillsRoot, entry);
133
+ rmIfExists(target);
134
+ ui.step({ status: "remove", label: "skill", value: target });
135
+ }
136
+ }
137
+ }
138
+
139
+ unregisterMcpServer();
140
+ ui.step({ status: "info", label: "mcp server", value: "unregistered" });
141
+ }