@supa-magic/spm 0.2.2 → 0.2.3

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 +60 -29
  2. package/package.json +1 -1
package/dist/bin/spm.js CHANGED
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdir
4
4
  import { join, dirname, relative, resolve, normalize } from "node:path";
5
5
  import { parse, stringify } from "yaml";
6
6
  import { execSync, spawn } from "node:child_process";
7
- import { readFile, mkdir, writeFile } from "node:fs/promises";
7
+ import { readFile, mkdir, writeFile, rm } from "node:fs/promises";
8
8
  import { tmpdir } from "node:os";
9
9
  import { createHash } from "node:crypto";
10
10
  const registerDoctorCommand = (program2) => {
@@ -350,7 +350,7 @@ const downloadFile = async (source) => {
350
350
  const content = await getContent(source);
351
351
  return { content, source };
352
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';
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. `Done`\n\nExample output:\n\nAnalyzing existing setup...\n • 4 skills, 2 rules, 1 hook\n\nAnalyzing downloaded files...\n • skills: git, github, implement\n • rules: coding\n • hook: biome-format\n\nDetecting conflicts...\n • No conflicts\n\nIntegrating...\n • skills/git/SKILL.md\n • skills/git/branch.md\n • skills/github/SKILL.md\n\nUpdating config...\n • skillset: skill-creator@1.0.0\n\nRunning setup...\n • Configured MCP server: biome\n\nDone\n\n## Step 1: Analyzing existing setup\n\nRead the provider directory (`{{providerDir}}`) and catalog what is already in place: skills, rules, agents, hooks, mcp servers files.\n\n## Step 2: Analyzing downloaded files\n\nRead all files in `{{downloadDir}}`. Only files present in this folder need to be installed.\n\n## Step 3: Detecting conflicts\n\nFor each downloaded file, check if a file with the same name exists in the provider directory.\n\n- **No existing file** → install as new\n- **Different skillset version** → replace silently (new version supersedes)\n- **Same version or no version info** → conflict — ask the user:\n\nDetecting conflicts...\n • rules/coding.md — local has custom rules\n • Choose: (r)eplace / (s)kip / (m)erge\n\nWait for response before proceeding.\n\n## Step 4: Integrating\n\nInstall files into `{{providerDir}}`, following the directory structure from the download folder.\n\n**Integrate, don\'t copy.** When a new skill can leverage existing project conventions, adapt it:\n\n- If the project has `rules/coding.md` with specific conventions → make new skills follow those rules instead of their own defaults\n- If existing skills handle branching or committing → reference them instead of duplicating instructions\n- If the project has naming conventions, testing patterns, or architectural rules → align new skills with them\n\n## Step 5: Updating config\n\nUpdate `{{configPath}}`:\n\n- Under the provider with path `{{providerDir}}`, add the skillset entry under `skillsets`:\n `{{skillsetName}}: "{{source}}@{{skillsetVersion}}"`\n- Do NOT add individual skill, agent, or file names to the config\n- The `skills` map is reserved for standalone skill installations\n\n{{setupSection}}\n\n## Rules\n\n- Setup files are NOT installed — they contain instructions to configure the project\n- Do not delete or modify existing files in the provider directory unless resolving a conflict\n- Do NOT delete the download folder cleanup is handled externally\n';
354
354
  const buildSetupSection = (setupFile) => setupFile ? [
355
355
  "## Step 6: Running setup",
356
356
  "",
@@ -365,7 +365,6 @@ const writeInstructionsFile = (input) => {
365
365
  writeFileSync(filePath, buildInstructions(input), "utf-8");
366
366
  return filePath;
367
367
  };
368
- const TIMEOUT_MS = 12e4;
369
368
  const isNoise = (line) => line === "```" || line.startsWith("```") || /^(now |let me |the files? |i'll |i will |looking |checking )/i.test(line);
370
369
  const isStepHeader = (raw, trimmed) => !raw.startsWith(" ") && !raw.startsWith(" ") && trimmed.endsWith("...");
371
370
  const stepCategory = (header) => {
@@ -392,7 +391,7 @@ const toRelativePath = (filePath, providerDir) => {
392
391
  if (idx !== -1) return normalized.slice(idx + marker.length);
393
392
  return normalized.split("/").pop() ?? normalized;
394
393
  };
395
- const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise((resolve2, reject) => {
394
+ const spawnClaude = (instructionsFilePath, stepper, providerDir, model) => new Promise((resolve2, reject) => {
396
395
  const args = [
397
396
  "-p",
398
397
  "Install the skill as instructed.",
@@ -402,11 +401,11 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
402
401
  "--output-format",
403
402
  "stream-json",
404
403
  "--allowedTools",
405
- "Read,Write,Edit,Bash,Glob,Grep"
404
+ "Read,Write,Edit,Bash,Glob,Grep",
405
+ ...model ? ["--model", model] : []
406
406
  ];
407
407
  const isWindows = process.platform === "win32";
408
408
  const child = spawn("claude", args, {
409
- timeout: TIMEOUT_MS,
410
409
  shell: isWindows,
411
410
  stdio: ["ignore", "pipe", "pipe"]
412
411
  });
@@ -415,7 +414,7 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
415
414
  let currentStepHeader = "Analyzing existing setup...";
416
415
  let stepItems = [];
417
416
  let stepFileCount = 0;
418
- const state = { doneReceived: false };
417
+ const state = { doneReceived: false, setupReached: false };
419
418
  const writtenFiles = [];
420
419
  const succeedCurrentStep = () => {
421
420
  if (!currentStepHeader) return;
@@ -432,10 +431,11 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
432
431
  return;
433
432
  }
434
433
  if (currentStepHeader.startsWith("Running setup")) {
435
- const context2 = stepItems.length === 1 ? stepItems[0] : void 0;
436
- stepper.succeed("Skillset setup completed", context2);
434
+ stepper.succeed("Skillset setup completed");
435
+ currentStepHeader = "";
437
436
  return;
438
437
  }
438
+ if (state.setupReached || currentStepHeader.startsWith("Cleaning")) return;
439
439
  const context = stepItems.length === 1 ? stepItems[0] : void 0;
440
440
  stepper.succeed(base, context);
441
441
  };
@@ -468,9 +468,14 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
468
468
  if (isStepHeader(ln, trimmed)) {
469
469
  if (trimmed === currentStepHeader) return;
470
470
  succeedCurrentStep();
471
+ if (state.setupReached || trimmed.startsWith("Cleaning"))
472
+ return;
471
473
  currentStepHeader = trimmed;
472
474
  stepItems = [];
473
475
  stepFileCount = 0;
476
+ if (trimmed.startsWith("Running setup")) {
477
+ state.setupReached = true;
478
+ }
474
479
  stepper.start(trimmed, stepCategory(trimmed));
475
480
  } else if (!currentStepHeader.startsWith("Integrating")) {
476
481
  stepItems.push(trimmed);
@@ -497,7 +502,9 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
497
502
  }
498
503
  });
499
504
  });
500
- child.stderr.on("data", () => {
505
+ let stderrBuffer = "";
506
+ child.stderr.on("data", (chunk) => {
507
+ stderrBuffer += chunk.toString();
501
508
  });
502
509
  child.on("error", (err) => {
503
510
  if ("code" in err && err.code === "ENOENT") {
@@ -514,13 +521,20 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
514
521
  if (currentStepHeader) {
515
522
  succeedCurrentStep();
516
523
  }
517
- const completed = state.doneReceived || resultText.length > 0;
524
+ state.doneReceived || resultText.length > 0;
525
+ const stderr = stderrBuffer.trim();
518
526
  if (code !== 0 && code !== null) {
519
- reject(new Error(`Claude CLI exited with code ${code}`));
527
+ const detail = stderr ? `
528
+ ${stderr}` : "";
529
+ reject(new Error(`Claude CLI exited with code ${code}${detail}`));
520
530
  return;
521
531
  }
522
- if (code === null && !completed) {
523
- reject(new Error("Claude CLI was interrupted before completing"));
532
+ if (code === null) {
533
+ const detail = stderr ? `
534
+ ${stderr}` : "";
535
+ reject(
536
+ new Error(`Claude CLI was interrupted before completing${detail}`)
537
+ );
524
538
  return;
525
539
  }
526
540
  resolve2({
@@ -530,7 +544,12 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
530
544
  });
531
545
  });
532
546
  });
533
- const installSkillset = (input, stepper) => spawnClaude(writeInstructionsFile(input), stepper, input.providerDir);
547
+ const installSkillset = (input, stepper) => spawnClaude(
548
+ writeInstructionsFile(input),
549
+ stepper,
550
+ input.providerDir,
551
+ input.model
552
+ );
534
553
  const hash = (content) => createHash("sha256").update(content).digest("hex");
535
554
  const collectFiles = (dir) => readdirSync(dir).flatMap((entry) => {
536
555
  const fullPath = join(dir, entry);
@@ -779,12 +798,18 @@ const renderTree = (node, prefix = "") => node.children.flatMap((child, i) => {
779
798
  const name = isDir ? `${child.name}/` : child.name;
780
799
  return [`${prefix}${connector} ${name}`, ...renderTree(child, childPrefix)];
781
800
  });
782
- const printSummary = (files) => {
801
+ const stripProviderPrefix = (file, prefix) => {
802
+ const norm = prefix.replace(/\\/g, "/").replace(/^\.\//, "");
803
+ return file.startsWith(`${norm}/`) ? file.slice(norm.length + 1) : file;
804
+ };
805
+ const printSummary = (files, providerPath) => {
783
806
  if (files.length === 0) return;
784
- const unique = [...new Set(files)];
807
+ const unique = [...new Set(files)].map(
808
+ (f) => stripProviderPrefix(f, providerPath)
809
+ );
785
810
  const tree = buildFileTree(unique);
786
811
  process.stdout.write(`
787
- 📂 Installed files:
812
+ 📂${providerPath}
788
813
  `);
789
814
  renderTree(tree).forEach((line) => {
790
815
  process.stdout.write(` ${dim$1}${line}${reset$2}
@@ -796,7 +821,7 @@ const registerInstallCommand = (program2) => {
796
821
  const stepper = createStepper();
797
822
  const startedAt = Date.now();
798
823
  try {
799
- const { config } = readConfig();
824
+ readConfig();
800
825
  stepper.start("Resolving endpoint...", "packages");
801
826
  const location = await resolveIdentifier(input);
802
827
  const locationRef = `${location.owner}/${location.repository}@${location.ref}`;
@@ -807,15 +832,13 @@ const registerInstallCommand = (program2) => {
807
832
  `Fetched skillset manifest`,
808
833
  `${skillset.name} v${skillset.version}`
809
834
  );
810
- const providerEntry = Object.entries(config.providers).find(
811
- ([name]) => name === skillset.provider
812
- );
813
- if (!providerEntry) {
835
+ const providerPath = knownProviders[skillset.provider];
836
+ if (!providerPath) {
814
837
  throw new Error(
815
- `Provider "${skillset.provider}" not found in config. Run "spm init" to detect providers.`
838
+ `Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`
816
839
  );
817
840
  }
818
- const [, provider] = providerEntry;
841
+ const provider = { path: providerPath };
819
842
  const entries = resolveSkillset(skillset, location);
820
843
  if (entries.length === 0) {
821
844
  stepper.succeed("No files to download");
@@ -857,18 +880,21 @@ const registerInstallCommand = (program2) => {
857
880
  if (pruned > 0) {
858
881
  stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
859
882
  }
883
+ const model = skillset.provider === "claude" ? "haiku" : void 0;
860
884
  const result = await installSkillset(
861
885
  {
862
886
  downloadDir,
863
887
  setupFile,
864
- providerDir: provider.path,
888
+ providerDir: providerFullPath,
865
889
  skillsetName: skillset.name,
866
890
  skillsetVersion: skillset.version,
867
891
  source: `@${location.owner}/${location.repository}`,
868
- configPath: getConfigPath()
892
+ configPath: getConfigPath(),
893
+ model
869
894
  },
870
895
  stepper
871
896
  );
897
+ await rm(downloadDir, { recursive: true, force: true });
872
898
  stepper.stop();
873
899
  const elapsed = Math.round((Date.now() - startedAt) / 1e3);
874
900
  const timeStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${(elapsed % 60).toString().padStart(2, "0")}s` : `${elapsed}s`;
@@ -877,7 +903,12 @@ const registerInstallCommand = (program2) => {
877
903
  `
878
904
  );
879
905
  const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
880
- printSummary(summaryFiles);
906
+ printSummary(summaryFiles, provider.path);
907
+ process.stdout.write(
908
+ `
909
+ 🪄 ${cyan}Restart your AI agent to apply the new skills.${reset$2}
910
+ `
911
+ );
881
912
  } catch (err) {
882
913
  const message = err instanceof Error ? err.message : String(err);
883
914
  stepper.fail(message);
@@ -901,7 +932,7 @@ const banner = (version2) => [
901
932
  `${shade}▝▜${light}████▛▘ ${reset$1}${dim}v${version2} ✨ supa-magic${reset$1}`,
902
933
  `${shade} ▘${light} ▝`
903
934
  ].join("\n");
904
- const version = "0.2.2";
935
+ const version = "0.2.3";
905
936
  const program = new Command();
906
937
  const gray = "\x1B[90m";
907
938
  const reset = "\x1B[0m";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supa-magic/spm",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "CLI tool for managing AI skillsets",
5
5
  "license": "MIT",
6
6
  "contributors": [