diff --git a/.changeset/frank-experts-build.md b/.changeset/frank-experts-build.md new file mode 100644 index 0000000..e5cb848 --- /dev/null +++ b/.changeset/frank-experts-build.md @@ -0,0 +1,5 @@ +--- +'@tanstack/intent': patch +--- + +Make `SKILL.md` frontmatter spec-compliant. `name` must now be a spec-legal leaf segment matching its parent directory (lowercase letters, numbers, and hyphens; 64 characters max; no slashes), and the Intent-specific scalars `type`, `library`, `library_version`, and `framework` live under the `metadata` map. `intent validate` now errors on a slash/non-leaf `name`, a `name` with non-spec characters or over 64 characters, non-spec top-level scalar keys, and a non-string `metadata` map. Skill identity is derived from the directory path rather than the frontmatter `name`, and the `generate-skill` and `tree-generator` templates emit the new shape. diff --git a/docs/cli/intent-validate.md b/docs/cli/intent-validate.md index 4b27e13..6e6e8b9 100644 --- a/docs/cli/intent-validate.md +++ b/docs/cli/intent-validate.md @@ -21,7 +21,11 @@ For each discovered `SKILL.md`: - Frontmatter delimiter and structure are valid - YAML frontmatter parses successfully - Required fields exist: `name`, `description` -- `name` matches skill directory path under the target root +- `name` is a single leaf segment matching the skill's parent directory (no slashes); the namespace is carried by the directory path +- `name` uses only lowercase letters, numbers, and hyphens +- `name` is at most 64 characters +- Only spec top-level keys are allowed (`name`, `description`, `license`, `compatibility`, `metadata`, `allowed-tools`); Intent-specific scalars (`type`, `library`, `library_version`, `framework`) must live under `metadata` +- `metadata`, when present, is a mapping of string values - `description` length is at most 1024 characters - `type: framework` requires `requires` to be an array - Total file length is at most 500 lines diff --git a/docs/getting-started/quick-start-maintainers.md b/docs/getting-started/quick-start-maintainers.md index f5879f8..d8378a4 100644 --- a/docs/getting-started/quick-start-maintainers.md +++ b/docs/getting-started/quick-start-maintainers.md @@ -65,7 +65,8 @@ npx @tanstack/intent@latest validate This checks: - Valid YAML frontmatter in every SKILL.md - Required fields (`name`, `description`) are present -- Skill names match their directory paths +- Skill `name` is a leaf segment matching its parent directory +- Intent-specific scalars (`type`, `library`, `library_version`, `framework`) live under `metadata`, not at the top level - Description length <= 1024 characters - Line count limits (500 lines max per skill) - Framework skills have a `requires` array diff --git a/package.json b/package.json index dfd070c..e3fb573 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,6 @@ "sherif": "^1.11.1", "tinyglobby": "^0.2.17", "typescript": "6.0.3", - "vitest": "4.1.8", - "yaml": "2.9.0" + "vitest": "4.1.8" } } diff --git a/packages/intent/meta/generate-skill/SKILL.md b/packages/intent/meta/generate-skill/SKILL.md index 6056082..e0c5dd7 100644 --- a/packages/intent/meta/generate-skill/SKILL.md +++ b/packages/intent/meta/generate-skill/SKILL.md @@ -52,7 +52,9 @@ SKILL.md into that package's skills directory (e.g. `packages/client/skills/core/SKILL.md`), not a shared root. 1. **Skill name** — format `library-group/skill-name` (e.g. `tanstack-query/core`, - `tanstack-router/loaders`, `db/core/live-queries`) + `tanstack-router/loaders`, `db/core/live-queries`). This is the skill's + directory path: its **last segment** becomes the frontmatter `name` (a + spec-legal leaf), and the full path is where the `SKILL.md` lives. 2. **Skill description** — what the skill covers and when an agent should load it 3. **Source documentation** — the docs, guides, API references, and/or source files to distill from @@ -170,17 +172,18 @@ domain-discovery — use those directly. ```yaml --- -name: [library]/[skill-name] +name: '[skill-name]' description: > [1–3 sentences. What this skill covers and exactly when an agent should load it. Written for the agent — include the keywords an agent would encounter when it needs this skill. Dense routing key.] -type: core -library: [library] -library_version: "[version this targets]" +metadata: + type: core + library: '[library]' + library_version: '[version this targets]' sources: - - "[Owner/repo]:docs/[path].md" - - "[Owner/repo]:src/[path].ts" + - '[Owner/repo]:docs/[path].md' + - '[Owner/repo]:src/[path].ts' --- ``` @@ -188,14 +191,15 @@ sources: ```yaml --- -name: [library]/[parent]/[skill-name] +name: '[skill-name]' description: > [1–3 sentences. What this sub-topic covers and when to load it.] -type: sub-skill -library: [library] -library_version: "[version]" +metadata: + type: sub-skill + library: '[library]' + library_version: '[version]' sources: - - "[Owner/repo]:docs/[path].md" + - '[Owner/repo]:docs/[path].md' --- ``` @@ -203,29 +207,39 @@ sources: ```yaml --- -name: [library]/[framework] +name: '[framework]' description: > [1–3 sentences. Framework-specific bindings. Name the hooks, components, providers.] -type: framework -library: [library] -framework: [react | vue | solid | svelte | angular] -library_version: "[version]" +metadata: + type: framework + library: '[library]' + framework: '[react | vue | solid | svelte | angular]' + library_version: '[version]' requires: - - [library]/core + - '[library]/core' sources: - - "[Owner/repo]:docs/framework/[framework]/[path].md" + - '[Owner/repo]:docs/framework/[framework]/[path].md' --- ``` ### Frontmatter rules +- `name` is the spec-legal leaf segment — lowercase letters, numbers, and + hyphens only — and matches the skill's parent directory. It carries no + slashes; the namespace lives in the skill's directory path instead. +- Intent-specific scalars (`type`, `library`, `library_version`, `framework`) + live under the `metadata` map. The Agent Skills spec permits only `name`, + `description`, `license`, `compatibility`, `metadata`, and `allowed-tools` + at the top level, so emitting these scalars at the top level fails + validation. - `description` must be written so the agent loads this skill at the right time — not too broad (triggers on everything) and not too narrow (never triggers). Pack with function names, option names, concept keywords. - `sources` uses the format `Owner/repo:relative-path`. Glob patterns are supported (e.g. `TanStack/query:docs/framework/react/guides/*.md`). -- `library_version` is the version of the source library this skill targets. +- `metadata.library_version` is the version of the source library this skill + targets. - `requires` lists skills that must be loaded before this one. --- diff --git a/packages/intent/meta/tree-generator/SKILL.md b/packages/intent/meta/tree-generator/SKILL.md index d948298..95f710f 100644 --- a/packages/intent/meta/tree-generator/SKILL.md +++ b/packages/intent/meta/tree-generator/SKILL.md @@ -287,14 +287,15 @@ framework-agnostic concepts and contains the sub-skill registry. ```yaml --- -name: [lib]-core +name: '[lib]-core' description: > [1–3 sentences. What this library does and the framework-agnostic concepts it provides. Pack with keywords: function names, config options, concepts. This is a routing key, not a human summary.] -type: core -library: [lib] -library_version: "[version this targets]" +metadata: + type: core + library: '[lib]' + library_version: '[version this targets]' --- ``` @@ -332,16 +333,17 @@ One SKILL.md per domain. Follow this structure exactly. ```yaml --- -name: [lib]-core/[domain-slug] +name: '[domain-slug]' description: > [1–3 sentences. What this domain covers AND when to load it. Name specific functions, options, or APIs. Dense routing key.] -type: sub-skill -library: [lib] -library_version: "[version]" +metadata: + type: sub-skill + library: '[lib]' + library_version: '[version]' sources: - - "[repo]:docs/[path].md" - - "[repo]:src/[path].ts" + - '[repo]:docs/[path].md' + - '[repo]:src/[path].ts' --- ``` @@ -452,17 +454,18 @@ framework-specific patterns and mistakes. ```yaml --- -name: react-[lib] +name: 'react-[lib]' description: > [1–3 sentences. React-specific bindings for [library]. Name the hooks, components, and providers. Mention React-specific patterns like SSR hydration if applicable.] -type: framework -library: [lib] -framework: react -library_version: "[version]" +metadata: + type: framework + library: '[lib]' + framework: react + library_version: '[version]' requires: - - [lib]-core + - '[lib]-core' --- ``` @@ -499,18 +502,18 @@ with the framework frontmatter: ```yaml --- -name: react-[lib]/[domain-slug] +name: '[domain-slug]' description: > [React-specific description for this domain.] -type: sub-skill -library: [lib] -framework: react -library_version: "[version]" +metadata: + type: sub-skill + library: '[lib]' + framework: react + library_version: '[version]' requires: - - [lib]-core - - [lib]-core/[domain-slug] + - '[lib]-core' + - '[lib]-core/[domain-slug]' --- - This skill builds on [lib]-core/[domain-slug]. Read the core skill first. ``` @@ -556,19 +559,19 @@ framework hooks and providers). ```yaml --- -name: compositions/[lib-a]-[lib-b] +name: '[lib-a]-[lib-b]' description: > [How lib-a and lib-b wire together. Name the specific integration points: functions, hooks, patterns.] -type: composition -library_version: "[version of primary lib]" +metadata: + type: composition + library_version: '[version of primary lib]' requires: - - [lib-a]-core - - react-[lib-a] - - [lib-b]-core - - react-[lib-b] + - '[lib-a]-core' + - 'react-[lib-a]' + - '[lib-b]-core' + - 'react-[lib-b]' --- - This skill requires familiarity with both [lib-a] and [lib-b]. Read their core and framework skills first. ``` @@ -601,15 +604,16 @@ for these skill types. ```yaml --- -name: react-[lib]/security +name: security description: > Go-live security validation for [library]. Checks [specific concerns]. -type: security -library: [lib] -framework: react -library_version: '[version]' +metadata: + type: security + library: '[lib]' + framework: react + library_version: '[version]' requires: - - react-[lib] + - 'react-[lib]' --- ``` diff --git a/packages/intent/src/commands/validate.ts b/packages/intent/src/commands/validate.ts index abda04b..c6981c0 100644 --- a/packages/intent/src/commands/validate.ts +++ b/packages/intent/src/commands/validate.ts @@ -1,5 +1,5 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs' -import { basename, dirname, join, relative, resolve, sep } from 'node:path' +import { basename, dirname, join, relative, resolve } from 'node:path' import { fail, isCliFailure } from '../cli-error.js' import { printWarnings } from '../cli-support.js' import { resolveProjectContext } from '../core/project-context.js' @@ -22,6 +22,28 @@ export interface ValidateCommandOptions { const agentSkillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ +// The Agent Skills spec allows exactly these six top-level frontmatter keys. +const specTopLevelKeys = new Set([ + 'name', + 'description', + 'license', + 'compatibility', + 'metadata', + 'allowed-tools', +]) + +// Array fields Intent still emits at the top level; their migration to a +// structured surface is tracked separately (#161), so they are not flagged here. +const intentArrayKeys = new Set(['sources', 'requires']) + +function isScalarValue(value: unknown): boolean { + return ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) +} + function buildValidationFailure( errors: Array, warnings: Array, @@ -97,45 +119,14 @@ function isRecord(value: unknown): value is Record { } function collectAgentSkillSpecWarnings({ - filePath, fm, rel, }: { - filePath: string fm: Record rel: string }): Array { const warnings: Array = [] - if (typeof fm.name === 'string') { - if (fm.name.length > 64) { - warnings.push({ - file: rel, - message: `Agent Skills spec warning: name exceeds 64 characters (${fm.name.length} chars)`, - }) - } - - for (const segment of fm.name.split('/')) { - if (!agentSkillNamePattern.test(segment)) { - warnings.push({ - file: rel, - message: - 'Agent Skills spec warning: each name segment should use lowercase letters, numbers, and single hyphens only', - }) - break - } - } - - const parentDir = basename(dirname(filePath)) - if (!fm.name.includes('/') && fm.name !== parentDir) { - warnings.push({ - file: rel, - message: - 'Agent Skills spec warning: name should match the parent directory name', - }) - } - } - if ( fm.license !== undefined && (typeof fm.license !== 'string' || fm.license.trim().length === 0) @@ -165,26 +156,6 @@ function collectAgentSkillSpecWarnings({ } } - if (fm.metadata !== undefined) { - if (!isRecord(fm.metadata)) { - warnings.push({ - file: rel, - message: 'Agent Skills spec warning: metadata should be a mapping', - }) - } else { - const hasNonStringValue = Object.values(fm.metadata).some( - (value) => typeof value !== 'string', - ) - if (hasNonStringValue) { - warnings.push({ - file: rel, - message: - 'Agent Skills spec warning: metadata values should be strings', - }) - } - } - } - if ( fm['allowed-tools'] !== undefined && typeof fm['allowed-tools'] !== 'string' @@ -294,14 +265,59 @@ async function runValidateCommandInternal(dir?: string): Promise { } if (typeof fm.name === 'string') { - const expectedPath = relative(skillsDir, filePath) - .replace(/[/\\]SKILL\.md$/, '') - .split(sep) - .join('/') - if (fm.name !== expectedPath) { + const parentDir = basename(dirname(filePath)) + if (fm.name.length > 64) { + errors.push({ + file: rel, + message: `name exceeds 64 characters (${fm.name.length} chars)`, + }) + } + if (fm.name.includes('/')) { + errors.push({ + file: rel, + message: `name "${fm.name}" must be a single leaf segment matching its parent directory "${parentDir}" — the namespace is carried by the directory path, not the name`, + }) + } else { + if (fm.name !== parentDir) { + errors.push({ + file: rel, + message: `name "${fm.name}" does not match parent directory "${parentDir}"`, + }) + } + if (!agentSkillNamePattern.test(fm.name)) { + errors.push({ + file: rel, + message: `name "${fm.name}" must use only lowercase letters, numbers, and hyphens`, + }) + } + } + } + + for (const [key, value] of Object.entries(fm)) { + if ( + !specTopLevelKeys.has(key) && + !intentArrayKeys.has(key) && + isScalarValue(value) + ) { + errors.push({ + file: rel, + message: `non-spec top-level key "${key}" — move client-specific scalar fields under "metadata"`, + }) + } + } + + if (fm.metadata !== undefined) { + if (!isRecord(fm.metadata)) { + errors.push({ + file: rel, + message: 'metadata must be a mapping', + }) + } else if ( + Object.values(fm.metadata).some((value) => typeof value !== 'string') + ) { errors.push({ file: rel, - message: `name "${fm.name}" does not match directory path "${expectedPath}"`, + message: 'metadata values must be strings', }) } } @@ -324,9 +340,7 @@ async function runValidateCommandInternal(dir?: string): Promise { } warnings.push( - ...collectAgentSkillSpecWarnings({ filePath, fm, rel }).map( - formatWarning, - ), + ...collectAgentSkillSpecWarnings({ fm, rel }).map(formatWarning), ) const lineCount = content.split(/\r?\n/).length diff --git a/packages/intent/src/scanner.ts b/packages/intent/src/scanner.ts index a706393..51baf42 100644 --- a/packages/intent/src/scanner.ts +++ b/packages/intent/src/scanner.ts @@ -264,7 +264,7 @@ function readSkillEntry( : '' return { - name: typeof fm?.name === 'string' ? fm.name : relName, + name: relName, path: skillFile, description: desc, type: readScalarField(fm, 'type'), diff --git a/packages/intent/src/staleness.ts b/packages/intent/src/staleness.ts index 2a0e1a7..e62fb15 100644 --- a/packages/intent/src/staleness.ts +++ b/packages/intent/src/staleness.ts @@ -21,7 +21,6 @@ import type { // --------------------------------------------------------------------------- interface SkillMeta { - name: string relName: string filePath: string libraryVersion?: string @@ -275,7 +274,6 @@ function findMatchingSkill( const skillsByName = new Map() for (const skill of skillMetas) { - skillsByName.set(skill.name, skill) skillsByName.set(skill.relName, skill) } @@ -359,7 +357,7 @@ function buildArtifactSignals({ reasons: ['artifact sources differ from SKILL.md frontmatter sources'], needsReview: true, artifactPath: artifact.artifactPath, - skill: matchingSkill.name, + skill: matchingSkill.relName, }) } @@ -380,7 +378,7 @@ function buildArtifactSignals({ ], needsReview: true, artifactPath: artifact.artifactPath, - skill: matchingSkill.name, + skill: matchingSkill.relName, }) } } @@ -486,7 +484,6 @@ export async function checkStaleness( '', ) return { - name: typeof fm?.name === 'string' ? fm.name : relName, relName, filePath, libraryVersion: readScalarField(fm, 'library_version'), @@ -532,7 +529,7 @@ export async function checkStaleness( } // Source SHA changes (from sync-state) - const storedShas = syncState?.skills?.[skill.name]?.sources_sha ?? {} + const storedShas = syncState?.skills?.[skill.relName]?.sources_sha ?? {} // We only flag if there are stored SHAs but we can't check remote // (actual remote checking requires GitHub API — deferred to agent) if (skill.sources && Object.keys(storedShas).length > 0) { @@ -545,7 +542,7 @@ export async function checkStaleness( } return { - name: skill.name, + name: skill.relName, reasons, needsReview: reasons.length > 0, } diff --git a/packages/intent/tests/cli.test.ts b/packages/intent/tests/cli.test.ts index 58ff47e..8d858fc 100644 --- a/packages/intent/tests/cli.test.ts +++ b/packages/intent/tests/cli.test.ts @@ -1732,7 +1732,7 @@ describe('cli commands', () => { tempDirs.push(root) writeSkillMd(join(root, 'skills', 'core', 'setup'), { - name: 'core/setup', + name: 'setup', description: 'Core setup concepts', }) @@ -1746,7 +1746,153 @@ describe('cli commands', () => { expect(output).not.toContain('Agent Skills spec warning') }) - it('warns but does not fail for Agent Skills spec-incompatible names', async () => { + it('fails when a nested skill name carries a slash instead of a leaf segment', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-slash-')) + tempDirs.push(root) + + writeSkillMd(join(root, 'skills', 'core', 'setup'), { + name: 'core/setup', + description: 'Core setup concepts', + }) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain( + 'name "core/setup" must be a single leaf segment matching its parent directory "setup"', + ) + }) + + it('fails when a non-spec scalar field is emitted at the top level', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-scalar-')) + tempDirs.push(root) + + const skillDir = join(root, 'skills', 'db-core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: db-core', + 'description: Core database concepts', + 'type: core', + 'library: db', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('non-spec top-level key "type"') + expect(output).toContain('non-spec top-level key "library"') + }) + + it('fails when metadata holds a non-string value', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-meta-')) + tempDirs.push(root) + + const skillDir = join(root, 'skills', 'db-core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: db-core', + 'description: Core database concepts', + 'metadata:', + ' library_version:', + ' - 1.0.0', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('metadata values must be strings') + }) + + it('fails when metadata is not a mapping', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-meta-map-')) + tempDirs.push(root) + + const skillDir = join(root, 'skills', 'db-core') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: db-core', + 'description: Core database concepts', + 'metadata: just-a-string', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) + expect(output).toContain('metadata must be a mapping') + }) + + it('does not flag array-valued top-level keys as non-spec scalars', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-array-key-')) + tempDirs.push(root) + + const skillDir = join(root, 'skills', 'react-db') + mkdirSync(skillDir, { recursive: true }) + writeFileSync( + join(skillDir, 'SKILL.md'), + [ + '---', + 'name: react-db', + 'description: React bindings for db', + 'metadata:', + ' type: framework', + 'requires:', + ' - db-core', + 'sources:', + ' - "TanStack/db:docs/react.md"', + '---', + '', + 'Skill content here.', + '', + ].join('\n'), + ) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = logSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(0) + expect(output).toContain('✅ Validated 1 skill files — all passed') + expect(output).not.toContain('non-spec top-level key') + }) + + it('fails for names with non-spec characters (uppercase)', async () => { const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-spec-')) tempDirs.push(root) @@ -1758,12 +1904,34 @@ describe('cli commands', () => { process.chdir(root) const exitCode = await main(['validate']) - const output = logSpy.mock.calls.flat().join('\n') + const output = errorSpy.mock.calls.flat().join('\n') - expect(exitCode).toBe(0) - expect(output).toContain('✅ Validated 1 skill files — all passed') + expect(exitCode).toBe(1) + expect(output).toContain( + 'name "PDF-Processing" must use only lowercase letters, numbers, and hyphens', + ) + }) + + it('fails when name exceeds 64 characters', async () => { + const root = mkdtempSync(join(realTmpdir, 'intent-cli-validate-len-')) + tempDirs.push(root) + + const longName = `a${'-very-long'.repeat(7)}` + expect(longName.length).toBeGreaterThan(64) + + writeSkillMd(join(root, 'skills', longName), { + name: longName, + description: 'A skill with an overly long name', + }) + + process.chdir(root) + + const exitCode = await main(['validate']) + const output = errorSpy.mock.calls.flat().join('\n') + + expect(exitCode).toBe(1) expect(output).toContain( - 'Agent Skills spec warning: each name segment should use lowercase letters, numbers, and single hyphens only', + `name exceeds 64 characters (${longName.length} chars)`, ) }) @@ -2017,7 +2185,7 @@ describe('cli commands', () => { expect(summary).toContain('Skill validation failed.') expect(summary).toContain('Why this failed:') expect(summary).toContain( - 'name "wrong-name" does not match directory path "db-core"', + 'name "wrong-name" does not match parent directory "db-core"', ) } finally { if (previousSummary === undefined) { diff --git a/packages/intent/tests/scanner.test.ts b/packages/intent/tests/scanner.test.ts index 8be85f4..735d950 100644 --- a/packages/intent/tests/scanner.test.ts +++ b/packages/intent/tests/scanner.test.ts @@ -1843,7 +1843,7 @@ describe('scanIntentPackageAtRoot', () => { ]) }) - it('falls back when the hinted path has a different canonical skill name', () => { + it('derives skill identity from the directory path, not the frontmatter name', () => { const pkgDir = createDir(root, 'node_modules', '@tanstack', 'query') writeJson(join(pkgDir, 'package.json'), { name: '@tanstack/query', @@ -1865,7 +1865,15 @@ describe('scanIntentPackageAtRoot', () => { skillNameHint: 'cache', }) - expect(result.package?.skills).toEqual([]) + expect(result.package?.skills).toEqual([ + { + name: 'cache', + path: 'node_modules/@tanstack/query/skills/cache/SKILL.md', + description: 'Cache query skill', + type: undefined, + framework: undefined, + }, + ]) }) it('does not follow hinted skill paths outside the skills directory', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ce3e06..8d95985 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,9 +59,6 @@ importers: vitest: specifier: 4.1.8 version: 4.1.8(@types/node@25.0.9)(happy-dom@20.3.1)(vite@7.3.1(@types/node@25.0.9)(jiti@2.7.0)(yaml@2.9.0)) - yaml: - specifier: 2.9.0 - version: 2.9.0 benchmarks/intent: devDependencies: diff --git a/scripts/validate-skills.ts b/scripts/validate-skills.ts deleted file mode 100644 index 68885c1..0000000 --- a/scripts/validate-skills.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { parse as parseYaml } from 'yaml' - -// ── Types ── - -interface SkillFrontmatter { - name: string - description: string - type?: string - library?: string - framework?: string - library_version?: string - requires?: Array - sources?: Array -} - -interface ValidationError { - file: string - message: string -} - -// ── Constants ── - -const skillsArg = process.argv[2] -const SKILLS_DIR = skillsArg - ? join(process.cwd(), skillsArg) - : join(process.cwd(), 'skills') -const MAX_LINES = 500 - -const PROHIBITED_PATTERNS: Array<{ pattern: RegExp; description: string }> = [ - { - pattern: /(?:npm|yarn|pnpm|bun)\s+(?:install|add|i)\s/i, - description: 'Install instructions', - }, - { - pattern: /(?:curl|wget|fetch)\s+https?:\/\//i, - description: 'Instructions to fetch external URLs at runtime', - }, -] - -const ALLOWED_SHELL_COMMANDS = [ - 'intent list', - 'npm install @tanstack/', - 'npx @tanstack/intent', -] - -// ── Helpers ── - -function findSkillFiles(dir: string): Array { - const files: Array = [] - if (!existsSync(dir)) return files - - for (const entry of readdirSync(dir)) { - const fullPath = join(dir, entry) - const stat = statSync(fullPath) - if (stat.isDirectory()) { - files.push(...findSkillFiles(fullPath)) - } else if (entry === 'SKILL.md') { - files.push(fullPath) - } - } - return files -} - -function extractFrontmatter( - content: string, -): { frontmatter: SkillFrontmatter; body: string } | null { - const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)/) - if (!match?.[1]) return null - - try { - const frontmatter = parseYaml(match[1]) as SkillFrontmatter - return { frontmatter, body: match[2] ?? '' } - } catch { - return null - } -} - -function skillPathFromFile(filePath: string): string { - const rel = relative(SKILLS_DIR, filePath) - return rel - .replace(/[/\\]SKILL\.md$/, '') - .split(sep) - .join('/') -} - -// ── Validators ── - -function validateFrontmatter( - filePath: string, - frontmatter: SkillFrontmatter, -): Array { - const errors: Array = [] - const rel = relative(process.cwd(), filePath) - - if (!frontmatter.name) { - errors.push({ file: rel, message: 'Missing required field: name' }) - } - - if (!frontmatter.description) { - errors.push({ file: rel, message: 'Missing required field: description' }) - } - - if (frontmatter.name) { - const expectedPath = skillPathFromFile(filePath) - if (frontmatter.name !== expectedPath) { - errors.push({ - file: rel, - message: `name "${frontmatter.name}" does not match directory path "${expectedPath}"`, - }) - } - } - - if (frontmatter.type === 'framework' && !frontmatter.requires?.length) { - errors.push({ - file: rel, - message: 'Framework skills must have a "requires" field', - }) - } - - return errors -} - -function validateContent( - filePath: string, - content: string, - body: string, -): Array { - const errors: Array = [] - const rel = relative(process.cwd(), filePath) - - const lineCount = content.split(/\r?\n/).length - if (lineCount > MAX_LINES) { - errors.push({ - file: rel, - message: `Exceeds ${MAX_LINES} line limit (${lineCount} lines)`, - }) - } - - for (const { pattern, description } of PROHIBITED_PATTERNS) { - for (const line of body.split(/\r?\n/)) { - if (pattern.test(line)) { - const isAllowed = ALLOWED_SHELL_COMMANDS.some((cmd) => - line.includes(cmd), - ) - if (!isAllowed) { - errors.push({ - file: rel, - message: `Prohibited content: ${description} — "${line.trim().slice(0, 80)}"`, - }) - break - } - } - } - } - - return errors -} - -// ── Main ── - -function main(): void { - const errors: Array = [] - - console.log(`Validating skills in: ${SKILLS_DIR}`) - - const skillFiles = findSkillFiles(SKILLS_DIR) - - if (skillFiles.length === 0) { - console.log('No SKILL.md files found — nothing to validate') - process.exit(0) - } - - for (const filePath of skillFiles) { - const content = readFileSync(filePath, 'utf-8') - const parsed = extractFrontmatter(content) - - const rel = relative(process.cwd(), filePath) - - if (!parsed) { - errors.push({ file: rel, message: 'Missing or invalid frontmatter' }) - continue - } - - errors.push(...validateFrontmatter(filePath, parsed.frontmatter)) - errors.push(...validateContent(filePath, content, parsed.body)) - } - - if (errors.length > 0) { - console.error(`\n❌ Validation failed with ${errors.length} error(s):\n`) - for (const { file, message } of errors) { - console.error(` ${file}: ${message}`) - } - console.error('') - process.exit(1) - } - - console.log(`✅ Validated ${skillFiles.length} skill files — all passed`) -} - -main()