happyskills 0.47.1 → 0.48.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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.48.0] - 2026-05-22
11
+
12
+ ### Added
13
+ - Add `--search-output <file>` flag to `happyskills postlex`. When set, postlex reads the full `search --with-rerank --json` response envelope from that file and extracts `data.results` internally — the calling agent's stdin payload shrinks to just `{"ranking": [...]}`. **Recommended path** for agentic callers: the agent never has to construct the `data` array by hand, which eliminates two observed failure modes in production (missing `data` field consuming the one retry budget, and `skill` vs `name` field mismatch dropping every ranking entry). Backward-compatible: the legacy `{"ranking", "data"}` stdin shape and the separate `--data <file>` flag both still work and take precedence only when `--search-output` is absent.
14
+
15
+ ### Fixed
16
+ - Fix `to_smart_json` in `search.js` stripping the bare `name` field from `data.results` rows. The function emitted `skill` (the composite "workspace/name" slug) but consumed the underlying `name` field into the template literal, so downstream consumers of `data.results` — most notably `happyskills postlex` — received rows without a usable `name`. postlex's `validate_ranking` then dropped every ranking entry with `data row missing name`. The function now emits both `name` (raw, for downstream pipeline consumers) AND `skill` (composite, for human-readable display), keeping backward compatibility with anything reading `skill`.
17
+ - Add `star_count` to `to_smart_json` output (in addition to the existing `stars` field) so `postlex`'s human-readable table renderer — which expected `row.star_count` — can find the value on rows that originated from the search response. Same root cause as the `name` issue: the search-output and postlex-input shapes had drifted apart silently.
18
+ - Add `resolve_row_name` + `normalize_data_rows` helpers in `postlex.js` that handle the legacy case where `data.results` rows have `skill` but no `name`. `resolve_row_name` falls back to the last `/`-separated segment of `skill`; `normalize_data_rows` runs the resolution across the array. Idempotent. This is defense-in-depth — anyone passing rows from an older CLI version or a hand-crafted payload no longer trips the validator.
19
+
20
+ ### Changed
21
+ - `parse_input` in `postlex.js` accepts a new optional third argument carrying the raw `--search-output` content. When provided, it's parsed and `data.results` is extracted via `extract_data_array_from_search_output`, which tolerates the canonical envelope shape (`{ data: { results: [...] } }`), the legacy `{ data: [...] }` shape, a bare array, and a defensive double-wrapped `{ data: { data: [...] } }`. When both inline `data` (from the stdin payload) and `--search-output` are provided, the search-output wins.
22
+ - `postlex`'s help text rewritten to lead with the recommended `--search-output` recipe and demote the legacy stdin `{ranking, data}` shape to a "still supported" alternative.
23
+ - When `data` cannot be located from any source (no inline `data`, no `--data` file, no `--search-output`), the error message now points the caller at `--search-output` explicitly so they know which input is missing.
24
+
10
25
  ## [0.47.1] - 2026-05-22
11
26
 
12
27
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.47.1",
3
+ "version": "0.48.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)",
@@ -32,22 +32,61 @@ Required:
32
32
  --ranking <file|-> Path to the ranking JSON, or \`-\` for stdin
33
33
 
34
34
  Optional:
35
- --data <file> Separate data file (when --ranking does not embed it)
35
+ --search-output <file> Path to the full \`search --with-rerank --json\` response
36
+ envelope. postlex extracts \`data.results\` from it
37
+ internally, so the ranking payload only needs the
38
+ ranking array. **Recommended path** for agentic
39
+ callers — eliminates the join the agent would
40
+ otherwise have to assemble by hand.
41
+ --data <file> Legacy: separate data file (when --ranking does not
42
+ embed it AND --search-output isn't used)
36
43
  --clarification-turns-used <N>
37
44
  Clarification budget already spent (0-2, default 0)
38
45
  --original-query <q> Original user query (opaque context from prior step)
39
46
  --json Output as JSON (default: human-readable table)
40
47
 
41
- Input shape (stdin or --ranking file):
42
- { "ranking": [{ "rank": 1, "candidate_id": 5, "rationale": "..." }, ...],
43
- "data": [{ "name": "...", "workspace_slug": "...", ... }, ...] }
48
+ Recommended shape (v0.48.0+) agent emits ONLY the ranking, postlex does the join:
49
+ echo '{"ranking":[{"rank":1,"candidate_id":5,"rationale":"..."}, ...]}' | \\
50
+ happyskills postlex --query "deploy aws" \\
51
+ --search-output /tmp/search-out.json \\
52
+ --ranking -
44
53
 
45
- Examples:
54
+ Legacy shape (still supported):
46
55
  echo '{"ranking":[...],"data":[...]}' | happyskills postlex --query "deploy aws" --ranking -
47
56
  happyskills postlex --query "deploy aws" --ranking r.json --data d.json --json`
