happyskills 0.31.1 → 0.33.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,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.33.0] - 2026-04-08
11
+
12
+ ### Added
13
+ - Add `--smart` / `-S` flag to `search` command — semantic search using hybrid vector + full-text + quality ranking via `POST /repos:semantic-search`
14
+ - Add `--limit` and `--min-quality` options to `search` (used with `--smart`)
15
+ - Add quality tier labels (High quality / Good / Fair / Low quality), star counts, and tags to smart search output
16
+ - Add `semantic_search()` API client function in `api/repos.js`
17
+ - Add 429 rate limit handling to API client with `Retry-After` header parsing
18
+
19
+ ## [0.32.0] - 2026-04-07
20
+
21
+ ### Added
22
+ - Add changelog version validation to `validate` and `publish` — verifies that the top `## [x.y.z]` entry in CHANGELOG.md matches the `skill.json` version before publishing, preventing stale or missing changelog releases
23
+
10
24
  ## [0.31.1] - 2026-04-07
11
25
 
12
26
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.31.1",
3
+ "version": "0.33.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/client.js CHANGED
@@ -51,6 +51,16 @@ const request = (method, path, options = {}) => catch_errors(`API ${method} ${pa
51
51
  throw new AuthError(err_msg)
52
52
  }
53
53
 
54
+ if (res.status === 429) {
55
+ const retry_after = res.headers.get('retry-after')
56
+ const retry_msg = retry_after
57
+ ? `API rate limit reached. Please wait ${retry_after} seconds and try again.`
58
+ : 'API rate limit reached. Please wait and try again.'
59
+ const err = new ApiError(retry_msg, 429, 'RATE_LIMITED')
60
+ err.retry_after = retry_after ? parseInt(retry_after) : null
61
+ throw err
62
+ }
63
+
54
64
  throw new ApiError(err_msg, res.status, err_code)
55
65
  }
56
66
 
package/src/api/repos.js CHANGED
@@ -81,4 +81,20 @@ const get_blob = (owner, repo, sha) => catch_errors(`Get blob ${owner}/${repo}/$
81
81
  return data
82
82
  })
83
83
 
84
- module.exports = { search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob }
84
+ const semantic_search = (query, options = {}) => catch_errors('Semantic search failed', async () => {
85
+ const needs_auth = !!(options.workspace_slug || options.scope === 'mine' || options.scope === 'personal')
86
+ const body = {
87
+ q: query,
88
+ tags: options.tags ? options.tags.split(',').map(t => t.trim()).filter(Boolean) : null,
89
+ type: options.type || null,
90
+ limit: options.limit || 10,
91
+ min_stars: null,
92
+ workspace_slugs: options.workspace_slug ? options.workspace_slug.split(',').map(s => s.trim()).filter(Boolean) : null,
93
+ scope: options.workspace_slug ? null : (options.scope || null),
94
+ }
95
+ const [errors, data] = await client.post('/repos:semantic-search', body, { auth: needs_auth || false })
96
+ if (errors) throw errors[errors.length - 1]
97
+ return data
98
+ })
99
+
100
+ module.exports = { search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob }
@@ -1,6 +1,7 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const repos_api = require('../api/repos')
3
- const { print_help, print_table, print_json, print_info } = require('../ui/output')
3
+ const { print_help, print_table, print_json, print_info, print_hint } = require('../ui/output')
4
+ const { bold, dim, yellow, cyan, gray } = require('../ui/colors')
4
5
  const { exit_with_error, UsageError, AuthError } = require('../utils/errors')
5
6
  const { EXIT_CODES, VALID_SKILL_TYPES } = require('../constants')
6
7
  const { load_token } = require('../auth/token_store')
@@ -18,6 +19,9 @@ Options:
18
19
  --personal Search only your personal workspace
19
20
  --tags <tags> Filter by tags (comma-separated)
20
21
  --type <type> Filter by type (skill, kit)
22
+ --smart, -S Use semantic search (vector + text + quality ranking)
23
+ --limit <n> Max results (default: 10, max: 50) — used with --smart
24
+ --min-quality <n> Minimum quality score 0-100 — used with --smart
21
25
  --json Output as JSON
22
26
 
23
27
  Aliases: s
@@ -25,52 +29,127 @@ Aliases: s
25
29
  Examples:
26
30
  happyskills search deploy
27
31
  happyskills search --mine
32
+ happyskills search "REST API with Node.js" --smart
28
33
  happyskills search deploy --workspace acme
29
34
  happyskills search --type kit
