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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
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
  })
@@ -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 keywords = args.flags.keywords
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: ${keywords.length ? keywords.join(', ') : '(none)'}`)
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 manifest = { name: skill_name, version, description, keywords }
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}@${version}`)
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: 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 })
@@ -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 create_manifest = (name) => ({
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 [md_err] = await write_file(path.join(dir, SKILL_MD), create_skill_md(name))
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 skill "${name}" at ${dir}`)
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 } })
@@ -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
- skills_map[name] = { version: data.version, source, status }
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
- rows.push([name, data.version, source, status])
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) {
@@ -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 [owner_err, resolved_owner] = await resolve_skill_owner(skill_name)
128
- if (owner_err) {
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
 
@@ -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
- `${item.owner || item.workspace_slug}/${item.name}`,
87
- item.description || '',
88
- item.latest_version || item.version || '-'
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 } })
@@ -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,
@@ -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)