happyskills 0.10.0 → 0.11.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 +2 -2
- package/src/commands/convert.js +22 -9
- package/src/commands/init.js +28 -8
- package/src/commands/list.js +16 -4
- package/src/commands/publish.js +18 -10
- package/src/commands/search.js +21 -8
- package/src/commands/validate.js +18 -5
- package/src/constants.js +5 -0
- package/src/engine/installer.js +9 -1
- package/src/validation/cross_rules.js +6 -1
- package/src/validation/cross_rules.test.js +22 -0
- package/src/validation/skill_json_rules.js +28 -1
- package/src/validation/skill_json_rules.test.js +68 -0
- package/src/validation/skill_md_rules.js +8 -2
- package/src/validation/skill_md_rules.test.js +37 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.0] - 2026-03-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add `--kit` flag to `init` command for scaffolding kits — generates `skill.json` with `type: "kit"` and `SKILL.md` without frontmatter
|
|
14
|
+
- Add `--type` flag to `search` command to filter results by type (`skill` or `kit`)
|
|
15
|
+
- Add `[kit]` badge to `list` and `search` table output for kit-type entries
|
|
16
|
+
- Add `type` field to `list` and `search` JSON output
|
|
17
|
+
- Add `type` field to lock file entries written by the installer (read from the downloaded package's `skill.json`)
|
|
18
|
+
- Add `validate_type` rule to skill.json validation — warns if `type` is absent, errors if invalid
|
|
19
|
+
- Add kit-specific validation: kits skip SKILL.md frontmatter checks, line count warnings, name match, and code block scanning
|
|
20
|
+
- Add kit-specific warning when a kit has no dependencies
|
|
21
|
+
|
|
22
|
+
## [0.10.1] - 2026-03-11
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Fix `publish` and `convert` dependency/registry checks returning 403 for private skills by passing authenticated requests to `get_repo`
|
|
26
|
+
- Fix `publish` failing with "Could not determine owner" on first publish when no lock entry exists — owner resolution is now soft, falling back to workspace selection
|
|
27
|
+
- Fix `publish` not creating a lock entry on first publish — a new entry with `requested_by: ['__root__']` is now auto-created after a successful push
|
|
28
|
+
- Fix `convert` overwriting enriched `skill.json` fields (authors, license, repository, dependencies, etc.) — now merges with the existing manifest, preserving fields set during post-init enrichment
|
|
29
|
+
|
|
10
30
|
## [0.10.0] - 2026-03-11
|
|
11
31
|
|
|
12
32
|
### Added
|
package/package.json
CHANGED
package/src/api/repos.js
CHANGED
|
@@ -40,8 +40,8 @@ const get_refs = (owner, repo) => catch_errors(`Get refs for ${owner}/${repo} fa
|
|
|
40
40
|
return data
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
-
const get_repo = (owner, repo) => catch_errors(`Get repo ${owner}/${repo} failed`, async () => {
|
|
44
|
-
const [errors, data] = await client.get(`/repos/${owner}/${repo}`, { auth: false })
|
|
43
|
+
const get_repo = (owner, repo, options = {}) => catch_errors(`Get repo ${owner}/${repo} failed`, async () => {
|
|
44
|
+
const [errors, data] = await client.get(`/repos/${owner}/${repo}`, { auth: options.auth || false })
|
|
45
45
|
if (errors) throw errors[errors.length - 1]
|
|
46
46
|
return data
|
|
47
47
|
})
|
package/src/commands/convert.js
CHANGED
|
@@ -6,6 +6,7 @@ const { require_token } = require('../auth/token_store')
|
|
|
6
6
|
const repos_api = require('../api/repos')
|
|
7
7
|
const workspaces_api = require('../api/workspaces')
|
|
8
8
|
const { parse_frontmatter } = require('../utils/skill_scanner')
|
|
9
|
+
const { read_manifest } = require('../manifest/reader')
|
|
9
10
|
const { write_manifest } = require('../manifest/writer')
|
|
10
11
|
const { read_lock } = require('../lock/reader')
|
|
11
12
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
@@ -95,7 +96,7 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
95
96
|
const fm_keywords = frontmatter?.keywords
|
|
96
97
|
? frontmatter.keywords.split(',').map(k => k.trim()).filter(Boolean)
|
|
97
98
|
: []
|
|
98
|
-
const
|
|
99
|
+
const display_keywords = args.flags.keywords
|
|
99
100
|
? args.flags.keywords.split(',').map(k => k.trim()).filter(Boolean)
|
|
100
101
|
: fm_keywords
|
|
101
102
|
|
|
@@ -126,7 +127,7 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
126
127
|
const workspace = choose_workspace(workspaces, args.flags.workspace)
|
|
127
128
|
|
|
128
129
|
spinner.update('Checking registry...')
|
|
129
|
-
const [repo_err] = await repos_api.get_repo(workspace.slug, skill_name)
|
|
130
|
+
const [repo_err] = await repos_api.get_repo(workspace.slug, skill_name, { auth: true })
|
|
130
131
|
if (!repo_err) {
|
|
131
132
|
spinner.stop()
|
|
132
133
|
throw new CliError(`Skill "${workspace.slug}/${skill_name}" already exists in the registry. Use \`happyskills publish\` instead to push a new version.`, EXIT_CODES.ERROR)
|
|
@@ -140,7 +141,7 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
140
141
|
console.error(` Workspace: ${workspace.slug}`)
|
|
141
142
|
console.error(` Version: ${version}`)
|
|
142
143
|
console.error(` Description: ${description || '(none)'}`)
|
|
143
|
-
console.error(` Keywords: ${
|
|
144
|
+
console.error(` Keywords: ${display_keywords.length ? display_keywords.join(', ') : '(none)'}`)
|
|
144
145
|
console.error('')
|
|
145
146
|
const answer = await confirm('Convert this skill to a managed package? [y/N] ')
|
|
146
147
|
if (answer !== 'y' && answer !== 'yes') {
|
|
@@ -151,7 +152,19 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
151
152
|
|
|
152
153
|
const pub_spinner = create_spinner('Writing skill.json...')
|
|
153
154
|
|
|
154
|
-
const
|
|
155
|
+
const [, existing_manifest] = await read_manifest(skill_dir)
|
|
156
|
+
const merged_version = args.flags.version || (existing_manifest?.version) || '1.0.0'
|
|
157
|
+
const merged_description = (existing_manifest?.description) || description
|
|
158
|
+
const merged_keywords = args.flags.keywords
|
|
159
|
+
? args.flags.keywords.split(',').map(k => k.trim()).filter(Boolean)
|
|
160
|
+
: (existing_manifest?.keywords?.length ? existing_manifest.keywords : fm_keywords)
|
|
161
|
+
const manifest = {
|
|
162
|
+
...(existing_manifest || {}),
|
|
163
|
+
name: skill_name,
|
|
164
|
+
version: merged_version,
|
|
165
|
+
description: merged_description,
|
|
166
|
+
keywords: merged_keywords
|
|
167
|
+
}
|
|
155
168
|
const [manifest_err] = await write_manifest(skill_dir, manifest)
|
|
156
169
|
if (manifest_err) { pub_spinner.fail('Failed to write skill.json'); throw e('Failed to write skill.json', manifest_err) }
|
|
157
170
|
|
|
@@ -163,12 +176,12 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
163
176
|
const full_name = `${workspace.slug}/${skill_name}`
|
|
164
177
|
const updates = {
|
|
165
178
|
[full_name]: {
|
|
166
|
-
version,
|
|
179
|
+
version: merged_version,
|
|
167
180
|
ref: null,
|
|
168
181
|
commit: null,
|
|
169
182
|
integrity: integrity || null,
|
|
170
183
|
requested_by: ['__root__'],
|
|
171
|
-
dependencies: {}
|
|
184
|
+
dependencies: manifest.dependencies || {}
|
|
172
185
|
}
|
|
173
186
|
}
|
|
174
187
|
|
|
@@ -176,14 +189,14 @@ const run = (args) => catch_errors('Convert failed', async () => {
|
|
|
176
189
|
const [lock_err] = await write_lock(lock_dir, new_skills)
|
|
177
190
|
if (lock_err) { pub_spinner.fail('Failed to write lock file'); throw e('Lock write failed', lock_err) }
|
|
178
191
|
|
|
179
|
-
pub_spinner.succeed(`Converted ${full_name}@${
|
|
192
|
+
pub_spinner.succeed(`Converted ${full_name}@${merged_version}`)
|
|
180
193
|
|
|
181
194
|
if (args.flags.json) {
|
|
182
195
|
const json_data = {
|
|
183
196
|
skill: full_name,
|
|
184
|
-
version,
|
|
197
|
+
version: merged_version,
|
|
185
198
|
workspace: workspace.slug,
|
|
186
|
-
description:
|
|
199
|
+
description: merged_description || ''
|
|
187
200
|
}
|
|
188
201
|
if (frontmatter_warnings.length > 0) json_data.warnings = frontmatter_warnings
|
|
189
202
|
print_json({ data: json_data })
|
package/src/commands/init.js
CHANGED
|
@@ -4,7 +4,7 @@ const { write_manifest } = require('../manifest/writer')
|
|
|
4
4
|
const { write_file, file_exists, ensure_dir } = require('../utils/fs')
|
|
5
5
|
const { print_success, print_error, print_help, print_hint, print_json, code } = require('../ui/output')
|
|
6
6
|
const { exit_with_error, CliError, UsageError } = require('../utils/errors')
|
|
7
|
-
const { SKILL_JSON, SKILL_MD, EXIT_CODES } = require('../constants')
|
|
7
|
+
const { SKILL_JSON, SKILL_MD, EXIT_CODES, SKILL_TYPES } = require('../constants')
|
|
8
8
|
const { find_project_root, skills_dir } = require('../config/paths')
|
|
9
9
|
|
|
10
10
|
const HELP_TEXT = `Usage: happyskills init <name> [options]
|
|
@@ -20,11 +20,13 @@ Arguments:
|
|
|
20
20
|
|
|
21
21
|
Options:
|
|
22
22
|
-g, --global Create in global skills (~/.claude/skills/)
|
|
23
|
+
--kit Initialize as a kit (collection of skills)
|
|
23
24
|
--json Output as JSON
|
|
24
25
|
|
|
25
26
|
Examples:
|
|
26
27
|
happyskills init my-deploy-skill
|
|
27
|
-
happyskills init my-deploy-skill -g
|
|
28
|
+
happyskills init my-deploy-skill -g
|
|
29
|
+
happyskills init my-kit --kit`
|
|
28
30
|
|
|
29
31
|
const create_skill_md = (name) => `---
|
|
30
32
|
name: ${name}
|
|
@@ -38,11 +40,25 @@ description: Describe what this skill does and when to invoke it
|
|
|
38
40
|
Provide step-by-step instructions for the AI agent to follow.
|
|
39
41
|
`
|
|
40
42
|
|
|
41
|
-
const
|
|
43
|
+
const create_kit_md = (name) => `# ${name}
|
|
44
|
+
|
|
45
|
+
This is a kit — a curated collection of skills installed together in one command.
|
|
46
|
+
|
|
47
|
+
## What's Included
|
|
48
|
+
|
|
49
|
+
Add the skills this kit bundles and why they work well together.
|
|
50
|
+
|
|
51
|
+
## When to Use
|
|
52
|
+
|
|
53
|
+
Describe the project type or workflow this kit is designed for.
|
|
54
|
+
`
|
|
55
|
+
|
|
56
|
+
const create_manifest = (name, is_kit = false) => ({
|
|
42
57
|
name,
|
|
43
58
|
version: '0.1.0',
|
|
44
59
|
description: '',
|
|
45
60
|
keywords: [],
|
|
61
|
+
...(is_kit ? { type: SKILL_TYPES.KIT } : {}),
|
|
46
62
|
dependencies: {}
|
|
47
63
|
})
|
|
48
64
|
|
|
@@ -58,6 +74,7 @@ const run = (args) => catch_errors('Init failed', async () => {
|
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
const is_global = args.flags.global || false
|
|
77
|
+
const is_kit = args.flags.kit || false
|
|
61
78
|
const project_root = find_project_root()
|
|
62
79
|
const base_skills_dir = skills_dir(is_global, project_root)
|
|
63
80
|
const dir = path.join(base_skills_dir, name)
|
|
@@ -70,7 +87,7 @@ const run = (args) => catch_errors('Init failed', async () => {
|
|
|
70
87
|
const [dir_err] = await ensure_dir(dir)
|
|
71
88
|
if (dir_err) throw e('Failed to create skill directory', dir_err)
|
|
72
89
|
|
|
73
|
-
const manifest = create_manifest(name)
|
|
90
|
+
const manifest = create_manifest(name, is_kit)
|
|
74
91
|
const [write_err] = await write_manifest(dir, manifest)
|
|
75
92
|
if (write_err) throw e('Failed to write manifest', write_err)
|
|
76
93
|
|
|
@@ -78,19 +95,22 @@ const run = (args) => catch_errors('Init failed', async () => {
|
|
|
78
95
|
|
|
79
96
|
const [, md_exists] = await file_exists(path.join(dir, SKILL_MD))
|
|
80
97
|
if (!md_exists) {
|
|
81
|
-
const
|
|
98
|
+
const md_content = is_kit ? create_kit_md(name) : create_skill_md(name)
|
|
99
|
+
const [md_err] = await write_file(path.join(dir, SKILL_MD), md_content)
|
|
82
100
|
if (md_err) throw e('Failed to write SKILL.md', md_err)
|
|
83
101
|
files_created.push(SKILL_MD)
|
|
84
102
|
}
|
|
85
103
|
|
|
104
|
+
const label = is_kit ? 'kit' : 'skill'
|
|
105
|
+
|
|
86
106
|
if (args.flags.json) {
|
|
87
|
-
print_json({ data: { name, files_created, directory: dir } })
|
|
107
|
+
print_json({ data: { name, type: is_kit ? SKILL_TYPES.KIT : SKILL_TYPES.SKILL, files_created, directory: dir } })
|
|
88
108
|
return
|
|
89
109
|
}
|
|
90
110
|
|
|
91
|
-
print_success(`Initialized
|
|
111
|
+
print_success(`Initialized ${label} "${name}" at ${dir}`)
|
|
92
112
|
console.log(` ${SKILL_JSON} — manifest`)
|
|
93
|
-
console.log(` ${SKILL_MD} — instructions`)
|
|
113
|
+
console.log(` ${SKILL_MD} — ${is_kit ? 'kit description' : 'instructions'}`)
|
|
94
114
|
console.log()
|
|
95
115
|
print_hint(`Edit these files, then run ${code('happyskills publish')} to share.`)
|
|
96
116
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
package/src/commands/list.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
3
3
|
const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
|
|
4
|
-
const { file_exists } = require('../utils/fs')
|
|
4
|
+
const { file_exists, read_json } = require('../utils/fs')
|
|
5
5
|
const { scan_skills_dir } = require('../utils/skill_scanner')
|
|
6
6
|
const { print_help, print_table, print_json, print_info } = require('../ui/output')
|
|
7
7
|
const { exit_with_error } = require('../utils/errors')
|
|
8
|
-
const { EXIT_CODES } = require('../constants')
|
|
8
|
+
const { EXIT_CODES, SKILL_JSON, SKILL_TYPES } = require('../constants')
|
|
9
9
|
|
|
10
10
|
const HELP_TEXT = `Usage: happyskills list [options]
|
|
11
11
|
|
|
@@ -49,6 +49,15 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
49
49
|
return
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Resolve type for each managed entry from lock data or skill.json on disk
|
|
53
|
+
const resolve_type = async (name, data) => {
|
|
54
|
+
if (data.type) return data.type
|
|
55
|
+
const dir = skill_install_dir(base_dir, name.split('/')[1])
|
|
56
|
+
const json_path = require('path').join(dir, SKILL_JSON)
|
|
57
|
+
const [, manifest] = await read_json(json_path)
|
|
58
|
+
return manifest?.type || SKILL_TYPES.SKILL
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
if (args.flags.json) {
|
|
53
62
|
const skills_map = {}
|
|
54
63
|
for (const [name, data] of managed_entries) {
|
|
@@ -56,7 +65,8 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
56
65
|
const [, exists] = await file_exists(dir)
|
|
57
66
|
const status = exists ? 'installed' : 'missing'
|
|
58
67
|
const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
|
|
59
|
-
|
|
68
|
+
const type = await resolve_type(name, data)
|
|
69
|
+
skills_map[name] = { version: data.version, type, source, status }
|
|
60
70
|
}
|
|
61
71
|
const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
|
|
62
72
|
print_json({ data: { skills: skills_map, external } })
|
|
@@ -69,7 +79,9 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
69
79
|
const [, exists] = await file_exists(dir)
|
|
70
80
|
const status = exists ? 'installed' : 'missing'
|
|
71
81
|
const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
|
|
72
|
-
|
|
82
|
+
const type = await resolve_type(name, data)
|
|
83
|
+
const display_name = type === SKILL_TYPES.KIT ? `${name} [kit]` : name
|
|
84
|
+
rows.push([display_name, data.version, source, status])
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
for (const s of external_skills) {
|
package/src/commands/publish.js
CHANGED
|
@@ -86,12 +86,13 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
// Run full validation — block on errors, show warnings
|
|
89
|
+
const skill_type = manifest.type
|
|
89
90
|
const dir_name = path.basename(dir)
|
|
90
|
-
const [md_err, md_data] = await validate_skill_md(dir, dir_name)
|
|
91
|
+
const [md_err, md_data] = await validate_skill_md(dir, dir_name, skill_type)
|
|
91
92
|
if (md_err) throw md_err
|
|
92
93
|
const [json_err, json_data] = await validate_skill_json(dir)
|
|
93
94
|
if (json_err) throw json_err
|
|
94
|
-
const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content)
|
|
95
|
+
const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content, skill_type)
|
|
95
96
|
if (cross_err) throw cross_err
|
|
96
97
|
|
|
97
98
|
const all_results = [...md_data.results, ...json_data.results, ...cross_results]
|
|
@@ -124,12 +125,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
124
125
|
|
|
125
126
|
let owner = args.flags.workspace
|
|
126
127
|
if (!owner) {
|
|
127
|
-
const [
|
|
128
|
-
if (
|
|
129
|
-
spinner.fail('Could not determine owner')
|
|
130
|
-
throw e('Could not determine skill owner from lock file. Use --workspace <slug> to specify.', owner_err)
|
|
131
|
-
}
|
|
132
|
-
owner = resolved_owner
|
|
128
|
+
const [, resolved_owner] = await resolve_skill_owner(skill_name)
|
|
129
|
+
if (resolved_owner) owner = resolved_owner
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
const [ws_err, workspaces] = await workspaces_api.list_workspaces()
|
|
@@ -144,7 +141,7 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
144
141
|
const parts = dep.split('/')
|
|
145
142
|
if (parts.length !== 2) return { dep, missing: true }
|
|
146
143
|
const [dep_owner, dep_name] = parts
|
|
147
|
-
const [err] = await repos_api.get_repo(dep_owner, dep_name)
|
|
144
|
+
const [err] = await repos_api.get_repo(dep_owner, dep_name, { auth: true })
|
|
148
145
|
return { dep, missing: !!err }
|
|
149
146
|
}))
|
|
150
147
|
const missing = dep_results.filter(r => r.missing)
|
|
@@ -182,8 +179,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
182
179
|
const all_skills = get_all_locked_skills(lock_data)
|
|
183
180
|
const suffix = `/${skill_name}`
|
|
184
181
|
const lock_key = Object.keys(all_skills).find(k => k.endsWith(suffix))
|
|
182
|
+
const [hash_err, integrity] = await hash_directory(dir)
|
|
185
183
|
if (lock_key && all_skills[lock_key]) {
|
|
186
|
-
const [hash_err, integrity] = await hash_directory(dir)
|
|
187
184
|
const updated_entry = {
|
|
188
185
|
...all_skills[lock_key],
|
|
189
186
|
version: manifest.version,
|
|
@@ -193,6 +190,17 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
193
190
|
if (!hash_err && integrity) updated_entry.integrity = integrity
|
|
194
191
|
const updated_skills = update_lock_skills(lock_data, { [lock_key]: updated_entry })
|
|
195
192
|
await write_lock(project_root, updated_skills)
|
|
193
|
+
} else {
|
|
194
|
+
const new_entry = {
|
|
195
|
+
version: manifest.version,
|
|
196
|
+
ref: push_data?.ref || `refs/tags/v${manifest.version}`,
|
|
197
|
+
commit: push_data?.commit || null,
|
|
198
|
+
integrity: (!hash_err && integrity) ? integrity : null,
|
|
199
|
+
requested_by: ['__root__'],
|
|
200
|
+
dependencies: manifest.dependencies || {}
|
|
201
|
+
}
|
|
202
|
+
const updated_skills = update_lock_skills(lock_data, { [full_name]: new_entry })
|
|
203
|
+
await write_lock(project_root, updated_skills)
|
|
196
204
|
}
|
|
197
205
|
}
|
|
198
206
|
|
package/src/commands/search.js
CHANGED
|
@@ -2,7 +2,7 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
|
2
2
|
const repos_api = require('../api/repos')
|
|
3
3
|
const { print_help, print_table, print_json, print_info } = require('../ui/output')
|
|
4
4
|
const { exit_with_error, UsageError, AuthError } = require('../utils/errors')
|
|
5
|
-
const { EXIT_CODES } = require('../constants')
|
|
5
|
+
const { EXIT_CODES, VALID_SKILL_TYPES } = require('../constants')
|
|
6
6
|
const { load_token } = require('../auth/token_store')
|
|
7
7
|
|
|
8
8
|
const HELP_TEXT = `Usage: happyskills search [query] [options]
|
|
@@ -17,6 +17,7 @@ Options:
|
|
|
17
17
|
--mine Search across all your workspaces
|
|
18
18
|
--personal Search only your personal workspace
|
|
19
19
|
--tags <tags> Filter by tags (comma-separated)
|
|
20
|
+
--type <type> Filter by type (skill, kit)
|
|
20
21
|
--json Output as JSON
|
|
21
22
|
|
|
22
23
|
Aliases: s
|
|
@@ -25,6 +26,7 @@ Examples:
|
|
|
25
26
|
happyskills search deploy
|
|
26
27
|
happyskills search --mine
|
|
27
28
|
happyskills search deploy --workspace acme
|
|
29
|
+
happyskills search --type kit
|
|
28
30
|
happyskills s --personal --json`
|
|
29
31
|
|
|
30
32
|
const run = (args) => catch_errors('Search failed', async () => {
|
|
@@ -34,13 +36,17 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
const query = args._.join(' ') || null
|
|
37
|
-
const { mine, personal, workspace, tags } = args.flags
|
|
39
|
+
const { mine, personal, workspace, tags, type } = args.flags
|
|
38
40
|
const has_scope_flag = mine || personal || workspace
|
|
39
41
|
|
|
40
|
-
if (!query && !has_scope_flag && !tags) {
|
|
42
|
+
if (!query && !has_scope_flag && !tags && !type) {
|
|
41
43
|
throw new UsageError('Please provide a search query or use --mine, --personal, or --workspace.')
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
if (type && !VALID_SKILL_TYPES.includes(type)) {
|
|
47
|
+
throw new UsageError(`--type must be one of: ${VALID_SKILL_TYPES.join(', ')}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
44
50
|
const scope = mine ? 'mine' : personal ? 'personal' : undefined
|
|
45
51
|
|
|
46
52
|
if (has_scope_flag) {
|
|
@@ -54,6 +60,7 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
54
60
|
if (scope) options.scope = scope
|
|
55
61
|
if (workspace) options.workspace = workspace
|
|
56
62
|
if (tags) options.tags = tags
|
|
63
|
+
if (type) options.type = type
|
|
57
64
|
|
|
58
65
|
const [errors, results] = await repos_api.search(query, options)
|
|
59
66
|
if (errors) throw e('Search failed', errors)
|
|
@@ -74,6 +81,7 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
74
81
|
if (args.flags.json) {
|
|
75
82
|
const mapped = items.map(item => ({
|
|
76
83
|
skill: `${item.owner || item.workspace_slug}/${item.name}`,
|
|
84
|
+
type: item.type || 'skill',
|
|
77
85
|
description: item.description || '',
|
|
78
86
|
version: item.latest_version || item.version || '-',
|
|
79
87
|
visibility: item.visibility || 'public'
|
|
@@ -82,11 +90,16 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
82
90
|
return
|
|
83
91
|
}
|
|
84
92
|
|
|
85
|
-
const rows = items.map(item =>
|
|
86
|
-
|
|
87
|
-
item.
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
const rows = items.map(item => {
|
|
94
|
+
const item_type = item.type || 'skill'
|
|
95
|
+
const name = `${item.owner || item.workspace_slug}/${item.name}`
|
|
96
|
+
const display_name = item_type === 'kit' ? `${name} [kit]` : name
|
|
97
|
+
return [
|
|
98
|
+
display_name,
|
|
99
|
+
item.description || '',
|
|
100
|
+
item.latest_version || item.version || '-'
|
|
101
|
+
]
|
|
102
|
+
})
|
|
90
103
|
|
|
91
104
|
print_table(['Skill', 'Description', 'Version'], rows)
|
|
92
105
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
package/src/commands/validate.js
CHANGED
|
@@ -3,12 +3,12 @@ const { error: { catch_errors } } = require('puffy-core')
|
|
|
3
3
|
const { validate_skill_md } = require('../validation/skill_md_rules')
|
|
4
4
|
const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
5
5
|
const { validate_cross } = require('../validation/cross_rules')
|
|
6
|
-
const { file_exists } = require('../utils/fs')
|
|
6
|
+
const { file_exists, read_json } = require('../utils/fs')
|
|
7
7
|
const { skills_dir, find_project_root } = require('../config/paths')
|
|
8
8
|
const { print_help, print_json } = require('../ui/output')
|
|
9
9
|
const { bold, green, yellow, red, dim } = require('../ui/colors')
|
|
10
10
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
11
|
-
const { EXIT_CODES, SKILL_MD } = require('../constants')
|
|
11
|
+
const { EXIT_CODES, SKILL_MD, SKILL_JSON, SKILL_TYPES } = require('../constants')
|
|
12
12
|
|
|
13
13
|
const HELP_TEXT = `Usage: happyskills validate <skill-name> [options]
|
|
14
14
|
|
|
@@ -118,8 +118,19 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
118
118
|
const skill_dir = await resolve_validate_dir(skill_name, is_global)
|
|
119
119
|
const dir_name = path.basename(skill_dir)
|
|
120
120
|
|
|
121
|
+
// Read skill_type from skill.json if it exists
|
|
122
|
+
let skill_type
|
|
123
|
+
const json_path = path.join(skill_dir, SKILL_JSON)
|
|
124
|
+
const [, json_exists] = await file_exists(json_path)
|
|
125
|
+
if (json_exists) {
|
|
126
|
+
const [, raw_manifest] = await read_json(json_path)
|
|
127
|
+
if (raw_manifest) skill_type = raw_manifest.type
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const is_kit = skill_type === SKILL_TYPES.KIT
|
|
131
|
+
|
|
121
132
|
// Run all rule modules
|
|
122
|
-
const [md_err, md_data] = await validate_skill_md(skill_dir, dir_name)
|
|
133
|
+
const [md_err, md_data] = await validate_skill_md(skill_dir, dir_name, skill_type)
|
|
123
134
|
if (md_err) throw md_err
|
|
124
135
|
|
|
125
136
|
const [json_err, json_data] = await validate_skill_json(skill_dir)
|
|
@@ -129,16 +140,18 @@ const run = (args) => catch_errors('Validate failed', async () => {
|
|
|
129
140
|
skill_dir,
|
|
130
141
|
md_data.frontmatter,
|
|
131
142
|
json_data.manifest,
|
|
132
|
-
md_data.content
|
|
143
|
+
md_data.content,
|
|
144
|
+
skill_type
|
|
133
145
|
)
|
|
134
146
|
if (cross_err) throw cross_err
|
|
135
147
|
|
|
136
148
|
const all_results = [...md_data.results, ...json_data.results, ...cross_results]
|
|
149
|
+
const type_label = is_kit ? ' [kit]' : ''
|
|
137
150
|
|
|
138
151
|
if (args.flags.json) {
|
|
139
152
|
format_json(skill_name, all_results)
|
|
140
153
|
} else {
|
|
141
|
-
format_human(skill_name, all_results)
|
|
154
|
+
format_human(skill_name + type_label, all_results)
|
|
142
155
|
}
|
|
143
156
|
|
|
144
157
|
const has_errors = all_results.some(r => r.severity === 'error')
|
package/src/constants.js
CHANGED
|
@@ -12,6 +12,9 @@ const EXIT_CODES = {
|
|
|
12
12
|
NETWORK: 4
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const SKILL_TYPES = { SKILL: 'skill', KIT: 'kit' }
|
|
16
|
+
const VALID_SKILL_TYPES = ['skill', 'kit']
|
|
17
|
+
|
|
15
18
|
const LOCK_VERSION = 1
|
|
16
19
|
|
|
17
20
|
const SKILL_JSON = 'skill.json'
|
|
@@ -59,6 +62,8 @@ module.exports = {
|
|
|
59
62
|
API_URL,
|
|
60
63
|
CLI_VERSION,
|
|
61
64
|
EXIT_CODES,
|
|
65
|
+
SKILL_TYPES,
|
|
66
|
+
VALID_SKILL_TYPES,
|
|
62
67
|
LOCK_VERSION,
|
|
63
68
|
SKILL_JSON,
|
|
64
69
|
SKILL_MD,
|
package/src/engine/installer.js
CHANGED
|
@@ -9,7 +9,8 @@ const { hash_directory, verify_integrity } = require('../lock/integrity')
|
|
|
9
9
|
const { read_lock, get_locked_skill } = require('../lock/reader')
|
|
10
10
|
const { write_lock, update_lock_skills } = require('../lock/writer')
|
|
11
11
|
const { skills_dir, tmp_dir, skill_install_dir, lock_root } = require('../config/paths')
|
|
12
|
-
const { ensure_dir, remove_dir, file_exists } = require('../utils/fs')
|
|
12
|
+
const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs')
|
|
13
|
+
const { SKILL_JSON } = require('../constants')
|
|
13
14
|
const { create_spinner } = require('../ui/spinner')
|
|
14
15
|
const { print_success, print_warn } = require('../ui/output')
|
|
15
16
|
|
|
@@ -137,6 +138,12 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
137
138
|
const final_dir = skill_install_dir(base_dir, name)
|
|
138
139
|
const [, integrity] = await hash_directory(final_dir)
|
|
139
140
|
|
|
141
|
+
// Read type from the installed package's skill.json
|
|
142
|
+
let pkg_type
|
|
143
|
+
const pkg_json_path = path.join(final_dir, SKILL_JSON)
|
|
144
|
+
const [, pkg_manifest] = await read_json(pkg_json_path)
|
|
145
|
+
if (pkg_manifest?.type) pkg_type = pkg_manifest.type
|
|
146
|
+
|
|
140
147
|
updates[pkg.skill] = {
|
|
141
148
|
version: pkg.version,
|
|
142
149
|
ref: pkg.ref,
|
|
@@ -144,6 +151,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
144
151
|
integrity: integrity || null,
|
|
145
152
|
requested_by: pkg.skill === skill ? ['__root__'] : [skill],
|
|
146
153
|
dependencies: pkg.dependencies || {},
|
|
154
|
+
...(pkg_type ? { type: pkg_type } : {}),
|
|
147
155
|
...(pkg.forced ? { forced: true } : {})
|
|
148
156
|
}
|
|
149
157
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const { error: { catch_errors } } = require('puffy-core')
|
|
4
|
+
const { SKILL_TYPES } = require('../constants')
|
|
4
5
|
|
|
5
6
|
const EXEC_LANGS = new Set(['python', 'bash', 'sh', 'javascript', 'js', 'typescript', 'ts', 'ruby', 'go', 'rust'])
|
|
6
7
|
const SCRIPT_MARKERS = /^\s*(#!\/|import |from |require\(|def |class |function |const |let |var |export |module\.|package |fn |func |pub fn)/
|
|
@@ -34,9 +35,13 @@ const scan_code_blocks = (content, file_label) => {
|
|
|
34
35
|
return results
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
const validate_cross = (skill_dir, frontmatter, manifest, skill_md_content) => catch_errors('Failed cross-file validation', async () => {
|
|
38
|
+
const validate_cross = (skill_dir, frontmatter, manifest, skill_md_content, skill_type) => catch_errors('Failed cross-file validation', async () => {
|
|
38
39
|
const results = []
|
|
39
40
|
|
|
41
|
+
if (skill_type === SKILL_TYPES.KIT) {
|
|
42
|
+
return results
|
|
43
|
+
}
|
|
44
|
+
|
|
40
45
|
// Name match
|
|
41
46
|
if (frontmatter && manifest) {
|
|
42
47
|
if (frontmatter.name !== manifest.name) {
|
|
@@ -43,6 +43,28 @@ describe('validate_cross — name match', () => {
|
|
|
43
43
|
})
|
|
44
44
|
})
|
|
45
45
|
|
|
46
|
+
describe('validate_cross — kit type', () => {
|
|
47
|
+
it('skips all checks for kit type', async () => {
|
|
48
|
+
const [err, results] = await validate_cross(tmp, { name: 'skill-a' }, { name: 'skill-b' }, '```python\nimport os\nimport sys\n' + 'print("hello")\n'.repeat(10) + '```', 'kit')
|
|
49
|
+
assert.ifError(err)
|
|
50
|
+
assert.strictEqual(results.length, 0)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('still runs checks when skill_type is undefined', async () => {
|
|
54
|
+
const [err, results] = await validate_cross(tmp, { name: 'skill-a' }, { name: 'skill-b' }, '')
|
|
55
|
+
assert.ifError(err)
|
|
56
|
+
const check = results.find(r => r.rule === 'name_match')
|
|
57
|
+
assert.strictEqual(check.severity, 'warning')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('still runs checks when skill_type is "skill"', async () => {
|
|
61
|
+
const [err, results] = await validate_cross(tmp, { name: 'skill-a' }, { name: 'skill-b' }, '', 'skill')
|
|
62
|
+
assert.ifError(err)
|
|
63
|
+
const check = results.find(r => r.rule === 'name_match')
|
|
64
|
+
assert.strictEqual(check.severity, 'warning')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
46
68
|
describe('validate_cross — executable code detection', () => {
|
|
47
69
|
it('warns for a long python code block in SKILL.md', async () => {
|
|
48
70
|
const code_block = '```python\nimport os\nimport sys\n' + 'print("hello")\n'.repeat(10) + '```'
|
|
@@ -2,7 +2,7 @@ const path = require('path')
|
|
|
2
2
|
const { error: { catch_errors } } = require('puffy-core')
|
|
3
3
|
const { file_exists, read_file } = require('../utils/fs')
|
|
4
4
|
const { valid } = require('../utils/semver')
|
|
5
|
-
const { SKILL_JSON } = require('../constants')
|
|
5
|
+
const { SKILL_JSON, VALID_SKILL_TYPES, SKILL_TYPES } = require('../constants')
|
|
6
6
|
|
|
7
7
|
const NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/
|
|
8
8
|
const CANONICAL_SLUGS = ['deployment', 'database', 'security', 'ai', 'api', 'monitoring', 'testing', 'devops', 'cloud', 'analytics']
|
|
@@ -93,6 +93,24 @@ const validate_keywords = (manifest) => {
|
|
|
93
93
|
return results
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
const validate_type = (manifest) => {
|
|
97
|
+
const results = []
|
|
98
|
+
const type = manifest.type
|
|
99
|
+
|
|
100
|
+
if (type === undefined) {
|
|
101
|
+
results.push(result('type', 'present', 'warning', 'type field is recommended (defaults to "skill")'))
|
|
102
|
+
return results
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!VALID_SKILL_TYPES.includes(type)) {
|
|
106
|
+
results.push(result('type', 'valid_value', 'error', `type must be one of: ${VALID_SKILL_TYPES.join(', ')} — got "${type}"`, type))
|
|
107
|
+
} else {
|
|
108
|
+
results.push(result('type', 'valid_value', 'pass', `type: "${type}"`, type))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return results
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
const validate_dependencies = (manifest) => {
|
|
97
115
|
const results = []
|
|
98
116
|
const deps = manifest.dependencies
|
|
@@ -191,11 +209,20 @@ const validate_skill_json = (skill_dir) => catch_errors('Failed to validate skil
|
|
|
191
209
|
|
|
192
210
|
results.push(...validate_name(manifest))
|
|
193
211
|
results.push(...validate_version(manifest))
|
|
212
|
+
results.push(...validate_type(manifest))
|
|
194
213
|
results.push(...validate_description(manifest))
|
|
195
214
|
results.push(...validate_keywords(manifest))
|
|
196
215
|
results.push(...validate_dependencies(manifest))
|
|
197
216
|
results.push(...validate_system_dependencies(manifest))
|
|
198
217
|
|
|
218
|
+
const is_kit = manifest.type === SKILL_TYPES.KIT
|
|
219
|
+
if (is_kit) {
|
|
220
|
+
const deps = manifest.dependencies
|
|
221
|
+
if (!deps || Object.keys(deps).length === 0) {
|
|
222
|
+
results.push(result('dependencies', 'kit_has_deps', 'warning', 'Kit has no dependencies — a kit typically bundles one or more skills'))
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
199
226
|
return { results, manifest }
|
|
200
227
|
})
|
|
201
228
|
|
|
@@ -187,6 +187,74 @@ describe('validate_skill_json — dependencies', () => {
|
|
|
187
187
|
})
|
|
188
188
|
})
|
|
189
189
|
|
|
190
|
+
describe('validate_skill_json — type', () => {
|
|
191
|
+
it('warns when type is missing', async () => {
|
|
192
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0' })
|
|
193
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
194
|
+
assert.ifError(err)
|
|
195
|
+
const check = data.results.find(r => r.field === 'type' && r.rule === 'present')
|
|
196
|
+
assert.strictEqual(check.severity, 'warning')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('passes for type "skill"', async () => {
|
|
200
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', type: 'skill' })
|
|
201
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
202
|
+
assert.ifError(err)
|
|
203
|
+
const check = data.results.find(r => r.field === 'type' && r.rule === 'valid_value')
|
|
204
|
+
assert.strictEqual(check.severity, 'pass')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('passes for type "kit"', async () => {
|
|
208
|
+
write_json(tmp, { name: 'my-kit', version: '1.0.0', type: 'kit' })
|
|
209
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
210
|
+
assert.ifError(err)
|
|
211
|
+
const check = data.results.find(r => r.field === 'type' && r.rule === 'valid_value')
|
|
212
|
+
assert.strictEqual(check.severity, 'pass')
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('errors for invalid type value', async () => {
|
|
216
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', type: 'plugin' })
|
|
217
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
218
|
+
assert.ifError(err)
|
|
219
|
+
const check = data.results.find(r => r.field === 'type' && r.rule === 'valid_value')
|
|
220
|
+
assert.strictEqual(check.severity, 'error')
|
|
221
|
+
assert.ok(check.message.includes('plugin'))
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
describe('validate_skill_json — kit dependencies', () => {
|
|
226
|
+
it('warns when kit has no dependencies', async () => {
|
|
227
|
+
write_json(tmp, { name: 'my-kit', version: '1.0.0', type: 'kit' })
|
|
228
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
229
|
+
assert.ifError(err)
|
|
230
|
+
const check = data.results.find(r => r.rule === 'kit_has_deps')
|
|
231
|
+
assert.strictEqual(check.severity, 'warning')
|
|
232
|
+
assert.ok(check.message.includes('no dependencies'))
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('warns when kit has empty dependencies object', async () => {
|
|
236
|
+
write_json(tmp, { name: 'my-kit', version: '1.0.0', type: 'kit', dependencies: {} })
|
|
237
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
238
|
+
assert.ifError(err)
|
|
239
|
+
const check = data.results.find(r => r.rule === 'kit_has_deps')
|
|
240
|
+
assert.strictEqual(check.severity, 'warning')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('does not warn when kit has dependencies', async () => {
|
|
244
|
+
write_json(tmp, { name: 'my-kit', version: '1.0.0', type: 'kit', dependencies: { 'acme/deploy': '^1.0.0' } })
|
|
245
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
246
|
+
assert.ifError(err)
|
|
247
|
+
assert.ok(!data.results.some(r => r.rule === 'kit_has_deps'))
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('does not produce kit_has_deps warning for regular skills', async () => {
|
|
251
|
+
write_json(tmp, { name: 'my-skill', version: '1.0.0', type: 'skill' })
|
|
252
|
+
const [err, data] = await validate_skill_json(tmp)
|
|
253
|
+
assert.ifError(err)
|
|
254
|
+
assert.ok(!data.results.some(r => r.rule === 'kit_has_deps'))
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
190
258
|
describe('validate_skill_json — systemDependencies', () => {
|
|
191
259
|
it('errors when systemDependencies is an array', async () => {
|
|
192
260
|
write_json(tmp, { name: 'my-skill', version: '1.0.0', systemDependencies: [] })
|
|
@@ -2,7 +2,7 @@ const path = require('path')
|
|
|
2
2
|
const { error: { catch_errors } } = require('puffy-core')
|
|
3
3
|
const { file_exists, read_file } = require('../utils/fs')
|
|
4
4
|
const { parse_frontmatter } = require('../utils/skill_scanner')
|
|
5
|
-
const { SKILL_MD } = require('../constants')
|
|
5
|
+
const { SKILL_MD, SKILL_TYPES } = require('../constants')
|
|
6
6
|
|
|
7
7
|
const FORBIDDEN_CHARS = {
|
|
8
8
|
';': 'semicolon', ':': 'colon', '#': 'hash', '{': 'left brace', '}': 'right brace',
|
|
@@ -130,9 +130,10 @@ const validate_optional_fields = (fm) => {
|
|
|
130
130
|
return results
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
const validate_skill_md = (skill_dir, dir_name) => catch_errors('Failed to validate SKILL.md', async () => {
|
|
133
|
+
const validate_skill_md = (skill_dir, dir_name, skill_type) => catch_errors('Failed to validate SKILL.md', async () => {
|
|
134
134
|
const results = []
|
|
135
135
|
const md_path = path.join(skill_dir, SKILL_MD)
|
|
136
|
+
const is_kit = skill_type === SKILL_TYPES.KIT
|
|
136
137
|
|
|
137
138
|
const [, exists] = await file_exists(md_path)
|
|
138
139
|
if (!exists) {
|
|
@@ -143,6 +144,11 @@ const validate_skill_md = (skill_dir, dir_name) => catch_errors('Failed to valid
|
|
|
143
144
|
results.push(result(null, 'exists', 'pass', 'SKILL.md exists'))
|
|
144
145
|
|
|
145
146
|
const [, content] = await read_file(md_path)
|
|
147
|
+
|
|
148
|
+
if (is_kit) {
|
|
149
|
+
return { results, frontmatter: null, content }
|
|
150
|
+
}
|
|
151
|
+
|
|
146
152
|
const fm = parse_frontmatter(content)
|
|
147
153
|
|
|
148
154
|
if (!fm) {
|
|
@@ -181,6 +181,43 @@ describe('validate_skill_md — optional fields', () => {
|
|
|
181
181
|
})
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
+
describe('validate_skill_md — kit type', () => {
|
|
185
|
+
it('errors when SKILL.md is missing for a kit', async () => {
|
|
186
|
+
const [err, data] = await validate_skill_md(tmp, 'my-kit', 'kit')
|
|
187
|
+
assert.ifError(err)
|
|
188
|
+
const check = data.results.find(r => r.rule === 'exists')
|
|
189
|
+
assert.strictEqual(check.severity, 'error')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('passes when SKILL.md exists for a kit (no frontmatter required)', async () => {
|
|
193
|
+
fs.writeFileSync(path.join(tmp, 'SKILL.md'), '# My Kit\n\nThis is a kit.')
|
|
194
|
+
const [err, data] = await validate_skill_md(tmp, 'my-kit', 'kit')
|
|
195
|
+
assert.ifError(err)
|
|
196
|
+
const exists_check = data.results.find(r => r.rule === 'exists')
|
|
197
|
+
assert.strictEqual(exists_check.severity, 'pass')
|
|
198
|
+
assert.strictEqual(data.frontmatter, null)
|
|
199
|
+
assert.ok(data.content.includes('My Kit'))
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('skips all frontmatter validation for kits', async () => {
|
|
203
|
+
fs.writeFileSync(path.join(tmp, 'SKILL.md'), '# My Kit\n\nNo frontmatter here.')
|
|
204
|
+
const [err, data] = await validate_skill_md(tmp, 'my-kit', 'kit')
|
|
205
|
+
assert.ifError(err)
|
|
206
|
+
// Should have no frontmatter, name, or description results
|
|
207
|
+
assert.ok(!data.results.some(r => r.rule === 'frontmatter'))
|
|
208
|
+
assert.ok(!data.results.some(r => r.field === 'name'))
|
|
209
|
+
assert.ok(!data.results.some(r => r.field === 'description'))
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('skips line count validation for kits', async () => {
|
|
213
|
+
const long_content = '# My Kit\n' + 'line\n'.repeat(600)
|
|
214
|
+
fs.writeFileSync(path.join(tmp, 'SKILL.md'), long_content)
|
|
215
|
+
const [err, data] = await validate_skill_md(tmp, 'my-kit', 'kit')
|
|
216
|
+
assert.ifError(err)
|
|
217
|
+
assert.ok(!data.results.some(r => r.rule === 'line_count'))
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
184
221
|
describe('validate_skill_md — line count', () => {
|
|
185
222
|
it('errors when file exceeds 500 lines', async () => {
|
|
186
223
|
const lines = '---\nname: my-skill\ndescription: Does things\n---\n' + 'line\n'.repeat(500)
|