@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.
- package/dist/bin/spm.js +64 -30
- 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. `
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
+
state.doneReceived || resultText.length > 0;
|
|
525
|
+
const stderr = stderrBuffer.trim();
|
|
515
526
|
if (code !== 0 && code !== null) {
|
|
516
|
-
|
|
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
|
|
520
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
808
|
-
|
|
809
|
-
);
|
|
810
|
-
if (!providerEntry) {
|
|
835
|
+
const providerPath = knownProviders[skillset.provider];
|
|
836
|
+
if (!providerPath) {
|
|
811
837
|
throw new Error(
|
|
812
|
-
`
|
|
838
|
+
`Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`
|
|
813
839
|
);
|
|
814
840
|
}
|
|
815
|
-
const
|
|
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:
|
|
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.
|
|
935
|
+
const version = "0.2.3";
|
|
902
936
|
const program = new Command();
|
|
903
937
|
const gray = "\x1B[90m";
|
|
904
938
|
const reset = "\x1B[0m";
|