fix: add strict YAML validation for plugin frontmatter (#399)
This commit is contained in:
@@ -67,6 +67,7 @@ When adding or modifying skills, verify compliance with the skill spec:
|
|||||||
|
|
||||||
- [ ] `name:` present and matches directory name (lowercase-with-hyphens)
|
- [ ] `name:` present and matches directory name (lowercase-with-hyphens)
|
||||||
- [ ] `description:` present and describes **what it does and when to use it** (per official spec: "Explains code with diagrams. Use when exploring how code works.")
|
- [ ] `description:` present and describes **what it does and when to use it** (per official spec: "Explains code with diagrams. Use when exploring how code works.")
|
||||||
|
- [ ] `description:` value is quoted (single or double) if it contains colons -- unquoted colons break `js-yaml` strict parsing and crash `install --to opencode/codex`. Run `bun test tests/frontmatter.test.ts` to verify.
|
||||||
|
|
||||||
### Reference File Inclusion (Required if references/ exists)
|
### Reference File Inclusion (Required if references/ exists)
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async function loadPersonalSkills(skillsDir: string): Promise<ClaudeSkill[]> {
|
|||||||
let data: Record<string, unknown> = {}
|
let data: Record<string, unknown> = {}
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(skillPath, "utf8")
|
const raw = await fs.readFile(skillPath, "utf8")
|
||||||
data = parseFrontmatter(raw).data
|
data = parseFrontmatter(raw, skillPath).data
|
||||||
} catch {
|
} catch {
|
||||||
// Keep syncing the skill even if frontmatter is malformed.
|
// Keep syncing the skill even if frontmatter is malformed.
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ async function loadPersonalCommands(commandsDir: string): Promise<ClaudeCommand[
|
|||||||
const commands: ClaudeCommand[] = []
|
const commands: ClaudeCommand[] = []
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const raw = await fs.readFile(file, "utf8")
|
const raw = await fs.readFile(file, "utf8")
|
||||||
const { data, body } = parseFrontmatter(raw)
|
const { data, body } = parseFrontmatter(raw, file)
|
||||||
commands.push({
|
commands.push({
|
||||||
name: typeof data.name === "string" ? data.name : deriveCommandName(commandsDir, file),
|
name: typeof data.name === "string" ? data.name : deriveCommandName(commandsDir, file),
|
||||||
description: data.description as string | undefined,
|
description: data.description as string | undefined,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ async function loadAgents(agentsDirs: string[]): Promise<ClaudeAgent[]> {
|
|||||||
const agents: ClaudeAgent[] = []
|
const agents: ClaudeAgent[] = []
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const raw = await readText(file)
|
const raw = await readText(file)
|
||||||
const { data, body } = parseFrontmatter(raw)
|
const { data, body } = parseFrontmatter(raw, file)
|
||||||
const name = (data.name as string) ?? path.basename(file, ".md")
|
const name = (data.name as string) ?? path.basename(file, ".md")
|
||||||
agents.push({
|
agents.push({
|
||||||
name,
|
name,
|
||||||
@@ -80,7 +80,7 @@ async function loadCommands(commandsDirs: string[]): Promise<ClaudeCommand[]> {
|
|||||||
const commands: ClaudeCommand[] = []
|
const commands: ClaudeCommand[] = []
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const raw = await readText(file)
|
const raw = await readText(file)
|
||||||
const { data, body } = parseFrontmatter(raw)
|
const { data, body } = parseFrontmatter(raw, file)
|
||||||
const name = (data.name as string) ?? path.basename(file, ".md")
|
const name = (data.name as string) ?? path.basename(file, ".md")
|
||||||
const allowedTools = parseAllowedTools(data["allowed-tools"])
|
const allowedTools = parseAllowedTools(data["allowed-tools"])
|
||||||
const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined
|
const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined
|
||||||
@@ -104,7 +104,7 @@ async function loadSkills(skillsDirs: string[]): Promise<ClaudeSkill[]> {
|
|||||||
const skills: ClaudeSkill[] = []
|
const skills: ClaudeSkill[] = []
|
||||||
for (const file of skillFiles) {
|
for (const file of skillFiles) {
|
||||||
const raw = await readText(file)
|
const raw = await readText(file)
|
||||||
const { data } = parseFrontmatter(raw)
|
const { data } = parseFrontmatter(raw, file)
|
||||||
const name = (data.name as string) ?? path.basename(path.dirname(file))
|
const name = (data.name as string) ?? path.basename(path.dirname(file))
|
||||||
const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined
|
const disableModelInvocation = data["disable-model-invocation"] === true ? true : undefined
|
||||||
skills.push({
|
skills.push({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export type FrontmatterResult = {
|
|||||||
body: string
|
body: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseFrontmatter(raw: string): FrontmatterResult {
|
export function parseFrontmatter(raw: string, sourcePath?: string): FrontmatterResult {
|
||||||
const lines = raw.split(/\r?\n/)
|
const lines = raw.split(/\r?\n/)
|
||||||
if (lines.length === 0 || lines[0].trim() !== "---") {
|
if (lines.length === 0 || lines[0].trim() !== "---") {
|
||||||
return { data: {}, body: raw }
|
return { data: {}, body: raw }
|
||||||
@@ -25,9 +25,15 @@ export function parseFrontmatter(raw: string): FrontmatterResult {
|
|||||||
|
|
||||||
const yamlText = lines.slice(1, endIndex).join("\n")
|
const yamlText = lines.slice(1, endIndex).join("\n")
|
||||||
const body = lines.slice(endIndex + 1).join("\n")
|
const body = lines.slice(endIndex + 1).join("\n")
|
||||||
const parsed = load(yamlText)
|
try {
|
||||||
const data = (parsed && typeof parsed === "object") ? (parsed as Record<string, unknown>) : {}
|
const parsed = load(yamlText)
|
||||||
return { data, body }
|
const data = (parsed && typeof parsed === "object") ? (parsed as Record<string, unknown>) : {}
|
||||||
|
return { data, body }
|
||||||
|
} catch (err) {
|
||||||
|
const location = sourcePath ? ` in ${sourcePath}` : ""
|
||||||
|
const hint = "Tip: quote frontmatter values containing colons (e.g. description: 'Use for X: Y')"
|
||||||
|
throw new Error(`Invalid YAML frontmatter${location}: ${err instanceof Error ? err.message : err}\n${hint}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFrontmatter(data: Record<string, unknown>, body: string): string {
|
export function formatFrontmatter(data: Record<string, unknown>, body: string): string {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { readdirSync, readFileSync, statSync } from "fs"
|
||||||
|
import path from "path"
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { load } from "js-yaml"
|
||||||
import { formatFrontmatter, parseFrontmatter } from "../src/utils/frontmatter"
|
import { formatFrontmatter, parseFrontmatter } from "../src/utils/frontmatter"
|
||||||
|
|
||||||
describe("frontmatter", () => {
|
describe("frontmatter", () => {
|
||||||
@@ -17,4 +20,58 @@ describe("frontmatter", () => {
|
|||||||
expect(parsed.data.description).toBe("Test")
|
expect(parsed.data.description).toBe("Test")
|
||||||
expect(parsed.body.trim()).toBe(body)
|
expect(parsed.body.trim()).toBe(body)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect all markdown files with YAML frontmatter from a plugin directory.
|
||||||
|
* Returns [relativePath, yamlText] pairs for each file with a frontmatter block.
|
||||||
|
*/
|
||||||
|
function collectFrontmatterFiles(pluginRoot: string): [string, string][] {
|
||||||
|
const results: [string, string][] = []
|
||||||
|
|
||||||
|
function walk(dir: string) {
|
||||||
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === "node_modules" || entry.name === ".git") continue
|
||||||
|
walk(full)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!entry.name.endsWith(".md")) continue
|
||||||
|
const raw = readFileSync(full, "utf8")
|
||||||
|
const lines = raw.split(/\r?\n/)
|
||||||
|
if (lines[0]?.trim() !== "---") continue
|
||||||
|
let end = -1
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
if (lines[i].trim() === "---") { end = i; break }
|
||||||
|
}
|
||||||
|
if (end === -1) continue
|
||||||
|
const yaml = lines.slice(1, end).join("\n")
|
||||||
|
const rel = path.relative(pluginRoot, full)
|
||||||
|
results.push([rel, yaml])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(pluginRoot)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("frontmatter YAML validity", () => {
|
||||||
|
const pluginRoots = [
|
||||||
|
"plugins/compound-engineering",
|
||||||
|
"plugins/coding-tutor",
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const pluginRoot of pluginRoots) {
|
||||||
|
const root = path.join(process.cwd(), pluginRoot)
|
||||||
|
try { statSync(root) } catch { continue }
|
||||||
|
const files = collectFrontmatterFiles(root)
|
||||||
|
|
||||||
|
for (const [rel, yaml] of files) {
|
||||||
|
test(`${pluginRoot}/${rel} has valid strict YAML frontmatter`, () => {
|
||||||
|
expect(() => load(yaml)).not.toThrow()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user