happyskills 0.41.0 → 0.43.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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.43.0] - 2026-05-07
11
+
12
+ ### Added
13
+ - Route the default `search` path to the new `POST /repos:search` dispatcher (API v2.9.0+). The same `happyskills search foo` command now picks the right strategy automatically based on the query shape — natural-language queries go to semantic hybrid search (existing behavior, unchanged), single dashed/word tokens like `deploy-aws` go to typo-tolerant fuzzy slug search, and `workspace/skill` form like `letta-ai/remotion-best-practices` goes to fuzzy scoped search (typo-tolerant on both halves). The CLI doesn't pre-decide the mode — it sends the raw query and the server picks. Implementation: new `dispatch_search` in `cli/src/api/repos.js`; default branch in `cli/src/commands/search.js` now calls it.
14
+ - Surface the chosen mode in human output. The result header now shows a `[mode]` chip (e.g. `Skills for: "deploy-aws" [fuzzy_slug]`) so users can see how the dispatcher routed without inspecting JSON. JSON output gains a top-level `mode` field, plus `workspace_match` when the request was `fuzzy_scoped`.
15
+ - Add an honest-failure path for `workspace/skill` lookups when the workspace doesn't match. Typing `happyskills search helo/do-something` (where `helo` doesn't fuzzy-match any workspace) now prints `No workspace matched "helo".` with a hint to remove the prefix to search globally. Previously a workspace-fuzzy miss would have silently returned nothing useful — the new path explains *why*.
16
+
17
+ ### Changed
18
+ - Rewrite the `search` command's help text to describe the three+list modes explicitly, with one example query per mode. The `--exact` flag is preserved as the explicit "skip the smart routing, just do keyword FTS" escape hatch.
19
+ - Trust the server's `match_notice` field instead of recomputing it client-side. The "No strong matches found..." notice was previously recomputed by the CLI based on `match_quality` — which produced a misleading warning for fuzzy-mode results (a tier-1 partial match is exactly what the user typed, not "may not match your intent"). The server now decides whether to emit a notice (only for `semantic` and `fuzzy_scoped` workspace-not-found cases); the CLI displays whatever it sends, verbatim.
20
+ - The legacy `semantic_search` API client function is now a deprecated alias of `dispatch_search` that forces `mode='semantic'` — kept for any internal callers that still reference it. New code should use `dispatch_search`.
21
+
22
+ ## [0.42.0] - 2026-05-05
23
+
24
+ ### Added
25
+ - Surface the new server-side cluster info from API v2.6.0 in `search` output. Human output gains a `+N similar` chip in the result meta line (alongside match quality, stars, and quality tier) when the row represents a duplicate cluster. JSON output gains two additive fields per result: `similar_count` (integer) and `similar_repos` (array of cluster members, each with a per-member `similarity` score). Internal refactor: extracted `to_smart_json()` so cluster members serialise with the same shape as top-level results.
26
+
10
27
  ## [0.41.0] - 2026-05-04
11
28
 
