happyskills 0.8.0 → 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 +9 -0
- package/package.json +1 -1
- package/src/commands/convert.js +24 -3
- package/src/commands/init.js +5 -4
- package/src/commands/publish.js +33 -3
- package/src/commands/validate.js +148 -0
- package/src/constants.js +3 -1
- package/src/index.js +1 -0
- package/src/integration/cli.test.js +129 -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
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-03-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add `validate` command to check a skill against all rules from the agentskills.io spec in one pass — validates SKILL.md frontmatter (name, description, optional fields, line count), skill.json (name, version, description, keywords, dependencies, systemDependencies), and cross-file consistency (name match, executable code detection); supports `--json` and `-g` flags (alias: `v`)
|
|
14
|
+
- Add SKILL.md frontmatter validation warnings to `publish` and `convert` commands — warn about missing `name` or `description` fields before publishing
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- Change `init` scaffolding to generate SKILL.md with proper YAML frontmatter (`name` and `description` fields) instead of a bare markdown heading
|
|
18
|
+
|
|
10
19
|
## [0.8.0] - 2026-03-08
|
|
11
20
|
|
|
12
21
|
### Added
|
package/package.json
CHANGED
package/src/commands/convert.js
CHANGED
|
@@ -13,7 +13,7 @@ const { hash_directory } = require('../lock/integrity')
|
|
|
13
13
|
const { skills_dir, find_project_root, lock_root } = require('../config/paths')
|
|
14
14
|
const { file_exists } = require('../utils/fs')
|
|
15
15
|
const { create_spinner } = require('../ui/spinner')
|
|
16
|
-
const { print_help, print_success, print_error, print_info, print_label, print_json } = require('../ui/output')
|
|
16
|
+
const { print_help, print_success, print_error, print_info, print_warn, print_label, print_json } = require('../ui/output')
|
|
17
17
|
const { exit_with_error, UsageError, CliError } = require('../utils/errors')
|
|
18
18
|
const { EXIT_CODES, SKILL_MD } = require('../constants')
|
|
19
19
|
|
|
@@ -99,6 +99,25 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
99
99
|
? args.flags.keywords.split(',').map(k => k.trim()).filter(Boolean)
|
|
100
100
|
: fm_keywords
|
|
101
101
|
|
|
102
|
+
// Warn about missing frontmatter fields that affect auto-invocation
|
|
103
|
+
const frontmatter_warnings = []
|
|
104
|
+
if (!frontmatter) {
|
|
105
|
+
frontmatter_warnings.push('SKILL.md has no YAML frontmatter (---). Without frontmatter, Claude cannot auto-invoke this skill. Add a frontmatter block with name and description fields.')
|
|
106
|
+
} else {
|
|
107
|
+
if (!frontmatter.name) {
|
|
108
|
+
frontmatter_warnings.push('SKILL.md frontmatter is missing "name". Add name: <skill-name> to the frontmatter.')
|
|
109
|
+
}
|
|
110
|
+
if (!frontmatter.description) {
|
|
111
|
+
frontmatter_warnings.push('SKILL.md frontmatter is missing "description". The description is the #1 factor for Claude auto-invocation quality. Without it, the skill will not be auto-invoked.')
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (frontmatter_warnings.length > 0) {
|
|
116
|
+
for (const warning of frontmatter_warnings) {
|
|
117
|
+
print_warn(warning)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
102
121
|
const spinner = create_spinner('Resolving workspace...')
|
|
103
122
|
|
|
104
123
|
const [ws_err, workspaces] = await workspaces_api.list_workspaces()
|
|
@@ -160,12 +179,14 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
160
179
|
pub_spinner.succeed(`Converted ${full_name}@${version}`)
|
|
161
180
|
|
|
162
181
|
if (args.flags.json) {
|
|
163
|
-
|
|
182
|
+
const json_data = {
|
|
164
183
|
skill: full_name,
|
|
165
184
|
version,
|
|
166
185
|
workspace: workspace.slug,
|
|
167
186
|
description: description || ''
|
|
168
|
-
}
|
|
187
|
+
}
|
|
188
|
+
if (frontmatter_warnings.length > 0) json_data.warnings = frontmatter_warnings
|
|
189
|
+
print_json({ data: json_data })
|
|
169
190
|
return
|
|
170
191
|
}
|
|
171
192
|
|
package/src/commands/init.js
CHANGED
|
@@ -24,11 +24,12 @@ Examples:
|
|
|
24
24
|
happyskills init
|
|
25
25
|
happyskills init my-deploy-skill`
|
|
26
26
|
|
|
27
|
-
const create_skill_md = (name) =>
|
|
27
|
+
const create_skill_md = (name) => `---
|
|
28
|
+
name: ${name}
|
|
29
|
+
description: Describe what this skill does and when to invoke it
|
|
30
|
+
---
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Describe what this skill does for the AI agent.
|
|
32
|
+
# ${name}
|
|
32
33
|
|
|
33
34
|
## Instructions
|
|
34
35
|
|
package/src/commands/publish.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
1
3
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
4
|
const { read_manifest } = require('../manifest/reader')
|
|
3
5
|
const { write_manifest } = require('../manifest/writer')
|
|
@@ -13,10 +15,11 @@ const { find_project_root } = require('../config/paths')
|
|
|
13
15
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
14
16
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
15
17
|
const { hash_directory } = require('../lock/integrity')
|
|
18
|
+
const { parse_frontmatter } = require('../utils/skill_scanner')
|
|
16
19
|
const { create_spinner } = require('../ui/spinner')
|
|
17
20
|
const { print_help, print_success, print_error, print_warn, print_hint, print_json, code } = require('../ui/output')
|
|
18
21
|
const { exit_with_error, UsageError, CliError } = require('../utils/errors')
|
|
19
|
-
const { EXIT_CODES } = require('../constants')
|
|
22
|
+
const { EXIT_CODES, SKILL_MD } = require('../constants')
|
|
20
23
|
|
|
21
24
|
const HELP_TEXT = `Usage: happyskills publish <skill-name> [options]
|
|
22
25
|
|
|
@@ -87,6 +90,31 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
87
90
|
throw new CliError(`Invalid skill.json: ${validation.errors.join(', ')}`, EXIT_CODES.ERROR)
|
|
88
91
|
}
|
|
89
92
|
|
|
93
|
+
// Validate SKILL.md frontmatter — warn about missing name/description
|
|
94
|
+
const frontmatter_warnings = []
|
|
95
|
+
try {
|
|
96
|
+
const skill_md_content = await fs.promises.readFile(path.join(dir, SKILL_MD), 'utf-8')
|
|
97
|
+
const frontmatter = parse_frontmatter(skill_md_content)
|
|
98
|
+
if (!frontmatter) {
|
|
99
|
+
frontmatter_warnings.push('SKILL.md has no YAML frontmatter (---). Without frontmatter, Claude cannot auto-invoke this skill.')
|
|
100
|
+
} else {
|
|
101
|
+
if (!frontmatter.name) {
|
|
102
|
+
frontmatter_warnings.push('SKILL.md frontmatter is missing "name". Add name: <skill-name> to the frontmatter.')
|
|
103
|
+
}
|
|
104
|
+
if (!frontmatter.description) {
|
|
105
|
+
frontmatter_warnings.push('SKILL.md frontmatter is missing "description". The description is the #1 factor for Claude auto-invocation quality. Without it, the skill will not be auto-invoked.')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
frontmatter_warnings.push(`No ${SKILL_MD} found. The skill may not work correctly.`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (frontmatter_warnings.length > 0) {
|
|
113
|
+
for (const warning of frontmatter_warnings) {
|
|
114
|
+
print_warn(warning)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
const spinner = create_spinner('Preparing to publish...')
|
|
91
119
|
|
|
92
120
|
let owner = args.flags.workspace
|
|
@@ -164,13 +192,15 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
164
192
|
}
|
|
165
193
|
|
|
166
194
|
if (args.flags.json) {
|
|
167
|
-
|
|
195
|
+
const json_data = {
|
|
168
196
|
skill: full_name,
|
|
169
197
|
version: manifest.version,
|
|
170
198
|
ref: push_data?.ref || `refs/tags/v${manifest.version}`,
|
|
171
199
|
commit: push_data?.commit || null,
|
|
172
200
|
bumped_from
|
|
173
|
-
}
|
|
201
|
+
}
|
|
202
|
+
if (frontmatter_warnings.length > 0) json_data.warnings = frontmatter_warnings
|
|
203
|
+
print_json({ data: json_data })
|
|
174
204
|
return
|
|
175
205
|
}
|
|
176
206
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
3
|
+
const { validate_skill_md } = require('../validation/skill_md_rules')
|
|
4
|
+
const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
5
|
+
const { validate_cross } = require('../validation/cross_rules')
|
|
6
|
+
const { file_exists } = require('../utils/fs')
|
|
7
|
+
const { skills_dir, find_project_root } = require('../config/paths')
|
|
8
|
+
const { print_help, print_json } = require('../ui/output')
|
|
9
|
+
const { bold, green, yellow, red, dim } = require('../ui/colors')
|
|
10
|
+
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
11
|
+
const { EXIT_CODES, SKILL_MD } = require('../constants')
|
|
12
|
+
|
|
13
|
+
const HELP_TEXT = `Usage: happyskills validate <skill-name> [options]
|
|
14
|
+
|
|
15
|
+
Validate a skill against all rules from the agentskills.io spec.
|
|
16
|
+
|
|
17
|
+
Arguments:
|
|
18
|
+
skill-name Name of the skill to validate
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
-g, --global Look in global skills (~/.claude/skills/)
|
|
22
|
+
--json Output as JSON
|
|
23
|
+
|
|
24
|
+
Aliases: v
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
happyskills validate my-skill
|
|
28
|
+
happyskills validate deploy-aws -g
|
|
29
|
+
happyskills v my-skill --json`
|
|
30
|
+
|
|
31
|
+
const resolve_validate_dir = async (skill_name, is_global) => {
|
|
32
|
+
const project_root = find_project_root()
|
|
33
|
+
const base = skills_dir(is_global, project_root)
|
|
34
|
+
const dir = path.join(base, skill_name)
|
|
35
|
+
|
|
36
|
+
// Check if directory exists (don't require skill.json — that's what validate checks)
|
|
37
|
+
const md_path = path.join(dir, SKILL_MD)
|
|
38
|
+
const json_path = path.join(dir, 'skill.json')
|
|
39
|
+
|
|
40
|
+
const [, md_exists] = await file_exists(md_path)
|
|
41
|
+
const [, json_exists] = await file_exists(json_path)
|
|
42
|
+
|
|
43
|
+
if (!md_exists && !json_exists) {
|
|
44
|
+
throw new UsageError(`Skill "${skill_name}" not found at ${dir}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return dir
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const format_human = (skill_name, all_results) => {
|
|
51
|
+
console.log(`\nValidating skill "${bold(skill_name)}"...\n`)
|
|
52
|
+
|
|
53
|
+
const groups = {}
|
|
54
|
+
for (const r of all_results) {
|
|
55
|
+
const key = r.file || 'General'
|
|
56
|
+
if (!groups[key]) groups[key] = []
|
|
57
|
+
groups[key].push(r)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const [file, items] of Object.entries(groups)) {
|
|
61
|
+
console.log(` ${bold(file)}`)
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
if (item.severity === 'pass') {
|
|
64
|
+
console.log(` ${green('✓')} ${item.message}`)
|
|
65
|
+
} else if (item.severity === 'warning') {
|
|
66
|
+
console.log(` ${yellow('⚠')} ${item.message}`)
|
|
67
|
+
} else {
|
|
68
|
+
console.log(` ${red('✗')} ${item.message}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
console.log()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const errors = all_results.filter(r => r.severity === 'error')
|
|
75
|
+
const warnings = all_results.filter(r => r.severity === 'warning')
|
|
76
|
+
const passed = all_results.filter(r => r.severity === 'pass')
|
|
77
|
+
|
|
78
|
+
const parts = []
|
|
79
|
+
if (warnings.length > 0) parts.push(yellow(`${warnings.length} warning${warnings.length === 1 ? '' : 's'}`))
|
|
80
|
+
if (errors.length > 0) parts.push(red(`${errors.length} error${errors.length === 1 ? '' : 's'}`))
|
|
81
|
+
if (errors.length === 0 && warnings.length === 0) parts.push(green('all checks passed'))
|
|
82
|
+
|
|
83
|
+
console.log(dim(`${passed.length} passed, `) + parts.join(', '))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const format_json = (skill_name, all_results) => {
|
|
87
|
+
const errors = all_results.filter(r => r.severity === 'error').map(({ severity, ...rest }) => rest)
|
|
88
|
+
const warnings = all_results.filter(r => r.severity === 'warning').map(({ severity, ...rest }) => rest)
|
|
89
|
+
const passed = all_results.filter(r => r.severity === 'pass')
|
|
90
|
+
const failed = all_results.filter(r => r.severity === 'error')
|
|
91
|
+
const warned = all_results.filter(r => r.severity === 'warning')
|
|
92
|
+
|
|
93
|
+
print_json({
|
|
94
|
+
data: {
|
|
95
|
+
skill: skill_name,
|
|
96
|
+
valid: failed.length === 0,
|
|
97
|
+
errors,
|
|
98
|
+
warnings,
|
|
99
|
+
checks_passed: passed.length,
|
|
100
|
+
checks_failed: failed.length,
|
|
101
|
+
checks_warned: warned.length
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const run = (args) => catch_errors('Validate failed', async () => {
|
|
107
|
+
if (args.flags._show_help) {
|
|
108
|
+
print_help(HELP_TEXT)
|
|
109
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const skill_name = args._[0]
|
|
113
|
+
if (!skill_name) {
|
|
114
|
+
throw new UsageError('Skill name required. Usage: happyskills validate <skill-name>')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const is_global = !!args.flags.global
|
|
118
|
+
const skill_dir = await resolve_validate_dir(skill_name, is_global)
|
|
119
|
+
const dir_name = path.basename(skill_dir)
|
|
120
|
+
|
|
121
|
+
// Run all rule modules
|
|
122
|
+
const [md_err, md_data] = await validate_skill_md(skill_dir, dir_name)
|
|
123
|
+
if (md_err) throw md_err
|
|
124
|
+
|
|
125
|
+
const [json_err, json_data] = await validate_skill_json(skill_dir)
|
|
126
|
+
if (json_err) throw json_err
|
|
127
|
+
|
|
128
|
+
const [cross_err, cross_results] = await validate_cross(
|
|
129
|
+
skill_dir,
|
|
130
|
+
md_data.frontmatter,
|
|
131
|
+
json_data.manifest,
|
|
132
|
+
md_data.content
|
|
133
|
+
)
|
|
134
|
+
if (cross_err) throw cross_err
|
|
135
|
+
|
|
136
|
+
const all_results = [...md_data.results, ...json_data.results, ...cross_results]
|
|
137
|
+
|
|
138
|
+
if (args.flags.json) {
|
|
139
|
+
format_json(skill_name, all_results)
|
|
140
|
+
} else {
|
|
141
|
+
format_human(skill_name, all_results)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const has_errors = all_results.some(r => r.severity === 'error')
|
|
145
|
+
process.exit(has_errors ? EXIT_CODES.ERROR : EXIT_CODES.SUCCESS)
|
|
146
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
147
|
+
|
|
148
|
+
module.exports = { run }
|
package/src/constants.js
CHANGED
|
@@ -28,7 +28,8 @@ const COMMAND_ALIASES = {
|
|
|
28
28
|
s: 'search',
|
|
29
29
|
r: 'refresh',
|
|
30
30
|
up: 'update',
|
|
31
|
-
pub: 'publish'
|
|
31
|
+
pub: 'publish',
|
|
32
|
+
v: 'validate'
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
const COMMANDS = [
|
|
@@ -48,6 +49,7 @@ const COMMANDS = [
|
|
|
48
49
|
'whoami',
|
|
49
50
|
'fork',
|
|
50
51
|
'setup',
|
|
52
|
+
'validate',
|
|
51
53
|
'self-update'
|
|
52
54
|
]
|
|
53
55
|
|
package/src/index.js
CHANGED
|
@@ -93,6 +93,7 @@ Commands:
|
|
|
93
93
|
refresh Check + update all outdated skills (alias: r)
|
|
94
94
|
update [owner/skill] Upgrade to latest versions (alias: up)
|
|
95
95
|
publish Push skill to registry (alias: pub)
|
|
96
|
+
validate <skill-name> Validate skill against all rules (alias: v)
|
|
96
97
|
fork <owner/skill> Fork a skill to your workspace
|
|
97
98
|
setup Install the HappySkills CLI skill globally
|
|
98
99
|
self-update Upgrade happyskills CLI to latest version
|
|
@@ -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', 'refresh', '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
|
}
|
|
@@ -165,6 +165,9 @@ describe('CLI — command --help', () => {
|
|
|
165
165
|
['refresh', 'Aliases:'],
|
|
166
166
|
['update', 'Aliases:'],
|
|
167
167
|
['publish', 'Aliases:'],
|
|
168
|
+
['validate', 'Arguments:'],
|
|
169
|
+
['validate', 'Aliases:'],
|
|
170
|
+
['validate', 'Examples:'],
|
|
168
171
|
['fork', 'Arguments:'],
|
|
169
172
|
['init', 'Examples:'],
|
|
170
173
|
['setup', 'Options:'],
|
|
@@ -199,6 +202,7 @@ describe('CLI — command aliases', () => {
|
|
|
199
202
|
['r', 'refresh'],
|
|
200
203
|
['up', 'update'],
|
|
201
204
|
['pub', 'publish'],
|
|
205
|
+
['v', 'validate'],
|
|
202
206
|
]
|
|
203
207
|
|
|
204
208
|
for (const [alias, canonical] of alias_cases) {
|
|
@@ -478,3 +482,127 @@ describe('CLI — self-update command', () => {
|
|
|
478
482
|
assert.strictEqual(out.error.exit_code, 4)
|
|
479
483
|
})
|
|
480
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
|
+
})
|