#!/usr/bin/env node /** * Verify a synced skills tree the way this port consumes it. * * node dsh/sync/verify-skills.mjs * * `install.sh` runs this between `sync-from-upstream.mjs` and the copy into * `$DSH_HOME/skills`: a tree that fails here is never installed. * * Checks, per `/SKILL.md`: * * 1. YAML frontmatter parses and carries non-empty scalar `name` and * `description` — the fields the DSH skill catalog reads. The parser is * the scalar one vendored from LayneChai's `parseFrontmatter` (MIT); no * provider is imported, because on the DeepSeek Harness the registry * belongs to the built-in `skill-filesystem`, not to this repository. * 2. The body carries no leftover upstream `superpowers:` namespace * prefix — the port strips it during sync, and the DSH registry * addresses skills by bare name. * 3. Every relative reference in the body resolves inside ``: * markdown links (`[x](..//y.md)`) and path-like inline code * (`references/y.md`, `..//references/y.md`, `scripts/y`). A * sibling skill's `scripts/` invocation only needs that skill directory * to exist: the reduced vendor tree under `dsh/tests/fixtures/` keeps * the upstream command without shipping the executable, and a mistyped * skill name still fails. Names that are illustrative examples rather * than real files (`scripts/tool.sh`, …) are skipped, as in the * vendored reference implementation. * * The prefix check is scoped to SKILL.md bodies on purpose: reference files * such as `using-superpowers/references/dsh-tools.md` quote `superpowers:` in * prose, and that is legal — the mapping table names the upstream namespace * on purpose (T1.5). Fenced code blocks are treated as examples, not prose, * exactly as the vendored reference implementation does. * * Output: one `ok: ` line per skill directory and a final * `verified N skills`. The first problem stops the run: `/SKILL.md: * ` on stderr, exit 1. * * @module dsh/sync/verify-skills */ import { readdir, readFile, stat } from 'node:fs/promises' import { join, resolve } from 'node:path' const USAGE = 'usage: node dsh/sync/verify-skills.mjs ' const MARKDOWN_LINK = /\[[^\]]*\]\(([^)\s]+)\)/g const INLINE_CODE = /`([^`\n]+)`/g /** Trailing punctuation a path picks up from the surrounding sentence. */ const TRAILING_PUNCTUATION = /[.,;:)\]]+$/ /** * Names that appear only as illustrative examples in prose, never as real * files — the same list the vendored reference implementation keeps. */ const ILLUSTRATIVE = new Set(['scripts/tool.sh', 'scripts/tool.js', 'scripts/helper.py', 'scripts/helper.sh']) /** * Parse a scalar-only YAML frontmatter block, same rules as the vendored * `parseFrontmatter`: the file starts with `---`, the block ends at the first * line that starts with `---`, and each `key: value` line becomes a string * (matching quotes stripped). Anything else is left for a real YAML parser. * * @returns {{metadata: Record, body: string} | null} */ function parseFrontmatter(text) { if (!text.startsWith('---')) return null const end = text.indexOf('\n---', 3) if (end === -1) return null const block = text.slice(3, end) const body = text.slice(end + 4).replace(/^\n+/, '') const metadata = {} for (const line of block.split('\n')) { const match = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(line.trim()) if (!match) continue let value = match[2].trim() if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1) } metadata[match[1]] = value } return { metadata, body } } /** Map every line inside a ``` / ~~~ fence to an empty line: examples are not prose. */ function blankFences(text) { let fenced = false return text .split('\n') .map((line) => { if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced return '' } return fenced ? '' : line }) .join('\n') } /** True when a markdown link target names no in-tree file to resolve. */ function isExternal(target) { return target.startsWith('#') || target.startsWith('/') || /^[a-z][a-z0-9+.-]*:/i.test(target) } async function statOrNull(path) { return stat(path).catch(() => null) } async function exists(path) { return Boolean(await statOrNull(path)) } async function isDirectory(path) { return Boolean((await statOrNull(path))?.isDirectory()) } /** * Classify one whitespace-separated token of an inline code span. * * `file` — a path that must resolve; `sibling-script` — a `..//scripts/` * invocation, where only the skill directory has to exist (see the header). * * @returns {{kind: 'file' | 'sibling-script', path: string} | null} */ function inlineReference(token) { if (/^(?:references|scripts)\//.test(token)) return { kind: 'file', path: token } if (!token.startsWith('../')) return null const parts = token.split('/') const scripts = parts.indexOf('scripts') if (scripts > 0) return { kind: 'sibling-script', path: parts.slice(0, scripts).join('/') } return { kind: 'file', path: token } } function fail(skill, reason) { console.error(`${skill}/SKILL.md: ${reason}`) process.exit(1) } /** Verify one `//SKILL.md`; exits the process on the first problem. */ async function verifySkill(skillsRoot, skill) { const skillDir = join(skillsRoot, skill) const skillFile = join(skillDir, 'SKILL.md') let text try { text = await readFile(skillFile, 'utf8') } catch (error) { fail(skill, `cannot read SKILL.md (${error?.code ?? error})`) } const parsed = parseFrontmatter(text) if (parsed === null) fail(skill, 'missing YAML frontmatter') if (!parsed.metadata.name) fail(skill, 'empty name in frontmatter') if (!parsed.metadata.description) fail(skill, 'empty description in frontmatter') // The body is a suffix of the file, so the number of newlines before it is // the line offset: report file lines, not body lines, to the operator. const lineOffset = (text.slice(0, text.length - parsed.body.length).match(/\n/g) ?? []).length const lines = blankFences(parsed.body).split('\n') for (const [index, line] of lines.entries()) { const lineNumber = lineOffset + index + 1 if (line.includes('superpowers:')) { fail(skill, `leftover superpowers: namespace prefix in body (line ${lineNumber})`) } for (const match of line.matchAll(MARKDOWN_LINK)) { const target = match[1] if (isExternal(target)) continue if (!(await exists(resolve(skillDir, target.replace(/[?#].*$/, ''))))) { fail(skill, `dangling relative reference: ${target} (line ${lineNumber})`) } } for (const code of line.matchAll(INLINE_CODE)) { for (const raw of code[1].split(/\s+/)) { const token = raw.replace(TRAILING_PUNCTUATION, '') if (token === '' || ILLUSTRATIVE.has(token)) continue const reference = inlineReference(token) if (reference === null) continue const target = resolve(skillDir, reference.path) const resolved = reference.kind === 'sibling-script' ? await isDirectory(target) : await exists(target) if (!resolved) fail(skill, `dangling relative reference: ${token} (line ${lineNumber})`) } } } } async function main() { if (process.argv.length > 3) { console.error(USAGE) process.exit(2) } const arg = process.argv[2] if (!arg) { console.error(USAGE) process.exit(2) } const skillsRoot = resolve(arg) if (!(await isDirectory(skillsRoot))) { console.error(`${skillsRoot}: not a skills directory`) process.exit(1) } const skills = (await readdir(skillsRoot, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort() for (const skill of skills) await verifySkill(skillsRoot, skill) for (const skill of skills) console.log(`ok: ${skill}`) console.log(`verified ${skills.length} skills`) } main().catch((error) => { console.error(`\n[FAIL] unexpected error: ${error?.stack ?? error}\n`) process.exit(1) })