happyskills 0.32.0 → 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 +9 -0
- package/package.json +1 -1
- package/src/api/client.js +10 -0
- package/src/api/repos.js +17 -1
- package/src/commands/search.js +163 -31
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.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
|
+
|
|
10
19
|
## [0.32.0] - 2026-04-07
|
|
11
20
|
|
|
12
21
|
### Added
|
package/package.json
CHANGED
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
|
-
|
|
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 }
|
package/src/commands/search.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
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 (
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 ||
|
|
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
|
|
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
|
|
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 }
|