@supa-magic/spm 0.1.0 → 0.2.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 +878 -4
  2. package/package.json +7 -4
package/dist/bin/spm.js CHANGED
@@ -1,13 +1,886 @@
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, resolve, normalize } from "node:path";
5
+ import { parse, stringify } from "yaml";
6
+ import { execSync, spawn } from "node:child_process";
7
+ import { readFile, mkdir, writeFile } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { createHash } from "node:crypto";
3
10
  const registerDoctorCommand = (program2) => {
4
11
  program2.command("doctor").description("Check project health and compatibility").action(() => {
5
12
  console.log("Not implemented yet");
6
13
  });
7
14
  };
15
+ const knownProviders = {
16
+ claude: ".claude",
17
+ cursor: ".cursor/rules",
18
+ copilot: ".copilot",
19
+ aider: ".aider",
20
+ codeium: ".codeium",
21
+ cody: ".cody"
22
+ };
23
+ const detectProviders = (root) => Object.entries(knownProviders).reduce(
24
+ (providers, [name, path]) => {
25
+ const fullPath = join(root, path);
26
+ if (existsSync(fullPath)) {
27
+ providers[name] = { path };
28
+ }
29
+ return providers;
30
+ },
31
+ {}
32
+ );
33
+ const getGitRoot = () => {
34
+ try {
35
+ return execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim();
36
+ } catch {
37
+ return void 0;
38
+ }
39
+ };
40
+ const getProjectRoot = () => getGitRoot() ?? process.cwd();
41
+ const CONFIG_FILE = ".spmrc.yml";
42
+ const getConfigPath = (root) => join(root ?? getProjectRoot(), CONFIG_FILE);
43
+ const createDefaultConfig = (root) => ({
44
+ version: 1,
45
+ providers: detectProviders(root)
46
+ });
47
+ const readConfig = (root) => {
48
+ const resolvedRoot = getProjectRoot();
49
+ const configPath = getConfigPath(resolvedRoot);
50
+ if (!existsSync(configPath)) {
51
+ const config = createDefaultConfig(resolvedRoot);
52
+ writeConfig(config, resolvedRoot);
53
+ return { config, created: true };
54
+ }
55
+ const content = readFileSync(configPath, "utf-8");
56
+ return { config: parse(content), created: false };
57
+ };
58
+ const writeConfig = (config, root) => {
59
+ const configPath = getConfigPath(root);
60
+ writeFileSync(configPath, stringify(config), "utf-8");
61
+ };
62
+ const green = "\x1B[32m";
63
+ const red = "\x1B[31m";
64
+ const cyan = "\x1B[36m";
65
+ const dim$1 = "\x1B[2m";
66
+ const reset$2 = "\x1B[0m";
67
+ const hideCursor = "\x1B[?25l";
68
+ const showCursor = "\x1B[?25h";
69
+ const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
70
+ const funMessages = {
71
+ packages: [
72
+ "Resolving dependency tree...",
73
+ "Negotiating versions...",
74
+ "Cross-referencing package manifests...",
75
+ "Validating checksums...",
76
+ "Flattening nested dependencies...",
77
+ "Fetching metadata from the registry...",
78
+ "Comparing lockfile with remote...",
79
+ "Deduplicating shared packages...",
80
+ // ~20% worried
81
+ "This package has 47 peer deps. Deep breath...",
82
+ "The lockfile disagrees. The lockfile is wrong..."
83
+ ],
84
+ skills: [
85
+ "Loading skill into working memory...",
86
+ "Parsing skill metadata...",
87
+ "Checking for skill conflicts...",
88
+ "Mapping skill dependencies...",
89
+ "Indexing knowledge base...",
90
+ "Verifying skill signatures...",
91
+ "Wiring skills into a blockchain...",
92
+ "Calibrating expertise level...",
93
+ "Cross-referencing existing skills...",
94
+ "Analyzing project conventions...",
95
+ "Merging skill definitions...",
96
+ "Resolving naming conflicts...",
97
+ "Adapting skills to project rules...",
98
+ "Aligning intentions with context...",
99
+ "Validating output structure...",
100
+ "Making sure skills actually work...",
101
+ "Mapping file dependencies...",
102
+ "Warming up the tubes and transistors...",
103
+ "Reading configuration files...",
104
+ "Scanning project structure...",
105
+ "Writing changes to disk...",
106
+ // ~20% worried
107
+ "Something looks off in the skill graph...",
108
+ "Skill definition is ambiguous. Guessing...",
109
+ "Agent returned something weird. Reading it charitably...",
110
+ "The agent went off-script. Gently correcting..."
111
+ ],
112
+ generic: [
113
+ "Warming up the tubes and transistors...",
114
+ "Reading configuration files...",
115
+ "Scanning project structure...",
116
+ "Writing changes to disk...",
117
+ "Tidying up temporary files...",
118
+ "Verifying file integrity...",
119
+ "Syncing state...",
120
+ "Finishing up loose ends...",
121
+ // ~20% worried
122
+ "This should've worked. Looking into it...",
123
+ "That took longer than expected. Moving on..."
124
+ ]
125
+ };
126
+ const write = (s) => process.stdout.write(s);
127
+ const createStepper = () => {
128
+ let frameIndex = 0;
129
+ let currentText = "";
130
+ let currentCategory = "generic";
131
+ let items = [];
132
+ let interval;
133
+ let renderedLineCount = 0;
134
+ let renderedItemCount = 0;
135
+ let prevKeptCount = 0;
136
+ let spinnerRendered = false;
137
+ let startTime = 0;
138
+ let funIndex = 0;
139
+ let lastFunSwap = 0;
140
+ let cursorHidden = false;
141
+ let funLineRendered = false;
142
+ let currentFunText = "";
143
+ const writeCursorHide = () => {
144
+ if (!cursorHidden) {
145
+ write(hideCursor);
146
+ cursorHidden = true;
147
+ }
148
+ };
149
+ const writeCursorShow = () => {
150
+ if (cursorHidden) {
151
+ write(showCursor);
152
+ cursorHidden = false;
153
+ }
154
+ };
155
+ const clearRendered = () => {
156
+ if (renderedLineCount > 0) {
157
+ write(`\x1B[${renderedLineCount}A\r\x1B[J`);
158
+ renderedLineCount = 0;
159
+ renderedItemCount = 0;
160
+ prevKeptCount = 0;
161
+ spinnerRendered = false;
162
+ funLineRendered = false;
163
+ currentFunText = "";
164
+ }
165
+ };
166
+ const donePrefix = ` ${green}•${reset$2} `;
167
+ const pendingPrefix = ` ${dim$1}•${reset$2} `;
168
+ const render = () => {
169
+ const elapsed = Date.now() - startTime;
170
+ const frame = frames[frameIndex % frames.length];
171
+ if (!spinnerRendered) {
172
+ write(`${cyan}${frame}${reset$2} ${currentText}\x1B[K
173
+ `);
174
+ renderedLineCount++;
175
+ spinnerRendered = true;
176
+ } else {
177
+ const dist = renderedLineCount - prevKeptCount;
178
+ write(
179
+ `\x1B[${dist}A\r${cyan}${frame}${reset$2} ${currentText}\x1B[K\x1B[${dist}B\r`
180
+ );
181
+ }
182
+ items.forEach((it, i) => {
183
+ if (i < renderedItemCount && it.done && !it.renderedDone) {
184
+ const linesUp = renderedLineCount - prevKeptCount - 1 - i;
185
+ write(
186
+ `\x1B[${linesUp}A\r${donePrefix}${it.text}\x1B[K\x1B[${linesUp}B\r`
187
+ );
188
+ it.renderedDone = true;
189
+ }
190
+ });
191
+ if (renderedItemCount < items.length && funLineRendered) {
192
+ write("\x1B[1A\r\x1B[K");
193
+ renderedLineCount--;
194
+ funLineRendered = false;
195
+ }
196
+ while (renderedItemCount < items.length) {
197
+ const it = items[renderedItemCount];
198
+ const prefix = it.done ? donePrefix : pendingPrefix;
199
+ write(`${prefix}${it.text}\x1B[K
200
+ `);
201
+ if (it.done) it.renderedDone = true;
202
+ renderedItemCount++;
203
+ renderedLineCount++;
204
+ }
205
+ if (elapsed > 1e4) {
206
+ const msgs = funMessages[currentCategory];
207
+ if (lastFunSwap === 0 || Date.now() - lastFunSwap > 1e4) {
208
+ funIndex = (funIndex + 1) % msgs.length;
209
+ lastFunSwap = Date.now();
210
+ }
211
+ const newFunText = msgs[funIndex];
212
+ const funLine = ` └ ${dim$1}${newFunText}${reset$2}`;
213
+ if (!funLineRendered) {
214
+ write(`${funLine}\x1B[K
215
+ `);
216
+ renderedLineCount++;
217
+ funLineRendered = true;
218
+ currentFunText = newFunText;
219
+ } else if (newFunText !== currentFunText) {
220
+ write(`\x1B[1A\r${funLine}\x1B[K\x1B[1B\r`);
221
+ currentFunText = newFunText;
222
+ }
223
+ }
224
+ frameIndex++;
225
+ };
226
+ const stopInterval = () => {
227
+ if (interval) {
228
+ clearInterval(interval);
229
+ interval = void 0;
230
+ }
231
+ };
232
+ const startInterval = () => {
233
+ stopInterval();
234
+ interval = setInterval(render, 80);
235
+ render();
236
+ };
237
+ const onExit = () => writeCursorShow();
238
+ process.on("exit", onExit);
239
+ return {
240
+ start: (text, category) => {
241
+ writeCursorHide();
242
+ currentText = text;
243
+ currentCategory = category ?? "generic";
244
+ items = [];
245
+ renderedItemCount = 0;
246
+ spinnerRendered = false;
247
+ funLineRendered = false;
248
+ currentFunText = "";
249
+ startTime = Date.now();
250
+ lastFunSwap = 0;
251
+ funIndex = Math.floor(Math.random() * funMessages[currentCategory].length);
252
+ startInterval();
253
+ },
254
+ item: (text) => {
255
+ const last = items.at(-1);
256
+ if (last && !last.done) last.done = true;
257
+ items.push({
258
+ text: text.replace(/^• /, ""),
259
+ done: false,
260
+ renderedDone: false
261
+ });
262
+ },
263
+ succeed: (text, dimText) => {
264
+ stopInterval();
265
+ clearRendered();
266
+ const dimPart = dimText ? ` ${dim$1}${dimText}${reset$2}` : "";
267
+ write(`${green}✔${reset$2} ${text}${dimPart}
268
+ `);
269
+ const keptItems = items.length <= 1 ? [] : items;
270
+ keptItems.forEach((it) => {
271
+ write(`${donePrefix}${it.text}\x1B[K
272
+ `);
273
+ });
274
+ prevKeptCount = keptItems.length;
275
+ renderedLineCount = prevKeptCount;
276
+ renderedItemCount = 0;
277
+ items = [];
278
+ spinnerRendered = false;
279
+ },
280
+ fail: (text) => {
281
+ stopInterval();
282
+ clearRendered();
283
+ write(`${red}✖${reset$2} ${text}
284
+ `);
285
+ writeCursorShow();
286
+ },
287
+ stop: () => {
288
+ stopInterval();
289
+ clearRendered();
290
+ writeCursorShow();
291
+ process.removeListener("exit", onExit);
292
+ }
293
+ };
294
+ };
295
+ const registerInitCommand = (program2) => {
296
+ program2.command("init").description("Initialize project configuration (.spmrc.yml)").action(() => {
297
+ const stepper = createStepper();
298
+ const { config, created } = readConfig();
299
+ const providers = Object.keys(config.providers);
300
+ if (created) {
301
+ stepper.succeed(`Created ${CONFIG_FILE}`);
302
+ } else {
303
+ stepper.succeed(`${CONFIG_FILE} already exists`);
304
+ }
305
+ stepper.start("Detecting providers...", "generic");
306
+ providers.forEach((name) => stepper.item(name));
307
+ if (providers.length > 0) {
308
+ stepper.succeed(`Detected ${providers.length} provider(s)`);
309
+ } else {
310
+ stepper.fail("No providers detected");
311
+ }
312
+ stepper.stop();
313
+ });
314
+ };
315
+ const downloadFromGitHub = async (source) => {
316
+ const url = `https://raw.githubusercontent.com/${source.owner}/${source.repository}/${source.ref}/${source.path}`;
317
+ const response = await fetch(url);
318
+ if (!response.ok) {
319
+ throw new Error(
320
+ `Failed to download from GitHub: ${url} (${response.status} ${response.statusText})`
321
+ );
322
+ }
323
+ return response.text();
324
+ };
325
+ const downloadFromUrl = async (source) => {
326
+ const response = await fetch(source.url);
327
+ if (!response.ok) {
328
+ throw new Error(
329
+ `Failed to download from URL: ${source.url} (${response.status} ${response.statusText})`
330
+ );
331
+ }
332
+ return response.text();
333
+ };
334
+ const readLocalFile = async (source) => {
335
+ try {
336
+ return await readFile(source.filePath, "utf-8");
337
+ } catch (error) {
338
+ const message = error instanceof Error ? error.message : String(error);
339
+ throw new Error(
340
+ `Failed to read local file: ${source.filePath} (${message})`
341
+ );
342
+ }
343
+ };
344
+ const getContent = (source) => {
345
+ if (source.kind === "github") return downloadFromGitHub(source);
346
+ if (source.kind === "url") return downloadFromUrl(source);
347
+ return readLocalFile(source);
348
+ };
349
+ const downloadFile = async (source) => {
350
+ const content = await getContent(source);
351
+ return { content, source };
352
+ };
353
+ const template = '# Skillset Integration\n\nYou are integrating a new skillset into the project. Analyze the existing project setup and intelligently integrate the new files.\n\n## Context\n\n- **Downloaded files**: `{{downloadDir}}`\n- **Provider directory**: `{{providerDir}}`\n- **Skillset**: `{{skillsetName}}` v`{{skillsetVersion}}`\n- **Source**: `{{source}}`\n- **Config file**: `{{configPath}}`\n\nIdentical files have already been removed from the download folder. Only files that need action remain. If the download folder is empty, skip to the config update step.\n\n## Output Format\n\nCRITICAL — follow exactly:\n- NEVER wrap output in code blocks or backticks\n- NEVER write conversational text ("Now let me...", "Let me check...", "The files are...")\n- NEVER use emojis\n- Output ONLY structured log lines\n- Step headers are plain text ending with `...` — these are parsed by the CLI to drive spinner states\n- Items below headers are indented with 2 spaces and use `• ` prefix\n- File lists use ASCII tree: `├─`, `└─`, `│`\n- End with `Done` on its own line\n\nStep headers (use these exactly):\n\n1. `Analyzing existing setup...`\n2. `Analyzing downloaded files...`\n3. `Detecting conflicts...`\n4. `Integrating...`\n5. `Updating config...`\n6. `Running setup...` (only if a setup file exists)\n7. `Cleaning up...`\n8. `Done`\n\nExample output:\n\nAnalyzing existing setup...\n • 4 skills, 2 rules, 1 hook\n\nAnalyzing downloaded files...\n • skills: git, github, implement\n • rules: coding\n • hook: biome-format\n\nDetecting conflicts...\n • No conflicts\n\nIntegrating...\n • skills/git/SKILL.md\n • skills/git/branch.md\n • skills/github/SKILL.md\n\nUpdating config...\n • skillset: skill-creator@1.0.0\n\nRunning setup...\n • Configured MCP server: biome\n\nCleaning up...\n • Removed .spm/skill-creator\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, memory files.\n\n## Step 2: Analyzing downloaded files\n\nRead all files in `{{downloadDir}}`. Only files present in this folder need to be installed.\n\n## Step 3: Detecting conflicts\n\nFor each downloaded file, check if a file with the same name exists in the provider directory.\n\n- **No existing file** → install as new\n- **Different skillset version** → replace silently (new version supersedes)\n- **Same version or no version info** → conflict — ask the user:\n\nDetecting conflicts...\n • rules/coding.md — local has custom rules\n • Choose: (r)eplace / (s)kip / (m)erge\n\nWait for response before proceeding.\n\n## Step 4: Integrating\n\nInstall files into `{{providerDir}}`, following the directory structure from the download folder.\n\n**Integrate, don\'t copy.** When a new skill can leverage existing project conventions, adapt it:\n\n- If the project has `rules/coding.md` with specific conventions → make new skills follow those rules instead of their own defaults\n- If existing skills handle branching or committing → reference them instead of duplicating instructions\n- If the project has naming conventions, testing patterns, or architectural rules → align new skills with them\n\n## Step 5: Updating config\n\nUpdate `{{configPath}}`:\n\n- Under the provider with path `{{providerDir}}`, add the skillset entry under `skillsets`:\n `{{skillsetName}}: "{{source}}@{{skillsetVersion}}"`\n- Do NOT add individual skill, agent, or file names to the config\n- The `skills` map is reserved for standalone skill installations\n\n{{setupSection}}\n\n## Step 7: Cleaning up (last step before Done)\n\nDelete the download folder `{{downloadDir}}` and its contents.\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- Always delete the download folder when done\n';
354
+ const buildSetupSection = (setupFile) => setupFile ? [
355
+ "## Step 6: Running setup",
356
+ "",
357
+ `Read the setup file at \`${setupFile}\` and follow its instructions.`,
358
+ "Setup files configure the project environment (e.g. MCP servers, LSP, tooling).",
359
+ "Do NOT copy the setup file into the provider directory — only execute its instructions."
360
+ ].join("\n") : "";
361
+ const buildInstructions = (input) => template.replace(/\{\{downloadDir\}\}/g, input.downloadDir).replace(/\{\{providerDir\}\}/g, input.providerDir).replace(/\{\{skillsetName\}\}/g, input.skillsetName).replace(/\{\{skillsetVersion\}\}/g, input.skillsetVersion).replace(/\{\{source\}\}/g, input.source).replace(/\{\{configPath\}\}/g, input.configPath).replace("{{setupSection}}", buildSetupSection(input.setupFile));
362
+ const writeInstructionsFile = (input) => {
363
+ const filePath = join(tmpdir(), "spm", `install-${input.skillsetName}.md`);
364
+ mkdirSync(dirname(filePath), { recursive: true });
365
+ writeFileSync(filePath, buildInstructions(input), "utf-8");
366
+ return filePath;
367
+ };
368
+ const TIMEOUT_MS = 12e4;
369
+ const isNoise = (line) => line === "```" || line.startsWith("```") || /^(now |let me |the files? |i'll |i will |looking |checking )/i.test(line);
370
+ const isStepHeader = (raw, trimmed) => !raw.startsWith(" ") && !raw.startsWith(" ") && trimmed.endsWith("...");
371
+ const stepCategory = (header) => {
372
+ if (header.startsWith("Integrating") || header.startsWith("Analyzing") || header.startsWith("Detecting"))
373
+ return "skills";
374
+ return "generic";
375
+ };
376
+ const irregulars = {
377
+ Running: "Ran"
378
+ };
379
+ const toSuccessText = (header) => {
380
+ const text = header.replace(/\.{3}$/, "").trim();
381
+ const firstWord = text.split(" ")[0];
382
+ if (irregulars[firstWord]) {
383
+ return text.replace(firstWord, irregulars[firstWord]);
384
+ }
385
+ return text.replace(/^(\w+)ing\b/, "$1ed");
386
+ };
387
+ const toRelativePath = (filePath, providerDir) => {
388
+ const normalized = filePath.replace(/\\/g, "/");
389
+ const base = providerDir.replace(/\\/g, "/");
390
+ const marker = `${base}/`;
391
+ const idx = normalized.lastIndexOf(marker);
392
+ if (idx !== -1) return normalized.slice(idx + marker.length);
393
+ return normalized.split("/").pop() ?? normalized;
394
+ };
395
+ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise((resolve2, reject) => {
396
+ const args = [
397
+ "-p",
398
+ "Install the skill as instructed.",
399
+ "--append-system-prompt-file",
400
+ instructionsFilePath,
401
+ "--verbose",
402
+ "--output-format",
403
+ "stream-json",
404
+ "--allowedTools",
405
+ "Read,Write,Edit,Bash,Glob,Grep"
406
+ ];
407
+ const isWindows = process.platform === "win32";
408
+ const child = spawn("claude", args, {
409
+ timeout: TIMEOUT_MS,
410
+ shell: isWindows,
411
+ stdio: ["ignore", "pipe", "pipe"]
412
+ });
413
+ let resultText = "";
414
+ let lineBuffer = "";
415
+ let currentStepHeader = "Analyzing existing setup...";
416
+ let stepItems = [];
417
+ let stepFileCount = 0;
418
+ const state = { doneReceived: false };
419
+ const writtenFiles = [];
420
+ const succeedCurrentStep = () => {
421
+ if (!currentStepHeader) return;
422
+ const base = toSuccessText(currentStepHeader);
423
+ if (currentStepHeader.startsWith("Detecting")) {
424
+ const noConflicts = stepItems.some((i) => /no conflict/i.test(i));
425
+ stepper.succeed(noConflicts ? "No conflicts" : base);
426
+ return;
427
+ }
428
+ if (currentStepHeader.startsWith("Integrating")) {
429
+ stepper.succeed(
430
+ stepFileCount > 0 ? `Skillset was integrated (${stepFileCount} file(s))` : "Skillset was integrated"
431
+ );
432
+ return;
433
+ }
434
+ if (currentStepHeader.startsWith("Running setup")) {
435
+ const context2 = stepItems.length === 1 ? stepItems[0] : void 0;
436
+ stepper.succeed("Skillset setup completed", context2);
437
+ return;
438
+ }
439
+ const context = stepItems.length === 1 ? stepItems[0] : void 0;
440
+ stepper.succeed(base, context);
441
+ };
442
+ stepper.start("Analyzing existing setup...", "skills");
443
+ child.stdout.on("data", (chunk) => {
444
+ lineBuffer += chunk.toString();
445
+ const lines = lineBuffer.split("\n");
446
+ lineBuffer = lines.pop() ?? "";
447
+ lines.filter(Boolean).forEach((line) => {
448
+ try {
449
+ const event = JSON.parse(line);
450
+ if (event.type === "result" && event.result) {
451
+ resultText = event.result;
452
+ return;
453
+ }
454
+ if (event.type !== "assistant") return;
455
+ const blocks = event.message?.content;
456
+ if (!blocks) return;
457
+ blocks.forEach((block) => {
458
+ if (block.type === "text" && block.text) {
459
+ block.text.split("\n").forEach((ln) => {
460
+ const trimmed = ln.trim();
461
+ if (!trimmed || isNoise(trimmed)) return;
462
+ if (trimmed === "Done") {
463
+ succeedCurrentStep();
464
+ currentStepHeader = "";
465
+ state.doneReceived = true;
466
+ return;
467
+ }
468
+ if (isStepHeader(ln, trimmed)) {
469
+ if (trimmed === currentStepHeader) return;
470
+ succeedCurrentStep();
471
+ currentStepHeader = trimmed;
472
+ stepItems = [];
473
+ stepFileCount = 0;
474
+ stepper.start(trimmed, stepCategory(trimmed));
475
+ } else if (!currentStepHeader.startsWith("Integrating")) {
476
+ stepItems.push(trimmed);
477
+ stepper.item(trimmed);
478
+ }
479
+ });
480
+ }
481
+ if (block.type === "tool_use" && (block.name === "Write" || block.name === "Edit")) {
482
+ const filePath = block.input?.file_path;
483
+ if (typeof filePath === "string") {
484
+ const relative2 = toRelativePath(filePath, providerDir);
485
+ writtenFiles.push(relative2);
486
+ stepFileCount++;
487
+ if (currentStepHeader.startsWith("Integrating")) {
488
+ stepper.item(relative2);
489
+ }
490
+ }
491
+ }
492
+ });
493
+ } catch {
494
+ }
495
+ });
496
+ });
497
+ child.stderr.on("data", () => {
498
+ });
499
+ child.on("error", (err) => {
500
+ if ("code" in err && err.code === "ENOENT") {
501
+ reject(
502
+ new Error(
503
+ "Claude CLI not found. Install it: npm i -g @anthropic-ai/claude-code"
504
+ )
505
+ );
506
+ return;
507
+ }
508
+ reject(err);
509
+ });
510
+ child.on("close", (code) => {
511
+ if (currentStepHeader) {
512
+ succeedCurrentStep();
513
+ }
514
+ const completed = state.doneReceived || resultText.length > 0;
515
+ if (code !== 0 && code !== null) {
516
+ reject(new Error(`Claude CLI exited with code ${code}`));
517
+ return;
518
+ }
519
+ if (code === null && !completed) {
520
+ reject(new Error("Claude CLI was interrupted before completing"));
521
+ return;
522
+ }
523
+ resolve2({
524
+ success: true,
525
+ output: resultText,
526
+ files: writtenFiles
527
+ });
528
+ });
529
+ });
530
+ const installSkillset = (input, stepper) => spawnClaude(writeInstructionsFile(input), stepper, input.providerDir);
531
+ const hash = (content) => createHash("sha256").update(content).digest("hex");
532
+ const collectFiles = (dir) => readdirSync(dir).flatMap((entry) => {
533
+ const fullPath = join(dir, entry);
534
+ return statSync(fullPath).isDirectory() ? collectFiles(fullPath) : [fullPath];
535
+ });
536
+ const pruneUnchanged = (downloadDir, providerDir) => {
537
+ const files = collectFiles(downloadDir);
538
+ let removed = 0;
539
+ files.forEach((downloadedFile) => {
540
+ const relativePath = relative(downloadDir, downloadedFile);
541
+ const existingFile = join(providerDir, relativePath);
542
+ if (!existsSync(existingFile)) return;
543
+ const downloadedHash = hash(readFileSync(downloadedFile, "utf-8"));
544
+ const existingHash = hash(readFileSync(existingFile, "utf-8"));
545
+ if (downloadedHash === existingHash) {
546
+ unlinkSync(downloadedFile);
547
+ removed++;
548
+ }
549
+ });
550
+ return removed;
551
+ };
552
+ const requiredFields = ["name", "version", "description", "provider"];
553
+ const validateSkillset = (data) => {
554
+ if (!data || typeof data !== "object") {
555
+ throw new Error("Invalid skillset: expected a YAML object.");
556
+ }
557
+ const record = data;
558
+ const missing = requiredFields.filter(
559
+ (field) => typeof record[field] !== "string"
560
+ );
561
+ if (missing.length > 0) {
562
+ throw new Error(
563
+ `Invalid skillset: missing required fields: ${missing.join(", ")}.`
564
+ );
565
+ }
566
+ return record;
567
+ };
568
+ const fetchSkillset = async (location) => {
569
+ const url = `https://raw.githubusercontent.com/${location.owner}/${location.repository}/${location.ref}/${location.path}`;
570
+ const response = await fetch(url);
571
+ if (!response.ok) {
572
+ throw new Error(
573
+ `Failed to fetch skillset from ${url} (${response.status} ${response.statusText})`
574
+ );
575
+ }
576
+ const text = await response.text();
577
+ return validateSkillset(parse(text));
578
+ };
579
+ const parseGitHubUrl = (url) => {
580
+ const match = url.match(
581
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/[^/]+\/(.+)$/
582
+ );
583
+ if (!match) return void 0;
584
+ return { owner: match[1], repository: match[2], path: match[3] };
585
+ };
586
+ const parseAtIdentifier = (input) => {
587
+ const parts = input.slice(1).split("/").filter(Boolean);
588
+ if (parts.length < 3) {
589
+ throw new Error(
590
+ `Invalid identifier "${input}". Expected format: @owner/repo/path (e.g., @supa-magic/skillbox/claude/fsd).`
591
+ );
592
+ }
593
+ return {
594
+ owner: parts[0],
595
+ repository: parts[1],
596
+ path: parts.slice(2).join("/")
597
+ };
598
+ };
599
+ const parseIdentifier = (input) => {
600
+ const trimmed = input.trim();
601
+ if (trimmed.startsWith("https://") || trimmed.startsWith("http://")) {
602
+ const result = parseGitHubUrl(trimmed);
603
+ if (!result) {
604
+ throw new Error(
605
+ `Invalid GitHub URL "${trimmed}". Expected format: https://github.com/owner/repo/tree/branch/path.`
606
+ );
607
+ }
608
+ return result;
609
+ }
610
+ if (!trimmed.startsWith("@")) {
611
+ throw new Error(
612
+ `Invalid identifier "${trimmed}". Must start with @ or be a GitHub URL.`
613
+ );
614
+ }
615
+ return parseAtIdentifier(trimmed);
616
+ };
617
+ const fetchDefaultBranch = async (owner, repository) => {
618
+ const response = await fetch(
619
+ `https://api.github.com/repos/${owner}/${repository}`
620
+ );
621
+ if (!response.ok) {
622
+ return "main";
623
+ }
624
+ const data = await response.json();
625
+ return data.default_branch ?? "main";
626
+ };
627
+ const resolveIdentifier = async (input) => {
628
+ const identifier = parseIdentifier(input);
629
+ const ref = await fetchDefaultBranch(identifier.owner, identifier.repository);
630
+ return {
631
+ owner: identifier.owner,
632
+ repository: identifier.repository,
633
+ path: `${identifier.path}/skillset.yml`,
634
+ ref
635
+ };
636
+ };
637
+ const resolveSkillSource = (source, location) => {
638
+ if (source.startsWith("./")) {
639
+ const skillsetDir = location.path.replace(/\/skillset\.yml$/, "");
640
+ const relativePath = source.slice(2).replace(/\/+$/, "");
641
+ return {
642
+ owner: location.owner,
643
+ repository: location.repository,
644
+ basePath: `${skillsetDir}/${relativePath}`
645
+ };
646
+ }
647
+ if (source.startsWith("@")) {
648
+ const parts = source.slice(1).split("/").filter(Boolean);
649
+ if (parts.length < 3) {
650
+ throw new Error(
651
+ `Invalid cross-repo source "${source}". Expected format: @owner/repo/path.`
652
+ );
653
+ }
654
+ return {
655
+ owner: parts[0],
656
+ repository: parts[1],
657
+ basePath: parts.slice(2).join("/")
658
+ };
659
+ }
660
+ const normalized = source.replace(/^\/+/, "").replace(/\/+$/, "");
661
+ if (normalized.includes("..")) {
662
+ throw new Error(
663
+ `Invalid skill source "${source}". Path traversal ("..") is not allowed.`
664
+ );
665
+ }
666
+ return {
667
+ owner: location.owner,
668
+ repository: location.repository,
669
+ basePath: normalized
670
+ };
671
+ };
672
+ const resolveSkillset = (skillset, location) => {
673
+ const entries = [];
674
+ const skillsetDir = location.path.replace(/\/skillset\.yml$/, "");
675
+ if (skillset.setup) {
676
+ entries.push({
677
+ owner: location.owner,
678
+ repository: location.repository,
679
+ path: `${skillsetDir}/${skillset.setup}`,
680
+ type: "setup"
681
+ });
682
+ }
683
+ if (skillset.skills) {
684
+ Object.entries(skillset.skills).forEach(
685
+ ([skillName, { source, files }]) => {
686
+ const resolved = resolveSkillSource(source, location);
687
+ files.forEach((file) => {
688
+ entries.push({
689
+ owner: resolved.owner,
690
+ repository: resolved.repository,
691
+ path: `${resolved.basePath}/${file}`,
692
+ type: "skill",
693
+ skillName
694
+ });
695
+ });
696
+ }
697
+ );
698
+ }
699
+ const localFileTypes = ["agents", "hooks", "mcp", "memory", "rules"];
700
+ const typeMap = {
701
+ agents: "agent",
702
+ hooks: "hook",
703
+ mcp: "mcp",
704
+ memory: "memory",
705
+ rules: "rule"
706
+ };
707
+ localFileTypes.forEach((key) => {
708
+ const files = skillset[key];
709
+ if (files) {
710
+ files.forEach((file) => {
711
+ entries.push({
712
+ owner: location.owner,
713
+ repository: location.repository,
714
+ path: `${skillsetDir}/${file}`,
715
+ type: typeMap[key]
716
+ });
717
+ });
718
+ }
719
+ });
720
+ return entries;
721
+ };
722
+ const toGitHubSource = (entry, ref) => ({
723
+ kind: "github",
724
+ owner: entry.owner,
725
+ repository: entry.repository,
726
+ path: entry.path,
727
+ ref
728
+ });
729
+ const downloadEntries = async (entries, location, onFile) => {
730
+ const results = await Promise.all(
731
+ entries.map((entry) => {
732
+ const source = toGitHubSource(entry, location.ref);
733
+ return downloadFile(source).then((result) => {
734
+ onFile(entry.type, entry.path);
735
+ return {
736
+ ...result,
737
+ type: entry.type,
738
+ path: entry.path,
739
+ skillName: entry.skillName
740
+ };
741
+ });
742
+ })
743
+ );
744
+ return results;
745
+ };
746
+ const safePath = (baseDir, filePath) => {
747
+ const resolved = resolve(baseDir, normalize(filePath));
748
+ if (!resolved.startsWith(baseDir)) {
749
+ throw new Error(`Path traversal detected: "${filePath}"`);
750
+ }
751
+ return resolved;
752
+ };
753
+ const buildFileTree = (paths) => {
754
+ const root = { name: "", children: [] };
755
+ paths.forEach((p) => {
756
+ const parts = p.split("/");
757
+ let current = root;
758
+ parts.forEach((part) => {
759
+ const existing = current.children.find((c) => c.name === part);
760
+ if (existing) {
761
+ current = existing;
762
+ } else {
763
+ const node = { name: part, children: [] };
764
+ current.children.push(node);
765
+ current = node;
766
+ }
767
+ });
768
+ });
769
+ return root;
770
+ };
771
+ const renderTree = (node, prefix = "") => node.children.flatMap((child, i) => {
772
+ const isLast = i === node.children.length - 1;
773
+ const connector = isLast ? "└─" : "├─";
774
+ const childPrefix = prefix + (isLast ? " " : "│ ");
775
+ const isDir = child.children.length > 0;
776
+ const name = isDir ? `${child.name}/` : child.name;
777
+ return [`${prefix}${connector} ${name}`, ...renderTree(child, childPrefix)];
778
+ });
779
+ const printSummary = (files) => {
780
+ if (files.length === 0) return;
781
+ const unique = [...new Set(files)];
782
+ const tree = buildFileTree(unique);
783
+ process.stdout.write(`
784
+ 📂 Installed files:
785
+ `);
786
+ renderTree(tree).forEach((line) => {
787
+ process.stdout.write(` ${dim$1}${line}${reset$2}
788
+ `);
789
+ });
790
+ };
8
791
  const registerInstallCommand = (program2) => {
9
- program2.command("install <skillset>").alias("i").description("Install a skillset into the project").action((skillset) => {
10
- console.log(`install ${skillset}: Not implemented yet`);
792
+ program2.command("install <skillset>").alias("i").description("Install a skillset into the project").action(async (input) => {
793
+ const stepper = createStepper();
794
+ const startedAt = Date.now();
795
+ try {
796
+ const { config } = readConfig();
797
+ stepper.start("Resolving endpoint...", "packages");
798
+ const location = await resolveIdentifier(input);
799
+ const locationRef = `${location.owner}/${location.repository}@${location.ref}`;
800
+ stepper.succeed("Resolved", locationRef);
801
+ stepper.start("Fetching skillset manifest...", "packages");
802
+ const skillset = await fetchSkillset(location);
803
+ stepper.succeed(
804
+ `Fetched skillset manifest`,
805
+ `${skillset.name} v${skillset.version}`
806
+ );
807
+ const providerEntry = Object.entries(config.providers).find(
808
+ ([name]) => name === skillset.provider
809
+ );
810
+ if (!providerEntry) {
811
+ throw new Error(
812
+ `Provider "${skillset.provider}" not found in config. Run "spm init" to detect providers.`
813
+ );
814
+ }
815
+ const [, provider] = providerEntry;
816
+ const entries = resolveSkillset(skillset, location);
817
+ if (entries.length === 0) {
818
+ stepper.succeed("No files to download");
819
+ stepper.stop();
820
+ return;
821
+ }
822
+ const projectRoot = getProjectRoot();
823
+ const downloadDir = join(projectRoot, ".spm", skillset.name);
824
+ const skillsetDir = location.path.replace(/\/[^/]+$/, "");
825
+ const stripPrefix = (p) => p.startsWith(`${skillsetDir}/`) ? p.slice(skillsetDir.length + 1) : p;
826
+ stepper.start(`Downloading ${entries.length} file(s)...`, "packages");
827
+ const results = await downloadEntries(
828
+ entries,
829
+ location,
830
+ (type, path) => stepper.item(`${type} ${stripPrefix(path)}`)
831
+ );
832
+ const setupResults = results.filter((r) => r.type === "setup");
833
+ const installResults = results.filter((r) => r.type !== "setup");
834
+ await Promise.all(
835
+ installResults.map(async (result2) => {
836
+ const relative2 = stripPrefix(result2.path);
837
+ const filePath = safePath(downloadDir, relative2);
838
+ await mkdir(dirname(filePath), { recursive: true });
839
+ await writeFile(filePath, result2.content, "utf-8");
840
+ })
841
+ );
842
+ let setupFile;
843
+ if (setupResults.length > 0) {
844
+ const setup = setupResults[0];
845
+ const relative2 = stripPrefix(setup.path);
846
+ setupFile = join(downloadDir, relative2);
847
+ await mkdir(dirname(setupFile), { recursive: true });
848
+ await writeFile(setupFile, setup.content, "utf-8");
849
+ }
850
+ stepper.succeed(`Downloaded ${results.length} file(s)`);
851
+ const downloadedPaths = installResults.map((r) => stripPrefix(r.path));
852
+ const providerFullPath = join(projectRoot, provider.path);
853
+ const pruned = pruneUnchanged(downloadDir, providerFullPath);
854
+ if (pruned > 0) {
855
+ stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
856
+ }
857
+ const result = await installSkillset(
858
+ {
859
+ downloadDir,
860
+ setupFile,
861
+ providerDir: provider.path,
862
+ skillsetName: skillset.name,
863
+ skillsetVersion: skillset.version,
864
+ source: `@${location.owner}/${location.repository}`,
865
+ configPath: getConfigPath()
866
+ },
867
+ stepper
868
+ );
869
+ stepper.stop();
870
+ const elapsed = Math.round((Date.now() - startedAt) / 1e3);
871
+ const timeStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${(elapsed % 60).toString().padStart(2, "0")}s` : `${elapsed}s`;
872
+ process.stdout.write(
873
+ `${green}✔${reset$2} Installation completed ${dim$1}in ${timeStr}${reset$2}
874
+ `
875
+ );
876
+ const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
877
+ printSummary(summaryFiles);
878
+ } catch (err) {
879
+ const message = err instanceof Error ? err.message : String(err);
880
+ stepper.fail(message);
881
+ stepper.stop();
882
+ process.exitCode = 1;
883
+ }
11
884
  });
12
885
  };
13
886
  const registerListCommand = (program2) => {
@@ -25,17 +898,18 @@ const banner = (version2) => [
25
898
  `${shade}▝▜${light}████▛▘ ${reset$1}${dim}v${version2} ✨ supa-magic${reset$1}`,
26
899
  `${shade} ▘${light} ▝`
27
900
  ].join("\n");
28
- const version = "0.1.0";
901
+ const version = "0.2.1";
29
902
  const program = new Command();
30
903
  const gray = "\x1B[90m";
31
904
  const reset = "\x1B[0m";
32
- program.name("spm").description(banner(version)).version(version).configureHelp({
905
+ program.name("spm").description(banner(version)).version(version, "-v, --version").configureHelp({
33
906
  formatHelp: (cmd, helper) => {
34
907
  const defaultHelp = Command.prototype.createHelp().formatHelp(cmd, helper);
35
908
  const [description, ...rest] = defaultHelp.split("\n\n");
36
909
  return [description, `${gray}${rest.join("\n\n")}${reset}`].join("\n\n");
37
910
  }
38
911
  });
912
+ registerInitCommand(program);
39
913
  registerInstallCommand(program);
40
914
  registerListCommand(program);
41
915
  registerDoctorCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supa-magic/spm",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "CLI tool for managing AI skillsets",
5
5
  "license": "MIT",
6
6
  "contributors": [
@@ -27,20 +27,23 @@
27
27
  "node": ">=20"
28
28
  },
29
29
  "scripts": {
30
- "start": "clear && claude --dangerously-skip-permissions",
31
30
  "build": "vite build",
32
31
  "pretest": "npm run build",
32
+ "start": "clear && claude --dangerously-skip-permissions",
33
33
  "test": "vitest run",
34
- "test:watch": "vitest"
34
+ "test:watch": "vitest",
35
+ "tokscale": "npx tokscale@latest"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@biomejs/biome": "2.4.6",
38
39
  "@types/node": "^25.4.0",
39
40
  "typescript": "5.8.3",
41
+ "typescript-language-server": "^5.1.3",
40
42
  "vite": "^7.3.1",
41
43
  "vitest": "^4.0.18"
42
44
  },
43
45
  "dependencies": {
44
- "commander": "^14.0.3"
46
+ "commander": "^14.0.3",
47
+ "yaml": "^2.8.2"
45
48
  }
46
49
  }