happyskills 1.6.0 → 1.7.1
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 +12 -0
- package/package.json +1 -1
- package/src/api/repos.js +3 -0
- package/src/api/repos.test.js +16 -0
- package/src/commands/search.js +39 -9
- package/src/commands/search.test.js +21 -1
- package/src/engine/downloader.js +2 -2
- package/src/engine/installer.js +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.7.1] - 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Stop double-counting a single install. The install pipeline tries an archive download first and falls back to a JSON clone when the archive is missing or unusable; both hit the clone endpoint, which counts (`repo.clone` + `download_count`) per request. The fallback now sends `?no_count=1` so one install registers exactly once. Only the install fallback sets it — `fork`, `pull`, and normal installs are unchanged. Takes effect against API v5.4.1+ (older APIs ignore the flag and still count both); forward-only.
|
|
15
|
+
|
|
16
|
+
## [1.7.0] - 2026-06-04
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `search` now exposes `created_at` / `updated_at` on every result and accepts `--sort <relevance|recent>`. `--sort recent` orders results newest-created first — pair it with `--mine` to list your latest-created skills (`happyskills search --mine --sort recent --limit 100`); human output shows a `Created` column. Sorts the fetched page, so pass a sufficient `--limit`. No API change required.
|
|
21
|
+
|
|
10
22
|
## [1.6.0] - 2026-06-04
|
|
11
23
|
|
|
12
24
|
### Added
|
package/package.json
CHANGED
package/src/api/repos.js
CHANGED
|
@@ -61,6 +61,9 @@ const clone = (owner, repo, ref, options = {}) => catch_errors(`Clone ${owner}/$
|
|
|
61
61
|
if (options.commit) params.set('commit', options.commit)
|
|
62
62
|
else if (ref) params.set('ref', ref)
|
|
63
63
|
if (options.format) params.set('format', options.format)
|
|
64
|
+
// Fallback retries (install archive→JSON) mark themselves so the server
|
|
65
|
+
// counts the install only once (the primary attempt already counted).
|
|
66
|
+
if (options.no_count) params.set('no_count', '1')
|
|
64
67
|
const qs = params.toString() ? `?${params}` : ''
|
|
65
68
|
const [errors, data] = await client.get(`/repos/${owner}/${repo}/clone${qs}`)
|
|
66
69
|
if (errors) throw errors[errors.length - 1]
|
package/src/api/repos.test.js
CHANGED
|
@@ -217,3 +217,19 @@ describe('repos.star / repos.unstar — endpoint + path', () => {
|
|
|
217
217
|
assert.strictEqual(calls[0][1], `/repos/${encodeURIComponent('acme corp')}/${encodeURIComponent('deploy/aws')}/star`)
|
|
218
218
|
})
|
|
219
219
|
})
|
|
220
|
+
|
|
221
|
+
// Bug B fix: a fallback retry marks itself no_count so the server counts the
|
|
222
|
+
// install only once (the primary archive attempt already counted).
|
|
223
|
+
describe('repos.clone — no_count flag (double-count prevention)', () => {
|
|
224
|
+
afterEach(restore)
|
|
225
|
+
|
|
226
|
+
it('adds ?no_count=1 only when options.no_count is set', async () => {
|
|
227
|
+
const { repos, calls } = stub_client_capture()
|
|
228
|
+
await repos.clone('acme', 'deploy', 'refs/tags/v1.0.0', { no_count: true })
|
|
229
|
+
await repos.clone('acme', 'deploy', 'refs/tags/v1.0.0', {})
|
|
230
|
+
await repos.clone('acme', 'deploy', 'refs/tags/v1.0.0', { format: 'archive' })
|
|
231
|
+
assert.match(calls[0][1], /[?&]no_count=1/)
|
|
232
|
+
assert.doesNotMatch(calls[1][1], /no_count/)
|
|
233
|
+
assert.doesNotMatch(calls[2][1], /no_count/) // archive is the primary attempt — counts
|
|
234
|
+
})
|
|
235
|
+
})
|
package/src/commands/search.js
CHANGED
|
@@ -34,6 +34,8 @@ Options:
|
|
|
34
34
|
Carried across re-searches in the clarification flow.
|
|
35
35
|
--limit <n> Max results (required, 1-50)
|
|
36
36
|
--min-quality <n> Minimum quality score 0-100
|
|
37
|
+
--sort <order> relevance (default) | recent (newest-created first).
|
|
38
|
+
Sorts the fetched page — pass a sufficient --limit.
|
|
37
39
|
--json Output as JSON
|
|
38
40
|
|
|
39
41
|
Aliases: s
|
|
@@ -43,6 +45,7 @@ Examples:
|
|
|
43
45
|
happyskills search deploy-aws --limit 10 # → fuzzy slug (typo-tolerant)
|
|
44
46
|
happyskills search letta-ai/remotion --limit 5 # → fuzzy scoped
|
|
45
47
|
happyskills search --mine --limit 20
|
|
48
|
+
happyskills search --mine --sort recent --limit 100 # → your latest-created skills
|
|
46
49
|
happyskills search --favorites --limit 20 # → your starred skills
|
|
47
50
|
happyskills search deploy --favorites --limit 10 # → favorites matching "deploy"
|
|
48
51
|
happyskills search deploy --workspace acme --limit 50
|
|
@@ -226,6 +229,10 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
|
|
|
226
229
|
)
|
|
227
230
|
}
|
|
228
231
|
|
|
232
|
+
// --sort recent: re-order newest-created first (the smart `data` shape
|
|
233
|
+
// already carries created_at). Sorts the fetched page.
|
|
234
|
+
if (args.flags.sort === 'recent') items = sort_by_created_desc(items)
|
|
235
|
+
|
|
229
236
|
const next_step = build_search_next_step(response, query, { with_rerank, clarification_turns_used })
|
|
230
237
|
|
|
231
238
|
// Telemetry beacons (fire-and-forget). Spec § 5.1 + § 5.3.
|
|
@@ -329,12 +336,24 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
|
|
|
329
336
|
console.log(`\n${gray(`Showing ${items.length} result${items.length === 1 ? '' : 's'}. Install with: happyskills install <owner>/<name>`)}\n`)
|
|
330
337
|
})
|
|
331
338
|
|
|
339
|
+
// Sort a result set newest-created first. Items without a created_at sink to the
|
|
340
|
+
// bottom. Pure — returns a new array. Used by --sort recent.
|
|
341
|
+
const sort_by_created_desc = (items) => items.slice().sort((a, b) => {
|
|
342
|
+
const da = a.created_at ? new Date(a.created_at).getTime() : 0
|
|
343
|
+
const db = b.created_at ? new Date(b.created_at).getTime() : 0
|
|
344
|
+
return db - da
|
|
345
|
+
})
|
|
346
|
+
|
|
332
347
|
const run_keyword_search = (args, query, options, { is_exact } = {}) => catch_errors('Keyword search failed', async () => {
|
|
333
348
|
const [errors, results] = await repos_api.search(query, options)
|
|
334
349
|
if (errors) throw e('Search failed', errors)
|
|
335
350
|
|
|
336
|
-
|
|
351
|
+
let items = Array.isArray(results) ? results : (results?.repos || results?.items || [])
|
|
337
352
|
const effective_scope = options.scope || 'all'
|
|
353
|
+
// --sort recent: order by creation date (newest first). Sorts the fetched
|
|
354
|
+
// page, so pass a --limit large enough to cover the set you care about.
|
|
355
|
+
const sort_recent = args.flags.sort === 'recent'
|
|
356
|
+
if (sort_recent) items = sort_by_created_desc(items)
|
|
338
357
|
|
|
339
358
|
if (items.length === 0) {
|
|
340
359
|
if (args.flags.json) {
|
|
@@ -354,26 +373,31 @@ const run_keyword_search = (args, query, options, { is_exact } = {}) => catch_er
|
|
|
354
373
|
type: item.type || 'skill',
|
|
355
374
|
description: item.description || '',
|
|
356
375
|
version: item.latest_version || item.version || '-',
|
|
357
|
-
visibility: item.visibility || 'public'
|
|
376
|
+
visibility: item.visibility || 'public',
|
|
377
|
+
created_at: item.created_at || null,
|
|
378
|
+
updated_at: item.updated_at || null
|
|
358
379
|
}))
|
|
359
380
|
const data = { query, scope: effective_scope, results: mapped, count: mapped.length }
|
|
381
|
+
if (sort_recent) data.sort = 'recent'
|
|
360
382
|
if (is_exact) data.hint = 'Smart search (default) uses semantic matching for better results. Remove --exact to use it.'
|
|
361
383
|
print_json({ data })
|
|
362
384
|
return
|
|
363
385
|
}
|
|
364
386
|
|
|
387
|
+
// When sorting by creation date, swap the Description column for a Created
|
|
388
|
+
// column — that's the field the user is sorting on and wants to see.
|
|
389
|
+
const headers = sort_recent ? ['Skill', 'Created', 'Version'] : ['Skill', 'Description', 'Version']
|
|
365
390
|
const rows = items.map(item => {
|
|
366
391
|
const item_type = item.type || 'skill'
|
|
367
392
|
const name = `${item.owner || item.workspace_slug}/${item.name}`
|
|
368
393
|
const display_name = item_type === 'kit' ? `${name} [kit]` : name
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
item.
|
|
372
|
-
|
|
373
|
-
]
|
|
394
|
+
const version = item.latest_version || item.version || '-'
|
|
395
|
+
return sort_recent
|
|
396
|
+
? [display_name, item.created_at ? String(item.created_at).slice(0, 10) : '-', version]
|
|
397
|
+
: [display_name, item.description || '', version]
|
|
374
398
|
})
|
|
375
399
|
|
|
376
|
-
print_table(
|
|
400
|
+
print_table(headers, rows)
|
|
377
401
|
})
|
|
378
402
|
|
|
379
403
|
const run = (args) => catch_errors('Search failed', async () => {
|
|
@@ -402,6 +426,10 @@ const run = (args) => catch_errors('Search failed', async () => {
|
|
|
402
426
|
throw new UsageError('--limit is required. Specify the number of results you want (e.g. --limit 10).')
|
|
403
427
|
}
|
|
404
428
|
|
|
429
|
+
if (args.flags.sort != null && !['relevance', 'recent'].includes(args.flags.sort)) {
|
|
430
|
+
throw new UsageError('--sort must be one of: relevance, recent.')
|
|
431
|
+
}
|
|
432
|
+
|
|
405
433
|
if (type && !VALID_SKILL_TYPES.includes(type)) {
|
|
406
434
|
throw new UsageError(`--type must be one of: ${VALID_SKILL_TYPES.join(', ')}`)
|
|
407
435
|
}
|
|
@@ -467,6 +495,7 @@ const schema = {
|
|
|
467
495
|
{ name: 'clarification-turns-used', type: 'number', default: 0, description: 'Clarification budget already spent (0-2)' },
|
|
468
496
|
{ name: 'limit', type: 'number', required: true, description: 'Max results (1-50)' },
|
|
469
497
|
{ name: 'min-quality', type: 'number', default: undefined, description: 'Minimum quality score 0-100' },
|
|
498
|
+
{ name: 'sort', type: 'string', default: 'relevance', description: 'relevance (default) | recent (newest-created first; sorts the fetched page)' },
|
|
470
499
|
{ name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
|
|
471
500
|
],
|
|
472
501
|
},
|
|
@@ -490,7 +519,8 @@ const schema = {
|
|
|
490
519
|
examples: [
|
|
491
520
|
'happyskills search "deploy infra to AWS" --limit 10',
|
|
492
521
|
'happyskills search "deploy infra to AWS" --with-rerank --json --limit 50',
|
|
522
|
+
'happyskills search --mine --sort recent --limit 100',
|
|
493
523
|
],
|
|
494
524
|
}
|
|
495
525
|
|
|
496
|
-
module.exports = { run, build_search_next_step, schema }
|
|
526
|
+
module.exports = { run, build_search_next_step, sort_by_created_desc, schema }
|
|
@@ -11,13 +11,33 @@
|
|
|
11
11
|
|
|
12
12
|
const { describe, it, afterEach } = require('node:test')
|
|
13
13
|
const assert = require('node:assert/strict')
|
|
14
|
-
const { build_search_next_step } = require('./search')
|
|
14
|
+
const { build_search_next_step, sort_by_created_desc } = require('./search')
|
|
15
15
|
const {
|
|
16
16
|
NEXT_STEP_KINDS,
|
|
17
17
|
NEXT_STEP_ACTIONS,
|
|
18
18
|
kind_for_action,
|
|
19
19
|
} = require('../constants/next_step_actions')
|
|
20
20
|
|
|
21
|
+
// --sort recent ordering (Phase B): newest created_at first, nulls last, pure.
|
|
22
|
+
describe('sort_by_created_desc — --sort recent', () => {
|
|
23
|
+
it('orders newest-created first and sinks items with no created_at', () => {
|
|
24
|
+
const out = sort_by_created_desc([
|
|
25
|
+
{ name: 'a', created_at: '2026-05-01T00:00:00Z' },
|
|
26
|
+
{ name: 'b', created_at: '2026-06-01T00:00:00Z' },
|
|
27
|
+
{ name: 'c', created_at: null },
|
|
28
|
+
{ name: 'd', created_at: '2026-05-20T00:00:00Z' },
|
|
29
|
+
])
|
|
30
|
+
assert.deepEqual(out.map(x => x.name), ['b', 'd', 'a', 'c'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('is pure — does not mutate the input array', () => {
|
|
34
|
+
const input = [{ name: 'a', created_at: '2026-01-01T00:00:00Z' }, { name: 'b', created_at: '2026-02-01T00:00:00Z' }]
|
|
35
|
+
const before = input.map(x => x.name).join(',')
|
|
36
|
+
sort_by_created_desc(input)
|
|
37
|
+
assert.equal(input.map(x => x.name).join(','), before)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
21
41
|
const is_empty = (o) => o && typeof o === 'object' && !Array.isArray(o) && Object.keys(o).length === 0
|
|
22
42
|
|
|
23
43
|
const assert_envelope_next_step = (ns, expected_action, label) => {
|
package/src/engine/downloader.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
2
|
const repos_api = require('../api/repos')
|
|
3
3
|
|
|
4
|
-
const download = (owner, repo, ref) => catch_errors(`Download ${owner}/${repo} failed`, async () => {
|
|
5
|
-
const [errors, data] = await repos_api.clone(owner, repo, ref)
|
|
4
|
+
const download = (owner, repo, ref, options = {}) => catch_errors(`Download ${owner}/${repo} failed`, async () => {
|
|
5
|
+
const [errors, data] = await repos_api.clone(owner, repo, ref, options)
|
|
6
6
|
if (errors) throw e(`Failed to clone ${owner}/${repo}`, errors)
|
|
7
7
|
return data
|
|
8
8
|
})
|
package/src/engine/installer.js
CHANGED
|
@@ -204,8 +204,11 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
|
|
|
204
204
|
clone_ref = arch_result.ref
|
|
205
205
|
clone_commit = arch_result.commit
|
|
206
206
|
} else {
|
|
207
|
-
// Fall back to JSON clone
|
|
208
|
-
|
|
207
|
+
// Fall back to JSON clone. The archive attempt above already hit the
|
|
208
|
+
// clone endpoint (and counted the install), so mark this retry
|
|
209
|
+
// no_count to avoid double-counting one install (download_count +
|
|
210
|
+
// repo.clone). See repos.js clone handler.
|
|
211
|
+
const [dl_errors, clone_data] = await download(owner, name, pkg.ref, { no_count: true })
|
|
209
212
|
if (dl_errors) { spinner.fail(`Download failed: ${pkg.skill}`); throw e(`Download ${pkg.skill} failed`, dl_errors) }
|
|
210
213
|
|
|
211
214
|
const [ext_errors] = await extract(clone_data, pkg_tmp)
|