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.
- package/.claude-plugin/plugin.json +23 -0
- package/CLAUDE.md +137 -0
- package/README.md +100 -0
- package/bin/slidemind.mjs +603 -0
- package/package.json +62 -0
- package/skills/create-slides/SKILL.md +108 -0
- package/src/bridge/build-and-push.mjs +232 -0
- package/src/bridge/bundle.mjs +373 -0
- package/src/bridge/client.mjs +338 -0
- package/src/builders/build.mjs +724 -0
- package/src/builders/lint-slides.mjs +796 -0
- package/src/builders/primitives.json +19 -0
- package/src/builders/resolve-skill.mjs +205 -0
- package/src/builders/runtime-applier.js +275 -0
- package/src/installers/brand-theme.mjs +38 -0
- package/src/installers/claude.mjs +141 -0
- package/src/installers/codex.mjs +229 -0
- package/src/installers/lib.mjs +435 -0
- package/src/installers/opencode.mjs +181 -0
- package/src/installers/tui.mjs +125 -0
- package/src/mcp/recolor.mjs +111 -0
- package/src/mcp/server.mjs +476 -0
|
@@ -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
|
+
|