Skip to content

Commit 5cb8791

Browse files
committed
refactor: extract validateAgentContent to shared paths.mjs
Signed-off-by: leocavalcante <[email protected]>
1 parent 704a86b commit 5cb8791

File tree

4 files changed

+425
-120
lines changed

4 files changed

+425
-120
lines changed

postinstall.mjs

Lines changed: 6 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getAgentsSourceDir,
1616
getErrorMessage,
1717
getPackageRoot,
18+
validateAgentContent,
1819
} from "./src/paths.mjs"
1920

2021
const packageRoot = getPackageRoot(import.meta.url)
@@ -36,130 +37,15 @@ function verbose(message) {
3637
}
3738
}
3839

39-
/** Minimum character count for valid agent files */
40-
const MIN_CONTENT_LENGTH = 100
41-
42-
/** Keywords that should appear in valid agent files (case-insensitive) */
43-
const REQUIRED_KEYWORDS = ["agent", "task"]
44-
45-
/** Required fields in YAML frontmatter */
46-
const REQUIRED_FRONTMATTER_FIELDS = ["version", "requires"]
47-
48-
/**
49-
* Parses YAML frontmatter from markdown content.
50-
*
51-
* Expects frontmatter to be delimited by --- at the start of the file.
52-
*
53-
* @param {string} content - The file content to parse
54-
* @returns {{ found: boolean, fields: Record<string, string>, endIndex: number }} Parse result
55-
*/
56-
function parseFrontmatter(content) {
57-
// Frontmatter must start at the beginning of the file
58-
if (!content.startsWith("---")) {
59-
return { found: false, fields: {}, endIndex: 0 }
60-
}
61-
62-
// Find the closing ---
63-
const endMatch = content.indexOf("\n---", 3)
64-
if (endMatch === -1) {
65-
return { found: false, fields: {}, endIndex: 0 }
66-
}
67-
68-
// Extract frontmatter content (between the --- delimiters)
69-
const frontmatterContent = content.slice(4, endMatch)
70-
const fields = {}
71-
72-
// Parse simple key: value pairs
73-
for (const line of frontmatterContent.split("\n")) {
74-
const trimmed = line.trim()
75-
if (!trimmed || trimmed.startsWith("#")) continue
76-
77-
const colonIndex = trimmed.indexOf(":")
78-
if (colonIndex === -1) continue
79-
80-
const key = trimmed.slice(0, colonIndex).trim()
81-
let value = trimmed.slice(colonIndex + 1).trim()
82-
83-
// Remove surrounding quotes if present
84-
if (
85-
(value.startsWith('"') && value.endsWith('"')) ||
86-
(value.startsWith("'") && value.endsWith("'"))
87-
) {
88-
value = value.slice(1, -1)
89-
}
90-
91-
fields[key] = value
92-
}
93-
94-
// endIndex points to the character after the closing ---\n
95-
const endIndex = endMatch + 4
96-
97-
return { found: true, fields, endIndex }
98-
}
99-
10040
/**
101-
* Validates that an agent file has valid content structure.
102-
*
103-
* Checks that the file:
104-
* 1. Has YAML frontmatter with required fields (version, requires)
105-
* 2. Starts with a markdown header (# ) after frontmatter
106-
* 3. Contains at least MIN_CONTENT_LENGTH characters
107-
* 4. Contains at least one of the expected keywords
41+
* Validates an agent file by reading and validating its content.
10842
*
10943
* @param {string} filePath - Path to the agent file to validate
11044
* @returns {{ valid: boolean, error?: string }} Validation result with optional error message
11145
*/
112-
function validateAgentContent(filePath) {
46+
function validateAgentFile(filePath) {
11347
const content = readFileSync(filePath, "utf-8")
114-
115-
// Check minimum length
116-
if (content.length < MIN_CONTENT_LENGTH) {
117-
return {
118-
valid: false,
119-
error: `File too short: ${content.length} characters (minimum ${MIN_CONTENT_LENGTH})`,
120-
}
121-
}
122-
123-
// Check for YAML frontmatter
124-
const frontmatter = parseFrontmatter(content)
125-
if (!frontmatter.found) {
126-
return {
127-
valid: false,
128-
error: "File missing YAML frontmatter (must start with ---)",
129-
}
130-
}
131-
132-
// Check for required frontmatter fields
133-
const missingFields = REQUIRED_FRONTMATTER_FIELDS.filter((field) => !frontmatter.fields[field])
134-
if (missingFields.length > 0) {
135-
return {
136-
valid: false,
137-
error: `Frontmatter missing required fields: ${missingFields.join(", ")}`,
138-
}
139-
}
140-
141-
// Get content after frontmatter
142-
const contentAfterFrontmatter = content.slice(frontmatter.endIndex).trimStart()
143-
144-
// Check for markdown header after frontmatter
145-
if (!contentAfterFrontmatter.startsWith("# ")) {
146-
return {
147-
valid: false,
148-
error: "File does not have a markdown header (# ) after frontmatter",
149-
}
150-
}
151-
152-
// Check for required keywords (case-insensitive)
153-
const lowerContent = content.toLowerCase()
154-
const hasKeyword = REQUIRED_KEYWORDS.some((keyword) => lowerContent.includes(keyword))
155-
if (!hasKeyword) {
156-
return {
157-
valid: false,
158-
error: `File missing required keywords: ${REQUIRED_KEYWORDS.join(", ")}`,
159-
}
160-
}
161-
162-
return { valid: true }
48+
return validateAgentContent(content)
16349
}
16450

16551
/**
@@ -236,7 +122,7 @@ function main() {
236122
if (DRY_RUN) {
237123
// In dry-run mode, validate source file but don't copy
238124
verbose(` Validating source file (dry-run mode)...`)
239-
const validation = validateAgentContent(sourcePath)
125+
const validation = validateAgentFile(sourcePath)
240126
if (!validation.valid) {
241127
throw new Error(`Invalid agent file content: ${validation.error}`)
242128
}
@@ -262,7 +148,7 @@ function main() {
262148

263149
// Validate content structure
264150
verbose(` Validating content structure...`)
265-
const validation = validateAgentContent(targetPath)
151+
const validation = validateAgentFile(targetPath)
266152
if (!validation.valid) {
267153
throw new Error(`Invalid agent file content: ${validation.error}`)
268154
}

src/paths.d.mts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22
* Type declarations for paths.mjs
33
*/
44

5+
/** Minimum character count for valid agent files */
6+
export declare const MIN_CONTENT_LENGTH: number
7+
8+
/** Keywords that should appear in valid agent files (case-insensitive) */
9+
export declare const REQUIRED_KEYWORDS: string[]
10+
11+
/** Required fields in YAML frontmatter */
12+
export declare const REQUIRED_FRONTMATTER_FIELDS: string[]
13+
514
/**
615
* Get the package root directory from a module's import.meta.url
716
*/
@@ -25,3 +34,38 @@ export function getErrorMessage(
2534
file: string,
2635
targetPath: string,
2736
): string
37+
38+
/**
39+
* Result of parsing YAML frontmatter from markdown content.
40+
*/
41+
export interface ParseFrontmatterResult {
42+
found: boolean
43+
fields: Record<string, string>
44+
endIndex: number
45+
}
46+
47+
/**
48+
* Parses YAML frontmatter from markdown content.
49+
*
50+
* Expects frontmatter to be delimited by --- at the start of the file.
51+
*/
52+
export function parseFrontmatter(content: string): ParseFrontmatterResult
53+
54+
/**
55+
* Result of validating agent content.
56+
*/
57+
export interface ValidateAgentContentResult {
58+
valid: boolean
59+
error?: string
60+
}
61+
62+
/**
63+
* Validates that agent content has a valid structure.
64+
*
65+
* Checks that the content:
66+
* 1. Has YAML frontmatter with required fields (version, requires)
67+
* 2. Starts with a markdown header (# ) after frontmatter
68+
* 3. Contains at least MIN_CONTENT_LENGTH characters
69+
* 4. Contains at least one of the expected keywords
70+
*/
71+
export function validateAgentContent(content: string): ValidateAgentContentResult

src/paths.mjs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import { homedir } from "node:os"
99
import { dirname, join } from "node:path"
1010
import { fileURLToPath } from "node:url"
1111

12+
/** Minimum character count for valid agent files */
13+
export const MIN_CONTENT_LENGTH = 100
14+
15+
/** Keywords that should appear in valid agent files (case-insensitive) */
16+
export const REQUIRED_KEYWORDS = ["agent", "task"]
17+
18+
/** Required fields in YAML frontmatter */
19+
export const REQUIRED_FRONTMATTER_FIELDS = ["version", "requires"]
20+
1221
/**
1322
* Get the package root directory from a module's import.meta.url
1423
* @param {string} importMetaUrl - The import.meta.url of the calling module
@@ -83,3 +92,118 @@ export function getErrorMessage(error, file, targetPath) {
8392
return error.message || "Unknown error"
8493
}
8594
}
95+
96+
/**
97+
* Parses YAML frontmatter from markdown content.
98+
*
99+
* Expects frontmatter to be delimited by --- at the start of the file.
100+
*
101+
* @param {string} content - The file content to parse
102+
* @returns {{ found: boolean, fields: Record<string, string>, endIndex: number }} Parse result
103+
*/
104+
export function parseFrontmatter(content) {
105+
// Frontmatter must start at the beginning of the file
106+
if (!content.startsWith("---")) {
107+
return { found: false, fields: {}, endIndex: 0 }
108+
}
109+
110+
// Find the closing ---
111+
const endMatch = content.indexOf("\n---", 3)
112+
if (endMatch === -1) {
113+
return { found: false, fields: {}, endIndex: 0 }
114+
}
115+
116+
// Extract frontmatter content (between the --- delimiters)
117+
const frontmatterContent = content.slice(4, endMatch)
118+
const fields = {}
119+
120+
// Parse simple key: value pairs
121+
for (const line of frontmatterContent.split("\n")) {
122+
const trimmed = line.trim()
123+
if (!trimmed || trimmed.startsWith("#")) continue
124+
125+
const colonIndex = trimmed.indexOf(":")
126+
if (colonIndex === -1) continue
127+
128+
const key = trimmed.slice(0, colonIndex).trim()
129+
let value = trimmed.slice(colonIndex + 1).trim()
130+
131+
// Remove surrounding quotes if present
132+
if (
133+
(value.startsWith('"') && value.endsWith('"')) ||
134+
(value.startsWith("'") && value.endsWith("'"))
135+
) {
136+
value = value.slice(1, -1)
137+
}
138+
139+
fields[key] = value
140+
}
141+
142+
// endIndex points to the character after the closing ---\n
143+
const endIndex = endMatch + 4
144+
145+
return { found: true, fields, endIndex }
146+
}
147+
148+
/**
149+
* Validates that agent content has a valid structure.
150+
*
151+
* Checks that the content:
152+
* 1. Has YAML frontmatter with required fields (version, requires)
153+
* 2. Starts with a markdown header (# ) after frontmatter
154+
* 3. Contains at least MIN_CONTENT_LENGTH characters
155+
* 4. Contains at least one of the expected keywords
156+
*
157+
* @param {string} content - The agent file content to validate
158+
* @returns {{ valid: boolean, error?: string }} Validation result with optional error message
159+
*/
160+
export function validateAgentContent(content) {
161+
// Check minimum length
162+
if (content.length < MIN_CONTENT_LENGTH) {
163+
return {
164+
valid: false,
165+
error: `File too short: ${content.length} characters (minimum ${MIN_CONTENT_LENGTH})`,
166+
}
167+
}
168+
169+
// Check for YAML frontmatter
170+
const frontmatter = parseFrontmatter(content)
171+
if (!frontmatter.found) {
172+
return {
173+
valid: false,
174+
error: "File missing YAML frontmatter (must start with ---)",
175+
}
176+
}
177+
178+
// Check for required frontmatter fields
179+
const missingFields = REQUIRED_FRONTMATTER_FIELDS.filter((field) => !frontmatter.fields[field])
180+
if (missingFields.length > 0) {
181+
return {
182+
valid: false,
183+
error: `Frontmatter missing required fields: ${missingFields.join(", ")}`,
184+
}
185+
}
186+
187+
// Get content after frontmatter
188+
const contentAfterFrontmatter = content.slice(frontmatter.endIndex).trimStart()
189+
190+
// Check for markdown header after frontmatter
191+
if (!contentAfterFrontmatter.startsWith("# ")) {
192+
return {
193+
valid: false,
194+
error: "File does not have a markdown header (# ) after frontmatter",
195+
}
196+
}
197+
198+
// Check for required keywords (case-insensitive)
199+
const lowerContent = content.toLowerCase()
200+
const hasKeyword = REQUIRED_KEYWORDS.some((keyword) => lowerContent.includes(keyword))
201+
if (!hasKeyword) {
202+
return {
203+
valid: false,
204+
error: `File missing required keywords: ${REQUIRED_KEYWORDS.join(", ")}`,
205+
}
206+
}
207+
208+
return { valid: true }
209+
}

0 commit comments

Comments
 (0)