@supa-magic/spm 0.2.6 → 0.3.0

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 +309 -120
  2. 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", { encoding: "utf-8" }).trim();
53
+ return execSync("git rev-parse --show-toplevel", {
54
+ encoding: "utf-8"
55
+ }).trim();
54
56
  } catch {
55
57
  return void 0;
56
58
  }
@@ -369,6 +371,7 @@ const downloadFile = async (source) => {
369
371
  return { content, source };
370
372
  };
371
373
  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';
374
+ 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- **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. `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\nUpdating config...\n • skill: git\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## Step 5: Updating config\n\nUpdate `{{configPath}}`:\n\n- Under the provider with path `{{providerDir}}`, add the skill entry under `skills`:\n `{{skillName}}: "{{source}}"`\n- The `skillsets` map is reserved for skillset installations\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
375
  const buildSetupSection = (setupFile) => setupFile ? [
373
376
  "## Step 6: Running setup",
374
377
  "",
@@ -383,6 +386,13 @@ const writeInstructionsFile = (input) => {
383
386
  writeFileSync(filePath, buildInstructions(input), "utf-8");
384
387
  return filePath;
385
388
  };
389
+ const buildSkillInstructions = (input) => skillTemplate.replace(/\{\{downloadDir\}\}/g, input.downloadDir).replace(/\{\{providerDir\}\}/g, input.providerDir).replace(/\{\{skillName\}\}/g, input.skillName).replace(/\{\{source\}\}/g, input.source).replace(/\{\{configPath\}\}/g, input.configPath);
390
+ const writeSkillInstructionsFile = (input) => {
391
+ const filePath = join(tmpdir(), "spm", `install-skill-${input.skillName}.md`);
392
+ mkdirSync(dirname(filePath), { recursive: true });
393
+ writeFileSync(filePath, buildSkillInstructions(input), "utf-8");
394
+ return filePath;
395
+ };
386
396
  const isNoise = (line) => line === "```" || line.startsWith("```") || /^(now |let me |the files? |i'll |i will |looking |checking )/i.test(line);
387
397
  const isStepHeader = (raw, trimmed) => !raw.startsWith(" ") && !raw.startsWith(" ") && trimmed.endsWith("...");
388
398
  const stepCategory = (header) => {
@@ -409,7 +419,7 @@ const toRelativePath = (filePath, providerDir) => {
409
419
  if (idx !== -1) return normalized.slice(idx + marker.length);
410
420
  return normalized.split("/").pop() ?? normalized;
411
421
  };
412
- const spawnClaude = (instructionsFilePath, stepper, providerDir, model) => new Promise((resolve2, reject) => {
422
+ const spawnClaude = (instructionsFilePath, stepper, providerDir, model, entityLabel = "Skillset") => new Promise((resolve2, reject) => {
413
423
  const args = [
414
424
  "-p",
415
425
  "Install the skill as instructed.",
@@ -444,12 +454,12 @@ const spawnClaude = (instructionsFilePath, stepper, providerDir, model) => new P
444
454
  }
445
455
  if (currentStepHeader.startsWith("Integrating")) {
446
456
  stepper.succeed(
447
- stepFileCount > 0 ? `Skillset was integrated (${stepFileCount} file(s))` : "Skillset was integrated"
457
+ stepFileCount > 0 ? `${entityLabel} was integrated (${stepFileCount} file(s))` : `${entityLabel} was integrated`
448
458
  );
449
459
  return;
450
460
  }
451
461
  if (currentStepHeader.startsWith("Running setup")) {
452
- stepper.succeed("Skillset setup completed");
462
+ stepper.succeed(`${entityLabel} setup completed`);
453
463
  currentStepHeader = "";
454
464
  return;
455
465
  }
@@ -564,6 +574,13 @@ ${stderr}` : "";
564
574
  });
565
575
  });
566
576
  });
577
+ const installSingleSkill = (input, stepper) => spawnClaude(
578
+ writeSkillInstructionsFile(input),
579
+ stepper,
580
+ input.providerDir,
581
+ input.model,
582
+ "Skill"
583
+ );
567
584
  const installSkillset = (input, stepper) => spawnClaude(
568
585
  writeInstructionsFile(input),
569
586
  stepper,
@@ -591,6 +608,27 @@ const pruneUnchanged = (downloadDir, providerDir) => {
591
608
  });
592
609
  return removed;
593
610
  };
