happyskills 1.5.0 → 1.7.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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.7.0] - 2026-06-04
11
+
12
+ ### Added
13
+
14
+ - `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.
15
+
16
+ ## [1.6.0] - 2026-06-04
17
+
18
+ ### Added
19
+
20
+ - Extend `stats` (all additive): `--group-by skill` on `--scope my_activity` (rank which skills *you* installed); a new `installs` metric on `--scope my_skills_reach` that counts total installs of your authored skills (you + others; bot excluded), with `--exclude-self` to drop your own. Availability warnings are now softer — suppressed for `--period` presets, terser for explicit `--from`. Requires API v5.4.0+.
21
+
10
22
  ## [1.5.0] - 2026-06-04
11
23
 
12
24
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.5.0",
3
+ "version": "1.7.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)",
@@ -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
- const items = Array.isArray(results) ? results : (results?.repos || results?.items || [])
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
- return [
370
- display_name,
371
- item.description || '',
372
- item.latest_version || item.version || '-'
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(['Skill', 'Description', 'Version'], rows)
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) => {
@@ -15,10 +15,10 @@ const token_store = require('../auth/token_store')
15
15
 
16
16
  const METRICS_BY_SCOPE = {
17
17
  my_activity: ['installs', 'updates', 'searches', 'uninstalls'],
18
- my_skills_reach: ['installs_by_others', 'distinct_installers'],
18
+ my_skills_reach: ['installs', 'installs_by_others', 'distinct_installers'],
19
19
  }
20
20
  const GROUP_BY_BY_SCOPE = {
21
- my_activity: ['none', 'day', 'week', 'month', 'surface'],
21
+ my_activity: ['none', 'day', 'week', 'month', 'surface', 'skill'],
22
22
  my_skills_reach: ['none', 'day', 'week', 'month', 'skill'],
23
23
  }
24
24
  const VALID_PRESETS = ['7d', '30d', '90d', '6mo', '12mo']
@@ -35,21 +35,25 @@ Two scopes:
35
35
  Options:
36
36
  --scope <scope> my_activity | my_skills_reach (required)
37
37
  --metric <metric> my_activity: installs, updates, searches, uninstalls
38
- my_skills_reach: installs_by_others, distinct_installers
38
+ my_skills_reach: installs (everyone), installs_by_others,
39
+ distinct_installers
39
40
  --period <preset> ${VALID_PRESETS.join(' | ')}
40
41
  --from <ISO date> Explicit window start (use instead of --period)
41
42
  --to <ISO date> Explicit window end (default: now)
42
- --group-by <bucket> my_activity: none, day, week, month, surface
43
+ --group-by <bucket> my_activity: none, day, week, month, surface, skill
43
44
  my_skills_reach: none, day, week, month, skill
44
45
  (default: none)
45
46
  --skill <owner/skill> Limit my_skills_reach to one skill you authored
47
+ --exclude-self my_skills_reach 'installs' only: exclude your own installs
48
+ (installs counts you by default)
46
49
  --json Output as JSON envelope
47
50
 
48
51
  Examples:
49
52
  happyskills stats --scope my_activity --metric installs --period 30d
53
+ happyskills stats --scope my_activity --metric installs --period 30d --group-by skill
50
54
  happyskills stats --scope my_activity --metric searches --period 90d --group-by week --json
51
- happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo
52
- happyskills stats --scope my_skills_reach --metric installs_by_others --period 12mo --group-by skill
55
+ happyskills stats --scope my_skills_reach --metric installs --period 12mo --group-by skill
56
+ happyskills stats --scope my_skills_reach --metric installs --period 12mo --exclude-self
53
57
  happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo --skill acme/deploy-aws
54
58
  happyskills stats --scope my_activity --metric installs --from 2026-05-01 --to 2026-06-01`
55
59
 
@@ -98,6 +102,14 @@ const build_request = (flags) => {
98
102
  body.repo_slug = flags.skill
99
103
  }
100
104
 
105
+ // --exclude-self refines my_skills_reach `installs` (which counts you by
106
+ // default). Meaningless elsewhere — the server enforces this too.
107
+ if (flags['exclude-self']) {
108
+ if (scope !== 'my_skills_reach')
109
+ throw new UsageError('--exclude-self is only valid for --scope my_skills_reach.')
110
+ body.exclude_self = true
111
+ }
112
+
101
113
  return body
102
114
  }
103
115
 
