From b339e3e9613bff07b3d3be8d2789f0ae44bb3d1a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 2 Mar 2026 16:28:44 -0700 Subject: [PATCH 1/8] feat: add playbook-library end-user CLI with list and install commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a separate CLI entry point for library consumers (distinct from the maintainer CLI). Libraries wire it up via a generated shim so coding agents can discover and load skills on demand. - `playbook list` — recursively traverses deps/peerDeps with bin.playbook, prints each package's skills with name, description, and full path - `playbook install` — agent-driven prompt that maps skills to project tasks, writing a block into CLAUDE.md - `playbook setup --shim` — generates bin/playbook.js shim in the library package and prints instructions to wire up the bin entry - 9 tests covering skill discovery, recursive traversal, cycle detection, peerDeps, missing skills dir, and error cases Co-Authored-By: Claude Sonnet 4.6 --- packages/playbooks/package.json | 8 +- packages/playbooks/src/library-scanner.ts | 151 +++++++++++ packages/playbooks/src/playbook-library.ts | 158 +++++++++++ packages/playbooks/src/setup.ts | 52 +++- .../playbooks/tests/library-scanner.test.ts | 252 ++++++++++++++++++ packages/playbooks/tests/setup.test.ts | 2 +- 6 files changed, 616 insertions(+), 7 deletions(-) create mode 100644 packages/playbooks/src/library-scanner.ts create mode 100644 packages/playbooks/src/playbook-library.ts create mode 100644 packages/playbooks/tests/library-scanner.test.ts diff --git a/packages/playbooks/package.json b/packages/playbooks/package.json index a8e79815..886f5bcd 100644 --- a/packages/playbooks/package.json +++ b/packages/playbooks/package.json @@ -12,10 +12,14 @@ ".": { "import": "./dist/index.mjs", "types": "./dist/index.d.mts" + }, + "./playbook-library": { + "import": "./dist/playbook-library.mjs" } }, "bin": { - "playbook": "dist/cli.mjs" + "playbook": "dist/cli.mjs", + "playbook-library": "dist/playbook-library.mjs" }, "files": [ "dist", @@ -28,7 +32,7 @@ "tsdown": "^0.19.0" }, "scripts": { -"build": "tsdown src/index.ts src/cli.ts src/setup.ts --format esm --dts", +"build": "tsdown src/index.ts src/cli.ts src/setup.ts src/playbook-library.ts src/library-scanner.ts --format esm --dts", "test:lib": "vitest run" } } diff --git a/packages/playbooks/src/library-scanner.ts b/packages/playbooks/src/library-scanner.ts new file mode 100644 index 00000000..b19e2632 --- /dev/null +++ b/packages/playbooks/src/library-scanner.ts @@ -0,0 +1,151 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, join, relative, sep } from 'node:path' +import type { SkillEntry } from './types.js' +import { parseFrontmatter } from './utils.js' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface LibraryPackage { + name: string + version: string + description: string + skills: SkillEntry[] +} + +export interface LibraryScanResult { + packages: LibraryPackage[] + warnings: string[] +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function readPkgJson(dir: string): Record | null { + try { + return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + } catch { + return null + } +} + +function findHomeDir(scriptPath: string): string | null { + let dir = dirname(scriptPath) + while (true) { + if (existsSync(join(dir, 'package.json'))) return dir + const parent = dirname(dir) + if (parent === dir) return null + dir = parent + } +} + +function hasPlaybookBin(pkg: Record): boolean { + const bin = pkg.bin + if (!bin || typeof bin !== 'object') return false + return 'playbook' in (bin as Record) +} + +function getDeps(pkg: Record): string[] { + const seen = new Set() + for (const field of ['dependencies', 'peerDependencies']) { + const d = pkg[field] + if (d && typeof d === 'object') { + for (const name of Object.keys(d as Record)) { + seen.add(name) + } + } + } + return [...seen] +} + +function discoverSkills(skillsDir: string): SkillEntry[] { + const skills: SkillEntry[] = [] + + function walk(dir: string): void { + let entries: ReturnType + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory()) continue + const childDir = join(dir, entry.name) + const skillFile = join(childDir, 'SKILL.md') + if (existsSync(skillFile)) { + const fm = parseFrontmatter(skillFile) + const relName = relative(skillsDir, childDir).split(sep).join('/') + skills.push({ + name: typeof fm?.name === 'string' ? fm.name : relName, + path: skillFile, + description: + typeof fm?.description === 'string' + ? fm.description.replace(/\s+/g, ' ').trim() + : '', + type: typeof fm?.type === 'string' ? fm.type : undefined, + framework: typeof fm?.framework === 'string' ? fm.framework : undefined, + }) + walk(childDir) + } + } + } + + walk(skillsDir) + return skills +} + +// --------------------------------------------------------------------------- +// Main scanner +// --------------------------------------------------------------------------- + +export async function scanLibrary(scriptPath: string, projectRoot?: string): Promise { + const nodeModulesDir = join(projectRoot ?? process.cwd(), 'node_modules') + const packages: LibraryPackage[] = [] + const warnings: string[] = [] + const visited = new Set() + + const homeDir = findHomeDir(scriptPath) + if (!homeDir) { + return { packages, warnings: ['Could not determine home package directory'] } + } + + const homePkg = readPkgJson(homeDir) + if (!homePkg) { + return { packages, warnings: ['Could not read home package.json'] } + } + + const homeName = typeof homePkg.name === 'string' ? homePkg.name : '' + + function processPackage(name: string, dir: string): void { + if (visited.has(name)) return + visited.add(name) + + const pkg = readPkgJson(dir) + if (!pkg) { + warnings.push(`Could not read package.json for ${name}`) + return + } + + const skillsDir = join(dir, 'skills') + packages.push({ + name, + version: typeof pkg.version === 'string' ? pkg.version : '0.0.0', + description: typeof pkg.description === 'string' ? pkg.description : '', + skills: existsSync(skillsDir) ? discoverSkills(skillsDir) : [], + }) + + for (const depName of getDeps(pkg)) { + const depDir = join(nodeModulesDir, depName) + if (!existsSync(depDir)) continue + const depPkg = readPkgJson(depDir) + if (depPkg && hasPlaybookBin(depPkg)) { + processPackage(depName, depDir) + } + } + } + + processPackage(homeName, homeDir) + return { packages, warnings } +} diff --git a/packages/playbooks/src/playbook-library.ts b/packages/playbooks/src/playbook-library.ts new file mode 100644 index 00000000..fb50f753 --- /dev/null +++ b/packages/playbooks/src/playbook-library.ts @@ -0,0 +1,158 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import { release } from 'node:os' +import type { LibraryScanResult } from './library-scanner.js' +import { scanLibrary } from './library-scanner.js' + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +async function cmdList(): Promise { + let result: LibraryScanResult + try { + result = await scanLibrary(process.argv[1]) + } catch (err) { + console.error((err as Error).message) + process.exit(1) + } + + if (result.packages.length === 0) { + console.log('No playbook-enabled packages found.') + if (result.warnings.length > 0) { + console.log('\nWarnings:') + for (const w of result.warnings) console.log(` ⚠ ${w}`) + } + return + } + + for (const pkg of result.packages) { + const header = pkg.description + ? `${pkg.name} v${pkg.version} — ${pkg.description}` + : `${pkg.name} v${pkg.version}` + console.log(header) + + for (const skill of pkg.skills) { + const name = skill.name.padEnd(28) + const desc = (skill.description || '').padEnd(52) + console.log(` ${name}${desc}${skill.path}`) + } + + console.log() + } + + if (result.warnings.length > 0) { + console.log('Warnings:') + for (const w of result.warnings) console.log(` ⚠ ${w}`) + } +} + +function cmdInstall(): void { + function tryCopyToClipboard(text: string): boolean { + const platform = process.platform + const isWsl = + platform === 'linux' && + (Boolean(process.env.WSL_DISTRO_NAME) || + Boolean(process.env.WSL_INTEROP) || + release().toLowerCase().includes('microsoft')) + + const tryCommand = (command: string, args: string[] = []) => { + const result = spawnSync(command, args, { input: text }) + return result.status === 0 + } + + if (platform === 'darwin') return tryCommand('pbcopy') + if (platform === 'win32') return tryCommand('clip') + if (isWsl) return tryCommand('clip.exe') + + return ( + tryCommand('wl-copy') || + tryCommand('xclip', ['-selection', 'clipboard']) || + tryCommand('xsel', ['--clipboard', '--input']) + ) + } + + const prompt = `You are an AI assistant helping a developer set up skill-to-task mappings for their project. + +Follow these steps in order: + +1. CHECK FOR EXISTING MAPPINGS + Search the project's agent config files (CLAUDE.md, AGENTS.md, .cursorrules, + .github/copilot-instructions.md) for a block delimited by: + + + - If found: show the user the current mappings and ask "What would you like to update?" + Then skip to step 4 with their requested changes. + - If not found: continue to step 2. + +2. DISCOVER AVAILABLE SKILLS + Run: playbook list + This outputs each skill's name, description, and full path — grouped by package. + +3. SCAN THE REPOSITORY + Build a picture of the project's structure and patterns: + - Read package.json for library dependencies + - Survey the directory layout (src/, app/, routes/, components/, api/, etc.) + - Note recurring patterns (routing, data fetching, auth, UI components, etc.) + + Based on this, propose 3–5 skill-to-task mappings. For each one explain: + - The task or code area (in plain language the user would recognise) + - Which skill applies and why + + Then ask: "What other tasks do you commonly use AI coding agents for? + I'll create mappings for those too." + +4. WRITE THE MAPPINGS BLOCK + Once you have the full set of mappings, write or update the agent config file + (prefer CLAUDE.md; create it if none exists) with this exact block: + + +# Skill mappings — when working in these areas, load the linked skill file into context. +skills: + - task: "describe the task or code area here" + load: "node_modules/package-name/skills/skill-name/SKILL.md" + + + Rules: + - Use the user's own words for task descriptions + - Include the exact path from \`playbook list\` output so agents can load it directly + - Keep entries concise — this block is read on every agent task + - Preserve all content outside the block tags unchanged` + + console.log('🚀 Playbook Install') + console.log('✨ Copy the prompt below into your AI agent:\n') + console.log(prompt) + + const copied = tryCopyToClipboard(prompt) + if (copied) { + console.log('\n✅ Copied prompt to clipboard') + } else { + console.log('\n⚠ Tip: Manually copy the prompt above into your agent') + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const USAGE = `TanStack Playbooks + +Usage: + playbook list List all available skills from this library and its dependencies + playbook install Set up skill-to-task mappings in your agent config` + +const command = process.argv[2] + +switch (command) { + case 'list': + case undefined: + await cmdList() + break + case 'install': + cmdInstall() + break + default: + console.log(USAGE) + process.exit(command ? 1 : 0) +} diff --git a/packages/playbooks/src/setup.ts b/packages/playbooks/src/setup.ts index 1ac79e36..17aeab90 100644 --- a/packages/playbooks/src/setup.ts +++ b/packages/playbooks/src/setup.ts @@ -9,6 +9,7 @@ export interface SetupResult { workflows: string[] oz: string[] skipped: string[] + shim: string | null } interface TemplateVars { @@ -101,6 +102,31 @@ function copyTemplates( return { copied, skipped } } +// --------------------------------------------------------------------------- +// Shim generation +// --------------------------------------------------------------------------- + +const SHIM_CONTENT = `#!/usr/bin/env node +// Auto-generated by @tanstack/playbooks setup +// Exposes the playbook end-user CLI for consumers of this library. +// Commit this file, then add to your package.json: +// "bin": { "playbook": "./bin/playbook.js" } +await import('@tanstack/playbooks/playbook-library') +` + +function generateShim(root: string, result: SetupResult): void { + const shimPath = join(root, 'bin', 'playbook.js') + + if (existsSync(shimPath)) { + result.skipped.push(shimPath) + return + } + + mkdirSync(join(root, 'bin'), { recursive: true }) + writeFileSync(shimPath, SHIM_CONTENT) + result.shim = shimPath +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -109,14 +135,16 @@ export function runSetup(root: string, metaDir: string, args: string[]): SetupRe const doAll = args.includes('--all') const doWorkflows = doAll || args.includes('--workflows') const doOz = doAll || args.includes('--oz') + const doShim = doAll || args.includes('--shim') // If no flags, default to --all - const defaultAll = !doWorkflows && !doOz + const defaultAll = !doWorkflows && !doOz && !doShim const installWorkflows = doWorkflows || defaultAll const installOz = doOz || defaultAll + const installShim = doShim || defaultAll const vars = detectVars(root) - const result: SetupResult = { workflows: [], oz: [], skipped: [] } + const result: SetupResult = { workflows: [], oz: [], skipped: [], shim: null } const templatesDir = join(metaDir, 'templates') @@ -136,14 +164,30 @@ export function runSetup(root: string, metaDir: string, args: string[]): SetupRe result.skipped.push(...skipped) } + if (installShim) { + generateShim(root, result) + } + // Print results for (const f of result.workflows) console.log(`✓ Copied workflow: ${f}`) for (const f of result.oz) console.log(`✓ Copied Oz prompt: ${f}`) for (const f of result.skipped) console.log(` Already exists: ${f}`) - if (result.workflows.length === 0 && result.oz.length === 0 && result.skipped.length === 0) { + if (result.shim) { + console.log(`✓ Generated playbook shim: ${result.shim}`) + console.log(`\n Add to your package.json:`) + console.log(` "bin": { "playbook": "./bin/playbook.js" }`) + console.log(`\n Add bin/playbook.js to your package.json "files" array.`) + } + + if ( + result.workflows.length === 0 && + result.oz.length === 0 && + result.shim === null && + result.skipped.length === 0 + ) { console.log('No templates directory found. Is @tanstack/playbooks installed?') - } else { + } else if (result.workflows.length > 0 || result.oz.length > 0) { console.log(`\nTemplate variables applied:`) console.log(` Package: ${vars.PACKAGE_NAME}`) console.log(` Repo: ${vars.REPO}`) diff --git a/packages/playbooks/tests/library-scanner.test.ts b/packages/playbooks/tests/library-scanner.test.ts new file mode 100644 index 00000000..a4311a6a --- /dev/null +++ b/packages/playbooks/tests/library-scanner.test.ts @@ -0,0 +1,252 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { scanLibrary } from '../src/library-scanner.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createDir(...segments: string[]): string { + const dir = join(...segments) + mkdirSync(dir, { recursive: true }) + return dir +} + +function writeJson(filePath: string, data: unknown): void { + writeFileSync(filePath, JSON.stringify(data, null, 2)) +} + +function writeSkillMd(dir: string, frontmatter: Record): void { + const yamlLines = Object.entries(frontmatter) + .map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v}"` : v}`) + .join('\n') + writeFileSync(join(dir, 'SKILL.md'), `---\n${yamlLines}\n---\n\nSkill content here.\n`) +} + +// Simulate the script path as it would appear in a library's bin/playbook.js shim. +// findHomeDir walks up from dirname(scriptPath) to find the nearest package.json. +function shimPath(pkgDir: string): string { + return join(pkgDir, 'bin', 'playbook.js') +} + +// --------------------------------------------------------------------------- +// Setup / Teardown +// --------------------------------------------------------------------------- + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'library-scanner-test-')) +}) + +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('scanLibrary', () => { + it('returns the home package with its skills', async () => { + const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/router', + version: '1.2.0', + description: 'Type-safe router for React', + bin: { playbook: './bin/playbook.js' }, + }) + const skillDir = createDir(pkgDir, 'skills', 'routing') + writeSkillMd(skillDir, { + name: 'routing', + description: 'File-based route definitions', + }) + + const result = await scanLibrary(shimPath(pkgDir), root) + + expect(result.warnings).toEqual([]) + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.name).toBe('@tanstack/router') + expect(result.packages[0]!.version).toBe('1.2.0') + expect(result.packages[0]!.description).toBe('Type-safe router for React') + expect(result.packages[0]!.skills).toHaveLength(1) + expect(result.packages[0]!.skills[0]!.name).toBe('routing') + expect(result.packages[0]!.skills[0]!.description).toBe('File-based route definitions') + }) + + it('includes the full path to each SKILL.md', async () => { + const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/router', + version: '1.0.0', + bin: { playbook: './bin/playbook.js' }, + }) + const skillDir = createDir(pkgDir, 'skills', 'routing') + writeSkillMd(skillDir, { name: 'routing', description: 'Routing patterns' }) + + const result = await scanLibrary(shimPath(pkgDir), root) + + const skill = result.packages[0]!.skills[0]! + expect(skill.path).toBe(join(pkgDir, 'skills', 'routing', 'SKILL.md')) + }) + + it('recursively discovers deps with bin.playbook', async () => { + // Home package: @tanstack/router, depends on @tanstack/query + const routerDir = createDir(root, 'node_modules', '@tanstack', 'router') + writeJson(join(routerDir, 'package.json'), { + name: '@tanstack/router', + version: '1.0.0', + description: 'Router', + bin: { playbook: './bin/playbook.js' }, + dependencies: { '@tanstack/query': '^5.0.0' }, + }) + const routerSkill = createDir(routerDir, 'skills', 'routing') + writeSkillMd(routerSkill, { name: 'routing', description: 'Route definitions' }) + + // Dep package: @tanstack/query + const queryDir = createDir(root, 'node_modules', '@tanstack', 'query') + writeJson(join(queryDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + description: 'Async state management', + bin: { playbook: './bin/playbook.js' }, + }) + const querySkill = createDir(queryDir, 'skills', 'fetching') + writeSkillMd(querySkill, { name: 'fetching', description: 'Query and mutation patterns' }) + + const result = await scanLibrary(shimPath(routerDir), root) + + expect(result.warnings).toEqual([]) + expect(result.packages).toHaveLength(2) + + const names = result.packages.map((p) => p.name) + expect(names).toContain('@tanstack/router') + expect(names).toContain('@tanstack/query') + + const query = result.packages.find((p) => p.name === '@tanstack/query')! + expect(query.skills[0]!.name).toBe('fetching') + expect(query.skills[0]!.description).toBe('Query and mutation patterns') + }) + + it('discovers deps via peerDependencies', async () => { + const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/router', + version: '1.0.0', + bin: { playbook: './bin/playbook.js' }, + peerDependencies: { '@tanstack/query': '^5.0.0' }, + }) + + const queryDir = createDir(root, 'node_modules', '@tanstack', 'query') + writeJson(join(queryDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + bin: { playbook: './bin/playbook.js' }, + }) + const querySkill = createDir(queryDir, 'skills', 'fetching') + writeSkillMd(querySkill, { name: 'fetching', description: 'Fetching' }) + + const result = await scanLibrary(shimPath(pkgDir), root) + + const names = result.packages.map((p) => p.name) + expect(names).toContain('@tanstack/query') + }) + + it('skips deps without bin.playbook', async () => { + const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/router', + version: '1.0.0', + bin: { playbook: './bin/playbook.js' }, + dependencies: { react: '^18.0.0' }, + }) + + const reactDir = createDir(root, 'node_modules', 'react') + writeJson(join(reactDir, 'package.json'), { + name: 'react', + version: '18.0.0', + // no bin.playbook + }) + const reactSkill = createDir(reactDir, 'skills', 'hooks') + writeSkillMd(reactSkill, { name: 'hooks', description: 'React hooks' }) + + const result = await scanLibrary(shimPath(pkgDir), root) + + const names = result.packages.map((p) => p.name) + expect(names).not.toContain('react') + }) + + it('handles packages with no skills/ directory', async () => { + const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/router', + version: '1.0.0', + bin: { playbook: './bin/playbook.js' }, + }) + // No skills/ directory + + const result = await scanLibrary(shimPath(pkgDir), root) + + expect(result.packages).toHaveLength(1) + expect(result.packages[0]!.skills).toEqual([]) + }) + + it('does not visit the same package twice (cycle detection)', async () => { + // router -> query -> router (circular) + const routerDir = createDir(root, 'node_modules', '@tanstack', 'router') + writeJson(join(routerDir, 'package.json'), { + name: '@tanstack/router', + version: '1.0.0', + bin: { playbook: './bin/playbook.js' }, + dependencies: { '@tanstack/query': '^5.0.0' }, + }) + + const queryDir = createDir(root, 'node_modules', '@tanstack', 'query') + writeJson(join(queryDir, 'package.json'), { + name: '@tanstack/query', + version: '5.0.0', + bin: { playbook: './bin/playbook.js' }, + dependencies: { '@tanstack/router': '^1.0.0' }, // circular back + }) + + const result = await scanLibrary(shimPath(routerDir), root) + + // Each package appears exactly once + const names = result.packages.map((p) => p.name) + expect(names).toHaveLength(2) + expect(new Set(names).size).toBe(2) + }) + + it('discovers sub-skills within a package', async () => { + const pkgDir = createDir(root, 'node_modules', '@tanstack', 'router') + writeJson(join(pkgDir, 'package.json'), { + name: '@tanstack/router', + version: '1.0.0', + bin: { playbook: './bin/playbook.js' }, + }) + const routingDir = createDir(pkgDir, 'skills', 'routing') + writeSkillMd(routingDir, { name: 'routing', description: 'Routing overview' }) + const nestedDir = createDir(routingDir, 'nested-routes') + writeSkillMd(nestedDir, { name: 'routing/nested-routes', description: 'Nested route patterns' }) + + const result = await scanLibrary(shimPath(pkgDir), root) + + const skills = result.packages[0]!.skills + expect(skills).toHaveLength(2) + const names = skills.map((s) => s.name) + expect(names).toContain('routing') + expect(names).toContain('routing/nested-routes') + }) + + it('returns a warning when home package.json cannot be found', async () => { + const fakeScript = join(root, 'nowhere', 'bin', 'playbook.js') + + const result = await scanLibrary(fakeScript, root) + + expect(result.packages).toEqual([]) + expect(result.warnings).toHaveLength(1) + expect(result.warnings[0]).toMatch(/home package/i) + }) +}) diff --git a/packages/playbooks/tests/setup.test.ts b/packages/playbooks/tests/setup.test.ts index 4379f005..41a89169 100644 --- a/packages/playbooks/tests/setup.test.ts +++ b/packages/playbooks/tests/setup.test.ts @@ -80,7 +80,7 @@ describe('runSetup', () => { const result = runSetup(root, metaDir, []) expect(result.workflows).toHaveLength(0) expect(result.oz).toHaveLength(0) - expect(result.skipped).toHaveLength(2) + expect(result.skipped).toHaveLength(3) }) it('handles missing templates directory gracefully', () => { From 06fe7db5330b1c3df510e05c1df88e47476ca23a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 2 Mar 2026 16:31:49 -0700 Subject: [PATCH 2/8] fix: add missing generate-docs script to root package.json autofix.yml runs `pnpm generate-docs` but the script was never wired up, causing CI to fail with ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 3f26dc20..4a80a1ec 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "lint:fix:all": "nx run-many --targets=lint --fix", "test": "pnpm run test:ci", "test:ci": "nx run-many --targets=test:eslint,test:sherif,test:knip,test:docs,test:lib,test:types,build", + "generate-docs": "node scripts/generate-docs.ts", "test:docs": "node scripts/verify-links.ts", "test:eslint": "nx affected --target=test:eslint --exclude=examples/**", "test:knip": "knip", From ac17259147b3f677752ae0f8a8003a4b4bbf4e64 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 2 Mar 2026 16:33:41 -0700 Subject: [PATCH 3/8] fix: exclude .github from prettier to unblock autofix.ci autofix.ci bans modifications to .github, but pnpm run format was reformatting workflow YAMLs and staging those changes for commit. Co-Authored-By: Claude Sonnet 4.6 --- .prettierignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierignore b/.prettierignore index fe4bf24f..f215b787 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,4 @@ +.github **/.nx/ **/.nx/cache **/.svelte-kit From 1dea00ba36cbae65b203a425a9d9231b6335e63f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:34:26 +0000 Subject: [PATCH 4/8] ci: apply automated fixes --- .changeset/config.json | 34 +- README.md | 132 +++--- packages/playbooks/README.md | 132 +++--- .../meta/skill-staleness-check/SKILL.md | 37 +- .../meta/templates/oz/domain-discovery.md | 2 + .../meta/templates/oz/skill-update.md | 1 + .../meta/templates/oz/tree-generation.md | 1 + packages/playbooks/package.json | 76 ++-- packages/playbooks/src/feedback.ts | 130 ++++-- packages/playbooks/src/init.ts | 9 +- packages/playbooks/src/library-scanner.ts | 13 +- packages/playbooks/src/scanner.ts | 16 +- packages/playbooks/src/setup.ts | 33 +- packages/playbooks/src/staleness.ts | 40 +- packages/playbooks/src/utils.ts | 4 +- packages/playbooks/tests/cli.test.ts | 10 +- packages/playbooks/tests/feedback.test.ts | 35 +- packages/playbooks/tests/init.test.ts | 26 +- .../playbooks/tests/library-scanner.test.ts | 29 +- .../playbooks/tests/meta-feedback.test.ts | 57 ++- packages/playbooks/tests/scanner.test.ts | 23 +- packages/playbooks/tests/setup.test.ts | 25 +- packages/playbooks/tests/skills.test.ts | 256 +++++------ packages/playbooks/tests/staleness.test.ts | 37 +- packages/playbooks/vitest.config.ts | 16 +- scripts/validate-skills.ts | 407 +++++++++--------- vitest.workspace.js | 16 +- 27 files changed, 921 insertions(+), 676 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 988dfe9e..1b5ea90a 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,17 +1,17 @@ -{ - "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", - "changelog": [ - "@svitejs/changesets-changelog-github-compact", -{ "repo": "TanStack/playbooks" } - ], - "commit": false, - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "fixed": [], - "linked": [], - "ignore": [], - "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { - "onlyUpdatePeerDependentsWhenOutOfRange": true - } -} +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json", + "changelog": [ + "@svitejs/changesets-changelog-github-compact", + { "repo": "TanStack/playbooks" } + ], + "commit": false, + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "fixed": [], + "linked": [], + "ignore": [], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } +} diff --git a/README.md b/README.md index bdc50439..6cf82599 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,66 @@ -# @tanstack/playbooks - -Ship compositional knowledge for AI coding agents alongside your npm packages. - -Playbooks are npm packages of skills — encoding how tools work together, what patterns apply for which goals, and what to avoid. Skills travel with the tool via `npm update`, not the model's training cutoff. - -`@tanstack/playbooks` is the toolkit for generating, discovering, and maintaining skills for your library. - -## Install - -```bash -pnpm add -D @tanstack/playbooks -``` - -## Quick Start - -### For library consumers - -Set up playbook discovery in your project's agent config files (CLAUDE.md, .cursorrules, etc.): - -```bash -npx playbook init -``` - -List available skills from installed packages: - -```bash -npx playbook list -``` - -### For library maintainers - -Generate skills for your library using the guided scaffold workflow: - -```bash -npx playbook scaffold -``` - -Validate your skill files: - -```bash -npx playbook validate -``` - -Copy CI and Oz workflow templates into your repo: - -```bash -npx playbook setup -``` - -## CLI Commands - -| Command | Description | -|---------|-------------| -| `playbook init` | Inject playbook discovery into agent config files | -| `playbook list [--json]` | Discover playbook-enabled packages | -| `playbook meta` | List meta-skills for library maintainers | -| `playbook scaffold` | Print the guided skill generation prompt | -| `playbook validate [dir]` | Validate SKILL.md files | -| `playbook setup` | Copy CI/Oz workflow templates | -| `playbook stale [--json]` | Check skills for version drift | -| `playbook feedback` | Submit skill feedback | - -## License - -[MIT](./LICENSE) +# @tanstack/playbooks + +Ship compositional knowledge for AI coding agents alongside your npm packages. + +Playbooks are npm packages of skills — encoding how tools work together, what patterns apply for which goals, and what to avoid. Skills travel with the tool via `npm update`, not the model's training cutoff. + +`@tanstack/playbooks` is the toolkit for generating, discovering, and maintaining skills for your library. + +## Install + +```bash +pnpm add -D @tanstack/playbooks +``` + +## Quick Start + +### For library consumers + +Set up playbook discovery in your project's agent config files (CLAUDE.md, .cursorrules, etc.): + +```bash +npx playbook init +``` + +List available skills from installed packages: + +```bash +npx playbook list +``` + +### For library maintainers + +Generate skills for your library using the guided scaffold workflow: + +```bash +npx playbook scaffold +``` + +Validate your skill files: + +```bash +npx playbook validate +``` + +Copy CI and Oz workflow templates into your repo: + +```bash +npx playbook setup +``` + +## CLI Commands + +| Command | Description | +| ------------------------- | ------------------------------------------------- | +| `playbook init` | Inject playbook discovery into agent config files | +| `playbook list [--json]` | Discover playbook-enabled packages | +| `playbook meta` | List meta-skills for library maintainers | +| `playbook scaffold` | Print the guided skill generation prompt | +| `playbook validate [dir]` | Validate SKILL.md files | +| `playbook setup` | Copy CI/Oz workflow templates | +| `playbook stale [--json]` | Check skills for version drift | +| `playbook feedback` | Submit skill feedback | + +## License + +[MIT](./LICENSE) diff --git a/packages/playbooks/README.md b/packages/playbooks/README.md index bdc50439..6cf82599 100644 --- a/packages/playbooks/README.md +++ b/packages/playbooks/README.md @@ -1,66 +1,66 @@ -# @tanstack/playbooks - -Ship compositional knowledge for AI coding agents alongside your npm packages. - -Playbooks are npm packages of skills — encoding how tools work together, what patterns apply for which goals, and what to avoid. Skills travel with the tool via `npm update`, not the model's training cutoff. - -`@tanstack/playbooks` is the toolkit for generating, discovering, and maintaining skills for your library. - -## Install - -```bash -pnpm add -D @tanstack/playbooks -``` - -## Quick Start - -### For library consumers - -Set up playbook discovery in your project's agent config files (CLAUDE.md, .cursorrules, etc.): - -```bash -npx playbook init -``` - -List available skills from installed packages: - -```bash -npx playbook list -``` - -### For library maintainers - -Generate skills for your library using the guided scaffold workflow: - -```bash -npx playbook scaffold -``` - -Validate your skill files: - -```bash -npx playbook validate -``` - -Copy CI and Oz workflow templates into your repo: - -```bash -npx playbook setup -``` - -## CLI Commands - -| Command | Description | -|---------|-------------| -| `playbook init` | Inject playbook discovery into agent config files | -| `playbook list [--json]` | Discover playbook-enabled packages | -| `playbook meta` | List meta-skills for library maintainers | -| `playbook scaffold` | Print the guided skill generation prompt | -| `playbook validate [dir]` | Validate SKILL.md files | -| `playbook setup` | Copy CI/Oz workflow templates | -| `playbook stale [--json]` | Check skills for version drift | -| `playbook feedback` | Submit skill feedback | - -## License - -[MIT](./LICENSE) +# @tanstack/playbooks + +Ship compositional knowledge for AI coding agents alongside your npm packages. + +Playbooks are npm packages of skills — encoding how tools work together, what patterns apply for which goals, and what to avoid. Skills travel with the tool via `npm update`, not the model's training cutoff. + +`@tanstack/playbooks` is the toolkit for generating, discovering, and maintaining skills for your library. + +## Install + +```bash +pnpm add -D @tanstack/playbooks +``` + +## Quick Start + +### For library consumers + +Set up playbook discovery in your project's agent config files (CLAUDE.md, .cursorrules, etc.): + +```bash +npx playbook init +``` + +List available skills from installed packages: + +```bash +npx playbook list +``` + +### For library maintainers + +Generate skills for your library using the guided scaffold workflow: + +```bash +npx playbook scaffold +``` + +Validate your skill files: + +```bash +npx playbook validate +``` + +Copy CI and Oz workflow templates into your repo: + +```bash +npx playbook setup +``` + +## CLI Commands + +| Command | Description | +| ------------------------- | ------------------------------------------------- | +| `playbook init` | Inject playbook discovery into agent config files | +| `playbook list [--json]` | Discover playbook-enabled packages | +| `playbook meta` | List meta-skills for library maintainers | +| `playbook scaffold` | Print the guided skill generation prompt | +| `playbook validate [dir]` | Validate SKILL.md files | +| `playbook setup` | Copy CI/Oz workflow templates | +| `playbook stale [--json]` | Check skills for version drift | +| `playbook feedback` | Submit skill feedback | + +## License + +[MIT](./LICENSE) diff --git a/packages/playbooks/meta/skill-staleness-check/SKILL.md b/packages/playbooks/meta/skill-staleness-check/SKILL.md index 4624a21d..53e98a84 100644 --- a/packages/playbooks/meta/skill-staleness-check/SKILL.md +++ b/packages/playbooks/meta/skill-staleness-check/SKILL.md @@ -7,7 +7,7 @@ description: > documented behavior, rewrites stale skills using skill-generate, checks cross-skill references, and opens PRs. Silent when nothing needs updating. metadata: - version: "1.0" + version: '1.0' category: meta-tooling input_artifacts: - webhook payload (package name, commit SHA, changed files) @@ -68,6 +68,7 @@ node scripts/sync-skills.mjs ``` This checks: + - Source file SHA drift (compares stored SHAs in `sync-state.json` against current remote SHAs via GitHub API) - Library version drift (frontmatter `library_version` vs current published @@ -94,12 +95,12 @@ For each matched skill: 2. Fetch the file diff from the triggering commit in the source repo 3. Classify the change: -| Classification | Criteria | Action | -|----------------|----------|--------| -| **No impact** | Diff is typo fix, comment change, test-only, or internal refactor with no API/behavior change | Skip — no update needed | -| **Version bump only** | Diff changes version numbers, dependency ranges, or metadata but no documented behavior | Bump `library_version` in frontmatter | -| **Content update** | Diff changes API shape, behavior, defaults, types, or patterns that the skill documents | Rewrite affected sections | -| **Breaking change** | Diff removes, renames, or fundamentally changes an API the skill documents | Rewrite + add old pattern as Common Mistake | +| Classification | Criteria | Action | +| --------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------- | +| **No impact** | Diff is typo fix, comment change, test-only, or internal refactor with no API/behavior change | Skip — no update needed | +| **Version bump only** | Diff changes version numbers, dependency ranges, or metadata but no documented behavior | Bump `library_version` in frontmatter | +| **Content update** | Diff changes API shape, behavior, defaults, types, or patterns that the skill documents | Rewrite affected sections | +| **Breaking change** | Diff removes, renames, or fundamentally changes an API the skill documents | Rewrite + add old pattern as Common Mistake | ### Two-pass classification @@ -189,18 +190,23 @@ For each skill (or group of skills) that was updated: ```markdown ### Triggered by + Changes to: ### What changed in the source + ### What changed in the skill + ### Cross-skill impact + ### Review checklist + - [ ] Skill content is accurate - [ ] Code examples are complete and copy-pasteable - [ ] No other skills need corresponding updates @@ -233,6 +239,7 @@ Exit silently (no PR, no notification, no issue) when ANY of these are true: The `sync-skills.mjs` script uses the `gh` CLI for GitHub API access. It requires: + - `gh` CLI installed and authenticated - Read access to upstream TanStack package repos (query, router, db, form, table) @@ -265,11 +272,11 @@ node scripts/sync-skills.mjs db --mark-synced --all ## Constraints -| Rule | Detail | -|------|--------| -| Silent when nothing changes | No noise — exit cleanly if no updates needed | -| Surgical updates over full rewrites | Only change sections affected by the diff | -| One cascade level | Cross-skill checks go one level deep, not recursive | -| PRs scoped to one library | Never mix libraries in a single PR | -| Version bumps are separate from content updates | A version-only bump doesn't require regeneration | -| Commit messages include co-author | `Co-Authored-By: Oz ` | +| Rule | Detail | +| ----------------------------------------------- | --------------------------------------------------- | +| Silent when nothing changes | No noise — exit cleanly if no updates needed | +| Surgical updates over full rewrites | Only change sections affected by the diff | +| One cascade level | Cross-skill checks go one level deep, not recursive | +| PRs scoped to one library | Never mix libraries in a single PR | +| Version bumps are separate from content updates | A version-only bump doesn't require regeneration | +| Commit messages include co-author | `Co-Authored-By: Oz ` | diff --git a/packages/playbooks/meta/templates/oz/domain-discovery.md b/packages/playbooks/meta/templates/oz/domain-discovery.md index 262e8cf4..bc4641ab 100644 --- a/packages/playbooks/meta/templates/oz/domain-discovery.md +++ b/packages/playbooks/meta/templates/oz/domain-discovery.md @@ -40,12 +40,14 @@ Run the full 5-phase domain discovery process: 5. **Phase 5 — Finalize:** Produce domain_map.yaml and skill_spec.md Write the output artifacts to the repository root: + - `domain_map.yaml` - `skill_spec.md` ### After completion Tell the user: + - "Domain discovery complete. Artifacts written to domain_map.yaml and skill_spec.md." - "Next step: load the tree-generator meta-skill to generate SKILL.md files." - "Run `npx playbook feedback --meta --interactive` to share how this went." diff --git a/packages/playbooks/meta/templates/oz/skill-update.md b/packages/playbooks/meta/templates/oz/skill-update.md index dd1f2212..58df6ddc 100644 --- a/packages/playbooks/meta/templates/oz/skill-update.md +++ b/packages/playbooks/meta/templates/oz/skill-update.md @@ -41,6 +41,7 @@ and follow its instructions. If nothing needs updating, say so and exit. If skills were updated, summarize: + - Which skills were updated and why - What sections changed - Any new Common Mistake entries added diff --git a/packages/playbooks/meta/templates/oz/tree-generation.md b/packages/playbooks/meta/templates/oz/tree-generation.md index d0f47568..fcbd6387 100644 --- a/packages/playbooks/meta/templates/oz/tree-generation.md +++ b/packages/playbooks/meta/templates/oz/tree-generation.md @@ -38,6 +38,7 @@ Ensure every skill from domain_map.yaml has a corresponding SKILL.md file. ### After completion Tell the user: + - "Skill tree generated. [N] skills written to skills/." - "Validation: [PASS/FAIL with details]" - "Next steps:" diff --git a/packages/playbooks/package.json b/packages/playbooks/package.json index 886f5bcd..1737509e 100644 --- a/packages/playbooks/package.json +++ b/packages/playbooks/package.json @@ -1,38 +1,38 @@ -{ - "name": "@tanstack/playbooks", - "version": "0.1.0-alpha.1", - "description": "Ship compositional knowledge for AI coding agents alongside your npm packages", - "license": "MIT", - "type": "module", - "repository": { - "type": "git", - "url": "https://github.com/tanstack/playbooks" - }, - "exports": { - ".": { - "import": "./dist/index.mjs", - "types": "./dist/index.d.mts" - }, - "./playbook-library": { - "import": "./dist/playbook-library.mjs" - } - }, - "bin": { - "playbook": "dist/cli.mjs", - "playbook-library": "dist/playbook-library.mjs" - }, - "files": [ - "dist", - "meta" - ], - "dependencies": { - "yaml": "^2.7.0" - }, - "devDependencies": { - "tsdown": "^0.19.0" - }, - "scripts": { -"build": "tsdown src/index.ts src/cli.ts src/setup.ts src/playbook-library.ts src/library-scanner.ts --format esm --dts", - "test:lib": "vitest run" - } -} +{ + "name": "@tanstack/playbooks", + "version": "0.1.0-alpha.1", + "description": "Ship compositional knowledge for AI coding agents alongside your npm packages", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/tanstack/playbooks" + }, + "exports": { + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./playbook-library": { + "import": "./dist/playbook-library.mjs" + } + }, + "bin": { + "playbook": "dist/cli.mjs", + "playbook-library": "dist/playbook-library.mjs" + }, + "files": [ + "dist", + "meta" + ], + "dependencies": { + "yaml": "^2.7.0" + }, + "devDependencies": { + "tsdown": "^0.19.0" + }, + "scripts": { + "build": "tsdown src/index.ts src/cli.ts src/setup.ts src/playbook-library.ts src/library-scanner.ts --format esm --dts", + "test:lib": "vitest run" + } +} diff --git a/packages/playbooks/src/feedback.ts b/packages/playbooks/src/feedback.ts index 18124a4b..43f8edc2 100644 --- a/packages/playbooks/src/feedback.ts +++ b/packages/playbooks/src/feedback.ts @@ -1,7 +1,11 @@ import { execSync } from 'node:child_process' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import type { FeedbackPayload, MetaFeedbackPayload, PlaybookProjectConfig } from './types.js' +import type { + FeedbackPayload, + MetaFeedbackPayload, + PlaybookProjectConfig, +} from './types.js' const META_FEEDBACK_REPO = 'TanStack/playbooks' @@ -10,13 +14,13 @@ const META_FEEDBACK_REPO = 'TanStack/playbooks' // --------------------------------------------------------------------------- const SECRET_PATTERNS = [ - /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/, // GitHub tokens - /(?:sk|pk)[-_](?:live|test)[-_][A-Za-z0-9]{24,}/, // Stripe keys - /AKIA[0-9A-Z]{16}/, // AWS access keys - /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, // PEM private keys - /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, // JWT-like tokens - /(?:Bearer|token)\s+[A-Za-z0-9_\-.~+/]{20,}/i, // Bearer tokens - /[A-Za-z0-9]{32,}(?=.*(?:key|secret|token|password))/i, // Generic secrets near keywords + /(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,}/, // GitHub tokens + /(?:sk|pk)[-_](?:live|test)[-_][A-Za-z0-9]{24,}/, // Stripe keys + /AKIA[0-9A-Z]{16}/, // AWS access keys + /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, // PEM private keys + /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, // JWT-like tokens + /(?:Bearer|token)\s+[A-Za-z0-9_\-.~+/]{20,}/i, // Bearer tokens + /[A-Za-z0-9]{32,}(?=.*(?:key|secret|token|password))/i, // Generic secrets near keywords ] export function containsSecrets(text: string): boolean { @@ -41,24 +45,34 @@ export function hasGhCli(): boolean { // --------------------------------------------------------------------------- function getHomeConfigDir(): string { - return process.env.XDG_CONFIG_HOME - ?? join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.config') + return ( + process.env.XDG_CONFIG_HOME ?? + join(process.env.HOME ?? process.env.USERPROFILE ?? '', '.config') + ) } export function resolveFrequency(root: string): string { // 1. User override (~/.config/playbook/config.json) const userConfigPath = join(getHomeConfigDir(), 'playbook', 'config.json') try { - const userCfg = JSON.parse(readFileSync(userConfigPath, 'utf8')) as Partial + const userCfg = JSON.parse( + readFileSync(userConfigPath, 'utf8'), + ) as Partial if (userCfg.feedback?.frequency) return userCfg.feedback.frequency - } catch { /* fallback */ } + } catch { + /* fallback */ + } // 2. Project config const projectConfigPath = join(root, 'playbook.config.json') try { - const projCfg = JSON.parse(readFileSync(projectConfigPath, 'utf8')) as Partial + const projCfg = JSON.parse( + readFileSync(projectConfigPath, 'utf8'), + ) as Partial if (projCfg.feedback?.frequency) return projCfg.feedback.frequency - } catch { /* fallback */ } + } catch { + /* fallback */ + } // 3. Default return 'every-5' @@ -69,12 +83,21 @@ export function resolveFrequency(root: string): string { // --------------------------------------------------------------------------- const REQUIRED_FIELDS: (keyof FeedbackPayload)[] = [ - 'skill', 'package', 'skillVersion', 'task', - 'whatWorked', 'whatFailed', 'missing', - 'selfCorrections', 'userRating', + 'skill', + 'package', + 'skillVersion', + 'task', + 'whatWorked', + 'whatFailed', + 'missing', + 'selfCorrections', + 'userRating', ] -export function validatePayload(payload: unknown): { valid: boolean; errors: string[] } { +export function validatePayload(payload: unknown): { + valid: boolean + errors: string[] +} { const errors: string[] = [] if (!payload || typeof payload !== 'object') { return { valid: false, errors: ['Payload must be a JSON object'] } @@ -82,12 +105,18 @@ export function validatePayload(payload: unknown): { valid: boolean; errors: str const obj = payload as Record for (const field of REQUIRED_FIELDS) { - if (typeof obj[field] !== 'string' || (obj[field] as string).trim() === '') { + if ( + typeof obj[field] !== 'string' || + (obj[field] as string).trim() === '' + ) { errors.push(`Missing or empty required field: ${field}`) } } - if (obj.userRating && !['good', 'mixed', 'bad'].includes(obj.userRating as string)) { + if ( + obj.userRating && + !['good', 'mixed', 'bad'].includes(obj.userRating as string) + ) { errors.push('userRating must be one of: good, mixed, bad') } @@ -97,7 +126,9 @@ export function validatePayload(payload: unknown): { valid: boolean; errors: str .join('\n') if (containsSecrets(allText)) { - errors.push('Payload appears to contain secrets or tokens — submission rejected') + errors.push( + 'Payload appears to contain secrets or tokens — submission rejected', + ) } return { valid: errors.length === 0, errors } @@ -108,19 +139,38 @@ export function validatePayload(payload: unknown): { valid: boolean; errors: str // --------------------------------------------------------------------------- const META_REQUIRED_FIELDS: (keyof MetaFeedbackPayload)[] = [ - 'metaSkill', 'library', 'agentUsed', 'artifactQuality', - 'whatWorked', 'whatFailed', 'suggestions', 'userRating', + 'metaSkill', + 'library', + 'agentUsed', + 'artifactQuality', + 'whatWorked', + 'whatFailed', + 'suggestions', + 'userRating', ] const VALID_META_SKILLS = [ - 'domain-discovery', 'tree-generator', 'generate-skill', 'skill-staleness-check', + 'domain-discovery', + 'tree-generator', + 'generate-skill', + 'skill-staleness-check', ] -const VALID_AGENTS = ['oz', 'claude-code', 'cursor', 'copilot', 'codex', 'other'] +const VALID_AGENTS = [ + 'oz', + 'claude-code', + 'cursor', + 'copilot', + 'codex', + 'other', +] const VALID_QUALITY_RATINGS = ['good', 'mixed', 'bad'] -export function validateMetaPayload(payload: unknown): { valid: boolean; errors: string[] } { +export function validateMetaPayload(payload: unknown): { + valid: boolean + errors: string[] +} { const errors: string[] = [] if (!payload || typeof payload !== 'object') { return { valid: false, errors: ['Payload must be a JSON object'] } @@ -128,7 +178,10 @@ export function validateMetaPayload(payload: unknown): { valid: boolean; errors: const obj = payload as Record for (const field of META_REQUIRED_FIELDS) { - if (typeof obj[field] !== 'string' || (obj[field] as string).trim() === '') { + if ( + typeof obj[field] !== 'string' || + (obj[field] as string).trim() === '' + ) { errors.push(`Missing or empty required field: ${field}`) } } @@ -141,11 +194,17 @@ export function validateMetaPayload(payload: unknown): { valid: boolean; errors: errors.push(`agentUsed must be one of: ${VALID_AGENTS.join(', ')}`) } - if (obj.artifactQuality && !VALID_QUALITY_RATINGS.includes(obj.artifactQuality as string)) { + if ( + obj.artifactQuality && + !VALID_QUALITY_RATINGS.includes(obj.artifactQuality as string) + ) { errors.push('artifactQuality must be one of: good, mixed, bad') } - if (obj.userRating && !VALID_QUALITY_RATINGS.includes(obj.userRating as string)) { + if ( + obj.userRating && + !VALID_QUALITY_RATINGS.includes(obj.userRating as string) + ) { errors.push('userRating must be one of: good, mixed, bad') } @@ -155,7 +214,9 @@ export function validateMetaPayload(payload: unknown): { valid: boolean; errors: .join('\n') if (containsSecrets(allText)) { - errors.push('Payload appears to contain secrets or tokens — submission rejected') + errors.push( + 'Payload appears to contain secrets or tokens — submission rejected', + ) } return { valid: errors.length === 0, errors } @@ -286,7 +347,10 @@ export function submitMetaFeedback( `gh issue create --repo ${META_FEEDBACK_REPO} --title "${title.replace(/"/g, '\\"')}" --label "feedback:${payload.metaSkill}" --body -`, { input: md, stdio: ['pipe', 'pipe', 'pipe'] }, ) - return { method: 'gh', detail: `Submitted issue to ${META_FEEDBACK_REPO}` } + return { + method: 'gh', + detail: `Submitted issue to ${META_FEEDBACK_REPO}`, + } } catch { // Fall through to file } @@ -364,7 +428,9 @@ export function runFeedback(args: string[]): void { break case 'file': console.log(`✓ ${result.detail}`) - console.log(`Open a GitHub Discussion at https://github.com/${META_FEEDBACK_REPO}/discussions/new?category=Feedback`) + console.log( + `Open a GitHub Discussion at https://github.com/${META_FEEDBACK_REPO}/discussions/new?category=Feedback`, + ) break case 'stdout': console.log('--- Meta-feedback markdown (copy/paste to discussion) ---') diff --git a/packages/playbooks/src/init.ts b/packages/playbooks/src/init.ts index 8ae6949a..379b9351 100644 --- a/packages/playbooks/src/init.ts +++ b/packages/playbooks/src/init.ts @@ -33,9 +33,9 @@ const DEFAULT_CONFIG: PlaybookProjectConfig = { // --------------------------------------------------------------------------- export function detectAgentConfigs(root: string): string[] { - return AGENT_CONFIG_FILES - .map((f) => join(root, f)) - .filter((f) => existsSync(f)) + return AGENT_CONFIG_FILES.map((f) => join(root, f)).filter((f) => + existsSync(f), + ) } // --------------------------------------------------------------------------- @@ -61,7 +61,8 @@ export function injectPlaybookBlock(filePath: string): boolean { content = '' } - const separator = content.length > 0 && !content.endsWith('\n\n') ? '\n\n' : '' + const separator = + content.length > 0 && !content.endsWith('\n\n') ? '\n\n' : '' const updated = content + separator + PLAYBOOK_BLOCK writeFileSync(filePath, updated) return true diff --git a/packages/playbooks/src/library-scanner.ts b/packages/playbooks/src/library-scanner.ts index b19e2632..e0771cb3 100644 --- a/packages/playbooks/src/library-scanner.ts +++ b/packages/playbooks/src/library-scanner.ts @@ -85,7 +85,8 @@ function discoverSkills(skillsDir: string): SkillEntry[] { ? fm.description.replace(/\s+/g, ' ').trim() : '', type: typeof fm?.type === 'string' ? fm.type : undefined, - framework: typeof fm?.framework === 'string' ? fm.framework : undefined, + framework: + typeof fm?.framework === 'string' ? fm.framework : undefined, }) walk(childDir) } @@ -100,7 +101,10 @@ function discoverSkills(skillsDir: string): SkillEntry[] { // Main scanner // --------------------------------------------------------------------------- -export async function scanLibrary(scriptPath: string, projectRoot?: string): Promise { +export async function scanLibrary( + scriptPath: string, + projectRoot?: string, +): Promise { const nodeModulesDir = join(projectRoot ?? process.cwd(), 'node_modules') const packages: LibraryPackage[] = [] const warnings: string[] = [] @@ -108,7 +112,10 @@ export async function scanLibrary(scriptPath: string, projectRoot?: string): Pro const homeDir = findHomeDir(scriptPath) if (!homeDir) { - return { packages, warnings: ['Could not determine home package directory'] } + return { + packages, + warnings: ['Could not determine home package directory'], + } } const homePkg = readPkgJson(homeDir) diff --git a/packages/playbooks/src/scanner.ts b/packages/playbooks/src/scanner.ts index b73b7a2f..d2b56407 100644 --- a/packages/playbooks/src/scanner.ts +++ b/packages/playbooks/src/scanner.ts @@ -31,7 +31,8 @@ function detectPackageManager(root: string): PackageManager { } if (existsSync(join(root, 'pnpm-lock.yaml'))) return 'pnpm' - if (existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock'))) return 'bun' + if (existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock'))) + return 'bun' if (existsSync(join(root, 'yarn.lock'))) return 'yarn' if (existsSync(join(root, 'package-lock.json'))) return 'npm' return 'unknown' @@ -85,15 +86,17 @@ function discoverSkills(skillsDir: string, baseName: string): SkillEntry[] { if (existsSync(skillFile)) { const fm = parseFrontmatter(skillFile) const relName = relative(skillsDir, childDir).split(sep).join('/') - const desc = typeof fm?.description === 'string' - ? fm.description.replace(/\s+/g, ' ').trim() - : '' + const desc = + typeof fm?.description === 'string' + ? fm.description.replace(/\s+/g, ' ').trim() + : '' skills.push({ name: typeof fm?.name === 'string' ? fm.name : relName, path: skillFile, description: desc, type: typeof fm?.type === 'string' ? fm.type : undefined, - framework: typeof fm?.framework === 'string' ? fm.framework : undefined, + framework: + typeof fm?.framework === 'string' ? fm.framework : undefined, }) // Recurse for sub-skills walk(childDir) @@ -194,7 +197,8 @@ export async function scanForPlaybooks(root?: string): Promise { } const pkgName = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' - const pkgVersion = typeof pkgJson.version === 'string' ? pkgJson.version : '0.0.0' + const pkgVersion = + typeof pkgJson.version === 'string' ? pkgJson.version : '0.0.0' // Validate playbook field const playbook = validatePlaybookField(pkgName, pkgJson.playbook) diff --git a/packages/playbooks/src/setup.ts b/packages/playbooks/src/setup.ts index 17aeab90..998c8431 100644 --- a/packages/playbooks/src/setup.ts +++ b/packages/playbooks/src/setup.ts @@ -1,4 +1,10 @@ -import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from 'node:fs' import { join } from 'node:path' // --------------------------------------------------------------------------- @@ -28,18 +34,19 @@ function detectVars(root: string): TemplateVars { let pkgJson: Record = {} try { pkgJson = JSON.parse(readFileSync(pkgPath, 'utf8')) - } catch { /* fallback to defaults */ } + } catch { + /* fallback to defaults */ + } const name = typeof pkgJson.name === 'string' ? pkgJson.name : 'unknown' const playbook = pkgJson.playbook as Record | undefined - const repo = typeof playbook?.repo === 'string' - ? playbook.repo - : name.replace(/^@/, '').replace(/\//, '/') + const repo = + typeof playbook?.repo === 'string' + ? playbook.repo + : name.replace(/^@/, '').replace(/\//, '/') - const docs = typeof playbook?.docs === 'string' - ? playbook.docs - : 'docs/' + const docs = typeof playbook?.docs === 'string' ? playbook.docs : 'docs/' // Best-guess src path from common monorepo patterns const shortName = name.replace(/^@[^/]+\//, '') @@ -131,7 +138,11 @@ function generateShim(root: string, result: SetupResult): void { // Main // --------------------------------------------------------------------------- -export function runSetup(root: string, metaDir: string, args: string[]): SetupResult { +export function runSetup( + root: string, + metaDir: string, + args: string[], +): SetupResult { const doAll = args.includes('--all') const doWorkflows = doAll || args.includes('--workflows') const doOz = doAll || args.includes('--oz') @@ -186,7 +197,9 @@ export function runSetup(root: string, metaDir: string, args: string[]): SetupRe result.shim === null && result.skipped.length === 0 ) { - console.log('No templates directory found. Is @tanstack/playbooks installed?') + console.log( + 'No templates directory found. Is @tanstack/playbooks installed?', + ) } else if (result.workflows.length > 0 || result.oz.length > 0) { console.log(`\nTemplate variables applied:`) console.log(` Package: ${vars.PACKAGE_NAME}`) diff --git a/packages/playbooks/src/staleness.ts b/packages/playbooks/src/staleness.ts index abe5a6d8..f2c25120 100644 --- a/packages/playbooks/src/staleness.ts +++ b/packages/playbooks/src/staleness.ts @@ -19,8 +19,14 @@ function classifyVersionDrift( newVer: string, ): 'major' | 'minor' | 'patch' | null { if (oldVer === newVer) return null - const oldParts = oldVer.replace(/[^0-9.]/g, '').split('.').map(Number) - const newParts = newVer.replace(/[^0-9.]/g, '').split('.').map(Number) + const oldParts = oldVer + .replace(/[^0-9.]/g, '') + .split('.') + .map(Number) + const newParts = newVer + .replace(/[^0-9.]/g, '') + .split('.') + .map(Number) if ((newParts[0] ?? 0) > (oldParts[0] ?? 0)) return 'major' if ((newParts[1] ?? 0) > (oldParts[1] ?? 0)) return 'minor' if ((newParts[2] ?? 0) > (oldParts[2] ?? 0)) return 'patch' @@ -33,7 +39,9 @@ function classifyVersionDrift( async function fetchNpmVersion(packageName: string): Promise { try { - const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`) + const res = await fetch( + `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, + ) if (!res.ok) return null const data = (await res.json()) as Record return typeof data.version === 'string' ? data.version : null @@ -80,23 +88,27 @@ export async function checkStaleness( .split(sep) .join('/') return { - name: fm?.name as string ?? relName, + name: (fm?.name as string) ?? relName, filePath, libraryVersion: fm?.library_version as string | undefined, - sources: Array.isArray(fm?.sources) ? fm.sources as string[] : undefined, + sources: Array.isArray(fm?.sources) + ? (fm.sources as string[]) + : undefined, } }) // Get the version from frontmatter (use first skill that has it) - const skillVersion = skillMetas.find((s) => s.libraryVersion)?.libraryVersion ?? null + const skillVersion = + skillMetas.find((s) => s.libraryVersion)?.libraryVersion ?? null // Fetch current npm version const currentVersion = await fetchNpmVersion(library) // Classify drift - const versionDrift = skillVersion && currentVersion - ? classifyVersionDrift(skillVersion, currentVersion) - : null + const versionDrift = + skillVersion && currentVersion + ? classifyVersionDrift(skillVersion, currentVersion) + : null // Read sync state const syncState = readSyncState(packageDir) @@ -106,8 +118,14 @@ export async function checkStaleness( const reasons: string[] = [] // Version drift - if (currentVersion && skill.libraryVersion && skill.libraryVersion !== currentVersion) { - reasons.push(`version drift (${skill.libraryVersion} → ${currentVersion})`) + if ( + currentVersion && + skill.libraryVersion && + skill.libraryVersion !== currentVersion + ) { + reasons.push( + `version drift (${skill.libraryVersion} → ${currentVersion})`, + ) } // Source SHA changes (from sync-state) diff --git a/packages/playbooks/src/utils.ts b/packages/playbooks/src/utils.ts index 6bf50239..ae85a9e3 100644 --- a/packages/playbooks/src/utils.ts +++ b/packages/playbooks/src/utils.ts @@ -22,7 +22,9 @@ export function findSkillFiles(dir: string): string[] { /** * Parse YAML frontmatter from a file. Returns null if no frontmatter or on error. */ -export function parseFrontmatter(filePath: string): Record | null { +export function parseFrontmatter( + filePath: string, +): Record | null { let content: string try { content = readFileSync(filePath, 'utf8') diff --git a/packages/playbooks/tests/cli.test.ts b/packages/playbooks/tests/cli.test.ts index 502168e0..f714a1dc 100644 --- a/packages/playbooks/tests/cli.test.ts +++ b/packages/playbooks/tests/cli.test.ts @@ -32,12 +32,18 @@ describe('playbook meta', () => { .filter((e) => existsSync(join(metaDir, e.name, 'SKILL.md'))) for (const entry of entries) { - const content = readFileSync(join(metaDir, entry.name, 'SKILL.md'), 'utf8') + const content = readFileSync( + join(metaDir, entry.name, 'SKILL.md'), + 'utf8', + ) const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) expect(match, `${entry.name} should have frontmatter`).not.toBeNull() const fm = parseYaml(match![1]) as Record - expect(fm.description, `${entry.name} should have a description`).toBeTruthy() + expect( + fm.description, + `${entry.name} should have a description`, + ).toBeTruthy() } }) }) diff --git a/packages/playbooks/tests/feedback.test.ts b/packages/playbooks/tests/feedback.test.ts index ca702df3..250a3c6a 100644 --- a/packages/playbooks/tests/feedback.test.ts +++ b/packages/playbooks/tests/feedback.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { @@ -18,12 +24,17 @@ import type { FeedbackPayload } from '../src/types.js' let tmpDir: string function setupDir(): string { - const dir = join(tmpdir(), `playbook-fb-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + const dir = join( + tmpdir(), + `playbook-fb-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ) mkdirSync(dir, { recursive: true }) return dir } -function validPayload(overrides: Partial = {}): FeedbackPayload { +function validPayload( + overrides: Partial = {}, +): FeedbackPayload { return { skill: 'db-core/live-queries', package: '@tanstack/db', @@ -74,7 +85,11 @@ describe('containsSecrets', () => { }) it('does not flag normal text', () => { - expect(containsSecrets('This is a perfectly normal feedback message about queries.')).toBe(false) + expect( + containsSecrets( + 'This is a perfectly normal feedback message about queries.', + ), + ).toBe(false) }) it('does not flag short strings', () => { @@ -118,15 +133,19 @@ describe('validatePayload', () => { }) it('rejects invalid userRating', () => { - const result = validatePayload(validPayload({ userRating: 'excellent' as 'good' })) + const result = validatePayload( + validPayload({ userRating: 'excellent' as 'good' }), + ) expect(result.valid).toBe(false) expect(result.errors.some((e) => e.includes('userRating'))).toBe(true) }) it('rejects payloads containing secrets', () => { - const result = validatePayload(validPayload({ - whatFailed: 'Used token ghp_' + 'A'.repeat(36) + ' and it failed', - })) + const result = validatePayload( + validPayload({ + whatFailed: 'Used token ghp_' + 'A'.repeat(36) + ' and it failed', + }), + ) expect(result.valid).toBe(false) expect(result.errors.some((e) => e.includes('secrets'))).toBe(true) }) diff --git a/packages/playbooks/tests/init.test.ts b/packages/playbooks/tests/init.test.ts index 77ecbb07..f1e22e94 100644 --- a/packages/playbooks/tests/init.test.ts +++ b/packages/playbooks/tests/init.test.ts @@ -1,4 +1,10 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -119,7 +125,10 @@ describe('writeProjectConfig', () => { it('does not overwrite existing config', () => { const configPath = join(root, 'playbook.config.json') - writeFileSync(configPath, JSON.stringify({ feedback: { frequency: 'never' } })) + writeFileSync( + configPath, + JSON.stringify({ feedback: { frequency: 'never' } }), + ) writeProjectConfig(root) const config = JSON.parse(readFileSync(configPath, 'utf8')) @@ -154,12 +163,19 @@ describe('runInit', () => { expect(existsSync(result.configPath)).toBe(true) // Verify injection happened - expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain('## Playbook Skills') - expect(readFileSync(join(root, 'CLAUDE.md'), 'utf8')).toContain('## Playbook Skills') + expect(readFileSync(join(root, 'AGENTS.md'), 'utf8')).toContain( + '## Playbook Skills', + ) + expect(readFileSync(join(root, 'CLAUDE.md'), 'utf8')).toContain( + '## Playbook Skills', + ) }) it('skips already-initialized files', () => { - writeFileSync(join(root, 'AGENTS.md'), '## Playbook Skills\n\nAlready done.\n') + writeFileSync( + join(root, 'AGENTS.md'), + '## Playbook Skills\n\nAlready done.\n', + ) const result = runInit(root) expect(result.injected).toHaveLength(0) diff --git a/packages/playbooks/tests/library-scanner.test.ts b/packages/playbooks/tests/library-scanner.test.ts index a4311a6a..70b388ce 100644 --- a/packages/playbooks/tests/library-scanner.test.ts +++ b/packages/playbooks/tests/library-scanner.test.ts @@ -22,7 +22,10 @@ function writeSkillMd(dir: string, frontmatter: Record): void { const yamlLines = Object.entries(frontmatter) .map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v}"` : v}`) .join('\n') - writeFileSync(join(dir, 'SKILL.md'), `---\n${yamlLines}\n---\n\nSkill content here.\n`) + writeFileSync( + join(dir, 'SKILL.md'), + `---\n${yamlLines}\n---\n\nSkill content here.\n`, + ) } // Simulate the script path as it would appear in a library's bin/playbook.js shim. @@ -73,7 +76,9 @@ describe('scanLibrary', () => { expect(result.packages[0]!.description).toBe('Type-safe router for React') expect(result.packages[0]!.skills).toHaveLength(1) expect(result.packages[0]!.skills[0]!.name).toBe('routing') - expect(result.packages[0]!.skills[0]!.description).toBe('File-based route definitions') + expect(result.packages[0]!.skills[0]!.description).toBe( + 'File-based route definitions', + ) }) it('includes the full path to each SKILL.md', async () => { @@ -103,7 +108,10 @@ describe('scanLibrary', () => { dependencies: { '@tanstack/query': '^5.0.0' }, }) const routerSkill = createDir(routerDir, 'skills', 'routing') - writeSkillMd(routerSkill, { name: 'routing', description: 'Route definitions' }) + writeSkillMd(routerSkill, { + name: 'routing', + description: 'Route definitions', + }) // Dep package: @tanstack/query const queryDir = createDir(root, 'node_modules', '@tanstack', 'query') @@ -114,7 +122,10 @@ describe('scanLibrary', () => { bin: { playbook: './bin/playbook.js' }, }) const querySkill = createDir(queryDir, 'skills', 'fetching') - writeSkillMd(querySkill, { name: 'fetching', description: 'Query and mutation patterns' }) + writeSkillMd(querySkill, { + name: 'fetching', + description: 'Query and mutation patterns', + }) const result = await scanLibrary(shimPath(routerDir), root) @@ -227,9 +238,15 @@ describe('scanLibrary', () => { bin: { playbook: './bin/playbook.js' }, }) const routingDir = createDir(pkgDir, 'skills', 'routing') - writeSkillMd(routingDir, { name: 'routing', description: 'Routing overview' }) + writeSkillMd(routingDir, { + name: 'routing', + description: 'Routing overview', + }) const nestedDir = createDir(routingDir, 'nested-routes') - writeSkillMd(nestedDir, { name: 'routing/nested-routes', description: 'Nested route patterns' }) + writeSkillMd(nestedDir, { + name: 'routing/nested-routes', + description: 'Nested route patterns', + }) const result = await scanLibrary(shimPath(pkgDir), root) diff --git a/packages/playbooks/tests/meta-feedback.test.ts b/packages/playbooks/tests/meta-feedback.test.ts index d012372b..f3c9fabb 100644 --- a/packages/playbooks/tests/meta-feedback.test.ts +++ b/packages/playbooks/tests/meta-feedback.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { @@ -16,12 +22,17 @@ import type { MetaFeedbackPayload } from '../src/types.js' let tmpDir: string function setupDir(): string { - const dir = join(tmpdir(), `playbook-meta-fb-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + const dir = join( + tmpdir(), + `playbook-meta-fb-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ) mkdirSync(dir, { recursive: true }) return dir } -function validMetaPayload(overrides: Partial = {}): MetaFeedbackPayload { +function validMetaPayload( + overrides: Partial = {}, +): MetaFeedbackPayload { return { metaSkill: 'domain-discovery', library: '@tanstack/query', @@ -81,36 +92,46 @@ describe('validateMetaPayload', () => { }) it('rejects invalid metaSkill', () => { - const result = validateMetaPayload(validMetaPayload({ metaSkill: 'not-a-skill' as any })) + const result = validateMetaPayload( + validMetaPayload({ metaSkill: 'not-a-skill' as any }), + ) expect(result.valid).toBe(false) expect(result.errors.some((e) => e.includes('metaSkill'))).toBe(true) }) it('rejects invalid agentUsed', () => { - const result = validateMetaPayload(validMetaPayload({ agentUsed: 'chatgpt' as any })) + const result = validateMetaPayload( + validMetaPayload({ agentUsed: 'chatgpt' as any }), + ) expect(result.valid).toBe(false) expect(result.errors.some((e) => e.includes('agentUsed'))).toBe(true) }) it('rejects invalid userRating', () => { - const result = validateMetaPayload(validMetaPayload({ userRating: 'excellent' as any })) + const result = validateMetaPayload( + validMetaPayload({ userRating: 'excellent' as any }), + ) expect(result.valid).toBe(false) expect(result.errors.some((e) => e.includes('userRating'))).toBe(true) }) it('rejects payloads containing secrets', () => { - const result = validateMetaPayload(validMetaPayload({ - whatFailed: 'Used token ghp_' + 'A'.repeat(36) + ' and it failed', - })) + const result = validateMetaPayload( + validMetaPayload({ + whatFailed: 'Used token ghp_' + 'A'.repeat(36) + ' and it failed', + }), + ) expect(result.valid).toBe(false) expect(result.errors.some((e) => e.includes('secrets'))).toBe(true) }) it('accepts optional fields', () => { - const result = validateMetaPayload(validMetaPayload({ - interviewQuality: 'good', - failureModeQuality: 'mixed', - })) + const result = validateMetaPayload( + validMetaPayload({ + interviewQuality: 'good', + failureModeQuality: 'mixed', + }), + ) expect(result.valid).toBe(true) }) }) @@ -133,10 +154,12 @@ describe('metaToMarkdown', () => { }) it('includes optional quality fields when present', () => { - const md = metaToMarkdown(validMetaPayload({ - interviewQuality: 'good', - failureModeQuality: 'bad', - })) + const md = metaToMarkdown( + validMetaPayload({ + interviewQuality: 'good', + failureModeQuality: 'bad', + }), + ) expect(md).toContain('**Interview quality:** good') expect(md).toContain('**Failure mode quality:** bad') }) diff --git a/packages/playbooks/tests/scanner.test.ts b/packages/playbooks/tests/scanner.test.ts index 3d03048f..01e49a8d 100644 --- a/packages/playbooks/tests/scanner.test.ts +++ b/packages/playbooks/tests/scanner.test.ts @@ -20,7 +20,10 @@ function writeSkillMd(dir: string, frontmatter: Record): void { const yamlLines = Object.entries(frontmatter) .map(([k, v]) => `${k}: ${typeof v === 'string' ? `"${v}"` : v}`) .join('\n') - writeFileSync(join(dir, 'SKILL.md'), `---\n${yamlLines}\n---\n\nSkill content here.\n`) + writeFileSync( + join(dir, 'SKILL.md'), + `---\n${yamlLines}\n---\n\nSkill content here.\n`, + ) } // ── Setup / Teardown ── @@ -78,7 +81,9 @@ describe('scanForPlaybooks', () => { expect(result.packages[0]!.version).toBe('0.5.2') expect(result.packages[0]!.skills).toHaveLength(1) expect(result.packages[0]!.skills[0]!.name).toBe('db-core') - expect(result.packages[0]!.skills[0]!.description).toBe('Core database concepts') + expect(result.packages[0]!.skills[0]!.description).toBe( + 'Core database concepts', + ) }) it('discovers sub-skills', async () => { @@ -91,7 +96,10 @@ describe('scanForPlaybooks', () => { const coreDir = createDir(pkgDir, 'skills', 'db-core') writeSkillMd(coreDir, { name: 'db-core', description: 'Core' }) const subDir = createDir(coreDir, 'live-queries') - writeSkillMd(subDir, { name: 'db-core/live-queries', description: 'Queries' }) + writeSkillMd(subDir, { + name: 'db-core/live-queries', + description: 'Queries', + }) const result = await scanForPlaybooks(root) expect(result.packages[0]!.skills).toHaveLength(2) @@ -153,7 +161,10 @@ describe('scanForPlaybooks', () => { }, }) const reactSkill = createDir(reactDir, 'skills', 'react-db') - writeSkillMd(reactSkill, { name: 'react-db', description: 'React bindings' }) + writeSkillMd(reactSkill, { + name: 'react-db', + description: 'React bindings', + }) const result = await scanForPlaybooks(root) expect(result.packages).toHaveLength(2) @@ -220,6 +231,8 @@ describe('package manager detection', () => { it('throws for Deno without node_modules', async () => { writeFileSync(join(root, 'deno.json'), '{}') // No node_modules dir - await expect(scanForPlaybooks(root)).rejects.toThrow('Deno without node_modules') + await expect(scanForPlaybooks(root)).rejects.toThrow( + 'Deno without node_modules', + ) }) }) diff --git a/packages/playbooks/tests/setup.test.ts b/packages/playbooks/tests/setup.test.ts index 41a89169..5bdc5f4e 100644 --- a/packages/playbooks/tests/setup.test.ts +++ b/packages/playbooks/tests/setup.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { runSetup } from '../src/setup.js' @@ -43,13 +50,19 @@ describe('runSetup', () => { }) it('substitutes variables from package.json playbook field', () => { - writeFileSync(join(root, 'package.json'), JSON.stringify({ - name: '@tanstack/query', - playbook: { repo: 'TanStack/query', docs: 'docs/' }, - })) + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: '@tanstack/query', + playbook: { repo: 'TanStack/query', docs: 'docs/' }, + }), + ) const result = runSetup(root, metaDir, []) - const wfContent = readFileSync(join(root, '.github', 'workflows', 'notify-playbooks.yml'), 'utf8') + const wfContent = readFileSync( + join(root, '.github', 'workflows', 'notify-playbooks.yml'), + 'utf8', + ) expect(wfContent).toContain('package: @tanstack/query') expect(wfContent).toContain('repo: TanStack/query') expect(wfContent).toContain('docs: docs/**') diff --git a/packages/playbooks/tests/skills.test.ts b/packages/playbooks/tests/skills.test.ts index 50cfe2c4..66015eb8 100644 --- a/packages/playbooks/tests/skills.test.ts +++ b/packages/playbooks/tests/skills.test.ts @@ -1,126 +1,130 @@ -import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' -import { join, relative, sep } from 'node:path' -import { describe, expect, it } from 'vitest' -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 -} - -// ── Helpers ── - -const META_DIR = join(__dirname, '..', 'meta') -const MAX_META_SKILL_LINES = 1000 - -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) return null - - try { - const frontmatter = parseYaml(match[1]) as SkillFrontmatter - return { frontmatter, body: match[2] } - } catch { - return null - } -} - -function skillPathFromFile(baseDir: string, filePath: string): string { - return relative(baseDir, filePath) - .replace(/[/\\]SKILL\.md$/, '') - .split(sep) - .join('/') -} - -// ── Collect meta-skills ── - -const metaSkillFiles = findSkillFiles(META_DIR) -const metaSkills = metaSkillFiles.map((filePath) => { - const content = readFileSync(filePath, 'utf-8') - const parsed = extractFrontmatter(content) - const relPath = skillPathFromFile(META_DIR, filePath) - return { filePath, content, parsed, relPath } -}) - -// ── Tests ── - -describe('meta-skill discovery', () => { - it('should find meta-skill files', () => { - expect(metaSkillFiles.length).toBeGreaterThan(0) - }) - - it('should include domain-discovery', () => { - expect(metaSkills.find((s) => s.relPath === 'domain-discovery')).toBeDefined() - }) - - it('should include tree-generator', () => { - expect(metaSkills.find((s) => s.relPath === 'tree-generator')).toBeDefined() - }) - - it('should include generate-skill', () => { - expect(metaSkills.find((s) => s.relPath === 'generate-skill')).toBeDefined() - }) - - it('should include skill-staleness-check', () => { - expect(metaSkills.find((s) => s.relPath === 'skill-staleness-check')).toBeDefined() - }) -}) - -describe('meta-skill frontmatter', () => { - for (const skill of metaSkills) { - describe(skill.relPath, () => { - it('should have valid frontmatter', () => { - expect(skill.parsed).not.toBeNull() - }) - - if (!skill.parsed) return - - const { frontmatter } = skill.parsed - - it('should have a name', () => { - expect(frontmatter.name).toBeTruthy() - }) - - it('should have a description', () => { - expect(frontmatter.description).toBeTruthy() - }) - }) - } -}) - -describe('meta-skill content', () => { - for (const skill of metaSkills) { - describe(skill.relPath, () => { - it(`should not exceed ${MAX_META_SKILL_LINES} lines`, () => { - const lineCount = skill.content.split(/\r?\n/).length - expect(lineCount).toBeLessThanOrEqual(MAX_META_SKILL_LINES) - }) - }) - } -}) +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs' +import { join, relative, sep } from 'node:path' +import { describe, expect, it } from 'vitest' +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 +} + +// ── Helpers ── + +const META_DIR = join(__dirname, '..', 'meta') +const MAX_META_SKILL_LINES = 1000 + +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) return null + + try { + const frontmatter = parseYaml(match[1]) as SkillFrontmatter + return { frontmatter, body: match[2] } + } catch { + return null + } +} + +function skillPathFromFile(baseDir: string, filePath: string): string { + return relative(baseDir, filePath) + .replace(/[/\\]SKILL\.md$/, '') + .split(sep) + .join('/') +} + +// ── Collect meta-skills ── + +const metaSkillFiles = findSkillFiles(META_DIR) +const metaSkills = metaSkillFiles.map((filePath) => { + const content = readFileSync(filePath, 'utf-8') + const parsed = extractFrontmatter(content) + const relPath = skillPathFromFile(META_DIR, filePath) + return { filePath, content, parsed, relPath } +}) + +// ── Tests ── + +describe('meta-skill discovery', () => { + it('should find meta-skill files', () => { + expect(metaSkillFiles.length).toBeGreaterThan(0) + }) + + it('should include domain-discovery', () => { + expect( + metaSkills.find((s) => s.relPath === 'domain-discovery'), + ).toBeDefined() + }) + + it('should include tree-generator', () => { + expect(metaSkills.find((s) => s.relPath === 'tree-generator')).toBeDefined() + }) + + it('should include generate-skill', () => { + expect(metaSkills.find((s) => s.relPath === 'generate-skill')).toBeDefined() + }) + + it('should include skill-staleness-check', () => { + expect( + metaSkills.find((s) => s.relPath === 'skill-staleness-check'), + ).toBeDefined() + }) +}) + +describe('meta-skill frontmatter', () => { + for (const skill of metaSkills) { + describe(skill.relPath, () => { + it('should have valid frontmatter', () => { + expect(skill.parsed).not.toBeNull() + }) + + if (!skill.parsed) return + + const { frontmatter } = skill.parsed + + it('should have a name', () => { + expect(frontmatter.name).toBeTruthy() + }) + + it('should have a description', () => { + expect(frontmatter.description).toBeTruthy() + }) + }) + } +}) + +describe('meta-skill content', () => { + for (const skill of metaSkills) { + describe(skill.relPath, () => { + it(`should not exceed ${MAX_META_SKILL_LINES} lines`, () => { + const lineCount = skill.content.split(/\r?\n/).length + expect(lineCount).toBeLessThanOrEqual(MAX_META_SKILL_LINES) + }) + }) + } +}) diff --git a/packages/playbooks/tests/staleness.test.ts b/packages/playbooks/tests/staleness.test.ts index f6c9a619..fc34685a 100644 --- a/packages/playbooks/tests/staleness.test.ts +++ b/packages/playbooks/tests/staleness.test.ts @@ -11,7 +11,10 @@ import { checkStaleness } from '../src/staleness.js' let tmpDir: string function setupDir(): string { - const dir = join(tmpdir(), `playbook-stale-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + const dir = join( + tmpdir(), + `playbook-stale-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ) mkdirSync(dir, { recursive: true }) return dir } @@ -37,10 +40,7 @@ function writeSkill( writeFileSync(join(skillDir, 'SKILL.md'), `---\n${fmStr}\n---\n${body}`) } -function writeSyncState( - dir: string, - state: Record, -): void { +function writeSyncState(dir: string, state: Record): void { const skillsDir = join(dir, 'skills') mkdirSync(skillsDir, { recursive: true }) writeFileSync(join(skillsDir, 'sync-state.json'), JSON.stringify(state)) @@ -83,14 +83,23 @@ describe('checkStaleness', () => { }) it('detects skills from SKILL.md files', async () => { - writeSkill(tmpDir, 'basics', { name: 'basics', description: 'Core concepts' }) - writeSkill(tmpDir, 'advanced', { name: 'advanced', description: 'Advanced usage' }) + writeSkill(tmpDir, 'basics', { + name: 'basics', + description: 'Core concepts', + }) + writeSkill(tmpDir, 'advanced', { + name: 'advanced', + description: 'Advanced usage', + }) globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) const report = await checkStaleness(tmpDir, '@example/lib') expect(report.skills).toHaveLength(2) - expect(report.skills.map((s) => s.name).sort()).toEqual(['advanced', 'basics']) + expect(report.skills.map((s) => s.name).sort()).toEqual([ + 'advanced', + 'basics', + ]) }) it('detects major version drift', async () => { @@ -199,7 +208,7 @@ describe('checkStaleness', () => { const report = await checkStaleness(tmpDir, '@example/lib') expect(report.skills[0].needsReview).toBe(true) expect(report.skills[0].reasons).toEqual( - expect.arrayContaining([expect.stringContaining('new source')]) + expect.arrayContaining([expect.stringContaining('new source')]), ) }) @@ -235,7 +244,9 @@ describe('checkStaleness', () => { }) it('uses directory name when frontmatter has no name', async () => { - writeSkill(tmpDir, 'my-skill', { description: 'A skill with no name field' }) + writeSkill(tmpDir, 'my-skill', { + description: 'A skill with no name field', + }) globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) @@ -245,7 +256,11 @@ describe('checkStaleness', () => { it('uses skillVersion from first skill that has library_version', async () => { writeSkill(tmpDir, 'a', { name: 'a', description: 'No version' }) - writeSkill(tmpDir, 'b', { name: 'b', description: 'Has version', library_version: '3.5.0' }) + writeSkill(tmpDir, 'b', { + name: 'b', + description: 'Has version', + library_version: '3.5.0', + }) globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/playbooks/vitest.config.ts b/packages/playbooks/vitest.config.ts index edca3b57..a691c9ac 100644 --- a/packages/playbooks/vitest.config.ts +++ b/packages/playbooks/vitest.config.ts @@ -1,8 +1,8 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - name: 'playbooks', - include: ['tests/**/*.test.ts'], - }, -}) +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + name: 'playbooks', + include: ['tests/**/*.test.ts'], + }, +}) diff --git a/scripts/validate-skills.ts b/scripts/validate-skills.ts index f9d95ffa..219b9bb2 100644 --- a/scripts/validate-skills.ts +++ b/scripts/validate-skills.ts @@ -1,204 +1,203 @@ -import { readFileSync, readdirSync, statSync, existsSync } 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 = [ - 'playbook list', - 'playbook feedback', - 'npm install @tanstack/', - 'npx playbook', -] - -// ── 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) 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.error('No SKILL.md files found') - process.exit(1) - } - - 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() +import { readFileSync, readdirSync, statSync, existsSync } 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 = [ + 'playbook list', + 'playbook feedback', + 'npm install @tanstack/', + 'npx playbook', +] + +// ── 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) 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.error('No SKILL.md files found') + process.exit(1) + } + + 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() diff --git a/vitest.workspace.js b/vitest.workspace.js index 08c2b528..801ad469 100644 --- a/vitest.workspace.js +++ b/vitest.workspace.js @@ -1,9 +1,7 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - projects: [ - './packages/playbooks/vitest.config.ts', - ], - }, -}) +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + projects: ['./packages/playbooks/vitest.config.ts'], + }, +}) From 7dcd12cc6934461b349a5349939527464dcd3d9c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 2 Mar 2026 16:39:26 -0700 Subject: [PATCH 5/8] fix: remove unused '-' dependency flagged by knip Co-Authored-By: Claude Sonnet 4.6 --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 4a80a1ec..8d0ac1b0 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,6 @@ }, "overrides": {}, "dependencies": { - "-": "^0.0.1", "@tanstack/typedoc-config": "^0.3.3" } } From 98a9c7f4c6789d2b31623257c34a758146365e9d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 2 Mar 2026 16:43:03 -0700 Subject: [PATCH 6/8] update lock --- pnpm-lock.yaml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5273270b..79937505 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '-': - specifier: ^0.0.1 - version: 0.0.1 '@tanstack/typedoc-config': specifier: ^0.3.3 version: 0.3.3(typescript@5.9.3) @@ -82,9 +79,6 @@ importers: packages: - '-@0.0.1': - resolution: {integrity: sha512-3HfneK3DGAm05fpyj20sT3apkNcvPpCuccOThOPdzz8sY7GgQGe0l93XH9bt+YzibcTIgUAIMoyVJI740RtgyQ==} - '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} @@ -2779,8 +2773,6 @@ packages: snapshots: - '-@0.0.1': {} - '@babel/generator@7.29.1': dependencies: '@babel/parser': 7.29.0 From b20d399ffbf4bd2cbe5ef52f1087620be1272824 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 2 Mar 2026 16:45:01 -0700 Subject: [PATCH 7/8] fix: exit 0 when no SKILL.md files found in validate-skills script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validate-skills workflow triggered (via autofix.ci reformatting the script) but this repo has no root-level skills/ dir — it's the toolkit, not a library. Exiting 0 with a message is the right behavior when there's nothing to validate. Co-Authored-By: Claude Sonnet 4.6 --- scripts/validate-skills.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/validate-skills.ts b/scripts/validate-skills.ts index 219b9bb2..28ec6232 100644 --- a/scripts/validate-skills.ts +++ b/scripts/validate-skills.ts @@ -169,8 +169,8 @@ function main(): void { const skillFiles = findSkillFiles(SKILLS_DIR) if (skillFiles.length === 0) { - console.error('No SKILL.md files found') - process.exit(1) + console.log('No SKILL.md files found — nothing to validate') + process.exit(0) } for (const filePath of skillFiles) { From c678ba17442224b4611651c651b2f0c22f4797e3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 2 Mar 2026 16:46:40 -0700 Subject: [PATCH 8/8] chore: add changeset for playbook-library CLI Co-Authored-By: Claude Sonnet 4.6 --- .changeset/add-playbook-library-cli.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/add-playbook-library-cli.md diff --git a/.changeset/add-playbook-library-cli.md b/.changeset/add-playbook-library-cli.md new file mode 100644 index 00000000..e12cff85 --- /dev/null +++ b/.changeset/add-playbook-library-cli.md @@ -0,0 +1,5 @@ +--- +'@tanstack/playbooks': patch +--- + +Add `playbook-library` end-user CLI for library consumers. Libraries wire it up via a generated shim (`playbook setup --shim`) to expose a `playbook` bin. Running `playbook list` recursively discovers skills across the library's dependency tree; `playbook install` prints an agent-driven prompt to map skills to project tasks in CLAUDE.md.