12
29
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.41.0",
3
+ "version": "0.43.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
@@ -87,9 +87,32 @@ const get_tree = (owner, repo, ref) => catch_errors(`Get tree for ${owner}/${rep
87
87
  return data
88
88
  })
89
89
 
90
+ // POST /repos:search — intelligent dispatcher.
91
+ // The server inspects `q` and routes to semantic / fuzzy_slug / fuzzy_scoped
92
+ // automatically. We pass the raw query through; trim + lowercase happens
93
+ // server-side. Always sends auth — when logged in, accessible private skills
94
+ // are included; when not, the client silently skips the auth header.
95
+ const dispatch_search = (query, options = {}) => catch_errors('Search failed', async () => {
96
+ const body = {
97
+ q: query,
98
+ tags: options.tags ? options.tags.split(',').map(t => t.trim()).filter(Boolean) : null,
99
+ type: options.type || null,
100
+ limit: options.limit || 10,
101
+ min_stars: null,
102
+ workspace_slugs: options.workspace_slug ? options.workspace_slug.split(',').map(s => s.trim()).filter(Boolean) : null,
103
+ scope: options.workspace_slug ? null : (options.scope || null),
104
+ }
105
+ const [errors, data] = await client.post('/repos:search', body, { auth: true })
106
+ if (errors) throw errors[errors.length - 1]
107
+ return data
108
+ })
109
+
110
+ // Deprecated alias — kept for backwards compatibility with older code paths.
111
+ // Prefer `dispatch_search`. Forces mode='semantic' on the server.
90
112
  const semantic_search = (query, options = {}) => catch_errors('Semantic search failed', async () => {
91
113
  const body = {
92
114
  q: query,
115
+ mode: 'semantic',
93
116
  tags: options.tags ? options.tags.split(',').map(t => t.trim()).filter(Boolean) : null,
94
117
  type: options.type || null,
95
118
  limit: options.limit || 10,
@@ -97,12 +120,9 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
97
120
  workspace_slugs: options.workspace_slug ? options.workspace_slug.split(',').map(s => s.trim()).filter(Boolean) : null,
98
121
  scope: options.workspace_slug ? null : (options.scope || null),
99
122
  }
100
- // Always attempt auth for smart search if the user is logged in, the API
101
- // returns public + accessible private skills. If not logged in, the client
102
- // silently skips the auth header and the API defaults to public-only.
103
- const [errors, data] = await client.post('/repos:semantic-search', body, { auth: true })
123
+ const [errors, data] = await client.post('/repos:search', body, { auth: true })
104
124
  if (errors) throw errors[errors.length - 1]
105
125
  return data
106
126
  })
107
127
 
108
- module.exports = { search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob, get_tree }
128
+ module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, compare, get_blob, get_tree }
@@ -8,10 +8,10 @@ const { load_token } = require('../auth/token_store')
8
8
 
9
9
  const HELP_TEXT = `Usage: happyskills search [query] [options]
10
10
 
11
- Search the skill registry.
12
-
13
- When a query is provided, smart search (semantic + quality ranking) is used by
14
- default. Use --exact to fall back to keyword-only matching.
11
+ Search the skill registry. The server picks the right strategy based on the
12
+ query shape — natural-language goes to semantic, a single dashed word goes to
13
+ fuzzy slug, and "workspace/skill" form goes to fuzzy scoped (both parts are
14
+ typo-tolerant). Use --exact to force keyword-only FTS instead.
15
15
 
16
16
  Arguments:
17
17
  query Search term (optional with --mine, --personal, or --workspace)
@@ -22,7 +22,7 @@ Options:
22
22
  --personal Search only your personal workspace
23
23
  --tags <tags> Filter by tags (comma-separated)
24
24
  --type <type> Filter by type (skill, kit)
25
- --exact Use keyword-only matching instead of smart search
25
+ --exact Force keyword-only FTS matching (skip smart routing)
26
26
  --limit <n> Max results (required, 1-50)
27
27
  --min-quality <n> Minimum quality score 0-100
28
28
  --json Output as JSON
@@ -30,14 +30,13 @@ Options:
30
30
  Aliases: s
31
31
 
32
32
  Examples:
33
- happyskills search deploy --limit 10
34
- happyskills search "REST API with Node.js" --limit 10
33
+ happyskills search "deploy infra to AWS" --limit 10 # → semantic
34
+ happyskills search deploy-aws --limit 10 # fuzzy slug (typo-tolerant)
35
+ happyskills search letta-ai/remotion --limit 5 # → fuzzy scoped
35
36
  happyskills search --mine --limit 20
36
37
  happyskills search deploy --workspace acme --limit 50
37
38
  happyskills search --type kit --limit 10
38
- happyskills s --personal --json --limit 20
39
- happyskills search "deploy to AWS" --limit 5
40
- happyskills search deploy --exact --limit 10`
39
+ happyskills search deploy --exact --limit 10 # → keyword FTS only`
41
40
 
42
41
  const QUALITY_TIERS = [
43
42
  { min: 80, label: 'High quality', color: cyan },
@@ -79,7 +78,8 @@ const format_smart_result = (item, index) => {
79
78
  const star_str = `★ ${stars}`
80
79
  const quality_str = tier ? tier.color(tier.label) : ''
81
80
  const match_str = match ? match.color(match.label) : ''
82
- const meta_parts = [match_str, star_str, quality_str].filter(Boolean).join(' · ')
81
+ const similar_str = item.similar_count ? `+${item.similar_count} similar` : ''
82
+ const meta_parts = [match_str, star_str, quality_str, similar_str].filter(Boolean).join(' · ')
83
83
 
84
84
  const num = ` ${String(index + 1).padStart(2)}. `
85
85
  const name_and_meta = `${bold(name)}${meta_parts ? ` ${dim(meta_parts)}` : ''}`
@@ -94,16 +94,39 @@ const format_smart_result = (item, index) => {
94
94
  return lines.join('\n')
95
95
  }
96
96
 
97
+ const to_smart_json = (item) => ({
98
+ skill: `${item.workspace_slug}/${item.name}`,
99
+ type: item.type || 'skill',
100
+ description: item.description || '',
101
+ version: item.latest_version || item.version || '-',
102
+ visibility: item.visibility || 'public',
103
+ workspace_slug: item.workspace_slug,
104
+ stars: item.star_count || 0,
105
+ quality_score: item.quality_score != null ? item.quality_score : null,
106
+ quality_tier: get_quality_tier_name(item.quality_score),
107
+ relevance_score: item.relevance_score != null ? item.relevance_score : null,
108
+ match_quality: item.match_quality || null,
109
+ tags: item.tags || [],
110
+ download_count: item.download_count || 0,
111
+ created_at: item.created_at,
112
+ updated_at: item.updated_at,
113
+ })
114
+
97
115
  const run_smart_search = (args, query, options) => catch_errors('Smart search failed', async () => {
98
116
  const limit = parseInt(args.flags.limit)
99
117
  const capped_limit = Math.min(Math.max(limit, 1), 50)
100
118
  const min_quality = args.flags['min-quality'] != null ? parseInt(args.flags['min-quality']) : null
101
119
 
102
120
  const search_opts = { ...options, limit: capped_limit }
103
- const [errors, results] = await repos_api.semantic_search(query, search_opts)
104
- if (errors) throw e('Semantic search failed', errors)
121
+ const [errors, response] = await repos_api.dispatch_search(query, search_opts)
122
+ if (errors) throw e('Search failed', errors)
105
123
 
106
- let items = Array.isArray(results) ? results : (results?.repos || results?.items || [])
124
+ // Response shape: { data: [...], mode, workspace_match, match_notice }
125
+ const items_raw = Array.isArray(response) ? response : (response?.data || response?.repos || response?.items || [])
126
+ let items = items_raw
127
+ const mode = response?.mode || null
128
+ const workspace_match = response?.workspace_match
129
+ const server_match_notice = response?.match_notice || null
107
130
 
108
131
  // Client-side quality filter (only when explicitly requested)
109
132
  if (min_quality != null) {
@@ -114,48 +137,45 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
114
137
 
115
138
  if (items.length === 0) {
116
139
  if (args.flags.json) {
117
- print_json({ data: { query, mode: 'smart', results: [], count: 0 } })
140
+ const data = { query, mode, results: [], count: 0 }
141
+ if (workspace_match !== undefined) data.workspace_match = workspace_match
142
+ if (server_match_notice) data.match_notice = server_match_notice
143
+ print_json({ data })
118
144
  return
119
145
  }
120
- const msg = `No skills found for "${query}".`
121
- print_info(msg)
122
- print_hint('Try broader terms or remove filters.')
146
+ if (mode === 'fuzzy_scoped' && workspace_match === null) {
147
+ const ws_part = query.includes('/') ? query.split('/')[0] : query
148
+ print_info(`No workspace matched "${ws_part}".`)
149
+ print_hint('Check spelling, or remove the workspace prefix to search globally.')
150
+ } else {
151
+ print_info(`No skills found for "${query}".`)
152
+ print_hint('Try broader terms or remove filters.')
153
+ }
123
154
  return
124
155
  }
125
156
 
126
- const has_strong_or_good = items.some(item =>
127
- item.match_quality === 'strong' || item.match_quality === 'good'
128
- )
129
- const match_notice = !has_strong_or_good && items.length > 0
130
- ? 'No strong matches found. The results below are the closest available but may not match your intent.'
131
- : null
157
+ // Trust the server's notice — only emitted for semantic mode where
158
+ // intent-matching is the relevant concept. Fuzzy modes return null.
159
+ const match_notice = server_match_notice
132
160
 
133
161
  if (args.flags.json) {
134
162
  const mapped = items.map(item => ({
135
- skill: `${item.workspace_slug}/${item.name}`,
136
- type: item.type || 'skill',
137
- description: item.description || '',
138
- version: item.latest_version || item.version || '-',
139
- visibility: item.visibility || 'public',
140
- workspace_slug: item.workspace_slug,
141
- stars: item.star_count || 0,
142
- quality_score: item.quality_score != null ? item.quality_score : null,
143
- quality_tier: get_quality_tier_name(item.quality_score),
144
- relevance_score: item.relevance_score != null ? item.relevance_score : null,
145
- match_quality: item.match_quality || null,
146
- tags: item.tags || [],
147
- download_count: item.download_count || 0,
148
- created_at: item.created_at,
149
- updated_at: item.updated_at,
163
+ ...to_smart_json(item),
164
+ similar_count: item.similar_count || 0,
165
+ similar_repos: (item.similar_repos || []).map(member => ({
166
+ ...to_smart_json(member),
167
+ similarity: member.similarity != null ? member.similarity : null,
168
+ })),
150
169
  }))
151
- const data = { query, mode: 'smart', results: mapped, count: mapped.length }
170
+ const data = { query, mode, results: mapped, count: mapped.length }
171
+ if (workspace_match !== undefined) data.workspace_match = workspace_match
152
172
  if (match_notice) data.match_notice = match_notice
153
173
  print_json({ data })
154
174
  return
155
175
  }
156
176
 
157
- // Human-readable smart output
158
- console.log(`\n${bold(`Skills for: "${query}"`)}\n`)
177
+ // Human-readable output
178
+ console.log(`\n${bold(`Skills for: "${query}"`)}${mode ? dim(` [${mode}]`) : ''}\n`)
159
179
  if (match_notice) {
160
180
  console.log(` ${yellow(match_notice)}\n`)
161
181
  }