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
|
@@ -92,7 +92,7 @@ describe('CLI — global flags', () => {
|
|
|
92
92
|
|
|
93
93
|
it('--help lists all expected commands', () => {
|
|
94
94
|
const { stdout } = run(['--help'])
|
|
95
|
-
const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', 'update', 'publish', 'fork', 'login', 'logout', 'whoami', 'init', 'setup', 'self-update']
|
|
95
|
+
const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', 'refresh', 'update', 'publish', 'validate', 'fork', 'login', 'logout', 'whoami', 'init', 'setup', 'self-update']
|
|
96
96
|
for (const cmd of expected_commands) {
|
|
97
97
|
assert.ok(stdout.includes(cmd), `help output should mention "${cmd}"`)
|
|
98
98
|
}
|
|
@@ -160,8 +160,14 @@ describe('CLI — command --help', () => {
|
|
|
160
160
|
['search', 'Arguments:'],
|
|
161
161
|
['search', 'Aliases:'],
|
|
162
162
|
['check', 'Examples:'],
|
|
163
|
+
['refresh', 'Options:'],
|
|
164
|
+
['refresh', 'Examples:'],
|
|
165
|
+
['refresh', 'Aliases:'],
|
|
163
166
|
['update', 'Aliases:'],
|
|
164
167
|
['publish', 'Aliases:'],
|
|
168
|
+
['validate', 'Arguments:'],
|
|
169
|
+
['validate', 'Aliases:'],
|
|
170
|
+
['validate', 'Examples:'],
|
|
165
171
|
['fork', 'Arguments:'],
|
|
166
172
|
['init', 'Examples:'],
|
|
167
173
|
['setup', 'Options:'],
|
|
@@ -193,8 +199,10 @@ describe('CLI — command aliases', () => {
|
|
|
193
199
|
['remove', 'uninstall'],
|
|
194
200
|
['ls', 'list'],
|
|
195
201
|
['s', 'search'],
|
|
202
|
+
['r', 'refresh'],
|
|
196
203
|
['up', 'update'],
|
|
197
204
|
['pub', 'publish'],
|
|
205
|
+
['v', 'validate'],
|
|
198
206
|
]
|
|
199
207
|
|
|
200
208
|
for (const [alias, canonical] of alias_cases) {
|
|
@@ -412,6 +420,24 @@ describe('CLI — --json: existing json commands now use { data } envelope', ()
|
|
|
412
420
|
})
|
|
413
421
|
})
|
|
414
422
|
|
|
423
|
+
// ─── refresh command ──────────────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
describe('CLI — --json: refresh command', () => {
|
|
426
|
+
it('refresh --json with no skills returns { data: { results, outdated_count, ... } }', () => {
|
|
427
|
+
const tmp = make_tmp()
|
|
428
|
+
try {
|
|
429
|
+
const { stdout, code } = run(['refresh', '--json'], {}, { cwd: tmp })
|
|
430
|
+
assert.strictEqual(code, 0)
|
|
431
|
+
const out = parse_json_output(stdout, 'refresh --json empty')
|
|
432
|
+
assert.ok('data' in out)
|
|
433
|
+
assert.ok(Array.isArray(out.data.results))
|
|
434
|
+
assert.strictEqual(out.data.outdated_count, 0)
|
|
435
|
+
} finally {
|
|
436
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
437
|
+
}
|
|
438
|
+
})
|
|
439
|
+
})
|
|
440
|
+
|
|
415
441
|
// ─── setup command ─────────────────────────────────────────────────────────────
|
|
416
442
|
|
|
417
443
|
describe('CLI — setup command', () => {
|
|
@@ -456,3 +482,127 @@ describe('CLI — self-update command', () => {
|
|
|
456
482
|
assert.strictEqual(out.error.exit_code, 4)
|
|
457
483
|
})
|
|
458
484
|
})
|
|
485
|
+
|
|
486
|
+
// ─── validate command ─────────────────────────────────────────────────────────
|
|
487
|
+
|
|
488
|
+
describe('CLI — validate command', () => {
|
|
489
|
+
it('validate --help exits 0 and shows usage', () => {
|
|
490
|
+
const { stdout, code } = run(['validate', '--help'])
|
|
491
|
+
assert.strictEqual(code, 0)
|
|
492
|
+
assert.ok(stdout.includes('Arguments:'))
|
|
493
|
+
assert.ok(stdout.includes('Aliases:'))
|
|
494
|
+
assert.ok(stdout.includes('Examples:'))
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('v alias resolves to validate (shows correct --help)', () => {
|
|
498
|
+
const { stdout, code } = run(['v', '--help'])
|
|
499
|
+
assert.strictEqual(code, 0)
|
|
500
|
+
assert.ok(stdout.toLowerCase().includes('validate'))
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it('exits 2 when no skill name is given', () => {
|
|
504
|
+
const { stderr, code } = run(['validate'])
|
|
505
|
+
assert.strictEqual(code, 2)
|
|
506
|
+
assert.ok(stderr.includes('Skill name required'))
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('exits 2 when skill does not exist', () => {
|
|
510
|
+
const tmp = make_tmp()
|
|
511
|
+
try {
|
|
512
|
+
const { code } = run(['validate', 'nonexistent'], {}, { cwd: tmp })
|
|
513
|
+
assert.strictEqual(code, 2)
|
|
514
|
+
} finally {
|
|
515
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
516
|
+
}
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('exits 2 (JSON) when skill does not exist', () => {
|
|
520
|
+
const tmp = make_tmp()
|
|
521
|
+
try {
|
|
522
|
+
const { stdout, code } = run(['validate', 'nonexistent', '--json'], {}, { cwd: tmp })
|
|
523
|
+
assert.strictEqual(code, 2)
|
|
524
|
+
const out = parse_json_output(stdout, 'validate nonexistent --json')
|
|
525
|
+
assert.ok('error' in out)
|
|
526
|
+
assert.strictEqual(out.error.code, 'USAGE_ERROR')
|
|
527
|
+
} finally {
|
|
528
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('exits 0 for a valid skill and reports all checks passed', () => {
|
|
533
|
+
const tmp = make_tmp()
|
|
534
|
+
const skill_dir = path.join(tmp, '.claude', 'skills', 'test-skill')
|
|
535
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
536
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: test-skill\ndescription: A valid test skill for unit testing\n---\n\n# Test Skill\n')
|
|
537
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({
|
|
538
|
+
name: 'test-skill', version: '1.0.0', description: 'A test skill',
|
|
539
|
+
keywords: ['testing']
|
|
540
|
+
}, null, '\t'))
|
|
541
|
+
try {
|
|
542
|
+
const { stdout, code } = run(['validate', 'test-skill'], {}, { cwd: tmp })
|
|
543
|
+
assert.strictEqual(code, 0)
|
|
544
|
+
assert.ok(stdout.includes('test-skill'))
|
|
545
|
+
} finally {
|
|
546
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('exits 1 for an invalid skill', () => {
|
|
551
|
+
const tmp = make_tmp()
|
|
552
|
+
const skill_dir = path.join(tmp, '.claude', 'skills', 'bad-skill')
|
|
553
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
554
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: bad-skill\n---\n')
|
|
555
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({ name: 'bad-skill' }, null, '\t'))
|
|
556
|
+
try {
|
|
557
|
+
const { code } = run(['validate', 'bad-skill'], {}, { cwd: tmp })
|
|
558
|
+
assert.strictEqual(code, 1)
|
|
559
|
+
} finally {
|
|
560
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
561
|
+
}
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('--json returns correct schema for a valid skill', () => {
|
|
565
|
+
const tmp = make_tmp()
|
|
566
|
+
const skill_dir = path.join(tmp, '.claude', 'skills', 'test-skill')
|
|
567
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
568
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: test-skill\ndescription: A valid test skill for unit testing\n---\n\n# Test\n')
|
|
569
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({
|
|
570
|
+
name: 'test-skill', version: '1.0.0', description: 'A test skill',
|
|
571
|
+
keywords: ['testing']
|
|
572
|
+
}, null, '\t'))
|
|
573
|
+
try {
|
|
574
|
+
const { stdout, code } = run(['validate', 'test-skill', '--json'], {}, { cwd: tmp })
|
|
575
|
+
assert.strictEqual(code, 0)
|
|
576
|
+
const out = parse_json_output(stdout, 'validate --json valid skill')
|
|
577
|
+
assert.ok('data' in out)
|
|
578
|
+
assert.strictEqual(out.data.skill, 'test-skill')
|
|
579
|
+
assert.strictEqual(out.data.valid, true)
|
|
580
|
+
assert.ok(Array.isArray(out.data.errors))
|
|
581
|
+
assert.ok(Array.isArray(out.data.warnings))
|
|
582
|
+
assert.strictEqual(typeof out.data.checks_passed, 'number')
|
|
583
|
+
assert.strictEqual(typeof out.data.checks_failed, 'number')
|
|
584
|
+
assert.strictEqual(typeof out.data.checks_warned, 'number')
|
|
585
|
+
assert.strictEqual(out.data.checks_failed, 0)
|
|
586
|
+
} finally {
|
|
587
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
588
|
+
}
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('--json returns errors for an invalid skill', () => {
|
|
592
|
+
const tmp = make_tmp()
|
|
593
|
+
const skill_dir = path.join(tmp, '.claude', 'skills', 'bad-skill')
|
|
594
|
+
fs.mkdirSync(skill_dir, { recursive: true })
|
|
595
|
+
fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: bad-skill\n---\n')
|
|
596
|
+
fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({ name: 'bad-skill' }, null, '\t'))
|
|
597
|
+
try {
|
|
598
|
+
const { stdout, code } = run(['validate', 'bad-skill', '--json'], {}, { cwd: tmp })
|
|
599
|
+
assert.strictEqual(code, 1)
|
|
600
|
+
const out = parse_json_output(stdout, 'validate --json invalid skill')
|
|
601
|
+
assert.strictEqual(out.data.valid, false)
|
|
602
|
+
assert.ok(out.data.errors.length > 0)
|
|
603
|
+
assert.ok(out.data.checks_failed > 0)
|
|
604
|
+
} finally {
|
|
605
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
606
|
+
}
|
|
607
|
+
})
|
|
608
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
4
|
+
|
|
5
|
+
const EXEC_LANGS = new Set(['python', 'bash', 'sh', 'javascript', 'js', 'typescript', 'ts', 'ruby', 'go', 'rust'])
|
|
6
|
+
const SCRIPT_MARKERS = /^\s*(#!\/|import |from |require\(|def |class |function |const |let |var |export |module\.|package |fn |func |pub fn)/
|
|
7
|
+
|
|
8
|
+
const result = (file, field, rule, severity, message, value) => ({
|
|
9
|
+
file, field, rule, severity, message, ...(value !== undefined ? { value } : {})
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const scan_code_blocks = (content, file_label) => {
|
|
13
|
+
const results = []
|
|
14
|
+
const block_rx = /```(\w+)?\n([\s\S]*?)```/g
|
|
15
|
+
let match
|
|
16
|
+
|
|
17
|
+
while ((match = block_rx.exec(content)) !== null) {
|
|
18
|
+
const lang = (match[1] || '').toLowerCase()
|
|
19
|
+
if (!EXEC_LANGS.has(lang)) continue
|
|
20
|
+
|
|
21
|
+
const body = match[2]
|
|
22
|
+
const lines = body.split('\n')
|
|
23
|
+
const line_count = lines.filter(l => l.trim()).length
|
|
24
|
+
const marker_count = lines.filter(l => SCRIPT_MARKERS.test(l)).length
|
|
25
|
+
|
|
26
|
+
if (marker_count >= 2 || line_count > 10) {
|
|
27
|
+
results.push(result(file_label, null, 'no_executable_code', 'warning',
|
|
28
|
+
`Found ${lang} code block (${line_count} lines) that looks like an executable script — consider moving to scripts/`,
|
|
29
|
+
lang
|
|
30
|
+
))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return results
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const validate_cross = (skill_dir, frontmatter, manifest, skill_md_content) => catch_errors('Failed cross-file validation', async () => {
|
|
38
|
+
const results = []
|
|
39
|
+
|
|
40
|
+
// Name match
|
|
41
|
+
if (frontmatter && manifest) {
|
|
42
|
+
if (frontmatter.name !== manifest.name) {
|
|
43
|
+
results.push(result('Cross-file', 'name', 'name_match', 'warning',
|
|
44
|
+
`SKILL.md name "${frontmatter.name}" does not match skill.json name "${manifest.name}"`,
|
|
45
|
+
`${frontmatter.name} vs ${manifest.name}`
|
|
46
|
+
))
|
|
47
|
+
} else {
|
|
48
|
+
results.push(result('Cross-file', 'name', 'name_match', 'pass',
|
|
49
|
+
`Names match: "${frontmatter.name}"`
|
|
50
|
+
))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Executable code in SKILL.md
|
|
55
|
+
if (skill_md_content) {
|
|
56
|
+
results.push(...scan_code_blocks(skill_md_content, 'SKILL.md'))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Executable code in references/*.md
|
|
60
|
+
const refs_dir = path.join(skill_dir, 'references')
|
|
61
|
+
try {
|
|
62
|
+
const entries = await fs.promises.readdir(refs_dir, { withFileTypes: true })
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
65
|
+
const ref_path = path.join(refs_dir, entry.name)
|
|
66
|
+
const ref_content = await fs.promises.readFile(ref_path, 'utf-8')
|
|
67
|
+
results.push(...scan_code_blocks(ref_content, `references/${entry.name}`))
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// No references dir — that's fine
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return results
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
module.exports = { validate_cross }
|
|
@@ -0,0 +1,88 @@
|
|
|
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_cross } = require('./cross_rules')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-validate-cross-'))
|
|
11
|
+
|
|
12
|
+
let tmp
|
|
13
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
14
|
+
afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }) })
|
|
15
|
+
|
|
16
|
+
describe('validate_cross — name match', () => {
|
|
17
|
+
it('passes when names match', async () => {
|
|
18
|
+
const [err, results] = await validate_cross(tmp, { name: 'my-skill' }, { name: 'my-skill' }, '')
|
|
19
|
+
assert.ifError(err)
|
|
20
|
+
const check = results.find(r => r.rule === 'name_match')
|
|
21
|
+
assert.strictEqual(check.severity, 'pass')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('warns when names differ', async () => {
|
|
25
|
+
const [err, results] = await validate_cross(tmp, { name: 'skill-a' }, { name: 'skill-b' }, '')
|
|
26
|
+
assert.ifError(err)
|
|
27
|
+
const check = results.find(r => r.rule === 'name_match')
|
|
28
|
+
assert.strictEqual(check.severity, 'warning')
|
|
29
|
+
assert.ok(check.message.includes('skill-a'))
|
|
30
|
+
assert.ok(check.message.includes('skill-b'))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('skips name match when frontmatter is null', async () => {
|
|
34
|
+
const [err, results] = await validate_cross(tmp, null, { name: 'my-skill' }, '')
|
|
35
|
+
assert.ifError(err)
|
|
36
|
+
assert.ok(!results.some(r => r.rule === 'name_match'))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('skips name match when manifest is null', async () => {
|
|
40
|
+
const [err, results] = await validate_cross(tmp, { name: 'my-skill' }, null, '')
|
|
41
|
+
assert.ifError(err)
|
|
42
|
+
assert.ok(!results.some(r => r.rule === 'name_match'))
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('validate_cross — executable code detection', () => {
|
|
47
|
+
it('warns for a long python code block in SKILL.md', async () => {
|
|
48
|
+
const code_block = '```python\nimport os\nimport sys\n' + 'print("hello")\n'.repeat(10) + '```'
|
|
49
|
+
const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, code_block)
|
|
50
|
+
assert.ifError(err)
|
|
51
|
+
const check = results.find(r => r.rule === 'no_executable_code')
|
|
52
|
+
assert.strictEqual(check.severity, 'warning')
|
|
53
|
+
assert.ok(check.message.includes('python'))
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not flag short documentation code blocks', async () => {
|
|
57
|
+
const code_block = '```python\nprint("hello")\n```'
|
|
58
|
+
const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, code_block)
|
|
59
|
+
assert.ifError(err)
|
|
60
|
+
assert.ok(!results.some(r => r.rule === 'no_executable_code'))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('does not flag non-executable language blocks', async () => {
|
|
64
|
+
const code_block = '```yaml\nname: test\nversion: 1.0.0\n```'
|
|
65
|
+
const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, code_block)
|
|
66
|
+
assert.ifError(err)
|
|
67
|
+
assert.ok(!results.some(r => r.rule === 'no_executable_code'))
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('warns for executable code in references/*.md', async () => {
|
|
71
|
+
const refs_dir = path.join(tmp, 'references')
|
|
72
|
+
fs.mkdirSync(refs_dir)
|
|
73
|
+
const code = '```bash\n#!/bin/bash\nimport os\nset -e\necho "deploy"\n' + 'echo "step"\n'.repeat(8) + '```'
|
|
74
|
+
fs.writeFileSync(path.join(refs_dir, 'deploy.md'), code)
|
|
75
|
+
|
|
76
|
+
const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, '')
|
|
77
|
+
assert.ifError(err)
|
|
78
|
+
const check = results.find(r => r.rule === 'no_executable_code' && r.file === 'references/deploy.md')
|
|
79
|
+
assert.strictEqual(check.severity, 'warning')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('does not error when references dir is missing', async () => {
|
|
83
|
+
const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, '')
|
|
84
|
+
assert.ifError(err)
|
|
85
|
+
// Should not throw or produce error results — just no code block warnings
|
|
86
|
+
assert.ok(!results.some(r => r.severity === 'error'))
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
+
const { file_exists, read_file } = require('../utils/fs')
|
|
4
|
+
const { valid } = require('../utils/semver')
|
|
5
|
+
const { SKILL_JSON } = require('../constants')
|
|
6
|
+
|
|
7
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
|
|
8
|
+
const CANONICAL_SLUGS = ['deployment', 'database', 'security', 'ai', 'api', 'monitoring', 'testing', 'devops', 'cloud', 'analytics']
|
|
9
|
+
const REQUIRED_PLATFORMS = ['darwin', 'linux', 'win32']
|
|
10
|
+
|
|
11
|
+
const result = (field, rule, severity, message, value) => ({
|
|
12
|
+
file: SKILL_JSON, field, rule, severity, message, ...(value !== undefined ? { value } : {})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const validate_name = (manifest) => {
|
|
16
|
+
const results = []
|
|
17
|
+
const name = manifest.name
|
|
18
|
+
|
|
19
|
+
if (!name) {
|
|
20
|
+
results.push(result('name', 'present', 'error', 'skill.json must include a "name" field'))
|
|
21
|
+
return results
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
results.push(result('name', 'present', 'pass', `name: "${name}"`, name))
|
|
25
|
+
|
|
26
|
+
if (!NAME_PATTERN.test(name)) {
|
|
27
|
+
results.push(result('name', 'format', 'error', 'Name must match /^[a-z0-9][a-z0-9_-]*$/', name))
|
|
28
|
+
} else {
|
|
29
|
+
results.push(result('name', 'format', 'pass', 'Name format valid', name))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return results
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const validate_version = (manifest) => {
|
|
36
|
+
const results = []
|
|
37
|
+
const ver = manifest.version
|
|
38
|
+
|
|
39
|
+
if (!ver) {
|
|
40
|
+
results.push(result('version', 'present', 'error', 'skill.json must include a "version" field'))
|
|
41
|
+
return results
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
results.push(result('version', 'present', 'pass', `version: "${ver}"`, ver))
|
|
45
|
+
|
|
46
|
+
if (!valid(ver)) {
|
|
47
|
+
results.push(result('version', 'valid_semver', 'error', `"${ver}" is not valid semver`, ver))
|
|
48
|
+
} else {
|
|
49
|
+
results.push(result('version', 'valid_semver', 'pass', `version "${ver}" is valid semver`, ver))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return results
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const validate_description = (manifest) => {
|
|
56
|
+
const results = []
|
|
57
|
+
const desc = manifest.description
|
|
58
|
+
|
|
59
|
+
if (!desc) {
|
|
60
|
+
results.push(result('description', 'recommended_field', 'warning', 'Description is recommended for registry search'))
|
|
61
|
+
return results
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
results.push(result('description', 'recommended_field', 'pass', 'Description present'))
|
|
65
|
+
|
|
66
|
+
if (desc.length > 200) {
|
|
67
|
+
results.push(result('description', 'max_length', 'warning', `Description is ${desc.length} chars (recommended max 200)`, desc))
|
|
68
|
+
} else {
|
|
69
|
+
results.push(result('description', 'max_length', 'pass', `Description: ${desc.length} chars (max 200)`))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return results
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const validate_keywords = (manifest) => {
|
|
76
|
+
const results = []
|
|
77
|
+
const kw = manifest.keywords
|
|
78
|
+
|
|
79
|
+
if (!kw || !Array.isArray(kw)) {
|
|
80
|
+
results.push(result('keywords', 'recommended_field', 'warning', 'Keywords array is recommended — include at least one canonical slug'))
|
|
81
|
+
return results
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
results.push(result('keywords', 'recommended_field', 'pass', `keywords: ${kw.length} entries`))
|
|
85
|
+
|
|
86
|
+
const has_slug = kw.some(k => CANONICAL_SLUGS.includes(k))
|
|
87
|
+
if (!has_slug) {
|
|
88
|
+
results.push(result('keywords', 'canonical_slug', 'warning', `Keywords should include at least one canonical slug: ${CANONICAL_SLUGS.join(', ')}`))
|
|
89
|
+
} else {
|
|
90
|
+
results.push(result('keywords', 'canonical_slug', 'pass', 'Contains canonical slug'))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return results
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const validate_dependencies = (manifest) => {
|
|
97
|
+
const results = []
|
|
98
|
+
const deps = manifest.dependencies
|
|
99
|
+
|
|
100
|
+
if (deps === undefined) return results
|
|
101
|
+
|
|
102
|
+
if (typeof deps !== 'object' || Array.isArray(deps)) {
|
|
103
|
+
results.push(result('dependencies', 'type', 'error', 'dependencies must be an object (not array)'))
|
|
104
|
+
return results
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
results.push(result('dependencies', 'type', 'pass', 'dependencies is an object'))
|
|
108
|
+
|
|
109
|
+
for (const [key, val] of Object.entries(deps)) {
|
|
110
|
+
if (!key.includes('/')) {
|
|
111
|
+
results.push(result('dependencies', 'key_format', 'error', `Dependency key "${key}" must be in owner/name format`, key))
|
|
112
|
+
}
|
|
113
|
+
if (typeof val !== 'string') {
|
|
114
|
+
results.push(result('dependencies', 'value_type', 'error', `Dependency "${key}" value must be a string (semver range)`, String(val)))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const entries = Object.entries(deps)
|
|
119
|
+
const all_valid = entries.every(([k, v]) => k.includes('/') && typeof v === 'string')
|
|
120
|
+
if (entries.length > 0 && all_valid) {
|
|
121
|
+
results.push(result('dependencies', 'entries', 'pass', `${entries.length} dependencies valid`))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return results
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const validate_system_dependencies = (manifest) => {
|
|
128
|
+
const results = []
|
|
129
|
+
const sys = manifest.systemDependencies
|
|
130
|
+
|
|
131
|
+
if (sys === undefined) return results
|
|
132
|
+
|
|
133
|
+
if (typeof sys !== 'object' || Array.isArray(sys)) {
|
|
134
|
+
results.push(result('systemDependencies', 'type', 'error', 'systemDependencies must be an object'))
|
|
135
|
+
return results
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
results.push(result('systemDependencies', 'type', 'pass', 'systemDependencies is an object'))
|
|
139
|
+
|
|
140
|
+
for (const [key, entry] of Object.entries(sys)) {
|
|
141
|
+
if (!entry || typeof entry !== 'object') {
|
|
142
|
+
results.push(result('systemDependencies', 'entry_structure', 'warning', `"${key}" should be an object with description, check, and install`))
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const has_desc = typeof entry.description === 'string'
|
|
147
|
+
const has_check = typeof entry.check === 'string'
|
|
148
|
+
const has_install = entry.install && typeof entry.install === 'object'
|
|
149
|
+
|
|
150
|
+
if (!has_desc || !has_check || !has_install) {
|
|
151
|
+
const missing = [!has_desc && 'description', !has_check && 'check', !has_install && 'install'].filter(Boolean)
|
|
152
|
+
results.push(result('systemDependencies', 'entry_structure', 'warning', `"${key}" is missing: ${missing.join(', ')}`))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (has_install) {
|
|
156
|
+
const platforms = Object.keys(entry.install)
|
|
157
|
+
const missing_platforms = REQUIRED_PLATFORMS.filter(p => !platforms.includes(p))
|
|
158
|
+
if (missing_platforms.length > 0) {
|
|
159
|
+
results.push(result('systemDependencies', 'platform_coverage', 'warning', `"${key}" install is missing platforms: ${missing_platforms.join(', ')}`))
|
|
160
|
+
} else {
|
|
161
|
+
results.push(result('systemDependencies', 'platform_coverage', 'pass', `"${key}" covers all platforms`))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return results
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skill.json', async () => {
|
|
170
|
+
const results = []
|
|
171
|
+
const json_path = path.join(skill_dir, SKILL_JSON)
|
|
172
|
+
|
|
173
|
+
const [, exists] = await file_exists(json_path)
|
|
174
|
+
if (!exists) {
|
|
175
|
+
results.push(result(null, 'exists', 'error', 'skill.json not found'))
|
|
176
|
+
return { results, manifest: null }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
results.push(result(null, 'exists', 'pass', 'skill.json exists'))
|
|
180
|
+
|
|
181
|
+
const [, raw] = await read_file(json_path)
|
|
182
|
+
let manifest
|
|
183
|
+
try {
|
|
184
|
+
manifest = JSON.parse(raw)
|
|
185
|
+
} catch (err) {
|
|
186
|
+
results.push(result(null, 'valid_json', 'error', `Invalid JSON: ${err.message}`))
|
|
187
|
+
return { results, manifest: null }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
results.push(result(null, 'valid_json', 'pass', 'Valid JSON'))
|
|
191
|
+
|
|
192
|
+
results.push(...validate_name(manifest))
|
|
193
|
+
results.push(...validate_version(manifest))
|
|
194
|
+
results.push(...validate_description(manifest))
|
|
195
|
+
results.push(...validate_keywords(manifest))
|
|
196
|
+
results.push(...validate_dependencies(manifest))
|
|
197
|
+
results.push(...validate_system_dependencies(manifest))
|
|
198
|
+
|
|
199
|
+
return { results, manifest }
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
module.exports = { validate_skill_json }
|