|
|
|
|
@@ -32,12 +32,24 @@ const MAX_SKILL_LINES = 500;
|
|
|
|
|
const ALLOWED_FIELDS = new Set([
|
|
|
|
|
'name',
|
|
|
|
|
'description',
|
|
|
|
|
'risk',
|
|
|
|
|
'source',
|
|
|
|
|
'license',
|
|
|
|
|
'compatibility',
|
|
|
|
|
'metadata',
|
|
|
|
|
'allowed-tools',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const USE_SECTION_PATTERNS = [
|
|
|
|
|
/^##\s+When\s+to\s+Use/im,
|
|
|
|
|
/^##\s+Use\s+this\s+skill\s+when/im,
|
|
|
|
|
/^##\s+When\s+to\s+Use\s+This\s+Skill/im,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function hasUseSection(content) {
|
|
|
|
|
return USE_SECTION_PATTERNS.some(pattern => pattern.test(content));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isPlainObject(value) {
|
|
|
|
|
return value && typeof value === 'object' && !Array.isArray(value);
|
|
|
|
|
}
|
|
|
|
|
@@ -99,172 +111,183 @@ function addStrictSectionErrors(label, missing, baselineSet) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const skillIds = listSkillIds(SKILLS_DIR);
|
|
|
|
|
const baseline = loadBaseline();
|
|
|
|
|
const baselineUse = new Set(baseline.useSection || []);
|
|
|
|
|
const baselineDoNotUse = new Set(baseline.doNotUseSection || []);
|
|
|
|
|
const baselineInstructions = new Set(baseline.instructionsSection || []);
|
|
|
|
|
const baselineLongFile = new Set(baseline.longFile || []);
|
|
|
|
|
function run() {
|
|
|
|
|
const skillIds = listSkillIds(SKILLS_DIR);
|
|
|
|
|
const baseline = loadBaseline();
|
|
|
|
|
const baselineUse = new Set(baseline.useSection || []);
|
|
|
|
|
const baselineDoNotUse = new Set(baseline.doNotUseSection || []);
|
|
|
|
|
const baselineInstructions = new Set(baseline.instructionsSection || []);
|
|
|
|
|
const baselineLongFile = new Set(baseline.longFile || []);
|
|
|
|
|
|
|
|
|
|
for (const skillId of skillIds) {
|
|
|
|
|
const skillPath = path.join(SKILLS_DIR, skillId, 'SKILL.md');
|
|
|
|
|
for (const skillId of skillIds) {
|
|
|
|
|
const skillPath = path.join(SKILLS_DIR, skillId, 'SKILL.md');
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(skillPath)) {
|
|
|
|
|
addError(`Missing SKILL.md: ${skillId}`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = fs.readFileSync(skillPath, 'utf8');
|
|
|
|
|
const { data, errors: fmErrors, hasFrontmatter } = parseFrontmatter(content);
|
|
|
|
|
const lineCount = content.split(/\r?\n/).length;
|
|
|
|
|
|
|
|
|
|
if (!hasFrontmatter) {
|
|
|
|
|
addError(`Missing frontmatter: ${skillId}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fmErrors && fmErrors.length) {
|
|
|
|
|
fmErrors.forEach(error => addError(`Frontmatter parse error (${skillId}): ${error}`));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!NAME_PATTERN.test(skillId)) {
|
|
|
|
|
addError(`Folder name must match ${NAME_PATTERN}: ${skillId}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.name !== undefined) {
|
|
|
|
|
const nameError = validateStringField('name', data.name, { min: 1, max: MAX_NAME_LENGTH });
|
|
|
|
|
if (nameError) {
|
|
|
|
|
addError(`${nameError} (${skillId})`);
|
|
|
|
|
} else {
|
|
|
|
|
const nameValue = String(data.name).trim();
|
|
|
|
|
if (!NAME_PATTERN.test(nameValue)) {
|
|
|
|
|
addError(`name must match ${NAME_PATTERN}: ${skillId}`);
|
|
|
|
|
}
|
|
|
|
|
if (nameValue !== skillId) {
|
|
|
|
|
addError(`name must match folder name: ${skillId} -> ${nameValue}`);
|
|
|
|
|
}
|
|
|
|
|
if (!fs.existsSync(skillPath)) {
|
|
|
|
|
addError(`Missing SKILL.md: ${skillId}`);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const descError = data.description === undefined
|
|
|
|
|
? 'description is required.'
|
|
|
|
|
: validateStringField('description', data.description, { min: 1, max: MAX_DESCRIPTION_LENGTH });
|
|
|
|
|
if (descError) {
|
|
|
|
|
addError(`${descError} (${skillId})`);
|
|
|
|
|
}
|
|
|
|
|
const content = fs.readFileSync(skillPath, 'utf8');
|
|
|
|
|
const { data, errors: fmErrors, hasFrontmatter } = parseFrontmatter(content);
|
|
|
|
|
const lineCount = content.split(/\r?\n/).length;
|
|
|
|
|
|
|
|
|
|
if (data.license !== undefined) {
|
|
|
|
|
const licenseError = validateStringField('license', data.license, { min: 1, max: 128 });
|
|
|
|
|
if (licenseError) {
|
|
|
|
|
addError(`${licenseError} (${skillId})`);
|
|
|
|
|
if (!hasFrontmatter) {
|
|
|
|
|
addError(`Missing frontmatter: ${skillId}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.compatibility !== undefined) {
|
|
|
|
|
const compatibilityError = validateStringField(
|
|
|
|
|
'compatibility',
|
|
|
|
|
data.compatibility,
|
|
|
|
|
{ min: 1, max: MAX_COMPATIBILITY_LENGTH },
|
|
|
|
|
);
|
|
|
|
|
if (compatibilityError) {
|
|
|
|
|
addError(`${compatibilityError} (${skillId})`);
|
|
|
|
|
if (fmErrors && fmErrors.length) {
|
|
|
|
|
fmErrors.forEach(error => addError(`Frontmatter parse error (${skillId}): ${error}`));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data['allowed-tools'] !== undefined) {
|
|
|
|
|
if (typeof data['allowed-tools'] !== 'string') {
|
|
|
|
|
addError(`allowed-tools must be a space-delimited string. (${skillId})`);
|
|
|
|
|
} else if (!data['allowed-tools'].trim()) {
|
|
|
|
|
addError(`allowed-tools cannot be empty. (${skillId})`);
|
|
|
|
|
if (!NAME_PATTERN.test(skillId)) {
|
|
|
|
|
addError(`Folder name must match ${NAME_PATTERN}: ${skillId}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.metadata !== undefined) {
|
|
|
|
|
if (!isPlainObject(data.metadata)) {
|
|
|
|
|
addError(`metadata must be a string map/object. (${skillId})`);
|
|
|
|
|
} else {
|
|
|
|
|
for (const [key, value] of Object.entries(data.metadata)) {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
addError(`metadata.${key} must be a string. (${skillId})`);
|
|
|
|
|
if (data.name !== undefined) {
|
|
|
|
|
const nameError = validateStringField('name', data.name, { min: 1, max: MAX_NAME_LENGTH });
|
|
|
|
|
if (nameError) {
|
|
|
|
|
addError(`${nameError} (${skillId})`);
|
|
|
|
|
} else {
|
|
|
|
|
const nameValue = String(data.name).trim();
|
|
|
|
|
if (!NAME_PATTERN.test(nameValue)) {
|
|
|
|
|
addError(`name must match ${NAME_PATTERN}: ${skillId}`);
|
|
|
|
|
}
|
|
|
|
|
if (nameValue !== skillId) {
|
|
|
|
|
addError(`name must match folder name: ${skillId} -> ${nameValue}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data && Object.keys(data).length) {
|
|
|
|
|
const unknownFields = Object.keys(data).filter(key => !ALLOWED_FIELDS.has(key));
|
|
|
|
|
if (unknownFields.length) {
|
|
|
|
|
unknownFieldSkills.push(skillId);
|
|
|
|
|
addError(`Unknown frontmatter fields (${skillId}): ${unknownFields.join(', ')}`);
|
|
|
|
|
const descError = data.description === undefined
|
|
|
|
|
? 'description is required.'
|
|
|
|
|
: validateStringField('description', data.description, { min: 1, max: MAX_DESCRIPTION_LENGTH });
|
|
|
|
|
if (descError) {
|
|
|
|
|
addError(`${descError} (${skillId})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.license !== undefined) {
|
|
|
|
|
const licenseError = validateStringField('license', data.license, { min: 1, max: 128 });
|
|
|
|
|
if (licenseError) {
|
|
|
|
|
addError(`${licenseError} (${skillId})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.compatibility !== undefined) {
|
|
|
|
|
const compatibilityError = validateStringField(
|
|
|
|
|
'compatibility',
|
|
|
|
|
data.compatibility,
|
|
|
|
|
{ min: 1, max: MAX_COMPATIBILITY_LENGTH },
|
|
|
|
|
);
|
|
|
|
|
if (compatibilityError) {
|
|
|
|
|
addError(`${compatibilityError} (${skillId})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data['allowed-tools'] !== undefined) {
|
|
|
|
|
if (typeof data['allowed-tools'] !== 'string') {
|
|
|
|
|
addError(`allowed-tools must be a space-delimited string. (${skillId})`);
|
|
|
|
|
} else if (!data['allowed-tools'].trim()) {
|
|
|
|
|
addError(`allowed-tools cannot be empty. (${skillId})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data.metadata !== undefined) {
|
|
|
|
|
if (!isPlainObject(data.metadata)) {
|
|
|
|
|
addError(`metadata must be a string map/object. (${skillId})`);
|
|
|
|
|
} else {
|
|
|
|
|
for (const [key, value] of Object.entries(data.metadata)) {
|
|
|
|
|
if (typeof value !== 'string') {
|
|
|
|
|
addError(`metadata.${key} must be a string. (${skillId})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (data && Object.keys(data).length) {
|
|
|
|
|
const unknownFields = Object.keys(data).filter(key => !ALLOWED_FIELDS.has(key));
|
|
|
|
|
if (unknownFields.length) {
|
|
|
|
|
unknownFieldSkills.push(skillId);
|
|
|
|
|
addError(`Unknown frontmatter fields (${skillId}): ${unknownFields.join(', ')}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (lineCount > MAX_SKILL_LINES) {
|
|
|
|
|
longFiles.push(skillId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!hasUseSection(content)) {
|
|
|
|
|
missingUseSection.push(skillId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!content.includes('## Do not use')) {
|
|
|
|
|
missingDoNotUseSection.push(skillId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!content.includes('## Instructions')) {
|
|
|
|
|
missingInstructionsSection.push(skillId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (lineCount > MAX_SKILL_LINES) {
|
|
|
|
|
longFiles.push(skillId);
|
|
|
|
|
if (missingUseSection.length) {
|
|
|
|
|
addWarning(`Missing "Use this skill when" section: ${missingUseSection.length} skills (examples: ${missingUseSection.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!content.includes('## Use this skill when')) {
|
|
|
|
|
missingUseSection.push(skillId);
|
|
|
|
|
if (missingDoNotUseSection.length) {
|
|
|
|
|
addWarning(`Missing "Do not use" section: ${missingDoNotUseSection.length} skills (examples: ${missingDoNotUseSection.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!content.includes('## Do not use')) {
|
|
|
|
|
missingDoNotUseSection.push(skillId);
|
|
|
|
|
if (missingInstructionsSection.length) {
|
|
|
|
|
addWarning(`Missing "Instructions" section: ${missingInstructionsSection.length} skills (examples: ${missingInstructionsSection.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!content.includes('## Instructions')) {
|
|
|
|
|
missingInstructionsSection.push(skillId);
|
|
|
|
|
if (longFiles.length) {
|
|
|
|
|
addWarning(`SKILL.md over ${MAX_SKILL_LINES} lines: ${longFiles.length} skills (examples: ${longFiles.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (missingUseSection.length) {
|
|
|
|
|
addWarning(`Missing "Use this skill when" section: ${missingUseSection.length} skills (examples: ${missingUseSection.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (missingDoNotUseSection.length) {
|
|
|
|
|
addWarning(`Missing "Do not use" section: ${missingDoNotUseSection.length} skills (examples: ${missingDoNotUseSection.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (missingInstructionsSection.length) {
|
|
|
|
|
addWarning(`Missing "Instructions" section: ${missingInstructionsSection.length} skills (examples: ${missingInstructionsSection.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (longFiles.length) {
|
|
|
|
|
addWarning(`SKILL.md over ${MAX_SKILL_LINES} lines: ${longFiles.length} skills (examples: ${longFiles.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (unknownFieldSkills.length) {
|
|
|
|
|
addWarning(`Unknown frontmatter fields detected: ${unknownFieldSkills.length} skills (examples: ${unknownFieldSkills.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addStrictSectionErrors('Use this skill when', missingUseSection, baselineUse);
|
|
|
|
|
addStrictSectionErrors('Do not use', missingDoNotUseSection, baselineDoNotUse);
|
|
|
|
|
addStrictSectionErrors('Instructions', missingInstructionsSection, baselineInstructions);
|
|
|
|
|
addStrictSectionErrors(`SKILL.md line count <= ${MAX_SKILL_LINES}`, longFiles, baselineLongFile);
|
|
|
|
|
|
|
|
|
|
if (writeBaseline) {
|
|
|
|
|
const baselineData = {
|
|
|
|
|
generatedAt: new Date().toISOString(),
|
|
|
|
|
useSection: [...missingUseSection].sort(),
|
|
|
|
|
doNotUseSection: [...missingDoNotUseSection].sort(),
|
|
|
|
|
instructionsSection: [...missingInstructionsSection].sort(),
|
|
|
|
|
longFile: [...longFiles].sort(),
|
|
|
|
|
};
|
|
|
|
|
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineData, null, 2));
|
|
|
|
|
console.log(`Baseline written to ${BASELINE_PATH}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (warnings.length) {
|
|
|
|
|
console.warn('Warnings:');
|
|
|
|
|
for (const warning of warnings) {
|
|
|
|
|
console.warn(`- ${warning}`);
|
|
|
|
|
if (unknownFieldSkills.length) {
|
|
|
|
|
addWarning(`Unknown frontmatter fields detected: ${unknownFieldSkills.length} skills (examples: ${unknownFieldSkills.slice(0, 5).join(', ')})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (errors.length) {
|
|
|
|
|
console.error('\nErrors:');
|
|
|
|
|
for (const error of errors) {
|
|
|
|
|
console.error(`- ${error}`);
|
|
|
|
|
addStrictSectionErrors('Use this skill when', missingUseSection, baselineUse);
|
|
|
|
|
addStrictSectionErrors('Do not use', missingDoNotUseSection, baselineDoNotUse);
|
|
|
|
|
addStrictSectionErrors('Instructions', missingInstructionsSection, baselineInstructions);
|
|
|
|
|
addStrictSectionErrors(`SKILL.md line count <= ${MAX_SKILL_LINES}`, longFiles, baselineLongFile);
|
|
|
|
|
|
|
|
|
|
if (writeBaseline) {
|
|
|
|
|
const baselineData = {
|
|
|
|
|
generatedAt: new Date().toISOString(),
|
|
|
|
|
useSection: [...missingUseSection].sort(),
|
|
|
|
|
doNotUseSection: [...missingDoNotUseSection].sort(),
|
|
|
|
|
instructionsSection: [...missingInstructionsSection].sort(),
|
|
|
|
|
longFile: [...longFiles].sort(),
|
|
|
|
|
};
|
|
|
|
|
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineData, null, 2));
|
|
|
|
|
console.log(`Baseline written to ${BASELINE_PATH}`);
|
|
|
|
|
}
|
|
|
|
|
process.exit(1);
|
|
|
|
|
|
|
|
|
|
if (warnings.length) {
|
|
|
|
|
console.warn('Warnings:');
|
|
|
|
|
for (const warning of warnings) {
|
|
|
|
|
console.warn(`- ${warning}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (errors.length) {
|
|
|
|
|
console.error('\nErrors:');
|
|
|
|
|
for (const error of errors) {
|
|
|
|
|
console.error(`- ${error}`);
|
|
|
|
|
}
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`Validation passed for ${skillIds.length} skills.`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(`Validation passed for ${skillIds.length} skills.`);
|
|
|
|
|
if (require.main === module) {
|
|
|
|
|
run();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
hasUseSection,
|
|
|
|
|
run,
|
|
|
|
|
};
|
|
|
|
|
|