@supa-magic/spm 0.3.1 → 0.4.1

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