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,229 @@
1
+ // Install slidemind into OpenAI Codex.
2
+ //
3
+ // Codex 0.128+ auto-discovers skills at `$CODEX_HOME/skills/<name>/SKILL.md`
4
+ // (mirroring Claude Code at `~/.claude/skills/<name>/SKILL.md`). It loads the
5
+ // frontmatter `description` to decide when to surface them. We:
6
+ //
7
+ // 1. Copy each skill to `~/.codex/skills/slidemind-<skill>/` so codex picks
8
+ // it up natively. The `slidemind-` prefix namespaces our skills and
9
+ // matches the convention used by the Claude installer.
10
+ // 2. Append a slidemind block to `~/.codex/AGENTS.md` documenting the
11
+ // skill name so the agent has a reliable handle.
12
+ //
13
+ // We do NOT install custom prompts to `~/.codex/prompts/` because:
14
+ // - they are officially deprecated in favour of skills, AND
15
+ // - they are broken in codex 0.117+ — see openai/codex#15941. The prompt
16
+ // files land on disk but never appear in the `/prompts:` menu.
17
+ // Past installs may have left stale `slidemind-*.md` files there; we wipe
18
+ // them on every install/uninstall.
19
+ //
20
+ // Earlier versions installed the skill payload at `~/.codex/slidemind/` which
21
+ // Codex never scanned. We delete that legacy directory on every install so
22
+ // upgraders converge to the correct layout.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+ import {
27
+ expandHome,
28
+ installDir,
29
+ isForce,
30
+ isLinkMode,
31
+ listSkills,
32
+ rmIfExists,
33
+ ui,
34
+ } from "./lib.mjs";
35
+
36
+ const NAMESPACE = "slidemind";
37
+ const AGENTS_MARK_BEGIN = "<!-- slidemind:begin -->";
38
+ const AGENTS_MARK_END = "<!-- slidemind:end -->";
39
+ const TOML_MARK_BEGIN = "# slidemind:begin";
40
+ const TOML_MARK_END = "# slidemind:end";
41
+
42
+ function codexRoot() {
43
+ return expandHome("~/.codex");
44
+ }
45
+
46
+ function promptsDir() {
47
+ return path.join(codexRoot(), "prompts");
48
+ }
49
+
50
+ // Sweep every ~/.codex/prompts/slidemind-*.md a past install left behind.
51
+ function removeStalePrompts() {
52
+ const dir = promptsDir();
53
+ if (!fs.existsSync(dir)) return;
54
+ for (const entry of fs.readdirSync(dir)) {
55
+ if (entry.startsWith(`${NAMESPACE}-`) && entry.endsWith(".md")) {
56
+ const target = path.join(dir, entry);
57
+ rmIfExists(target);
58
+ ui.step({ status: "remove", label: "stale prompt", value: target });
59
+ }
60
+ }
61
+ }
62
+
63
+ function skillsRoot() {
64
+ return path.join(codexRoot(), "skills");
65
+ }
66
+
67
+ function skillTarget(skillName) {
68
+ return path.join(skillsRoot(), `${NAMESPACE}-${skillName}`);
69
+ }
70
+
71
+ // Pre-0.128 install layout. Removed on every install/uninstall.
72
+ function legacySkillsRoot() {
73
+ return path.join(codexRoot(), NAMESPACE);
74
+ }
75
+
76
+ function agentsFile() {
77
+ return path.join(codexRoot(), "AGENTS.md");
78
+ }
79
+
80
+ function configTomlFile() {
81
+ return path.join(codexRoot(), "config.toml");
82
+ }
83
+
84
+ function writeMcpServerBlock(packageRoot) {
85
+ const mcpServer = path.join(packageRoot, "src", "mcp", "server.mjs");
86
+ const block =
87
+ `${TOML_MARK_BEGIN}\n` +
88
+ `[mcp_servers.slidemind]\n` +
89
+ `command = "node"\n` +
90
+ `args = ["${mcpServer.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]\n` +
91
+ `${TOML_MARK_END}\n`;
92
+
93
+ const file = configTomlFile();
94
+ let existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
95
+ const re = new RegExp(
96
+ `${TOML_MARK_BEGIN}[\\s\\S]*?${TOML_MARK_END}\\n?`,
97
+ "g",
98
+ );
99
+ if (re.test(existing)) {
100
+ existing = existing.replace(re, "");
101
+ }
102
+ const sep = existing.length && !existing.endsWith("\n") ? "\n\n" : existing.length ? "\n" : "";
103
+ fs.writeFileSync(file, existing + sep + block);
104
+ }
105
+
106
+ function clearMcpServerBlock() {
107
+ const file = configTomlFile();
108
+ if (!fs.existsSync(file)) return;
109
+ let existing = fs.readFileSync(file, "utf8");
110
+ const re = new RegExp(
111
+ `${TOML_MARK_BEGIN}[\\s\\S]*?${TOML_MARK_END}\\n?`,
112
+ "g",
113
+ );
114
+ existing = existing.replace(re, "");
115
+ fs.writeFileSync(file, existing);
116
+ }
117
+
118
+ function writeAgentsBlock() {
119
+ const block =
120
+ `${AGENTS_MARK_BEGIN}\n` +
121
+ `## slidemind\n` +
122
+ `Slide-deck authoring skill installed via slidemind.\n` +
123
+ `\n` +
124
+ `When the user asks to create, plan, or edit a slide deck or presentation, load\n` +
125
+ `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` +
129
+ `\n` +
130
+ `Build with: \`slidemind build <deck>\` (HTML) or \`slidemind build-pptx <deck>\` (PPTX).\n` +
131
+ `${AGENTS_MARK_END}\n`;
132
+
133
+ const file = agentsFile();
134
+ let existing = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
135
+ const re = new RegExp(
136
+ `${AGENTS_MARK_BEGIN}[\\s\\S]*?${AGENTS_MARK_END}\\n?`,
137
+ "g",
138
+ );
139
+ if (re.test(existing)) {
140
+ existing = existing.replace(re, "");
141
+ }
142
+ const sep = existing.length && !existing.endsWith("\n") ? "\n\n" : existing.length ? "\n" : "";
143
+ fs.writeFileSync(file, existing + sep + block);
144
+ }
145
+
146
+ function clearAgentsBlock() {
147
+ const file = agentsFile();
148
+ if (!fs.existsSync(file)) return;
149
+ let existing = fs.readFileSync(file, "utf8");
150
+ const re = new RegExp(
151
+ `${AGENTS_MARK_BEGIN}[\\s\\S]*?${AGENTS_MARK_END}\\n?`,
152
+ "g",
153
+ );
154
+ existing = existing.replace(re, "");
155
+ fs.writeFileSync(file, existing);
156
+ }
157
+
158
+ export async function install({ packageRoot, flags }) {
159
+ const link = isLinkMode(flags);
160
+ const force = isForce(flags);
161
+
162
+ await ui.section("Codex", codexRoot());
163
+
164
+ // Clean up the pre-0.128 layout if it's still on disk — Codex never
165
+ // scanned it, so leaving it would just waste space.
166
+ if (fs.existsSync(legacySkillsRoot())) {
167
+ rmIfExists(legacySkillsRoot());
168
+ ui.step({ status: "remove", label: "legacy", value: legacySkillsRoot() });
169
+ }
170
+
171
+ // Skill files: copy/symlink each skill into ~/.codex/skills/slidemind-<name>/
172
+ // so codex auto-discovers them via SKILL.md frontmatter.
173
+ fs.mkdirSync(skillsRoot(), { recursive: true });
174
+ for (const skill of listSkills(packageRoot)) {
175
+ const dest = skillTarget(skill.name);
176
+ await ui.spin(
177
+ {
178
+ label: "skill",
179
+ value: dest,
180
+ note: link ? "linking" : "copying",
181
+ finalNote: link ? "linked" : "copied",
182
+ },
183
+ () => installDir({ src: skill.path, dest, link, force }),
184
+ );
185
+ }
186
+
187
+ // Wipe any stale slidemind-* prompts a prior install left behind. Codex
188
+ // 0.117+ no longer surfaces ~/.codex/prompts/ entries (openai/codex#15941),
189
+ // and the mechanism is officially deprecated in favour of skills.
190
+ removeStalePrompts();
191
+
192
+ writeAgentsBlock();
193
+ ui.step({ status: "ok", label: "AGENTS.md", value: agentsFile() });
194
+
195
+ // Register the slidemind MCP server so agents can list/push into open
196
+ // PowerPoint windows via slidemind_list_open_presentations / slidemind_push_deck.
197
+ writeMcpServerBlock(packageRoot);
198
+ ui.step({ status: "ok", label: "mcp server", value: configTomlFile() });
199
+
200
+ ui.note("restart Codex to pick up the skill + MCP server");
201
+ }
202
+
203
+ export async function uninstall() {
204
+ await ui.section("Codex", codexRoot());
205
+
206
+ // Remove any prompts a prior install wrote (we no longer install them, but
207
+ // older versions did).
208
+ removeStalePrompts();
209
+ // Remove our installed skills (only the ones we own — leave other entries
210
+ // in ~/.codex/skills/ alone).
211
+ if (fs.existsSync(skillsRoot())) {
212
+ for (const entry of fs.readdirSync(skillsRoot())) {
213
+ if (entry.startsWith(`${NAMESPACE}-`)) {
214
+ const target = path.join(skillsRoot(), entry);
215
+ rmIfExists(target);
216
+ ui.step({ status: "remove", label: "skill", value: target });
217
+ }
218
+ }
219
+ }
220
+ // Drop the legacy ~/.codex/slidemind/ payload if present.
221
+ if (fs.existsSync(legacySkillsRoot())) {
222
+ rmIfExists(legacySkillsRoot());
223
+ ui.step({ status: "remove", label: "legacy", value: legacySkillsRoot() });
224
+ }
225
+ clearAgentsBlock();
226
+ ui.step({ status: "remove", label: "AGENTS.md", value: agentsFile() });
227
+ clearMcpServerBlock();
228
+ ui.step({ status: "info", label: "mcp server", value: "unregistered" });
229
+ }
@@ -0,0 +1,435 @@
1
+ // Shared helpers for the per-harness installers.
2
+ //
3
+ // Every installer copies (or symlinks) the package's `skills/` folder into
4
+ // the harness's expected location. The skill itself owns the user-facing
5
+ // plan / create / edit workflow, so we no longer ship separate slash commands.
6
+ // We support --link (symlink instead of copy) so contributors hacking on
7
+ // slidemind see edits live without re-running install.
8
+
9
+ import { brand, ANIMATE } from "./brand-theme.mjs";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import * as tui from "./tui.mjs";
13
+
14
+ // Brand-colored glyphs injected into clack's log.message via the
15
+ // `symbol` option. Pre-wrapped in our own truecolor escapes
16
+ // (terminated with `\x1b[39m`) so they override whatever 16-color name
17
+ // clack's util.styleText would otherwise apply.
18
+ //
19
+ // Palette is tuned for readability on dark terminals — full-saturation
20
+ // tech blue (#0000FF) and ink (#283440) disappear on dark backgrounds,
21
+ // so we use techBlue40 (#9999FF) as the canonical accent and let bold
22
+ // + terminal default foreground carry the body text.
23
+ const SYM = {
24
+ step: brand.techBlue40("◇"), // section header
25
+ success: brand.techBlue40("◆"), // step complete
26
+ info: brand.techBlue40("●"), // neutral info (same hue, shape distinguishes)
27
+ warn: brand.magenta("▲"), // call-out / heads-up
28
+ error: brand.magenta("■"), // remove / failure
29
+ };
30
+
31
+ const SPINNER_FRAMES = ["◐", "◓", "◑", "◒"];
32
+
33
+ // Brand wordmark in a small ASCII block style. "mind" sits nudged right
34
+ // of "slide" — same vertical-stagger move the brand uses in print.
35
+ const SLIDE_ART = [
36
+ ".--. | o .--| .---.",
37
+ "`--. | | | | |---",
38
+ "`--' `-- ' `--' `---'",
39
+ ];
40
+ // "mind" is offset further right so it visibly overhangs "slide"'s right
41
+ // edge — same vertical-stagger move the brand uses on print decks.
42
+ const MIND_ART = [
43
+ " .-.-. o .--. .--|",
44
+ " | | | | | | | |",
45
+ " ' ' ' ' ' ' `--'",
46
+ ];
47
+ const ART_BLOCK_WIDTH = Math.max(SLIDE_ART[0].length, MIND_ART[0].length); // 34
48
+
49
+ // Three-tier vertical gradient applied to every wordmark frame.
50
+ const WORDMARK_TIERS = [
51
+ (s) => brand.bold(brand.techBlue20(s)), // #CCCCFF — top, brightest
52
+ (s) => brand.techBlue40(s), // #9999FF — middle
53
+ (s) => brand.dim(brand.techBlue40(s)), // dimmed bottom
54
+ ];
55
+
56
+ // Compose one frame of the wordmark at a given separation. `apart` is
57
+ // in [0,1] where 0 = closed (rest) and 1 = maximally apart. Slide and
58
+ // mind drift symmetrically — both move the same number of cols, in
59
+ // opposite directions, off a shared base indent that gives slide
60
+ // enough room to travel left.
61
+ const WORDMARK_BASE_INDENT = 10;
62
+ const WORDMARK_MAX_SHIFT = 8;
63
+
64
+ function wordmarkFrame(apart) {
65
+ const shift = Math.round(apart * WORDMARK_MAX_SHIFT);
66
+ const slideIndent = Math.max(0, WORDMARK_BASE_INDENT - shift);
67
+ const mindIndent = WORDMARK_BASE_INDENT + shift;
68
+
69
+ const slidePad = " ".repeat(slideIndent);
70
+ const mindPad = " ".repeat(mindIndent);
71
+
72
+ const lines = [];
73
+ for (let i = 0; i < SLIDE_ART.length; i++) {
74
+ lines.push(slidePad + WORDMARK_TIERS[i](SLIDE_ART[i]));
75
+ }
76
+ lines.push("");
77
+ for (let i = 0; i < MIND_ART.length; i++) {
78
+ lines.push(mindPad + WORDMARK_TIERS[i](MIND_ART[i]));
79
+ }
80
+ return lines;
81
+ }
82
+
83
+ // Maximum visible width any animation frame can render at.
84
+ const WORDMARK_MAX_WIDTH = WORDMARK_BASE_INDENT + WORDMARK_MAX_SHIFT + MIND_ART[0].length;
85
+
86
+ // Static render — used in non-TTY paths and as the settled fallback.
87
+ function renderStaticWordmark() {
88
+ const cols = process.stdout.columns || 80;
89
+ if (cols < ART_BLOCK_WIDTH + 4) return null;
90
+ return wordmarkFrame(0).join("\n");
91
+ }
92
+
93
+ const CURSOR_HIDE = "\x1b[?25l";
94
+ const CURSOR_SHOW = "\x1b[?25h";
95
+
96
+ // Render the wordmark inline at the start of the install. We tried
97
+ // pinning it via DECSTBM scroll regions so it could animate while the
98
+ // install streamed below, but Terminal.app discards content that
99
+ // scrolls off the top of a scroll region — that lost older install
100
+ // output, which the user (correctly) didn't want. Inline rendering
101
+ // preserves everything in scrollback at the cost of dropping the
102
+ // concurrent animation. Returns a no-op stop fn so callers can stay
103
+ // uniform.
104
+ function startWordmarkAnimation() {
105
+ const lines = wordmarkFrame(0);
106
+ process.stdout.write("\n" + lines.join("\n") + "\n\n");
107
+ return () => {};
108
+ }
109
+
110
+ export function isLinkMode(flags) {
111
+ return flags.includes("--link") || flags.includes("-l");
112
+ }
113
+
114
+ export function isForce(flags) {
115
+ return flags.includes("--force") || flags.includes("-F");
116
+ }
117
+
118
+ export function expandHome(p) {
119
+ if (p.startsWith("~/")) return path.join(process.env.HOME || "", p.slice(2));
120
+ return p;
121
+ }
122
+
123
+ export function rmIfExists(target) {
124
+ if (!fs.existsSync(target) && !isSymlink(target)) return;
125
+ fs.rmSync(target, { recursive: true, force: true });
126
+ }
127
+
128
+ function isSymlink(p) {
129
+ try {
130
+ return fs.lstatSync(p).isSymbolicLink();
131
+ } catch {
132
+ return false;
133
+ }
134
+ }
135
+
136
+ export function ensureDir(dir) {
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ }
139
+
140
+ export function copyDir(src, dest) {
141
+ fs.cpSync(src, dest, { recursive: true });
142
+ }
143
+
144
+ export function linkDir(src, dest) {
145
+ ensureDir(path.dirname(dest));
146
+ fs.symlinkSync(src, dest, "dir");
147
+ }
148
+
149
+ // Install one folder (src) into dest. If link mode, symlink; otherwise copy.
150
+ // Replaces an existing target. Async so a spinner can tick while the copy
151
+ // runs (the skill payload can carry many files).
152
+ export async function installDir({ src, dest, link, force }) {
153
+ const exists = fs.existsSync(dest) || isSymlink(dest);
154
+ if (exists) {
155
+ if (!force) {
156
+ // Replace existing slidemind installs unconditionally — they are
157
+ // generated artifacts. We surface the action so the user sees what
158
+ // happened.
159
+ }
160
+ rmIfExists(dest);
161
+ }
162
+ ensureDir(path.dirname(dest));
163
+ if (link) linkDir(src, dest);
164
+ else await fs.promises.cp(src, dest, { recursive: true });
165
+ return { exists, mode: link ? "linked" : "copied" };
166
+ }
167
+
168
+ // ---------- pretty terminal output ----------
169
+
170
+ export function tildify(p) {
171
+ if (typeof p !== "string") return p;
172
+ const home = process.env.HOME || "";
173
+ if (home && (p === home || p.startsWith(home + path.sep))) {
174
+ return "~" + p.slice(home.length);
175
+ }
176
+ return p;
177
+ }
178
+
179
+ const LABEL_WIDTH = 12;
180
+
181
+ // Visible (printable) length: strips CSI escape sequences before counting
182
+ // so right-aligned timing badges line up correctly even when the line is
183
+ // peppered with colour escapes.
184
+ function visibleLength(s) {
185
+ return String(s).replace(/\x1b\[[0-9;]*m/g, "").length;
186
+ }
187
+
188
+ function termWidth(cap = 72) {
189
+ const cols = process.stdout.columns;
190
+ if (!cols || cols < 40) return cap;
191
+ return Math.min(cols, cap);
192
+ }
193
+
194
+ // Compose the right-hand portion of a step line. Body text stays at the
195
+ // terminal's default foreground (bold-only) so it renders on both light
196
+ // and dark backgrounds. The leading glyph is supplied by clack via
197
+ // `log.message({ symbol })`, so we only return the content here. When
198
+ // `time` is provided (ms), append a right-aligned dim badge so completed
199
+ // spinner steps read like a CI line: "skill ... (0.4s)".
200
+ function fmtStep({ label, value, note, time }) {
201
+ const lab = brand.bold(String(label).padEnd(LABEL_WIDTH));
202
+ const valStr = value ? brand.slate(tildify(value)) : "";
203
+ const noteStr = note ? " " + brand.dim("(" + note + ")") : "";
204
+ const left = `${lab}${valStr}${noteStr}`;
205
+ if (time == null) return left;
206
+
207
+ const timeStr = brand.dim(`${(time / 1000).toFixed(1)}s`);
208
+ if (process.stdout.isTTY && process.stdout.columns) {
209
+ // Clack's log.message prepends "X " (3 cols) before our message, so
210
+ // pad against (columns - 3) to land timing flush against the right edge.
211
+ const target = termWidth(120) - 3;
212
+ const pad = Math.max(2, target - visibleLength(left) - visibleLength(timeStr));
213
+ return left + " ".repeat(pad) + timeStr;
214
+ }
215
+ return left + " " + timeStr;
216
+ }
217
+
218
+ const _sections = []; // recorded for the summary card
219
+ let _startedAt = 0;
220
+ let _stopWordmark = null;
221
+
222
+ export const ui = {
223
+ // `wordmark: false` skips painting the wordmark — used when the caller
224
+ // (e.g. the install.sh bootstrap) already printed it, so we only emit the
225
+ // `v… · action` intro line and continue the rail.
226
+ banner(version, action, { wordmark = true } = {}) {
227
+ _startedAt = Date.now();
228
+ _sections.length = 0;
229
+ const v = brand.dim("v" + version);
230
+ const sep = brand.dim("·");
231
+ const act = brand.bold(action);
232
+ if (!wordmark) {
233
+ tui.intro(`${v} ${sep} ${act}`);
234
+ return;
235
+ }
236
+ const cols = process.stdout.columns || 80;
237
+ if (cols >= ART_BLOCK_WIDTH + 4) {
238
+ // Pin an animated wordmark above the install. Returns a no-op
239
+ // stop fn for static fallbacks, so we always have something to
240
+ // call in `done()`.
241
+ _stopWordmark = startWordmarkAnimation();
242
+ tui.intro(`${v} ${sep} ${act}`);
243
+ } else {
244
+ // Very narrow terminals: skip the wordmark entirely.
245
+ const name = brand.bold(brand.techBlue40("slidemind"));
246
+ tui.intro(`${name} ${brand.dim(version)} ${sep} ${act}`);
247
+ }
248
+ },
249
+ // Async so we can run a left→right shimmer across the harness name
250
+ // before settling on the final colour. Callers must `await` this.
251
+ async section(title, sub) {
252
+ // Capture for the summary card rendered by `done()`.
253
+ _sections.push({ title, sub: sub ? tildify(sub) : "" });
254
+
255
+ if (!ANIMATE) {
256
+ const head = brand.bold(title);
257
+ const subStr = sub ? " " + brand.slate(tildify(sub)) : "";
258
+ tui.message(head + subStr, { symbol: SYM.step });
259
+ return;
260
+ }
261
+
262
+ // Manual emission so we can animate the title in place. Symbol
263
+ // sits at col 1; " " padding lands the title at col 4.
264
+ tui.bumpRow(1);
265
+ process.stdout.write(tui.rail() + "\n");
266
+ tui.bumpRow(1);
267
+ process.stdout.write(SYM.step + " ");
268
+ await tui.shimmer(title, { startCol: 4, restColor: (s) => s });
269
+ if (sub) process.stdout.write(" " + brand.slate(tildify(sub)));
270
+ process.stdout.write("\n");
271
+ },
272
+ step({ status = "ok", label, value, note }) {
273
+ const symbol =
274
+ status === "ok"
275
+ ? SYM.success
276
+ : status === "warn"
277
+ ? SYM.warn
278
+ : status === "remove"
279
+ ? SYM.error
280
+ : SYM.info;
281
+ tui.message(fmtStep({ label, value, note }), { symbol });
282
+ },
283
+ note(msg) {
284
+ tui.message(brand.magenta(msg), { symbol: SYM.warn });
285
+ },
286
+ done(msg) {
287
+ const elapsed = _startedAt
288
+ ? ((Date.now() - _startedAt) / 1000).toFixed(1)
289
+ : null;
290
+ const action = msg;
291
+
292
+ if (_sections.length === 0) {
293
+ // No sections to summarise — fall back to a plain outro. Emit
294
+ // first so it lands inside the still-active scroll region (the
295
+ // wordmark stays pinned), then stop the animation last.
296
+ tui.outro(brand.bold(action) + (elapsed ? ` ${brand.dim(`(${elapsed}s)`)}` : ""));
297
+ if (_stopWordmark) {
298
+ _stopWordmark();
299
+ _stopWordmark = null;
300
+ }
301
+ _startedAt = 0;
302
+ return;
303
+ }
304
+
305
+ // Boxed summary card. Replaces clack's `└ <msg>` outro.
306
+ const W = termWidth(72);
307
+ const inner = W - 2;
308
+ const slate = brand.slate;
309
+ const dim = brand.dim;
310
+ const titleColW = Math.max(
311
+ ..._sections.map((s) => s.title.length),
312
+ 8,
313
+ );
314
+
315
+ const boxLine = (content) => {
316
+ const pad = Math.max(0, inner - visibleLength(content));
317
+ process.stdout.write(slate("│") + content + " ".repeat(pad) + slate("│") + "\n");
318
+ };
319
+
320
+ process.stdout.write("\n");
321
+
322
+ // Top: ╭─ installed ────╮
323
+ const labelText = " installed ";
324
+ const fillerLen = Math.max(2, inner - 1 - labelText.length);
325
+ process.stdout.write(
326
+ slate("╭─") +
327
+ dim(labelText) +
328
+ slate("─".repeat(fillerLen)) +
329
+ slate("╮") +
330
+ "\n",
331
+ );
332
+
333
+ boxLine("");
334
+ for (const sec of _sections) {
335
+ const tit = brand.bold(sec.title.padEnd(titleColW));
336
+ const sub = sec.sub ? slate(sec.sub) : "";
337
+ boxLine(" " + tit + " " + sub);
338
+ }
339
+ boxLine("");
340
+
341
+ const count = _sections.length;
342
+ const stats =
343
+ `${count} ${count === 1 ? "harness" : "harnesses"}` +
344
+ (elapsed ? ` ${dim("·")} ${elapsed}s` : "");
345
+ boxLine(" " + dim(stats));
346
+
347
+ // Bottom: ╰────────╯
348
+ process.stdout.write(slate("╰" + "─".repeat(inner) + "╯") + "\n");
349
+
350
+ process.stdout.write("\n");
351
+ process.stdout.write(`${SYM.success} ${brand.bold(action)}\n`);
352
+
353
+ // Stop the wordmark animation last — that resets the scroll region
354
+ // and shows the cursor. The wordmark itself stays painted at the
355
+ // top of the screen because we don't scroll any further.
356
+ if (_stopWordmark) {
357
+ _stopWordmark();
358
+ _stopWordmark = null;
359
+ }
360
+
361
+ _startedAt = 0;
362
+ _sections.length = 0;
363
+ },
364
+ // Run `fn` while a small dot-circle animates on the step line. The
365
+ // frames carry brand color so the spinner reads as on-brand rather
366
+ // than "random package default". Falls back to a static step in
367
+ // non-TTY contexts (CI logs, piped output).
368
+ async spin({ label, value, note, finalNote }, fn) {
369
+ const start = Date.now();
370
+ if (!ANIMATE) {
371
+ try {
372
+ const result = await fn();
373
+ const time = Date.now() - start;
374
+ tui.message(
375
+ fmtStep({ label, value, note: finalNote ?? note, time }),
376
+ { symbol: SYM.success },
377
+ );
378
+ return result;
379
+ } catch (err) {
380
+ tui.message(
381
+ fmtStep({ label, value, note: "failed" }),
382
+ { symbol: SYM.error },
383
+ );
384
+ throw err;
385
+ }
386
+ }
387
+ // Reserve two rows: rail spacer + the active spinner line. Both
388
+ // count toward the gradient row counter.
389
+ tui.bumpRow(1);
390
+ process.stdout.write(tui.rail() + "\n");
391
+ tui.bumpRow(1);
392
+ process.stdout.write(
393
+ `${brand.techBlue40(SPINNER_FRAMES[0])} ${fmtStep({ label, value, note })}`,
394
+ );
395
+ let frame = 0;
396
+ const timer = setInterval(() => {
397
+ frame = (frame + 1) % SPINNER_FRAMES.length;
398
+ process.stdout.write("\r\x1b[2K");
399
+ process.stdout.write(
400
+ `${brand.techBlue40(SPINNER_FRAMES[frame])} ${fmtStep({ label, value, note })}`,
401
+ );
402
+ }, 80);
403
+ try {
404
+ const result = await fn();
405
+ const time = Date.now() - start;
406
+ clearInterval(timer);
407
+ process.stdout.write("\r\x1b[2K");
408
+ process.stdout.write(
409
+ `${SYM.success} ${fmtStep({ label, value, note: finalNote ?? note, time })}\n`,
410
+ );
411
+ return result;
412
+ } catch (err) {
413
+ clearInterval(timer);
414
+ process.stdout.write("\r\x1b[2K");
415
+ process.stdout.write(
416
+ `${SYM.error} ${fmtStep({ label, value, note: "failed" })}\n`,
417
+ );
418
+ throw err;
419
+ }
420
+ },
421
+ };
422
+
423
+ // Read every skill folder under skills/ and return their names + paths.
424
+ export function listSkills(packageRoot) {
425
+ const skillsDir = path.join(packageRoot, "skills");
426
+ if (!fs.existsSync(skillsDir)) return [];
427
+ return fs
428
+ .readdirSync(skillsDir)
429
+ .filter((name) => {
430
+ const stat = fs.statSync(path.join(skillsDir, name));
431
+ return stat.isDirectory();
432
+ })
433
+ .map((name) => ({ name, path: path.join(skillsDir, name) }));
434
+ }
435
+