diff --git a/.github/scripts/lint-skill-entry.mjs b/.github/scripts/lint-skill-entry.mjs new file mode 100644 index 00000000..8de11ba1 --- /dev/null +++ b/.github/scripts/lint-skill-entry.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +// +// Structural validator for skill contributions. +// +// Reuses the installer's own parser (bin/metamask-skills.mjs collectSkills / +// parseFrontmatter) so that it validates exactly what ships, rather than a +// parallel model. Errors block; warnings advise. Exits non-zero on any error. +// +// Run against the repo: node .github/scripts/lint-skill-entry.mjs +// Run against another tree: SKILLS_LINT_ROOT=/path node .github/scripts/lint-skill-entry.mjs + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { collectSkills, parseFrontmatter } from '../../bin/metamask-skills.mjs'; +import { + ALLOWED_SIBLING_DIRS, + DESCRIPTION_MAX, + INSTALLED_PREFIX, + KNOWN_FRONTMATTER, + KNOWN_REPOS, + MATURITY_VALUES, + NAME_PATTERN, + SCOPE_VALUES, + RECOMMENDED_SECTIONS, +} from '../../tools/skill-schema.mjs'; + +const ROOT = process.env.SKILLS_LINT_ROOT + ? path.resolve(process.env.SKILLS_LINT_ROOT) + : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +const allowedSiblings = new Set(ALLOWED_SIBLING_DIRS); +const TRUTHY = new Set(['1', 'true', 'yes', 'on']); +const FALSY = new Set(['0', 'false', 'no', 'off']); + +export function lintSkill(skill) { + const errors = []; + const warnings = []; + const dirName = skill.id.slice(skill.domain.length + 1); + + let raw; + try { + raw = parseFrontmatter(readFileSync(path.join(skill.path, 'skill.md'), 'utf8')); + } catch (error) { + return { errors: [`could not read skill.md: ${error.message}`], warnings }; + } + + if (!raw.name) { + errors.push('missing required `name` in frontmatter'); + } else { + if (raw.name !== dirName) { + errors.push(`\`name\` "${raw.name}" must match the directory "${dirName}"`); + } + if (!NAME_PATTERN.test(raw.name)) { + errors.push(`\`name\` "${raw.name}" must be kebab-case`); + } + if (raw.name.startsWith(INSTALLED_PREFIX)) { + errors.push(`source \`name\` must not carry the \`${INSTALLED_PREFIX}\` prefix; the installer adds it`); + } + } + + if (!raw.description) { + errors.push('missing required `description` in frontmatter'); + } else if (raw.description.length > DESCRIPTION_MAX) { + errors.push(`\`description\` is ${raw.description.length} chars, over the ${DESCRIPTION_MAX}-char budget`); + } + + if (raw.maturity && !MATURITY_VALUES.includes(raw.maturity)) { + errors.push(`\`maturity\` "${raw.maturity}" must be one of: ${MATURITY_VALUES.join(', ')}`); + } + + // `scope` and `mandatory` change installer behaviour, and a typo in either is a silent + // no-op today: the key is accepted, no enum runs, and the skill installs in a way the + // author did not intend. `scope: users` falls back to project scope; `mandatory: ture` + // is falsy. Warnings rather than errors — the blocking surface stays small. + if (raw.scope !== undefined && !SCOPE_VALUES.includes(raw.scope)) { + warnings.push(`\`scope\` "${raw.scope}" is not one of: ${SCOPE_VALUES.join(', ')} (installs as project scope)`); + } + if (raw.mandatory !== undefined && !TRUTHY.has(String(raw.mandatory).toLowerCase()) && !FALSY.has(String(raw.mandatory).toLowerCase())) { + warnings.push(`\`mandatory\` "${raw.mandatory}" is neither truthy nor falsy (treated as false)`); + } + + // On-demand-only contract: a source skill must not force persistent loading. + if (raw.alwaysApply !== undefined && TRUTHY.has(String(raw.alwaysApply).toLowerCase())) { + errors.push('skills are on-demand only; remove `alwaysApply: true` (always-on guidance belongs in AGENTS.md)'); + } + + // Sibling directories: bundle dirs and repos/ only. knowledge/ is rejected. + let entries = []; + try { + entries = readdirSync(skill.path, { withFileTypes: true }); + } catch { + // skill dir vanished mid-run; nothing to check + } + for (const entry of entries) { + if (entry.isDirectory() && !allowedSiblings.has(entry.name)) { + errors.push(`unexpected directory "${entry.name}/" beside skill.md (allowed: ${[...allowedSiblings].join(', ')}); domain knowledge belongs in references/`); + } + } + + for (const repo of skill.repos) { + if (!KNOWN_REPOS.includes(repo)) { + warnings.push(`repos/${repo}.md targets an unknown consumer (known: ${KNOWN_REPOS.join(', ')})`); + } + } + + for (const key of Object.keys(raw)) { + if (!KNOWN_FRONTMATTER.includes(key) && key !== 'alwaysApply') { + warnings.push(`unknown frontmatter key "${key}"; operators silently ignore unrecognised keys (typo?)`); + } + } + + for (const section of RECOMMENDED_SECTIONS) { + // A trailing `\b` let `## When To Use Cases` satisfy `When To Use` — a different + // section. Anchoring to end-of-line fixes that but rejects `## Workflows` and + // `## Workflow (interactive)`, both of which are the section, and both of which exist + // in this corpus. So: optional plural, optional parenthetical qualifier, nothing else. + if (!new RegExp(`^#{1,4}\\s+${section}s?(?:\\s*\\([^)]*\\))?\\s*$`, 'imu').test(skill.body)) { + warnings.push(`missing recommended section "## ${section}"`); + } + } + + return { errors, warnings }; +} + +// Restrict to skills touched by the given file paths (the CI gate passes the +// PR's changed files, so pre-existing drift in untouched skills never blocks an +// unrelated change). With no paths, every skill is linted (a full audit). +function skillsForPaths(skills, paths) { + const resolved = paths.map((file) => path.resolve(ROOT, file)); + return skills.filter((skill) => + resolved.some((file) => file === skill.path || file.startsWith(`${skill.path}${path.sep}`)), + ); +} + +// A changed path under domains/ must live at domains//skills//… and that +// skill root must have a readable skill.md. +// +// This has to run BEFORE the collectSkills filter, not inside the per-skill loop. +// collectSkills only returns directories that already match the expected layout and parse, +// so anything malformed is invisible to it — a misplaced file, a SKILL.md casing error, or +// a skill.md deleted in the same PR all produced "0 skill(s) checked, 0 error(s)" and a +// green run. The shape has to be checked from the path side, where the malformed cases +// actually exist. +export const SKILL_PATH = /^domains\/([^/]+)\/skills\/([^/]+)\/(?:[^/]+\/)*[^/]+$/u; + +export function validatePathShape(file, root = ROOT) { + const normalized = file.split(path.sep).join('/'); + if (!normalized.startsWith('domains/')) { + return null; + } + const match = SKILL_PATH.exec(normalized); + if (!match) { + return `path "${normalized}" is not under domains//skills//`; + } + const [, domain, name] = match; + const skillRoot = path.join(root, 'domains', domain, 'skills', name); + try { + statSync(path.join(skillRoot, 'skill.md')); + } catch { + return `skill root "domains/${domain}/skills/${name}/" has no readable skill.md`; + } + return null; +} + +function main() { + const paths = process.argv.slice(2).filter((arg) => !arg.startsWith('-')); + let errorCount = 0; + let warningCount = 0; + + for (const file of paths) { + const problem = validatePathShape(file); + if (problem) { + console.log(`\nerror: ${problem}`); + errorCount += 1; + } + } + + // '*' rather than undefined: the linter never reads repoApplicable, but relying on that + // would break silently if `repo` became required. + const all = collectSkills([ROOT], '*'); + const skills = paths.length > 0 ? skillsForPaths(all, paths) : all; + + for (const skill of skills) { + const { errors, warnings } = lintSkill(skill); + if (errors.length > 0 || warnings.length > 0) { + console.log(`\n${skill.id}`); + for (const message of errors) { + console.log(` error: ${message}`); + } + for (const message of warnings) { + console.log(` warning: ${message}`); + } + } + errorCount += errors.length; + warningCount += warnings.length; + } + + console.log(`\n${skills.length} skill(s) checked, ${errorCount} error(s), ${warningCount} warning(s).`); + // Set exitCode rather than process.exit() so buffered stdout flushes when it + // is a pipe (e.g. under CI or execFileSync), instead of being truncated. + process.exitCode = errorCount > 0 ? 1 : 0; +} + +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (invokedDirectly) { + main(); +} diff --git a/.github/workflows/lint-skill-entry.yml b/.github/workflows/lint-skill-entry.yml new file mode 100644 index 00000000..b774aa7a --- /dev/null +++ b/.github/workflows/lint-skill-entry.yml @@ -0,0 +1,66 @@ +name: Lint skill entries + +on: + pull_request: + paths: + - 'domains/**' + - 'tools/**' + - '.github/scripts/lint-skill-entry.mjs' + - '.github/workflows/lint-skill-entry.yml' + +permissions: + contents: read + +jobs: + lint-skills: + name: Lint skill entries + runs-on: ubuntu-latest + steps: + # The repo's own composite action, as every other workflow here uses. Removes the + # third-party dependency, and with it the unpinned-reference and credential- + # persistence findings that raw actions/checkout raised. + # Pinned to a hash per the blanket policy code scanning enforces. The other + # workflows here still float on @v3; they predate the policy check. + - uses: MetaMask/action-checkout-and-setup@0543b5929698c71e3ccc6ed24eac87825669b5de # v3.5.0 + with: + is-high-risk-environment: false + node-version: 24.x + # Full history: the changed-file diff needs the base commit. + fetch-depth: 0 + + # Filenames come from PR contents, so they are untrusted input. They are read + # NUL-delimited, passed through the environment, and split on NUL into an array — + # never interpolated into a command line. A path containing a space, a quote, or a + # shell metacharacter is therefore just a filename. + - name: Collect changed skill files + id: changed + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + git diff -z --name-only --diff-filter=d "$BASE_SHA" "$HEAD_SHA" -- 'domains/**' \ + > changed-skill-files.bin + if [[ -s changed-skill-files.bin ]]; then + echo "any_changed=true" >> "$GITHUB_OUTPUT" + else + echo "any_changed=false" >> "$GITHUB_OUTPUT" + fi + + # A schema or installer change re-validates the whole catalogue. Without this the + # workflow triggers on tools/**, finds no changed skill files, skips the lint step, + # and reports green — so a tightened rule is never applied to what already exists. + if git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- tools/ .github/scripts/ | grep -q .; then + echo "full_audit=true" >> "$GITHUB_OUTPUT" + else + echo "full_audit=false" >> "$GITHUB_OUTPUT" + fi + + - name: Audit every skill (schema or installer changed) + if: steps.changed.outputs.full_audit == 'true' + run: node .github/scripts/lint-skill-entry.mjs + + - name: Lint changed skills + if: steps.changed.outputs.full_audit != 'true' && steps.changed.outputs.any_changed == 'true' + run: | + mapfile -d '' -t files < changed-skill-files.bin + node .github/scripts/lint-skill-entry.mjs "${files[@]}" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9194bd9a..d2616a98 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,47 @@ A well-crafted skill should: - **Declare maturity** — Use `experimental`, `stable`, or `deprecated` in frontmatter - **Be well-documented** — Clear instructions, examples, and expected outcomes +### Is it a skill, or does it belong in an enforcement layer? + +Skills shape generation. Linters, fitness functions, and hooks enforce. The two do +different work, and any pattern load-bearing enough to encode probably wants both. + +| Layer | Mechanism | Can an agent bypass it? | Timing | +|-------|-----------|-------------------------|--------| +| Skills (`domains//skills//skill.md`) | Generation-time guidance | Yes — suggestive only | Before output | +| AI rules (`AGENTS.md`, `.cursor/rules/*.mdc`, `CLAUDE.md`) | Context injection | Yes — suggestive only | Before output | +| Hooks (Claude Code `PreToolUse`, Cursor hooks) | Runtime interception | No | At tool call | +| Linters, fitness functions, CI | Validation | No | After output | + +Three ways a proposed skill fails this test: + +- **A skill that substitutes for enforcement is unsafe.** An agent can ignore any context + it is given, so anything that must not be bypassed belongs in a hook or a lint rule. +- **A skill that restates what a deterministic check already verifies is wasteful.** It + spends context on every invocation to duplicate ground truth that CI produces for free. +- **A skill that teaches the upstream pattern, so the enforcement layer rarely has to fire, + is the right shape.** An existing lint rule is evidence the pattern matters enough to + encode at both layers — name the layer the skill pairs with. + +The question to answer in review is not *"is this redundant with the linter?"* but *"is this +doing generation-time work the linter cannot?"* + +### Does it earn its context budget? + +Frontmatter for every installed skill is loaded at agent startup, as fixed overhead that +grows linearly with the catalogue. A skill that is never selected still costs its +`description` on every run. + +- Not a duplicate of a skill that already exists — check `metamask-skills list` first. +- Actionable rather than aspirational: steps an agent can follow, not principles to admire. +- Scoped so a reader can tell when it applies — neither one repo's quirk nor "good code". +- `description` within the ceiling in [`tools/skill-schema.mjs`](tools/skill-schema.mjs). It is + the lowest limit across operators, so a description that passes is accepted by all of them. + +`yarn audit:skills` checks the deterministic properties — directory layout, name pattern, +frontmatter keys, maturity values, description length — so review time goes to the two +questions above, which no check can answer. + ## How to Contribute ### Adding a New Skill diff --git a/README.md b/README.md index c0954790..ab639f72 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,11 @@ Extra metadata blocks (e.g. OpenClaw-style `metadata:` with emoji and homepage) are preserved through install — only `name`, `description`, `maturity`, `mandatory`, and `scope` are read by the CLI. +The 1,536-character ceiling is a repo budget rather than an operator limit — the +description is always-on context for every installed skill, so it is capped +deliberately. It is enforced by `yarn audit:skills` from +[`tools/skill-schema.mjs`](tools/skill-schema.mjs), which is the source of truth. + ### Overlay frontmatter ```yaml diff --git a/package.json b/package.json index d8ae935b..d2b992bf 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "test": "node --test test/*.test.mjs", "pack:dry-run": "yarn pack --dry-run", "lint": "yarn lint:changelog", - "lint:changelog": "auto-changelog validate --formatter oxfmt" + "lint:changelog": "auto-changelog validate --formatter oxfmt", + "audit:skills": "node .github/scripts/lint-skill-entry.mjs" }, "publishConfig": { "access": "public", diff --git a/test/lint-skill-entry.test.mjs b/test/lint-skill-entry.test.mjs new file mode 100644 index 00000000..744096ce --- /dev/null +++ b/test/lint-skill-entry.test.mjs @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, test } from 'node:test'; +import { + BUNDLE_DIRS, + DESCRIPTION_MAX, + KNOWN_FRONTMATTER, + KNOWN_KNOWLEDGE_FRONTMATTER, +} from '../tools/skill-schema.mjs'; + +const LINTER = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '.github', + 'scripts', + 'lint-skill-entry.mjs', +); + +const roots = []; + +function makeRoot() { + const root = mkdtempSync(path.join(os.tmpdir(), 'skill-lint-')); + roots.push(root); + return root; +} + +afterEach(() => { + while (roots.length > 0) { + rmSync(roots.pop(), { recursive: true, force: true }); + } +}); + +function writeSkill(root, domain, name, frontmatter, body) { + const dir = path.join(root, 'domains', domain, 'skills', name); + mkdirSync(dir, { recursive: true }); + const defaultBody = '## When To Use\n\n- always\n\n## Workflow\n\n1. do the thing\n'; + writeFileSync(path.join(dir, 'skill.md'), `---\n${frontmatter}\n---\n\n${body ?? defaultBody}`); + return dir; +} + +function lint(root, ...paths) { + const result = spawnSync(process.execPath, [LINTER, ...paths], { + env: { ...process.env, SKILLS_LINT_ROOT: root }, + encoding: 'utf8', + }); + return { code: result.status, output: `${result.stdout}${result.stderr}` }; +} + +describe('lint-skill-entry', () => { + test('a well-formed skill passes', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: Write unit tests'); + assert.equal(lint(root).code, 0); + }); + + test('missing name fails', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'unit-testing', 'description: x'); + const { code, output } = lint(root); + assert.equal(code, 1); + assert.match(output, /missing required `name`/u); + }); + + test('name not matching the directory fails', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'unit-testing', 'name: wrong-name\ndescription: x'); + const { code, output } = lint(root); + assert.equal(code, 1); + assert.match(output, /must match the directory/u); + }); + + // `workflows/` was the live failure mode this audit surfaced on `main`: 14 files across + // two web3-tools skills, referenced 22 times, never shipped. It is now IN the bundle + // list, so the guarantee to test is the general one — a sibling the installer does not + // copy must fail, whatever it is called. + test('a sibling directory the installer does not ship fails', () => { + const root = makeRoot(); + const dir = writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x'); + mkdirSync(path.join(dir, 'playbooks')); + const { code, output } = lint(root); + assert.equal(code, 1, output); + assert.match(output, /unexpected directory "playbooks\/"/u); + }); + + test('every directory in BUNDLE_DIRS is accepted as a sibling', () => { + const root = makeRoot(); + const dir = writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x'); + for (const bundle of BUNDLE_DIRS) { + mkdirSync(path.join(dir, bundle), { recursive: true }); + } + const { code, output } = lint(root); + assert.equal(code, 0, output); + }); + + test('a knowledge/ sibling directory fails (the conversion guarantee)', () => { + const root = makeRoot(); + const dir = writeSkill(root, 'perps', 'fix-bug', 'name: fix-bug\ndescription: x'); + mkdirSync(path.join(dir, 'knowledge')); + const { code, output } = lint(root); + assert.equal(code, 1); + assert.match(output, /knowledge/u); + }); + + test('an mms- prefix in the source name fails', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'mms-unit', 'name: mms-unit\ndescription: x'); + const { code, output } = lint(root); + assert.equal(code, 1); + assert.match(output, /prefix/u); + }); + + test('a description over the budget fails', () => { + const root = makeRoot(); + // Derived from the constant: a hardcoded length silently stops testing the boundary + // the moment the budget moves. + writeSkill(root, 'testing', 'unit-testing', `name: unit-testing\ndescription: ${'x'.repeat(DESCRIPTION_MAX + 1)}`); + const { code, output } = lint(root); + assert.equal(code, 1); + assert.match(output, /over the \d+-char budget/u); + }); + + test('an invalid maturity value fails', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x\nmaturity: beta'); + const { code, output } = lint(root); + assert.equal(code, 1); + assert.match(output, /maturity/u); + }); + + test('alwaysApply: true fails (on-demand-only contract)', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x\nalwaysApply: true'); + const { code, output } = lint(root); + assert.equal(code, 1); + assert.match(output, /on-demand/u); + }); +}); + +describe('schema tracks the installer', () => { + // The schema and tools/install (Bash) describe the same facts in two languages. The + // comment in skill-schema.mjs asks a human to keep them in sync; these check it. + // + // Drift here is not cosmetic. `workflows/` existed in two web3-tools skills, was + // referenced 17 times from their bodies, and was absent from the installer's bundle + // list — so every installed copy carried 17 dangling links, and nothing reported it. + const INSTALL = readFileSync( + path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'tools', 'install'), + 'utf8', + ); + + test('BUNDLE_DIRS matches the directories tools/install copies', () => { + const m = /for bundle in ([\w\s]+); do/u.exec(INSTALL); + assert.ok(m, 'could not find the bundle loop in tools/install'); + const shipped = m[1].trim().split(/\s+/u); + assert.deepEqual( + [...shipped].sort(), + [...BUNDLE_DIRS].sort(), + 'tools/install ships a different set of directories than BUNDLE_DIRS declares', + ); + }); + + test('the schema declares every frontmatter key tools/install reads', () => { + const read = [...INSTALL.matchAll(/frontmatter_value\s+"\$\w+"\s+"([\w-]+)"/gu)].map((x) => x[1]); + assert.ok(read.length > 0, 'expected frontmatter_value calls in tools/install'); + const known = new Set([...KNOWN_FRONTMATTER, ...KNOWN_KNOWLEDGE_FRONTMATTER]); + assert.deepEqual( + [...new Set(read)].filter((k) => !known.has(k)), + [], + 'tools/install reads a frontmatter key the schema does not declare', + ); + }); +}); + +// The workflow invokes the linter WITH changed-file arguments. Every test above runs the +// no-argument full-audit branch, so the branch CI actually depends on had no coverage — +// which is how malformed paths reached main. These mirror the CI invocation. +// 1,536 is a repo budget, not an operator limit — no observed operator rejects or +// truncates a longer description, and several over 1,024 install and load today. The +// check exists to bound always-on context, so what matters is that the number the docs +// state and the number enforced are the same one. +describe('description budget', () => { + test('the enforced ceiling is the one the docs state', () => { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + for (const doc of ['README.md', 'CONTRIBUTING.md', path.join('.github', 'SKILL_TEMPLATE.md')]) { + const body = readFileSync(path.join(root, doc), 'utf8'); + const stated = [...body.matchAll(/(\d[\d,]*)[- ]?character|≤([\d,]+) chars|within ([\d,]+) characters/gu)] + .flatMap((m) => [m[1], m[2], m[3]]) + .filter(Boolean) + .map((n) => Number(n.replace(/,/gu, ''))) + .filter((n) => n > 100); + for (const n of stated) { + assert.equal(n, DESCRIPTION_MAX, `${doc} states ${n} but the schema enforces ${DESCRIPTION_MAX}`); + } + } + }); +}); + +describe('changed-files mode', () => { + test('a changed skill.md is linted', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x'); + const { code, output } = lint(root, 'domains/testing/skills/unit-testing/skill.md'); + assert.equal(code, 0, output); + assert.match(output, /1 skill\(s\) checked/u); + }); + + test('a changed reference file maps back to its skill root', () => { + const root = makeRoot(); + const dir = writeSkill(root, 'testing', 'unit-testing', 'name: wrong-name\ndescription: x'); + mkdirSync(path.join(dir, 'references'), { recursive: true }); + writeFileSync(path.join(dir, 'references', 'foo.md'), '# foo\n'); + const { code, output } = lint(root, 'domains/testing/skills/unit-testing/references/foo.md'); + assert.equal(code, 1, output); + assert.match(output, /must match the directory/u, 'should lint the owning skill, not just the file'); + }); + + test('a malformed domains path fails', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x'); + const { code, output } = lint(root, 'domains/testing/bar/skill.md'); + assert.equal(code, 1, output); + assert.match(output, /is not under domains\/\/skills\/\//u); + }); + + test('a changed path whose skill.md is missing is reported, not skipped', () => { + const root = makeRoot(); + // Skill-shaped directory, no skill.md — the case that previously printed + // "0 skill(s) checked, 0 error(s)" and exited 0. + mkdirSync(path.join(root, 'domains', 'testing', 'skills', 'ghost', 'references'), { + recursive: true, + }); + writeFileSync(path.join(root, 'domains', 'testing', 'skills', 'ghost', 'references', 'a.md'), 'x'); + const { code, output } = lint(root, 'domains/testing/skills/ghost/references/a.md'); + assert.equal(code, 1, output); + assert.match(output, /has no readable skill\.md/u); + }); + + test('a filename containing spaces survives argv handling', () => { + const root = makeRoot(); + const dir = writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x'); + writeFileSync(path.join(dir, 'references file.md'), '# spaced\n'); + const { code, output } = lint(root, 'domains/testing/skills/unit-testing/references file.md'); + assert.equal(code, 0, output); + assert.match(output, /1 skill\(s\) checked/u, 'the path should resolve as one argument'); + }); + + test('warnings-only input exits 0', () => { + const root = makeRoot(); + writeSkill(root, 'testing', 'unit-testing', 'name: unit-testing\ndescription: x', '# no sections\n'); + const { code, output } = lint(root, 'domains/testing/skills/unit-testing/skill.md'); + assert.equal(code, 0, output); + assert.match(output, /warning:/u); + }); + + test('a description of exactly DESCRIPTION_MAX passes, +1 fails', () => { + const atLimit = makeRoot(); + writeSkill(atLimit, 'testing', 'unit-testing', `name: unit-testing\ndescription: ${'x'.repeat(DESCRIPTION_MAX)}`); + assert.equal(lint(atLimit, 'domains/testing/skills/unit-testing/skill.md').code, 0); + + const overLimit = makeRoot(); + writeSkill(overLimit, 'testing', 'unit-testing', `name: unit-testing\ndescription: ${'x'.repeat(DESCRIPTION_MAX + 1)}`); + const { code, output } = lint(overLimit, 'domains/testing/skills/unit-testing/skill.md'); + assert.equal(code, 1, output); + assert.match(output, /over the \d+-char budget/u); + }); +}); diff --git a/tools/install b/tools/install index e39404aa..af56d281 100755 --- a/tools/install +++ b/tools/install @@ -346,7 +346,7 @@ write_user_codex() { copy_bundle_dirs() { local skill_dir="$1" dest_dir="$2" label="$3" local bundle - for bundle in references scripts assets adapters; do + for bundle in references scripts assets adapters workflows; do if [[ -d "$skill_dir/$bundle" ]]; then action "$label/$bundle/" $DRY_RUN && continue diff --git a/tools/skill-schema.mjs b/tools/skill-schema.mjs new file mode 100644 index 00000000..290b4222 --- /dev/null +++ b/tools/skill-schema.mjs @@ -0,0 +1,51 @@ +// Single source of truth for the skill contribution schema. +// +// Imported by the lint-skill-entry validator so that the documented schema and +// the enforced schema cannot drift apart. The installer's bundle-directory list +// in tools/install (Bash) mirrors BUNDLE_DIRS; keep the two in sync. + +export const REQUIRED_FRONTMATTER = ['name', 'description']; +export const OPTIONAL_FRONTMATTER = ['maturity', 'mandatory', 'scope', 'metadata']; +export const KNOWN_FRONTMATTER = [...REQUIRED_FRONTMATTER, ...OPTIONAL_FRONTMATTER]; + +export const MATURITY_VALUES = ['experimental', 'stable', 'deprecated']; + +// `scope: user` installs to $HOME instead of the target repo; anything else is project scope. +export const SCOPE_VALUES = ['user', 'project']; + +// Directories the installer copies alongside skill.md (see tools/install). +export const BUNDLE_DIRS = ['references', 'scripts', 'assets', 'adapters', 'workflows']; + +// Directories allowed beside skill.md: the bundle dirs plus the repo-overlay +// dir. Anything else is rejected, because the installer does not ship it and any +// reference to it would dangle post-install. `knowledge/` is not listed here +// because it is a per-DOMAIN directory, never a skill sibling; the installer +// delivers it via copy_domain_knowledge. +export const ALLOWED_SIBLING_DIRS = [...BUNDLE_DIRS, 'repos']; + +export const KNOWN_REPOS = ['metamask-extension', 'metamask-mobile', 'core']; + +// The description is always loaded into the operator's discovery surface, so it is the +// per-skill always-on cost, and the only part of a skill that carries its own trigger +// cues — cutting it makes the skill less likely to be selected when it is relevant. +// +// This is a REPO BUDGET, not an operator limit. No operator observed here rejects or +// truncates a longer one: `tools/install` emits the value verbatim, and descriptions +// well over 1024 characters install and load in Claude Code today. Treat a lower number +// as a deliberate budget decision, and cite the operator and version before claiming any +// figure is externally imposed. +export const DESCRIPTION_MAX = 1536; + +export const RECOMMENDED_SECTIONS = ['When To Use', 'Workflow']; + +// kebab-case, matching the name regex Claude Code and OpenCode require. +export const NAME_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/u; + +// The installer prepends this to generated output names; source names must not +// carry it. +export const INSTALLED_PREFIX = 'mms-'; + +// Frontmatter permitted on a domain knowledge file. Knowledge files are a distinct +// artifact from skills and take a different set — `domain` instead of `maturity`, +// and none of the installer-behaviour keys. +export const KNOWN_KNOWLEDGE_FRONTMATTER = ['name', 'domain', 'description'];