@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.
- package/dist/bin/spm.js +60 -29
- 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);
|
|
@@ -497,7 +502,9 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir) => new Promise(
|
|
|
497
502
|
}
|
|
498
503
|
});
|
|
499
504
|
});
|
|
500
|
-
|
|
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
|
-
|
|
524
|
+
state.doneReceived || resultText.length > 0;
|
|
525
|
+
const stderr = stderrBuffer.trim();
|
|
518
526
|
if (code !== 0 && code !== null) {
|
|
519
|
-
|
|
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
|
|
523
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
811
|
-
|
|
812
|
-
);
|
|
813
|
-
if (!providerEntry) {
|
|
835
|
+
const providerPath = knownProviders[skillset.provider];
|
|
836
|
+
if (!providerPath) {
|
|
814
837
|
throw new Error(
|
|
815
|
-
`
|
|
838
|
+
`Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`
|
|
816
839
|
);
|
|
817
840
|
}
|
|
818
|
-
const
|
|
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:
|
|
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.
|
|
935
|
+
const version = "0.2.3";
|
|
905
936
|
const program = new Command();
|
|
906
937
|
const gray = "\x1B[90m";
|
|
907
938
|
const reset = "\x1B[0m";
|