happyskills 0.51.0 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/package.json +1 -1
- package/src/commands/init.js +13 -6
- package/src/commands/release.js +6 -3
- package/src/constants.js +2 -0
- package/src/utils/skill_scanner.js +34 -13
- package/src/utils/skill_scanner.test.js +61 -0
- package/src/utils/yaml_frontmatter.js +175 -0
- package/src/utils/yaml_frontmatter.test.js +110 -0
- package/src/validation/frontmatter_schema.js +107 -0
- package/src/validation/skill_md_rules.js +254 -110
- package/src/validation/skill_md_rules.test.js +350 -11
|
@@ -2,84 +2,231 @@ const path = require('path')
|
|
|
2
2
|
const { error: { catch_errors } } = require('puffy-core')
|
|
3
3
|
const { file_exists, read_file } = require('../utils/fs')
|
|
4
4
|
const { parse_frontmatter } = require('../utils/skill_scanner')
|
|
5
|
-
const {
|
|
5
|
+
const { parse_typed } = require('../utils/yaml_frontmatter')
|
|
6
|
+
const { SKILL_MD, README_MD, SKILL_TYPES } = require('../constants')
|
|
7
|
+
const {
|
|
8
|
+
FRONTMATTER_SCHEMA,
|
|
9
|
+
FORBIDDEN_CHARS,
|
|
10
|
+
KNOWN_FIELDS,
|
|
11
|
+
did_you_mean
|
|
12
|
+
} = require('./frontmatter_schema')
|
|
6
13
|
|
|
7
|
-
const FORBIDDEN_CHARS = {
|
|
8
|
-
';': 'semicolon', ':': 'colon', '#': 'hash', '{': 'left brace', '}': 'right brace',
|
|
9
|
-
'[': 'left bracket', ']': 'right bracket', "'": 'single quote', '"': 'double quote',
|
|
10
|
-
'!': 'exclamation', '&': 'ampersand', '*': 'asterisk', '%': 'percent', '|': 'pipe', '>': 'greater-than'
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/
|
|
14
14
|
const PLACEHOLDER_DESC = 'Describe what this skill does and when to invoke it'
|
|
15
15
|
const DESC_SOFT_CAP = 250
|
|
16
16
|
const DESC_TARGET_MIN = 80
|
|
17
17
|
const DESC_TARGET_MAX = 180
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
// Every result carries enough structured detail for an LLM (or human) to
|
|
20
|
+
// pinpoint and auto-correct: which file, which field, which YAML line, what
|
|
21
|
+
// the validator expected vs. saw, and — for the common fixable cases — the
|
|
22
|
+
// literal corrected line to write back.
|
|
23
|
+
const result = (field, rule, severity, message, value, extras = {}) => ({
|
|
24
|
+
file: SKILL_MD,
|
|
25
|
+
field,
|
|
26
|
+
rule,
|
|
27
|
+
severity,
|
|
28
|
+
message,
|
|
29
|
+
...(value !== undefined ? { value } : {}),
|
|
30
|
+
...extras
|
|
21
31
|
})
|
|
22
32
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const name = fm.name
|
|
33
|
+
// Format a parsed YAML type for an error message ("array", "boolean", etc.).
|
|
34
|
+
const fmt_type = (t) => t === 'block_scalar' ? 'block scalar' : t
|
|
26
35
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
// Schema-driven validation. One pass over every declared field; one pass over
|
|
37
|
+
// every unknown field (with did-you-mean for typos).
|
|
38
|
+
const validate_against_schema = (typed, dir_name) => {
|
|
39
|
+
const results = []
|
|
40
|
+
const fields = typed.fields
|
|
41
|
+
|
|
42
|
+
// Bubble up structural parse errors (lines without a colon, etc.) first —
|
|
43
|
+
// they indicate the frontmatter is malformed enough that later checks may
|
|
44
|
+
// be misleading.
|
|
45
|
+
for (const err of typed.errors) {
|
|
46
|
+
results.push(result(null, 'frontmatter_parse', 'error',
|
|
47
|
+
`SKILL.md line ${err.line}: ${err.message}`,
|
|
48
|
+
undefined,
|
|
49
|
+
{ line: err.line }
|
|
50
|
+
))
|
|
30
51
|
}
|
|
31
52
|
|
|
32
|
-
|
|
53
|
+
// Required + type + per-rule checks for every known field.
|
|
54
|
+
for (const key of KNOWN_FIELDS) {
|
|
55
|
+
const schema = FRONTMATTER_SCHEMA[key]
|
|
56
|
+
const present = key in fields
|
|
57
|
+
|
|
58
|
+
if (!present) {
|
|
59
|
+
if (schema.required) {
|
|
60
|
+
results.push(result(key, 'present', 'error',
|
|
61
|
+
`Frontmatter is missing the required "${key}" field. Add it inside the --- block at the top of SKILL.md.`,
|
|
62
|
+
undefined,
|
|
63
|
+
{ fix: `${key}: <value>` }
|
|
64
|
+
))
|
|
65
|
+
}
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
33
68
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
69
|
+
const f = fields[key]
|
|
70
|
+
|
|
71
|
+
// Type check first — every later rule assumes the type matches.
|
|
72
|
+
if (!schema.types.includes(f.type)) {
|
|
73
|
+
const expected = schema.types.join(' or ')
|
|
74
|
+
const fix_line = f.type === 'array' || f.type === 'object'
|
|
75
|
+
? `${key}: "${f.raw}"`
|
|
76
|
+
: null
|
|
77
|
+
const hint = f.type === 'array'
|
|
78
|
+
? ` — looks like an unquoted "[...]" was interpreted as a YAML list. Quote the value to keep it as a string. Replace line ${f.line} with: ${fix_line}`
|
|
79
|
+
: f.type === 'object'
|
|
80
|
+
? ` — looks like an unquoted "{...}" was interpreted as a YAML map. Quote the value to keep it as a string. Replace line ${f.line} with: ${fix_line}`
|
|
81
|
+
: ''
|
|
82
|
+
results.push(result(
|
|
83
|
+
key,
|
|
84
|
+
'type',
|
|
85
|
+
'error',
|
|
86
|
+
`SKILL.md line ${f.line}: "${key}" must be a ${expected} (got ${fmt_type(f.type)})${hint}`,
|
|
87
|
+
f.raw,
|
|
88
|
+
{ line: f.line, expected, actual: fmt_type(f.type), ...(fix_line ? { fix: fix_line } : {}) }
|
|
89
|
+
))
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
results.push(result(key, 'type', 'pass', `${key}: ${fmt_type(f.type)}`, undefined, { line: f.line }))
|
|
94
|
+
|
|
95
|
+
// String-specific rules.
|
|
96
|
+
if (f.type === 'string') {
|
|
97
|
+
const value = f.value
|
|
98
|
+
|
|
99
|
+
if (schema.non_empty && !value.trim()) {
|
|
100
|
+
results.push(result(key, 'non_empty', 'error',
|
|
101
|
+
`SKILL.md line ${f.line}: "${key}" must not be empty or whitespace-only`,
|
|
102
|
+
undefined,
|
|
103
|
+
{ line: f.line }
|
|
104
|
+
))
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (schema.max_length && value.length > schema.max_length) {
|
|
109
|
+
results.push(result(key, 'max_length', 'error',
|
|
110
|
+
`SKILL.md line ${f.line}: "${key}" is ${value.length} chars (max ${schema.max_length})`,
|
|
111
|
+
value,
|
|
112
|
+
{ line: f.line, length: value.length, max: schema.max_length }
|
|
113
|
+
))
|
|
114
|
+
} else if (schema.max_length) {
|
|
115
|
+
results.push(result(key, 'max_length', 'pass', `${key}: ${value.length} chars (max ${schema.max_length})`, undefined, { line: f.line }))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (schema.pattern && !schema.pattern.test(value)) {
|
|
119
|
+
results.push(result(key, 'format', 'error',
|
|
120
|
+
`SKILL.md line ${f.line}: "${key}" ${schema.pattern_message || 'has invalid format'}. Got: "${value}"`,
|
|
121
|
+
value,
|
|
122
|
+
{ line: f.line }
|
|
123
|
+
))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
127
|
+
results.push(result(key, 'enum', 'warning',
|
|
128
|
+
`SKILL.md line ${f.line}: "${key}" should be one of [${schema.enum.join(', ')}], got "${value}"`,
|
|
129
|
+
value,
|
|
130
|
+
{ line: f.line, expected: schema.enum, actual: value }
|
|
131
|
+
))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Forbidden-chars: always run on plain (unquoted) scalars, since those
|
|
135
|
+
// are the ones that can re-trigger YAML structural parsing in another
|
|
136
|
+
// loader. For quoted scalars, only run if the schema demands it.
|
|
137
|
+
const should_scan = schema.forbidden_chars || (schema.forbidden_chars_in_plain_only && f.quoted === 'plain')
|
|
138
|
+
if (should_scan) {
|
|
139
|
+
let found = false
|
|
140
|
+
for (const [char, ch_name] of Object.entries(FORBIDDEN_CHARS)) {
|
|
141
|
+
const idx = value.indexOf(char)
|
|
142
|
+
if (idx !== -1) {
|
|
143
|
+
results.push(result(key, 'no_forbidden_characters', 'error',
|
|
144
|
+
`SKILL.md line ${f.line}: "${key}" contains forbidden character ${ch_name} ('${char}') at position ${idx} of the value. Rephrase without it.`,
|
|
145
|
+
value,
|
|
146
|
+
{ line: f.line, character: char, character_name: ch_name, char_index: idx }
|
|
147
|
+
))
|
|
148
|
+
found = true
|
|
149
|
+
break
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!found) results.push(result(key, 'no_forbidden_characters', 'pass', 'No forbidden characters', undefined, { line: f.line }))
|
|
153
|
+
}
|
|
154
|
+
}
|
|
38
155
|
}
|
|
39
156
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
157
|
+
// Unknown-field warnings — did-you-mean for likely typos.
|
|
158
|
+
for (const key of Object.keys(fields)) {
|
|
159
|
+
if (KNOWN_FIELDS.includes(key)) continue
|
|
160
|
+
const f = fields[key]
|
|
161
|
+
const suggestion = did_you_mean(key)
|
|
162
|
+
if (suggestion) {
|
|
163
|
+
results.push(result(key, 'unknown_field', 'warning',
|
|
164
|
+
`SKILL.md line ${f.line}: Unknown frontmatter field "${key}" — did you mean "${suggestion}"? Rename the key on line ${f.line}.`,
|
|
165
|
+
undefined,
|
|
166
|
+
{ line: f.line, suggestion, fix: `${suggestion}: ${f.raw}` }
|
|
167
|
+
))
|
|
168
|
+
}
|
|
44
169
|
}
|
|
45
170
|
|
|
171
|
+
return results
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Per-field post-checks that go beyond the schema (rich recommendations,
|
|
175
|
+
// name↔dir cross-check, placeholder detection, description soft cap).
|
|
176
|
+
const validate_name_extras = (fields, dir_name) => {
|
|
177
|
+
const results = []
|
|
178
|
+
const f = fields.name
|
|
179
|
+
if (!f || f.type !== 'string') return results
|
|
180
|
+
const name = f.value
|
|
181
|
+
|
|
46
182
|
if (name.includes('--')) {
|
|
47
|
-
results.push(result('name', 'no_consecutive_hyphens', 'error',
|
|
183
|
+
results.push(result('name', 'no_consecutive_hyphens', 'error',
|
|
184
|
+
`SKILL.md line ${f.line}: Name "${name}" must not contain consecutive hyphens (--)`,
|
|
185
|
+
name,
|
|
186
|
+
{ line: f.line }
|
|
187
|
+
))
|
|
48
188
|
}
|
|
49
|
-
|
|
50
189
|
if (name.startsWith('-') || name.endsWith('-')) {
|
|
51
|
-
results.push(result('name', 'no_edge_hyphens', 'error',
|
|
190
|
+
results.push(result('name', 'no_edge_hyphens', 'error',
|
|
191
|
+
`SKILL.md line ${f.line}: Name "${name}" must not start or end with a hyphen`,
|
|
192
|
+
name,
|
|
193
|
+
{ line: f.line }
|
|
194
|
+
))
|
|
52
195
|
}
|
|
53
|
-
|
|
54
196
|
if (dir_name && name !== dir_name) {
|
|
55
|
-
results.push(result('name', 'matches_directory', 'warning',
|
|
197
|
+
results.push(result('name', 'matches_directory', 'warning',
|
|
198
|
+
`SKILL.md line ${f.line}: name "${name}" does not match directory "${dir_name}". Rename one so they match.`,
|
|
199
|
+
name,
|
|
200
|
+
{ line: f.line, expected: dir_name, actual: name, fix: `name: ${dir_name}` }
|
|
201
|
+
))
|
|
56
202
|
} else if (dir_name) {
|
|
57
|
-
results.push(result('name', 'matches_directory', 'pass', `Name matches directory "${dir_name}"`, name))
|
|
203
|
+
results.push(result('name', 'matches_directory', 'pass', `Name matches directory "${dir_name}"`, name, { line: f.line }))
|
|
58
204
|
}
|
|
59
205
|
|
|
60
206
|
return results
|
|
61
207
|
}
|
|
62
208
|
|
|
63
|
-
const
|
|
209
|
+
const validate_description_extras = (fields) => {
|
|
64
210
|
const results = []
|
|
65
|
-
const
|
|
211
|
+
const f = fields.description
|
|
212
|
+
if (!f || f.type !== 'string') return results
|
|
213
|
+
const desc = f.value
|
|
66
214
|
|
|
67
|
-
if (
|
|
68
|
-
results.push(result('description', '
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (!desc.trim()) {
|
|
75
|
-
results.push(result('description', 'non_empty', 'error', 'Description must not be empty or whitespace-only', desc))
|
|
76
|
-
return results
|
|
215
|
+
if (desc === PLACEHOLDER_DESC) {
|
|
216
|
+
results.push(result('description', 'not_placeholder', 'warning',
|
|
217
|
+
`SKILL.md line ${f.line}: description is still the init placeholder — update it before publishing`,
|
|
218
|
+
desc,
|
|
219
|
+
{ line: f.line }
|
|
220
|
+
))
|
|
77
221
|
}
|
|
78
222
|
|
|
79
223
|
if (desc.length > 1024) {
|
|
80
|
-
const deficit = desc.length - 1024
|
|
81
224
|
results.push({
|
|
82
|
-
...result('description', '
|
|
225
|
+
...result('description', 'max_length_advice', 'error',
|
|
226
|
+
`SKILL.md line ${f.line}: description is ${desc.length} chars (max 1024). Must reduce by ${desc.length - 1024} chars.`,
|
|
227
|
+
desc,
|
|
228
|
+
{ line: f.line, length: desc.length, max: 1024 }
|
|
229
|
+
),
|
|
83
230
|
recommendations: [
|
|
84
231
|
'STEP 1 - AUDIT: Before changing anything, read the skill\'s routing table or capability list. Map each phrase in the description to the capability it triggers. Mark each phrase as: IDENTITY (describes what the skill is, usually one phrase), UNIQUE (the only phrase matching a specific capability), or REINFORCING (overlaps with another phrase\'s coverage).',
|
|
85
232
|
'STEP 2 - LOSSLESS COMPRESSION: Apply these transformations that reduce characters without changing semantic meaning: (a) Remove articles (a, an, the). (b) Remove possessives (my, your) when the subject is implied. (c) Remove filler verbs (do, does, can, have, is, am). (d) Merge parallel structures that share the same verb (e.g., \'install kit. publish kit\' becomes \'install, publish kits\') or the same object (e.g., \'find kits, search kits\' becomes \'find, search kits\'). Stop here if now under the limit.',
|
|
@@ -90,79 +237,75 @@ const validate_description = (fm) => {
|
|
|
90
237
|
'STEP 4 - VERIFY: Cross-check the shortened description against the skill\'s routing table or capability list. Every documented capability must still have at least one semantically matching phrase in the description. If any capability lost coverage, restore its trigger phrase and find different savings.'
|
|
91
238
|
]
|
|
92
239
|
})
|
|
240
|
+
} else if (desc.length <= DESC_SOFT_CAP) {
|
|
241
|
+
results.push(result('description', 'soft_cap', 'pass', `Description: ${desc.length} chars (soft cap ${DESC_SOFT_CAP})`, undefined, { line: f.line }))
|
|
93
242
|
} else {
|
|
94
|
-
results.push(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
'For the canonical Constellation Pattern reference (orthogonal verb ownership, the five-slot description grammar, failure modes, orthogonality test): happyskills-design references/constellation-pattern.md, or docs/cli-skill.md in the HappySkills repo.'
|
|
104
|
-
]
|
|
105
|
-
})
|
|
106
|
-
} else {
|
|
107
|
-
results.push(result('description', 'soft_cap', 'pass', `Description: ${desc.length} chars (soft cap ${DESC_SOFT_CAP})`))
|
|
108
|
-
}
|
|
243
|
+
results.push({
|
|
244
|
+
...result('description', 'soft_cap', 'warning', `SKILL.md line ${f.line}: description is ${desc.length} chars (target: ${DESC_TARGET_MIN}-${DESC_TARGET_MAX}, soft cap: ${DESC_SOFT_CAP}). Above ${DESC_SOFT_CAP} chars usually signals a mega-skill — apply the Constellation Pattern to decompose it into a core skill plus focused satellites. Run "npx happyskills audit <name>" or ask your agent "audit this skill" — happyskills-design will walk you through the Constellation Decomposition Workflow.`, desc),
|
|
245
|
+
recommendations: [
|
|
246
|
+
'CANONICAL — Apply the Constellation Pattern: decompose the skill into a core entry-point skill plus satellite skills, each owning one orthogonal verb cluster, bundled via the core skill.json dependencies. This is the answer once a description crosses the soft cap. happyskills-design implements the Constellation Decomposition Workflow end-to-end — invoke it with "audit this skill" or "decompose this mega-skill".',
|
|
247
|
+
'Alternative — Compress the description first (AUDIT/LOSSLESS/LOSSY procedure in happyskills-design references/skill-authoring.md). Buys time, but compression alone will not keep up as the API surface grows.',
|
|
248
|
+
'Alternative — Hybrid umbrella+satellites: keep one main skill for high-frequency operations, extract specialized domains into satellites.',
|
|
249
|
+
'For the canonical Constellation Pattern reference (orthogonal verb ownership, the five-slot description grammar, failure modes, orthogonality test): happyskills-design references/constellation-pattern.md, or docs/cli-skill.md in the HappySkills repo.'
|
|
250
|
+
]
|
|
251
|
+
})
|
|
109
252
|
}
|
|
110
253
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
254
|
+
return results
|
|
255
|
+
}
|
|
114
256
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
257
|
+
const validate_agent_requires_fork = (fields) => {
|
|
258
|
+
const results = []
|
|
259
|
+
if (fields.agent === undefined) return results
|
|
260
|
+
const context = fields.context
|
|
261
|
+
const agent_f = fields.agent
|
|
262
|
+
if (!context || context.value !== 'fork') {
|
|
263
|
+
results.push(result('agent', 'requires_context', 'warning',
|
|
264
|
+
`SKILL.md line ${agent_f.line}: agent is set but context is not "fork" — agent skills require "context: fork". Add (or change) the context line.`,
|
|
265
|
+
undefined,
|
|
266
|
+
{ line: agent_f.line, fix: 'context: fork' }
|
|
267
|
+
))
|
|
120
268
|
}
|
|
121
|
-
|
|
122
|
-
results.push(result('description', 'no_forbidden_characters', 'pass', 'No forbidden characters'))
|
|
123
269
|
return results
|
|
124
270
|
}
|
|
125
271
|
|
|
126
|
-
const
|
|
127
|
-
const
|
|
272
|
+
const validate_skill_md = (skill_dir, dir_name, skill_type) => catch_errors('Failed to validate SKILL.md', async () => {
|
|
273
|
+
const is_kit = skill_type === SKILL_TYPES.KIT
|
|
128
274
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
275
|
+
// Kits have no SKILL.md at all — they ship a README.md describing the
|
|
276
|
+
// bundle. Validate that branch separately so the file-label, error
|
|
277
|
+
// messages, and skipped checks all reflect the kit format.
|
|
278
|
+
if (is_kit) {
|
|
279
|
+
const readme_path = path.join(skill_dir, README_MD)
|
|
280
|
+
const skill_md_path = path.join(skill_dir, SKILL_MD)
|
|
281
|
+
const kit_result = (field, rule, severity, message, value) => ({
|
|
282
|
+
file: README_MD, field, rule, severity, message, ...(value !== undefined ? { value } : {})
|
|
283
|
+
})
|
|
284
|
+
const kit_results = []
|
|
285
|
+
const [, readme_exists] = await file_exists(readme_path)
|
|
286
|
+
if (!readme_exists) {
|
|
287
|
+
kit_results.push(kit_result(null, 'exists', 'error', 'README.md not found (required for kits)'))
|
|
288
|
+
return { results: kit_results, frontmatter: null, content: null }
|
|
134
289
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
290
|
+
kit_results.push(kit_result(null, 'exists', 'pass', 'README.md exists'))
|
|
291
|
+
|
|
292
|
+
// Kits must NOT contain a SKILL.md — its presence would re-expose
|
|
293
|
+
// the kit to agent auto-invocation and re-trigger the frontmatter
|
|
294
|
+
// warnings in Codex/Gemini that the README.md format exists to avoid.
|
|
295
|
+
const [, skill_md_exists] = await file_exists(skill_md_path)
|
|
296
|
+
if (skill_md_exists) {
|
|
297
|
+
kit_results.push({
|
|
298
|
+
file: SKILL_MD, field: null, rule: 'not_in_kit', severity: 'error',
|
|
299
|
+
message: 'Kits must not contain a SKILL.md — delete it. Kit documentation belongs in README.md.'
|
|
300
|
+
})
|
|
146
301
|
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (fm.context !== undefined && fm.context !== 'fork') {
|
|
150
|
-
results.push(result('context', 'value', 'warning', `context should be "fork", got "${fm.context}"`, fm.context))
|
|
151
|
-
} else if (fm.context !== undefined) {
|
|
152
|
-
results.push(result('context', 'value', 'pass', 'context: fork'))
|
|
153
|
-
}
|
|
154
302
|
|
|
155
|
-
|
|
156
|
-
results
|
|
303
|
+
const [, readme_content] = await read_file(readme_path)
|
|
304
|
+
return { results: kit_results, frontmatter: null, content: readme_content }
|
|
157
305
|
}
|
|
158
306
|
|
|
159
|
-
return results
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const validate_skill_md = (skill_dir, dir_name, skill_type) => catch_errors('Failed to validate SKILL.md', async () => {
|
|
163
307
|
const results = []
|
|
164
308
|
const md_path = path.join(skill_dir, SKILL_MD)
|
|
165
|
-
const is_kit = skill_type === SKILL_TYPES.KIT
|
|
166
309
|
|
|
167
310
|
const [, exists] = await file_exists(md_path)
|
|
168
311
|
if (!exists) {
|
|
@@ -174,22 +317,23 @@ const validate_skill_md = (skill_dir, dir_name, skill_type) => catch_errors('Fai
|
|
|
174
317
|
|
|
175
318
|
const [, content] = await read_file(md_path)
|
|
176
319
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
320
|
+
// Two parses: the legacy plain-string parser keeps downstream callers
|
|
321
|
+
// (cross-rule validation, convert, etc.) working unchanged, while the
|
|
322
|
+
// typed parser is what the schema runs against.
|
|
181
323
|
const fm = parse_frontmatter(content)
|
|
324
|
+
const typed = parse_typed(content)
|
|
182
325
|
|
|
183
|
-
if (!
|
|
326
|
+
if (!typed) {
|
|
184
327
|
results.push(result(null, 'frontmatter', 'error', 'SKILL.md has no YAML frontmatter (---)'))
|
|
185
328
|
return { results, frontmatter: null, content }
|
|
186
329
|
}
|
|
187
330
|
|
|
188
331
|
results.push(result(null, 'frontmatter', 'pass', 'Frontmatter present'))
|
|
189
332
|
|
|
190
|
-
results.push(...
|
|
191
|
-
results.push(...
|
|
192
|
-
results.push(...
|
|
333
|
+
results.push(...validate_against_schema(typed, dir_name))
|
|
334
|
+
results.push(...validate_name_extras(typed.fields, dir_name))
|
|
335
|
+
results.push(...validate_description_extras(typed.fields))
|
|
336
|
+
results.push(...validate_agent_requires_fork(typed.fields))
|
|
193
337
|
|
|
194
338
|
const line_count = content.split('\n').length
|
|
195
339
|
if (line_count >= 500) {
|