happyskills 0.50.0 → 0.52.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 +34 -0
- package/package.json +1 -1
- package/src/commands/init.js +14 -7
- package/src/commands/list.js +27 -5
- package/src/constants.js +2 -0
- package/src/integration/cli.test.js +38 -1
- package/src/utils/skill_scanner.js +56 -3
- 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
|
@@ -186,23 +186,26 @@ describe('validate_skill_md — optional fields', () => {
|
|
|
186
186
|
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', compatibility: 'x'.repeat(501) })
|
|
187
187
|
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
188
188
|
assert.ifError(err)
|
|
189
|
-
const check = data.results.find(r => r.field === 'compatibility')
|
|
189
|
+
const check = data.results.find(r => r.field === 'compatibility' && r.rule === 'max_length')
|
|
190
190
|
assert.strictEqual(check.severity, 'error')
|
|
191
191
|
})
|
|
192
192
|
|
|
193
|
-
it('
|
|
193
|
+
it('errors for non-boolean disable-model-invocation (type mismatch)', async () => {
|
|
194
|
+
// Promoted from warning → error in the schema-driven validator: the
|
|
195
|
+
// loader treats this field as a YAML boolean strictly, so anything
|
|
196
|
+
// that doesn't parse as one breaks the contract.
|
|
194
197
|
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', 'disable-model-invocation': 'yes' })
|
|
195
198
|
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
196
199
|
assert.ifError(err)
|
|
197
200
|
const check = data.results.find(r => r.field === 'disable-model-invocation')
|
|
198
|
-
assert.strictEqual(check.severity, '
|
|
201
|
+
assert.strictEqual(check.severity, 'error')
|
|
199
202
|
})
|
|
200
203
|
|
|
201
204
|
it('warns when context is not fork', async () => {
|
|
202
205
|
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', context: 'other' })
|
|
203
206
|
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
204
207
|
assert.ifError(err)
|
|
205
|
-
const check = data.results.find(r => r.field === 'context')
|
|
208
|
+
const check = data.results.find(r => r.field === 'context' && r.rule === 'enum')
|
|
206
209
|
assert.strictEqual(check.severity, 'warning')
|
|
207
210
|
})
|
|
208
211
|
|
|
@@ -210,21 +213,21 @@ describe('validate_skill_md — optional fields', () => {
|
|
|
210
213
|
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', agent: 'my-agent' })
|
|
211
214
|
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
212
215
|
assert.ifError(err)
|
|
213
|
-
const check = data.results.find(r => r.field === 'agent')
|
|
216
|
+
const check = data.results.find(r => r.field === 'agent' && r.rule === 'requires_context')
|
|
214
217
|
assert.strictEqual(check.severity, 'warning')
|
|
215
218
|
})
|
|
216
219
|
})
|
|
217
220
|
|
|
218
221
|
describe('validate_skill_md — kit type', () => {
|
|
219
|
-
it('errors when
|
|
222
|
+
it('errors when README.md is missing for a kit', async () => {
|
|
220
223
|
const [err, data] = await validate_skill_md(tmp, 'my-kit', 'kit')
|
|
221
224
|
assert.ifError(err)
|
|
222
225
|
const check = data.results.find(r => r.rule === 'exists')
|
|
223
226
|
assert.strictEqual(check.severity, 'error')
|
|
224
227
|
})
|
|
225
228
|
|
|
226
|
-
it('passes when
|
|
227
|
-
fs.writeFileSync(path.join(tmp, '
|
|
229
|
+
it('passes when README.md exists for a kit (no frontmatter required)', async () => {
|
|
230
|
+
fs.writeFileSync(path.join(tmp, 'README.md'), '# My Kit\n\nThis is a kit.')
|
|
228
231
|
const [err, data] = await validate_skill_md(tmp, 'my-kit', 'kit')
|
|
229
232
|
assert.ifError(err)
|
|
230
233
|
const exists_check = data.results.find(r => r.rule === 'exists')
|
|
@@ -234,10 +237,9 @@ describe('validate_skill_md — kit type', () => {
|
|
|
234
237
|
})
|
|
235
238
|
|
|
236
239
|
it('skips all frontmatter validation for kits', async () => {
|
|
237
|
-
fs.writeFileSync(path.join(tmp, '
|
|
240
|
+
fs.writeFileSync(path.join(tmp, 'README.md'), '# My Kit\n\nNo frontmatter here.')
|
|
238
241
|
const [err, data] = await validate_skill_md(tmp, 'my-kit', 'kit')
|
|
239
242
|
assert.ifError(err)
|
|
240
|
-
// Should have no frontmatter, name, or description results
|
|
241
243
|
assert.ok(!data.results.some(r => r.rule === 'frontmatter'))
|
|
242
244
|
assert.ok(!data.results.some(r => r.field === 'name'))
|
|
243
245
|
assert.ok(!data.results.some(r => r.field === 'description'))
|
|
@@ -245,11 +247,22 @@ describe('validate_skill_md — kit type', () => {
|
|
|
245
247
|
|
|
246
248
|
it('skips line count validation for kits', async () => {
|
|
247
249
|
const long_content = '# My Kit\n' + 'line\n'.repeat(600)
|
|
248
|
-
fs.writeFileSync(path.join(tmp, '
|
|
250
|
+
fs.writeFileSync(path.join(tmp, 'README.md'), long_content)
|
|
249
251
|
const [err, data] = await validate_skill_md(tmp, 'my-kit', 'kit')
|
|
250
252
|
assert.ifError(err)
|
|
251
253
|
assert.ok(!data.results.some(r => r.rule === 'line_count'))
|
|
252
254
|
})
|
|
255
|
+
|
|
256
|
+
it('errors when a kit also contains a SKILL.md', async () => {
|
|
257
|
+
fs.writeFileSync(path.join(tmp, 'README.md'), '# My Kit')
|
|
258
|
+
fs.writeFileSync(path.join(tmp, 'SKILL.md'), '# Should not be here')
|
|
259
|
+
const [err, data] = await validate_skill_md(tmp, 'my-kit', 'kit')
|
|
260
|
+
assert.ifError(err)
|
|
261
|
+
const check = data.results.find(r => r.rule === 'not_in_kit')
|
|
262
|
+
assert.ok(check)
|
|
263
|
+
assert.strictEqual(check.severity, 'error')
|
|
264
|
+
assert.strictEqual(check.file, 'SKILL.md')
|
|
265
|
+
})
|
|
253
266
|
})
|
|
254
267
|
|
|
255
268
|
describe('validate_skill_md — line count', () => {
|
|
@@ -270,3 +283,329 @@ describe('validate_skill_md — line count', () => {
|
|
|
270
283
|
assert.strictEqual(check.severity, 'pass')
|
|
271
284
|
})
|
|
272
285
|
})
|
|
286
|
+
|
|
287
|
+
describe('validate_skill_md — schema-driven type checks (regression: argument-hint bracket bug)', () => {
|
|
288
|
+
it('errors when argument-hint is an unquoted bracketed value (parses as YAML array)', async () => {
|
|
289
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', 'argument-hint': '[question or topic]' })
|
|
290
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
291
|
+
assert.ifError(err)
|
|
292
|
+
const check = data.results.find(r => r.field === 'argument-hint' && r.rule === 'type')
|
|
293
|
+
assert.strictEqual(check.severity, 'error')
|
|
294
|
+
assert.ok(/must be a string \(got array\)/.test(check.message), `got: ${check.message}`)
|
|
295
|
+
assert.ok(/Replace line 4 with: argument-hint: "\[question or topic\]"/.test(check.message), `got: ${check.message}`)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('passes when argument-hint is properly quoted', async () => {
|
|
299
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', 'argument-hint': '"[question or topic]"' })
|
|
300
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
301
|
+
assert.ifError(err)
|
|
302
|
+
const errs = data.results.filter(r => r.field === 'argument-hint' && r.severity === 'error')
|
|
303
|
+
assert.deepEqual(errs, [])
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('errors when argument-hint is an unquoted flow mapping (parses as YAML object)', async () => {
|
|
307
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', 'argument-hint': '{a: 1}' })
|
|
308
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
309
|
+
assert.ifError(err)
|
|
310
|
+
const check = data.results.find(r => r.field === 'argument-hint' && r.rule === 'type')
|
|
311
|
+
assert.strictEqual(check.severity, 'error')
|
|
312
|
+
assert.ok(/got object/.test(check.message), `got: ${check.message}`)
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('errors (not warning) when disable-model-invocation is not actually a boolean', async () => {
|
|
316
|
+
// The schema makes type mismatches errors. The old per-field check used
|
|
317
|
+
// "warning" — we promote to error because Claude Code's loader treats
|
|
318
|
+
// the value strictly as a YAML boolean.
|
|
319
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', 'disable-model-invocation': 'yes' })
|
|
320
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
321
|
+
assert.ifError(err)
|
|
322
|
+
const check = data.results.find(r => r.field === 'disable-model-invocation' && r.rule === 'type')
|
|
323
|
+
assert.strictEqual(check.severity, 'error')
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('accepts disable-model-invocation: true', async () => {
|
|
327
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', 'disable-model-invocation': 'true' })
|
|
328
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
329
|
+
assert.ifError(err)
|
|
330
|
+
const errs = data.results.filter(r => r.field === 'disable-model-invocation' && r.severity === 'error')
|
|
331
|
+
assert.deepEqual(errs, [])
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('warns on a typo of a known frontmatter field (did-you-mean)', async () => {
|
|
335
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', argument_hint: '"[foo]"' })
|
|
336
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
337
|
+
assert.ifError(err)
|
|
338
|
+
const check = data.results.find(r => r.field === 'argument_hint' && r.rule === 'unknown_field')
|
|
339
|
+
assert.strictEqual(check.severity, 'warning')
|
|
340
|
+
assert.ok(/did you mean "argument-hint"\?/.test(check.message), `got: ${check.message}`)
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
it('does not warn on unknown fields that are not close to any known field (forward-compat)', async () => {
|
|
344
|
+
write_skill_md(tmp, { name: 'my-skill', description: 'Does things', 'some-future-anthropic-field': 'hello' })
|
|
345
|
+
const [err, data] = await validate_skill_md(tmp, 'my-skill')
|
|
346
|
+
assert.ifError(err)
|
|
347
|
+
const warnings = data.results.filter(r => r.field === 'some-future-anthropic-field')
|
|
348
|
+
assert.deepEqual(warnings, [])
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
// Error messages must be auto-correct-friendly: every frontmatter error
|
|
353
|
+
// includes (1) the file, (2) the field, (3) the offending YAML line number,
|
|
354
|
+
// and where applicable a structured `fix` field with the literal replacement
|
|
355
|
+
// line. Without those, an LLM cannot mechanically apply the correction.
|
|
356
|
+
describe('validate_skill_md — error message pinpointing (for LLM auto-correct)', () => {
|
|
357
|
+
const write_raw = (lines) => fs.writeFileSync(path.join(tmp, 'SKILL.md'), `---\n${lines.join('\n')}\n---\n\n# body\n`)
|
|
358
|
+
|
|
359
|
+
it('argument-hint bracket bug: error names the file, the field, the line, AND ships a literal fix', async () => {
|
|
360
|
+
write_raw([
|
|
361
|
+
'name: my-skill', // line 2
|
|
362
|
+
'description: Valid description for tests.', // line 3
|
|
363
|
+
'argument-hint: [question or topic]' // line 4
|
|
364
|
+
])
|
|
365
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
366
|
+
const e = data.results.find(r => r.field === 'argument-hint' && r.rule === 'type' && r.severity === 'error')
|
|
367
|
+
assert.ok(e, 'expected a type error on argument-hint')
|
|
368
|
+
assert.strictEqual(e.file, 'SKILL.md')
|
|
369
|
+
assert.strictEqual(e.field, 'argument-hint')
|
|
370
|
+
assert.strictEqual(e.line, 4, 'line number must point at the actual offending YAML line')
|
|
371
|
+
assert.strictEqual(e.expected, 'string')
|
|
372
|
+
assert.strictEqual(e.actual, 'array')
|
|
373
|
+
assert.strictEqual(e.fix, 'argument-hint: "[question or topic]"',
|
|
374
|
+
'fix must be the literal corrected YAML line so an LLM can write it back verbatim')
|
|
375
|
+
assert.ok(/SKILL\.md line 4/.test(e.message), `message must lead with file:line — got: ${e.message}`)
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('multiple errors in the same file are each individually pinpointed', async () => {
|
|
379
|
+
// A pathologically broken frontmatter: bracket bug + forbidden char + wrong boolean type.
|
|
380
|
+
write_raw([
|
|
381
|
+
'name: my-skill', // line 2 — ok
|
|
382
|
+
'description: Deploys to AWS: ECS and Lambda', // line 3 — colon forbidden
|
|
383
|
+
'argument-hint: [foo]', // line 4 — bracket bug
|
|
384
|
+
'disable-model-invocation: maybe' // line 5 — boolean type mismatch
|
|
385
|
+
])
|
|
386
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
387
|
+
const errors_by_field = new Map(
|
|
388
|
+
data.results.filter(r => r.severity === 'error').map(r => [r.field + '/' + r.rule, r])
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
const desc_err = errors_by_field.get('description/no_forbidden_characters')
|
|
392
|
+
assert.ok(desc_err, 'expected forbidden-char error on description')
|
|
393
|
+
assert.strictEqual(desc_err.line, 3)
|
|
394
|
+
assert.strictEqual(desc_err.character, ':')
|
|
395
|
+
|
|
396
|
+
const hint_err = errors_by_field.get('argument-hint/type')
|
|
397
|
+
assert.ok(hint_err, 'expected type error on argument-hint')
|
|
398
|
+
assert.strictEqual(hint_err.line, 4)
|
|
399
|
+
assert.strictEqual(hint_err.fix, 'argument-hint: "[foo]"')
|
|
400
|
+
|
|
401
|
+
const bool_err = errors_by_field.get('disable-model-invocation/type')
|
|
402
|
+
assert.ok(bool_err, 'expected type error on disable-model-invocation')
|
|
403
|
+
assert.strictEqual(bool_err.line, 5)
|
|
404
|
+
assert.strictEqual(bool_err.expected, 'boolean')
|
|
405
|
+
assert.strictEqual(bool_err.actual, 'string')
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
it('typo-of-known-field warning ships a structured `fix` with the corrected key + the same value', async () => {
|
|
409
|
+
write_raw([
|
|
410
|
+
'name: my-skill',
|
|
411
|
+
'description: Valid description here.',
|
|
412
|
+
'argument_hint: "[foo]"' // underscore typo
|
|
413
|
+
])
|
|
414
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
415
|
+
const w = data.results.find(r => r.field === 'argument_hint' && r.rule === 'unknown_field')
|
|
416
|
+
assert.ok(w)
|
|
417
|
+
assert.strictEqual(w.line, 4)
|
|
418
|
+
assert.strictEqual(w.suggestion, 'argument-hint')
|
|
419
|
+
assert.strictEqual(w.fix, 'argument-hint: "[foo]"')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('missing required field error includes a structured `fix` template', async () => {
|
|
423
|
+
write_raw(['name: my-skill']) // description missing
|
|
424
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
425
|
+
const e = data.results.find(r => r.field === 'description' && r.rule === 'present')
|
|
426
|
+
assert.ok(e)
|
|
427
|
+
assert.strictEqual(e.fix, 'description: <value>')
|
|
428
|
+
assert.ok(/missing the required "description" field/.test(e.message))
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
it('forbidden-character error reports the character, its name, and its index in the value', async () => {
|
|
432
|
+
write_raw([
|
|
433
|
+
'name: my-skill',
|
|
434
|
+
'description: Hello; world'
|
|
435
|
+
])
|
|
436
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
437
|
+
const e = data.results.find(r => r.field === 'description' && r.rule === 'no_forbidden_characters')
|
|
438
|
+
assert.ok(e)
|
|
439
|
+
assert.strictEqual(e.line, 3)
|
|
440
|
+
assert.strictEqual(e.character, ';')
|
|
441
|
+
assert.strictEqual(e.character_name, 'semicolon')
|
|
442
|
+
assert.strictEqual(e.char_index, 5) // "Hello" is 5 chars long; ';' is at index 5
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('agent-without-context-fork ships a structured `fix` for the missing context line', async () => {
|
|
446
|
+
write_raw([
|
|
447
|
+
'name: my-skill',
|
|
448
|
+
'description: Valid description for the agent test.',
|
|
449
|
+
'agent: my-agent'
|
|
450
|
+
])
|
|
451
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
452
|
+
const w = data.results.find(r => r.field === 'agent' && r.rule === 'requires_context')
|
|
453
|
+
assert.ok(w)
|
|
454
|
+
assert.strictEqual(w.line, 4)
|
|
455
|
+
assert.strictEqual(w.fix, 'context: fork')
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it('name-does-not-match-directory ships a structured `fix` line', async () => {
|
|
459
|
+
write_raw([
|
|
460
|
+
'name: wrong-name',
|
|
461
|
+
'description: Valid description here.'
|
|
462
|
+
])
|
|
463
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
464
|
+
const w = data.results.find(r => r.field === 'name' && r.rule === 'matches_directory')
|
|
465
|
+
assert.ok(w)
|
|
466
|
+
assert.strictEqual(w.line, 2)
|
|
467
|
+
assert.strictEqual(w.fix, 'name: my-skill')
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('frontmatter parse error (malformed YAML line) carries the source line number', async () => {
|
|
471
|
+
// "this line has no colon" sits inside the frontmatter block and has no key:value structure.
|
|
472
|
+
fs.writeFileSync(path.join(tmp, 'SKILL.md'),
|
|
473
|
+
'---\nname: my-skill\nthis line has no colon\ndescription: ok desc here.\n---\n\nbody\n')
|
|
474
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
475
|
+
const e = data.results.find(r => r.rule === 'frontmatter_parse')
|
|
476
|
+
assert.ok(e)
|
|
477
|
+
assert.strictEqual(e.severity, 'error')
|
|
478
|
+
assert.strictEqual(e.line, 3) // file line 3 — opening `---` is line 1, `name:` is line 2
|
|
479
|
+
assert.ok(/SKILL\.md line 3/.test(e.message), `got: ${e.message}`)
|
|
480
|
+
})
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// Happy-path coverage: realistic, fully-valid skills must produce ZERO
|
|
484
|
+
// errors (and, in the strict case, zero warnings). Without these, a future
|
|
485
|
+
// schema tweak that accidentally rejects a legitimate field would slip
|
|
486
|
+
// through every CI run.
|
|
487
|
+
describe('validate_skill_md — valid skills (no false positives)', () => {
|
|
488
|
+
it('minimal valid skill: only name + description → no errors and no warnings', async () => {
|
|
489
|
+
write_skill_md(tmp, {
|
|
490
|
+
name: 'my-skill',
|
|
491
|
+
description: 'Deploy apps to AWS using ECS and Lambda'
|
|
492
|
+
})
|
|
493
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
494
|
+
const errors = data.results.filter(r => r.severity === 'error')
|
|
495
|
+
const warnings = data.results.filter(r => r.severity === 'warning')
|
|
496
|
+
assert.deepEqual(errors, [], `unexpected errors: ${JSON.stringify(errors, null, 2)}`)
|
|
497
|
+
assert.deepEqual(warnings, [], `unexpected warnings: ${JSON.stringify(warnings, null, 2)}`)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('full valid skill exercising every optional field → no errors', async () => {
|
|
501
|
+
write_skill_md(tmp, {
|
|
502
|
+
name: 'my-skill',
|
|
503
|
+
description: 'Deploy apps to AWS using ECS and Lambda — handles rollouts, rollbacks, and canary releases.',
|
|
504
|
+
'argument-hint': '"[your deploy request]"',
|
|
505
|
+
'allowed-tools': '"Bash, Read, Write, Edit"',
|
|
506
|
+
'disable-model-invocation': 'false',
|
|
507
|
+
'user-invocable': 'true',
|
|
508
|
+
compatibility: 'claude, cursor, windsurf',
|
|
509
|
+
keywords: 'deploy, aws, ecs'
|
|
510
|
+
})
|
|
511
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
512
|
+
const errors = data.results.filter(r => r.severity === 'error')
|
|
513
|
+
assert.deepEqual(errors, [], `unexpected errors: ${JSON.stringify(errors, null, 2)}`)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('valid agent skill (context: fork + agent) → no errors and no agent warning', async () => {
|
|
517
|
+
write_skill_md(tmp, {
|
|
518
|
+
name: 'my-skill',
|
|
519
|
+
description: 'A valid fork-context skill description.',
|
|
520
|
+
context: 'fork',
|
|
521
|
+
agent: 'my-agent'
|
|
522
|
+
})
|
|
523
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
524
|
+
const errors = data.results.filter(r => r.severity === 'error')
|
|
525
|
+
const agent_warnings = data.results.filter(r => r.field === 'agent' && r.severity === 'warning')
|
|
526
|
+
assert.deepEqual(errors, [])
|
|
527
|
+
assert.deepEqual(agent_warnings, [])
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
it('valid allowed-tools as a YAML list (not a string) is accepted', async () => {
|
|
531
|
+
write_skill_md(tmp, {
|
|
532
|
+
name: 'my-skill',
|
|
533
|
+
description: 'A valid skill using allowed-tools in list form.',
|
|
534
|
+
'allowed-tools': '[Bash, Read, Write]'
|
|
535
|
+
})
|
|
536
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
537
|
+
const errors = data.results.filter(r => r.severity === 'error')
|
|
538
|
+
assert.deepEqual(errors, [])
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('valid keywords as a YAML list is accepted', async () => {
|
|
542
|
+
write_skill_md(tmp, {
|
|
543
|
+
name: 'my-skill',
|
|
544
|
+
description: 'A valid skill using keywords in list form.',
|
|
545
|
+
keywords: '[deploy, aws, ecs]'
|
|
546
|
+
})
|
|
547
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
548
|
+
const errors = data.results.filter(r => r.severity === 'error')
|
|
549
|
+
assert.deepEqual(errors, [])
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('valid skill emits the expected positive-confirmation pass results', async () => {
|
|
553
|
+
// Locks in the shape of the pass output an LLM/agent can rely on to
|
|
554
|
+
// confirm "yes, this skill is good to publish."
|
|
555
|
+
write_skill_md(tmp, {
|
|
556
|
+
name: 'my-skill',
|
|
557
|
+
description: 'A valid description that passes every rule cleanly.'
|
|
558
|
+
})
|
|
559
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
560
|
+
const passes = data.results.filter(r => r.severity === 'pass').map(r => ({ field: r.field, rule: r.rule }))
|
|
561
|
+
// Required positive-confirmation rules. Order is not asserted — only presence.
|
|
562
|
+
const required_passes = [
|
|
563
|
+
{ field: null, rule: 'exists' },
|
|
564
|
+
{ field: null, rule: 'frontmatter' },
|
|
565
|
+
{ field: 'name', rule: 'type' },
|
|
566
|
+
{ field: 'name', rule: 'max_length' },
|
|
567
|
+
{ field: 'name', rule: 'matches_directory' },
|
|
568
|
+
{ field: 'description', rule: 'type' },
|
|
569
|
+
{ field: 'description', rule: 'max_length' },
|
|
570
|
+
{ field: 'description', rule: 'no_forbidden_characters' },
|
|
571
|
+
{ field: 'description', rule: 'soft_cap' },
|
|
572
|
+
{ field: null, rule: 'line_count' }
|
|
573
|
+
]
|
|
574
|
+
for (const expected of required_passes) {
|
|
575
|
+
assert.ok(
|
|
576
|
+
passes.some(p => p.field === expected.field && p.rule === expected.rule),
|
|
577
|
+
`missing pass for field=${expected.field} rule=${expected.rule}\nactual passes: ${JSON.stringify(passes)}`
|
|
578
|
+
)
|
|
579
|
+
}
|
|
580
|
+
})
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
// Sanity: pin the *shape* of an error so an LLM consumer can rely on the
|
|
584
|
+
// contract (every error has file + field + line + message + rule + severity).
|
|
585
|
+
// If the validator ever drops one of these for a fixable error, this fails.
|
|
586
|
+
describe('validate_skill_md — error contract for LLM consumers', () => {
|
|
587
|
+
it('every error from a known field includes the full pinpointing contract', async () => {
|
|
588
|
+
fs.writeFileSync(path.join(tmp, 'SKILL.md'), [
|
|
589
|
+
'---',
|
|
590
|
+
'name: my-skill',
|
|
591
|
+
'description: Deploys to AWS; supports ECS', // forbidden semicolon
|
|
592
|
+
'argument-hint: [foo]', // bracket bug
|
|
593
|
+
'---',
|
|
594
|
+
'',
|
|
595
|
+
'# body'
|
|
596
|
+
].join('\n'))
|
|
597
|
+
|
|
598
|
+
const [, data] = await validate_skill_md(tmp, 'my-skill')
|
|
599
|
+
const field_errors = data.results.filter(r => r.severity === 'error' && r.field !== null)
|
|
600
|
+
assert.ok(field_errors.length >= 2, `expected at least 2 errors, got ${field_errors.length}`)
|
|
601
|
+
|
|
602
|
+
for (const e of field_errors) {
|
|
603
|
+
assert.strictEqual(e.file, 'SKILL.md', `error is missing file: ${JSON.stringify(e)}`)
|
|
604
|
+
assert.ok(typeof e.field === 'string' && e.field.length > 0, `error is missing field: ${JSON.stringify(e)}`)
|
|
605
|
+
assert.ok(typeof e.rule === 'string' && e.rule.length > 0, `error is missing rule: ${JSON.stringify(e)}`)
|
|
606
|
+
assert.ok(typeof e.message === 'string' && e.message.length > 0, `error is missing message: ${JSON.stringify(e)}`)
|
|
607
|
+
assert.ok(Number.isInteger(e.line) && e.line > 0, `error is missing/invalid line: ${JSON.stringify(e)}`)
|
|
608
|
+
assert.ok(e.message.startsWith(`SKILL.md line ${e.line}:`), `message must lead with "SKILL.md line N:" — got: ${e.message}`)
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
})
|