48
57
 
49
58
  // ─── Pure logic — exported for unit testing ────────────────────────────────
50
59
 
60
+ // Resolve a candidate's bare name, tolerating multiple field conventions.
61
+ // The API row shape has `name`. The CLI's `to_smart_json` output (as of
62
+ // happyskills@0.48.0) emits both `name` and the composite `skill`
63
+ // ("workspace/name"). Older callers may pass through rows that only have
64
+ // `skill`, so we fall back to extracting the bare name from the slug's last
65
+ // `/`-separated segment. Returns null when no name can be derived.
66
+ const resolve_row_name = (row) => {
67
+ if (!row || typeof row !== 'object') return null
68
+ if (typeof row.name === 'string' && row.name) return row.name
69
+ if (typeof row.skill === 'string' && row.skill) {
70
+ const parts = row.skill.split('/')
71
+ const tail = parts[parts.length - 1]
72
+ if (tail) return tail
73
+ }
74
+ return null
75
+ }
76
+
77
+ // Mutate `data` rows in-place to ensure they have a `name` field.
78
+ // Idempotent. Used before validate_ranking / apply_postlex / build_final_ordering
79
+ // so downstream code can keep its simple `row.name` access pattern.
80
+ const normalize_data_rows = (data) => {
81
+ if (!Array.isArray(data)) return
82
+ for (const row of data) {
83
+ if (row && typeof row === 'object' && !row.name) {
84
+ const resolved = resolve_row_name(row)
85
+ if (resolved) row.name = resolved
86
+ }
87
+ }
88
+ }
89
+
51
90
  // Validate the ranking shape. Returns { valid_items, dropped } — invalid
52
91
  // entries are dropped with a reason rather than crashing.
53
92
  const validate_ranking = (ranking, data) => {
@@ -204,9 +243,33 @@ const read_stdin_sync = () => {
204
243
  }
205
244
  }
206
245
 
