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.
@@ -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('warns for non-boolean disable-model-invocation', async () => {
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, 'warning')
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 SKILL.md is missing for a kit', async () => {
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 SKILL.md exists for a kit (no frontmatter required)', async () => {
227
- fs.writeFileSync(path.join(tmp, 'SKILL.md'), '# My Kit\n\nThis is a kit.')
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, 'SKILL.md'), '# My Kit\n\nNo frontmatter here.')
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, 'SKILL.md'), long_content)
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
+ })