@@ -161,14 +173,15 @@ const schema = {
161
173
  input: {
162
174
  positional: [],
163
175
  flags: [
164
- { name: 'scope', type: 'string', required: true, description: 'my_activity | my_skills_reach' },
165
- { name: 'metric', type: 'string', required: true, description: 'my_activity: installs|updates|searches|uninstalls; my_skills_reach: installs_by_others|distinct_installers' },
166
- { name: 'period', type: 'string', default: undefined, description: 'Window preset: 7d|30d|90d|6mo|12mo (use instead of --from/--to)' },
167
- { name: 'from', type: 'string', default: undefined, description: 'Explicit window start (ISO date)' },
168
- { name: 'to', type: 'string', default: undefined, description: 'Explicit window end (ISO date, default now)' },
169
- { name: 'group-by', type: 'string', default: 'none', description: 'my_activity: none|day|week|month|surface; my_skills_reach: none|day|week|month|skill' },
170
- { name: 'skill', type: 'string', default: undefined, description: 'my_skills_reach only: limit to one authored skill (owner/skill)' },
171
- { name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
176
+ { name: 'scope', type: 'string', required: true, description: 'my_activity | my_skills_reach' },
177
+ { name: 'metric', type: 'string', required: true, description: 'my_activity: installs|updates|searches|uninstalls; my_skills_reach: installs|installs_by_others|distinct_installers' },
178
+ { name: 'period', type: 'string', default: undefined, description: 'Window preset: 7d|30d|90d|6mo|12mo (use instead of --from/--to)' },
179
+ { name: 'from', type: 'string', default: undefined, description: 'Explicit window start (ISO date)' },
180
+ { name: 'to', type: 'string', default: undefined, description: 'Explicit window end (ISO date, default now)' },
181
+ { name: 'group-by', type: 'string', default: 'none', description: 'my_activity: none|day|week|month|surface|skill; my_skills_reach: none|day|week|month|skill' },
182
+ { name: 'skill', type: 'string', default: undefined, description: 'my_skills_reach only: limit to one authored skill (owner/skill)' },
183
+ { name: 'exclude-self', type: 'boolean', default: false, description: "my_skills_reach 'installs' only: exclude your own installs (counted by default)" },
184
+ { name: 'json', type: 'boolean', default: false, description: 'Output as JSON' },
172
185
  ],
173
186
  },
174
187
  output: {
@@ -190,9 +203,10 @@ const schema = {
190
203
  { code: 'NETWORK_ERROR', next_step: { kind: 'recovery', action: 'retry' } },
191
204
  ],
192
205
  examples: [
193
- 'happyskills stats --scope my_activity --metric installs --period 30d',
194
- 'happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo --group-by skill',
195
- 'happyskills stats --scope my_skills_reach --metric installs_by_others --period 12mo --skill acme/deploy-aws',
206
+ 'happyskills stats --scope my_activity --metric installs --period 30d --group-by skill',
207
+ 'happyskills stats --scope my_skills_reach --metric installs --period 12mo --group-by skill',
208
+ 'happyskills stats --scope my_skills_reach --metric installs --period 12mo --exclude-self',
209
+ 'happyskills stats --scope my_skills_reach --metric distinct_installers --period 12mo --skill acme/deploy-aws',
196
210
  ],
197
211
  }
198
212
 
@@ -59,6 +59,26 @@ describe('build_request — closed grammar', () => {
59
59
  it('rejects a --skill without owner/skill form', () => {
60
60
  assert.throws(() => build_request({ scope: 'my_skills_reach', metric: 'installs_by_others', period: '12mo', skill: 'deploy' }), /owner\/skill/)
61
61
  })
62
+
63
+ it('accepts group-by skill on my_activity (rank your own installs by skill)', () => {
64
+ const body = build_request({ scope: 'my_activity', metric: 'installs', period: '30d', 'group-by': 'skill' })
65
+ assert.equal(body.group_by, 'skill')
66
+ })
67
+
68
+ it('accepts the my_skills_reach `installs` metric', () => {
69
+ const body = build_request({ scope: 'my_skills_reach', metric: 'installs', period: '12mo' })
70
+ assert.equal(body.metric, 'installs')
71
+ assert.equal(body.exclude_self, undefined) // self included by default
72
+ })
73
+
74
+ it('maps --exclude-self to exclude_self for my_skills_reach', () => {
75
+ const body = build_request({ scope: 'my_skills_reach', metric: 'installs', period: '12mo', 'exclude-self': true })
76
+ assert.equal(body.exclude_self, true)
77
+ })
78
+
79
+ it('rejects --exclude-self on my_activity', () => {
80
+ assert.throws(() => build_request({ scope: 'my_activity', metric: 'installs', period: '30d', 'exclude-self': true }), /--exclude-self is only valid/)
81
+ })
62
82
  })
63
83
 
64
84
  describe('schema export', () => {