@supa-magic/spm 0.2.2 → 0.2.4

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 +102 -43
  2. package/package.json +6 -2
package/dist/bin/spm.js CHANGED
@@ -3,8 +3,8 @@ import { Command } from "commander";
3
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
4
4
  import { join, dirname, relative, resolve, normalize } from "node:path";
5
5
  import { parse, stringify } from "yaml";
6
- import { execSync, spawn } from "node:child_process";
7
- import { readFile, mkdir, writeFile } from "node:fs/promises";
6
+ import { execFileSync, execSync, spawn } from "node:child_process";
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) => {
@@ -20,10 +20,28 @@ const knownProviders = {
20
20
  codeium: ".codeium",
21
21
  cody: ".cody"
22
22
  };
23
+ const cliCommands = {
24
+ claude: "claude",
25
+ aider: "aider"
26
+ };
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
+ }
38
+ };
23
39
  const detectProviders = (root) => Object.entries(knownProviders).reduce(
24
40
  (providers, [name, path]) => {
25
- const fullPath = join(root, path);
26
- if (existsSync(fullPath)) {
41
+ const hasDir = existsSync(join(root, path));
42
+ const cli = cliCommands[name];
43
+ const hasCli = cli ? hasCliInstalled(cli) : false;
44
+ if (hasDir || hasCli) {
27
45
  providers[name] = { path };
28
46
  }
29
47
  return providers;
@@ -350,7 +368,7 @@ const downloadFile = async (source) => {
350
368
  const content = await getContent(source);
351
369
  return { content, source };
352
370
  };
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';
371
+ 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
372
  const buildSetupSection = (setupFile) => setupFile ? [
355
373
  "## Step 6: Running setup",
356
374
  "",
@@ -365,7 +383,6 @@ const writeInstructionsFile = (input) => {
365
383
  writeFileSync(filePath, buildInstructions(input), "utf-8");
366
384
  return filePath;
367
385
  };
368
- const TIMEOUT_MS = 12e4;
369
386
  const isNoise = (line) => line === "```" || line.startsWith("```") || /^(now |let me |the files? |i'll |i will |looking |checking )/i.test(line);
370
387
  const isStepHeader = (raw, trimmed) => !raw.startsWith(" ") && !raw.startsWith(" ") && trimmed.endsWith("...");
371
388
  const stepCategory = (header) => {
@@ -392,7 +409,7 @@ const toRelativePath = (filePath, providerDir) => {
392
409
  if (idx !== -1) return normalized.slice(idx + marker.length);
393
410
  return normalized.split("/").pop() ?? normalized;
394
411
  };
395
- const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise((resolve2, reject) => {
412
+ const spawnClaude = (instructionsFilePath, stepper, providerDir, model) => new Promise((resolve2, reject) => {
396
413
  const args = [
397
414
  "-p",
398
415
  "Install the skill as instructed.",
@@ -402,11 +419,11 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
402
419
  "--output-format",
403
420
  "stream-json",
404
421
  "--allowedTools",
405
- "Read,Write,Edit,Bash,Glob,Grep"
422
+ "Read,Write,Edit,Bash,Glob,Grep",
423
+ ...model ? ["--model", model] : []
406
424
  ];
407
425
  const isWindows = process.platform === "win32";
408
426
  const child = spawn("claude", args, {
409
- timeout: TIMEOUT_MS,
410
427
  shell: isWindows,
411
428
  stdio: ["ignore", "pipe", "pipe"]
412
429
  });
@@ -415,7 +432,7 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
415
432
  let currentStepHeader = "Analyzing existing setup...";
416
433
  let stepItems = [];
417
434
  let stepFileCount = 0;
418
- const state = { doneReceived: false };
435
+ const state = { doneReceived: false, setupReached: false };
419
436
  const writtenFiles = [];
420
437
  const succeedCurrentStep = () => {
421
438
  if (!currentStepHeader) return;
@@ -432,11 +449,12 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
432
449
  return;
433
450
  }
434
451
  if (currentStepHeader.startsWith("Running setup")) {
435
- const context2 = stepItems.length === 1 ? stepItems[0] : void 0;
436
- stepper.succeed("Skillset setup completed", context2);
452
+ stepper.succeed("Skillset setup completed");
453
+ currentStepHeader = "";
437
454
  return;
438
455
  }
439
- const context = stepItems.length === 1 ? stepItems[0] : void 0;
456
+ if (state.setupReached || currentStepHeader.startsWith("Cleaning")) return;
457
+ const context = stepItems.length > 0 ? stepItems.join(", ") : void 0;
440
458
  stepper.succeed(base, context);
441
459
  };
442
460
  stepper.start("Analyzing existing setup...", "skills");
@@ -468,13 +486,20 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
468
486
  if (isStepHeader(ln, trimmed)) {
469
487
  if (trimmed === currentStepHeader) return;
470
488
  succeedCurrentStep();
489
+ if (state.setupReached || trimmed.startsWith("Cleaning"))
490
+ return;
471
491
  currentStepHeader = trimmed;
472
492
  stepItems = [];
473
493
  stepFileCount = 0;
494
+ if (trimmed.startsWith("Running setup")) {
495
+ state.setupReached = true;
496
+ }
474
497
  stepper.start(trimmed, stepCategory(trimmed));
475
- } else if (!currentStepHeader.startsWith("Integrating")) {
498
+ } else {
476
499
  stepItems.push(trimmed);
477
- stepper.item(trimmed);
500
+ if (currentStepHeader.startsWith("Integrating")) {
501
+ stepper.item(trimmed);
502
+ }
478
503
  }
479
504
  });
480
505
  }
@@ -497,7 +522,9 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
497
522
  }
498
523
  });
499
524
  });
500
- child.stderr.on("data", () => {
525
+ let stderrBuffer = "";
526
+ child.stderr.on("data", (chunk) => {
527
+ stderrBuffer += chunk.toString();
501
528
  });
502
529
  child.on("error", (err) => {
503
530
  if ("code" in err && err.code === "ENOENT") {
@@ -514,13 +541,20 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
514
541
  if (currentStepHeader) {
515
542
  succeedCurrentStep();
516
543
  }
517
- const completed = state.doneReceived || resultText.length > 0;
544
+ state.doneReceived || resultText.length > 0;
545
+ const stderr = stderrBuffer.trim();
518
546
  if (code !== 0 && code !== null) {
519
- reject(new Error(`Claude CLI exited with code ${code}`));
547
+ const detail = stderr ? `
548
+ ${stderr}` : "";
549
+ reject(new Error(`Claude CLI exited with code ${code}${detail}`));
520
550
  return;
521
551
  }
522
- if (code === null && !completed) {
523
- reject(new Error("Claude CLI was interrupted before completing"));
552
+ if (code === null) {
553
+ const detail = stderr ? `
554
+ ${stderr}` : "";
555
+ reject(
556
+ new Error(`Claude CLI was interrupted before completing${detail}`)
557
+ );
524
558
  return;
525
559
  }
526
560
  resolve2({
@@ -530,7 +564,12 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
530
564
  });
531
565
  });
532
566
  });
533
- const installSkillset = (input, stepper) => spawnClaude(writeInstructionsFile(input), stepper, input.providerDir);
567
+ const installSkillset = (input, stepper) => spawnClaude(
568
+ writeInstructionsFile(input),
569
+ stepper,
570
+ input.providerDir,
571
+ input.model
572
+ );
534
573
  const hash = (content) => createHash("sha256").update(content).digest("hex");
535
574
  const collectFiles = (dir) => readdirSync(dir).flatMap((entry) => {
536
575
  const fullPath = join(dir, entry);
@@ -779,12 +818,21 @@ const renderTree = (node, prefix = "") => node.children.flatMap((child, i) => {
779
818
  const name = isDir ? `${child.name}/` : child.name;
780
819
  return [`${prefix}${connector} ${name}`, ...renderTree(child, childPrefix)];
781
820
  });
782
- const printSummary = (files) => {
821
+ const stripProviderPrefix = (file, prefix) => {
822
+ const norm = prefix.replace(/\\/g, "/").replace(/^\.\//, "");
823
+ if (file.startsWith(`${norm}/`)) return file.slice(norm.length + 1);
824
+ const bare = norm.replace(/^\./, "");
825
+ if (bare && file.startsWith(`${bare}/`)) return file.slice(bare.length + 1);
826
+ return file;
827
+ };
828
+ const printSummary = (files, providerPath) => {
783
829
  if (files.length === 0) return;
784
- const unique = [...new Set(files)];
830
+ const unique = [...new Set(files)].map(
831
+ (f) => stripProviderPrefix(f, providerPath)
832
+ );
785
833
  const tree = buildFileTree(unique);
786
834
  process.stdout.write(`
787
- 📂 Installed files:
835
+ 📂${providerPath}
788
836
  `);
789
837
  renderTree(tree).forEach((line) => {
790
838
  process.stdout.write(` ${dim$1}${line}${reset$2}
@@ -796,7 +844,7 @@ const registerInstallCommand = (program2) => {
796
844
  const stepper = createStepper();
797
845
  const startedAt = Date.now();
798
846
  try {
799
- const { config } = readConfig();
847
+ readConfig();
800
848
  stepper.start("Resolving endpoint...", "packages");
801
849
  const location = await resolveIdentifier(input);
802
850
  const locationRef = `${location.owner}/${location.repository}@${location.ref}`;
@@ -807,15 +855,13 @@ const registerInstallCommand = (program2) => {
807
855
  `Fetched skillset manifest`,
808
856
  `${skillset.name} v${skillset.version}`
809
857
  );
810
- const providerEntry = Object.entries(config.providers).find(
811
- ([name]) => name === skillset.provider
812
- );
813
- if (!providerEntry) {
858
+ const providerPath = knownProviders[skillset.provider];
859
+ if (!providerPath) {
814
860
  throw new Error(
815
- `Provider "${skillset.provider}" not found in config. Run "spm init" to detect providers.`
861
+ `Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`
816
862
  );
817
863
  }
818
- const [, provider] = providerEntry;
864
+ const provider = { path: providerPath };
819
865
  const entries = resolveSkillset(skillset, location);
820
866
  if (entries.length === 0) {
821
867
  stepper.succeed("No files to download");
@@ -824,20 +870,26 @@ const registerInstallCommand = (program2) => {
824
870
  }
825
871
  const projectRoot = getProjectRoot();
826
872
  const downloadDir = join(projectRoot, ".spm", skillset.name);
827
- const skillsetDir = location.path.replace(/\/[^/]+$/, "");
828
- const stripPrefix = (p) => p.startsWith(`${skillsetDir}/`) ? p.slice(skillsetDir.length + 1) : p;
873
+ const toTargetPath = (entry) => {
874
+ if (entry.type === "skill" && entry.skillName) {
875
+ const fileName = entry.path.split("/").pop() ?? entry.path;
876
+ return `skills/${entry.skillName}/${fileName}`;
877
+ }
878
+ const skillsetDir = location.path.replace(/\/[^/]+$/, "");
879
+ return entry.path.startsWith(`${skillsetDir}/`) ? entry.path.slice(skillsetDir.length + 1) : entry.path;
880
+ };
829
881
  stepper.start(`Downloading ${entries.length} file(s)...`, "packages");
830
882
  const results = await downloadEntries(
831
883
  entries,
832
884
  location,
833
- (type, path) => stepper.item(`${type} ${stripPrefix(path)}`)
885
+ (type, path) => stepper.item(`${type} ${path.split("/").pop() ?? path}`)
834
886
  );
835
887
  const setupResults = results.filter((r) => r.type === "setup");
836
888
  const installResults = results.filter((r) => r.type !== "setup");
837
889
  await Promise.all(
838
890
  installResults.map(async (result2) => {
839
- const relative2 = stripPrefix(result2.path);
840
- const filePath = safePath(downloadDir, relative2);
891
+ const target = toTargetPath(result2);
892
+ const filePath = safePath(downloadDir, target);
841
893
  await mkdir(dirname(filePath), { recursive: true });
842
894
  await writeFile(filePath, result2.content, "utf-8");
843
895
  })
@@ -845,30 +897,32 @@ const registerInstallCommand = (program2) => {
845
897
  let setupFile;
846
898
  if (setupResults.length > 0) {
847
899
  const setup = setupResults[0];
848
- const relative2 = stripPrefix(setup.path);
849
- setupFile = join(downloadDir, relative2);
900
+ setupFile = join(downloadDir, "SETUP.md");
850
901
  await mkdir(dirname(setupFile), { recursive: true });
851
902
  await writeFile(setupFile, setup.content, "utf-8");
852
903
  }
853
904
  stepper.succeed(`Downloaded ${results.length} file(s)`);
854
- const downloadedPaths = installResults.map((r) => stripPrefix(r.path));
905
+ const downloadedPaths = installResults.map((r) => toTargetPath(r));
855
906
  const providerFullPath = join(projectRoot, provider.path);
856
907
  const pruned = pruneUnchanged(downloadDir, providerFullPath);
857
908
  if (pruned > 0) {
858
909
  stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
859
910
  }
911
+ const model = skillset.provider === "claude" ? "haiku" : void 0;
860
912
  const result = await installSkillset(
861
913
  {
862
914
  downloadDir,
863
915
  setupFile,
864
- providerDir: provider.path,
916
+ providerDir: providerFullPath,
865
917
  skillsetName: skillset.name,
866
918
  skillsetVersion: skillset.version,
867
919
  source: `@${location.owner}/${location.repository}`,
868
- configPath: getConfigPath()
920
+ configPath: getConfigPath(),
921
+ model
869
922
  },
870
923
  stepper
871
924
  );
925
+ await rm(downloadDir, { recursive: true, force: true });
872
926
  stepper.stop();
873
927
  const elapsed = Math.round((Date.now() - startedAt) / 1e3);
874
928
  const timeStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${(elapsed % 60).toString().padStart(2, "0")}s` : `${elapsed}s`;
@@ -877,7 +931,12 @@ const registerInstallCommand = (program2) => {
877
931
  `
878
932
  );
879
933
  const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
880
- printSummary(summaryFiles);
934
+ printSummary(summaryFiles, provider.path);
935
+ process.stdout.write(
936
+ `
937
+ 🪄 ${cyan}Restart your AI agent to apply the new skills.${reset$2}
938
+ `
939
+ );
881
940
  } catch (err) {
882
941
  const message = err instanceof Error ? err.message : String(err);
883
942
  stepper.fail(message);
@@ -901,7 +960,7 @@ const banner = (version2) => [
901
960
  `${shade}▝▜${light}████▛▘ ${reset$1}${dim}v${version2} ✨ supa-magic${reset$1}`,
902
961
  `${shade} ▘${light} ▝`
903
962
  ].join("\n");
904
- const version = "0.2.2";
963
+ const version = "0.2.4";
905
964
  const program = new Command();
906
965
  const gray = "\x1B[90m";
907
966
  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.4",
4
4
  "description": "CLI tool for managing AI skillsets",
5
5
  "license": "MIT",
6
6
  "contributors": [
@@ -28,11 +28,14 @@
28
28
  },
29
29
  "scripts": {
30
30
  "build": "vite build",
31
+ "install": "npm install @supa-magic/spm",
32
+ "link": "npm link",
31
33
  "pretest": "npm run build",
32
34
  "start": "clear && claude --dangerously-skip-permissions",
33
35
  "test": "vitest run",
34
36
  "test:watch": "vitest",
35
- "tokscale": "npx tokscale@latest"
37
+ "un": "npm uninstall @supa-magic/spm",
38
+ "unlink": "npm unlink spm"
36
39
  },
37
40
  "devDependencies": {
38
41
  "@biomejs/biome": "2.4.6",
@@ -43,6 +46,7 @@
43
46
  "vitest": "^4.0.18"
44
47
  },
45
48
  "dependencies": {
49
+ "@supa-magic/spm": "^0.2.3",
46
50
  "commander": "^14.0.3",
47
51
  "yaml": "^2.8.2"
48
52
  }