207
- const parse_input = (raw_ranking_input, raw_data_input) => {
208
- // Accept either combined `{ranking, data}` from stdin/one file, or
209
- // separate `{ranking: ...}`/`{data: ...}` (or bare arrays) from two files.
246
+ // Extract the data array (the rerank candidate set) from a full
247
+ // `happyskills search --with-rerank --json` response envelope. The
248
+ // envelope's shape is `{ data: { query, mode, results: [...], ... },
249
+ // error, next_step }` — we want `envelope.data.results`. Also tolerates a
250
+ // bare array, a `{data: [...]}` shape (legacy callers), and the
251
+ // `{data: {data: [...]}}` shape that appears if someone double-wraps.
252
+ // Returns the array, or null when no array can be located.
253
+ const extract_data_array_from_search_output = (parsed) => {
254
+ if (Array.isArray(parsed)) return parsed
255
+ if (!parsed || typeof parsed !== 'object') return null
256
+ const inner = parsed.data
257
+ if (Array.isArray(inner)) return inner
258
+ if (inner && typeof inner === 'object') {
259
+ if (Array.isArray(inner.results)) return inner.results
260
+ if (Array.isArray(inner.data)) return inner.data
261
+ }
262
+ if (Array.isArray(parsed.results)) return parsed.results
263
+ return null
264
+ }
265
+
266
+ const parse_input = (raw_ranking_input, raw_data_input, raw_search_output_input) => {
267
+ // Accept either:
268
+ // - combined `{ranking, data}` from stdin/one file (legacy v0.47.x shape), OR
269
+ // - separate ranking + data files (`--ranking <file>` + `--data <file>`), OR
270
+ // - just `{ranking: ...}` or a bare ranking array PLUS a `--search-output` file
271
+ // containing the full search envelope from which we extract `data.results`
272
+ // (v0.48.0+ recommended shape — agent never has to construct `data`).
210
273
  const parse_one = (raw, label) => {
211
274
  if (raw == null || raw === '') return { value: null, parse_error: `${label} input is empty` }
212
275
  try {
@@ -240,8 +303,25 @@ const parse_input = (raw_ranking_input, raw_data_input) => {
240
303
  else return { ranking, data: null, parse_error: 'data file does not contain a data array' }
241
304
  }
242
305
 
306
+ if (raw_search_output_input != null) {
307
+ const so_parse = parse_one(raw_search_output_input, 'search-output')
308
+ if (so_parse.parse_error) return { ranking, data, parse_error: so_parse.parse_error }
309
+ const extracted = extract_data_array_from_search_output(so_parse.value)
310
+ if (!Array.isArray(extracted)) {
311
+ return { ranking, data: null, parse_error: 'search-output does not contain a data.results array' }
312
+ }
313
+ // search-output is the recommended source — it overrides any inline data.
314
+ data = extracted
315
+ }
316
+
243
317
  if (!Array.isArray(ranking)) return { ranking: null, data, parse_error: 'ranking field is missing or not an array' }
244
- if (!Array.isArray(data)) return { ranking, data: null, parse_error: 'data field is missing or not an array' }
318
+ if (!Array.isArray(data)) return { ranking, data: null, parse_error: 'data field is missing or not an array — provide --search-output <file> with the search response, or include "data" in the stdin payload' }
319
+
320
+ // Normalize row names (handles the `skill`-without-`name` case from older
321
+ // CLI versions or hand-crafted payloads). Idempotent on rows that already
322
+ // have a `name` field.
323
+ normalize_data_rows(data)
324
+
245
325
  return { ranking, data, parse_error: null }
246
326
  }
247
327
 
@@ -287,6 +367,7 @@ const run = (args) => catch_errors('Postlex failed', async () => {
287
367
  const query = args.flags.query
288
368
  const ranking_path = args.flags.ranking
289
369
  const data_path = args.flags.data
370
+ const search_output_path = args.flags['search-output']
290
371
  const clarification_turns_used = parseInt(args.flags['clarification-turns-used'] || '0', 10) || 0
291
372
 
292
373
  if (!query || typeof query !== 'string')
@@ -309,9 +390,16 @@ const run = (args) => catch_errors('Postlex failed', async () => {
309
390
  return r.content
310
391
  })()
311
392
  : null
393
+ const raw_search_output = search_output_path
394
+ ? (() => {
395
+ const r = read_file(search_output_path)
396
+ if (r.err) throw new UsageError(`Cannot read --search-output file: ${r.err}`)
397
+ return r.content
398
+ })()
399
+ : null
312
400
 
313
401
  // Parse
314
- const { ranking, data, parse_error } = parse_input(raw_ranking, raw_data)
402
+ const { ranking, data, parse_error } = parse_input(raw_ranking, raw_data, raw_search_output)
315
403
  if (parse_error) {
316
404
  process.stderr.write(`postlex: ${parse_error}\n`)
317
405
  const env = build_retry_envelope(query, parse_error, clarification_turns_used, 0)
@@ -399,4 +487,7 @@ module.exports = {
399
487
  determine_next_step,
400
488
  build_retry_envelope,
401
489
  parse_input,
490
+ resolve_row_name,
491
+ normalize_data_rows,
492
+ extract_data_array_from_search_output,
402
493
  }
@@ -16,6 +16,9 @@ const {
16
16
  determine_next_step,
17
17
  build_retry_envelope,
18
18
  parse_input,
19
+ resolve_row_name,
20
+ normalize_data_rows,
21
+ extract_data_array_from_search_output,
19
22
  } = require('./postlex')
20
23
 
21
24
  // ─── Test fixtures ────────────────────────────────────────────────────────
@@ -283,6 +286,144 @@ describe('parse_input', () => {
283
286
 
284
287
  // ─── build_final_ordering ─────────────────────────────────────────────────
285
288
 
289
+ // ─── resolve_row_name + normalize_data_rows (v0.48.0) ─────────────────────
290
+
291
+ describe('resolve_row_name', () => {
292
+ it('returns row.name when present', () => {
293
+ assert.equal(resolve_row_name({ name: 'foo', skill: 'acme/foo' }), 'foo')
294
+ })
295
+
296
+ it('falls back to last segment of skill when name is missing', () => {
297
+ assert.equal(resolve_row_name({ skill: 'acme/deploy-aws' }), 'deploy-aws')
298
+ })
299
+
300
+ it('returns null when neither name nor skill is present', () => {
301
+ assert.equal(resolve_row_name({ description: 'whatever' }), null)
302
+ assert.equal(resolve_row_name({}), null)
303
+ assert.equal(resolve_row_name(null), null)
304
+ })
305
+
306
+ it('returns null when skill is malformed (no slash, just a bare value, returns the value itself)', () => {
307
+ // "deploy-aws" with no slash is a one-segment slug — last segment IS deploy-aws.
308
+ assert.equal(resolve_row_name({ skill: 'deploy-aws' }), 'deploy-aws')
309
+ })
310
+
311
+ it('handles empty-string name by falling back to skill', () => {
312
+ assert.equal(resolve_row_name({ name: '', skill: 'acme/foo' }), 'foo')
313
+ })
314
+ })
315
+
316
+ describe('normalize_data_rows', () => {
317
+ it('adds name to rows that only have skill', () => {
318
+ const data = [{ skill: 'acme/deploy-aws', workspace_slug: 'acme' }]
319
+ normalize_data_rows(data)
320
+ assert.equal(data[0].name, 'deploy-aws')
321
+ })
322
+
323
+ it('leaves rows with existing name untouched', () => {
324
+ const data = [{ name: 'pre-existing', skill: 'acme/different' }]
325
+ normalize_data_rows(data)
326
+ assert.equal(data[0].name, 'pre-existing')
327
+ })
328
+
329
+ it('is idempotent', () => {
330
+ const data = [{ skill: 'acme/deploy-aws' }]
331
+ normalize_data_rows(data)
332
+ normalize_data_rows(data)
333
+ assert.equal(data[0].name, 'deploy-aws')
334
+ })
335
+
336
+ it('handles non-array input without crashing', () => {
337
+ normalize_data_rows(null)
338
+ normalize_data_rows('not an array')
339
+ normalize_data_rows({})
340
+ // no assertion — just confirming no throw
341
+ })
342
+ })
343
+
344
+ // ─── extract_data_array_from_search_output (v0.48.0) ──────────────────────
345
+
346
+ describe('extract_data_array_from_search_output', () => {
347
+ it('extracts data.results from the canonical envelope shape', () => {
348
+ const env = { data: { query: 'q', mode: 'semantic', results: [{ name: 'foo' }] }, error: null, next_step: null }
349
+ const r = extract_data_array_from_search_output(env)
350
+ assert.equal(r.length, 1)
351
+ assert.equal(r[0].name, 'foo')
352
+ })
353
+
354
+ it('handles legacy {data: [...]} (data is bare array)', () => {
355
+ const env = { data: [{ name: 'foo' }] }
356
+ assert.deepEqual(extract_data_array_from_search_output(env), [{ name: 'foo' }])
357
+ })
358
+
359
+ it('handles bare array', () => {
360
+ const env = [{ name: 'foo' }]
361
+ assert.deepEqual(extract_data_array_from_search_output(env), env)
362
+ })
363
+
364
+ it('handles double-wrapped {data: {data: [...]}} (defensive)', () => {
365
+ const env = { data: { data: [{ name: 'foo' }] } }
366
+ assert.deepEqual(extract_data_array_from_search_output(env), [{ name: 'foo' }])
367
+ })
368
+
369
+ it('returns null when no data array can be found', () => {
370
+ assert.equal(extract_data_array_from_search_output({}), null)
371
+ assert.equal(extract_data_array_from_search_output({ data: null }), null)
372
+ assert.equal(extract_data_array_from_search_output('string'), null)
373
+ assert.equal(extract_data_array_from_search_output(null), null)
374
+ })
375
+ })
376
+
377
+ // ─── parse_input with --search-output path (v0.48.0) ──────────────────────
378
+
379
+ describe('parse_input with --search-output', () => {
380
+ it('extracts data.results from a full search envelope passed as the third argument', () => {
381
+ const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'top' }] })
382
+ const search_out = JSON.stringify({
383
+ data: { query: 'q', mode: 'semantic', results: [{ name: 'deploy-aws', workspace_slug: 'acme' }] },
384
+ error: null,
385
+ next_step: null,
386
+ })
387
+ const r = parse_input(ranking, null, search_out)
388
+ assert.equal(r.parse_error, null)
389
+ assert.equal(r.ranking[0].candidate_id, 1)
390
+ assert.equal(r.data[0].name, 'deploy-aws')
391
+ })
392
+
393
+ it('search-output overrides inline data when both are provided', () => {
394
+ const combined = JSON.stringify({
395
+ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }],
396
+ data: [{ name: 'stale-inline-name' }],
397
+ })
398
+ const search_out = JSON.stringify({ data: { results: [{ name: 'fresh-from-search-output' }] } })
399
+ const r = parse_input(combined, null, search_out)
400
+ assert.equal(r.parse_error, null)
401
+ assert.equal(r.data[0].name, 'fresh-from-search-output')
402
+ })
403
+
404
+ it('normalizes rows that only have skill (no name) when sourced from search-output', () => {
405
+ const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
406
+ // to_smart_json before v0.48.0 emitted `skill` without `name`. Simulate that.
407
+ const search_out = JSON.stringify({ data: { results: [{ skill: 'acme/legacy-row', workspace_slug: 'acme' }] } })
408
+ const r = parse_input(ranking, null, search_out)
409
+ assert.equal(r.parse_error, null)
410
+ assert.equal(r.data[0].name, 'legacy-row')
411
+ })
412
+
413
+ it('errors when search-output does not contain a data array', () => {
414
+ const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
415
+ const search_out = JSON.stringify({ data: { query: 'q' } })
416
+ const r = parse_input(ranking, null, search_out)
417
+ assert.match(r.parse_error, /does not contain a data\.results array/)
418
+ })
419
+
420
+ it('errors with an actionable message when data is missing from all sources', () => {
421
+ const ranking = JSON.stringify({ ranking: [{ rank: 1, candidate_id: 1, rationale: 'x' }] })
422
+ const r = parse_input(ranking, null, null)
423
+ assert.match(r.parse_error, /--search-output/)
424
+ })
425
+ })
426
+
286
427
  describe('build_final_ordering', () => {
287
428
  it('joins ranking with data rows, producing slug + rationale', () => {
288
429
  const data = make_data(['deploy-aws', 'serverless'])
@@ -103,12 +103,14 @@ const format_smart_result = (item, index) => {
103
103
 
104
104
  const to_smart_json = (item) => ({
105
105
  skill: `${item.workspace_slug}/${item.name}`,
106
+ name: item.name,
106
107
  type: item.type || 'skill',
107
108
  description: item.description || '',
108
109
  version: item.latest_version || item.version || '-',
109
110
  visibility: item.visibility || 'public',
110
111
  workspace_slug: item.workspace_slug,
111
112
  stars: item.star_count || 0,
113
+ star_count: item.star_count || 0,
112
114
  quality_score: item.quality_score != null ? item.quality_score : null,
113
115
  quality_tier: get_quality_tier_name(item.quality_score),
114
116
  relevance_score: item.relevance_score != null ? item.relevance_score : null,