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.
@@ -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 }