@supa-magic/spm 0.2.1 → 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 +64 -30
  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);
@@ -481,8 +486,11 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
481
486
  if (block.type === "tool_use" && (block.name === "Write" || block.name === "Edit")) {
482
487
  const filePath = block.input?.file_path;
483
488
  if (typeof filePath === "string") {
489
+ const normalized = filePath.replace(/\\/g, "/");
490
+ const base = providerDir.replace(/\\/g, "/");
491
+ const isProviderFile = normalized.includes(`${base}/`);
484
492
  const relative2 = toRelativePath(filePath, providerDir);
485
- writtenFiles.push(relative2);
493
+ if (isProviderFile) writtenFiles.push(relative2);
486
494
  stepFileCount++;
487
495
  if (currentStepHeader.startsWith("Integrating")) {
488
496
  stepper.item(relative2);
@@ -494,7 +502,9 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
494
502
  }
495
503
  });
496
504
  });
497
- child.stderr.on("data", () => {
505
+ let stderrBuffer = "";
506
+ child.stderr.on("data", (chunk) => {
507
+ stderrBuffer += chunk.toString();
498
508
  });
499
509
  child.on("error", (err) => {
500
510
  if ("code" in err && err.code === "ENOENT") {
@@ -511,13 +521,20 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
511
521
  if (currentStepHeader) {
512
522
  succeedCurrentStep();
513
523
  }
514
- const completed = state.doneReceived || resultText.length > 0;
524
+ state.doneReceived || resultText.length > 0;
525
+ const stderr = stderrBuffer.trim();
515
526
  if (code !== 0 && code !== null) {
516
- 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}`));
517
530
  return;
518
531
  }
519
- if (code === null && !completed) {
520
- 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
+ );
521
538
  return;
522
539
  }
523
540
  resolve2({
@@ -527,7 +544,12 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
527
544
  });
528
545
  });
529
546
  });
530
- 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
+ );
531
553
  const hash = (content) => createHash("sha256").update(content).digest("hex");
532
554
  const collectFiles = (dir) => readdirSync(dir).flatMap((entry) => {
533
555
  const fullPath = join(dir, entry);
@@ -776,12 +798,18 @@ const renderTree = (node, prefix = "") => node.children.flatMap((child, i) => {
776
798
  const name = isDir ? `${child.name}/` : child.name;
777
799
  return [`${prefix}${connector} ${name}`, ...renderTree(child, childPrefix)];
778
800
  });
779
- 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) => {
780
806
  if (files.length === 0) return;
781
- const unique = [...new Set(files)];
807
+ const unique = [...new Set(files)].map(
808
+ (f) => stripProviderPrefix(f, providerPath)
809
+ );
782
810
  const tree = buildFileTree(unique);
783
811
  process.stdout.write(`
784
- 📂 Installed files:
812
+ 📂${providerPath}
785
813
  `);
786
814
  renderTree(tree).forEach((line) => {
787
815
  process.stdout.write(` ${dim$1}${line}${reset$2}
@@ -793,7 +821,7 @@ const registerInstallCommand = (program2) => {
793
821
  const stepper = createStepper();
794
822
  const startedAt = Date.now();
795
823
  try {
796
- const { config } = readConfig();
824
+ readConfig();
797
825
  stepper.start("Resolving endpoint...", "packages");
798
826
  const location = await resolveIdentifier(input);
799
827
  const locationRef = `${location.owner}/${location.repository}@${location.ref}`;
@@ -804,15 +832,13 @@ const registerInstallCommand = (program2) => {
804
832
  `Fetched skillset manifest`,
805
833
  `${skillset.name} v${skillset.version}`
806
834
  );
807
- const providerEntry = Object.entries(config.providers).find(
808
- ([name]) => name === skillset.provider
809
- );
810
- if (!providerEntry) {
835
+ const providerPath = knownProviders[skillset.provider];
836
+ if (!providerPath) {
811
837
  throw new Error(
812
- `Provider "${skillset.provider}" not found in config. Run "spm init" to detect providers.`
838
+ `Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`
813
839
  );
814
840
  }
815
- const [, provider] = providerEntry;
841
+ const provider = { path: providerPath };
816
842
  const entries = resolveSkillset(skillset, location);
817
843
  if (entries.length === 0) {
818
844
  stepper.succeed("No files to download");
@@ -854,18 +880,21 @@ const registerInstallCommand = (program2) => {
854
880
  if (pruned > 0) {
855
881
  stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
856
882
  }
883
+ const model = skillset.provider === "claude" ? "haiku" : void 0;
857
884
  const result = await installSkillset(
858
885
  {
859
886
  downloadDir,
860
887
  setupFile,
861
- providerDir: provider.path,
888
+ providerDir: providerFullPath,
862
889
  skillsetName: skillset.name,
863
890
  skillsetVersion: skillset.version,
864
891
  source: `@${location.owner}/${location.repository}`,
865
- configPath: getConfigPath()
892
+ configPath: getConfigPath(),
893
+ model
866
894
  },
867
895
  stepper
868
896
  );
897
+ await rm(downloadDir, { recursive: true, force: true });
869
898
  stepper.stop();
870
899
  const elapsed = Math.round((Date.now() - startedAt) / 1e3);
871
900
  const timeStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${(elapsed % 60).toString().padStart(2, "0")}s` : `${elapsed}s`;
@@ -874,7 +903,12 @@ const registerInstallCommand = (program2) => {
874
903
  `
875
904
  );
876
905
  const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
877
- 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
+ );
878
912
  } catch (err) {
879
913
  const message = err instanceof Error ? err.message : String(err);
880
914
  stepper.fail(message);
@@ -898,7 +932,7 @@ const banner = (version2) => [
898
932
  `${shade}▝▜${light}████▛▘ ${reset$1}${dim}v${version2} ✨ supa-magic${reset$1}`,
899
933
  `${shade} ▘${light} ▝`
900
934
  ].join("\n");
901
- const version = "0.2.1";
935
+ const version = "0.2.3";
902
936
  const program = new Command();
903
937
  const gray = "\x1B[90m";
904
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.1",
3
+ "version": "0.2.3",
4
4
  "description": "CLI tool for managing AI skillsets",
5
5
  "license": "MIT",
6
6
  "contributors": [