diff --git a/AGENTS.md b/AGENTS.md index 4bc4def..9bfa676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Guidance for AI coding agents working with this repository. skills/ {skill-name}/ SKILL.md # Required: skill manifest (Agent Skills spec) - AGENTS.md # Generated: navigation guide for agents + AGENTS.md # Generated: SKILL.md body (frontmatter stripped) CLAUDE.md # Generated: symlink to AGENTS.md references/ _sections.md # Required: section definitions diff --git a/packages/skills-build/src/build.ts b/packages/skills-build/src/build.ts index 03b06c8..13272c2 100644 --- a/packages/skills-build/src/build.ts +++ b/packages/skills-build/src/build.ts @@ -8,7 +8,7 @@ import { unlinkSync, writeFileSync, } from "node:fs"; -import { basename, join } from "node:path"; +import { join } from "node:path"; import { discoverSkills, getSkillPaths, @@ -77,78 +77,19 @@ function parseSections(rulesDir: string): Section[] { } /** - * Parse SKILL.md frontmatter to extract metadata + * Extract the markdown body from SKILL.md (everything after frontmatter) */ -function parseSkillFrontmatter(content: string): Record { +function extractSkillBody(content: string): string { if (!content.startsWith("---")) { - return {}; + return content.trim(); } const endIndex = content.indexOf("---", 3); if (endIndex === -1) { - return {}; + return content.trim(); } - const frontmatterContent = content.slice(3, endIndex).trim(); - const result: Record = {}; - let inMetadata = false; - const metadataObj: Record = {}; - - for (const line of frontmatterContent.split("\n")) { - // Check for metadata block start - if (line.trim() === "metadata:") { - inMetadata = true; - continue; - } - - // Handle metadata nested values - if (inMetadata && line.startsWith(" ")) { - const colonIndex = line.indexOf(":"); - if (colonIndex !== -1) { - const key = line.slice(0, colonIndex).trim(); - let value = line.slice(colonIndex + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - metadataObj[key] = value; - } - continue; - } - - // End metadata block when we hit a non-indented line - if (inMetadata && !line.startsWith(" ") && line.trim()) { - inMetadata = false; - result.metadata = metadataObj; - } - - // Handle top-level key-value - const colonIndex = line.indexOf(":"); - if (colonIndex === -1) continue; - - const currentKey = line.slice(0, colonIndex).trim(); - let value = line.slice(colonIndex + 1).trim(); - - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - - if (value) { - result[currentKey] = value; - } - } - - // Ensure metadata is captured if file ends in metadata block - if (inMetadata && Object.keys(metadataObj).length > 0) { - result.metadata = metadataObj; - } - - return result; + return content.slice(endIndex + 3).trim(); } /** @@ -219,22 +160,29 @@ function getReferenceFiles(referencesDir: string): string[] { } /** - * Convert skill name to title (e.g., "postgres-best-practices" -> "Postgres Best Practices") + * Parse the SKILL.md body into its H1 title and the content after it. + * Returns the title text and the remaining body content. */ -function skillNameToTitle(skillName: string): string { - return skillName - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); +function parseSkillBodySections(body: string): { + title: string | null; + content: string; +} { + const lines = body.split("\n"); + const firstLine = lines[0]?.trim() ?? ""; + + const h1Match = firstLine.match(/^#\s+(.+)$/); + if (!h1Match) { + return { title: null, content: body }; + } + + const content = lines.slice(1).join("\n").trim(); + return { title: h1Match[1].trim(), content }; } /** - * Create CLAUDE.md symlink pointing to AGENTS.md + * Create a symlink, removing any existing file or symlink at the target path */ -function createClaudeSymlink(paths: SkillPaths): void { - const symlinkPath = paths.claudeSymlink; - - // Remove existing symlink or file if it exists +function createSymlink(symlinkPath: string, target: string): void { if (existsSync(symlinkPath)) { const stat = lstatSync(symlinkPath); if (stat.isSymbolicLink() || stat.isFile()) { @@ -242,46 +190,33 @@ function createClaudeSymlink(paths: SkillPaths): void { } } - // Create symlink (relative path so it works across environments) - symlinkSync("AGENTS.md", symlinkPath); - console.log(` Created symlink: CLAUDE.md -> AGENTS.md`); + symlinkSync(target, symlinkPath); } /** * Build AGENTS.md for a specific skill * - * AGENTS.md is a concise navigation guide for AI agents, NOT a comprehensive - * documentation dump. It helps agents understand the skill directory structure - * and how to find information. + * Structure: H1 Title > Structure > Usage > rest of SKILL.md body + * CLAUDE.md = symlink to AGENTS.md. */ function buildSkill(paths: SkillPaths): void { console.log(`[${paths.name}] Building AGENTS.md...`); - // Read SKILL.md for metadata + // Read SKILL.md and strip frontmatter const skillContent = existsSync(paths.skillFile) ? readFileSync(paths.skillFile, "utf-8") : ""; - const frontmatter = parseSkillFrontmatter(skillContent); - const skillTitle = skillNameToTitle(paths.name); - const description = - (frontmatter.description as string) || `${skillTitle} skill for AI agents.`; + const body = extractSkillBody(skillContent); + const { title, content: skillBodyContent } = parseSkillBodySections(body); - // Parse sections if available - const sections = parseAllSections(paths.referencesDir); - const referenceFiles = getReferenceFiles(paths.referencesDir); - - // Generate concise AGENTS.md const output: string[] = []; - // Header - output.push(`# ${paths.name}\n`); - output.push(`> **Note:** \`CLAUDE.md\` is a symlink to this file.\n`); + // 1. Title (from SKILL.md H1) + if (title) { + output.push(`# ${title}\n`); + } - // Brief description - output.push(`## Overview\n`); - output.push(`${description}\n`); - - // Directory structure + // 2. Structure output.push(`## Structure\n`); output.push("```"); output.push(`${paths.name}/`); @@ -293,7 +228,7 @@ function buildSkill(paths: SkillPaths): void { } output.push("```\n"); - // How to use + // 3. Usage output.push(`## Usage\n`); output.push(`1. Read \`SKILL.md\` for the main skill instructions`); output.push( @@ -303,59 +238,19 @@ function buildSkill(paths: SkillPaths): void { `3. Reference files are loaded on-demand - read only what you need\n`, ); - // Reference sections (if available) - if (sections.length > 0) { - output.push(`## Reference Categories\n`); - output.push(`| Priority | Category | Impact | Prefix |`); - output.push(`|----------|----------|--------|--------|`); - for (const section of sections.sort((a, b) => a.number - b.number)) { - output.push( - `| ${section.number} | ${section.title} | ${section.impact} | \`${section.prefix}-\` |`, - ); - } + // 4. Rest of SKILL.md body (after H1 title) + if (skillBodyContent) { + output.push(skillBodyContent); output.push(""); - output.push( - `Reference files are named \`{prefix}-{topic}.md\` (e.g., \`query-missing-indexes.md\`).\n`, - ); } - // Reference file list (just filenames, not content) - if (referenceFiles.length > 0) { - output.push(`## Available References\n`); - const grouped = new Map(); - - for (const file of referenceFiles) { - const name = basename(file, ".md"); - const prefix = name.split("-")[0]; - const group = grouped.get(prefix) || []; - group.push(name); - grouped.set(prefix, group); - } - - for (const [prefix, files] of grouped) { - const section = sections.find((s) => s.prefix === prefix); - const title = section ? section.title : prefix; - output.push(`**${title}** (\`${prefix}-\`):`); - for (const file of files.sort()) { - output.push(`- \`references/${file}.md\``); - } - output.push(""); - } - } - - // Stats - output.push(`---\n`); - output.push( - `*${referenceFiles.length} reference files across ${sections.length} categories*`, - ); - // Write AGENTS.md writeFileSync(paths.agentsOutput, output.join("\n")); console.log(` Generated: ${paths.agentsOutput}`); - console.log(` Total references: ${referenceFiles.length}`); - // Create CLAUDE.md symlink - createClaudeSymlink(paths); + // Create CLAUDE.md -> AGENTS.md symlink + createSymlink(paths.claudeSymlink, "AGENTS.md"); + console.log(` Created symlink: CLAUDE.md -> AGENTS.md`); } // Run build when executed directly @@ -397,11 +292,12 @@ if (isMainModule) { export { buildSkill, + extractSkillBody, generateSectionMap, getMarkdownFiles, getMarkdownFilesRecursive, // deprecated, use getMarkdownFiles getReferenceFiles, parseAllSections, + parseSkillBodySections, parseSections, - skillNameToTitle, }; diff --git a/packages/skills-build/src/validate.ts b/packages/skills-build/src/validate.ts index 10830f1..41694da 100644 --- a/packages/skills-build/src/validate.ts +++ b/packages/skills-build/src/validate.ts @@ -1,10 +1,12 @@ -import { existsSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { basename } from "node:path"; import { + extractSkillBody, generateSectionMap, getMarkdownFiles, parseAllSections, parseSections, + parseSkillBodySections, } from "./build.js"; import { discoverSkills, @@ -142,12 +144,102 @@ export function validateRuleFile( }; } +/** + * Extract the `name` field from SKILL.md frontmatter + */ +function extractSkillName(skillFilePath: string): string | null { + if (!existsSync(skillFilePath)) return null; + + const content = readFileSync(skillFilePath, "utf-8"); + if (!content.startsWith("---")) return null; + + const endIndex = content.indexOf("---", 3); + if (endIndex === -1) return null; + + const frontmatter = content.slice(3, endIndex); + for (const line of frontmatter.split("\n")) { + const match = line.match(/^name:\s*(.+)/); + if (match) return match[1].trim(); + } + return null; +} + +/** + * Convert a title to kebab-case (e.g., "Supabase Postgres Best Practices" -> "supabase-postgres-best-practices") + */ +function titleToKebab(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Validate SKILL.md structure: + * - `name` field matches directory name (kebab-case) + * - Body starts with an H1 heading + * - H1 title in kebab-case matches directory name + */ +function validateSkillStructure(paths: SkillPaths): string[] { + const errors: string[] = []; + const skillName = extractSkillName(paths.skillFile); + + if (!skillName) { + errors.push("SKILL.md is missing the `name` field in frontmatter"); + return errors; + } + + if (skillName !== paths.name) { + errors.push( + `SKILL.md name "${skillName}" does not match directory name "${paths.name}"`, + ); + } + + // Validate kebab-case format + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(paths.name)) { + errors.push( + `Directory name "${paths.name}" is not valid kebab-case (lowercase alphanumeric and hyphens only, no leading/trailing/consecutive hyphens)`, + ); + } + + // Validate body starts with H1 and title matches directory name in kebab-case + if (existsSync(paths.skillFile)) { + const content = readFileSync(paths.skillFile, "utf-8"); + const body = extractSkillBody(content); + const { title } = parseSkillBodySections(body); + + if (!title) { + errors.push( + "SKILL.md body must start with an H1 heading (e.g., `# Skill Title`)", + ); + } else { + const kebabTitle = titleToKebab(title); + if (kebabTitle !== paths.name) { + errors.push( + `H1 title "${title}" in kebab-case is "${kebabTitle}", but directory name is "${paths.name}"`, + ); + } + } + } + + return errors; +} + /** * Validate all reference files for a skill */ function validateSkill(paths: SkillPaths): boolean { console.log(`[${paths.name}] Validating...`); + // Validate skill structure (name, kebab-case, H1 heading) + const nameErrors = validateSkillStructure(paths); + if (nameErrors.length > 0) { + for (const error of nameErrors) { + console.log(` ERROR: ${error}`); + } + return false; + } + // Check if references directory exists if (!existsSync(paths.referencesDir)) { console.log(` No references directory found.`); diff --git a/skills/supabase-postgres-best-practices/AGENTS.md b/skills/supabase-postgres-best-practices/AGENTS.md index a744cc8..a7baf44 100644 --- a/skills/supabase-postgres-best-practices/AGENTS.md +++ b/skills/supabase-postgres-best-practices/AGENTS.md @@ -1,10 +1,4 @@ -# supabase-postgres-best-practices - -> **Note:** `CLAUDE.md` is a symlink to this file. - -## Overview - -Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations. +# Supabase Postgres Best Practices ## Structure @@ -22,7 +16,19 @@ supabase-postgres-best-practices/ 2. Browse `references/` for detailed documentation on specific topics 3. Reference files are loaded on-demand - read only what you need -## Reference Categories +Comprehensive performance optimization guide for Postgres, maintained by Supabase. Contains rules across 8 categories, prioritized by impact to guide automated query optimization and schema design. + +## When to Apply + +Reference these guidelines when: +- Writing SQL queries or designing schemas +- Implementing indexes or query optimization +- Reviewing database performance issues +- Configuring connection pooling or scaling +- Optimizing for Postgres-specific features +- Working with Row-Level Security (RLS) + +## Rule Categories by Priority | Priority | Category | Impact | Prefix | |----------|----------|--------|--------| @@ -35,57 +41,28 @@ supabase-postgres-best-practices/ | 7 | Monitoring & Diagnostics | LOW-MEDIUM | `monitor-` | | 8 | Advanced Features | LOW | `advanced-` | -Reference files are named `{prefix}-{topic}.md` (e.g., `query-missing-indexes.md`). +## How to Use -## Available References +Read individual rule files for detailed explanations and SQL examples: -**Advanced Features** (`advanced-`): -- `references/advanced-full-text-search.md` -- `references/advanced-jsonb-indexing.md` +``` +references/query-missing-indexes.md +references/schema-partial-indexes.md +references/_sections.md +``` -**Connection Management** (`conn-`): -- `references/conn-idle-timeout.md` -- `references/conn-limits.md` -- `references/conn-pooling.md` -- `references/conn-prepared-statements.md` +Each rule file contains: +- Brief explanation of why it matters +- Incorrect SQL example with explanation +- Correct SQL example with explanation +- Optional EXPLAIN output or metrics +- Additional context and references +- Supabase-specific notes (when applicable) -**Data Access Patterns** (`data-`): -- `references/data-batch-inserts.md` -- `references/data-n-plus-one.md` -- `references/data-pagination.md` -- `references/data-upsert.md` +## References -**Concurrency & Locking** (`lock-`): -- `references/lock-advisory.md` -- `references/lock-deadlock-prevention.md` -- `references/lock-short-transactions.md` -- `references/lock-skip-locked.md` - -**Monitoring & Diagnostics** (`monitor-`): -- `references/monitor-explain-analyze.md` -- `references/monitor-pg-stat-statements.md` -- `references/monitor-vacuum-analyze.md` - -**Query Performance** (`query-`): -- `references/query-composite-indexes.md` -- `references/query-covering-indexes.md` -- `references/query-index-types.md` -- `references/query-missing-indexes.md` -- `references/query-partial-indexes.md` - -**Schema Design** (`schema-`): -- `references/schema-constraints.md` -- `references/schema-data-types.md` -- `references/schema-foreign-key-indexes.md` -- `references/schema-lowercase-identifiers.md` -- `references/schema-partitioning.md` -- `references/schema-primary-keys.md` - -**Security & RLS** (`security-`): -- `references/security-privileges.md` -- `references/security-rls-basics.md` -- `references/security-rls-performance.md` - ---- - -*31 reference files across 8 categories* \ No newline at end of file +- https://www.postgresql.org/docs/current/ +- https://supabase.com/docs +- https://wiki.postgresql.org/wiki/Performance_Optimization +- https://supabase.com/docs/guides/database/overview +- https://supabase.com/docs/guides/auth/row-level-security