happyskills 0.7.3 → 0.9.0
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 +20 -0
- package/package.json +1 -1
- package/src/api/repos.js +7 -1
- package/src/commands/check.js +18 -21
- package/src/commands/convert.js +24 -3
- package/src/commands/init.js +5 -4
- package/src/commands/publish.js +33 -3
- package/src/commands/refresh.js +170 -0
- package/src/commands/validate.js +148 -0
- package/src/constants.js +5 -1
- package/src/index.js +2 -0
- package/src/integration/cli.test.js +151 -1
- package/src/validation/cross_rules.js +76 -0
- package/src/validation/cross_rules.test.js +88 -0
- package/src/validation/skill_json_rules.js +202 -0
- package/src/validation/skill_json_rules.test.js +239 -0
- package/src/validation/skill_md_rules.js +169 -0
- package/src/validation/skill_md_rules.test.js +201 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const { validate_skill_json } = require('./skill_json_rules')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-validate-json-'))
|
|
11
|
+
|
|
12
|
+
const write_json = (dir, data) => {
|
|
13
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify(data, null, '\t'))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let tmp
|
|
17
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
18
|
+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }) })
|
|
19
|
+
|
|
20
|
+
describe('validate_skill_json — existence', () => {
|
|
21
|
+
it('errors when skill.json is missing', async () => {
|
|
22
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
23
|
+
assert.ifError(err)
|
|
24
|
+
const check = data.results.find(r => r.rule === 'exists')
|
|
25
|
+
assert.strictEqual(check.severity, 'error')
|
|
26
|
+
assert.strictEqual(data.manifest, null)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('errors for invalid JSON', async () => {
|
|
30
|
+
fs.writeFileSync(path.join(tmp, 'skill.json'), '{ bad json }')
|
|
31
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
32
|
+
assert.ifError(err)
|
|
33
|
+
const check = data.results.find(r => r.rule === 'valid_json')
|
|
34
|
+
assert.strictEqual(check.severity, 'error')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('passes for valid JSON', async () => {
|
|
38
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0' })
|
|
39
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
40
|
+
assert.ifError(err)
|
|
41
|
+
assert.strictEqual(data.results.find(r => r.rule === 'exists').severity, 'pass')
|
|
42
|
+
assert.strictEqual(data.results.find(r => r.rule === 'valid_json').severity, 'pass')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('validate_skill_json — name', () => {
|
|
47
|
+
it('errors when name is missing', async () => {
|
|
48
|
+
write_json(tmp, { version: '1.0.0' })
|
|
49
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
50
|
+
assert.ifError(err)
|
|
51
|
+
const check = data.results.find(r => r.field === 'name' && r.rule === 'present')
|
|
52
|
+
assert.strictEqual(check.severity, 'error')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('errors for invalid name format', async () => {
|
|
56
|
+
write_json(tmp, { name: 'MySkill', version: '1.0.0' })
|
|
57
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
58
|
+
assert.ifError(err)
|
|
59
|
+
const check = data.results.find(r => r.field === 'name' && r.rule === 'format')
|
|
60
|
+
assert.strictEqual(check.severity, 'error')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('passes for valid name', async () => {
|
|
64
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0' })
|
|
65
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
66
|
+
assert.ifError(err)
|
|
67
|
+
const checks = data.results.filter(r => r.field === 'name')
|
|
68
|
+
assert.ok(checks.every(r => r.severity === 'pass'))
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('validate_skill_json — version', () => {
|
|
73
|
+
it('errors when version is missing', async () => {
|
|
74
|
+
write_json(tmp, { name: 'my-skill' })
|
|
75
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
76
|
+
assert.ifError(err)
|
|
77
|
+
const check = data.results.find(r => r.field === 'version' && r.rule === 'present')
|
|
78
|
+
assert.strictEqual(check.severity, 'error')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('errors for invalid semver', async () => {
|
|
82
|
+
write_json(tmp, { name: 'my-skill', version: 'abc' })
|
|
83
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
84
|
+
assert.ifError(err)
|
|
85
|
+
const check = data.results.find(r => r.rule === 'valid_semver')
|
|
86
|
+
assert.strictEqual(check.severity, 'error')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('passes for valid semver', async () => {
|
|
90
|
+
write_json(tmp, { name: 'my-skill', version: '2.1.0' })
|
|
91
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
92
|
+
assert.ifError(err)
|
|
93
|
+
const check = data.results.find(r => r.rule === 'valid_semver')
|
|
94
|
+
assert.strictEqual(check.severity, 'pass')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('validate_skill_json — description', () => {
|
|
99
|
+
it('warns when description is missing', async () => {
|
|
100
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0' })
|
|
101
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
102
|
+
assert.ifError(err)
|
|
103
|
+
const check = data.results.find(r => r.field === 'description' && r.rule === 'recommended_field')
|
|
104
|
+
assert.strictEqual(check.severity, 'warning')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('warns when description exceeds 200 chars', async () => {
|
|
108
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', description: 'a'.repeat(201) })
|
|
109
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
110
|
+
assert.ifError(err)
|
|
111
|
+
const check = data.results.find(r => r.field === 'description' && r.rule === 'max_length')
|
|
112
|
+
assert.strictEqual(check.severity, 'warning')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('passes for valid description', async () => {
|
|
116
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', description: 'A great skill' })
|
|
117
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
118
|
+
assert.ifError(err)
|
|
119
|
+
const check = data.results.find(r => r.field === 'description' && r.rule === 'recommended_field')
|
|
120
|
+
assert.strictEqual(check.severity, 'pass')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('validate_skill_json — keywords', () => {
|
|
125
|
+
it('warns when keywords are missing', async () => {
|
|
126
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0' })
|
|
127
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
128
|
+
assert.ifError(err)
|
|
129
|
+
const check = data.results.find(r => r.field === 'keywords' && r.rule === 'recommended_field')
|
|
130
|
+
assert.strictEqual(check.severity, 'warning')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('warns when no canonical slug is present', async () => {
|
|
134
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', keywords: ['custom'] })
|
|
135
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
136
|
+
assert.ifError(err)
|
|
137
|
+
const check = data.results.find(r => r.rule === 'canonical_slug')
|
|
138
|
+
assert.strictEqual(check.severity, 'warning')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('passes when a canonical slug is present', async () => {
|
|
142
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', keywords: ['deployment', 'custom'] })
|
|
143
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
144
|
+
assert.ifError(err)
|
|
145
|
+
const check = data.results.find(r => r.rule === 'canonical_slug')
|
|
146
|
+
assert.strictEqual(check.severity, 'pass')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('validate_skill_json — dependencies', () => {
|
|
151
|
+
it('errors when dependencies is an array', async () => {
|
|
152
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', dependencies: ['acme/deploy'] })
|
|
153
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
154
|
+
assert.ifError(err)
|
|
155
|
+
const check = data.results.find(r => r.field === 'dependencies' && r.rule === 'type')
|
|
156
|
+
assert.strictEqual(check.severity, 'error')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('errors when dependency key has no slash', async () => {
|
|
160
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', dependencies: { 'deploy': '^1.0.0' } })
|
|
161
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
162
|
+
assert.ifError(err)
|
|
163
|
+
const check = data.results.find(r => r.rule === 'key_format')
|
|
164
|
+
assert.strictEqual(check.severity, 'error')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('errors when dependency value is not a string', async () => {
|
|
168
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/deploy': 123 } })
|
|
169
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
170
|
+
assert.ifError(err)
|
|
171
|
+
const check = data.results.find(r => r.rule === 'value_type')
|
|
172
|
+
assert.strictEqual(check.severity, 'error')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('passes for valid dependencies', async () => {
|
|
176
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', dependencies: { 'acme/deploy': '^1.0.0' } })
|
|
177
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
178
|
+
assert.ifError(err)
|
|
179
|
+
assert.ok(!data.results.some(r => r.field === 'dependencies' && r.severity === 'error'))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('skips checks when dependencies is omitted', async () => {
|
|
183
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0' })
|
|
184
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
185
|
+
assert.ifError(err)
|
|
186
|
+
assert.ok(!data.results.some(r => r.field === 'dependencies'))
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('validate_skill_json — systemDependencies', () => {
|
|
191
|
+
it('errors when systemDependencies is an array', async () => {
|
|
192
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', systemDependencies: [] })
|
|
193
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
194
|
+
assert.ifError(err)
|
|
195
|
+
const check = data.results.find(r => r.field === 'systemDependencies' && r.rule === 'type')
|
|
196
|
+
assert.strictEqual(check.severity, 'error')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('warns when entry is missing required fields', async () => {
|
|
200
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', systemDependencies: { docker: { description: 'Docker' } } })
|
|
201
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
202
|
+
assert.ifError(err)
|
|
203
|
+
const check = data.results.find(r => r.rule === 'entry_structure')
|
|
204
|
+
assert.strictEqual(check.severity, 'warning')
|
|
205
|
+
assert.ok(check.message.includes('check'))
|
|
206
|
+
assert.ok(check.message.includes('install'))
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('warns when install is missing platforms', async () => {
|
|
210
|
+
write_json(tmp, {
|
|
211
|
+
name: 'my-skill', version: '1.0.0',
|
|
212
|
+
systemDependencies: {
|
|
213
|
+
docker: { description: 'Docker', check: 'docker --version', install: { darwin: 'brew install docker' } }
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
217
|
+
assert.ifError(err)
|
|
218
|
+
const check = data.results.find(r => r.rule === 'platform_coverage')
|
|
219
|
+
assert.strictEqual(check.severity, 'warning')
|
|
220
|
+
assert.ok(check.message.includes('linux'))
|
|
221
|
+
assert.ok(check.message.includes('win32'))
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('passes when all platforms are covered', async () => {
|
|
225
|
+
write_json(tmp, {
|
|
226
|
+
name: 'my-skill', version: '1.0.0',
|
|
227
|
+
systemDependencies: {
|
|
228
|
+
docker: {
|
|
229
|
+
description: 'Docker', check: 'docker --version',
|
|
230
|
+
install: { darwin: 'brew install docker', linux: 'apt install docker', win32: 'choco install docker' }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
235
|
+
assert.ifError(err)
|
|
236
|
+
const check = data.results.find(r => r.rule === 'platform_coverage')
|
|
237
|
+
assert.strictEqual(check.severity, 'pass')
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
+
const { file_exists, read_file } = require('../utils/fs')
|
|
4
|
+
const { parse_frontmatter } = require('../utils/skill_scanner')
|
|
5
|
+
const { SKILL_MD } = require('../constants')
|
|
6
|
+
|
|
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
|
+
const PLACEHOLDER_DESC = 'Describe what this skill does and when to invoke it'
|
|
15
|
+
|
|
16
|
+
const result = (field, rule, severity, message, value) => ({
|
|
17
|
+
file: SKILL_MD, field, rule, severity, message, ...(value !== undefined ? { value } : {})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const validate_name = (fm, dir_name) => {
|
|
21
|
+
const results = []
|
|
22
|
+
const name = fm.name
|
|
23
|
+
|
|
24
|
+
if (!name) {
|
|
25
|
+
results.push(result('name', 'present', 'error', 'Frontmatter must include a "name" field'))
|
|
26
|
+
return results
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
results.push(result('name', 'present', 'pass', `name: "${name}"`, name))
|
|
30
|
+
|
|
31
|
+
if (name.length > 64) {
|
|
32
|
+
results.push(result('name', 'max_length', 'error', `Name is ${name.length} chars (max 64)`, name))
|
|
33
|
+
} else {
|
|
34
|
+
results.push(result('name', 'max_length', 'pass', `name: ${name.length} chars (max 64)`, name))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!NAME_PATTERN.test(name)) {
|
|
38
|
+
results.push(result('name', 'format', 'error', 'Name must match /^[a-z][a-z0-9-]*$/', name))
|
|
39
|
+
} else {
|
|
40
|
+
results.push(result('name', 'format', 'pass', 'Name format valid', name))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (name.includes('--')) {
|
|
44
|
+
results.push(result('name', 'no_consecutive_hyphens', 'error', 'Name must not contain consecutive hyphens (--)', name))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (name.startsWith('-') || name.endsWith('-')) {
|
|
48
|
+
results.push(result('name', 'no_edge_hyphens', 'error', 'Name must not start or end with a hyphen', name))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (dir_name && name !== dir_name) {
|
|
52
|
+
results.push(result('name', 'matches_directory', 'warning', `Name "${name}" does not match directory "${dir_name}"`, name))
|
|
53
|
+
} else if (dir_name) {
|
|
54
|
+
results.push(result('name', 'matches_directory', 'pass', `Name matches directory "${dir_name}"`, name))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return results
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const validate_description = (fm) => {
|
|
61
|
+
const results = []
|
|
62
|
+
const desc = fm.description
|
|
63
|
+
|
|
64
|
+
if (!desc) {
|
|
65
|
+
results.push(result('description', 'present', 'error', 'Frontmatter must include a "description" field'))
|
|
66
|
+
return results
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
results.push(result('description', 'present', 'pass', `description present (${desc.length} chars)`))
|
|
70
|
+
|
|
71
|
+
if (!desc.trim()) {
|
|
72
|
+
results.push(result('description', 'non_empty', 'error', 'Description must not be empty or whitespace-only', desc))
|
|
73
|
+
return results
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (desc.length > 1024) {
|
|
77
|
+
results.push(result('description', 'max_length', 'error', `Description is ${desc.length} chars (max 1024)`, desc))
|
|
78
|
+
} else {
|
|
79
|
+
results.push(result('description', 'max_length', 'pass', `Description: ${desc.length} chars (max 1024)`))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (desc === PLACEHOLDER_DESC) {
|
|
83
|
+
results.push(result('description', 'not_placeholder', 'warning', 'Description is the init placeholder — update it before publishing', desc))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const [char, name] of Object.entries(FORBIDDEN_CHARS)) {
|
|
87
|
+
if (desc.includes(char)) {
|
|
88
|
+
results.push(result('description', 'no_forbidden_characters', 'error', `Description contains forbidden character: ${name} (${char})`, desc))
|
|
89
|
+
return results
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
results.push(result('description', 'no_forbidden_characters', 'pass', 'No forbidden characters'))
|
|
94
|
+
return results
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const validate_optional_fields = (fm) => {
|
|
98
|
+
const results = []
|
|
99
|
+
|
|
100
|
+
if (fm.compatibility !== undefined) {
|
|
101
|
+
if (fm.compatibility.length > 500) {
|
|
102
|
+
results.push(result('compatibility', 'max_length', 'error', `Compatibility is ${fm.compatibility.length} chars (max 500)`, fm.compatibility))
|
|
103
|
+
} else {
|
|
104
|
+
results.push(result('compatibility', 'max_length', 'pass', `Compatibility: ${fm.compatibility.length} chars (max 500)`))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const bool_fields = ['disable-model-invocation', 'user-invocable']
|
|
109
|
+
for (const field of bool_fields) {
|
|
110
|
+
if (fm[field] !== undefined) {
|
|
111
|
+
const val = fm[field]
|
|
112
|
+
if (val !== 'true' && val !== 'false') {
|
|
113
|
+
results.push(result(field, 'boolean_type', 'warning', `${field} should be true or false, got "${val}"`, val))
|
|
114
|
+
} else {
|
|
115
|
+
results.push(result(field, 'boolean_type', 'pass', `${field}: ${val}`))
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (fm.context !== undefined && fm.context !== 'fork') {
|
|
121
|
+
results.push(result('context', 'value', 'warning', `context should be "fork", got "${fm.context}"`, fm.context))
|
|
122
|
+
} else if (fm.context !== undefined) {
|
|
123
|
+
results.push(result('context', 'value', 'pass', 'context: fork'))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (fm.agent !== undefined && fm.context !== 'fork') {
|
|
127
|
+
results.push(result('agent', 'requires_context', 'warning', 'agent is set but context is not "fork" — agent skills require context: fork'))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return results
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const validate_skill_md = (skill_dir, dir_name) => catch_errors('Failed to validate SKILL.md', async () => {
|
|
134
|
+
const results = []
|
|
135
|
+
const md_path = path.join(skill_dir, SKILL_MD)
|
|
136
|
+
|
|
137
|
+
const [, exists] = await file_exists(md_path)
|
|
138
|
+
if (!exists) {
|
|
139
|
+
results.push(result(null, 'exists', 'error', 'SKILL.md not found'))
|
|
140
|
+
return { results, frontmatter: null, content: null }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
results.push(result(null, 'exists', 'pass', 'SKILL.md exists'))
|
|
144
|
+
|
|
145
|
+
const [, content] = await read_file(md_path)
|
|
146
|
+
const fm = parse_frontmatter(content)
|
|
147
|
+
|
|
148
|
+
if (!fm) {
|
|
149
|
+
results.push(result(null, 'frontmatter', 'error', 'SKILL.md has no YAML frontmatter (---)'))
|
|
150
|
+
return { results, frontmatter: null, content }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
results.push(result(null, 'frontmatter', 'pass', 'Frontmatter present'))
|
|
154
|
+
|
|
155
|
+
results.push(...validate_name(fm, dir_name))
|
|
156
|
+
results.push(...validate_description(fm))
|
|
157
|
+
results.push(...validate_optional_fields(fm))
|
|
158
|
+
|
|
159
|
+
const line_count = content.split('\n').length
|
|
160
|
+
if (line_count >= 500) {
|
|
161
|
+
results.push(result(null, 'line_count', 'error', `SKILL.md is ${line_count} lines (max 500)`, String(line_count)))
|
|
162
|
+
} else {
|
|
163
|
+
results.push(result(null, 'line_count', 'pass', `File size: ${line_count} lines (max 500)`, String(line_count)))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { results, frontmatter: fm, content }
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
module.exports = { validate_skill_md }
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
|
|
8
|
+
const { validate_skill_md } = require('./skill_md_rules')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-validate-md-'))
|
|
11
|
+
|
|
12
|
+
const write_skill_md = (dir, frontmatter_fields, body = '\nSome content.') => {
|
|
13
|
+
const lines = Object.entries(frontmatter_fields).map(([k, v]) => `${k}: ${v}`)
|
|
14
|
+
const content = `---\n${lines.join('\n')}\n---${body}`
|
|
15
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), content)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let tmp
|
|
19
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
20
|
+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }) })
|
|
21
|
+
|
|
22
|
+
describe('validate_skill_md — existence', () => {
|
|
23
|
+
it('errors when SKILL.md is missing', async () => {
|
|
24
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
25
|
+
assert.ifError(err)
|
|
26
|
+
const exists_check = data.results.find(r => r.rule === 'exists')
|
|
27
|
+
assert.strictEqual(exists_check.severity, 'error')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('errors when frontmatter is missing', async () => {
|
|
31
|
+
fs.writeFileSync(path.join(tmp, 'SKILL.md'), '# No frontmatter\nJust text.')
|
|
32
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
33
|
+
assert.ifError(err)
|
|
34
|
+
const fm_check = data.results.find(r => r.rule === 'frontmatter')
|
|
35
|
+
assert.strictEqual(fm_check.severity, 'error')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('passes when SKILL.md has valid frontmatter', async () => {
|
|
39
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things' })
|
|
40
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
41
|
+
assert.ifError(err)
|
|
42
|
+
assert.strictEqual(data.results.find(r => r.rule === 'exists').severity, 'pass')
|
|
43
|
+
assert.strictEqual(data.results.find(r => r.rule === 'frontmatter').severity, 'pass')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('validate_skill_md — name', () => {
|
|
48
|
+
it('errors when name is missing', async () => {
|
|
49
|
+
write_skill_md(tmp, { description: 'Does things' })
|
|
50
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
51
|
+
assert.ifError(err)
|
|
52
|
+
const name_check = data.results.find(r => r.field === 'name' && r.rule === 'present')
|
|
53
|
+
assert.strictEqual(name_check.severity, 'error')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('errors when name exceeds 64 characters', async () => {
|
|
57
|
+
const long_name = 'a' + '-b'.repeat(33)
|
|
58
|
+
write_skill_md(tmp, { name: long_name, description: 'Does things' })
|
|
59
|
+
const [err, data] = await validate_skill_md(tmp, 'test')
|
|
60
|
+
assert.ifError(err)
|
|
61
|
+
const len_check = data.results.find(r => r.rule === 'max_length')
|
|
62
|
+
assert.strictEqual(len_check.severity, 'error')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('errors for invalid name format (uppercase)', async () => {
|
|
66
|
+
write_skill_md(tmp, { name: 'MySkill', description: 'Does things' })
|
|
67
|
+
const [err, data] = await validate_skill_md(tmp, 'test')
|
|
68
|
+
assert.ifError(err)
|
|
69
|
+
const fmt = data.results.find(r => r.rule === 'format')
|
|
70
|
+
assert.strictEqual(fmt.severity, 'error')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('errors for consecutive hyphens', async () => {
|
|
74
|
+
write_skill_md(tmp, { name: 'my--skill', description: 'Does things' })
|
|
75
|
+
const [err, data] = await validate_skill_md(tmp, 'test')
|
|
76
|
+
assert.ifError(err)
|
|
77
|
+
const check = data.results.find(r => r.rule === 'no_consecutive_hyphens')
|
|
78
|
+
assert.strictEqual(check.severity, 'error')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('errors for trailing hyphen', async () => {
|
|
82
|
+
write_skill_md(tmp, { name: 'my-skill-', description: 'Does things' })
|
|
83
|
+
const [err, data] = await validate_skill_md(tmp, 'test')
|
|
84
|
+
assert.ifError(err)
|
|
85
|
+
const check = data.results.find(r => r.rule === 'no_edge_hyphens')
|
|
86
|
+
assert.strictEqual(check.severity, 'error')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('warns when name does not match directory', async () => {
|
|
90
|
+
write_skill_md(tmp, { name: 'other-name', description: 'Does things' })
|
|
91
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
92
|
+
assert.ifError(err)
|
|
93
|
+
const check = data.results.find(r => r.rule === 'matches_directory')
|
|
94
|
+
assert.strictEqual(check.severity, 'warning')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('passes for a valid name matching directory', async () => {
|
|
98
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things' })
|
|
99
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
100
|
+
assert.ifError(err)
|
|
101
|
+
const checks = data.results.filter(r => r.field === 'name')
|
|
102
|
+
assert.ok(checks.every(r => r.severity === 'pass'))
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('validate_skill_md — description', () => {
|
|
107
|
+
it('errors when description is missing', async () => {
|
|
108
|
+
write_skill_md(tmp, { name: 'my-skill' })
|
|
109
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
110
|
+
assert.ifError(err)
|
|
111
|
+
const check = data.results.find(r => r.field === 'description' && r.rule === 'present')
|
|
112
|
+
assert.strictEqual(check.severity, 'error')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('errors when description exceeds 1024 characters', async () => {
|
|
116
|
+
const long_desc = 'a'.repeat(1025)
|
|
117
|
+
write_skill_md(tmp, { name: 'my-skill', description: long_desc })
|
|
118
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
119
|
+
assert.ifError(err)
|
|
120
|
+
const check = data.results.find(r => r.field === 'description' && r.rule === 'max_length')
|
|
121
|
+
assert.strictEqual(check.severity, 'error')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('warns for placeholder description', async () => {
|
|
125
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Describe what this skill does and when to invoke it' })
|
|
126
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
127
|
+
assert.ifError(err)
|
|
128
|
+
const check = data.results.find(r => r.rule === 'not_placeholder')
|
|
129
|
+
assert.strictEqual(check.severity, 'warning')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('errors for forbidden characters in description', async () => {
|
|
133
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Deploy apps; supports ECS' })
|
|
134
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
135
|
+
assert.ifError(err)
|
|
136
|
+
const check = data.results.find(r => r.rule === 'no_forbidden_characters')
|
|
137
|
+
assert.strictEqual(check.severity, 'error')
|
|
138
|
+
assert.ok(check.message.includes('semicolon'))
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('passes for valid description', async () => {
|
|
142
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Deploy apps to AWS using ECS' })
|
|
143
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
144
|
+
assert.ifError(err)
|
|
145
|
+
const check = data.results.find(r => r.rule === 'no_forbidden_characters')
|
|
146
|
+
assert.strictEqual(check.severity, 'pass')
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('validate_skill_md — optional fields', () => {
|
|
151
|
+
it('errors when compatibility exceeds 500 chars', async () => {
|
|
152
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', compatibility: 'x'.repeat(501) })
|
|
153
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
154
|
+
assert.ifError(err)
|
|
155
|
+
const check = data.results.find(r => r.field === 'compatibility')
|
|
156
|
+
assert.strictEqual(check.severity, 'error')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('warns for non-boolean disable-model-invocation', async () => {
|
|
160
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', 'disable-model-invocation': 'yes' })
|
|
161
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
162
|
+
assert.ifError(err)
|
|
163
|
+
const check = data.results.find(r => r.field === 'disable-model-invocation')
|
|
164
|
+
assert.strictEqual(check.severity, 'warning')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('warns when context is not fork', async () => {
|
|
168
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', context: 'other' })
|
|
169
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
170
|
+
assert.ifError(err)
|
|
171
|
+
const check = data.results.find(r => r.field === 'context')
|
|
172
|
+
assert.strictEqual(check.severity, 'warning')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('warns when agent is set without context fork', async () => {
|
|
176
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', agent: 'my-agent' })
|
|
177
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
178
|
+
assert.ifError(err)
|
|
179
|
+
const check = data.results.find(r => r.field === 'agent')
|
|
180
|
+
assert.strictEqual(check.severity, 'warning')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('validate_skill_md — line count', () => {
|
|
185
|
+
it('errors when file exceeds 500 lines', async () => {
|
|
186
|
+
const lines = '---\nname: my-skill\ndescription: Does things\n---\n' + 'line\n'.repeat(500)
|
|
187
|
+
fs.writeFileSync(path.join(tmp, 'SKILL.md'), lines)
|
|
188
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
189
|
+
assert.ifError(err)
|
|
190
|
+
const check = data.results.find(r => r.rule === 'line_count')
|
|
191
|
+
assert.strictEqual(check.severity, 'error')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('passes when file is under 500 lines', async () => {
|
|
195
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things' })
|
|
196
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
197
|
+
assert.ifError(err)
|
|
198
|
+
const check = data.results.find(r => r.rule === 'line_count')
|
|
199
|
+
assert.strictEqual(check.severity, 'pass')
|
|
200
|
+
})
|
|
201
|
+
})
|