mirror of
https://github.com/supabase/agent-skills.git
synced 2026-03-27 10:09:26 +08:00
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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
Reference in New Issue
Block a user