diff --git a/.github/workflows/release-skills.yml b/.github/workflows/release-skills.yml new file mode 100644 index 0000000..da82486 --- /dev/null +++ b/.github/workflows/release-skills.yml @@ -0,0 +1,70 @@ +name: release-skills + +on: + push: + branches: [main] + paths: ['skills/**'] + workflow_dispatch: + +jobs: + release: + runs-on: ubuntu-latest + outputs: + skipped: ${{ steps.changelog.outputs.skipped }} + version: ${{ steps.changelog.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.IDEE_GH_TOKEN }} + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + + - run: npm ci + + - name: Conventional Changelog Action + id: changelog + uses: TriPSs/conventional-changelog-action@v5 + with: + git-user-name: svc-idee-bot + git-user-email: svc_idee_bot@salesforce.com + github-token: ${{ secrets.IDEE_GH_TOKEN }} + tag-prefix: "" + release-count: "0" + skip-on-empty: ${{ github.event_name == 'push' }} + version-file: "package.json" + output-file: "CHANGELOG.md" + + - name: Create Github Release + id: release + uses: ncipollo/release-action@v2 + if: ${{ steps.changelog.outputs.skipped == 'false' }} + with: + name: "${{ steps.changelog.outputs.version }}" + tag: "${{ steps.changelog.outputs.version }}" + commit: ${{ github.sha }} + body: | + ## Changes in @salesforce/afv-skills + + ${{ steps.changelog.outputs.clean_changelog }} + token: ${{ secrets.IDEE_GH_TOKEN }} + skipIfReleaseExists: true + + - name: Publish to npm + if: ${{ steps.changelog.outputs.skipped == 'false' && steps.release.outputs.id != '' }} + run: | + echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc + npm publish --access public + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Notify cline-fork + if: ${{ steps.changelog.outputs.skipped == 'false' }} + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.IDEE_GH_TOKEN }} + repository: forcedotcom/cline-fork + event-type: skills-released + client-payload: '{"version": "${{ steps.changelog.outputs.version }}"}' diff --git a/.github/workflows/validate-pr.yml b/.github/workflows/validate-pr.yml new file mode 100644 index 0000000..22b478c --- /dev/null +++ b/.github/workflows/validate-pr.yml @@ -0,0 +1,10 @@ +name: pr-validation + +on: + pull_request: + types: [opened, reopened, edited] + branches: [main] + +jobs: + pr-validation: + uses: salesforcecli/github-workflows/.github/workflows/validatePR.yml@main diff --git a/.github/workflows/validate-skills.yml b/.github/workflows/validate-skills.yml new file mode 100644 index 0000000..c6a66cc --- /dev/null +++ b/.github/workflows/validate-skills.yml @@ -0,0 +1,16 @@ +name: Validate Skills + +on: + pull_request: + paths: ['skills/**'] + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + - run: npm ci + - run: node scripts/validate-skills.js diff --git a/package.json b/package.json index d0e11ad..10c91ff 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,14 @@ { - "name": "afv-library", - "description": "AI prompts and rules library for Agentforce Vibes development", - "private": true, + "name": "@salesforce/afv-skills", + "version": "1.0.0", + "description": "Salesforce skills for Agentforce Vibes", + "license": "Apache-2.0", + "files": [ + "skills/*/" + ], "devDependencies": { + "@commitlint/cli": "^19.0.0", + "@commitlint/config-conventional": "^19.0.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 new file mode 100644 index 0000000..2f6ea89 --- /dev/null +++ b/scripts/validate-skills.js @@ -0,0 +1,104 @@ +#!/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.`) +}