From b4a9c468103d88e0b02998db1ea368dda12cae50 Mon Sep 17 00:00:00 2001 From: Mohan Raj Rajamanickam Date: Wed, 11 Mar 2026 13:17:42 -0700 Subject: [PATCH] chore: update Node version, change license, and refactor skills validation script to TypeScript --- .github/workflows/validate-skills.yml | 2 +- .nvmrc | 2 +- package.json | 3 +- scripts/validate-skills.js | 104 ------------- scripts/validate-skills.ts | 206 ++++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 107 deletions(-) delete mode 100644 scripts/validate-skills.js create mode 100644 scripts/validate-skills.ts diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml index c6a66cc..29e51f8 100644 --- a/.github/workflows/validate-skills.yml +++ b/.github/workflows/validate-skills.yml @@ -13,4 +13,4 @@ jobs: with: node-version-file: '.nvmrc' - run: npm ci - - run: node scripts/validate-skills.js + - run: npx tsx scripts/validate-skills.ts diff --git a/.nvmrc b/.nvmrc index 12419e9..ffa3aec 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.18.1 \ No newline at end of file +v22.22.1 \ No newline at end of file diff --git a/package.json b/package.json index 10c91ff..9c2831d 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,14 @@ "name": "@salesforce/afv-skills", "version": "1.0.0", "description": "Salesforce skills for Agentforce Vibes", - "license": "Apache-2.0", + "license": "CC-BY-NC-4.0", "files": [ "skills/*/" ], "devDependencies": { "@commitlint/cli": "^19.0.0", "@commitlint/config-conventional": "^19.0.0", + "tsx": "^4.21.0", "@salesforce/webapp-template-app-react-sample-b2e-experimental": "*", "@salesforce/webapp-template-app-react-sample-b2x-experimental": "*" }, diff --git a/scripts/validate-skills.js b/scripts/validate-skills.js deleted file mode 100644 index 2f6ea89..0000000 --- a/scripts/validate-skills.js +++ /dev/null @@ -1,104 +0,0 @@ -#!/usr/bin/env node -// Validates the skills/ directory structure and SKILL.md format. -// Exits with code 1 if any violations are found. - -const fs = require("fs") -const path = require("path") - -const SKILLS_DIR = path.join(__dirname, "..", "skills") - -let errors = [] -let checked = 0 - -function parseFrontmatter(content) { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) - if (!match) return null - const raw = match[1] - const result = {} - for (const line of raw.split(/\r?\n/)) { - const colonIdx = line.indexOf(":") - if (colonIdx === -1) continue - const key = line.slice(0, colonIdx).trim() - const value = line.slice(colonIdx + 1).trim() - result[key] = value - } - return result -} - -function getFrontmatterEnd(content) { - const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/) - if (!match) return -1 - return match[0].length -} - -const topLevelEntries = fs.readdirSync(SKILLS_DIR) - -for (const entry of topLevelEntries) { - const entryPath = path.join(SKILLS_DIR, entry) - const stat = fs.statSync(entryPath) - - if (!stat.isDirectory()) { - errors.push(`Loose file in skills/: ${entry} (expected only directories)`) - continue - } - - const skillMdPath = path.join(entryPath, "SKILL.md") - if (!fs.existsSync(skillMdPath)) { - errors.push(`Missing SKILL.md in skills/${entry}/`) - continue - } - - // Check for nested subdirectories that also contain SKILL.md - const subEntries = fs.readdirSync(entryPath) - for (const sub of subEntries) { - const subPath = path.join(entryPath, sub) - if (fs.statSync(subPath).isDirectory()) { - const nestedSkillMd = path.join(subPath, "SKILL.md") - if (fs.existsSync(nestedSkillMd)) { - errors.push( - `Nested skill detected: skills/${entry}/${sub}/SKILL.md — skill directories must be exactly one level deep under skills/` - ) - } - } - } - - // Validate SKILL.md frontmatter and body - const content = fs.readFileSync(skillMdPath, "utf8") - - const frontmatter = parseFrontmatter(content) - if (!frontmatter) { - errors.push(`skills/${entry}/SKILL.md: missing or malformed YAML frontmatter (expected --- ... --- block at top)`) - continue - } - - if (!frontmatter.name) { - errors.push(`skills/${entry}/SKILL.md: missing "name" field in frontmatter`) - } else if (frontmatter.name !== entry) { - errors.push( - `skills/${entry}/SKILL.md: "name" field ("${frontmatter.name}") does not match directory name ("${entry}")` - ) - } - - if (!frontmatter.description || frontmatter.description.trim() === "") { - errors.push(`skills/${entry}/SKILL.md: missing or empty "description" field in frontmatter`) - } - - const frontmatterEnd = getFrontmatterEnd(content) - const body = frontmatterEnd !== -1 ? content.slice(frontmatterEnd).trim() : "" - if (!body) { - errors.push(`skills/${entry}/SKILL.md: body (instructions after frontmatter) is empty`) - } - - checked++ -} - -if (errors.length > 0) { - console.error(`\nSkill validation failed with ${errors.length} error(s):\n`) - for (const err of errors) { - console.error(` ✗ ${err}`) - } - console.error("") - process.exit(1) -} else { - console.log(`Skill validation passed: ${checked} skill(s) checked.`) -} diff --git a/scripts/validate-skills.ts b/scripts/validate-skills.ts new file mode 100644 index 0000000..93892e2 --- /dev/null +++ b/scripts/validate-skills.ts @@ -0,0 +1,206 @@ +#!/usr/bin/env tsx +// Validates the skills/ directory structure and SKILL.md format. +// Exits with code 1 if any violations are found. + +import fs from "fs" +import path from "path" + +const SKILLS_DIR = path.join(__dirname, "..", "skills") + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SkillContext { + dirName: string + dirPath: string + content: string + frontmatter: Record | null + body: string +} + +interface CheckResult { + errors: string[] + /** When true and errors is non-empty, skip remaining checks for this entry. */ + fatal?: boolean +} + +interface StructureCheck { + description: string + run(dirName: string, dirPath: string): CheckResult +} + +interface ContentCheck { + description: string + run(ctx: SkillContext): CheckResult +} + +// --------------------------------------------------------------------------- +// Structure checks — run on every entry in skills/ before reading SKILL.md. +// Fatal errors abort content checks for that entry. +// --------------------------------------------------------------------------- + +const STRUCTURE_CHECKS: StructureCheck[] = [ + { + description: "Entry must be a directory (no loose files in skills/)", + run(dirName, dirPath) { + if (!fs.statSync(dirPath).isDirectory()) { + return { errors: [`Loose file in skills/: ${dirName} (expected only directories)`], fatal: true } + } + return { errors: [] } + }, + }, + { + description: "Skill directory must contain SKILL.md", + run(dirName, dirPath) { + if (!fs.existsSync(path.join(dirPath, "SKILL.md"))) { + return { errors: [`Missing SKILL.md in skills/${dirName}/`], fatal: true } + } + return { errors: [] } + }, + }, + { + description: "Skills must be exactly one level deep (no nested category directories)", + run(dirName, dirPath) { + const errors: string[] = [] + for (const sub of fs.readdirSync(dirPath)) { + const subPath = path.join(dirPath, sub) + if (fs.statSync(subPath).isDirectory() && fs.existsSync(path.join(subPath, "SKILL.md"))) { + errors.push( + `Nested skill detected: skills/${dirName}/${sub}/SKILL.md — skill directories must be exactly one level deep under skills/` + ) + } + } + return { errors } + }, + }, +] + +// --------------------------------------------------------------------------- +// Content checks — run only on entries that have SKILL.md. +// Fatal errors abort remaining content checks for that entry. +// --------------------------------------------------------------------------- + +const CONTENT_CHECKS: ContentCheck[] = [ + { + description: "SKILL.md must have a valid YAML frontmatter block (--- ... ---)", + run({ dirName, frontmatter }) { + if (!frontmatter) { + return { + errors: [`skills/${dirName}/SKILL.md: missing or malformed YAML frontmatter (expected --- ... --- block at top)`], + fatal: true, + } + } + return { errors: [] } + }, + }, + { + description: 'Frontmatter "name" must be present and match the directory name', + run({ dirName, frontmatter }) { + if (!frontmatter) return { errors: [] } + if (!frontmatter.name) { + return { errors: [`skills/${dirName}/SKILL.md: missing "name" field in frontmatter`] } + } + if (frontmatter.name !== dirName) { + return { + errors: [ + `skills/${dirName}/SKILL.md: "name" value ("${frontmatter.name}") does not match directory name ("${dirName}")`, + ], + } + } + return { errors: [] } + }, + }, + { + description: 'Frontmatter "description" must be present and non-empty', + run({ dirName, frontmatter }) { + if (!frontmatter) return { errors: [] } + if (!frontmatter.description?.trim()) { + return { errors: [`skills/${dirName}/SKILL.md: missing or empty "description" field in frontmatter`] } + } + return { errors: [] } + }, + }, + { + description: "SKILL.md must have a non-empty body (instructions after the frontmatter block)", + run({ dirName, body }) { + if (!body.trim()) { + return { errors: [`skills/${dirName}/SKILL.md: body (instructions after frontmatter) is empty`] } + } + return { errors: [] } + }, + }, +] + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseFrontmatter(content: string): Record | null { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return null + const result: Record = {} + for (const line of match[1].split(/\r?\n/)) { + const colonIdx = line.indexOf(":") + if (colonIdx === -1) continue + result[line.slice(0, colonIdx).trim()] = line.slice(colonIdx + 1).trim() + } + return result +} + +function getFrontmatterEnd(content: string): number { + const match = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/) + return match ? match[0].length : -1 +} + +// --------------------------------------------------------------------------- +// Main validation loop +// --------------------------------------------------------------------------- + +function validateSkill(dirName: string, dirPath: string): string[] { + const errors: string[] = [] + + for (const check of STRUCTURE_CHECKS) { + const { errors: checkErrors, fatal } = check.run(dirName, dirPath) + errors.push(...checkErrors) + if (fatal && checkErrors.length > 0) return errors + } + + const content = fs.readFileSync(path.join(dirPath, "SKILL.md"), "utf8") + const frontmatter = parseFrontmatter(content) + const frontmatterEnd = getFrontmatterEnd(content) + const body = frontmatterEnd !== -1 ? content.slice(frontmatterEnd) : "" + const ctx: SkillContext = { dirName, dirPath, content, frontmatter, body } + + for (const check of CONTENT_CHECKS) { + const { errors: checkErrors, fatal } = check.run(ctx) + errors.push(...checkErrors) + if (fatal && checkErrors.length > 0) return errors + } + + return errors +} + +function main(): void { + const allErrors: string[] = [] + let checked = 0 + + for (const entry of fs.readdirSync(SKILLS_DIR)) { + const entryErrors = validateSkill(entry, path.join(SKILLS_DIR, entry)) + allErrors.push(...entryErrors) + if (entryErrors.length === 0) checked++ + } + + if (allErrors.length > 0) { + console.error(`\nSkill validation failed with ${allErrors.length} error(s):\n`) + for (const err of allErrors) { + console.error(` ✗ ${err}`) + } + console.error("") + process.exit(1) + } else { + console.log(`Skill validation passed: ${checked} skill(s) checked.`) + } +} + +main()