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,181 @@
1
+ // Install slidemind into opencode.
2
+ //
3
+ // opencode auto-discovers user-level skills at
4
+ // `~/.config/opencode/skills/<name>/SKILL.md` (parity with Claude Code's
5
+ // `~/.claude/skills/` layout). We copy each skill there with the
6
+ // `slidemind-` namespace prefix so opencode loads it via SKILL.md
7
+ // frontmatter, exactly like the other harnesses.
8
+ //
9
+ // Layout after install:
10
+ // ~/.config/opencode/skills/slidemind-create-slides/SKILL.md + assets
11
+ //
12
+ // Earlier versions modelled the skill as an opencode "agent" (because
13
+ // opencode didn't have native skills at the time) and stashed the
14
+ // payload at `~/.config/opencode/slidemind/`. We actively remove both
15
+ // on every install so upgraders converge to the correct layout.
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import {
20
+ expandHome,
21
+ installDir,
22
+ isForce,
23
+ isLinkMode,
24
+ listSkills,
25
+ rmIfExists,
26
+ ui,
27
+ } from "./lib.mjs";
28
+
29
+ const NAMESPACE = "slidemind";
30
+
31
+ function opencodeRoot() {
32
+ return expandHome("~/.config/opencode");
33
+ }
34
+
35
+ function skillsRoot() {
36
+ return path.join(opencodeRoot(), "skills");
37
+ }
38
+
39
+ function skillTarget(skillName) {
40
+ return path.join(skillsRoot(), `${NAMESPACE}-${skillName}`);
41
+ }
42
+
43
+ // Pre-skills layout. Removed on every install/uninstall.
44
+ function legacySkillsRoot() {
45
+ return path.join(opencodeRoot(), NAMESPACE);
46
+ }
47
+ function legacyAgentDir() {
48
+ return path.join(opencodeRoot(), "agent");
49
+ }
50
+ function legacyCommandDir() {
51
+ return path.join(opencodeRoot(), "command");
52
+ }
53
+
54
+ function opencodeConfigFile() {
55
+ return path.join(opencodeRoot(), "opencode.json");
56
+ }
57
+
58
+ function loadOpencodeConfig() {
59
+ const file = opencodeConfigFile();
60
+ if (!fs.existsSync(file)) return {};
61
+ try {
62
+ return JSON.parse(fs.readFileSync(file, "utf8")) || {};
63
+ } catch {
64
+ throw new Error(`opencode config at ${file} is not valid JSON; refusing to overwrite.`);
65
+ }
66
+ }
67
+
68
+ function saveOpencodeConfig(cfg) {
69
+ const file = opencodeConfigFile();
70
+ fs.mkdirSync(path.dirname(file), { recursive: true });
71
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
72
+ }
73
+
74
+ function unregisterMcpServer() {
75
+ const file = opencodeConfigFile();
76
+ if (!fs.existsSync(file)) return;
77
+ const cfg = loadOpencodeConfig();
78
+ if (cfg.mcp && cfg.mcp[NAMESPACE]) {
79
+ delete cfg.mcp[NAMESPACE];
80
+ if (Object.keys(cfg.mcp).length === 0) delete cfg.mcp;
81
+ saveOpencodeConfig(cfg);
82
+ }
83
+ }
84
+
85
+ function registerMcpServer(packageRoot) {
86
+ const mcpServer = path.join(packageRoot, "src", "mcp", "server.mjs");
87
+ const cfg = loadOpencodeConfig();
88
+ if (!cfg["$schema"]) cfg["$schema"] = "https://opencode.ai/config.json";
89
+ cfg.mcp = cfg.mcp || {};
90
+ cfg.mcp[NAMESPACE] = {
91
+ type: "local",
92
+ command: ["node", mcpServer],
93
+ enabled: true,
94
+ };
95
+ saveOpencodeConfig(cfg);
96
+ }
97
+
98
+ function removeLegacyArtifacts() {
99
+ // Old `~/.config/opencode/slidemind/` payload directory.
100
+ if (fs.existsSync(legacySkillsRoot())) {
101
+ rmIfExists(legacySkillsRoot());
102
+ ui.step({ status: "remove", label: "legacy", value: legacySkillsRoot() });
103
+ }
104
+ // Old `~/.config/opencode/agent/slidemind-*.md` agent shims.
105
+ const agentDir = legacyAgentDir();
106
+ if (fs.existsSync(agentDir)) {
107
+ for (const entry of fs.readdirSync(agentDir)) {
108
+ if (entry.startsWith(`${NAMESPACE}-`) && entry.endsWith(".md")) {
109
+ const target = path.join(agentDir, entry);
110
+ rmIfExists(target);
111
+ ui.step({ status: "remove", label: "legacy agent", value: target });
112
+ }
113
+ }
114
+ }
115
+ // Old `~/.config/opencode/command/slidemind-*.md` command files.
116
+ const commandDir = legacyCommandDir();
117
+ if (fs.existsSync(commandDir)) {
118
+ for (const entry of fs.readdirSync(commandDir)) {
119
+ if (entry.startsWith(`${NAMESPACE}-`) && entry.endsWith(".md")) {
120
+ const target = path.join(commandDir, entry);
121
+ rmIfExists(target);
122
+ ui.step({ status: "remove", label: "legacy command", value: target });
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ export async function install({ packageRoot, flags }) {
129
+ const link = isLinkMode(flags);
130
+ const force = isForce(flags);
131
+
132
+ await ui.section("opencode", opencodeRoot());
133
+
134
+ // Drop pre-skills layout (agent shims, command files, the old
135
+ // ~/.config/opencode/slidemind/ payload) — opencode now scans
136
+ // ~/.config/opencode/skills/ natively.
137
+ removeLegacyArtifacts();
138
+
139
+ // Skill files: copy/symlink each skill into
140
+ // ~/.config/opencode/skills/slidemind-<name>/ so opencode auto-discovers
141
+ // them via SKILL.md frontmatter.
142
+ fs.mkdirSync(skillsRoot(), { recursive: true });
143
+ for (const skill of listSkills(packageRoot)) {
144
+ const dest = skillTarget(skill.name);
145
+ await ui.spin(
146
+ {
147
+ label: "skill",
148
+ value: dest,
149
+ note: link ? "linking" : "copying",
150
+ finalNote: link ? "linked" : "copied",
151
+ },
152
+ () => installDir({ src: skill.path, dest, link, force }),
153
+ );
154
+ }
155
+
156
+ // Register the slidemind MCP server so agents can list/push into open
157
+ // PowerPoint windows via slidemind_list_open_presentations / slidemind_push_deck.
158
+ registerMcpServer(packageRoot);
159
+ ui.step({ status: "ok", label: "mcp server", value: opencodeConfigFile() });
160
+ }
161
+
162
+ export async function uninstall() {
163
+ await ui.section("opencode", opencodeRoot());
164
+
165
+ removeLegacyArtifacts();
166
+
167
+ // Remove our installed skills (only the ones we own — leave other
168
+ // entries in ~/.config/opencode/skills/ alone).
169
+ if (fs.existsSync(skillsRoot())) {
170
+ for (const entry of fs.readdirSync(skillsRoot())) {
171
+ if (entry.startsWith(`${NAMESPACE}-`)) {
172
+ const target = path.join(skillsRoot(), entry);
173
+ rmIfExists(target);
174
+ ui.step({ status: "remove", label: "skill", value: target });
175
+ }
176
+ }
177
+ }
178
+
179
+ unregisterMcpServer();
180
+ ui.step({ status: "info", label: "mcp server", value: "unregistered" });
181
+ }
@@ -0,0 +1,125 @@
1
+ // Minimal clack-style TUI primitives, but with full colour control over
2
+ // every glyph. Replaces @clack/prompts where we need to paint the rail
3
+ // itself (gradient) and animate text mid-line (harness shimmer) — both
4
+ // of which clack hides behind util.styleText named-colour calls.
5
+ //
6
+ // API mirrors the slice of clack we used:
7
+ // intro(text)
8
+ // message(text, { symbol })
9
+ // outro(text)
10
+ // Plus internal row tracking so a top→bottom gradient can be applied to
11
+ // the rail glyphs.
12
+
13
+ import { brand } from "./brand-theme.mjs";
14
+
15
+ const FORCE_COLOR =
16
+ process.env.FORCE_COLOR === "1" || process.env.FORCE_COLOR === "true";
17
+ const NO_COLOR =
18
+ !!process.env.NO_COLOR ||
19
+ (!FORCE_COLOR && (!process.stdout.isTTY || process.env.TERM === "dumb"));
20
+
21
+ const FG_RESET = "\x1b[39m";
22
+ const FADE_ROWS = 28; // rail finishes fading after this many emitted rows
23
+
24
+ let _row = 0;
25
+
26
+ // Linear interpolate between #9999FF (techBlue40) and #6C7D8C (slate).
27
+ // Top of the rail is bright tech-blue, fading to brand slate as the
28
+ // install progresses — feels like the deck's tech-blue gradation card.
29
+ function railEscape(row) {
30
+ if (NO_COLOR) return "";
31
+ const t = Math.min(row / FADE_ROWS, 1);
32
+ const r = Math.round(153 + (108 - 153) * t);
33
+ const g = Math.round(153 + (125 - 153) * t);
34
+ const b = Math.round(255 + (140 - 255) * t);
35
+ return `\x1b[38;2;${r};${g};${b}m`;
36
+ }
37
+
38
+ function paint(row, glyph) {
39
+ return NO_COLOR ? glyph : railEscape(row) + glyph + FG_RESET;
40
+ }
41
+
42
+ export function rail(row = _row) {
43
+ return paint(row, "│");
44
+ }
45
+
46
+ export function getRow() {
47
+ return _row;
48
+ }
49
+ export function bumpRow(n = 1) {
50
+ _row += n;
51
+ }
52
+
53
+ export function intro(text) {
54
+ _row = 0;
55
+ process.stdout.write("\n" + paint(_row, "┌") + " " + text + "\n");
56
+ }
57
+
58
+ // Each message starts with its own gradient-coloured rail spacer, then
59
+ // the glyph + text. The caller supplies `symbol` for non-rail glyphs
60
+ // (◆ ● ▲ ■ etc.); when omitted we fall back to a continuation rail.
61
+ export function message(text, { symbol } = {}) {
62
+ _row += 1;
63
+ process.stdout.write(paint(_row, "│") + "\n");
64
+ _row += 1;
65
+ const sym = symbol ?? paint(_row, "│");
66
+ process.stdout.write(sym + " " + text + "\n");
67
+ }
68
+
69
+ export function outro(text) {
70
+ _row += 1;
71
+ process.stdout.write(paint(_row, "│") + "\n");
72
+ _row += 1;
73
+ process.stdout.write(paint(_row, "└") + " " + text + "\n\n");
74
+ _row = 0;
75
+ }
76
+
77
+ // Reserve the next two rows for a spin step: emits the rail spacer,
78
+ // returns the row index the spinner is written on. Callers should
79
+ // `process.stdout.write(...)` the spinner content themselves (without a
80
+ // trailing `\n`) so they can overwrite it via `\r`. When the operation
81
+ // completes they call `commitSpinRow()` after writing the final line.
82
+ export function reserveSpinRows() {
83
+ _row += 1;
84
+ process.stdout.write(paint(_row, "│") + "\n");
85
+ _row += 1;
86
+ return _row;
87
+ }
88
+
89
+ const ANIMATE = !NO_COLOR && process.stdout.isTTY;
90
+
91
+ // Sweep a brightness "highlight" left-to-right across `text`, settling
92
+ // on `restColor(text)` at the end. The caller has already positioned
93
+ // the cursor where the text should appear; this helper writes only the
94
+ // text region (no rail, no symbol) and uses `\r\x1b[<col>G` to reset to
95
+ // that column for each redraw.
96
+ export async function shimmer(text, { startCol = 1, restColor }) {
97
+ const final = brand.bold(restColor(text));
98
+ if (!ANIMATE) {
99
+ process.stdout.write(final);
100
+ return;
101
+ }
102
+ const len = text.length;
103
+ const sweepRange = len + 6; // start the wave just before the text
104
+ const frames = 14;
105
+ const frameMs = 45;
106
+
107
+ for (let f = 0; f <= frames; f++) {
108
+ const pos = (f / frames) * sweepRange - 3;
109
+ let line = "";
110
+ for (let i = 0; i < len; i++) {
111
+ const dist = i - pos;
112
+ const a = Math.abs(dist);
113
+ let painter;
114
+ if (a <= 1) painter = brand.techBlue20; // peak brightness
115
+ else if (a <= 2.5) painter = brand.techBlue40;
116
+ else if (a <= 4) painter = brand.slate;
117
+ else painter = (s) => brand.dim(brand.slate(s));
118
+ line += brand.bold(painter(text[i]));
119
+ }
120
+ process.stdout.write(`\x1b[${startCol}G\x1b[K` + line);
121
+ if (f < frames) await new Promise((r) => setTimeout(r, frameMs));
122
+ }
123
+ // Final settle.
124
+ process.stdout.write(`\x1b[${startCol}G\x1b[K` + final);
125
+ }
@@ -0,0 +1,111 @@
1
+ // Describe how an engine result can be recoloured, so the agent never has to
2
+ // open the SVG to find out (the exact step that was wasting time: "let me
3
+ // inspect two SVGs to confirm how recolouring works").
4
+ //
5
+ // The slidemind_download_result tool already has the chosen result's bytes in
6
+ // hand (the bridge returns `svgMarkup` for SVG results), so we can classify it
7
+ // once and report the answer instead of leaving the agent to guess.
8
+ //
9
+ // mode:
10
+ // "currentColor" — paints with fill/stroke="currentColor". A CSS `color`
11
+ // on the <img> tints it live in BOTH the HTML preview
12
+ // (which inlines the SVG, see src/builders/build.mjs) and
13
+ // the .pptx render (which substitutes currentColor). You
14
+ // can also bake a fixed colour in with icon_color.
15
+ // "mono" — a single hard-coded paint colour (or the SVG default,
16
+ // black) and no currentColor. CSS `color` will NOT touch
17
+ // it — recolour by re-downloading with icon_color, which
18
+ // makes the bridge rewrite the fill.
19
+ // "multicolor" — several distinct paint colours. Leave it as-is; passing
20
+ // icon_color would flatten a deliberately multi-tone glyph.
21
+ // "raster" — PNG/JPEG/etc. Colour is not applicable.
22
+
23
+ function isSvg(mimeType, svgMarkup) {
24
+ if (typeof svgMarkup === "string" && /<svg[\s>]/i.test(svgMarkup)) return true;
25
+ return /svg/i.test(String(mimeType || ""));
26
+ }
27
+
28
+ // Collect the distinct, concrete paint colours an SVG declares — via fill="…"
29
+ // / stroke="…" attributes and `fill:`/`stroke:` style properties. `none`,
30
+ // `transparent`, and `currentColor` are not concrete colours and are excluded.
31
+ function distinctPaints(svgMarkup) {
32
+ const colors = new Set();
33
+ const re =
34
+ /(?:fill|stroke)\s*[:=]\s*["']?\s*(#[0-9a-fA-F]{3,8}|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-zA-Z]+)/g;
35
+ let m;
36
+ while ((m = re.exec(svgMarkup)) !== null) {
37
+ const raw = m[1].trim().toLowerCase();
38
+ if (raw === "none" || raw === "transparent" || raw === "currentcolor") {
39
+ continue;
40
+ }
41
+ // Bare keyword that isn't a real colour name (e.g. "url" from url(#grad))
42
+ // would over-count; only keep hex/rgb/hsl and a small set of plain names.
43
+ if (/^#|^rgb|^hsl/.test(raw) || NAMED_COLORS.has(raw)) {
44
+ colors.add(raw);
45
+ }
46
+ }
47
+ return colors;
48
+ }
49
+
50
+ const NAMED_COLORS = new Set([
51
+ "black", "white", "red", "green", "blue", "yellow", "orange", "purple",
52
+ "pink", "gray", "grey", "cyan", "magenta", "brown", "navy", "teal",
53
+ "silver", "gold", "maroon", "olive", "lime", "aqua", "fuchsia",
54
+ ]);
55
+
56
+ function hasCurrentColor(svgMarkup) {
57
+ return /(?:fill|stroke)\s*[:=]\s*["']?\s*currentColor/i.test(svgMarkup);
58
+ }
59
+
60
+ // Build the descriptor attached to a download result. `appliedColor` is the
61
+ // hex the caller asked the bridge to bake in (null if none) — when present the
62
+ // returned bytes are already coloured.
63
+ export function describeRecolor(svgMarkup, mimeType, appliedColor = null) {
64
+ const applied = Boolean(appliedColor) && isSvg(mimeType, svgMarkup);
65
+
66
+ if (!isSvg(mimeType, svgMarkup)) {
67
+ return {
68
+ recolorable: false,
69
+ mode: "raster",
70
+ applied: false,
71
+ color: null,
72
+ note: "Raster image — recolouring not applicable.",
73
+ };
74
+ }
75
+
76
+ const markup = typeof svgMarkup === "string" ? svgMarkup : "";
77
+ let mode;
78
+ if (hasCurrentColor(markup)) {
79
+ mode = "currentColor";
80
+ } else {
81
+ const paints = distinctPaints(markup);
82
+ mode = paints.size >= 2 ? "multicolor" : "mono";
83
+ }
84
+
85
+ const recolorable = mode === "currentColor" || mode === "mono";
86
+
87
+ let note;
88
+ if (applied) {
89
+ note =
90
+ `Saved already coloured ${appliedColor} — reference it directly; ` +
91
+ `the colour is baked into the file (correct in preview and render).`;
92
+ } else if (mode === "currentColor") {
93
+ note =
94
+ "Tintable: set CSS `color` on the <img> (honoured in both the HTML " +
95
+ "preview and the .pptx render), or pass icon_color to bake a fixed colour in.";
96
+ } else if (mode === "mono") {
97
+ note =
98
+ "Single-colour glyph: CSS `color` won't tint it — re-download with " +
99
+ "icon_color (a hex resolved from a corporate-design.css token) to recolour.";
100
+ } else {
101
+ note = "Multi-colour glyph: leave as-is; icon_color would flatten it.";
102
+ }
103
+
104
+ return {
105
+ recolorable,
106
+ mode,
107
+ applied,
108
+ color: applied ? appliedColor : null,
109
+ note,
110
+ };
111
+ }