@supa-magic/spm 0.3.0 → 0.4.0

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.
Files changed (2) hide show
  1. package/dist/bin/spm.js +1315 -1110
  2. package/package.json +6 -7
package/dist/bin/spm.js CHANGED
@@ -1,1171 +1,1376 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
4
- import { join, dirname, relative, posix, resolve, normalize } from "node:path";
5
- import { parse, stringify } from "yaml";
6
3
  import { execFileSync, execSync, spawn } from "node:child_process";
7
- import { readFile, mkdir, writeFile, rm, readdir } from "node:fs/promises";
4
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, normalize, posix, relative, resolve, sep } from "node:path";
6
+ import { parse, stringify } from "yaml";
8
7
  import { tmpdir } from "node:os";
8
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
9
9
  import { createHash } from "node:crypto";
10
- const registerDoctorCommand = (program2) => {
11
- program2.command("doctor").description("Check project health and compatibility").action(() => {
12
- console.log("Not implemented yet");
13
- });
10
+ //#region src/commands/doctor.ts
11
+ var registerDoctorCommand = (program) => {
12
+ program.command("doctor").description("Check project health and compatibility").action(() => {
13
+ console.log("Not implemented yet");
14
+ });
14
15
  };
15
- const knownProviders = {
16
- claude: ".claude",
17
- cursor: ".cursor/rules",
18
- copilot: ".copilot",
19
- aider: ".aider",
20
- codeium: ".codeium",
21
- cody: ".cody"
16
+ //#endregion
17
+ //#region src/core/config/detect-providers.ts
18
+ var knownProviders = {
19
+ claude: ".claude",
20
+ cursor: ".cursor/rules",
21
+ copilot: ".copilot",
22
+ aider: ".aider",
23
+ codeium: ".codeium",
24
+ cody: ".cody"
22
25
  };
23
- const cliCommands = {
24
- claude: "claude",
25
- aider: "aider"
26
+ var cliCommands = {
27
+ claude: "claude",
28
+ aider: "aider"
26
29
  };
27
- const hasCliInstalled = (command) => {
28
- try {
29
- execFileSync(command, ["--version"], {
30
- stdio: "ignore",
31
- timeout: 5e3,
32
- shell: process.platform === "win32"
33
- });
34
- return true;
35
- } catch {
36
- return false;
37
- }
30
+ var hasCliInstalled = (command) => {
31
+ try {
32
+ execFileSync(command, ["--version"], {
33
+ stdio: "ignore",
34
+ timeout: 5e3,
35
+ shell: process.platform === "win32"
36
+ });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
38
41
  };
39
- const detectProviders = (root) => Object.entries(knownProviders).reduce(
40
- (providers, [name, path]) => {
41
- const hasDir = existsSync(join(root, path));
42
- const cli = cliCommands[name];
43
- const hasCli = cli ? hasCliInstalled(cli) : false;
44
- if (hasDir || hasCli) {
45
- providers[name] = { path };
46
- }
47
- return providers;
48
- },
49
- {}
50
- );
51
- const getGitRoot = () => {
52
- try {
53
- return execSync("git rev-parse --show-toplevel", {
54
- encoding: "utf-8"
55
- }).trim();
56
- } catch {
57
- return void 0;
58
- }
42
+ var detectProviders = (root) => Object.entries(knownProviders).reduce((providers, [name, path]) => {
43
+ const hasDir = existsSync(join(root, path));
44
+ const cli = cliCommands[name];
45
+ const hasCli = cli ? hasCliInstalled(cli) : false;
46
+ if (hasDir || hasCli) providers[name] = { path };
47
+ return providers;
48
+ }, {});
49
+ //#endregion
50
+ //#region src/core/config/project-root.ts
51
+ var getGitRoot = () => {
52
+ try {
53
+ return execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
54
+ } catch {
55
+ return;
56
+ }
59
57
  };
60
- const getProjectRoot = () => getGitRoot() ?? process.cwd();
61
- const CONFIG_FILE = ".spmrc.yml";
62
- const getConfigPath = (root) => join(root ?? getProjectRoot(), CONFIG_FILE);
63
- const createDefaultConfig = (root) => ({
64
- version: 1,
65
- providers: detectProviders(root)
58
+ var getProjectRoot = () => getGitRoot() ?? process.cwd();
59
+ //#endregion
60
+ //#region src/core/config/spmrc.ts
61
+ var CONFIG_FILE = ".spmrc.yml";
62
+ var getConfigPath = (root) => join(root ?? getProjectRoot(), CONFIG_FILE);
63
+ var createDefaultConfig = (root) => ({
64
+ version: 1,
65
+ providers: detectProviders(root)
66
66
  });
67
- const readConfig = (root) => {
68
- const resolvedRoot = getProjectRoot();
69
- const configPath = getConfigPath(resolvedRoot);
70
- if (!existsSync(configPath)) {
71
- const config = createDefaultConfig(resolvedRoot);
72
- writeConfig(config, resolvedRoot);
73
- return { config, created: true };
74
- }
75
- const content = readFileSync(configPath, "utf-8");
76
- return { config: parse(content), created: false };
67
+ var readConfig = (root) => {
68
+ const resolvedRoot = root ?? getProjectRoot();
69
+ const configPath = getConfigPath(resolvedRoot);
70
+ if (!existsSync(configPath)) {
71
+ const config = createDefaultConfig(resolvedRoot);
72
+ writeConfig(config, resolvedRoot);
73
+ return {
74
+ config,
75
+ created: true
76
+ };
77
+ }
78
+ return {
79
+ config: parse(readFileSync(configPath, "utf-8")),
80
+ created: false
81
+ };
77
82
  };
78
- const writeConfig = (config, root) => {
79
- const configPath = getConfigPath(root);
80
- writeFileSync(configPath, stringify(config), "utf-8");
83
+ var writeConfig = (config, root) => {
84
+ writeFileSync(getConfigPath(root), stringify(config), "utf-8");
81
85
  };
82
- const green = "\x1B[32m";
83
- const red = "\x1B[31m";
84
- const cyan = "\x1B[36m";
85
- const dim$1 = "\x1B[2m";
86
- const reset$2 = "\x1B[0m";
87
- const hideCursor = "\x1B[?25l";
88
- const showCursor = "\x1B[?25h";
89
- const frames = ["ā ‹", "ā ™", "ā ¹", "ā ø", "ā ¼", "ā “", "ā ¦", "ā §", "ā ‡", "ā "];
90
- const funMessages = {
91
- packages: [
92
- "Resolving dependency tree...",
93
- "Negotiating versions...",
94
- "Cross-referencing package manifests...",
95
- "Validating checksums...",
96
- "Flattening nested dependencies...",
97
- "Fetching metadata from the registry...",
98
- "Comparing lockfile with remote...",
99
- "Deduplicating shared packages...",
100
- // ~20% worried
101
- "This package has 47 peer deps. Deep breath...",
102
- "The lockfile disagrees. The lockfile is wrong..."
103
- ],
104
- skills: [
105
- "Loading skill into working memory...",
106
- "Parsing skill metadata...",
107
- "Checking for skill conflicts...",
108
- "Mapping skill dependencies...",
109
- "Indexing knowledge base...",
110
- "Verifying skill signatures...",
111
- "Wiring skills into a blockchain...",
112
- "Calibrating expertise level...",
113
- "Cross-referencing existing skills...",
114
- "Analyzing project conventions...",
115
- "Merging skill definitions...",
116
- "Resolving naming conflicts...",
117
- "Adapting skills to project rules...",
118
- "Aligning intentions with context...",
119
- "Validating output structure...",
120
- "Making sure skills actually work...",
121
- "Mapping file dependencies...",
122
- "Warming up the tubes and transistors...",
123
- "Reading configuration files...",
124
- "Scanning project structure...",
125
- "Writing changes to disk...",
126
- // ~20% worried
127
- "Something looks off in the skill graph...",
128
- "Skill definition is ambiguous. Guessing...",
129
- "Agent returned something weird. Reading it charitably...",
130
- "The agent went off-script. Gently correcting..."
131
- ],
132
- generic: [
133
- "Warming up the tubes and transistors...",
134
- "Reading configuration files...",
135
- "Scanning project structure...",
136
- "Writing changes to disk...",
137
- "Tidying up temporary files...",
138
- "Verifying file integrity...",
139
- "Syncing state...",
140
- "Finishing up loose ends...",
141
- // ~20% worried
142
- "This should've worked. Looking into it...",
143
- "That took longer than expected. Moving on..."
144
- ]
86
+ var addConfigEntry = ({ providerPath, kind, name, source }) => {
87
+ const { config } = readConfig();
88
+ const provider = Object.values(config.providers).find((p) => p.path === providerPath);
89
+ if (!provider) throw new Error(`Provider with path "${providerPath}" not found in config`);
90
+ if (!provider[kind]) provider[kind] = {};
91
+ provider[kind][name] = source;
92
+ writeConfig(config);
145
93
  };
146
- const write = (s) => process.stdout.write(s);
147
- const createStepper = () => {
148
- let frameIndex = 0;
149
- let currentText = "";
150
- let currentCategory = "generic";
151
- let items = [];
152
- let interval;
153
- let renderedLineCount = 0;
154
- let renderedItemCount = 0;
155
- let prevKeptCount = 0;
156
- let spinnerRendered = false;
157
- let startTime = 0;
158
- let funIndex = 0;
159
- let lastFunSwap = 0;
160
- let cursorHidden = false;
161
- let funLineRendered = false;
162
- let currentFunText = "";
163
- const writeCursorHide = () => {
164
- if (!cursorHidden) {
165
- write(hideCursor);
166
- cursorHidden = true;
167
- }
168
- };
169
- const writeCursorShow = () => {
170
- if (cursorHidden) {
171
- write(showCursor);
172
- cursorHidden = false;
173
- }
174
- };
175
- const clearRendered = () => {
176
- if (renderedLineCount > 0) {
177
- write(`\x1B[${renderedLineCount}A\r\x1B[J`);
178
- renderedLineCount = 0;
179
- renderedItemCount = 0;
180
- prevKeptCount = 0;
181
- spinnerRendered = false;
182
- funLineRendered = false;
183
- currentFunText = "";
184
- }
185
- };
186
- const donePrefix = ` ${green}•${reset$2} `;
187
- const pendingPrefix = ` ${dim$1}•${reset$2} `;
188
- const render = () => {
189
- const elapsed = Date.now() - startTime;
190
- const frame = frames[frameIndex % frames.length];
191
- if (!spinnerRendered) {
192
- write(`${cyan}${frame}${reset$2} ${currentText}\x1B[K
193
- `);
194
- renderedLineCount++;
195
- spinnerRendered = true;
196
- } else {
197
- const dist = renderedLineCount - prevKeptCount;
198
- write(
199
- `\x1B[${dist}A\r${cyan}${frame}${reset$2} ${currentText}\x1B[K\x1B[${dist}B\r`
200
- );
201
- }
202
- items.forEach((it, i) => {
203
- if (i < renderedItemCount && it.done && !it.renderedDone) {
204
- const linesUp = renderedLineCount - prevKeptCount - 1 - i;
205
- write(
206
- `\x1B[${linesUp}A\r${donePrefix}${it.text}\x1B[K\x1B[${linesUp}B\r`
207
- );
208
- it.renderedDone = true;
209
- }
210
- });
211
- if (renderedItemCount < items.length && funLineRendered) {
212
- write("\x1B[1A\r\x1B[K");
213
- renderedLineCount--;
214
- funLineRendered = false;
215
- }
216
- while (renderedItemCount < items.length) {
217
- const it = items[renderedItemCount];
218
- const prefix = it.done ? donePrefix : pendingPrefix;
219
- write(`${prefix}${it.text}\x1B[K
220
- `);
221
- if (it.done) it.renderedDone = true;
222
- renderedItemCount++;
223
- renderedLineCount++;
224
- }
225
- if (elapsed > 1e4) {
226
- const msgs = funMessages[currentCategory];
227
- if (lastFunSwap === 0 || Date.now() - lastFunSwap > 1e4) {
228
- funIndex = (funIndex + 1) % msgs.length;
229
- lastFunSwap = Date.now();
230
- }
231
- const newFunText = msgs[funIndex];
232
- const funLine = ` ā”” ${dim$1}${newFunText}${reset$2}`;
233
- if (!funLineRendered) {
234
- write(`${funLine}\x1B[K
235
- `);
236
- renderedLineCount++;
237
- funLineRendered = true;
238
- currentFunText = newFunText;
239
- } else if (newFunText !== currentFunText) {
240
- write(`\x1B[1A\r${funLine}\x1B[K\x1B[1B\r`);
241
- currentFunText = newFunText;
242
- }
243
- }
244
- frameIndex++;
245
- };
246
- const stopInterval = () => {
247
- if (interval) {
248
- clearInterval(interval);
249
- interval = void 0;
250
- }
251
- };
252
- const startInterval = () => {
253
- stopInterval();
254
- interval = setInterval(render, 80);
255
- render();
256
- };
257
- const onExit = () => writeCursorShow();
258
- process.on("exit", onExit);
259
- return {
260
- start: (text, category) => {
261
- writeCursorHide();
262
- currentText = text;
263
- currentCategory = category ?? "generic";
264
- items = [];
265
- renderedItemCount = 0;
266
- spinnerRendered = false;
267
- funLineRendered = false;
268
- currentFunText = "";
269
- startTime = Date.now();
270
- lastFunSwap = 0;
271
- funIndex = Math.floor(Math.random() * funMessages[currentCategory].length);
272
- startInterval();
273
- },
274
- item: (text) => {
275
- const last = items.at(-1);
276
- if (last && !last.done) last.done = true;
277
- items.push({
278
- text: text.replace(/^• /, ""),
279
- done: false,
280
- renderedDone: false
281
- });
282
- },
283
- succeed: (text, dimText) => {
284
- stopInterval();
285
- clearRendered();
286
- const dimPart = dimText ? ` ${dim$1}${dimText}${reset$2}` : "";
287
- write(`${green}āœ”${reset$2} ${text}${dimPart}
288
- `);
289
- const keptItems = items.length <= 1 ? [] : items;
290
- keptItems.forEach((it) => {
291
- write(`${donePrefix}${it.text}\x1B[K
292
- `);
293
- });
294
- prevKeptCount = keptItems.length;
295
- renderedLineCount = prevKeptCount;
296
- renderedItemCount = 0;
297
- items = [];
298
- spinnerRendered = false;
299
- },
300
- fail: (text) => {
301
- stopInterval();
302
- clearRendered();
303
- write(`${red}āœ–${reset$2} ${text}
304
- `);
305
- writeCursorShow();
306
- },
307
- stop: () => {
308
- stopInterval();
309
- clearRendered();
310
- writeCursorShow();
311
- process.removeListener("exit", onExit);
312
- }
313
- };
94
+ var removeConfigEntry = ({ providerPath, kind, name }) => {
95
+ const { config } = readConfig();
96
+ const provider = Object.values(config.providers).find((p) => p.path === providerPath);
97
+ if (!provider) throw new Error(`Provider with path "${providerPath}" not found in config`);
98
+ const entries = provider[kind];
99
+ if (!entries || !(name in entries)) return;
100
+ delete entries[name];
101
+ if (Object.keys(entries).length === 0) delete provider[kind];
102
+ writeConfig(config);
314
103
  };
315
- const registerInitCommand = (program2) => {
316
- program2.command("init").description("Initialize project configuration (.spmrc.yml)").action(() => {
317
- const stepper = createStepper();
318
- const { config, created } = readConfig();
319
- const providers = Object.keys(config.providers);
320
- if (created) {
321
- stepper.succeed(`Created ${CONFIG_FILE}`);
322
- } else {
323
- stepper.succeed(`${CONFIG_FILE} already exists`);
324
- }
325
- stepper.start("Detecting providers...", "generic");
326
- providers.forEach((name) => stepper.item(name));
327
- if (providers.length > 0) {
328
- stepper.succeed(`Detected ${providers.length} provider(s)`);
329
- } else {
330
- stepper.fail("No providers detected");
331
- }
332
- stepper.stop();
333
- });
104
+ //#endregion
105
+ //#region src/utils/ansi.ts
106
+ var green = "\x1B[32m";
107
+ var red = "\x1B[31m";
108
+ var cyan = "\x1B[36m";
109
+ var dim$1 = "\x1B[2m";
110
+ var reset$2 = "\x1B[0m";
111
+ var hideCursor = "\x1B[?25l";
112
+ var showCursor = "\x1B[?25h";
113
+ //#endregion
114
+ //#region src/utils/stepper.ts
115
+ var frames = [
116
+ "ā ‹",
117
+ "ā ™",
118
+ "ā ¹",
119
+ "ā ø",
120
+ "ā ¼",
121
+ "ā “",
122
+ "ā ¦",
123
+ "ā §",
124
+ "ā ‡",
125
+ "ā "
126
+ ];
127
+ var funMessages = {
128
+ packages: [
129
+ "Resolving dependency tree...",
130
+ "Negotiating versions...",
131
+ "Cross-referencing package manifests...",
132
+ "Validating checksums...",
133
+ "Flattening nested dependencies...",
134
+ "Fetching metadata from the registry...",
135
+ "Comparing lockfile with remote...",
136
+ "Deduplicating shared packages...",
137
+ "This package has 47 peer deps. Deep breath...",
138
+ "The lockfile disagrees. The lockfile is wrong..."
139
+ ],
140
+ skills: [
141
+ "Loading skill into working memory...",
142
+ "Parsing skill metadata...",
143
+ "Checking for skill conflicts...",
144
+ "Mapping skill dependencies...",
145
+ "Indexing knowledge base...",
146
+ "Verifying skill signatures...",
147
+ "Wiring skills into a blockchain...",
148
+ "Calibrating expertise level...",
149
+ "Cross-referencing existing skills...",
150
+ "Analyzing project conventions...",
151
+ "Merging skill definitions...",
152
+ "Resolving naming conflicts...",
153
+ "Adapting skills to project rules...",
154
+ "Aligning intentions with context...",
155
+ "Validating output structure...",
156
+ "Making sure skills actually work...",
157
+ "Mapping file dependencies...",
158
+ "Warming up the tubes and transistors...",
159
+ "Reading configuration files...",
160
+ "Scanning project structure...",
161
+ "Writing changes to disk...",
162
+ "Something looks off in the skill graph...",
163
+ "Skill definition is ambiguous. Guessing...",
164
+ "Agent returned something weird. Reading it charitably...",
165
+ "The agent went off-script. Gently correcting..."
166
+ ],
167
+ generic: [
168
+ "Warming up the tubes and transistors...",
169
+ "Reading configuration files...",
170
+ "Scanning project structure...",
171
+ "Writing changes to disk...",
172
+ "Tidying up temporary files...",
173
+ "Verifying file integrity...",
174
+ "Syncing state...",
175
+ "Finishing up loose ends...",
176
+ "This should've worked. Looking into it...",
177
+ "That took longer than expected. Moving on..."
178
+ ]
334
179
  };
335
- const downloadFromGitHub = async (source) => {
336
- const url = `https://raw.githubusercontent.com/${source.owner}/${source.repository}/${source.ref}/${source.path}`;
337
- const response = await fetch(url);
338
- if (!response.ok) {
339
- throw new Error(
340
- `Failed to download from GitHub: ${url} (${response.status} ${response.statusText})`
341
- );
342
- }
343
- return response.text();
180
+ var write = (s) => process.stdout.write(s);
181
+ var createStepper = () => {
182
+ let frameIndex = 0;
183
+ let currentText = "";
184
+ let currentCategory = "generic";
185
+ let items = [];
186
+ let interval;
187
+ let renderedLineCount = 0;
188
+ let renderedItemCount = 0;
189
+ let prevKeptCount = 0;
190
+ let spinnerRendered = false;
191
+ let startTime = 0;
192
+ let funIndex = 0;
193
+ let lastFunSwap = 0;
194
+ let cursorHidden = false;
195
+ let funLineRendered = false;
196
+ let currentFunText = "";
197
+ const writeCursorHide = () => {
198
+ if (!cursorHidden) {
199
+ write(hideCursor);
200
+ cursorHidden = true;
201
+ }
202
+ };
203
+ const writeCursorShow = () => {
204
+ if (cursorHidden) {
205
+ write(showCursor);
206
+ cursorHidden = false;
207
+ }
208
+ };
209
+ const clearRendered = () => {
210
+ if (renderedLineCount > 0) {
211
+ write(`\x1b[${renderedLineCount}A\r\x1b[J`);
212
+ renderedLineCount = 0;
213
+ renderedItemCount = 0;
214
+ prevKeptCount = 0;
215
+ spinnerRendered = false;
216
+ funLineRendered = false;
217
+ currentFunText = "";
218
+ }
219
+ };
220
+ const donePrefix = ` ${green}•${reset$2} `;
221
+ const pendingPrefix = ` ${dim$1}•${reset$2} `;
222
+ const render = () => {
223
+ const elapsed = Date.now() - startTime;
224
+ const frame = frames[frameIndex % frames.length];
225
+ if (!spinnerRendered) {
226
+ write(`${cyan}${frame}${reset$2} ${currentText}\x1b[K\n`);
227
+ renderedLineCount++;
228
+ spinnerRendered = true;
229
+ } else {
230
+ const dist = renderedLineCount - prevKeptCount;
231
+ write(`\x1b[${dist}A\r${cyan}${frame}${reset$2} ${currentText}\x1b[K\x1b[${dist}B\r`);
232
+ }
233
+ items.forEach((it, i) => {
234
+ if (i < renderedItemCount && it.done && !it.renderedDone) {
235
+ const linesUp = renderedLineCount - prevKeptCount - 1 - i;
236
+ write(`\x1b[${linesUp}A\r${donePrefix}${it.text}\x1b[K\x1b[${linesUp}B\r`);
237
+ it.renderedDone = true;
238
+ }
239
+ });
240
+ if (renderedItemCount < items.length && funLineRendered) {
241
+ write("\x1B[1A\r\x1B[K");
242
+ renderedLineCount--;
243
+ funLineRendered = false;
244
+ }
245
+ while (renderedItemCount < items.length) {
246
+ const it = items[renderedItemCount];
247
+ write(`${it.done ? donePrefix : pendingPrefix}${it.text}\x1b[K\n`);
248
+ if (it.done) it.renderedDone = true;
249
+ renderedItemCount++;
250
+ renderedLineCount++;
251
+ }
252
+ if (elapsed > 1e4) {
253
+ const msgs = funMessages[currentCategory];
254
+ if (lastFunSwap === 0 || Date.now() - lastFunSwap > 1e4) {
255
+ funIndex = (funIndex + 1) % msgs.length;
256
+ lastFunSwap = Date.now();
257
+ }
258
+ const newFunText = msgs[funIndex];
259
+ const funLine = ` ā”” ${dim$1}${newFunText}${reset$2}`;
260
+ if (!funLineRendered) {
261
+ write(`${funLine}\x1b[K\n`);
262
+ renderedLineCount++;
263
+ funLineRendered = true;
264
+ currentFunText = newFunText;
265
+ } else if (newFunText !== currentFunText) {
266
+ write(`\x1b[1A\r${funLine}\x1b[K\x1b[1B\r`);
267
+ currentFunText = newFunText;
268
+ }
269
+ }
270
+ frameIndex++;
271
+ };
272
+ const stopInterval = () => {
273
+ if (interval) {
274
+ clearInterval(interval);
275
+ interval = void 0;
276
+ }
277
+ };
278
+ const startInterval = () => {
279
+ stopInterval();
280
+ interval = setInterval(render, 80);
281
+ render();
282
+ };
283
+ const onExit = () => writeCursorShow();
284
+ process.on("exit", onExit);
285
+ return {
286
+ start: (text, category) => {
287
+ writeCursorHide();
288
+ currentText = text;
289
+ currentCategory = category ?? "generic";
290
+ items = [];
291
+ renderedItemCount = 0;
292
+ spinnerRendered = false;
293
+ funLineRendered = false;
294
+ currentFunText = "";
295
+ startTime = Date.now();
296
+ lastFunSwap = 0;
297
+ funIndex = Math.floor(Math.random() * funMessages[currentCategory].length);
298
+ startInterval();
299
+ },
300
+ item: (text) => {
301
+ const last = items.at(-1);
302
+ if (last && !last.done) last.done = true;
303
+ items.push({
304
+ text: text.replace(/^• /, ""),
305
+ done: false,
306
+ renderedDone: false
307
+ });
308
+ },
309
+ succeed: (text, dimText) => {
310
+ stopInterval();
311
+ clearRendered();
312
+ write(`${green}āœ”${reset$2} ${text}${dimText ? ` ${dim$1}${dimText}${reset$2}` : ""}\n`);
313
+ const keptItems = items.length <= 1 ? [] : items;
314
+ keptItems.forEach((it) => {
315
+ write(`${donePrefix}${it.text}\x1b[K\n`);
316
+ });
317
+ prevKeptCount = keptItems.length;
318
+ renderedLineCount = prevKeptCount;
319
+ renderedItemCount = 0;
320
+ items = [];
321
+ spinnerRendered = false;
322
+ },
323
+ fail: (text) => {
324
+ stopInterval();
325
+ clearRendered();
326
+ write(`${red}āœ–${reset$2} ${text}\n`);
327
+ writeCursorShow();
328
+ },
329
+ stop: () => {
330
+ stopInterval();
331
+ clearRendered();
332
+ writeCursorShow();
333
+ process.removeListener("exit", onExit);
334
+ }
335
+ };
344
336
  };
345
- const downloadFromUrl = async (source) => {
346
- const response = await fetch(source.url);
347
- if (!response.ok) {
348
- throw new Error(
349
- `Failed to download from URL: ${source.url} (${response.status} ${response.statusText})`
350
- );
351
- }
352
- return response.text();
337
+ //#endregion
338
+ //#region src/commands/init.ts
339
+ var registerInitCommand = (program) => {
340
+ program.command("init").description("Initialize project configuration (.spmrc.yml)").action(() => {
341
+ const stepper = createStepper();
342
+ const { config, created } = readConfig();
343
+ const providers = Object.keys(config.providers);
344
+ if (created) stepper.succeed(`Created ${CONFIG_FILE}`);
345
+ else stepper.succeed(`${CONFIG_FILE} already exists`);
346
+ stepper.start("Detecting providers...", "generic");
347
+ providers.forEach((name) => stepper.item(name));
348
+ if (providers.length > 0) stepper.succeed(`Detected ${providers.length} provider(s)`);
349
+ else stepper.fail("No providers detected");
350
+ stepper.stop();
351
+ });
353
352
  };
354
- const readLocalFile = async (source) => {
355
- try {
356
- return await readFile(source.filePath, "utf-8");
357
- } catch (error) {
358
- const message = error instanceof Error ? error.message : String(error);
359
- throw new Error(
360
- `Failed to read local file: ${source.filePath} (${message})`
361
- );
362
- }
353
+ //#endregion
354
+ //#region src/core/installer/install-skill/install-skill.md?raw
355
+ var install_skill_default = "# Skill Integration\n\nYou are integrating a single skill into the project. Analyze the existing project setup and intelligently integrate the new files.\n\n## Context\n\n- **Provider directory**: `{{providerDir}}`\n- **Skill**: `{{skillName}}`\n\nIdentical files have already been removed. Only files that need action are listed below. If no downloaded files are listed, output `Done` immediately.\n\n## Output Format\n\nCRITICAL — follow exactly:\n- NEVER wrap output in code blocks or backticks\n- NEVER write conversational text (\"Now let me...\", \"Let me check...\", \"The files are...\")\n- NEVER use emojis\n- Output ONLY structured log lines\n- Step headers are plain text ending with `...` — these are parsed by the CLI to drive spinner states\n- Items below headers are indented with 2 spaces and use `• ` prefix\n- File lists use ASCII tree: `ā”œā”€`, `└─`, `│`\n- End with `Done` on its own line\n\nStep headers (use these exactly):\n\n1. `Analyzing existing setup...`\n2. `Analyzing downloaded files...`\n3. `Detecting conflicts...`\n4. `Integrating...`\n5. `Done`\n\nExample output:\n\nAnalyzing existing setup...\n • 4 skills, 2 rules, 1 hook\n\nAnalyzing downloaded files...\n • skill: git (3 files)\n\nDetecting conflicts...\n • No conflicts\n\nIntegrating...\n • skills/git/SKILL.md\n • skills/git/branch.md\n\nDone\n\n{{embeddedSection}}\n\n## Step 1: Analyzing existing setup\n\nReview the existing files list above and catalog what is already in place: skills, rules, agents, hooks, mcp servers files.\nDo NOT use Read or Glob to scan the provider directory — all information is provided above.\n\n## Step 2: Analyzing downloaded files\n\nReview the downloaded files listed above. These are the only files that need to be installed.\nDo NOT use Read or Glob to scan the download directory — all file contents are provided above.\n\n## Step 3: Detecting conflicts\n\nUsing the existing files list and downloaded files above, check if any downloaded file conflicts with an existing file.\n\n- **No existing file** → install as new\n- **Different content** → replace silently\n- **Same content** → skip (should already be pruned)\n\nIf there is a real conflict (same path, user has customized the existing file):\n\nDetecting conflicts...\n • rules/coding.md — local has custom rules\n • Choose: (r)eplace / (s)kip / (m)erge\n\nWait for response before proceeding.\n\n## Step 4: Integrating\n\nWrite each downloaded file to `{{providerDir}}/skills/{{skillName}}/` using the Write tool. Use the exact content provided above.\n\n**Integrate, don't copy.** When a new skill can leverage existing project conventions, adapt it:\n\n- If the project has `rules/coding.md` with specific conventions → make new skills follow those rules instead of their own defaults\n- If existing skills handle branching or committing → reference them instead of duplicating instructions\n- If the project has naming conventions, testing patterns, or architectural rules → align new skills with them\n\n{{unresolvedSection}}\n\n## Rules\n\n- Do not delete or modify existing files in the provider directory unless resolving a conflict\n- Do NOT read files from disk — all content is embedded in this prompt\n- Do NOT delete the download folder — cleanup is handled externally\n";
356
+ //#endregion
357
+ //#region src/core/installer/install-skillset/install.md?raw
358
+ var install_default = "# Skillset Integration\n\nYou are integrating a new skillset into the project. Analyze the existing project setup and intelligently integrate the new files.\n\n## Context\n\n- **Provider directory**: `{{providerDir}}`\n- **Skillset**: `{{skillsetName}}` v`{{skillsetVersion}}`\n\nIdentical files have already been removed. Only files that need action are listed below. If no downloaded files are listed, output `Done` immediately.\n\n## Output Format\n\nCRITICAL — follow exactly:\n- NEVER wrap output in code blocks or backticks\n- NEVER write conversational text (\"Now let me...\", \"Let me check...\", \"The files are...\")\n- NEVER use emojis\n- Output ONLY structured log lines\n- Step headers are plain text ending with `...` — these are parsed by the CLI to drive spinner states\n- Items below headers are indented with 2 spaces and use `• ` prefix\n- File lists use ASCII tree: `ā”œā”€`, `└─`, `│`\n- End with `Done` on its own line\n\nStep headers (use these exactly):\n\n1. `Analyzing existing setup...`\n2. `Analyzing downloaded files...`\n3. `Detecting conflicts...`\n4. `Integrating...`\n5. `Running setup...` (only if a setup file exists)\n6. `Done`\n\nExample output:\n\nAnalyzing existing setup...\n • 4 skills, 2 rules, 1 hook\n\nAnalyzing downloaded files...\n • skills: git, github, implement\n • rules: coding\n • hook: biome-format\n\nDetecting conflicts...\n • No conflicts\n\nIntegrating...\n • skills/git/SKILL.md\n • skills/git/branch.md\n • skills/github/SKILL.md\n\nRunning setup...\n • Configured MCP server: biome\n\nDone\n\n{{embeddedSection}}\n\n## Step 1: Analyzing existing setup\n\nReview the existing files list above and catalog what is already in place: skills, rules, agents, hooks, mcp servers files.\nDo NOT use Read or Glob to scan the provider directory — all information is provided above.\n\n## Step 2: Analyzing downloaded files\n\nReview the downloaded files listed above. These are the only files that need to be installed.\nDo NOT use Read or Glob to scan the download directory — all file contents are provided above.\n\n## Step 3: Detecting conflicts\n\nUsing the existing files list and downloaded files above, check if any downloaded file conflicts with an existing file.\n\n- **No existing file** → install as new\n- **Different skillset version** → replace silently (new version supersedes)\n- **Same version or no version info** → conflict — ask the user:\n\nDetecting conflicts...\n • rules/coding.md — local has custom rules\n • Choose: (r)eplace / (s)kip / (m)erge\n\nWait for response before proceeding.\n\n## Step 4: Integrating\n\nWrite each downloaded file to `{{providerDir}}` using the Write tool, following the directory structure from the file paths. Use the exact content provided above.\n\n**Integrate, don't copy.** When a new skill can leverage existing project conventions, adapt it:\n\n- If the project has `rules/coding.md` with specific conventions → make new skills follow those rules instead of their own defaults\n- If existing skills handle branching or committing → reference them instead of duplicating instructions\n- If the project has naming conventions, testing patterns, or architectural rules → align new skills with them\n\n{{setupSection}}\n\n## Rules\n\n- Setup files are NOT installed — they contain instructions to configure the project\n- Do not delete or modify existing files in the provider directory unless resolving a conflict\n- Do NOT read files from disk — all content is embedded in this prompt\n- Do NOT delete the download folder — cleanup is handled externally\n";
359
+ //#endregion
360
+ //#region src/core/installer/shared/build-prompt.ts
361
+ var buildSetupSection = (setupContent) => setupContent ? [
362
+ "## Step 6: Running setup",
363
+ "",
364
+ "Follow the setup instructions below. Setup files configure the project environment (e.g. MCP servers, LSP, tooling).",
365
+ "Do NOT copy the setup content into the provider directory — only execute its instructions.",
366
+ "",
367
+ "```",
368
+ setupContent,
369
+ "```"
370
+ ].join("\n") : "";
371
+ var buildUnresolvedSection = (refs) => refs && refs.length > 0 ? [
372
+ "## Unresolved references",
373
+ "",
374
+ "The following files are referenced in the skill but do not exist in the source repository.",
375
+ "They may be runtime-generated paths (e.g. template outputs, reports). Review each reference",
376
+ "in context and decide:",
377
+ "",
378
+ "- **Runtime path** → keep the reference as-is, the file will be created when the skill runs",
379
+ "- **Missing required file** → warn the user that this file could not be found",
380
+ "",
381
+ ...refs.map((r) => `- \`${r}\``)
382
+ ].join("\n") : "";
383
+ var buildEmbeddedSection = (embedded) => {
384
+ const parts = [];
385
+ parts.push("## Existing files in provider directory");
386
+ parts.push("");
387
+ if (embedded.existingFiles.length === 0) parts.push("Provider directory is empty.");
388
+ else embedded.existingFiles.forEach((f) => parts.push(`- ${f}`));
389
+ parts.push("");
390
+ parts.push("## Downloaded files (to be installed)");
391
+ parts.push("");
392
+ if (embedded.downloadedFiles.length === 0) parts.push("No files to install (all unchanged).");
393
+ else embedded.downloadedFiles.forEach((f) => {
394
+ parts.push(`### ${f.path}`);
395
+ parts.push("");
396
+ parts.push("```");
397
+ parts.push(f.content);
398
+ parts.push("```");
399
+ parts.push("");
400
+ });
401
+ return parts.join("\n");
363
402
  };
364
- const getContent = (source) => {
365
- if (source.kind === "github") return downloadFromGitHub(source);
366
- if (source.kind === "url") return downloadFromUrl(source);
367
- return readLocalFile(source);
403
+ var buildInstructions = (input) => install_default.replace(/\{\{providerDir\}\}/g, input.providerDir).replace(/\{\{skillsetName\}\}/g, input.skillsetName).replace(/\{\{skillsetVersion\}\}/g, input.skillsetVersion).replace("{{setupSection}}", buildSetupSection(input.setupContent)).replace("{{embeddedSection}}", buildEmbeddedSection(input.embedded));
404
+ var writeInstructionsFile = (input) => {
405
+ const filePath = join(tmpdir(), "spm", `install-${input.skillsetName}.md`);
406
+ mkdirSync(dirname(filePath), { recursive: true });
407
+ writeFileSync(filePath, buildInstructions(input), "utf-8");
408
+ return filePath;
368
409
  };
369
- const downloadFile = async (source) => {
370
- const content = await getContent(source);
371
- return { content, source };
410
+ var buildSkillInstructions = (input) => install_skill_default.replace(/\{\{providerDir\}\}/g, input.providerDir).replace(/\{\{skillName\}\}/g, input.skillName).replace("{{unresolvedSection}}", buildUnresolvedSection(input.unresolvedRefs)).replace("{{embeddedSection}}", buildEmbeddedSection(input.embedded));
411
+ var writeSkillInstructionsFile = (input) => {
412
+ const filePath = join(tmpdir(), "spm", `install-skill-${input.skillName}.md`);
413
+ mkdirSync(dirname(filePath), { recursive: true });
414
+ writeFileSync(filePath, buildSkillInstructions(input), "utf-8");
415
+ return filePath;
372
416
  };
373
- const template = '# Skillset Integration\n\nYou are integrating a new skillset into the project. Analyze the existing project setup and intelligently integrate the new files.\n\n## Context\n\n- **Downloaded files**: `{{downloadDir}}`\n- **Provider directory**: `{{providerDir}}`\n- **Skillset**: `{{skillsetName}}` v`{{skillsetVersion}}`\n- **Source**: `{{source}}`\n- **Config file**: `{{configPath}}`\n\nIdentical files have already been removed from the download folder. Only files that need action remain. If the download folder is empty, skip to the config update step.\n\n## Output Format\n\nCRITICAL — follow exactly:\n- NEVER wrap output in code blocks or backticks\n- NEVER write conversational text ("Now let me...", "Let me check...", "The files are...")\n- NEVER use emojis\n- Output ONLY structured log lines\n- Step headers are plain text ending with `...` — these are parsed by the CLI to drive spinner states\n- Items below headers are indented with 2 spaces and use `• ` prefix\n- File lists use ASCII tree: `ā”œā”€`, `└─`, `│`\n- End with `Done` on its own line\n\nStep headers (use these exactly):\n\n1. `Analyzing existing setup...`\n2. `Analyzing downloaded files...`\n3. `Detecting conflicts...`\n4. `Integrating...`\n5. `Updating config...`\n6. `Running setup...` (only if a setup file exists)\n7. `Done`\n\nExample output:\n\nAnalyzing existing setup...\n • 4 skills, 2 rules, 1 hook\n\nAnalyzing downloaded files...\n • skills: git, github, implement\n • rules: coding\n • hook: biome-format\n\nDetecting conflicts...\n • No conflicts\n\nIntegrating...\n • skills/git/SKILL.md\n • skills/git/branch.md\n • skills/github/SKILL.md\n\nUpdating config...\n • skillset: skill-creator@1.0.0\n\nRunning setup...\n • Configured MCP server: biome\n\nDone\n\n## Step 1: Analyzing existing setup\n\nRead the provider directory (`{{providerDir}}`) and catalog what is already in place: skills, rules, agents, hooks, mcp servers files.\n\n## Step 2: Analyzing downloaded files\n\nRead all files in `{{downloadDir}}`. Only files present in this folder need to be installed.\n\n## Step 3: Detecting conflicts\n\nFor each downloaded file, check if a file with the same name exists in the provider directory.\n\n- **No existing file** → install as new\n- **Different skillset version** → replace silently (new version supersedes)\n- **Same version or no version info** → conflict — ask the user:\n\nDetecting conflicts...\n • rules/coding.md — local has custom rules\n • Choose: (r)eplace / (s)kip / (m)erge\n\nWait for response before proceeding.\n\n## Step 4: Integrating\n\nInstall files into `{{providerDir}}`, following the directory structure from the download folder.\n\n**Integrate, don\'t copy.** When a new skill can leverage existing project conventions, adapt it:\n\n- If the project has `rules/coding.md` with specific conventions → make new skills follow those rules instead of their own defaults\n- If existing skills handle branching or committing → reference them instead of duplicating instructions\n- If the project has naming conventions, testing patterns, or architectural rules → align new skills with them\n\n## Step 5: Updating config\n\nUpdate `{{configPath}}`:\n\n- Under the provider with path `{{providerDir}}`, add the skillset entry under `skillsets`:\n `{{skillsetName}}: "{{source}}@{{skillsetVersion}}"`\n- Do NOT add individual skill, agent, or file names to the config\n- The `skills` map is reserved for standalone skill installations\n\n{{setupSection}}\n\n## Rules\n\n- Setup files are NOT installed — they contain instructions to configure the project\n- Do not delete or modify existing files in the provider directory unless resolving a conflict\n- Do NOT delete the download folder — cleanup is handled externally\n';
374
- const skillTemplate = '# Skill Integration\n\nYou are integrating a single skill into the project. Analyze the existing project setup and intelligently integrate the new files.\n\n## Context\n\n- **Downloaded files**: `{{downloadDir}}`\n- **Provider directory**: `{{providerDir}}`\n- **Skill**: `{{skillName}}`\n- **Source**: `{{source}}`\n- **Config file**: `{{configPath}}`\n\nIdentical files have already been removed from the download folder. Only files that need action remain. If the download folder is empty, skip to the config update step.\n\n## Output Format\n\nCRITICAL — follow exactly:\n- NEVER wrap output in code blocks or backticks\n- NEVER write conversational text ("Now let me...", "Let me check...", "The files are...")\n- NEVER use emojis\n- Output ONLY structured log lines\n- Step headers are plain text ending with `...` — these are parsed by the CLI to drive spinner states\n- Items below headers are indented with 2 spaces and use `• ` prefix\n- File lists use ASCII tree: `ā”œā”€`, `└─`, `│`\n- End with `Done` on its own line\n\nStep headers (use these exactly):\n\n1. `Analyzing existing setup...`\n2. `Analyzing downloaded files...`\n3. `Detecting conflicts...`\n4. `Integrating...`\n5. `Updating config...`\n6. `Done`\n\nExample output:\n\nAnalyzing existing setup...\n • 4 skills, 2 rules, 1 hook\n\nAnalyzing downloaded files...\n • skill: git (3 files)\n\nDetecting conflicts...\n • No conflicts\n\nIntegrating...\n • skills/git/SKILL.md\n • skills/git/branch.md\n\nUpdating config...\n • skill: git\n\nDone\n\n## Step 1: Analyzing existing setup\n\nRead the provider directory (`{{providerDir}}`) and catalog what is already in place: skills, rules, agents, hooks, mcp servers files.\n\n## Step 2: Analyzing downloaded files\n\nRead all files in `{{downloadDir}}`. Only files present in this folder need to be installed.\n\n## Step 3: Detecting conflicts\n\nFor each downloaded file, check if a file with the same name exists in the provider directory.\n\n- **No existing file** → install as new\n- **Different content** → replace silently\n- **Same content** → skip (should already be pruned)\n\nDetecting conflicts...\n • rules/coding.md — local has custom rules\n • Choose: (r)eplace / (s)kip / (m)erge\n\nWait for response before proceeding.\n\n## Step 4: Integrating\n\nInstall files into `{{providerDir}}/skills/{{skillName}}/`, following the directory structure from the download folder.\n\n**Integrate, don\'t copy.** When a new skill can leverage existing project conventions, adapt it:\n\n- If the project has `rules/coding.md` with specific conventions → make new skills follow those rules instead of their own defaults\n- If existing skills handle branching or committing → reference them instead of duplicating instructions\n- If the project has naming conventions, testing patterns, or architectural rules → align new skills with them\n\n## Step 5: Updating config\n\nUpdate `{{configPath}}`:\n\n- Under the provider with path `{{providerDir}}`, add the skill entry under `skills`:\n `{{skillName}}: "{{source}}"`\n- The `skillsets` map is reserved for skillset installations\n\n## Rules\n\n- Do not delete or modify existing files in the provider directory unless resolving a conflict\n- Do NOT delete the download folder — cleanup is handled externally\n';
375
- const buildSetupSection = (setupFile) => setupFile ? [
376
- "## Step 6: Running setup",
377
- "",
378
- `Read the setup file at \`${setupFile}\` and follow its instructions.`,
379
- "Setup files configure the project environment (e.g. MCP servers, LSP, tooling).",
380
- "Do NOT copy the setup file into the provider directory — only execute its instructions."
381
- ].join("\n") : "";
382
- const buildInstructions = (input) => template.replace(/\{\{downloadDir\}\}/g, input.downloadDir).replace(/\{\{providerDir\}\}/g, input.providerDir).replace(/\{\{skillsetName\}\}/g, input.skillsetName).replace(/\{\{skillsetVersion\}\}/g, input.skillsetVersion).replace(/\{\{source\}\}/g, input.source).replace(/\{\{configPath\}\}/g, input.configPath).replace("{{setupSection}}", buildSetupSection(input.setupFile));
383
- const writeInstructionsFile = (input) => {
384
- const filePath = join(tmpdir(), "spm", `install-${input.skillsetName}.md`);
385
- mkdirSync(dirname(filePath), { recursive: true });
386
- writeFileSync(filePath, buildInstructions(input), "utf-8");
387
- return filePath;
417
+ var writeSetupInstructionsFile = (setupContent, skillsetName) => {
418
+ const instructions = [
419
+ "# Skillset Setup",
420
+ "",
421
+ `You are running setup for the **${skillsetName}** skillset.`,
422
+ "Follow the instructions below to configure the project environment.",
423
+ "",
424
+ "## Output Format",
425
+ "",
426
+ "CRITICAL — follow exactly:",
427
+ "- NEVER wrap output in code blocks or backticks",
428
+ "- NEVER write conversational text",
429
+ "- NEVER use emojis",
430
+ "- Output ONLY structured log lines",
431
+ "- End with `Done` on its own line",
432
+ "",
433
+ "Step header: `Running setup...`",
434
+ "",
435
+ "## Instructions",
436
+ "",
437
+ setupContent,
438
+ "",
439
+ "Done"
440
+ ].join("\n");
441
+ const filePath = join(tmpdir(), "spm", `setup-${skillsetName}.md`);
442
+ mkdirSync(dirname(filePath), { recursive: true });
443
+ writeFileSync(filePath, instructions, "utf-8");
444
+ return filePath;
445
+ };
446
+ //#endregion
447
+ //#region src/core/installer/shared/cleanup.ts
448
+ var cleanupDownloadDir = async (projectRoot, downloadDir) => {
449
+ await rm(downloadDir, {
450
+ recursive: true,
451
+ force: true
452
+ });
453
+ const spmDir = join(projectRoot, ".spm");
454
+ if ((await readdir(spmDir).catch(() => [])).length === 0) await rm(spmDir, {
455
+ recursive: true,
456
+ force: true
457
+ });
458
+ };
459
+ //#endregion
460
+ //#region src/core/installer/shared/collect-files.ts
461
+ var walkDir = (dir) => readdirSync(dir).flatMap((entry) => {
462
+ const fullPath = join(dir, entry);
463
+ return statSync(fullPath).isDirectory() ? walkDir(fullPath) : [fullPath];
464
+ });
465
+ var collectRemainingFiles = (downloadDir) => {
466
+ if (!existsSync(downloadDir)) return [];
467
+ return walkDir(downloadDir).filter((f) => !f.endsWith("SETUP.md")).map((fullPath) => ({
468
+ path: relative(downloadDir, fullPath).replace(/\\/g, "/"),
469
+ content: readFileSync(fullPath, "utf-8")
470
+ }));
471
+ };
472
+ var listExistingFiles = (providerDir) => {
473
+ if (!existsSync(providerDir)) return [];
474
+ return walkDir(providerDir).map((fullPath) => relative(providerDir, fullPath).replace(/\\/g, "/"));
475
+ };
476
+ //#endregion
477
+ //#region src/core/installer/shared/path-utils.ts
478
+ var safePath = (baseDir, filePath) => {
479
+ const resolvedBase = resolve(baseDir);
480
+ const resolved = resolve(resolvedBase, normalize(filePath));
481
+ if (resolved !== resolvedBase && !resolved.startsWith(`${resolvedBase}${sep}`)) throw new Error(`Path traversal detected: "${filePath}"`);
482
+ return resolved;
483
+ };
484
+ var stripProviderPrefix = (file, prefix) => {
485
+ const norm = prefix.replace(/\\/g, "/").replace(/^\.\//, "");
486
+ if (file.startsWith(`${norm}/`)) return file.slice(norm.length + 1);
487
+ const bare = norm.replace(/^\./, "");
488
+ if (bare && file.startsWith(`${bare}/`)) return file.slice(bare.length + 1);
489
+ return file;
490
+ };
491
+ //#endregion
492
+ //#region src/core/installer/shared/copy-files.ts
493
+ var copyFilesToProvider = (files, targetDir, stepper, entityLabel) => {
494
+ if (files.length === 0) {
495
+ stepper.succeed(`${entityLabel} is up to date`);
496
+ return {
497
+ success: true,
498
+ output: "",
499
+ files: []
500
+ };
501
+ }
502
+ stepper.start("Integrating...", "skills");
503
+ const writtenFiles = files.map((file) => {
504
+ const targetPath = safePath(targetDir, file.path);
505
+ mkdirSync(dirname(targetPath), { recursive: true });
506
+ writeFileSync(targetPath, file.content, "utf-8");
507
+ stepper.item(file.path);
508
+ return file.path;
509
+ });
510
+ stepper.succeed(`${entityLabel} was integrated (${writtenFiles.length} file(s))`);
511
+ return {
512
+ success: true,
513
+ output: "",
514
+ files: writtenFiles
515
+ };
388
516
  };
389
- const buildSkillInstructions = (input) => skillTemplate.replace(/\{\{downloadDir\}\}/g, input.downloadDir).replace(/\{\{providerDir\}\}/g, input.providerDir).replace(/\{\{skillName\}\}/g, input.skillName).replace(/\{\{source\}\}/g, input.source).replace(/\{\{configPath\}\}/g, input.configPath);
390
- const writeSkillInstructionsFile = (input) => {
391
- const filePath = join(tmpdir(), "spm", `install-skill-${input.skillName}.md`);
392
- mkdirSync(dirname(filePath), { recursive: true });
393
- writeFileSync(filePath, buildSkillInstructions(input), "utf-8");
394
- return filePath;
517
+ //#endregion
518
+ //#region src/core/installer/shared/detect-conflicts.ts
519
+ var detectConflicts = (files, providerDir) => {
520
+ const newFiles = [];
521
+ const conflictFiles = [];
522
+ files.forEach((file) => {
523
+ if (existsSync(join(providerDir, file.path))) conflictFiles.push(file);
524
+ else newFiles.push(file);
525
+ });
526
+ return {
527
+ newFiles,
528
+ conflictFiles
529
+ };
395
530
  };
396
- const isNoise = (line) => line === "```" || line.startsWith("```") || /^(now |let me |the files? |i'll |i will |looking |checking )/i.test(line);
397
- const isStepHeader = (raw, trimmed) => !raw.startsWith(" ") && !raw.startsWith(" ") && trimmed.endsWith("...");
398
- const stepCategory = (header) => {
399
- if (header.startsWith("Integrating") || header.startsWith("Analyzing") || header.startsWith("Detecting"))
400
- return "skills";
401
- return "generic";
531
+ //#endregion
532
+ //#region src/core/downloader/download-from-github.ts
533
+ var downloadFromGitHub = async (source) => {
534
+ const url = `https://raw.githubusercontent.com/${source.owner}/${source.repository}/${source.ref}/${source.path}`;
535
+ const response = await fetch(url);
536
+ if (!response.ok) throw new Error(`Failed to download from GitHub: ${url} (${response.status} ${response.statusText})`);
537
+ return response.text();
402
538
  };
403
- const irregulars = {
404
- Running: "Ran"
539
+ //#endregion
540
+ //#region src/core/downloader/download-from-url.ts
541
+ var downloadFromUrl = async (source) => {
542
+ const response = await fetch(source.url);
543
+ if (!response.ok) throw new Error(`Failed to download from URL: ${source.url} (${response.status} ${response.statusText})`);
544
+ return response.text();
405
545
  };
406
- const toSuccessText = (header) => {
407
- const text = header.replace(/\.{3}$/, "").trim();
408
- const firstWord = text.split(" ")[0];
409
- if (irregulars[firstWord]) {
410
- return text.replace(firstWord, irregulars[firstWord]);
411
- }
412
- return text.replace(/^(\w+)ing\b/, "$1ed");
546
+ //#endregion
547
+ //#region src/core/downloader/read-local-file.ts
548
+ var readLocalFile = async (source) => {
549
+ try {
550
+ return await readFile(source.filePath, "utf-8");
551
+ } catch (error) {
552
+ const message = error instanceof Error ? error.message : String(error);
553
+ throw new Error(`Failed to read local file: ${source.filePath} (${message})`);
554
+ }
413
555
  };
414
- const toRelativePath = (filePath, providerDir) => {
415
- const normalized = filePath.replace(/\\/g, "/");
416
- const base = providerDir.replace(/\\/g, "/");
417
- const marker = `${base}/`;
418
- const idx = normalized.lastIndexOf(marker);
419
- if (idx !== -1) return normalized.slice(idx + marker.length);
420
- return normalized.split("/").pop() ?? normalized;
556
+ //#endregion
557
+ //#region src/core/downloader/download-file.ts
558
+ var getContent = (source) => {
559
+ if (source.kind === "github") return downloadFromGitHub(source);
560
+ if (source.kind === "url") return downloadFromUrl(source);
561
+ return readLocalFile(source);
421
562
  };
422
- const spawnClaude = (instructionsFilePath, stepper, providerDir, model, entityLabel = "Skillset") => new Promise((resolve2, reject) => {
423
- const args = [
424
- "-p",
425
- "Install the skill as instructed.",
426
- "--append-system-prompt-file",
427
- instructionsFilePath,
428
- "--verbose",
429
- "--output-format",
430
- "stream-json",
431
- "--allowedTools",
432
- "Read,Write,Edit,Bash,Glob,Grep",
433
- ...model ? ["--model", model] : []
434
- ];
435
- const isWindows = process.platform === "win32";
436
- const child = spawn("claude", args, {
437
- shell: isWindows,
438
- stdio: ["ignore", "pipe", "pipe"]
439
- });
440
- let resultText = "";
441
- let lineBuffer = "";
442
- let currentStepHeader = "Analyzing existing setup...";
443
- let stepItems = [];
444
- let stepFileCount = 0;
445
- const state = { doneReceived: false, setupReached: false };
446
- const writtenFiles = [];
447
- const succeedCurrentStep = () => {
448
- if (!currentStepHeader) return;
449
- const base = toSuccessText(currentStepHeader);
450
- if (currentStepHeader.startsWith("Detecting")) {
451
- const noConflicts = stepItems.some((i) => /no conflict/i.test(i));
452
- stepper.succeed(noConflicts ? "No conflicts" : base);
453
- return;
454
- }
455
- if (currentStepHeader.startsWith("Integrating")) {
456
- stepper.succeed(
457
- stepFileCount > 0 ? `${entityLabel} was integrated (${stepFileCount} file(s))` : `${entityLabel} was integrated`
458
- );
459
- return;
460
- }
461
- if (currentStepHeader.startsWith("Running setup")) {
462
- stepper.succeed(`${entityLabel} setup completed`);
463
- currentStepHeader = "";
464
- return;
465
- }
466
- if (state.setupReached || currentStepHeader.startsWith("Cleaning")) return;
467
- const context = stepItems.length > 0 ? stepItems.join(", ") : void 0;
468
- stepper.succeed(base, context);
469
- };
470
- stepper.start("Analyzing existing setup...", "skills");
471
- child.stdout.on("data", (chunk) => {
472
- lineBuffer += chunk.toString();
473
- const lines = lineBuffer.split("\n");
474
- lineBuffer = lines.pop() ?? "";
475
- lines.filter(Boolean).forEach((line) => {
476
- try {
477
- const event = JSON.parse(line);
478
- if (event.type === "result" && event.result) {
479
- resultText = event.result;
480
- return;
481
- }
482
- if (event.type !== "assistant") return;
483
- const blocks = event.message?.content;
484
- if (!blocks) return;
485
- blocks.forEach((block) => {
486
- if (block.type === "text" && block.text) {
487
- block.text.split("\n").forEach((ln) => {
488
- const trimmed = ln.trim();
489
- if (!trimmed || isNoise(trimmed)) return;
490
- if (trimmed === "Done") {
491
- succeedCurrentStep();
492
- currentStepHeader = "";
493
- state.doneReceived = true;
494
- return;
495
- }
496
- if (isStepHeader(ln, trimmed)) {
497
- if (trimmed === currentStepHeader) return;
498
- succeedCurrentStep();
499
- if (state.setupReached || trimmed.startsWith("Cleaning"))
500
- return;
501
- currentStepHeader = trimmed;
502
- stepItems = [];
503
- stepFileCount = 0;
504
- if (trimmed.startsWith("Running setup")) {
505
- state.setupReached = true;
506
- }
507
- stepper.start(trimmed, stepCategory(trimmed));
508
- } else {
509
- stepItems.push(trimmed);
510
- if (currentStepHeader.startsWith("Integrating")) {
511
- stepper.item(trimmed);
512
- }
513
- }
514
- });
515
- }
516
- if (block.type === "tool_use" && (block.name === "Write" || block.name === "Edit")) {
517
- const filePath = block.input?.file_path;
518
- if (typeof filePath === "string") {
519
- const normalized = filePath.replace(/\\/g, "/");
520
- const base = providerDir.replace(/\\/g, "/");
521
- const isProviderFile = normalized.includes(`${base}/`);
522
- const relative2 = toRelativePath(filePath, providerDir);
523
- if (isProviderFile) writtenFiles.push(relative2);
524
- stepFileCount++;
525
- if (currentStepHeader.startsWith("Integrating")) {
526
- stepper.item(relative2);
527
- }
528
- }
529
- }
530
- });
531
- } catch {
532
- }
533
- });
534
- });
535
- let stderrBuffer = "";
536
- child.stderr.on("data", (chunk) => {
537
- stderrBuffer += chunk.toString();
538
- });
539
- child.on("error", (err) => {
540
- if ("code" in err && err.code === "ENOENT") {
541
- reject(
542
- new Error(
543
- "Claude CLI not found. Install it: npm i -g @anthropic-ai/claude-code"
544
- )
545
- );
546
- return;
547
- }
548
- reject(err);
549
- });
550
- child.on("close", (code) => {
551
- if (currentStepHeader) {
552
- succeedCurrentStep();
553
- }
554
- state.doneReceived || resultText.length > 0;
555
- const stderr = stderrBuffer.trim();
556
- if (code !== 0 && code !== null) {
557
- const detail = stderr ? `
558
- ${stderr}` : "";
559
- reject(new Error(`Claude CLI exited with code ${code}${detail}`));
560
- return;
561
- }
562
- if (code === null) {
563
- const detail = stderr ? `
564
- ${stderr}` : "";
565
- reject(
566
- new Error(`Claude CLI was interrupted before completing${detail}`)
567
- );
568
- return;
569
- }
570
- resolve2({
571
- success: true,
572
- output: resultText,
573
- files: writtenFiles
574
- });
575
- });
563
+ var downloadFile = async (source) => {
564
+ return {
565
+ content: await getContent(source),
566
+ source
567
+ };
568
+ };
569
+ //#endregion
570
+ //#region src/core/installer/shared/download-entries.ts
571
+ var toGitHubSource = (entry, ref) => ({
572
+ kind: "github",
573
+ owner: entry.owner,
574
+ repository: entry.repository,
575
+ path: entry.path,
576
+ ref
576
577
  });
577
- const installSingleSkill = (input, stepper) => spawnClaude(
578
- writeSkillInstructionsFile(input),
579
- stepper,
580
- input.providerDir,
581
- input.model,
582
- "Skill"
583
- );
584
- const installSkillset = (input, stepper) => spawnClaude(
585
- writeInstructionsFile(input),
586
- stepper,
587
- input.providerDir,
588
- input.model
589
- );
590
- const hash = (content) => createHash("sha256").update(content).digest("hex");
591
- const collectFiles = (dir) => readdirSync(dir).flatMap((entry) => {
592
- const fullPath = join(dir, entry);
593
- return statSync(fullPath).isDirectory() ? collectFiles(fullPath) : [fullPath];
578
+ var downloadEntries = async (entries, location, onFile) => {
579
+ return await Promise.all(entries.map((entry) => {
580
+ return downloadFile(toGitHubSource(entry, location.ref)).then((result) => {
581
+ onFile(entry.type, entry.path);
582
+ return {
583
+ ...result,
584
+ type: entry.type,
585
+ path: entry.path,
586
+ skillName: entry.skillName
587
+ };
588
+ });
589
+ }));
590
+ };
591
+ //#endregion
592
+ //#region src/core/installer/shared/summary.ts
593
+ var buildFileTree = (paths) => {
594
+ const root = {
595
+ name: "",
596
+ children: []
597
+ };
598
+ paths.forEach((p) => {
599
+ const parts = p.split("/");
600
+ let current = root;
601
+ parts.forEach((part) => {
602
+ const existing = current.children.find((c) => c.name === part);
603
+ if (existing) current = existing;
604
+ else {
605
+ const node = {
606
+ name: part,
607
+ children: []
608
+ };
609
+ current.children.push(node);
610
+ current = node;
611
+ }
612
+ });
613
+ });
614
+ return root;
615
+ };
616
+ var renderTree = (node, prefix = "") => node.children.flatMap((child, i) => {
617
+ const isLast = i === node.children.length - 1;
618
+ const connector = isLast ? "└─" : "ā”œā”€";
619
+ const childPrefix = prefix + (isLast ? " " : "│ ");
620
+ return [`${prefix}${connector} ${child.children.length > 0 ? `${child.name}/` : child.name}`, ...renderTree(child, childPrefix)];
594
621
  });
595
- const pruneUnchanged = (downloadDir, providerDir) => {
596
- const files = collectFiles(downloadDir);
597
- let removed = 0;
598
- files.forEach((downloadedFile) => {
599
- const relativePath = relative(downloadDir, downloadedFile);
600
- const existingFile = join(providerDir, relativePath);
601
- if (!existsSync(existingFile)) return;
602
- const downloadedHash = hash(readFileSync(downloadedFile, "utf-8"));
603
- const existingHash = hash(readFileSync(existingFile, "utf-8"));
604
- if (downloadedHash === existingHash) {
605
- unlinkSync(downloadedFile);
606
- removed++;
607
- }
608
- });
609
- return removed;
622
+ var printSummary = (files, providerPath) => {
623
+ if (files.length === 0) return;
624
+ const tree = buildFileTree([...new Set(files)].map((f) => stripProviderPrefix(f, providerPath)));
625
+ process.stdout.write(`\nšŸ“‚${providerPath}\n`);
626
+ renderTree(tree).forEach((line) => {
627
+ process.stdout.write(` ${dim$1}${line}${reset$2}\n`);
628
+ });
610
629
  };
611
- const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---/;
612
- const hasStringName = (value) => typeof value === "object" && value !== null && "name" in value && typeof value.name === "string";
613
- const deriveSkillName = (content, fileName) => {
614
- const match = content.match(frontmatterPattern);
615
- if (match) {
616
- const frontmatter = parse(match[1]);
617
- if (hasStringName(frontmatter)) return frontmatter.name;
618
- }
619
- return fileName.replace(/\.md$/i, "").toLowerCase();
630
+ var printCompleted = (startedAt) => {
631
+ const elapsed = Math.round((Date.now() - startedAt) / 1e3);
632
+ const timeStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${(elapsed % 60).toString().padStart(2, "0")}s` : `${elapsed}s`;
633
+ process.stdout.write(`${green}āœ”${reset$2} Installation completed ${dim$1}in ${timeStr}${reset$2}\n`);
620
634
  };
621
- const hasDefaultBranch = (value) => typeof value === "object" && value !== null && "default_branch" in value && typeof value.default_branch === "string";
622
- const fetchDefaultBranch = async (owner, repository) => {
623
- const response = await fetch(
624
- `https://api.github.com/repos/${owner}/${repository}`
625
- );
626
- if (!response.ok) {
627
- return "main";
628
- }
629
- const data = await response.json();
630
- return hasDefaultBranch(data) ? data.default_branch : "main";
635
+ //#endregion
636
+ //#region src/core/installer/shared/write-to-temp.ts
637
+ var writeFilesToTemp = async (downloadDir, files, onFile) => {
638
+ await Promise.all(files.map(async ({ relativePath, content }) => {
639
+ const filePath = safePath(downloadDir, relativePath);
640
+ await mkdir(dirname(filePath), { recursive: true });
641
+ await writeFile(filePath, content, "utf-8");
642
+ onFile?.(relativePath);
643
+ }));
631
644
  };
632
- const requiredFields = ["name", "version", "description", "provider"];
633
- const validateSkillset = (data) => {
634
- if (!data || typeof data !== "object") {
635
- throw new Error("Invalid skillset: expected a YAML object.");
636
- }
637
- const record = data;
638
- const missing = requiredFields.filter(
639
- (field) => typeof record[field] !== "string"
640
- );
641
- if (missing.length > 0) {
642
- throw new Error(
643
- `Invalid skillset: missing required fields: ${missing.join(", ")}.`
644
- );
645
- }
646
- return record;
645
+ //#endregion
646
+ //#region src/core/installer/spawn-claude/parse-stream.ts
647
+ var createStreamParser = (onEvent) => {
648
+ let lineBuffer = "";
649
+ let resultText = "";
650
+ const onChunk = (chunk) => {
651
+ lineBuffer += chunk.toString();
652
+ const lines = lineBuffer.split("\n");
653
+ lineBuffer = lines.pop() ?? "";
654
+ lines.filter(Boolean).forEach((line) => {
655
+ try {
656
+ const event = JSON.parse(line);
657
+ if (event.type === "result" && event.result) {
658
+ resultText = event.result;
659
+ return;
660
+ }
661
+ onEvent(event);
662
+ } catch {}
663
+ });
664
+ };
665
+ return {
666
+ onChunk,
667
+ getResultText: () => resultText
668
+ };
647
669
  };
648
- const fetchSkillset = async (location) => {
649
- const url = `https://raw.githubusercontent.com/${location.owner}/${location.repository}/${location.ref}/${location.path}`;
650
- const response = await fetch(url);
651
- if (!response.ok) {
652
- throw new Error(
653
- `Failed to fetch skillset from ${url} (${response.status} ${response.statusText})`
654
- );
655
- }
656
- const text = await response.text();
657
- return validateSkillset(parse(text));
670
+ //#endregion
671
+ //#region src/core/installer/spawn-claude/text-utils.ts
672
+ var isNoise = (line) => line === "```" || line.startsWith("```") || /^(now |let me |the files? |i'll |i will |looking |checking )/i.test(line);
673
+ var isStepHeader = (raw, trimmed) => !raw.startsWith(" ") && !raw.startsWith(" ") && trimmed.endsWith("...");
674
+ var stepCategory = (header) => {
675
+ if (header.startsWith("Integrating") || header.startsWith("Analyzing") || header.startsWith("Detecting")) return "skills";
676
+ return "generic";
658
677
  };
659
- const detectKind = (path) => /\.md$/i.test(path) ? "skill" : "skillset";
660
- const parseGitHubUrl = (url) => {
661
- const blobOrTree = url.match(
662
- /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:tree|blob)\/([^/]+)\/(.+)$/
663
- );
664
- if (blobOrTree) {
665
- const identifier = {
666
- owner: blobOrTree[1],
667
- repository: blobOrTree[2],
668
- path: blobOrTree[4],
669
- ref: blobOrTree[3]
670
- };
671
- return { kind: detectKind(identifier.path), identifier };
672
- }
673
- const raw = url.match(
674
- /^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/
675
- );
676
- if (raw) {
677
- const identifier = {
678
- owner: raw[1],
679
- repository: raw[2],
680
- path: raw[4],
681
- ref: raw[3]
682
- };
683
- return { kind: detectKind(identifier.path), identifier };
684
- }
685
- return void 0;
678
+ var irregulars = { Running: "Ran" };
679
+ var toSuccessText = (header) => {
680
+ const text = header.replace(/\.{3}$/, "").trim();
681
+ const firstWord = text.split(" ")[0];
682
+ if (irregulars[firstWord]) return text.replace(firstWord, irregulars[firstWord]);
683
+ return text.replace(/^(\w+)ing\b/, "$1ed");
686
684
  };
687
- const parseAtIdentifier = (input) => {
688
- const parts = input.slice(1).split("/").filter(Boolean);
689
- if (parts.length < 3) {
690
- throw new Error(
691
- `Invalid identifier "${input}". Expected format: @owner/repo/path (e.g., @supa-magic/skillbox/claude/fsd).`
692
- );
693
- }
694
- const identifier = {
695
- owner: parts[0],
696
- repository: parts[1],
697
- path: parts.slice(2).join("/")
698
- };
699
- return { kind: detectKind(identifier.path), identifier };
685
+ var toRelativePath = (filePath, providerDir) => {
686
+ const normalized = filePath.replace(/\\/g, "/");
687
+ const marker = `${providerDir.replace(/\\/g, "/")}/`;
688
+ const idx = normalized.lastIndexOf(marker);
689
+ if (idx !== -1) return normalized.slice(idx + marker.length);
690
+ return normalized.split("/").pop() ?? normalized;
691
+ };
692
+ //#endregion
693
+ //#region src/core/installer/spawn-claude/step-tracker.ts
694
+ var createStepTracker = (stepper, providerDir, entityLabel) => {
695
+ let currentStepHeader = "Analyzing existing setup...";
696
+ let stepItems = [];
697
+ let stepFileCount = 0;
698
+ const state = { setupReached: false };
699
+ const writtenFiles = /* @__PURE__ */ new Set();
700
+ const succeedCurrentStep = () => {
701
+ if (!currentStepHeader) return;
702
+ const base = toSuccessText(currentStepHeader);
703
+ if (currentStepHeader.startsWith("Detecting")) {
704
+ const noConflicts = stepItems.some((i) => /no conflict/i.test(i));
705
+ stepper.succeed(noConflicts ? "No conflicts" : base);
706
+ return;
707
+ }
708
+ if (currentStepHeader.startsWith("Integrating")) {
709
+ stepper.succeed(stepFileCount > 0 ? `${entityLabel} was integrated (${stepFileCount} file(s))` : `${entityLabel} was integrated`);
710
+ return;
711
+ }
712
+ if (currentStepHeader.startsWith("Running setup")) {
713
+ stepper.succeed(`${entityLabel} setup completed`);
714
+ currentStepHeader = "";
715
+ return;
716
+ }
717
+ if (state.setupReached || currentStepHeader.startsWith("Cleaning")) return;
718
+ const context = stepItems.length > 0 ? stepItems.join(", ") : void 0;
719
+ stepper.succeed(base, context);
720
+ };
721
+ const processTextLine = (raw) => {
722
+ const trimmed = raw.trim();
723
+ if (!trimmed || isNoise(trimmed)) return;
724
+ if (trimmed === "Done") {
725
+ succeedCurrentStep();
726
+ currentStepHeader = "";
727
+ return;
728
+ }
729
+ if (isStepHeader(raw, trimmed)) {
730
+ if (trimmed === currentStepHeader) return;
731
+ succeedCurrentStep();
732
+ if (state.setupReached || trimmed.startsWith("Cleaning")) return;
733
+ currentStepHeader = trimmed;
734
+ stepItems = [];
735
+ stepFileCount = 0;
736
+ if (trimmed.startsWith("Running setup")) state.setupReached = true;
737
+ stepper.start(trimmed, stepCategory(trimmed));
738
+ } else stepItems.push(trimmed);
739
+ };
740
+ const processToolUse = (block) => {
741
+ if (block.name !== "Write" && block.name !== "Edit") return;
742
+ const filePath = block.input?.file_path;
743
+ if (typeof filePath !== "string") return;
744
+ const normalized = filePath.replace(/\\/g, "/");
745
+ const base = providerDir.replace(/\\/g, "/");
746
+ const isProviderFile = normalized.includes(`${base}/`);
747
+ const relative = toRelativePath(filePath, providerDir);
748
+ if (isProviderFile && !writtenFiles.has(relative)) {
749
+ writtenFiles.add(relative);
750
+ stepFileCount++;
751
+ if (currentStepHeader.startsWith("Integrating")) stepper.item(relative);
752
+ }
753
+ };
754
+ const processBlock = (block) => {
755
+ if (block.type === "text" && block.text) block.text.split("\n").forEach(processTextLine);
756
+ if (block.type === "tool_use") processToolUse(block);
757
+ };
758
+ return {
759
+ processBlock,
760
+ getWrittenFiles: () => [...writtenFiles],
761
+ finalize: () => {
762
+ if (currentStepHeader) succeedCurrentStep();
763
+ }
764
+ };
765
+ };
766
+ //#endregion
767
+ //#region src/core/installer/spawn-claude/spawn-claude.ts
768
+ var debug = (() => {
769
+ const logPath = process.env.SPM_DEBUG;
770
+ if (!logPath) return void 0;
771
+ mkdirSync(dirname(logPath), { recursive: true });
772
+ return (label, data) => appendFileSync(logPath, `[${label}] ${data}\n`, "utf-8");
773
+ })();
774
+ var spawnClaude = (instructionsFilePath, stepper, providerDir, model, entityLabel = "Skillset") => new Promise((resolve, reject) => {
775
+ const args = [
776
+ "-p",
777
+ "Install the skill as instructed.",
778
+ "--append-system-prompt-file",
779
+ instructionsFilePath,
780
+ "--verbose",
781
+ "--output-format",
782
+ "stream-json",
783
+ "--permission-mode",
784
+ "acceptEdits",
785
+ "--allowedTools",
786
+ "Read,Write,Edit,Bash,Glob,Grep",
787
+ ...model ? ["--model", model] : []
788
+ ];
789
+ debug?.("spawn", `claude ${args.join(" ")}`);
790
+ debug?.("config", `providerDir=${providerDir} instructions=${instructionsFilePath}`);
791
+ const child = spawn("claude", args, {
792
+ shell: process.platform === "win32",
793
+ stdio: [
794
+ "ignore",
795
+ "pipe",
796
+ "pipe"
797
+ ]
798
+ });
799
+ const tracker = createStepTracker(stepper, providerDir, entityLabel);
800
+ const stream = createStreamParser((event) => {
801
+ debug?.("event", JSON.stringify(event));
802
+ if (event.type !== "assistant") return;
803
+ const blocks = event.message?.content;
804
+ if (!blocks) return;
805
+ blocks.forEach((block) => tracker.processBlock(block));
806
+ });
807
+ stepper.start("Analyzing existing setup...", "skills");
808
+ child.stdout.on("data", (chunk) => {
809
+ debug?.("stdout", chunk.toString());
810
+ stream.onChunk(chunk);
811
+ });
812
+ let stderrBuffer = "";
813
+ child.stderr.on("data", (chunk) => {
814
+ const text = chunk.toString();
815
+ debug?.("stderr", text);
816
+ stderrBuffer += text;
817
+ });
818
+ child.on("error", (err) => {
819
+ debug?.("error", err.message);
820
+ if ("code" in err && err.code === "ENOENT") {
821
+ reject(/* @__PURE__ */ new Error("Claude CLI not found. Install it: npm i -g @anthropic-ai/claude-code"));
822
+ return;
823
+ }
824
+ reject(err);
825
+ });
826
+ child.on("close", (code) => {
827
+ tracker.finalize();
828
+ const stderr = stderrBuffer.trim();
829
+ const writtenFiles = tracker.getWrittenFiles();
830
+ debug?.("close", `code=${code} stderr=${stderr}`);
831
+ debug?.("files", JSON.stringify(writtenFiles));
832
+ debug?.("result", stream.getResultText());
833
+ if (code !== 0 && code !== null) {
834
+ const detail = stderr ? `\n${stderr}` : "";
835
+ reject(/* @__PURE__ */ new Error(`Claude CLI exited with code ${code}${detail}`));
836
+ return;
837
+ }
838
+ if (code === null) {
839
+ const detail = stderr ? `\n${stderr}` : "";
840
+ reject(/* @__PURE__ */ new Error(`Claude CLI was interrupted before completing${detail}`));
841
+ return;
842
+ }
843
+ resolve({
844
+ success: true,
845
+ output: stream.getResultText(),
846
+ files: writtenFiles
847
+ });
848
+ });
849
+ });
850
+ //#endregion
851
+ //#region src/core/resolver/derive-skill-name.ts
852
+ var frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---/;
853
+ var hasStringName = (value) => typeof value === "object" && value !== null && "name" in value && typeof value.name === "string";
854
+ var deriveSkillName = (content, fileName) => {
855
+ const match = content.match(frontmatterPattern);
856
+ if (match) {
857
+ const frontmatter = parse(match[1]);
858
+ if (hasStringName(frontmatter)) return frontmatter.name;
859
+ }
860
+ return fileName.replace(/\.md$/i, "").toLowerCase();
700
861
  };
701
- const parseIdentifier = (input) => {
702
- const trimmed = input.trim();
703
- if (trimmed.startsWith("https://") || trimmed.startsWith("http://")) {
704
- const result = parseGitHubUrl(trimmed);
705
- if (!result) {
706
- throw new Error(
707
- `Invalid GitHub URL "${trimmed}". Expected format: https://github.com/owner/repo/tree|blob/branch/path or https://raw.githubusercontent.com/owner/repo/ref/path.`
708
- );
709
- }
710
- return result;
711
- }
712
- if (!trimmed.startsWith("@")) {
713
- throw new Error(
714
- `Invalid identifier "${trimmed}". Must start with @ or be a GitHub URL.`
715
- );
716
- }
717
- return parseAtIdentifier(trimmed);
862
+ //#endregion
863
+ //#region src/core/resolver/fetch-default-branch.ts
864
+ var hasDefaultBranch = (value) => typeof value === "object" && value !== null && "default_branch" in value && typeof value.default_branch === "string";
865
+ var fetchDefaultBranch = async (owner, repository) => {
866
+ const response = await fetch(`https://api.github.com/repos/${owner}/${repository}`);
867
+ if (!response.ok) return "main";
868
+ const data = await response.json();
869
+ return hasDefaultBranch(data) ? data.default_branch : "main";
718
870
  };
719
- const markdownLinkPattern = /\[[^\]]*\]\(([^)]+\.md)\)/g;
720
- const inlinePathPattern = /(?:`|(?:^|\s))(\.[^\s`]*\.md)/g;
721
- const providerPrefixes = [
722
- ".claude/",
723
- ".cursor/",
724
- ".copilot/",
725
- ".aider/",
726
- ".codeium/",
727
- ".cody/"
871
+ //#endregion
872
+ //#region src/core/resolver/fetch-skillset.ts
873
+ var requiredFields = [
874
+ "name",
875
+ "version",
876
+ "description",
877
+ "provider"
728
878
  ];
729
- const isExcluded = (ref) => {
730
- const normalized = ref.replace(/^\.\//, "");
731
- return ref.startsWith("http://") || ref.startsWith("https://") || ref.startsWith("/") || ref.includes("..") || providerPrefixes.some((prefix) => normalized.startsWith(prefix));
879
+ var validateSkillset = (data) => {
880
+ if (!data || typeof data !== "object") throw new Error("Invalid skillset: expected a YAML object.");
881
+ const record = data;
882
+ const missing = requiredFields.filter((field) => typeof record[field] !== "string");
883
+ if (missing.length > 0) throw new Error(`Invalid skillset: missing required fields: ${missing.join(", ")}.`);
884
+ return record;
732
885
  };
733
- const parseSkillRefs = (content, fileDir) => {
734
- const refs = /* @__PURE__ */ new Set();
735
- const collect = (pattern) => {
736
- for (const match of content.matchAll(pattern)) {
737
- const ref = match[1];
738
- if (!isExcluded(ref)) {
739
- const resolved = posix.normalize(posix.join(fileDir, ref));
740
- refs.add(resolved);
741
- }
742
- }
743
- };
744
- collect(markdownLinkPattern);
745
- collect(inlinePathPattern);
746
- return [...refs];
886
+ var fetchSkillset = async (location) => {
887
+ const url = `https://raw.githubusercontent.com/${location.owner}/${location.repository}/${location.ref}/${location.path}`;
888
+ const response = await fetch(url);
889
+ if (!response.ok) throw new Error(`Failed to fetch skillset from ${url} (${response.status} ${response.statusText})`);
890
+ return validateSkillset(parse(await response.text()));
747
891
  };
748
- const resolveIdentifier = async (input) => {
749
- const parsed = parseIdentifier(input);
750
- const { identifier } = parsed;
751
- const ref = identifier.ref ?? await fetchDefaultBranch(identifier.owner, identifier.repository);
752
- return {
753
- owner: identifier.owner,
754
- repository: identifier.repository,
755
- path: `${identifier.path}/skillset.yml`,
756
- ref
757
- };
892
+ //#endregion
893
+ //#region src/core/resolver/parse-identifier.ts
894
+ var detectKind = (path) => /\.md$/i.test(path) ? "skill" : "skillset";
895
+ var parseGitHubUrl = (url) => {
896
+ const blobOrTree = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:tree|blob)\/([^/]+)\/(.+)$/);
897
+ if (blobOrTree) {
898
+ const identifier = {
899
+ owner: blobOrTree[1],
900
+ repository: blobOrTree[2],
901
+ path: blobOrTree[4],
902
+ ref: blobOrTree[3]
903
+ };
904
+ return {
905
+ kind: detectKind(identifier.path),
906
+ identifier
907
+ };
908
+ }
909
+ const raw = url.match(/^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
910
+ if (raw) {
911
+ const identifier = {
912
+ owner: raw[1],
913
+ repository: raw[2],
914
+ path: raw[4],
915
+ ref: raw[3]
916
+ };
917
+ return {
918
+ kind: detectKind(identifier.path),
919
+ identifier
920
+ };
921
+ }
758
922
  };
759
- const resolveSkill = async (identifier) => {
760
- const ref = identifier.ref ?? await fetchDefaultBranch(identifier.owner, identifier.repository);
761
- const visited = /* @__PURE__ */ new Set();
762
- const files = [];
763
- const fetchRecursive = async (repoPath) => {
764
- const normalized = posix.normalize(repoPath);
765
- if (visited.has(normalized)) return;
766
- visited.add(normalized);
767
- const content = await downloadFromGitHub({
768
- owner: identifier.owner,
769
- repository: identifier.repository,
770
- path: normalized,
771
- ref
772
- });
773
- files.push({ path: normalized, content });
774
- const fileDir = posix.dirname(normalized);
775
- const refs = parseSkillRefs(content, fileDir);
776
- await Promise.all(refs.map((refPath) => fetchRecursive(refPath)));
777
- };
778
- await fetchRecursive(identifier.path);
779
- const mainFile = files[0];
780
- const fileName = posix.basename(identifier.path);
781
- const name = mainFile ? deriveSkillName(mainFile.content, fileName) : fileName.replace(/\.md$/i, "");
782
- return {
783
- name,
784
- location: {
785
- owner: identifier.owner,
786
- repository: identifier.repository,
787
- path: identifier.path,
788
- ref
789
- },
790
- files
791
- };
923
+ var parseAtIdentifier = (input) => {
924
+ const parts = input.slice(1).split("/").filter(Boolean);
925
+ if (parts.length < 3) throw new Error(`Invalid identifier "${input}". Expected format: @owner/repo/path (e.g., @supa-magic/skillbox/claude/fsd).`);
926
+ const identifier = {
927
+ owner: parts[0],
928
+ repository: parts[1],
929
+ path: parts.slice(2).join("/")
930
+ };
931
+ return {
932
+ kind: detectKind(identifier.path),
933
+ identifier
934
+ };
792
935
  };
793
- const resolveSkillSource = (source, location) => {
794
- if (source.startsWith("./")) {
795
- const skillsetDir = location.path.replace(/\/skillset\.yml$/, "");
796
- const relativePath = source.slice(2).replace(/\/+$/, "");
797
- return {
798
- owner: location.owner,
799
- repository: location.repository,
800
- basePath: `${skillsetDir}/${relativePath}`
801
- };
802
- }
803
- if (source.startsWith("@")) {
804
- const parts = source.slice(1).split("/").filter(Boolean);
805
- if (parts.length < 3) {
806
- throw new Error(
807
- `Invalid cross-repo source "${source}". Expected format: @owner/repo/path.`
808
- );
809
- }
810
- return {
811
- owner: parts[0],
812
- repository: parts[1],
813
- basePath: parts.slice(2).join("/")
814
- };
815
- }
816
- const normalized = source.replace(/^\/+/, "").replace(/\/+$/, "");
817
- if (normalized.includes("..")) {
818
- throw new Error(
819
- `Invalid skill source "${source}". Path traversal ("..") is not allowed.`
820
- );
821
- }
822
- return {
823
- owner: location.owner,
824
- repository: location.repository,
825
- basePath: normalized
826
- };
936
+ var parseIdentifier = (input) => {
937
+ const trimmed = input.trim();
938
+ if (trimmed.startsWith("https://") || trimmed.startsWith("http://")) {
939
+ const result = parseGitHubUrl(trimmed);
940
+ if (!result) throw new Error(`Invalid GitHub URL "${trimmed}". Expected format: https://github.com/owner/repo/tree|blob/branch/path or https://raw.githubusercontent.com/owner/repo/ref/path.`);
941
+ return result;
942
+ }
943
+ if (!trimmed.startsWith("@")) throw new Error(`Invalid identifier "${trimmed}". Must start with @ or be a GitHub URL.`);
944
+ return parseAtIdentifier(trimmed);
827
945
  };
828
- const resolveSkillset = (skillset, location) => {
829
- const entries = [];
830
- const skillsetDir = location.path.replace(/\/skillset\.yml$/, "");
831
- if (skillset.setup) {
832
- entries.push({
833
- owner: location.owner,
834
- repository: location.repository,
835
- path: `${skillsetDir}/${skillset.setup}`,
836
- type: "setup"
837
- });
838
- }
839
- if (skillset.skills) {
840
- Object.entries(skillset.skills).forEach(
841
- ([skillName, { source, files }]) => {
842
- const resolved = resolveSkillSource(source, location);
843
- files.forEach((file) => {
844
- entries.push({
845
- owner: resolved.owner,
846
- repository: resolved.repository,
847
- path: `${resolved.basePath}/${file}`,
848
- type: "skill",
849
- skillName
850
- });
851
- });
852
- }
853
- );
854
- }
855
- const localFileTypes = ["agents", "hooks", "mcp", "memory", "rules"];
856
- const typeMap = {
857
- agents: "agent",
858
- hooks: "hook",
859
- mcp: "mcp",
860
- memory: "memory",
861
- rules: "rule"
862
- };
863
- localFileTypes.forEach((key) => {
864
- const files = skillset[key];
865
- if (files) {
866
- files.forEach((file) => {
867
- entries.push({
868
- owner: location.owner,
869
- repository: location.repository,
870
- path: `${skillsetDir}/${file}`,
871
- type: typeMap[key]
872
- });
873
- });
874
- }
875
- });
876
- return entries;
946
+ //#endregion
947
+ //#region src/core/resolver/parse-skill-refs.ts
948
+ var markdownLinkPattern = /\[[^\]]*\]\(([^)]+\.md)\)/g;
949
+ var inlinePathPattern = /(?:`|(?:^|\s))(\.[^\s`]*\.md)/g;
950
+ var providerPrefixes = [
951
+ ".claude/",
952
+ ".cursor/",
953
+ ".copilot/",
954
+ ".aider/",
955
+ ".codeium/",
956
+ ".cody/"
957
+ ];
958
+ var isExcluded = (ref) => {
959
+ const normalized = ref.replace(/^\.\//, "");
960
+ return ref.startsWith("http://") || ref.startsWith("https://") || ref.startsWith("/") || ref.includes("..") || /\{[^}]+\}/.test(ref) || providerPrefixes.some((prefix) => normalized.startsWith(prefix));
877
961
  };
878
- const toGitHubSource = (entry, ref) => ({
879
- kind: "github",
880
- owner: entry.owner,
881
- repository: entry.repository,
882
- path: entry.path,
883
- ref
884
- });
885
- const downloadEntries = async (entries, location, onFile) => {
886
- const results = await Promise.all(
887
- entries.map((entry) => {
888
- const source = toGitHubSource(entry, location.ref);
889
- return downloadFile(source).then((result) => {
890
- onFile(entry.type, entry.path);
891
- return {
892
- ...result,
893
- type: entry.type,
894
- path: entry.path,
895
- skillName: entry.skillName
896
- };
897
- });
898
- })
899
- );
900
- return results;
962
+ var stripCodeBlocks = (text) => text.replace(/^(`{3,})[^\n]*\n[\s\S]*?\n\1\s*$/gm, "");
963
+ var parseSkillRefs = (content, fileDir) => {
964
+ const refs = /* @__PURE__ */ new Set();
965
+ const stripped = stripCodeBlocks(content);
966
+ const collect = (pattern) => {
967
+ for (const match of stripped.matchAll(pattern)) {
968
+ const ref = match[1];
969
+ if (!isExcluded(ref)) {
970
+ const resolved = posix.normalize(posix.join(fileDir, ref));
971
+ refs.add(resolved);
972
+ }
973
+ }
974
+ };
975
+ collect(markdownLinkPattern);
976
+ collect(inlinePathPattern);
977
+ return [...refs];
978
+ };
979
+ //#endregion
980
+ //#region src/core/resolver/resolve-identifier.ts
981
+ var resolveIdentifier = async (input) => {
982
+ const { identifier } = parseIdentifier(input);
983
+ const ref = identifier.ref ?? await fetchDefaultBranch(identifier.owner, identifier.repository);
984
+ return {
985
+ owner: identifier.owner,
986
+ repository: identifier.repository,
987
+ path: `${identifier.path}/skillset.yml`,
988
+ ref
989
+ };
901
990
  };
902
- const safePath = (baseDir, filePath) => {
903
- const resolved = resolve(baseDir, normalize(filePath));
904
- if (!resolved.startsWith(baseDir)) {
905
- throw new Error(`Path traversal detected: "${filePath}"`);
906
- }
907
- return resolved;
991
+ //#endregion
992
+ //#region src/core/resolver/resolve-skill.ts
993
+ var resolveSkill = async (identifier) => {
994
+ const ref = identifier.ref ?? await fetchDefaultBranch(identifier.owner, identifier.repository);
995
+ const visited = /* @__PURE__ */ new Set();
996
+ const files = [];
997
+ const unresolvedRefs = [];
998
+ const fetchRecursive = async (repoPath, isRoot) => {
999
+ const normalized = posix.normalize(repoPath);
1000
+ if (visited.has(normalized)) return;
1001
+ visited.add(normalized);
1002
+ let content;
1003
+ try {
1004
+ content = await downloadFromGitHub({
1005
+ kind: "github",
1006
+ owner: identifier.owner,
1007
+ repository: identifier.repository,
1008
+ path: normalized,
1009
+ ref
1010
+ });
1011
+ } catch (err) {
1012
+ if (isRoot) throw err;
1013
+ unresolvedRefs.push(normalized);
1014
+ return;
1015
+ }
1016
+ files.push({
1017
+ path: normalized,
1018
+ content
1019
+ });
1020
+ const fileDir = posix.dirname(normalized);
1021
+ const refs = parseSkillRefs(content, fileDir);
1022
+ await Promise.all(refs.map((refPath) => fetchRecursive(refPath, false)));
1023
+ };
1024
+ await fetchRecursive(identifier.path, true);
1025
+ const mainFile = files[0];
1026
+ const fileName = posix.basename(identifier.path);
1027
+ return {
1028
+ name: mainFile ? deriveSkillName(mainFile.content, fileName) : fileName.replace(/\.md$/i, ""),
1029
+ location: {
1030
+ owner: identifier.owner,
1031
+ repository: identifier.repository,
1032
+ path: identifier.path,
1033
+ ref
1034
+ },
1035
+ files,
1036
+ unresolvedRefs
1037
+ };
908
1038
  };
909
- const buildFileTree = (paths) => {
910
- const root = { name: "", children: [] };
911
- paths.forEach((p) => {
912
- const parts = p.split("/");
913
- let current = root;
914
- parts.forEach((part) => {
915
- const existing = current.children.find((c) => c.name === part);
916
- if (existing) {
917
- current = existing;
918
- } else {
919
- const node = { name: part, children: [] };
920
- current.children.push(node);
921
- current = node;
922
- }
923
- });
924
- });
925
- return root;
1039
+ //#endregion
1040
+ //#region src/core/resolver/resolve-skillset.ts
1041
+ var resolveSkillSource = (source, location) => {
1042
+ if (source.startsWith("./")) {
1043
+ const skillsetDir = location.path.replace(/\/skillset\.yml$/, "");
1044
+ const relativePath = source.slice(2).replace(/\/+$/, "");
1045
+ return {
1046
+ owner: location.owner,
1047
+ repository: location.repository,
1048
+ basePath: `${skillsetDir}/${relativePath}`
1049
+ };
1050
+ }
1051
+ if (source.startsWith("@")) {
1052
+ const parts = source.slice(1).split("/").filter(Boolean);
1053
+ if (parts.length < 3) throw new Error(`Invalid cross-repo source "${source}". Expected format: @owner/repo/path.`);
1054
+ return {
1055
+ owner: parts[0],
1056
+ repository: parts[1],
1057
+ basePath: parts.slice(2).join("/")
1058
+ };
1059
+ }
1060
+ const normalized = source.replace(/^\/+/, "").replace(/\/+$/, "");
1061
+ if (normalized.includes("..")) throw new Error(`Invalid skill source "${source}". Path traversal ("..") is not allowed.`);
1062
+ return {
1063
+ owner: location.owner,
1064
+ repository: location.repository,
1065
+ basePath: normalized
1066
+ };
926
1067
  };
927
- const renderTree = (node, prefix = "") => node.children.flatMap((child, i) => {
928
- const isLast = i === node.children.length - 1;
929
- const connector = isLast ? "└─" : "ā”œā”€";
930
- const childPrefix = prefix + (isLast ? " " : "│ ");
931
- const isDir = child.children.length > 0;
932
- const name = isDir ? `${child.name}/` : child.name;
933
- return [`${prefix}${connector} ${name}`, ...renderTree(child, childPrefix)];
1068
+ var resolveSkillset = (skillset, location) => {
1069
+ const entries = [];
1070
+ const skillsetDir = location.path.replace(/\/skillset\.yml$/, "");
1071
+ if (skillset.setup) entries.push({
1072
+ owner: location.owner,
1073
+ repository: location.repository,
1074
+ path: `${skillsetDir}/${skillset.setup}`,
1075
+ type: "setup"
1076
+ });
1077
+ if (skillset.skills) Object.entries(skillset.skills).forEach(([skillName, { source, files }]) => {
1078
+ const resolved = resolveSkillSource(source, location);
1079
+ files.forEach((file) => {
1080
+ entries.push({
1081
+ owner: resolved.owner,
1082
+ repository: resolved.repository,
1083
+ path: `${resolved.basePath}/${file}`,
1084
+ type: "skill",
1085
+ skillName
1086
+ });
1087
+ });
1088
+ });
1089
+ const localFileTypes = [
1090
+ "agents",
1091
+ "hooks",
1092
+ "mcp",
1093
+ "memory",
1094
+ "rules"
1095
+ ];
1096
+ const typeMap = {
1097
+ agents: "agent",
1098
+ hooks: "hook",
1099
+ mcp: "mcp",
1100
+ memory: "memory",
1101
+ rules: "rule"
1102
+ };
1103
+ localFileTypes.forEach((key) => {
1104
+ const files = skillset[key];
1105
+ if (files) files.forEach((file) => {
1106
+ entries.push({
1107
+ owner: location.owner,
1108
+ repository: location.repository,
1109
+ path: `${skillsetDir}/${file}`,
1110
+ type: typeMap[key]
1111
+ });
1112
+ });
1113
+ });
1114
+ return entries;
1115
+ };
1116
+ //#endregion
1117
+ //#region src/core/installer/prune-unchanged.ts
1118
+ var hash = (content) => createHash("sha256").update(content).digest("hex");
1119
+ var collectFiles = (dir) => readdirSync(dir).flatMap((entry) => {
1120
+ const fullPath = join(dir, entry);
1121
+ return statSync(fullPath).isDirectory() ? collectFiles(fullPath) : [fullPath];
934
1122
  });
935
- const stripProviderPrefix = (file, prefix) => {
936
- const norm = prefix.replace(/\\/g, "/").replace(/^\.\//, "");
937
- if (file.startsWith(`${norm}/`)) return file.slice(norm.length + 1);
938
- const bare = norm.replace(/^\./, "");
939
- if (bare && file.startsWith(`${bare}/`)) return file.slice(bare.length + 1);
940
- return file;
1123
+ var pruneUnchanged = (downloadDir, providerDir) => {
1124
+ const files = collectFiles(downloadDir);
1125
+ let removed = 0;
1126
+ files.forEach((downloadedFile) => {
1127
+ const existingFile = join(providerDir, relative(downloadDir, downloadedFile));
1128
+ if (!existsSync(existingFile)) return;
1129
+ if (hash(readFileSync(downloadedFile, "utf-8")) === hash(readFileSync(existingFile, "utf-8"))) {
1130
+ unlinkSync(downloadedFile);
1131
+ removed++;
1132
+ }
1133
+ });
1134
+ return removed;
1135
+ };
1136
+ //#endregion
1137
+ //#region src/core/installer/install-skill/install-skill-flow.ts
1138
+ var installSkillFlow = async (identifier, stepper, startedAt) => {
1139
+ const { config } = readConfig();
1140
+ stepper.start("Resolving skill...", "packages");
1141
+ const resolved = await resolveSkill(identifier);
1142
+ const locationRef = `${resolved.location.owner}/${resolved.location.repository}@${resolved.location.ref}`;
1143
+ stepper.succeed("Resolved", `${resolved.name} ${dim$1}${locationRef}${reset$2}`);
1144
+ const firstProvider = Object.entries(config.providers)[0];
1145
+ if (!firstProvider) throw new Error("No provider detected. Initialize a provider directory first (e.g., .claude/).");
1146
+ const [providerName, providerConfig] = firstProvider;
1147
+ const providerPath = providerConfig.path;
1148
+ const projectRoot = getProjectRoot();
1149
+ const downloadDir = join(projectRoot, ".spm", resolved.name);
1150
+ const skillDir = posix.dirname(resolved.location.path);
1151
+ stepper.start(`Downloading ${resolved.files.length} file(s)...`, "packages");
1152
+ await writeFilesToTemp(downloadDir, resolved.files.map((file) => ({
1153
+ relativePath: file.path.startsWith(`${skillDir}/`) ? file.path.slice(skillDir.length + 1) : file.path.split("/").pop() ?? file.path,
1154
+ content: file.content
1155
+ })), (relativePath) => stepper.item(relativePath));
1156
+ stepper.succeed(`Downloaded ${resolved.files.length} file(s)`);
1157
+ if (resolved.unresolvedRefs.length > 0) stepper.succeed(`Skipped ${resolved.unresolvedRefs.length} unresolved reference(s)`, resolved.unresolvedRefs.map((r) => r.split("/").pop()).join(", "));
1158
+ const providerFullPath = join(projectRoot, providerPath);
1159
+ const skillsBase = resolve(providerFullPath, "skills");
1160
+ const skillProviderDir = join(skillsBase, resolved.name);
1161
+ if (!resolve(skillProviderDir).startsWith(`${skillsBase}${sep}`)) throw new Error(`Invalid skill name: "${resolved.name}"`);
1162
+ const pruned = pruneUnchanged(downloadDir, skillProviderDir);
1163
+ if (pruned > 0) stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
1164
+ const source = `https://github.com/${resolved.location.owner}/${resolved.location.repository}/blob/${resolved.location.ref}/${resolved.location.path}`;
1165
+ const remainingFiles = collectRemainingFiles(downloadDir);
1166
+ const { newFiles, conflictFiles } = detectConflicts(remainingFiles, skillProviderDir);
1167
+ let result;
1168
+ if (conflictFiles.length === 0) result = copyFilesToProvider(newFiles, skillProviderDir, stepper, "Skill");
1169
+ else {
1170
+ const model = providerName === "claude" ? "sonnet" : void 0;
1171
+ const embedded = {
1172
+ downloadedFiles: remainingFiles,
1173
+ existingFiles: listExistingFiles(providerFullPath)
1174
+ };
1175
+ result = await spawnClaude(writeSkillInstructionsFile({
1176
+ providerDir: providerFullPath,
1177
+ skillName: resolved.name,
1178
+ source,
1179
+ configPath: getConfigPath(),
1180
+ model,
1181
+ unresolvedRefs: resolved.unresolvedRefs,
1182
+ embedded
1183
+ }), stepper, providerFullPath, model, "Skill");
1184
+ }
1185
+ addConfigEntry({
1186
+ providerPath,
1187
+ kind: "skills",
1188
+ name: resolved.name,
1189
+ source
1190
+ });
1191
+ await cleanupDownloadDir(projectRoot, downloadDir);
1192
+ stepper.stop();
1193
+ printCompleted(startedAt);
1194
+ printSummary(result.files, providerPath);
1195
+ process.stdout.write(`\nšŸŖ„ ${cyan}Restart your AI agent to apply the new skills.${reset$2}\n`);
941
1196
  };
942
- const printSummary = (files, providerPath) => {
943
- if (files.length === 0) return;
944
- const unique = [...new Set(files)].map(
945
- (f) => stripProviderPrefix(f, providerPath)
946
- );
947
- const tree = buildFileTree(unique);
948
- process.stdout.write(`
949
- šŸ“‚${providerPath}
950
- `);
951
- renderTree(tree).forEach((line) => {
952
- process.stdout.write(` ${dim$1}${line}${reset$2}
953
- `);
954
- });
1197
+ //#endregion
1198
+ //#region src/core/installer/install-skillset/install-skillset-flow.ts
1199
+ var toTargetPath = (entry, skillsetDir) => {
1200
+ if (entry.type === "skill" && entry.skillName) {
1201
+ const fileName = entry.path.split("/").pop() ?? entry.path;
1202
+ return `skills/${entry.skillName}/${fileName}`;
1203
+ }
1204
+ return entry.path.startsWith(`${skillsetDir}/`) ? entry.path.slice(skillsetDir.length + 1) : entry.path;
955
1205
  };
956
- const printCompleted = (startedAt) => {
957
- const elapsed = Math.round((Date.now() - startedAt) / 1e3);
958
- const timeStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${(elapsed % 60).toString().padStart(2, "0")}s` : `${elapsed}s`;
959
- process.stdout.write(
960
- `${green}āœ”${reset$2} Installation completed ${dim$1}in ${timeStr}${reset$2}
961
- `
962
- );
1206
+ var installSkillsetFlow = async (input, stepper, startedAt) => {
1207
+ readConfig();
1208
+ stepper.start("Resolving endpoint...", "packages");
1209
+ const location = await resolveIdentifier(input);
1210
+ const locationRef = `${location.owner}/${location.repository}@${location.ref}`;
1211
+ stepper.succeed("Resolved", locationRef);
1212
+ stepper.start("Fetching skillset manifest...", "packages");
1213
+ const skillset = await fetchSkillset(location);
1214
+ stepper.succeed("Fetched skillset manifest", `${skillset.name} v${skillset.version}`);
1215
+ const providerPath = knownProviders[skillset.provider];
1216
+ if (!providerPath) throw new Error(`Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`);
1217
+ const entries = resolveSkillset(skillset, location);
1218
+ if (entries.length === 0) {
1219
+ stepper.succeed("No files to download");
1220
+ stepper.stop();
1221
+ return;
1222
+ }
1223
+ const projectRoot = getProjectRoot();
1224
+ const downloadDir = join(projectRoot, ".spm", skillset.name);
1225
+ const skillsetDir = location.path.replace(/\/[^/]+$/, "");
1226
+ stepper.start(`Downloading ${entries.length} file(s)...`, "packages");
1227
+ const results = await downloadEntries(entries, location, (type, path) => stepper.item(`${type} ${path.split("/").pop() ?? path}`));
1228
+ const setupResults = results.filter((r) => r.type === "setup");
1229
+ await writeFilesToTemp(downloadDir, results.filter((r) => r.type !== "setup").map((r) => ({
1230
+ relativePath: toTargetPath(r, skillsetDir),
1231
+ content: r.content
1232
+ })));
1233
+ const setupContent = setupResults.length > 0 ? setupResults[0].content : void 0;
1234
+ stepper.succeed(`Downloaded ${results.length} file(s)`);
1235
+ const providerFullPath = join(projectRoot, providerPath);
1236
+ const pruned = pruneUnchanged(downloadDir, providerFullPath);
1237
+ if (pruned > 0) stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
1238
+ const source = `https://github.com/${location.owner}/${location.repository}/blob/${location.ref}/${location.path}`;
1239
+ const remainingFiles = collectRemainingFiles(downloadDir);
1240
+ const { newFiles, conflictFiles } = detectConflicts(remainingFiles, providerFullPath);
1241
+ let result;
1242
+ if (conflictFiles.length === 0) result = copyFilesToProvider(newFiles, providerFullPath, stepper, "Skillset");
1243
+ else {
1244
+ const model = skillset.provider === "claude" ? "sonnet" : void 0;
1245
+ const embedded = {
1246
+ downloadedFiles: remainingFiles,
1247
+ existingFiles: listExistingFiles(providerFullPath)
1248
+ };
1249
+ result = await spawnClaude(writeInstructionsFile({
1250
+ setupContent,
1251
+ providerDir: providerFullPath,
1252
+ skillsetName: skillset.name,
1253
+ skillsetVersion: skillset.version,
1254
+ source,
1255
+ configPath: getConfigPath(),
1256
+ model,
1257
+ embedded
1258
+ }), stepper, providerFullPath, model);
1259
+ }
1260
+ if (setupContent && conflictFiles.length === 0) {
1261
+ const model = skillset.provider === "claude" ? "sonnet" : void 0;
1262
+ await spawnClaude(writeSetupInstructionsFile(setupContent, skillset.name), stepper, providerFullPath, model);
1263
+ }
1264
+ addConfigEntry({
1265
+ providerPath,
1266
+ kind: "skillsets",
1267
+ name: skillset.name,
1268
+ source
1269
+ });
1270
+ await cleanupDownloadDir(projectRoot, downloadDir);
1271
+ stepper.stop();
1272
+ printCompleted(startedAt);
1273
+ printSummary(result.files, providerPath);
1274
+ process.stdout.write(`\nšŸŖ„ ${cyan}Restart your AI agent to apply the new skills.${reset$2}\n`);
963
1275
  };
964
- const installSkillsetFlow = async (input, stepper, startedAt) => {
965
- readConfig();
966
- stepper.start("Resolving endpoint...", "packages");
967
- const location = await resolveIdentifier(input);
968
- const locationRef = `${location.owner}/${location.repository}@${location.ref}`;
969
- stepper.succeed("Resolved", locationRef);
970
- stepper.start("Fetching skillset manifest...", "packages");
971
- const skillset = await fetchSkillset(location);
972
- stepper.succeed(
973
- `Fetched skillset manifest`,
974
- `${skillset.name} v${skillset.version}`
975
- );
976
- const providerPath = knownProviders[skillset.provider];
977
- if (!providerPath) {
978
- throw new Error(
979
- `Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`
980
- );
981
- }
982
- const provider = { path: providerPath };
983
- const entries = resolveSkillset(skillset, location);
984
- if (entries.length === 0) {
985
- stepper.succeed("No files to download");
986
- stepper.stop();
987
- return;
988
- }
989
- const projectRoot = getProjectRoot();
990
- const downloadDir = join(projectRoot, ".spm", skillset.name);
991
- const toTargetPath = (entry) => {
992
- if (entry.type === "skill" && entry.skillName) {
993
- const fileName = entry.path.split("/").pop() ?? entry.path;
994
- return `skills/${entry.skillName}/${fileName}`;
995
- }
996
- const skillsetDir = location.path.replace(/\/[^/]+$/, "");
997
- return entry.path.startsWith(`${skillsetDir}/`) ? entry.path.slice(skillsetDir.length + 1) : entry.path;
998
- };
999
- stepper.start(`Downloading ${entries.length} file(s)...`, "packages");
1000
- const results = await downloadEntries(
1001
- entries,
1002
- location,
1003
- (type, path) => stepper.item(`${type} ${path.split("/").pop() ?? path}`)
1004
- );
1005
- const setupResults = results.filter((r) => r.type === "setup");
1006
- const installResults = results.filter((r) => r.type !== "setup");
1007
- await Promise.all(
1008
- installResults.map(async (result2) => {
1009
- const target = toTargetPath(result2);
1010
- const filePath = safePath(downloadDir, target);
1011
- await mkdir(dirname(filePath), { recursive: true });
1012
- await writeFile(filePath, result2.content, "utf-8");
1013
- })
1014
- );
1015
- let setupFile;
1016
- if (setupResults.length > 0) {
1017
- const setup = setupResults[0];
1018
- setupFile = join(downloadDir, "SETUP.md");
1019
- await mkdir(dirname(setupFile), { recursive: true });
1020
- await writeFile(setupFile, setup.content, "utf-8");
1021
- }
1022
- stepper.succeed(`Downloaded ${results.length} file(s)`);
1023
- const downloadedPaths = installResults.map((r) => toTargetPath(r));
1024
- const providerFullPath = join(projectRoot, provider.path);
1025
- const pruned = pruneUnchanged(downloadDir, providerFullPath);
1026
- if (pruned > 0) {
1027
- stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
1028
- }
1029
- const model = skillset.provider === "claude" ? "haiku" : void 0;
1030
- const result = await installSkillset(
1031
- {
1032
- downloadDir,
1033
- setupFile,
1034
- providerDir: providerFullPath,
1035
- skillsetName: skillset.name,
1036
- skillsetVersion: skillset.version,
1037
- source: `@${location.owner}/${location.repository}`,
1038
- configPath: getConfigPath(),
1039
- model
1040
- },
1041
- stepper
1042
- );
1043
- await rm(downloadDir, { recursive: true, force: true });
1044
- const spmDir = join(projectRoot, ".spm");
1045
- const remaining = await readdir(spmDir).catch(() => []);
1046
- if (remaining.length === 0) await rm(spmDir, { recursive: true, force: true });
1047
- stepper.stop();
1048
- printCompleted(startedAt);
1049
- const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
1050
- printSummary(summaryFiles, provider.path);
1051
- process.stdout.write(
1052
- `
1053
- šŸŖ„ ${cyan}Restart your AI agent to apply the new skills.${reset$2}
1054
- `
1055
- );
1276
+ //#endregion
1277
+ //#region src/commands/install.ts
1278
+ var registerInstallCommand = (program) => {
1279
+ program.command("install <target>").alias("i").description("Install a skillset or skill into the project").action(async (input) => {
1280
+ const stepper = createStepper();
1281
+ const startedAt = Date.now();
1282
+ try {
1283
+ const parsed = parseIdentifier(input);
1284
+ if (parsed.kind === "skill") await installSkillFlow(parsed.identifier, stepper, startedAt);
1285
+ else await installSkillsetFlow(input, stepper, startedAt);
1286
+ } catch (err) {
1287
+ const message = err instanceof Error ? err.message : String(err);
1288
+ stepper.fail(message);
1289
+ stepper.stop();
1290
+ process.exitCode = 1;
1291
+ }
1292
+ });
1056
1293
  };
1057
- const installSkillFlow = async (identifier, stepper, startedAt) => {
1058
- const { config } = readConfig();
1059
- stepper.start("Resolving skill...", "packages");
1060
- const resolved = await resolveSkill(identifier);
1061
- const locationRef = `${resolved.location.owner}/${resolved.location.repository}@${resolved.location.ref}`;
1062
- stepper.succeed("Resolved", `${resolved.name} ${dim$1}${locationRef}${reset$2}`);
1063
- const firstProvider = Object.entries(config.providers)[0];
1064
- if (!firstProvider) {
1065
- throw new Error(
1066
- "No provider detected. Initialize a provider directory first (e.g., .claude/)."
1067
- );
1068
- }
1069
- const [providerName, providerConfig] = firstProvider;
1070
- const providerPath = providerConfig.path;
1071
- const projectRoot = getProjectRoot();
1072
- const downloadDir = join(projectRoot, ".spm", resolved.name);
1073
- const skillDir = posix.dirname(resolved.location.path);
1074
- stepper.start(`Downloading ${resolved.files.length} file(s)...`, "packages");
1075
- await Promise.all(
1076
- resolved.files.map(async (file) => {
1077
- const relativePath = file.path.startsWith(`${skillDir}/`) ? file.path.slice(skillDir.length + 1) : file.path.split("/").pop() ?? file.path;
1078
- const filePath = safePath(downloadDir, relativePath);
1079
- await mkdir(dirname(filePath), { recursive: true });
1080
- await writeFile(filePath, file.content, "utf-8");
1081
- stepper.item(relativePath);
1082
- })
1083
- );
1084
- stepper.succeed(`Downloaded ${resolved.files.length} file(s)`);
1085
- const providerFullPath = join(projectRoot, providerPath);
1086
- const skillProviderDir = join(providerFullPath, "skills", resolved.name);
1087
- const pruned = pruneUnchanged(downloadDir, skillProviderDir);
1088
- if (pruned > 0) {
1089
- stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
1090
- }
1091
- const model = providerName === "claude" ? "haiku" : void 0;
1092
- const source = `@${resolved.location.owner}/${resolved.location.repository}`;
1093
- const result = await installSingleSkill(
1094
- {
1095
- downloadDir,
1096
- providerDir: providerFullPath,
1097
- skillName: resolved.name,
1098
- source,
1099
- configPath: getConfigPath(),
1100
- model
1101
- },
1102
- stepper
1103
- );
1104
- await rm(downloadDir, { recursive: true, force: true });
1105
- const spmDir = join(projectRoot, ".spm");
1106
- const remaining = await readdir(spmDir).catch(() => []);
1107
- if (remaining.length === 0) await rm(spmDir, { recursive: true, force: true });
1108
- stepper.stop();
1109
- printCompleted(startedAt);
1110
- const downloadedPaths = resolved.files.map((f) => {
1111
- const skillDirPrefix = `${skillDir}/`;
1112
- return f.path.startsWith(skillDirPrefix) ? `skills/${resolved.name}/${f.path.slice(skillDirPrefix.length)}` : `skills/${resolved.name}/${f.path.split("/").pop() ?? f.path}`;
1113
- });
1114
- const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
1115
- printSummary(summaryFiles, providerPath);
1116
- process.stdout.write(
1117
- `
1118
- šŸŖ„ ${cyan}Restart your AI agent to apply the new skills.${reset$2}
1119
- `
1120
- );
1294
+ //#endregion
1295
+ //#region src/commands/list.ts
1296
+ var registerListCommand = (program) => {
1297
+ program.command("list").alias("ls").description("List installed skillsets").action(() => {
1298
+ console.log("Not implemented yet");
1299
+ });
1121
1300
  };
1122
- const registerInstallCommand = (program2) => {
1123
- program2.command("install <target>").alias("i").description("Install a skillset or skill into the project").action(async (input) => {
1124
- const stepper = createStepper();
1125
- const startedAt = Date.now();
1126
- try {
1127
- const parsed = parseIdentifier(input);
1128
- if (parsed.kind === "skill") {
1129
- await installSkillFlow(parsed.identifier, stepper, startedAt);
1130
- } else {
1131
- await installSkillsetFlow(input, stepper, startedAt);
1132
- }
1133
- } catch (err) {
1134
- const message = err instanceof Error ? err.message : String(err);
1135
- stepper.fail(message);
1136
- stepper.stop();
1137
- process.exitCode = 1;
1138
- }
1139
- });
1301
+ //#endregion
1302
+ //#region src/commands/uninstall.ts
1303
+ var findSkillProvider = (config, skillName) => {
1304
+ const entry = Object.entries(config.providers).find(([, provider]) => provider.skills != null && Object.hasOwn(provider.skills, skillName));
1305
+ if (!entry) return void 0;
1306
+ const [, provider] = entry;
1307
+ return {
1308
+ providerPath: provider.path,
1309
+ source: provider.skills?.[skillName] ?? ""
1310
+ };
1140
1311
  };
1141
- const registerListCommand = (program2) => {
1142
- program2.command("list").alias("ls").description("List installed skillsets").action(() => {
1143
- console.log("Not implemented yet");
1144
- });
1312
+ var registerUninstallCommand = (program) => {
1313
+ program.command("uninstall <skill>").alias("un").description("Uninstall a skill from the project").action(async (skillName) => {
1314
+ const stepper = createStepper();
1315
+ try {
1316
+ stepper.start("Removing skill...", "packages");
1317
+ const { config } = readConfig();
1318
+ const match = findSkillProvider(config, skillName);
1319
+ if (!match) throw new Error(`Skill "${skillName}" is not installed`);
1320
+ const skillsParentDir = join(getProjectRoot(), match.providerPath, "skills");
1321
+ const skillDir = join(skillsParentDir, skillName);
1322
+ if (!resolve(skillDir).startsWith(`${resolve(skillsParentDir)}${sep}`)) throw new Error(`Invalid skill name: "${skillName}"`);
1323
+ await rm(skillDir, {
1324
+ recursive: true,
1325
+ force: true
1326
+ });
1327
+ if ((await readdir(skillsParentDir).catch(() => [])).length === 0) await rm(skillsParentDir, {
1328
+ recursive: true,
1329
+ force: true
1330
+ });
1331
+ removeConfigEntry({
1332
+ providerPath: match.providerPath,
1333
+ kind: "skills",
1334
+ name: skillName
1335
+ });
1336
+ stepper.succeed(`${green}Removed${reset$2}`, `${skillName} ${dim$1}from ${match.providerPath}${reset$2}`);
1337
+ stepper.stop();
1338
+ process.stdout.write(`\nšŸŖ„ ${cyan}Restart your AI agent to apply the changes.${reset$2}\n`);
1339
+ } catch (err) {
1340
+ const message = err instanceof Error ? err.message : String(err);
1341
+ stepper.fail(message);
1342
+ stepper.stop();
1343
+ process.exitCode = 1;
1344
+ }
1345
+ });
1145
1346
  };
1146
- const light = "\x1B[96m";
1147
- const shade = "\x1B[36m";
1148
- const dim = "\x1B[2m";
1149
- const reset$1 = "\x1B[0m";
1150
- const banner = (version2) => [
1151
- `${shade} ▐${light}ā–Œ ā–ā–Œ ${reset$1}AI Skill Package Manager`,
1152
- `${shade} ▐${light}ā–›ā–ˆā–ˆā–œā–Œ ${reset$1}install • compose • share`,
1153
- `${shade}ā–ā–œ${light}ā–ˆā–ˆā–ˆā–ˆā–›ā–˜ ${reset$1}${dim}v${version2} ✨ supa-magic${reset$1}`,
1154
- `${shade} ā–˜${light} ā–`
1347
+ //#endregion
1348
+ //#region src/utils/banner.ts
1349
+ var light = "\x1B[96m";
1350
+ var shade = "\x1B[36m";
1351
+ var dim = "\x1B[2m";
1352
+ var reset$1 = "\x1B[0m";
1353
+ var banner = (version) => [
1354
+ `${shade} ▐${light}ā–Œ ā–ā–Œ ${reset$1}AI Skill Package Manager`,
1355
+ `${shade} ▐${light}ā–›ā–ˆā–ˆā–œā–Œ ${reset$1}install • compose • share`,
1356
+ `${shade}ā–ā–œ${light}ā–ˆā–ˆā–ˆā–ˆā–›ā–˜ ${reset$1}${dim}v${version} ✨ supa-magic${reset$1}`,
1357
+ `${shade} ā–˜${light} ā–`
1155
1358
  ].join("\n");
1156
- const version = "0.3.0";
1157
- const program = new Command();
1158
- const gray = "\x1B[90m";
1159
- const reset = "\x1B[0m";
1160
- program.name("spm").description(banner(version)).version(version, "-v, --version").configureHelp({
1161
- formatHelp: (cmd, helper) => {
1162
- const defaultHelp = Command.prototype.createHelp().formatHelp(cmd, helper);
1163
- const [description, ...rest] = defaultHelp.split("\n\n");
1164
- return [description, `${gray}${rest.join("\n\n")}${reset}`].join("\n\n");
1165
- }
1166
- });
1359
+ //#endregion
1360
+ //#region src/bin/spm.ts
1361
+ var version = "0.4.0";
1362
+ var program = new Command();
1363
+ var gray = "\x1B[90m";
1364
+ var reset = "\x1B[0m";
1365
+ program.name("spm").description(banner(version)).version(version, "-v, --version").configureHelp({ formatHelp: (cmd, helper) => {
1366
+ const [description, ...rest] = Command.prototype.createHelp().formatHelp(cmd, helper).split("\n\n");
1367
+ return [description, `${gray}${rest.join("\n\n")}${reset}`].join("\n\n");
1368
+ } });
1167
1369
  registerInitCommand(program);
1168
1370
  registerInstallCommand(program);
1371
+ registerUninstallCommand(program);
1169
1372
  registerListCommand(program);
1170
1373
  registerDoctorCommand(program);
1171
1374
  program.parse();
1375
+ //#endregion
1376
+ export {};