Skip to content

Commit bfc2b6d

Browse files
committed
feat(postinstall): add YAML frontmatter validation for agent files
Signed-off-by: leocavalcante <leo@cavalcante.dev>
1 parent 750c8e5 commit bfc2b6d

2 files changed

Lines changed: 259 additions & 19 deletions

File tree

postinstall.mjs

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,69 @@ const MIN_CONTENT_LENGTH = 100
2929
/** Keywords that should appear in valid agent files (case-insensitive) */
3030
const REQUIRED_KEYWORDS = ["agent", "task"]
3131

32+
/** Required fields in YAML frontmatter */
33+
const REQUIRED_FRONTMATTER_FIELDS = ["version", "requires"]
34+
35+
/**
36+
* Parses YAML frontmatter from markdown content.
37+
*
38+
* Expects frontmatter to be delimited by --- at the start of the file.
39+
*
40+
* @param {string} content - The file content to parse
41+
* @returns {{ found: boolean, fields: Record<string, string>, endIndex: number }} Parse result
42+
*/
43+
function parseFrontmatter(content) {
44+
// Frontmatter must start at the beginning of the file
45+
if (!content.startsWith("---")) {
46+
return { found: false, fields: {}, endIndex: 0 }
47+
}
48+
49+
// Find the closing ---
50+
const endMatch = content.indexOf("\n---", 3)
51+
if (endMatch === -1) {
52+
return { found: false, fields: {}, endIndex: 0 }
53+
}
54+
55+
// Extract frontmatter content (between the --- delimiters)
56+
const frontmatterContent = content.slice(4, endMatch)
57+
const fields = {}
58+
59+
// Parse simple key: value pairs
60+
for (const line of frontmatterContent.split("\n")) {
61+
const trimmed = line.trim()
62+
if (!trimmed || trimmed.startsWith("#")) continue
63+
64+
const colonIndex = trimmed.indexOf(":")
65+
if (colonIndex === -1) continue
66+
67+
const key = trimmed.slice(0, colonIndex).trim()
68+
let value = trimmed.slice(colonIndex + 1).trim()
69+
70+
// Remove surrounding quotes if present
71+
if (
72+
(value.startsWith('"') && value.endsWith('"')) ||
73+
(value.startsWith("'") && value.endsWith("'"))
74+
) {
75+
value = value.slice(1, -1)
76+
}
77+
78+
fields[key] = value
79+
}
80+
81+
// endIndex points to the character after the closing ---\n
82+
const endIndex = endMatch + 4
83+
84+
return { found: true, fields, endIndex }
85+
}
86+
3287
/**
3388
* Validates that an agent file has valid content structure.
3489
*
3590
* Checks that the file:
36-
* 1. Starts with a markdown header (# )
37-
* 2. Contains at least MIN_CONTENT_LENGTH characters
38-
* 3. Contains at least one of the expected keywords
91+
* 1. Has YAML frontmatter with required fields (version, requires)
92+
* 2. Starts with a markdown header (# ) after frontmatter
93+
* 3. Contains at least MIN_CONTENT_LENGTH characters
94+
* 4. Contains at least one of the expected keywords
3995
*
4096
* @param {string} filePath - Path to the agent file to validate
4197
* @returns {{ valid: boolean, error?: string }} Validation result with optional error message
@@ -51,11 +107,32 @@ function validateAgentContent(filePath) {
51107
}
52108
}
53109

54-
// Check for markdown header at start
55-
if (!content.startsWith("# ")) {
110+
// Check for YAML frontmatter
111+
const frontmatter = parseFrontmatter(content)
112+
if (!frontmatter.found) {
113+
return {
114+
valid: false,
115+
error: "File missing YAML frontmatter (must start with ---)",
116+
}
117+
}
118+
119+
// Check for required frontmatter fields
120+
const missingFields = REQUIRED_FRONTMATTER_FIELDS.filter((field) => !frontmatter.fields[field])
121+
if (missingFields.length > 0) {
122+
return {
123+
valid: false,
124+
error: `Frontmatter missing required fields: ${missingFields.join(", ")}`,
125+
}
126+
}
127+
128+
// Get content after frontmatter
129+
const contentAfterFrontmatter = content.slice(frontmatter.endIndex).trimStart()
130+
131+
// Check for markdown header after frontmatter
132+
if (!contentAfterFrontmatter.startsWith("# ")) {
56133
return {
57134
valid: false,
58-
error: "File does not start with a markdown header (# )",
135+
error: "File does not have a markdown header (# ) after frontmatter",
59136
}
60137
}
61138

tests/install.test.ts

Lines changed: 176 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,41 @@ const AGENTS_TARGET_DIR = "${agentsTargetDir.replace(/\\/g, "/")}"
283283
284284
const MIN_CONTENT_LENGTH = 100
285285
const REQUIRED_KEYWORDS = ["agent", "task"]
286+
const REQUIRED_FRONTMATTER_FIELDS = ["version", "requires"]
287+
288+
function parseFrontmatter(content) {
289+
if (!content.startsWith("---")) {
290+
return { found: false, fields: {}, endIndex: 0 }
291+
}
292+
293+
const endMatch = content.indexOf("\\n---", 3)
294+
if (endMatch === -1) {
295+
return { found: false, fields: {}, endIndex: 0 }
296+
}
297+
298+
const frontmatterContent = content.slice(4, endMatch)
299+
const fields = {}
300+
301+
for (const line of frontmatterContent.split("\\n")) {
302+
const trimmed = line.trim()
303+
if (!trimmed || trimmed.startsWith("#")) continue
304+
305+
const colonIndex = trimmed.indexOf(":")
306+
if (colonIndex === -1) continue
307+
308+
const key = trimmed.slice(0, colonIndex).trim()
309+
let value = trimmed.slice(colonIndex + 1).trim()
310+
311+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
312+
value = value.slice(1, -1)
313+
}
314+
315+
fields[key] = value
316+
}
317+
318+
const endIndex = endMatch + 4
319+
return { found: true, fields, endIndex }
320+
}
286321
287322
function validateAgentContent(filePath) {
288323
const content = readFileSync(filePath, "utf-8")
@@ -294,10 +329,28 @@ function validateAgentContent(filePath) {
294329
}
295330
}
296331
297-
if (!content.startsWith("# ")) {
332+
const frontmatter = parseFrontmatter(content)
333+
if (!frontmatter.found) {
334+
return {
335+
valid: false,
336+
error: "File missing YAML frontmatter (must start with ---)",
337+
}
338+
}
339+
340+
const missingFields = REQUIRED_FRONTMATTER_FIELDS.filter((field) => !frontmatter.fields[field])
341+
if (missingFields.length > 0) {
298342
return {
299343
valid: false,
300-
error: "File does not start with a markdown header (# )",
344+
error: \`Frontmatter missing required fields: \${missingFields.join(", ")}\`,
345+
}
346+
}
347+
348+
const contentAfterFrontmatter = content.slice(frontmatter.endIndex).trimStart()
349+
350+
if (!contentAfterFrontmatter.startsWith("# ")) {
351+
return {
352+
valid: false,
353+
error: "File does not have a markdown header (# ) after frontmatter",
301354
}
302355
}
303356
@@ -387,8 +440,13 @@ main()
387440
`
388441
}
389442

390-
// Valid agent content for tests (meets all requirements)
391-
const validAgentContent = `# Test Agent
443+
// Valid agent content for tests (meets all requirements including frontmatter)
444+
const validAgentContent = `---
445+
version: 0.1.0
446+
requires: ">=0.1.0"
447+
---
448+
449+
# Test Agent
392450
393451
This is a valid agent file that contains enough content to pass the minimum length requirement.
394452
@@ -426,11 +484,101 @@ The agent handles various tasks and operations in the system.
426484
expect(stderr).toContain("File too short")
427485
})
428486

429-
it("should reject files without markdown header", async () => {
430-
// Create a file without markdown header
431-
const contentWithoutHeader = `This file does not start with a markdown header.
487+
it("should reject files without YAML frontmatter", async () => {
488+
// Create a file without frontmatter (starts with header directly)
489+
const contentWithoutFrontmatter = `# Test Agent Without Frontmatter
490+
491+
This file does not have YAML frontmatter at the start.
432492
It has enough content and contains the word agent and task.
433-
This should fail the validation because it doesn't start with # symbol.`
493+
This should fail the validation because it doesn't start with ---.`
494+
writeFileSync(join(agentsSourceDir, "no-frontmatter.md"), contentWithoutFrontmatter)
495+
496+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
497+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
498+
499+
const proc = Bun.spawn(["node", scriptPath], {
500+
cwd: mockProjectDir,
501+
stdout: "pipe",
502+
stderr: "pipe",
503+
})
504+
505+
await proc.exited
506+
507+
const stderr = await new Response(proc.stderr).text()
508+
expect(stderr).toContain("no-frontmatter.md")
509+
expect(stderr).toContain("missing YAML frontmatter")
510+
})
511+
512+
it("should reject files with frontmatter missing required fields", async () => {
513+
// Create a file with frontmatter but missing 'requires' field
514+
const contentMissingRequires = `---
515+
version: 0.1.0
516+
---
517+
518+
# Test Agent
519+
520+
This file has frontmatter but is missing the requires field.
521+
It contains the word agent and task to pass keyword validation.
522+
This should fail because requires is a required frontmatter field.`
523+
writeFileSync(join(agentsSourceDir, "missing-requires.md"), contentMissingRequires)
524+
525+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
526+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
527+
528+
const proc = Bun.spawn(["node", scriptPath], {
529+
cwd: mockProjectDir,
530+
stdout: "pipe",
531+
stderr: "pipe",
532+
})
533+
534+
await proc.exited
535+
536+
const stderr = await new Response(proc.stderr).text()
537+
expect(stderr).toContain("missing-requires.md")
538+
expect(stderr).toContain("Frontmatter missing required fields")
539+
expect(stderr).toContain("requires")
540+
})
541+
542+
it("should reject files with frontmatter missing version field", async () => {
543+
// Create a file with frontmatter but missing 'version' field
544+
const contentMissingVersion = `---
545+
requires: ">=0.1.0"
546+
---
547+
548+
# Test Agent
549+
550+
This file has frontmatter but is missing the version field.
551+
It contains the word agent and task to pass keyword validation.
552+
This should fail because version is a required frontmatter field.`
553+
writeFileSync(join(agentsSourceDir, "missing-version.md"), contentMissingVersion)
554+
555+
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
556+
writeFileSync(scriptPath, createPostinstallWithIntegrity())
557+
558+
const proc = Bun.spawn(["node", scriptPath], {
559+
cwd: mockProjectDir,
560+
stdout: "pipe",
561+
stderr: "pipe",
562+
})
563+
564+
await proc.exited
565+
566+
const stderr = await new Response(proc.stderr).text()
567+
expect(stderr).toContain("missing-version.md")
568+
expect(stderr).toContain("Frontmatter missing required fields")
569+
expect(stderr).toContain("version")
570+
})
571+
572+
it("should reject files without markdown header after frontmatter", async () => {
573+
// Create a file with frontmatter but no header after it
574+
const contentWithoutHeader = `---
575+
version: 0.1.0
576+
requires: ">=0.1.0"
577+
---
578+
579+
This file has valid frontmatter but no markdown header after it.
580+
It has enough content and contains the word agent and task.
581+
This should fail the validation because it needs a # header.`
434582
writeFileSync(join(agentsSourceDir, "no-header.md"), contentWithoutHeader)
435583

436584
const scriptPath = join(mockProjectDir, "test-postinstall.mjs")
@@ -446,12 +594,17 @@ This should fail the validation because it doesn't start with # symbol.`
446594

447595
const stderr = await new Response(proc.stderr).text()
448596
expect(stderr).toContain("no-header.md")
449-
expect(stderr).toContain("does not start with a markdown header")
597+
expect(stderr).toContain("does not have a markdown header")
450598
})
451599

452600
it("should reject files missing required keywords", async () => {
453-
// Create a file with header and length but missing keywords
454-
const contentWithoutKeywords = `# Valid Header
601+
// Create a file with frontmatter, header, and length but missing keywords
602+
const contentWithoutKeywords = `---
603+
version: 0.1.0
604+
requires: ">=0.1.0"
605+
---
606+
607+
# Valid Header
455608
456609
This file has a valid markdown header and enough content length.
457610
However, it does not contain any of the required keywords.
@@ -565,7 +718,12 @@ This should fail the validation check for missing keywords.`
565718
})
566719

567720
it("should accept keyword 'agent' case-insensitively", async () => {
568-
const contentWithUpperAgent = `# Test File
721+
const contentWithUpperAgent = `---
722+
version: 0.1.0
723+
requires: ">=0.1.0"
724+
---
725+
726+
# Test File
569727
570728
This file contains the word AGENT in uppercase and has enough content.
571729
The validation should accept this because keyword matching is case-insensitive.
@@ -589,7 +747,12 @@ Adding more text to ensure minimum length requirement is satisfied here.`
589747
})
590748

591749
it("should accept keyword 'task' as alternative to 'agent'", async () => {
592-
const contentWithTask = `# Test File
750+
const contentWithTask = `---
751+
version: 0.1.0
752+
requires: ">=0.1.0"
753+
---
754+
755+
# Test File
593756
594757
This file contains the word TASK but not the other keyword.
595758
The validation should accept this because either keyword is sufficient.

0 commit comments

Comments
 (0)