30
- happyskills s --personal --json`
31
-
32
- const run = (args) => catch_errors('Search failed', async () => {
33
- if (args.flags._show_help) {
34
- print_help(HELP_TEXT)
35
- return process.exit(EXIT_CODES.SUCCESS)
35
+ happyskills s --personal --json
36
+ happyskills search "deploy to AWS" -S --limit 5`
37
+
38
+ const QUALITY_TIERS = [
39
+ { min: 80, label: 'High quality', color: cyan },
40
+ { min: 60, label: 'Good', color: cyan },
41
+ { min: 40, label: 'Fair', color: yellow },
42
+ { min: 20, label: 'Low quality', color: yellow },
43
+ ]
44
+
45
+ const get_quality_label = (score) => {
46
+ if (score == null) return null
47
+ for (const tier of QUALITY_TIERS) {
48
+ if (score >= tier.min) return tier
36
49
  }
37
-
38
- const query = args._.join(' ') || null
39
- const { mine, personal, workspace, tags, type } = args.flags
40
- const has_scope_flag = mine || personal || workspace
41
-
42
- if (!query && !has_scope_flag && !tags && !type) {
43
- throw new UsageError('Please provide a search query or use --mine, --personal, or --workspace.')
50
+ return null
51
+ }
52
+
53
+ const get_quality_tier_name = (score) => {
54
+ if (score == null) return null
55
+ if (score >= 80) return 'high'
56
+ if (score >= 60) return 'good'
57
+ if (score >= 40) return 'fair'
58
+ if (score >= 20) return 'low'
59
+ return null
60
+ }
61
+
62
+ const format_smart_result = (item, index) => {
63
+ const name = `${item.workspace_slug}/${item.name}`
64
+ const stars = item.star_count || 0
65
+ const tier = get_quality_label(item.quality_score)
66
+
67
+ const star_str = `★ ${stars}`
68
+ const quality_str = tier ? tier.color(tier.label) : ''
69
+ const meta_parts = [star_str, quality_str].filter(Boolean).join(' · ')
70
+
71
+ const num = ` ${String(index + 1).padStart(2)}. `
72
+ const name_and_meta = `${bold(name)}${meta_parts ? ` ${dim(meta_parts)}` : ''}`
73
+ const desc = item.description ? ` ${item.description}` : ''
74
+ const tags_line = item.tags && item.tags.length > 0
75
+ ? ` ${dim('Tags: ' + item.tags.join(', '))}`
76
+ : ''
77
+
78
+ const lines = [`${num}${name_and_meta}`]
79
+ if (desc) lines.push(desc)
80
+ if (tags_line) lines.push(tags_line)
81
+ return lines.join('\n')
82
+ }
83
+
84
+ const run_smart_search = (args, query, options) => catch_errors('Smart search failed', async () => {
85
+ const limit = args.flags.limit ? parseInt(args.flags.limit) : 10
86
+ const capped_limit = Math.min(Math.max(limit, 1), 50)
87
+ const min_quality = args.flags['min-quality'] != null ? parseInt(args.flags['min-quality']) : null
88
+
89
+ const search_opts = { ...options, limit: capped_limit }
90
+ const [errors, results] = await repos_api.semantic_search(query, search_opts)
91
+ if (errors) throw e('Semantic search failed', errors)
92
+
93
+ let items = Array.isArray(results) ? results : (results?.repos || results?.items || [])
94
+
95
+ // Client-side quality filter (only when explicitly requested)
96
+ if (min_quality != null) {
97
+ items = items.filter(item =>
98
+ item.quality_score != null && item.quality_score >= min_quality
99
+ )
44
100
  }
45
101
 
46
- if (type && !VALID_SKILL_TYPES.includes(type)) {
47
- throw new UsageError(`--type must be one of: ${VALID_SKILL_TYPES.join(', ')}`)
102
+ if (items.length === 0) {
103
+ if (args.flags.json) {
104
+ print_json({ data: { query, mode: 'smart', results: [], count: 0 } })
105
+ return
106
+ }
107
+ const msg = `No skills found for "${query}".`
108
+ print_info(msg)
109
+ print_hint('Try broader terms or remove filters.')
110
+ return
48
111
  }
49
112
 
50
- const scope = mine ? 'mine' : personal ? 'personal' : undefined
51
-
52
- if (has_scope_flag) {
53
- const [, token_data] = await load_token()
54
- if (!token_data) {
55
- throw new AuthError('Authentication required. Run `happyskills login` first.')
56
- }
113
+ if (args.flags.json) {
114
+ const mapped = items.map(item => ({
115
+ skill: `${item.workspace_slug}/${item.name}`,
116
+ type: item.type || 'skill',
117
+ description: item.description || '',
118
+ version: item.latest_version || item.version || '-',
119
+ visibility: item.visibility || 'public',
120
+ workspace_slug: item.workspace_slug,
121
+ stars: item.star_count || 0,
122
+ quality_score: item.quality_score != null ? item.quality_score : null,
123
+ quality_tier: get_quality_tier_name(item.quality_score),
124
+ relevance_score: item.relevance_score != null ? item.relevance_score : null,
125
+ tags: item.tags || [],
126
+ download_count: item.download_count || 0,
127
+ created_at: item.created_at,
128
+ updated_at: item.updated_at,
129
+ }))
130
+ print_json({ data: { query, mode: 'smart', results: mapped, count: mapped.length } })
131
+ return
57
132
  }
58
133
 
59
- const options = {}
60
- if (scope) options.scope = scope
61
- if (workspace) options.workspace = workspace
62
- if (tags) options.tags = tags
63
- if (type) options.type = type
134
+ // Human-readable smart output
135
+ console.log(`\n${bold(`Skills for: "${query}"`)}\n`)
136
+ items.forEach((item, i) => {
137
+ console.log(format_smart_result(item, i))
138
+ if (i < items.length - 1) console.log('')
139
+ })
140
+ console.log(`\n${gray(`Showing ${items.length} result${items.length === 1 ? '' : 's'}. Install with: happyskills install <owner>/<name>`)}\n`)
141
+ })
64
142
 
143
+ const run_keyword_search = (args, query, options) => catch_errors('Keyword search failed', async () => {
65
144
  const [errors, results] = await repos_api.search(query, options)
66
145
  if (errors) throw e('Search failed', errors)
67
146
 
68
147
  const items = Array.isArray(results) ? results : (results?.repos || results?.items || [])
69
- const effective_scope = scope || (has_scope_flag ? undefined : undefined)
148
+ const effective_scope = options.scope || 'all'
70
149
 
71
150
  if (items.length === 0) {
72
151
  if (args.flags.json) {
73
- print_json({ data: { query, scope: effective_scope || 'all', results: [], count: 0 } })
152
+ print_json({ data: { query, scope: effective_scope, results: [], count: 0 } })
74
153
  return
75
154
  }
76
155
  const context = query ? ` for "${query}"` : ''
@@ -86,7 +165,7 @@ const run = (args) => catch_errors('Search failed', async () => {
86
165
  version: item.latest_version || item.version || '-',
87
166
  visibility: item.visibility || 'public'
88
167
  }))
89
- print_json({ data: { query, scope: effective_scope || 'all', results: mapped, count: mapped.length } })
168
+ print_json({ data: { query, scope: effective_scope, results: mapped, count: mapped.length } })
90
169
  return
91
170
  }
92
171
 
@@ -102,6 +181,59 @@ const run = (args) => catch_errors('Search failed', async () => {
102
181
  })
103
182
 
104
183
  print_table(['Skill', 'Description', 'Version'], rows)
184
+ })
185
+
186
+ const run = (args) => catch_errors('Search failed', async () => {
187
+ if (args.flags._show_help) {
188
+ print_help(HELP_TEXT)
189
+ return process.exit(EXIT_CODES.SUCCESS)
190
+ }
191
+
192
+ const query = args._.join(' ') || null
193
+ const { mine, personal, workspace, tags, type } = args.flags
194
+ const is_smart = !!(args.flags.smart || args.flags.S)
195
+ const has_scope_flag = mine || personal || workspace
196
+
197
+ if (is_smart && !query) {
198
+ throw new UsageError('--smart requires a search query.')
199
+ }
200
+
201
+ if (!query && !has_scope_flag && !tags && !type) {
202
+ throw new UsageError('Please provide a search query or use --mine, --personal, or --workspace.')
203
+ }
204
+
205
+ if (type && !VALID_SKILL_TYPES.includes(type)) {
206
+ throw new UsageError(`--type must be one of: ${VALID_SKILL_TYPES.join(', ')}`)
207
+ }
208
+
209
+ const scope = mine ? 'mine' : personal ? 'personal' : undefined
210
+
211
+ if (has_scope_flag) {
212
+ const [, token_data] = await load_token()
213
+ if (!token_data) {
214
+ throw new AuthError('Authentication required. Run `happyskills login` first.')
215
+ }
216
+ }
217
+
218
+ const options = {}
219
+ if (scope) options.scope = scope
220
+ if (workspace) options.workspace_slug = workspace
221
+ if (tags) options.tags = tags
222
+ if (type) options.type = type
223
+
224
+ if (is_smart) {
225
+ const [errors] = await run_smart_search(args, query, options)
226
+ if (errors) throw e('Smart search failed', errors)
227
+ } else {
228
+ // Keyword search uses 'workspace' not 'workspace_slug'
229
+ const kw_options = { ...options }
230
+ if (options.workspace_slug) {
231
+ kw_options.workspace = options.workspace_slug
232
+ delete kw_options.workspace_slug
233
+ }
234
+ const [errors] = await run_keyword_search(args, query, kw_options)
235
+ if (errors) throw e('Search failed', errors)
236
+ }
105
237
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
106
238
 
107
239
  module.exports = { run }
@@ -5,6 +5,7 @@ const { validate_skill_json } = require('../validation/skill_json_rules')
5
5
  const { validate_cross } = require('../validation/cross_rules')
6
6
  const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
7
7
  const { validate_file_sizes } = require('../validation/file_size_rules')
8
+ const { validate_changelog_version } = require('../validation/changelog_rules')
8
9
  const { file_exists, read_json } = require('../utils/fs')
9
10
  const { skills_dir, find_project_root } = require('../config/paths')
10
11
  const { print_help, print_json } = require('../ui/output')
@@ -150,8 +151,10 @@ const run = (args) => catch_errors('Validate failed', async () => {
150
151
  if (marker_err) throw marker_err
151
152
  const [size_err, size_results] = await validate_file_sizes(skill_dir)
152
153
  if (size_err) throw size_err
154
+ const [cl_err, cl_results] = await validate_changelog_version(skill_dir, json_data.manifest)
155
+ if (cl_err) throw cl_err
153
156
 
154
- const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results]
157
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results, ...marker_results, ...size_results, ...cl_results]
155
158
  const type_label = is_kit ? ' [kit]' : ''
156
159
 
157
160
  if (args.flags.json) {
@@ -0,0 +1,64 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { error: { catch_errors } } = require('puffy-core')
4
+
5
+ const CHANGELOG = 'CHANGELOG.md'
6
+ const VERSION_HEADING_RE = /^##\s+\[(\d+\.\d+\.\d+[^\]]*)\]/
7
+
8
+ /**
9
+ * Validates that the CHANGELOG.md top version entry matches skill.json version.
10
+ * Only runs when both CHANGELOG.md and skill.json exist — skills without a
11
+ * changelog are not penalised.
12
+ *
13
+ * @param {string} skill_dir - Absolute path to the skill directory
14
+ * @param {object|null} manifest - Parsed skill.json (may be null if missing)
15
+ * @returns {[errors, results[]]}
16
+ */
17
+ const validate_changelog_version = (skill_dir, manifest) => catch_errors('Failed to validate changelog', async () => {
18
+ const changelog_path = path.join(skill_dir, CHANGELOG)
19
+ let content
20
+ try { content = await fs.promises.readFile(changelog_path, 'utf-8') } catch { return [] }
21
+
22
+ // No manifest means skill.json is missing — other rules already flag that
23
+ if (!manifest || !manifest.version) return []
24
+
25
+ const lines = content.split('\n')
26
+ let first_version = null
27
+ for (const line of lines) {
28
+ const m = line.match(VERSION_HEADING_RE)
29
+ if (m) {
30
+ first_version = m[1]
31
+ break
32
+ }
33
+ }
34
+
35
+ if (!first_version) {
36
+ return [{
37
+ file: CHANGELOG,
38
+ field: null,
39
+ rule: 'changelog_has_version',
40
+ severity: 'warning',
41
+ message: 'CHANGELOG.md exists but has no version entry (expected ## [x.y.z])'
42
+ }]
43
+ }
44
+
45
+ if (first_version !== manifest.version) {
46
+ return [{
47
+ file: CHANGELOG,
48
+ field: 'version',
49
+ rule: 'changelog_version_match',
50
+ severity: 'error',
51
+ message: `CHANGELOG.md top version [${first_version}] does not match skill.json version ${manifest.version}`
52
+ }]
53
+ }
54
+
55
+ return [{
56
+ file: CHANGELOG,
57
+ field: null,
58
+ rule: 'changelog_version_match',
59
+ severity: 'pass',
60
+ message: `CHANGELOG.md version matches skill.json (${manifest.version})`
61
+ }]
62
+ })
63
+
64
+ module.exports = { validate_changelog_version }
@@ -0,0 +1,90 @@
1
+ const { describe, it, beforeEach, afterEach } = require('node:test')
2
+ const assert = require('node:assert/strict')
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const os = require('os')
6
+ const { validate_changelog_version } = require('./changelog_rules')
7
+
8
+ const make_temp_dir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-test-'))
9
+ const clean = (dir) => fs.rmSync(dir, { recursive: true, force: true })
10
+
11
+ describe('validate_changelog_version', () => {
12
+ let dir
13
+
14
+ beforeEach(() => { dir = make_temp_dir() })
15
+ afterEach(() => { clean(dir) })
16
+
17
+ it('returns empty when no CHANGELOG.md exists', async () => {
18
+ const [err, results] = await validate_changelog_version(dir, { version: '1.0.0' })
19
+ assert.strictEqual(err, null)
20
+ assert.strictEqual(results.length, 0)
21
+ })
22
+
23
+ it('returns empty when manifest is null', async () => {
24
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '## [1.0.0] - 2026-01-01\n')
25
+ const [err, results] = await validate_changelog_version(dir, null)
26
+ assert.strictEqual(err, null)
27
+ assert.strictEqual(results.length, 0)
28
+ })
29
+
30
+ it('returns empty when manifest has no version', async () => {
31
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '## [1.0.0] - 2026-01-01\n')
32
+ const [err, results] = await validate_changelog_version(dir, {})
33
+ assert.strictEqual(err, null)
34
+ assert.strictEqual(results.length, 0)
35
+ })
36
+
37
+ it('returns pass when versions match', async () => {
38
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '# Changelog\n\n## [2.1.0] - 2026-04-07\n\n### Added\n- Something\n')
39
+ const [err, results] = await validate_changelog_version(dir, { version: '2.1.0' })
40
+ assert.strictEqual(err, null)
41
+ assert.strictEqual(results.length, 1)
42
+ assert.strictEqual(results[0].severity, 'pass')
43
+ assert.strictEqual(results[0].rule, 'changelog_version_match')
44
+ })
45
+
46
+ it('returns error when versions mismatch', async () => {
47
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '# Changelog\n\n## [1.0.0] - 2026-01-01\n')
48
+ const [err, results] = await validate_changelog_version(dir, { version: '1.1.0' })
49
+ assert.strictEqual(err, null)
50
+ assert.strictEqual(results.length, 1)
51
+ assert.strictEqual(results[0].severity, 'error')
52
+ assert.strictEqual(results[0].rule, 'changelog_version_match')
53
+ assert.ok(results[0].message.includes('[1.0.0]'))
54
+ assert.ok(results[0].message.includes('1.1.0'))
55
+ })
56
+
57
+ it('uses the first version heading, not later ones', async () => {
58
+ const content = '# Changelog\n\n## [2.0.0] - 2026-04-07\n\n## [1.0.0] - 2026-01-01\n'
59
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), content)
60
+ const [err, results] = await validate_changelog_version(dir, { version: '2.0.0' })
61
+ assert.strictEqual(err, null)
62
+ assert.strictEqual(results[0].severity, 'pass')
63
+ })
64
+
65
+ it('skips [Unreleased] heading and finds the first real version', async () => {
66
+ const content = '# Changelog\n\n## [Unreleased]\n\n## [1.5.0] - 2026-03-01\n'
67
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), content)
68
+ const [err, results] = await validate_changelog_version(dir, { version: '1.5.0' })
69
+ assert.strictEqual(err, null)
70
+ assert.strictEqual(results[0].severity, 'pass')
71
+ })
72
+
73
+ it('returns warning when changelog has no version entries', async () => {
74
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '# Changelog\n\nNothing here yet.\n')
75
+ const [err, results] = await validate_changelog_version(dir, { version: '1.0.0' })
76
+ assert.strictEqual(err, null)
77
+ assert.strictEqual(results.length, 1)
78
+ assert.strictEqual(results[0].severity, 'warning')
79
+ assert.strictEqual(results[0].rule, 'changelog_has_version')
80
+ })
81
+
82
+ it('handles changelog with only [Unreleased] heading', async () => {
83
+ fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), '# Changelog\n\n## [Unreleased]\n\n- WIP stuff\n')
84
+ const [err, results] = await validate_changelog_version(dir, { version: '1.0.0' })
85
+ assert.strictEqual(err, null)
86
+ assert.strictEqual(results.length, 1)
87
+ assert.strictEqual(results[0].severity, 'warning')
88
+ assert.strictEqual(results[0].rule, 'changelog_has_version')
89
+ })
90
+ })