209 lines
6.3 KiB
JavaScript
209 lines
6.3 KiB
JavaScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import { execSync } from 'child_process';
|
|
|
|
/**
|
|
* Extract YAML frontmatter from a skill file.
|
|
* Current format:
|
|
* ---
|
|
* name: skill-name
|
|
* description: Use when [condition] - [what it does]
|
|
* ---
|
|
*
|
|
* @param {string} filePath - Path to SKILL.md file
|
|
* @returns {{name: string, description: string}}
|
|
*/
|
|
function extractFrontmatter(filePath) {
|
|
try {
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const lines = content.split('\n');
|
|
|
|
let inFrontmatter = false;
|
|
let name = '';
|
|
let description = '';
|
|
|
|
for (const line of lines) {
|
|
if (line.trim() === '---') {
|
|
if (inFrontmatter) break;
|
|
inFrontmatter = true;
|
|
continue;
|
|
}
|
|
|
|
if (inFrontmatter) {
|
|
const match = line.match(/^(\w+):\s*(.*)$/);
|
|
if (match) {
|
|
const [, key, value] = match;
|
|
switch (key) {
|
|
case 'name':
|
|
name = value.trim();
|
|
break;
|
|
case 'description':
|
|
description = value.trim();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { name, description };
|
|
} catch (error) {
|
|
return { name: '', description: '' };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find all SKILL.md files in a directory recursively.
|
|
*
|
|
* @param {string} dir - Directory to search
|
|
* @param {string} sourceType - 'personal' or 'superpowers' for namespacing
|
|
* @param {number} maxDepth - Maximum recursion depth (default: 3)
|
|
* @returns {Array<{path: string, name: string, description: string, sourceType: string}>}
|
|
*/
|
|
function findSkillsInDir(dir, sourceType, maxDepth = 3) {
|
|
const skills = [];
|
|
|
|
if (!fs.existsSync(dir)) return skills;
|
|
|
|
function recurse(currentDir, depth) {
|
|
if (depth > maxDepth) return;
|
|
|
|
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(currentDir, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
// Check for SKILL.md in this directory
|
|
const skillFile = path.join(fullPath, 'SKILL.md');
|
|
if (fs.existsSync(skillFile)) {
|
|
const { name, description } = extractFrontmatter(skillFile);
|
|
skills.push({
|
|
path: fullPath,
|
|
skillFile: skillFile,
|
|
name: name || entry.name,
|
|
description: description || '',
|
|
sourceType: sourceType
|
|
});
|
|
}
|
|
|
|
// Recurse into subdirectories
|
|
recurse(fullPath, depth + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
recurse(dir, 0);
|
|
return skills;
|
|
}
|
|
|
|
/**
|
|
* Resolve a skill name to its file path, handling shadowing
|
|
* (personal skills override superpowers skills).
|
|
*
|
|
* @param {string} skillName - Name like "superpowers:brainstorming" or "my-skill"
|
|
* @param {string} superpowersDir - Path to superpowers skills directory
|
|
* @param {string} personalDir - Path to personal skills directory
|
|
* @returns {{skillFile: string, sourceType: string, skillPath: string} | null}
|
|
*/
|
|
function resolveSkillPath(skillName, superpowersDir, personalDir) {
|
|
// Strip superpowers: prefix if present
|
|
const forceSuperpowers = skillName.startsWith('superpowers:');
|
|
const actualSkillName = forceSuperpowers ? skillName.replace(/^superpowers:/, '') : skillName;
|
|
|
|
// Try personal skills first (unless explicitly superpowers:)
|
|
if (!forceSuperpowers && personalDir) {
|
|
const personalPath = path.join(personalDir, actualSkillName);
|
|
const personalSkillFile = path.join(personalPath, 'SKILL.md');
|
|
if (fs.existsSync(personalSkillFile)) {
|
|
return {
|
|
skillFile: personalSkillFile,
|
|
sourceType: 'personal',
|
|
skillPath: actualSkillName
|
|
};
|
|
}
|
|
}
|
|
|
|
// Try superpowers skills
|
|
if (superpowersDir) {
|
|
const superpowersPath = path.join(superpowersDir, actualSkillName);
|
|
const superpowersSkillFile = path.join(superpowersPath, 'SKILL.md');
|
|
if (fs.existsSync(superpowersSkillFile)) {
|
|
return {
|
|
skillFile: superpowersSkillFile,
|
|
sourceType: 'superpowers',
|
|
skillPath: actualSkillName
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Check if a git repository has updates available.
|
|
*
|
|
* @param {string} repoDir - Path to git repository
|
|
* @returns {boolean} - True if updates are available
|
|
*/
|
|
function checkForUpdates(repoDir) {
|
|
try {
|
|
// Quick check with 3 second timeout to avoid delays if network is down
|
|
const output = execSync('git fetch origin && git status --porcelain=v1 --branch', {
|
|
cwd: repoDir,
|
|
timeout: 3000,
|
|
encoding: 'utf8',
|
|
stdio: 'pipe'
|
|
});
|
|
|
|
// Parse git status output to see if we're behind
|
|
const statusLines = output.split('\n');
|
|
for (const line of statusLines) {
|
|
if (line.startsWith('## ') && line.includes('[behind ')) {
|
|
return true; // We're behind remote
|
|
}
|
|
}
|
|
return false; // Up to date
|
|
} catch (error) {
|
|
// Network down, git error, timeout, etc. - don't block bootstrap
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strip YAML frontmatter from skill content, returning just the content.
|
|
*
|
|
* @param {string} content - Full content including frontmatter
|
|
* @returns {string} - Content without frontmatter
|
|
*/
|
|
function stripFrontmatter(content) {
|
|
const lines = content.split('\n');
|
|
let inFrontmatter = false;
|
|
let frontmatterEnded = false;
|
|
const contentLines = [];
|
|
|
|
for (const line of lines) {
|
|
if (line.trim() === '---') {
|
|
if (inFrontmatter) {
|
|
frontmatterEnded = true;
|
|
continue;
|
|
}
|
|
inFrontmatter = true;
|
|
continue;
|
|
}
|
|
|
|
if (frontmatterEnded || !inFrontmatter) {
|
|
contentLines.push(line);
|
|
}
|
|
}
|
|
|
|
return contentLines.join('\n').trim();
|
|
}
|
|
|
|
export {
|
|
extractFrontmatter,
|
|
findSkillsInDir,
|
|
resolveSkillPath,
|
|
checkForUpdates,
|
|
stripFrontmatter
|
|
};
|