* fix: stabilize validation and tests on Windows * test: add Windows smoke coverage for skill activation * refactor: make setup_web script CommonJS * fix: repair aegisops-ai frontmatter * docs: add when-to-use guidance to core skills * docs: add when-to-use guidance to Apify skills * docs: add when-to-use guidance to Google and Expo skills * docs: add when-to-use guidance to Makepad skills * docs: add when-to-use guidance to git workflow skills * docs: add when-to-use guidance to fp-ts skills * docs: add when-to-use guidance to Three.js skills * docs: add when-to-use guidance to n8n skills * docs: add when-to-use guidance to health analysis skills * docs: add when-to-use guidance to writing and review skills * meta: sync generated catalog metadata * docs: add when-to-use guidance to Robius skills * docs: add when-to-use guidance to review and workflow skills * docs: add when-to-use guidance to science and data skills * docs: add when-to-use guidance to tooling and automation skills * docs: add when-to-use guidance to remaining skills * fix: gate bundle helper execution in Windows activation * chore: drop generated artifacts from contributor PR * docs(maintenance): Record PR 457 sweep Document the open issue triage, PR supersedence decision, local verification, and source-only cleanup that prepared PR #457 for re-running CI. --------- Co-authored-by: sickn33 <sickn33@users.noreply.github.com>
227 lines
6.0 KiB
JavaScript
227 lines
6.0 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const yaml = require('yaml');
|
|
|
|
function isSafeDirectory(dirPath) {
|
|
try {
|
|
const stats = fs.lstatSync(dirPath);
|
|
return stats.isDirectory() && !stats.isSymbolicLink();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isSafeSkillFile(skillPath) {
|
|
try {
|
|
const stats = fs.lstatSync(skillPath);
|
|
return stats.isFile() && !stats.isSymbolicLink();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function stripQuotes(value) {
|
|
if (typeof value !== 'string') return value;
|
|
if (value.length < 2) return value.trim();
|
|
const first = value[0];
|
|
const last = value[value.length - 1];
|
|
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
return value.slice(1, -1).trim();
|
|
}
|
|
if (first === '"' || first === "'") {
|
|
return value.slice(1).trim();
|
|
}
|
|
if (last === '"' || last === "'") {
|
|
return value.slice(0, -1).trim();
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function parseInlineList(raw) {
|
|
if (typeof raw !== 'string') return [];
|
|
const value = raw.trim();
|
|
if (!value.startsWith('[') || !value.endsWith(']')) return [];
|
|
const inner = value.slice(1, -1).trim();
|
|
if (!inner) return [];
|
|
return inner
|
|
.split(',')
|
|
.map(item => stripQuotes(item.trim()))
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
return value && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function parseFrontmatter(content) {
|
|
const sanitized = content.replace(/^\uFEFF/, '');
|
|
const lines = sanitized.split(/\r?\n/);
|
|
if (!lines.length || lines[0].trim() !== '---') {
|
|
return { data: {}, body: content, errors: [], hasFrontmatter: false };
|
|
}
|
|
|
|
let endIndex = -1;
|
|
for (let i = 1; i < lines.length; i += 1) {
|
|
if (lines[i].trim() === '---') {
|
|
endIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (endIndex === -1) {
|
|
return {
|
|
data: {},
|
|
body: content,
|
|
errors: ['Missing closing frontmatter delimiter (---).'],
|
|
hasFrontmatter: true,
|
|
};
|
|
}
|
|
|
|
const errors = [];
|
|
const fmText = lines.slice(1, endIndex).join('\n');
|
|
let data = {};
|
|
|
|
try {
|
|
const doc = yaml.parseDocument(fmText, { prettyErrors: false });
|
|
if (doc.errors && doc.errors.length) {
|
|
errors.push(...doc.errors.map(error => error.message));
|
|
}
|
|
data = doc.toJS();
|
|
} catch (err) {
|
|
errors.push(err.message);
|
|
data = {};
|
|
}
|
|
|
|
if (!isPlainObject(data)) {
|
|
errors.push('Frontmatter must be a YAML mapping/object.');
|
|
data = {};
|
|
}
|
|
|
|
const body = lines.slice(endIndex + 1).join('\n');
|
|
return { data, body, errors, hasFrontmatter: true };
|
|
}
|
|
|
|
function tokenize(value) {
|
|
if (!value) return [];
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.split(' ')
|
|
.map(token => token.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function unique(list) {
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const item of list) {
|
|
if (!item || seen.has(item)) continue;
|
|
seen.add(item);
|
|
result.push(item);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function readSkill(skillDir, skillId) {
|
|
const skillPath = path.join(skillDir, skillId, 'SKILL.md');
|
|
const content = fs.readFileSync(skillPath, 'utf8');
|
|
const { data } = parseFrontmatter(content);
|
|
const name = typeof data.name === 'string' && data.name.trim()
|
|
? data.name.trim()
|
|
: skillId;
|
|
const description = typeof data.description === 'string'
|
|
? data.description.trim()
|
|
: '';
|
|
|
|
let tags = [];
|
|
if (Array.isArray(data.tags)) {
|
|
tags = data.tags.map(tag => String(tag).trim());
|
|
} else if (typeof data.tags === 'string' && data.tags.trim()) {
|
|
const inlineTags = parseInlineList(data.tags);
|
|
const parts = inlineTags.length > 0
|
|
? inlineTags
|
|
: (data.tags.includes(',') ? data.tags.split(',') : data.tags.split(/\s+/));
|
|
tags = parts.map(tag => tag.trim());
|
|
} else if (isPlainObject(data.metadata) && data.metadata.tags) {
|
|
const rawTags = data.metadata.tags;
|
|
if (Array.isArray(rawTags)) {
|
|
tags = rawTags.map(tag => String(tag).trim());
|
|
} else if (typeof rawTags === 'string' && rawTags.trim()) {
|
|
const inlineTags = parseInlineList(rawTags);
|
|
const parts = inlineTags.length > 0
|
|
? inlineTags
|
|
: (rawTags.includes(',') ? rawTags.split(',') : rawTags.split(/\s+/));
|
|
tags = parts.map(tag => tag.trim());
|
|
}
|
|
}
|
|
|
|
tags = tags.filter(Boolean);
|
|
const category = typeof data.category === 'string' ? data.category.trim() : '';
|
|
const risk = typeof data.risk === 'string' ? data.risk.trim() : '';
|
|
|
|
return {
|
|
id: skillId,
|
|
name,
|
|
description,
|
|
category,
|
|
risk,
|
|
tags,
|
|
path: skillPath,
|
|
content,
|
|
};
|
|
}
|
|
|
|
function listSkillIds(skillsDir) {
|
|
return fs.readdirSync(skillsDir)
|
|
.filter(entry => {
|
|
if (entry.startsWith('.')) return false;
|
|
const dirPath = path.join(skillsDir, entry);
|
|
if (!isSafeDirectory(dirPath)) return false;
|
|
const skillPath = path.join(dirPath, 'SKILL.md');
|
|
return isSafeSkillFile(skillPath);
|
|
})
|
|
.sort();
|
|
}
|
|
|
|
/**
|
|
* Recursively list all skill directory paths under skillsDir (relative paths).
|
|
* Matches generate_index.py behavior so catalog includes nested skills (e.g. game-development/2d-games).
|
|
*/
|
|
function listSkillIdsRecursive(skillsDir, baseDir = skillsDir, acc = []) {
|
|
const stack = [baseDir];
|
|
const visited = new Set();
|
|
|
|
while (stack.length > 0) {
|
|
const currentDir = stack.pop();
|
|
const resolvedDir = path.resolve(currentDir);
|
|
if (visited.has(resolvedDir)) continue;
|
|
visited.add(resolvedDir);
|
|
|
|
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith('.')) continue;
|
|
const dirPath = path.join(currentDir, entry.name);
|
|
if (!isSafeDirectory(dirPath)) continue;
|
|
|
|
const skillPath = path.join(dirPath, 'SKILL.md');
|
|
const relPath = path.relative(skillsDir, dirPath).split(path.sep).join('/');
|
|
if (isSafeSkillFile(skillPath)) {
|
|
acc.push(relPath);
|
|
}
|
|
stack.push(dirPath);
|
|
}
|
|
}
|
|
return acc.sort();
|
|
}
|
|
|
|
module.exports = {
|
|
listSkillIds,
|
|
listSkillIdsRecursive,
|
|
parseFrontmatter,
|
|
parseInlineList,
|
|
readSkill,
|
|
stripQuotes,
|
|
tokenize,
|
|
unique,
|
|
};
|