chore: build system skill body (#42)

* feat: extract SKILL.md body into AGENTS.md with H1 title and Overview section

Build system now parses SKILL.md body to extract H1 heading as the AGENTS.md
title and places remaining content under an Overview section. Adds validation
that SKILL.md body starts with H1, directory name is kebab-case, and name
field matches directory name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: AGENTS.md is now SKILL.md body with frontmatter stripped

Build now generates AGENTS.md by extracting the SKILL.md markdown body
(everything after YAML frontmatter). CLAUDE.md remains a symlink to
AGENTS.md. Removes content generation logic (Structure, Usage, Overview,
Reference Categories, Available References sections) — SKILL.md is the
single source of truth for agent instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Structure and Usage sections to AGENTS.md, validate H1 title matches directory name

Build now generates AGENTS.md as: H1 Title > Structure > Usage > rest of
SKILL.md body. Validates that SKILL.md body starts with H1 heading and
that the title in kebab-case matches the directory name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Pedro Rodrigues
2026-02-13 17:14:49 +00:00
committed by GitHub
parent 9b236f3ebd
commit c6f5a2bec0
4 changed files with 172 additions and 207 deletions

View File

@@ -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<string, unknown> {
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<string, unknown> = {};
let inMetadata = false;
const metadataObj: Record<string, string> = {};
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<string, string[]>();
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,
};

View File

@@ -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.`);