611
+ const frontmatterPattern = /^---\r?\n([\s\S]*?)\r?\n---/;
612
+ const hasStringName = (value) => typeof value === "object" && value !== null && "name" in value && typeof value.name === "string";
613
+ const deriveSkillName = (content, fileName) => {
614
+ const match = content.match(frontmatterPattern);
615
+ if (match) {
616
+ const frontmatter = parse(match[1]);
617
+ if (hasStringName(frontmatter)) return frontmatter.name;
618
+ }
619
+ return fileName.replace(/\.md$/i, "").toLowerCase();
620
+ };
621
+ const hasDefaultBranch = (value) => typeof value === "object" && value !== null && "default_branch" in value && typeof value.default_branch === "string";
622
+ const fetchDefaultBranch = async (owner, repository) => {
623
+ const response = await fetch(
624
+ `https://api.github.com/repos/${owner}/${repository}`
625
+ );
626
+ if (!response.ok) {
627
+ return "main";
628
+ }
629
+ const data = await response.json();
630
+ return hasDefaultBranch(data) ? data.default_branch : "main";
631
+ };
594
632
  const requiredFields = ["name", "version", "description", "provider"];
595
633
  const validateSkillset = (data) => {
596
634
  if (!data || typeof data !== "object") {
@@ -618,12 +656,33 @@ const fetchSkillset = async (location) => {
618
656
  const text = await response.text();
619
657
  return validateSkillset(parse(text));
620
658
  };
659
+ const detectKind = (path) => /\.md$/i.test(path) ? "skill" : "skillset";
621
660
  const parseGitHubUrl = (url) => {
622
- const match = url.match(
623
- /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/tree\/[^/]+\/(.+)$/
661
+ const blobOrTree = url.match(
662
+ /^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:tree|blob)\/([^/]+)\/(.+)$/
663
+ );
664
+ if (blobOrTree) {
665
+ const identifier = {
666
+ owner: blobOrTree[1],
667
+ repository: blobOrTree[2],
668
+ path: blobOrTree[4],
669
+ ref: blobOrTree[3]
670
+ };
671
+ return { kind: detectKind(identifier.path), identifier };
672
+ }
673
+ const raw = url.match(
674
+ /^https?:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/
624
675
  );
625
- if (!match) return void 0;
626
- return { owner: match[1], repository: match[2], path: match[3] };
676
+ if (raw) {
677
+ const identifier = {
678
+ owner: raw[1],
679
+ repository: raw[2],
680
+ path: raw[4],
681
+ ref: raw[3]
682
+ };
683
+ return { kind: detectKind(identifier.path), identifier };
684
+ }
685
+ return void 0;
627
686
  };
628
687
  const parseAtIdentifier = (input) => {
629
688
  const parts = input.slice(1).split("/").filter(Boolean);
@@ -632,11 +691,12 @@ const parseAtIdentifier = (input) => {
632
691
  `Invalid identifier "${input}". Expected format: @owner/repo/path (e.g., @supa-magic/skillbox/claude/fsd).`
633
692
  );
634
693
  }
635
- return {
694
+ const identifier = {
636
695
  owner: parts[0],
637
696
  repository: parts[1],
638
697
  path: parts.slice(2).join("/")
639
698
  };
699
+ return { kind: detectKind(identifier.path), identifier };
640
700
  };
641
701
  const parseIdentifier = (input) => {
642
702
  const trimmed = input.trim();
@@ -644,7 +704,7 @@ const parseIdentifier = (input) => {
644
704
  const result = parseGitHubUrl(trimmed);
645
705
  if (!result) {
646
706
  throw new Error(
647
- `Invalid GitHub URL "${trimmed}". Expected format: https://github.com/owner/repo/tree/branch/path.`
707
+ `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
708
  );
649
709
  }
650
710
  return result;
@@ -656,19 +716,39 @@ const parseIdentifier = (input) => {
656
716
  }
657
717
  return parseAtIdentifier(trimmed);
658
718
  };
659
- const fetchDefaultBranch = async (owner, repository) => {
660
- const response = await fetch(
661
- `https://api.github.com/repos/${owner}/${repository}`
662
- );
663
- if (!response.ok) {
664
- return "main";
665
- }
666
- const data = await response.json();
667
- return data.default_branch ?? "main";
719
+ const markdownLinkPattern = /\[[^\]]*\]\(([^)]+\.md)\)/g;
720
+ const inlinePathPattern = /(?:`|(?:^|\s))(\.[^\s`]*\.md)/g;
721
+ const providerPrefixes = [
722
+ ".claude/",
723
+ ".cursor/",
724
+ ".copilot/",
725
+ ".aider/",
726
+ ".codeium/",
727
+ ".cody/"
728
+ ];
729
+ const isExcluded = (ref) => {
730
+ const normalized = ref.replace(/^\.\//, "");
731
+ return ref.startsWith("http://") || ref.startsWith("https://") || ref.startsWith("/") || ref.includes("..") || providerPrefixes.some((prefix) => normalized.startsWith(prefix));
732
+ };
733
+ const parseSkillRefs = (content, fileDir) => {
734
+ const refs = /* @__PURE__ */ new Set();
735
+ const collect = (pattern) => {
736
+ for (const match of content.matchAll(pattern)) {
737
+ const ref = match[1];
738
+ if (!isExcluded(ref)) {
739
+ const resolved = posix.normalize(posix.join(fileDir, ref));
740
+ refs.add(resolved);
741
+ }
742
+ }
743
+ };
744
+ collect(markdownLinkPattern);
745
+ collect(inlinePathPattern);
746
+ return [...refs];
668
747
  };
669
748
  const resolveIdentifier = async (input) => {
670
- const identifier = parseIdentifier(input);
671
- const ref = await fetchDefaultBranch(identifier.owner, identifier.repository);
749
+ const parsed = parseIdentifier(input);
750
+ const { identifier } = parsed;
751
+ const ref = identifier.ref ?? await fetchDefaultBranch(identifier.owner, identifier.repository);
672
752
  return {
673
753
  owner: identifier.owner,
674
754
  repository: identifier.repository,
@@ -676,6 +756,40 @@ const resolveIdentifier = async (input) => {
676
756
  ref
677
757
  };
678
758
  };
759
+ const resolveSkill = async (identifier) => {
760
+ const ref = identifier.ref ?? await fetchDefaultBranch(identifier.owner, identifier.repository);
761
+ const visited = /* @__PURE__ */ new Set();
762
+ const files = [];
763
+ const fetchRecursive = async (repoPath) => {
764
+ const normalized = posix.normalize(repoPath);
765
+ if (visited.has(normalized)) return;
766
+ visited.add(normalized);
767
+ const content = await downloadFromGitHub({
768
+ owner: identifier.owner,
769
+ repository: identifier.repository,
770
+ path: normalized,
771
+ ref
772
+ });
773
+ files.push({ path: normalized, content });
774
+ const fileDir = posix.dirname(normalized);
775
+ const refs = parseSkillRefs(content, fileDir);
776
+ await Promise.all(refs.map((refPath) => fetchRecursive(refPath)));
777
+ };
778
+ await fetchRecursive(identifier.path);
779
+ const mainFile = files[0];
780
+ const fileName = posix.basename(identifier.path);
781
+ const name = mainFile ? deriveSkillName(mainFile.content, fileName) : fileName.replace(/\.md$/i, "");
782
+ return {
783
+ name,
784
+ location: {
785
+ owner: identifier.owner,
786
+ repository: identifier.repository,
787
+ path: identifier.path,
788
+ ref
789
+ },
790
+ files
791
+ };
792
+ };
679
793
  const resolveSkillSource = (source, location) => {
680
794
  if (source.startsWith("./")) {
681
795
  const skillsetDir = location.path.replace(/\/skillset\.yml$/, "");
@@ -839,108 +953,183 @@ const printSummary = (files, providerPath) => {
839
953
  `);
840
954
  });
841
955
  };
956
+ const printCompleted = (startedAt) => {
957
+ const elapsed = Math.round((Date.now() - startedAt) / 1e3);
958
+ const timeStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m${(elapsed % 60).toString().padStart(2, "0")}s` : `${elapsed}s`;
959
+ process.stdout.write(
960
+ `${green}✔${reset$2} Installation completed ${dim$1}in ${timeStr}${reset$2}
961
+ `
962
+ );
963
+ };
964
+ const installSkillsetFlow = async (input, stepper, startedAt) => {
965
+ readConfig();
966
+ stepper.start("Resolving endpoint...", "packages");
967
+ const location = await resolveIdentifier(input);
968
+ const locationRef = `${location.owner}/${location.repository}@${location.ref}`;
969
+ stepper.succeed("Resolved", locationRef);
970
+ stepper.start("Fetching skillset manifest...", "packages");
971
+ const skillset = await fetchSkillset(location);
972
+ stepper.succeed(
973
+ `Fetched skillset manifest`,
974
+ `${skillset.name} v${skillset.version}`
975
+ );
976
+ const providerPath = knownProviders[skillset.provider];
977
+ if (!providerPath) {
978
+ throw new Error(
979
+ `Unknown provider "${skillset.provider}". Known providers: ${Object.keys(knownProviders).join(", ")}`
980
+ );
981
+ }
982
+ const provider = { path: providerPath };
983
+ const entries = resolveSkillset(skillset, location);
984
+ if (entries.length === 0) {
985
+ stepper.succeed("No files to download");
986
+ stepper.stop();
987
+ return;
988
+ }
989
+ const projectRoot = getProjectRoot();
990
+ const downloadDir = join(projectRoot, ".spm", skillset.name);
991
+ const toTargetPath = (entry) => {
992
+ if (entry.type === "skill" && entry.skillName) {
993
+ const fileName = entry.path.split("/").pop() ?? entry.path;
994
+ return `skills/${entry.skillName}/${fileName}`;
995
+ }
996
+ const skillsetDir = location.path.replace(/\/[^/]+$/, "");
997
+ return entry.path.startsWith(`${skillsetDir}/`) ? entry.path.slice(skillsetDir.length + 1) : entry.path;
998
+ };
999
+ stepper.start(`Downloading ${entries.length} file(s)...`, "packages");
1000
+ const results = await downloadEntries(
1001
+ entries,
1002
+ location,
1003
+ (type, path) => stepper.item(`${type} ${path.split("/").pop() ?? path}`)
1004
+ );
1005
+ const setupResults = results.filter((r) => r.type === "setup");
1006
+ const installResults = results.filter((r) => r.type !== "setup");
1007
+ await Promise.all(
1008
+ installResults.map(async (result2) => {
1009
+ const target = toTargetPath(result2);
1010
+ const filePath = safePath(downloadDir, target);
1011
+ await mkdir(dirname(filePath), { recursive: true });
1012
+ await writeFile(filePath, result2.content, "utf-8");
1013
+ })
1014
+ );
1015
+ let setupFile;
1016
+ if (setupResults.length > 0) {
1017
+ const setup = setupResults[0];
1018
+ setupFile = join(downloadDir, "SETUP.md");
1019
+ await mkdir(dirname(setupFile), { recursive: true });
1020
+ await writeFile(setupFile, setup.content, "utf-8");
1021
+ }
1022
+ stepper.succeed(`Downloaded ${results.length} file(s)`);
1023
+ const downloadedPaths = installResults.map((r) => toTargetPath(r));
1024
+ const providerFullPath = join(projectRoot, provider.path);
1025
+ const pruned = pruneUnchanged(downloadDir, providerFullPath);
1026
+ if (pruned > 0) {
1027
+ stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
1028
+ }
1029
+ const model = skillset.provider === "claude" ? "haiku" : void 0;
1030
+ const result = await installSkillset(
1031
+ {
1032
+ downloadDir,
1033
+ setupFile,
1034
+ providerDir: providerFullPath,
1035
+ skillsetName: skillset.name,
1036
+ skillsetVersion: skillset.version,
1037
+ source: `@${location.owner}/${location.repository}`,
1038
+ configPath: getConfigPath(),
1039
+ model
1040
+ },
1041
+ stepper
1042
+ );
1043
+ await rm(downloadDir, { recursive: true, force: true });
1044
+ const spmDir = join(projectRoot, ".spm");
1045
+ const remaining = await readdir(spmDir).catch(() => []);
1046
+ if (remaining.length === 0) await rm(spmDir, { recursive: true, force: true });
1047
+ stepper.stop();
1048
+ printCompleted(startedAt);
1049
+ const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
1050
+ printSummary(summaryFiles, provider.path);
1051
+ process.stdout.write(
1052
+ `
1053
+ 🪄 ${cyan}Restart your AI agent to apply the new skills.${reset$2}
1054
+ `
1055
+ );
1056
+ };
1057
+ const installSkillFlow = async (identifier, stepper, startedAt) => {
1058
+ const { config } = readConfig();
1059
+ stepper.start("Resolving skill...", "packages");
1060
+ const resolved = await resolveSkill(identifier);
1061
+ const locationRef = `${resolved.location.owner}/${resolved.location.repository}@${resolved.location.ref}`;
1062
+ stepper.succeed("Resolved", `${resolved.name} ${dim$1}${locationRef}${reset$2}`);
1063
+ const firstProvider = Object.entries(config.providers)[0];
1064
+ if (!firstProvider) {
1065
+ throw new Error(
1066
+ "No provider detected. Initialize a provider directory first (e.g., .claude/)."
1067
+ );
1068
+ }
1069
+ const [providerName, providerConfig] = firstProvider;
1070
+ const providerPath = providerConfig.path;
1071
+ const projectRoot = getProjectRoot();
1072
+ const downloadDir = join(projectRoot, ".spm", resolved.name);
1073
+ const skillDir = posix.dirname(resolved.location.path);
1074
+ stepper.start(`Downloading ${resolved.files.length} file(s)...`, "packages");
1075
+ await Promise.all(
1076
+ resolved.files.map(async (file) => {
1077
+ const relativePath = file.path.startsWith(`${skillDir}/`) ? file.path.slice(skillDir.length + 1) : file.path.split("/").pop() ?? file.path;
1078
+ const filePath = safePath(downloadDir, relativePath);
1079
+ await mkdir(dirname(filePath), { recursive: true });
1080
+ await writeFile(filePath, file.content, "utf-8");
1081
+ stepper.item(relativePath);
1082
+ })
1083
+ );
1084
+ stepper.succeed(`Downloaded ${resolved.files.length} file(s)`);
1085
+ const providerFullPath = join(projectRoot, providerPath);
1086
+ const skillProviderDir = join(providerFullPath, "skills", resolved.name);
1087
+ const pruned = pruneUnchanged(downloadDir, skillProviderDir);
1088
+ if (pruned > 0) {
1089
+ stepper.succeed(`Skipped ${pruned} unchanged file(s)`);
1090
+ }
1091
+ const model = providerName === "claude" ? "haiku" : void 0;
1092
+ const source = `@${resolved.location.owner}/${resolved.location.repository}`;
1093
+ const result = await installSingleSkill(
1094
+ {
1095
+ downloadDir,
1096
+ providerDir: providerFullPath,
1097
+ skillName: resolved.name,
1098
+ source,
1099
+ configPath: getConfigPath(),
1100
+ model
1101
+ },
1102
+ stepper
1103
+ );
1104
+ await rm(downloadDir, { recursive: true, force: true });
1105
+ const spmDir = join(projectRoot, ".spm");
1106
+ const remaining = await readdir(spmDir).catch(() => []);
1107
+ if (remaining.length === 0) await rm(spmDir, { recursive: true, force: true });
1108
+ stepper.stop();
1109
+ printCompleted(startedAt);
1110
+ const downloadedPaths = resolved.files.map((f) => {
1111
+ const skillDirPrefix = `${skillDir}/`;
1112
+ return f.path.startsWith(skillDirPrefix) ? `skills/${resolved.name}/${f.path.slice(skillDirPrefix.length)}` : `skills/${resolved.name}/${f.path.split("/").pop() ?? f.path}`;
1113
+ });
1114
+ const summaryFiles = result.files.length > 0 ? result.files : downloadedPaths;
1115
+ printSummary(summaryFiles, providerPath);
1116
+ process.stdout.write(
1117
+ `
1118
+ 🪄 ${cyan}Restart your AI agent to apply the new skills.${reset$2}
1119
+ `
1120
+ );
1121
+ };
842
1122
  const registerInstallCommand = (program2) => {
843
- program2.command("install <skillset>").alias("i").description("Install a skillset into the project").action(async (input) => {
1123
+ program2.command("install <target>").alias("i").description("Install a skillset or skill into the project").action(async (input) => {
844
1124
  const stepper = createStepper();
845
1125
  const startedAt = Date.now();
846
1126
  try {
847
- readConfig();
848
- stepper.start("Resolving endpoint...", "packages");
849
- const location = await resolveIdentifier(input);
850
- const locationRef = `${location.owner}/${location.repository}@${location.ref}`;
851
- stepper.succeed("Resolved", locationRef);
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)`);
1127
+ const parsed = parseIdentifier(input);
1128
+ if (parsed.kind === "skill") {
1129
+ await installSkillFlow(parsed.identifier, stepper, startedAt);
1130
+ } else {
1131
+ await installSkillsetFlow(input, stepper, startedAt);
910
1132
  }
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
1133
  } catch (err) {
945
1134
  const message = err instanceof Error ? err.message : String(err);
946
1135
  stepper.fail(message);
@@ -964,7 +1153,7 @@ const banner = (version2) => [
964
1153
  `${shade}▝▜${light}████▛▘ ${reset$1}${dim}v${version2} ✨ supa-magic${reset$1}`,
965
1154
  `${shade} ▘${light} ▝`
966
1155
  ].join("\n");
967
- const version = "0.2.6";
1156
+ const version = "0.3.0";
968
1157
  const program = new Command();
969
1158
  const gray = "\x1B[90m";
970
1159
  const reset = "\x1B[0m";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supa-magic/spm",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "CLI tool for managing AI skillsets",
5
5
  "license": "MIT",
6
6
  "contributors": [