Centralize frontmatter parsing + parse frontmatter with yaml library (#728)

* Add frontmatter utility and tidy coding agent prompts

* Add frontmatter parsing utilities and tests

* Parse frontmatter with YAML parser

* Simplify frontmatter parsing utilities

* strip body in 1 place

* Improve frontmatter parsing error handling

* Normalize multiline skill and select-list descriptions
This commit is contained in:
Richard Gill 2026-01-15 23:31:53 +00:00 committed by GitHub
parent df58d3191e
commit ce7e73b503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 213 additions and 126 deletions

View file

@ -1,6 +1,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
import { join, resolve } from "path";
import { CONFIG_DIR_NAME, getPromptsDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
/**
* Represents a prompt template loaded from a markdown file
@ -12,36 +13,6 @@ export interface PromptTemplate {
source: string; // e.g., "(user)", "(project)", "(project:frontend)"
}
/**
* Parse YAML frontmatter from markdown content
* Returns { frontmatter, content } where content has frontmatter stripped
*/
function parseFrontmatter(content: string): { frontmatter: Record<string, string>; content: string } {
const frontmatter: Record<string, string> = {};
if (!content.startsWith("---")) {
return { frontmatter, content };
}
const endIndex = content.indexOf("\n---", 3);
if (endIndex === -1) {
return { frontmatter, content };
}
const frontmatterBlock = content.slice(4, endIndex);
const remainingContent = content.slice(endIndex + 4).trim();
// Simple YAML parsing - just key: value pairs
for (const line of frontmatterBlock.split("\n")) {
const match = line.match(/^(\w+):\s*(.*)$/);
if (match) {
frontmatter[match[1]] = match[2].trim();
}
}
return { frontmatter, content: remainingContent };
}
/**
* Parse command arguments respecting quoted strings (bash-style)
* Returns array of arguments
@ -145,7 +116,7 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
} else if (isFile && entry.name.endsWith(".md")) {
try {
const rawContent = readFileSync(fullPath, "utf-8");
const { frontmatter, content } = parseFrontmatter(rawContent);
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(rawContent);
const name = entry.name.slice(0, -3); // Remove .md extension
@ -160,7 +131,7 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
// Get description from frontmatter or first non-empty line
let description = frontmatter.description || "";
if (!description) {
const firstLine = content.split("\n").find((line) => line.trim());
const firstLine = body.split("\n").find((line) => line.trim());
if (firstLine) {
// Truncate if too long
description = firstLine.slice(0, 60);
@ -174,7 +145,7 @@ function loadTemplatesFromDir(dir: string, source: "user" | "project", subdir: s
templates.push({
name,
description,
content,
content: body,
source: sourceStr,
});
} catch (_error) {

View file

@ -3,6 +3,7 @@ import { minimatch } from "minimatch";
import { homedir } from "os";
import { basename, dirname, join, resolve } from "path";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
import { parseFrontmatter } from "../utils/frontmatter.js";
import type { SkillsSettings } from "./settings-manager.js";
/**
@ -50,48 +51,6 @@ export interface LoadSkillsResult {
type SkillFormat = "recursive" | "claude";
function stripQuotes(value: string): string {
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
return value.slice(1, -1);
}
return value;
}
function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string; allKeys: string[] } {
const frontmatter: SkillFrontmatter = {};
const allKeys: string[] = [];
const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
if (!normalizedContent.startsWith("---")) {
return { frontmatter, body: normalizedContent, allKeys };
}
const endIndex = normalizedContent.indexOf("\n---", 3);
if (endIndex === -1) {
return { frontmatter, body: normalizedContent, allKeys };
}
const frontmatterBlock = normalizedContent.slice(4, endIndex);
const body = normalizedContent.slice(endIndex + 4).trim();
for (const line of frontmatterBlock.split("\n")) {
const match = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (match) {
const key = match[1];
const value = stripQuotes(match[2].trim());
allKeys.push(key);
if (key === "name") {
frontmatter.name = value;
} else if (key === "description") {
frontmatter.description = value;
}
}
}
return { frontmatter, body, allKeys };
}
/**
* Validate skill name per Agent Skills spec.
* Returns array of validation error messages (empty if valid).
@ -244,7 +203,8 @@ function loadSkillFromFile(filePath: string, source: string): { skill: Skill | n
try {
const rawContent = readFileSync(filePath, "utf-8");
const { frontmatter, allKeys } = parseFrontmatter(rawContent);
const { frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent);
const allKeys = Object.keys(frontmatter);
const skillDir = dirname(filePath);
const parentDirName = basename(skillDir);
@ -284,7 +244,9 @@ function loadSkillFromFile(filePath: string, source: string): { skill: Skill | n
},
warnings,
};
} catch {
} catch (error) {
const message = error instanceof Error ? error.message : "failed to parse skill file";
warnings.push({ skillPath: filePath, message });
return { skill: null, warnings };
}
}

View file

@ -276,3 +276,4 @@ export {
Theme,
type ThemeColor,
} from "./modes/interactive/theme/theme.js";
export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.js";

View file

@ -60,7 +60,7 @@ import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.js";
import { stripFrontmatter } from "../../utils/frontmatter.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { ArminComponent } from "./components/armin.js";
import { AssistantMessageComponent } from "./components/assistant-message.js";
@ -3316,8 +3316,7 @@ export class InteractiveMode {
private async handleSkillCommand(skillPath: string, args: string): Promise<void> {
try {
const content = fs.readFileSync(skillPath, "utf-8");
// Strip YAML frontmatter if present
const body = content.replace(/^---\n[\s\S]*?\n---\n/, "").trim();
const body = stripFrontmatter(content).trim();
const skillDir = path.dirname(skillPath);
const header = `Skill location: ${skillPath}\nReferences are relative to ${skillDir}.`;
const skillMessage = `${header}\n\n${body}`;

View file

@ -0,0 +1,39 @@
import { parse } from "yaml";
type ParsedFrontmatter<T extends Record<string, unknown>> = {
frontmatter: T;
body: string;
};
const normalizeNewlines = (value: string): string => value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const extractFrontmatter = (content: string): { yamlString: string | null; body: string } => {
const normalized = normalizeNewlines(content);
if (!normalized.startsWith("---")) {
return { yamlString: null, body: normalized };
}
const endIndex = normalized.indexOf("\n---", 3);
if (endIndex === -1) {
return { yamlString: null, body: normalized };
}
return {
yamlString: normalized.slice(4, endIndex),
body: normalized.slice(endIndex + 4).trim(),
};
};
export const parseFrontmatter = <T extends Record<string, unknown> = Record<string, unknown>>(
content: string,
): ParsedFrontmatter<T> => {
const { yamlString, body } = extractFrontmatter(content);
if (!yamlString) {
return { frontmatter: {} as T, body };
}
const parsed = parse(yamlString);
return { frontmatter: (parsed ?? {}) as T, body };
};
export const stripFrontmatter = (content: string): string => parseFrontmatter(content).body;