skills-core.js 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import fs from 'fs';
  2. import path from 'path';
  3. import { execSync } from 'child_process';
  4. /**
  5. * Extract YAML frontmatter from a skill file.
  6. * Current format:
  7. * ---
  8. * name: skill-name
  9. * description: Use when [condition] - [what it does]
  10. * ---
  11. *
  12. * @param {string} filePath - Path to SKILL.md file
  13. * @returns {{name: string, description: string}}
  14. */
  15. function extractFrontmatter(filePath) {
  16. try {
  17. const content = fs.readFileSync(filePath, 'utf8');
  18. const lines = content.split('\n');
  19. let inFrontmatter = false;
  20. let name = '';
  21. let description = '';
  22. for (const line of lines) {
  23. if (line.trim() === '---') {
  24. if (inFrontmatter) break;
  25. inFrontmatter = true;
  26. continue;
  27. }
  28. if (inFrontmatter) {
  29. const match = line.match(/^(\w+):\s*(.*)$/);
  30. if (match) {
  31. const [, key, value] = match;
  32. switch (key) {
  33. case 'name':
  34. name = value.trim();
  35. break;
  36. case 'description':
  37. description = value.trim();
  38. break;
  39. }
  40. }
  41. }
  42. }
  43. return { name, description };
  44. } catch (error) {
  45. return { name: '', description: '' };
  46. }
  47. }
  48. /**
  49. * Find all SKILL.md files in a directory recursively.
  50. *
  51. * @param {string} dir - Directory to search
  52. * @param {string} sourceType - 'personal' or 'superpowers' for namespacing
  53. * @param {number} maxDepth - Maximum recursion depth (default: 3)
  54. * @returns {Array<{path: string, name: string, description: string, sourceType: string}>}
  55. */
  56. function findSkillsInDir(dir, sourceType, maxDepth = 3) {
  57. const skills = [];
  58. if (!fs.existsSync(dir)) return skills;
  59. function recurse(currentDir, depth) {
  60. if (depth > maxDepth) return;
  61. const entries = fs.readdirSync(currentDir, { withFileTypes: true });
  62. for (const entry of entries) {
  63. const fullPath = path.join(currentDir, entry.name);
  64. if (entry.isDirectory()) {
  65. // Check for SKILL.md in this directory
  66. const skillFile = path.join(fullPath, 'SKILL.md');
  67. if (fs.existsSync(skillFile)) {
  68. const { name, description } = extractFrontmatter(skillFile);
  69. skills.push({
  70. path: fullPath,
  71. skillFile: skillFile,
  72. name: name || entry.name,
  73. description: description || '',
  74. sourceType: sourceType
  75. });
  76. }
  77. // Recurse into subdirectories
  78. recurse(fullPath, depth + 1);
  79. }
  80. }
  81. }
  82. recurse(dir, 0);
  83. return skills;
  84. }
  85. /**
  86. * Resolve a skill name to its file path, handling shadowing
  87. * (personal skills override superpowers skills).
  88. *
  89. * @param {string} skillName - Name like "superpowers:brainstorming" or "my-skill"
  90. * @param {string} superpowersDir - Path to superpowers skills directory
  91. * @param {string} personalDir - Path to personal skills directory
  92. * @returns {{skillFile: string, sourceType: string, skillPath: string} | null}
  93. */
  94. function resolveSkillPath(skillName, superpowersDir, personalDir) {
  95. // Strip superpowers: prefix if present
  96. const forceSuperpowers = skillName.startsWith('superpowers:');
  97. const actualSkillName = forceSuperpowers ? skillName.replace(/^superpowers:/, '') : skillName;
  98. // Try personal skills first (unless explicitly superpowers:)
  99. if (!forceSuperpowers && personalDir) {
  100. const personalPath = path.join(personalDir, actualSkillName);
  101. const personalSkillFile = path.join(personalPath, 'SKILL.md');
  102. if (fs.existsSync(personalSkillFile)) {
  103. return {
  104. skillFile: personalSkillFile,
  105. sourceType: 'personal',
  106. skillPath: actualSkillName
  107. };
  108. }
  109. }
  110. // Try superpowers skills
  111. if (superpowersDir) {
  112. const superpowersPath = path.join(superpowersDir, actualSkillName);
  113. const superpowersSkillFile = path.join(superpowersPath, 'SKILL.md');
  114. if (fs.existsSync(superpowersSkillFile)) {
  115. return {
  116. skillFile: superpowersSkillFile,
  117. sourceType: 'superpowers',
  118. skillPath: actualSkillName
  119. };
  120. }
  121. }
  122. return null;
  123. }
  124. /**
  125. * Check if a git repository has updates available.
  126. *
  127. * @param {string} repoDir - Path to git repository
  128. * @returns {boolean} - True if updates are available
  129. */
  130. function checkForUpdates(repoDir) {
  131. try {
  132. // Quick check with 3 second timeout to avoid delays if network is down
  133. const output = execSync('git fetch origin && git status --porcelain=v1 --branch', {
  134. cwd: repoDir,
  135. timeout: 3000,
  136. encoding: 'utf8',
  137. stdio: 'pipe'
  138. });
  139. // Parse git status output to see if we're behind
  140. const statusLines = output.split('\n');
  141. for (const line of statusLines) {
  142. if (line.startsWith('## ') && line.includes('[behind ')) {
  143. return true; // We're behind remote
  144. }
  145. }
  146. return false; // Up to date
  147. } catch (error) {
  148. // Network down, git error, timeout, etc. - don't block bootstrap
  149. return false;
  150. }
  151. }
  152. /**
  153. * Strip YAML frontmatter from skill content, returning just the content.
  154. *
  155. * @param {string} content - Full content including frontmatter
  156. * @returns {string} - Content without frontmatter
  157. */
  158. function stripFrontmatter(content) {
  159. const lines = content.split('\n');
  160. let inFrontmatter = false;
  161. let frontmatterEnded = false;
  162. const contentLines = [];
  163. for (const line of lines) {
  164. if (line.trim() === '---') {
  165. if (inFrontmatter) {
  166. frontmatterEnded = true;
  167. continue;
  168. }
  169. inFrontmatter = true;
  170. continue;
  171. }
  172. if (frontmatterEnded || !inFrontmatter) {
  173. contentLines.push(line);
  174. }
  175. }
  176. return contentLines.join('\n').trim();
  177. }
  178. export {
  179. extractFrontmatter,
  180. findSkillsInDir,
  181. resolveSkillPath,
  182. checkForUpdates,
  183. stripFrontmatter
  184. };