@supa-magic/spm 0.2.6 → 0.3.1
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 +341 -122
- package/package.json +1 -1
package/dist/bin/spm.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
|
4
|
-
import { join, dirname, relative, resolve, normalize } from "node:path";
|
|
4
|
+
import { join, dirname, relative, posix, resolve, normalize } from "node:path";
|
|
5
5
|
import { parse, stringify } from "yaml";
|
|
6
6
|
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
7
7
|
import { readFile, mkdir, writeFile, rm, readdir } from "node:fs/promises";
|
|
@@ -50,7 +50,9 @@ const detectProviders = (root) => Object.entries(knownProviders).reduce(
|
|
|
50
50
|
);
|
|
51
51
|
const getGitRoot = () => {
|
|
52
52
|
try {
|
|
53
|
-
return execSync("git rev-parse --show-toplevel", {
|
|
53
|
+
return execSync("git rev-parse --show-toplevel", {
|
|
54
|
+
encoding: "utf-8"
|
|
55
|
+
}).trim();
|
|
54
56
|
} catch {
|
|
55
57
|
return void 0;
|
|
56
58
|
}
|
|
@@ -77,6 +79,25 @@ const writeConfig = (config, root) => {
|
|
|
77
79
|
const configPath = getConfigPath(root);
|
|
78
80
|
writeFileSync(configPath, stringify(config), "utf-8");
|
|
79
81
|
};
|
|
82
|
+
const addConfigEntry = ({
|
|
83
|
+
providerPath,
|
|
84
|
+
kind,
|
|
85
|
+
name,
|
|
86
|
+
source
|
|
87
|
+
}) => {
|
|
88
|
+
const { config } = readConfig();
|
|
89
|
+
const provider = Object.values(config.providers).find(
|
|
90
|
+
(p) => p.path === providerPath
|
|
91
|
+
);
|
|
92
|
+
if (!provider) {
|
|
93
|
+
throw new Error(`Provider with path "${providerPath}" not found in config`);
|
|
94
|
+
}
|
|
95
|
+
if (!provider[kind]) {
|
|
96
|
+
provider[kind] = {};
|
|
97
|
+
}
|
|
98
|
+
provider[kind][name] = source;
|
|
99
|
+
writeConfig(config);
|
|
100
|
+
};
|
|
80
101
|
const green = "\x1B[32m";
|
|
81
102
|
const red = "\x1B[31m";
|
|
82
103
|
const cyan = "\x1B[36m";
|
|
@@ -368,7 +389,8 @@ const downloadFile = async (source) => {
|
|
|
368
389
|
const content = await getContent(source);
|
|
369
390
|
return { content, source };
|
|
370
391
|
};
|
|
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
|
|
392
|
+
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\nIdentical files have already been removed from the download folder. Only files that need action remain. If the download folder is empty, output `Done` immediately.\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. `Running setup...` (only if a setup file exists)\n6. `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\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{{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';
|
|
393
|
+
const skillTemplate = '# Skill Integration\n\nYou are integrating a single skill 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- **Skill**: `{{skillName}}`\n\nIdentical files have already been removed from the download folder. Only files that need action remain. If the download folder is empty, output `Done` immediately.\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. `Done`\n\nExample output:\n\nAnalyzing existing setup...\n • 4 skills, 2 rules, 1 hook\n\nAnalyzing downloaded files...\n • skill: git (3 files)\n\nDetecting conflicts...\n • No conflicts\n\nIntegrating...\n • skills/git/SKILL.md\n • skills/git/branch.md\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 content** → replace silently\n- **Same content** → skip (should already be pruned)\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}}/skills/{{skillName}}/`, 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## Rules\n\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';
|
|
372
394
|
const buildSetupSection = (setupFile) => setupFile ? [
|
|
373
395
|
"## Step 6: Running setup",
|
|
374
396
|
"",
|
|
@@ -376,13 +398,20 @@ const buildSetupSection = (setupFile) => setupFile ? [
|
|
|
376
398
|
"Setup files configure the project environment (e.g. MCP servers, LSP, tooling).",
|
|
377
399
|
"Do NOT copy the setup file into the provider directory — only execute its instructions."
|
|
378
400
|
].join("\n") : "";
|
|
379
|
-
const buildInstructions = (input) => template.replace(/\{\{downloadDir\}\}/g, input.downloadDir).replace(/\{\{providerDir\}\}/g, input.providerDir).replace(/\{\{skillsetName\}\}/g, input.skillsetName).replace(/\{\{skillsetVersion\}\}/g, input.skillsetVersion).replace(
|
|
401
|
+
const buildInstructions = (input) => template.replace(/\{\{downloadDir\}\}/g, input.downloadDir).replace(/\{\{providerDir\}\}/g, input.providerDir).replace(/\{\{skillsetName\}\}/g, input.skillsetName).replace(/\{\{skillsetVersion\}\}/g, input.skillsetVersion).replace("{{setupSection}}", buildSetupSection(input.setupFile));
|
|
380
402
|
const writeInstructionsFile = (input) => {
|
|
381
403
|
const filePath = join(tmpdir(), "spm", `install-${input.skillsetName}.md`);
|
|
382
404
|
mkdirSync(dirname(filePath), { recursive: true });
|
|
383
405
|
writeFileSync(filePath, buildInstructions(input), "utf-8");
|
|
384
406
|
return filePath;
|
|
385
407
|
};
|
|
408
|
+
const buildSkillInstructions = (input) => skillTemplate.replace(/\{\{downloadDir\}\}/g, input.downloadDir).replace(/\{\{providerDir\}\}/g, input.providerDir).replace(/\{\{skillName\}\}/g, input.skillName);
|
|
409
|
+
const writeSkillInstructionsFile = (input) => {
|
|
410
|
+
const filePath = join(tmpdir(), "spm", `install-skill-${input.skillName}.md`);
|
|
411
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
412
|
+
writeFileSync(filePath, buildSkillInstructions(input), "utf-8");
|
|
413
|
+
return filePath;
|
|
414
|
+
};
|
|
386
415
|
const isNoise = (line) => line === "```" || line.startsWith("```") || /^(now |let me |the files? |i'll |i will |looking |checking )/i.test(line);
|
|
387
416
|
const isStepHeader = (raw, trimmed) => !raw.startsWith(" ") && !raw.startsWith(" ") && trimmed.endsWith("...");
|
|
388
417
|
const stepCategory = (header) => {
|
|
@@ -409,7 +438,7 @@ const toRelativePath = (filePath, providerDir) => {
|
|
|
409
438
|
if (idx !== -1) return normalized.slice(idx + marker.length);
|
|
410
439
|
return normalized.split("/").pop() ?? normalized;
|
|
411
440
|
};
|
|
412
|
-
const spawnClaude = (instructionsFilePath, stepper, providerDir, model) => new Promise((resolve2, reject) => {
|
|
441
|
+
const spawnClaude = (instructionsFilePath, stepper, providerDir, model, entityLabel = "Skillset") => new Promise((resolve2, reject) => {
|
|
413
442
|
const args = [
|
|
414
443
|
"-p",
|
|
415
444
|
"Install the skill as instructed.",
|
|
@@ -444,12 +473,12 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir, model) => new P
|
|
|
444
473
|
}
|
|
445
474
|
if (currentStepHeader.startsWith("Integrating")) {
|
|
446
475
|
stepper.succeed(
|
|
447
|
-
stepFileCount > 0 ?
|
|
476
|
+
stepFileCount > 0 ? `${entityLabel} was integrated (${stepFileCount} file(s))` : `${entityLabel} was integrated`
|
|
448
477
|
);
|
|
449
478
|
return;
|
|
450
479
|
}
|
|
451
480
|
if (currentStepHeader.startsWith("Running setup")) {
|
|
452
|
-
stepper.succeed(
|
|
481
|
+
stepper.succeed(`${entityLabel} setup completed`);
|
|
453
482
|
currentStepHeader = "";
|
|
454
483
|
return;
|
|
455
484
|
}
|
|
@@ -564,6 +593,13 @@ ${stderr}` : "";
|
|
|
564
593
|
});
|
|
565
594
|
});
|
|
566
595
|
});
|
|
596
|
+
const installSingleSkill = (input, stepper) => spawnClaude(
|
|
597
|
+
writeSkillInstructionsFile(input),
|
|
598
|
+
stepper,
|
|
599
|
+
input.providerDir,
|
|
600
|
+
input.model,
|
|
601
|
+
"Skill"
|
|
602
|
+
);
|
|
567
603
|
const installSkillset = (input, stepper) => spawnClaude(
|
|
568
604
|
writeInstructionsFile(input),
|
|
569
605
|
stepper,
|
|
@@ -591,6 +627,27 @@ const pruneUnchanged = (downloadDir, providerDir) => {
|
|
|
591
627
|
});
|
|
592
628
|
return removed;
|
|
593
629
|
};
|
|
630
|
+
const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---/;
|
|
631
|
+
const hasStringName = (value) => typeof value === "object" && value !== null && "name" in value && typeof value.name === "string";
|
|
632
|
+
const deriveSkillName = (content, fileName) => {
|
|
633
|
+
const match = content.match(frontmatterPattern);
|
|
634
|
+
if (match) {
|
|
635
|
+
const frontmatter = parse(match[1]);
|
|
636
|
+
if (hasStringName(frontmatter)) return frontmatter.name;
|
|
637
|
+
}
|
|
638
|
+
return fileName.replace(/\.md$/i, "").toLowerCase();
|
|
639
|
+
};
|
|
640
|
+
const hasDefaultBranch = (value) => typeof value === "object" && value !== null && "default_branch" in value && typeof value.default_branch === "string";
|
|
641
|
+
const fetchDefaultBranch = async (owner, repository) => {
|
|
642
|
+
const response = await fetch(
|
|
643
|
+
`https://api.github.com/repos/${owner}/${repository}`
|
|
644
|
+
);
|
|
645
|
+
if (!response.ok) {
|
|
646
|
+
return "main";
|
|
647
|
+
}
|
|
648
|
+
const data = await response.json();
|
|
649
|
+
return hasDefaultBranch(data) ? data.default_branch : "main";
|
|
650
|
+
};
|
|
594
651
|
const requiredFields = ["name", "version", "description", "provider"];
|
|
595
652
|
const validateSkillset = (data) => {
|
|
596
653
|
if (!data || typeof data !== "object") {
|
|
@@ -618,12 +675,33 @@ const fetchSkillset = async (location) => {
|
|
|
618
675
|
const text = await response.text();
|
|
619
676
|
return validateSkillset(parse(text));
|
|
620
677
|
};
|
|
678
|
+
const detectKind = (path) => /\.md$/i.test(path) ? "skill" : "skillset";
|
|
621
679
|
const parseGitHubUrl = (url) => {
|
|
622
|
-
const
|
|
623
|
-
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/[^/]
|
|
680
|
+
const blobOrTree = url.match(
|
|
681
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:tree|blob)\/([^/]+)\/(.+)$/
|
|
682
|
+
);
|
|
683
|
+
if (blobOrTree) {
|
|
684
|
+
const identifier = {
|
|
685
|
+
owner: blobOrTree[1],
|
|
686
|
+
repository: blobOrTree[2],
|
|
687
|
+
path: blobOrTree[4],
|
|
688
|
+
ref: blobOrTree[3]
|
|
689
|
+
};
|
|
690
|
+
return { kind: detectKind(identifier.path), identifier };
|
|
691
|
+
}
|
|
692
|
+
const raw = url.match(
|
|
693
|
+
/^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/
|
|
624
694
|
);
|
|
625
|
-
if (
|
|
626
|
-
|
|
695
|
+
if (raw) {
|
|
696
|
+
const identifier = {
|
|
697
|
+
owner: raw[1],
|
|
698
|
+
repository: raw[2],
|
|
699
|
+
path: raw[4],
|
|
700
|
+
ref: raw[3]
|
|
701
|
+
};
|
|
702
|
+
return { kind: detectKind(identifier.path), identifier };
|
|
703
|
+
}
|
|
704
|
+
return void 0;
|
|
627
705
|
};
|
|
628
706
|
const parseAtIdentifier = (input) => {
|
|
629
707
|
const parts = input.slice(1).split("/").filter(Boolean);
|
|
@@ -632,11 +710,12 @@ const parseAtIdentifier = (input) => {
|
|
|
632
710
|
`Invalid identifier "${input}". Expected format: @owner/repo/path (e.g., @supa-magic/skillbox/claude/fsd).`
|
|
633
711
|
);
|
|
634
712
|
}
|
|
635
|
-
|
|
713
|
+
const identifier = {
|
|
636
714
|
owner: parts[0],
|
|
637
715
|
repository: parts[1],
|
|
638
716
|
path: parts.slice(2).join("/")
|
|
639
717
|
};
|
|
718
|
+
return { kind: detectKind(identifier.path), identifier };
|
|
640
719
|
};
|
|
641
720
|
const parseIdentifier = (input) => {
|
|
642
721
|
const trimmed = input.trim();
|
|
@@ -644,7 +723,7 @@ const parseIdentifier = (input) => {
|
|
|
644
723
|
const result = parseGitHubUrl(trimmed);
|
|
645
724
|
if (!result) {
|
|
646
725
|
throw new Error(
|
|
647
|
-
`Invalid GitHub URL "${trimmed}". Expected format: https://github.com/owner/repo/tree/branch/path.`
|
|
726
|
+
`Invalid GitHub URL "${trimmed}". Expected format: https://github.com/owner/repo/tree|blob/branch/path or https://raw.githubusercontent.com/owner/repo/ref/path.`
|
|
648
727
|
);
|
|
649
728
|
}
|
|
650
729
|
return result;
|
|
@@ -656,19 +735,39 @@ const parseIdentifier = (input) => {
|
|
|
656
735
|
}
|
|
657
736
|
return parseAtIdentifier(trimmed);
|
|
658
737
|
};
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
738
|
+
const markdownLinkPattern = /\[[^\]]*\]\(([^)]+\.md)\)/g;
|
|
739
|
+
const inlinePathPattern = /(?:`|(?:^|\s))(\.[^\s`]*\.md)/g;
|
|
740
|
+
const providerPrefixes = [
|
|
741
|
+
".claude/",
|
|
742
|
+
".cursor/",
|
|
743
|
+
".copilot/",
|
|
744
|
+
".aider/",
|
|
745
|
+
".codeium/",
|
|
746
|
+
".cody/"
|
|
747
|
+
];
|
|
748
|
+
const isExcluded = (ref) => {
|
|
749
|
+
const normalized = ref.replace(/^\.\//, "");
|
|
750
|
+
return ref.startsWith("http://") || ref.startsWith("https://") || ref.startsWith("/") || ref.includes("..") || providerPrefixes.some((prefix) => normalized.startsWith(prefix));
|
|
751
|
+
};
|
|
752
|
+
const parseSkillRefs = (content, fileDir) => {
|
|
753
|
+
const refs = /* @__PURE__ */ new Set();
|
|
754
|
+
const collect = (pattern) => {
|
|
755
|
+
for (const match of content.matchAll(pattern)) {
|
|
756
|
+
const ref = match[1];
|
|
757
|
+
if (!isExcluded(ref)) {
|
|
758
|
+
const resolved = posix.normalize(posix.join(fileDir, ref));
|
|
759
|
+
refs.add(resolved);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
collect(markdownLinkPattern);
|
|
764
|
+
collect(inlinePathPattern);
|
|
765
|
+
return [...refs];
|
|
668
766
|
};
|
|
669
767
|
const resolveIdentifier = async (input) => {
|
|
670
|
-
const
|
|
671
|
-
const
|
|
768
|
+
const parsed = parseIdentifier(input);
|
|
769
|
+
const { identifier } = parsed;
|
|
770
|
+
const ref = identifier.ref ?? await fetchDefaultBranch(identifier.owner, identifier.repository);
|
|
672
771
|
return {
|
|
673
772
|
owner: identifier.owner,
|
|
674
773
|
repository: identifier.repository,
|
|
@@ -676,6 +775,40 @@ const resolveIdentifier = async (input) => {
|
|
|
676
775
|
ref
|
|
677
776
|
};
|
|
678
777
|
};
|
|
778
|
+
const resolveSkill = async (identifier) => {
|
|
779
|
+
const ref = identifier.ref ?? await fetchDefaultBranch(identifier.owner, identifier.repository);
|
|
780
|
+
const visited = /* @__PURE__ */ new Set();
|
|
781
|
+
const files = [];
|
|
782
|
+
const fetchRecursive = async (repoPath) => {
|
|
783
|
+
const normalized = posix.normalize(repoPath);
|
|
784
|
+
if (visited.has(normalized)) return;
|
|
785
|
+
visited.add(normalized);
|
|
786
|
+
const content = await downloadFromGitHub({
|
|
787
|
+
owner: identifier.owner,
|
|
788
|
+
repository: identifier.repository,
|
|
789
|
+
path: normalized,
|
|
790
|
+
ref
|
|
791
|
+
});
|
|
792
|
+
files.push({ path: normalized, content });
|
|
793
|
+
const fileDir = posix.dirname(normalized);
|
|
794
|
+
const refs = parseSkillRefs(content, fileDir);
|
|
795
|
+
await Promise.all(refs.map((refPath) => fetchRecursive(refPath)));
|
|
796
|
+
};
|
|
797
|
+
await fetchRecursive(identifier.path);
|
|
798
|
+
const mainFile = files[0];
|
|
799
|
+
const fileName = posix.basename(identifier.path);
|
|
800
|
+
const name = mainFile ? deriveSkillName(mainFile.content, fileName) : fileName.replace(/\.md$/i, "");
|
|
801
|
+
return {
|
|
802
|
+
name,
|
|
803
|
+
location: {
|
|
804
|
+
owner: identifier.owner,
|
|
805
|
+
repository: identifier.repository,
|
|
806
|
+
path: identifier.path,
|
|
807
|
+
ref
|
|
808
|
+
},
|
|
809
|
+
files
|
|
810
|
+
};
|
|
811
|
+
};
|
|
679
812
|
const resolveSkillSource = (source, location) => {
|
|
680
813
|
if (source.startsWith("./")) {
|
|
681
814
|
const skillsetDir = location.path.replace(/\/skillset\.yml$/, "");
|
|
@@ -839,108 +972,194 @@ const printSummary = (files, providerPath) => {
|
|
|
839
972
|
`);
|
|
840
973
|
});
|
|
841
974
|
};
|
|
975
|
+
const printCompleted = (startedAt) => {
|
|
976
|
+
const elapsed = Math.round((Date.now() - startedAt) / 1e3);
|
|
977
|
+
const timeStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${(elapsed % 60).toString().padStart(2, "0")}s` : `${elapsed}s`;
|
|
978
|
+
process.stdout.write(
|
|
979
|
+
`${green}✔${reset$2} Installation completed ${dim$1}in ${timeStr}${reset$2}
|
|
980
|
+
`
|
|
981
|
+
);
|
|
982
|
+
};
|
|
983
|
+
const installSkillsetFlow = async (input, stepper, startedAt) => {
|
|
984
|
+
readConfig();
|
|
985
|
+
stepper.start("Resolving endpoint...", "packages");
|
|
986
|
+
const location = await resolveIdentifier(input);
|
|
987
|
+
const locationRef = `${location.owner}/${location.repository}@${location.ref}`;
|
|
988
|
+
stepper.succeed("Resolved", locationRef);
|
|
989
|
+
stepper.start("Fetching skillset manifest...", "packages");
|
|
990
|
+
const skillset = await fetchSkillset(location);
|
|
991
|
+
stepper.succeed(
|
|
992
|
+
`Fetched skillset manifest`,
|
|
993
|
+
`${skillset.name} v${skillset.version}`
|
|
994
|
+
);
|
|
995
|
+
const providerPath = knownProviders[skillset.provider];
|
|
996
|
+
if (!providerPath) {
|
|
997
|
+
throw new Error(
|
|
998
|
+
`Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
const provider = { path: providerPath };
|
|
1002
|
+
const entries = resolveSkillset(skillset, location);
|
|
1003
|
+
if (entries.length === 0) {
|
|
1004
|
+
stepper.succeed("No files to download");
|
|
1005
|
+
stepper.stop();
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const projectRoot = getProjectRoot();
|
|
1009
|
+
const downloadDir = join(projectRoot, ".spm", skillset.name);
|
|
1010
|
+
const toTargetPath = (entry) => {
|
|
1011
|
+
if (entry.type === "skill" && entry.skillName) {
|
|
1012
|
+
const fileName = entry.path.split("/").pop() ?? entry.path;
|
|
1013
|
+
return `skills/${entry.skillName}/${fileName}`;
|
|
1014
|
+
}
|
|
1015
|
+
const skillsetDir = location.path.replace(/\/[^/]+$/, "");
|
|
1016
|
+
return entry.path.startsWith(`${skillsetDir}/`) ? entry.path.slice(skillsetDir.length + 1) : entry.path;
|
|
1017
|
+
};
|
|
1018
|
+
stepper.start(`Downloading ${entries.length} file(s)...`, "packages");
|
|
1019
|
+
const results = await downloadEntries(
|
|
1020
|
+
entries,
|
|
1021
|
+
location,
|
|
1022
|
+
(type, path) => stepper.item(`${type} ${path.split("/").pop() ?? path}`)
|
|
1023
|
+
);
|
|
1024
|
+
const setupResults = results.filter((r) => r.type === "setup");
|
|
1025
|
+
const installResults = results.filter((r) => r.type !== "setup");
|
|
1026
|
+
await Promise.all(
|
|
1027
|
+
installResults.map(async (result2) => {
|
|
1028
|
+
const target = toTargetPath(result2);
|
|
1029
|
+
const filePath = safePath(downloadDir, target);
|
|
1030
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
1031
|
+
await writeFile(filePath, result2.content, "utf-8");
|
|
1032
|
+
})
|
|
1033
|
+
);
|
|
1034
|
+
let setupFile;
|
|
1035
|
+
if (setupResults.length > 0) {
|
|
1036
|
+
const setup = setupResults[0];
|
|
1037
|
+
setupFile = join(downloadDir, "SETUP.md");
|
|
1038
|
+
await mkdir(dirname(setupFile), { recursive: true });
|
|
1039
|
+
await writeFile(setupFile, setup.content, "utf-8");
|
|
1040
|
+
}
|
|
1041
|
+
stepper.succeed(`Downloaded ${results.length} file(s)`);
|
|
1042
|
+
const downloadedPaths = installResults.map((r) => toTargetPath(r));
|
|
1043
|
+
const providerFullPath = join(projectRoot, provider.path);
|
|
1044
|
+
const pruned = pruneUnchanged(downloadDir, providerFullPath);
|
|
1045
|
+
if (pruned > 0) {
|
|
1046
|
+
stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
|
|
1047
|
+
}
|
|
1048
|
+
const model = skillset.provider === "claude" ? "haiku" : void 0;
|
|
1049
|
+
const source = `https://github.com/${location.owner}/${location.repository}/blob/${location.ref}/${location.path}`;
|
|
1050
|
+
const result = await installSkillset(
|
|
1051
|
+
{
|
|
1052
|
+
downloadDir,
|
|
1053
|
+
setupFile,
|
|
1054
|
+
providerDir: providerFullPath,
|
|
1055
|
+
skillsetName: skillset.name,
|
|
1056
|
+
skillsetVersion: skillset.version,
|
|
1057
|
+
configPath: getConfigPath(),
|
|
1058
|
+
model
|
|
1059
|
+
},
|
|
1060
|
+
stepper
|
|
1061
|
+
);
|
|
1062
|
+
addConfigEntry({
|
|
1063
|
+
providerPath: provider.path,
|
|
1064
|
+
kind: "skillsets",
|
|
1065
|
+
name: skillset.name,
|
|
1066
|
+
source
|
|
1067
|
+
});
|
|
1068
|
+
await rm(downloadDir, { recursive: true, force: true });
|
|
1069
|
+
const spmDir = join(projectRoot, ".spm");
|
|
1070
|
+
const remaining = await readdir(spmDir).catch(() => []);
|
|
1071
|
+
if (remaining.length === 0) await rm(spmDir, { recursive: true, force: true });
|
|
1072
|
+
stepper.stop();
|
|
1073
|
+
printCompleted(startedAt);
|
|
1074
|
+
const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
|
|
1075
|
+
printSummary(summaryFiles, provider.path);
|
|
1076
|
+
process.stdout.write(
|
|
1077
|
+
`
|
|
1078
|
+
🪄 ${cyan}Restart your AI agent to apply the new skills.${reset$2}
|
|
1079
|
+
`
|
|
1080
|
+
);
|
|
1081
|
+
};
|
|
1082
|
+
const installSkillFlow = async (identifier, stepper, startedAt) => {
|
|
1083
|
+
const { config } = readConfig();
|
|
1084
|
+
stepper.start("Resolving skill...", "packages");
|
|
1085
|
+
const resolved = await resolveSkill(identifier);
|
|
1086
|
+
const locationRef = `${resolved.location.owner}/${resolved.location.repository}@${resolved.location.ref}`;
|
|
1087
|
+
stepper.succeed("Resolved", `${resolved.name} ${dim$1}${locationRef}${reset$2}`);
|
|
1088
|
+
const firstProvider = Object.entries(config.providers)[0];
|
|
1089
|
+
if (!firstProvider) {
|
|
1090
|
+
throw new Error(
|
|
1091
|
+
"No provider detected. Initialize a provider directory first (e.g., .claude/)."
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
const [providerName, providerConfig] = firstProvider;
|
|
1095
|
+
const providerPath = providerConfig.path;
|
|
1096
|
+
const projectRoot = getProjectRoot();
|
|
1097
|
+
const downloadDir = join(projectRoot, ".spm", resolved.name);
|
|
1098
|
+
const skillDir = posix.dirname(resolved.location.path);
|
|
1099
|
+
stepper.start(`Downloading ${resolved.files.length} file(s)...`, "packages");
|
|
1100
|
+
await Promise.all(
|
|
1101
|
+
resolved.files.map(async (file) => {
|
|
1102
|
+
const relativePath = file.path.startsWith(`${skillDir}/`) ? file.path.slice(skillDir.length + 1) : file.path.split("/").pop() ?? file.path;
|
|
1103
|
+
const filePath = safePath(downloadDir, relativePath);
|
|
1104
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
1105
|
+
await writeFile(filePath, file.content, "utf-8");
|
|
1106
|
+
stepper.item(relativePath);
|
|
1107
|
+
})
|
|
1108
|
+
);
|
|
1109
|
+
stepper.succeed(`Downloaded ${resolved.files.length} file(s)`);
|
|
1110
|
+
const providerFullPath = join(projectRoot, providerPath);
|
|
1111
|
+
const skillProviderDir = join(providerFullPath, "skills", resolved.name);
|
|
1112
|
+
const pruned = pruneUnchanged(downloadDir, skillProviderDir);
|
|
1113
|
+
if (pruned > 0) {
|
|
1114
|
+
stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
|
|
1115
|
+
}
|
|
1116
|
+
const model = providerName === "claude" ? "haiku" : void 0;
|
|
1117
|
+
const source = `https://github.com/${resolved.location.owner}/${resolved.location.repository}/blob/${resolved.location.ref}/${resolved.location.path}`;
|
|
1118
|
+
const result = await installSingleSkill(
|
|
1119
|
+
{
|
|
1120
|
+
downloadDir,
|
|
1121
|
+
providerDir: providerFullPath,
|
|
1122
|
+
skillName: resolved.name,
|
|
1123
|
+
configPath: getConfigPath(),
|
|
1124
|
+
model
|
|
1125
|
+
},
|
|
1126
|
+
stepper
|
|
1127
|
+
);
|
|
1128
|
+
addConfigEntry({
|
|
1129
|
+
providerPath,
|
|
1130
|
+
kind: "skills",
|
|
1131
|
+
name: resolved.name,
|
|
1132
|
+
source
|
|
1133
|
+
});
|
|
1134
|
+
await rm(downloadDir, { recursive: true, force: true });
|
|
1135
|
+
const spmDir = join(projectRoot, ".spm");
|
|
1136
|
+
const remaining = await readdir(spmDir).catch(() => []);
|
|
1137
|
+
if (remaining.length === 0) await rm(spmDir, { recursive: true, force: true });
|
|
1138
|
+
stepper.stop();
|
|
1139
|
+
printCompleted(startedAt);
|
|
1140
|
+
const downloadedPaths = resolved.files.map((f) => {
|
|
1141
|
+
const skillDirPrefix = `${skillDir}/`;
|
|
1142
|
+
return f.path.startsWith(skillDirPrefix) ? `skills/${resolved.name}/${f.path.slice(skillDirPrefix.length)}` : `skills/${resolved.name}/${f.path.split("/").pop() ?? f.path}`;
|
|
1143
|
+
});
|
|
1144
|
+
const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
|
|
1145
|
+
printSummary(summaryFiles, providerPath);
|
|
1146
|
+
process.stdout.write(
|
|
1147
|
+
`
|
|
1148
|
+
🪄 ${cyan}Restart your AI agent to apply the new skills.${reset$2}
|
|
1149
|
+
`
|
|
1150
|
+
);
|
|
1151
|
+
};
|
|
842
1152
|
const registerInstallCommand = (program2) => {
|
|
843
|
-
program2.command("install <
|
|
1153
|
+
program2.command("install <target>").alias("i").description("Install a skillset or skill into the project").action(async (input) => {
|
|
844
1154
|
const stepper = createStepper();
|
|
845
1155
|
const startedAt = Date.now();
|
|
846
1156
|
try {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
stepper.start("Fetching skillset manifest...", "packages");
|
|
853
|
-
const skillset = await fetchSkillset(location);
|
|
854
|
-
stepper.succeed(
|
|
855
|
-
`Fetched skillset manifest`,
|
|
856
|
-
`${skillset.name} v${skillset.version}`
|
|
857
|
-
);
|
|
858
|
-
const providerPath = knownProviders[skillset.provider];
|
|
859
|
-
if (!providerPath) {
|
|
860
|
-
throw new Error(
|
|
861
|
-
`Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`
|
|
862
|
-
);
|
|
863
|
-
}
|
|
864
|
-
const provider = { path: providerPath };
|
|
865
|
-
const entries = resolveSkillset(skillset, location);
|
|
866
|
-
if (entries.length === 0) {
|
|
867
|
-
stepper.succeed("No files to download");
|
|
868
|
-
stepper.stop();
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
const projectRoot = getProjectRoot();
|
|
872
|
-
const downloadDir = join(projectRoot, ".spm", skillset.name);
|
|
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
|
-
};
|
|
881
|
-
stepper.start(`Downloading ${entries.length} file(s)...`, "packages");
|
|
882
|
-
const results = await downloadEntries(
|
|
883
|
-
entries,
|
|
884
|
-
location,
|
|
885
|
-
(type, path) => stepper.item(`${type} ${path.split("/").pop() ?? path}`)
|
|
886
|
-
);
|
|
887
|
-
const setupResults = results.filter((r) => r.type === "setup");
|
|
888
|
-
const installResults = results.filter((r) => r.type !== "setup");
|
|
889
|
-
await Promise.all(
|
|
890
|
-
installResults.map(async (result2) => {
|
|
891
|
-
const target = toTargetPath(result2);
|
|
892
|
-
const filePath = safePath(downloadDir, target);
|
|
893
|
-
await mkdir(dirname(filePath), { recursive: true });
|
|
894
|
-
await writeFile(filePath, result2.content, "utf-8");
|
|
895
|
-
})
|
|
896
|
-
);
|
|
897
|
-
let setupFile;
|
|
898
|
-
if (setupResults.length > 0) {
|
|
899
|
-
const setup = setupResults[0];
|
|
900
|
-
setupFile = join(downloadDir, "SETUP.md");
|
|
901
|
-
await mkdir(dirname(setupFile), { recursive: true });
|
|
902
|
-
await writeFile(setupFile, setup.content, "utf-8");
|
|
903
|
-
}
|
|
904
|
-
stepper.succeed(`Downloaded ${results.length} file(s)`);
|
|
905
|
-
const downloadedPaths = installResults.map((r) => toTargetPath(r));
|
|
906
|
-
const providerFullPath = join(projectRoot, provider.path);
|
|
907
|
-
const pruned = pruneUnchanged(downloadDir, providerFullPath);
|
|
908
|
-
if (pruned > 0) {
|
|
909
|
-
stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
|
|
1157
|
+
const parsed = parseIdentifier(input);
|
|
1158
|
+
if (parsed.kind === "skill") {
|
|
1159
|
+
await installSkillFlow(parsed.identifier, stepper, startedAt);
|
|
1160
|
+
} else {
|
|
1161
|
+
await installSkillsetFlow(input, stepper, startedAt);
|
|
910
1162
|
}
|
|
911
|
-
const model = skillset.provider === "claude" ? "haiku" : void 0;
|
|
912
|
-
const result = await installSkillset(
|
|
913
|
-
{
|
|
914
|
-
downloadDir,
|
|
915
|
-
setupFile,
|
|
916
|
-
providerDir: providerFullPath,
|
|
917
|
-
skillsetName: skillset.name,
|
|
918
|
-
skillsetVersion: skillset.version,
|
|
919
|
-
source: `@${location.owner}/${location.repository}`,
|
|
920
|
-
configPath: getConfigPath(),
|
|
921
|
-
model
|
|
922
|
-
},
|
|
923
|
-
stepper
|
|
924
|
-
);
|
|
925
|
-
await rm(downloadDir, { recursive: true, force: true });
|
|
926
|
-
const spmDir = join(projectRoot, ".spm");
|
|
927
|
-
const remaining = await readdir(spmDir).catch(() => []);
|
|
928
|
-
if (remaining.length === 0)
|
|
929
|
-
await rm(spmDir, { recursive: true, force: true });
|
|
930
|
-
stepper.stop();
|
|
931
|
-
const elapsed = Math.round((Date.now() - startedAt) / 1e3);
|
|
932
|
-
const timeStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${(elapsed % 60).toString().padStart(2, "0")}s` : `${elapsed}s`;
|
|
933
|
-
process.stdout.write(
|
|
934
|
-
`${green}✔${reset$2} Installation completed ${dim$1}in ${timeStr}${reset$2}
|
|
935
|
-
`
|
|
936
|
-
);
|
|
937
|
-
const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
|
|
938
|
-
printSummary(summaryFiles, provider.path);
|
|
939
|
-
process.stdout.write(
|
|
940
|
-
`
|
|
941
|
-
🪄 ${cyan}Restart your AI agent to apply the new skills.${reset$2}
|
|
942
|
-
`
|
|
943
|
-
);
|
|
944
1163
|
} catch (err) {
|
|
945
1164
|
const message = err instanceof Error ? err.message : String(err);
|
|
946
1165
|
stepper.fail(message);
|
|
@@ -964,7 +1183,7 @@ const banner = (version2) => [
|
|
|
964
1183
|
`${shade}▝▜${light}████▛▘ ${reset$1}${dim}v${version2} ✨ supa-magic${reset$1}`,
|
|
965
1184
|
`${shade} ▘${light} ▝`
|
|
966
1185
|
].join("\n");
|
|
967
|
-
const version = "0.
|
|
1186
|
+
const version = "0.3.1";
|
|
968
1187
|
const program = new Command();
|
|
969
1188
|
const gray = "\x1B[90m";
|
|
970
1189
|
const reset = "\x1B[0m";
|