happyskills 1.3.0 → 1.4.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,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.4.0] - 2026-06-03
11
+
12
+ ### Added
13
+
14
+ - Add `star <owner/name>` and `unstar <owner/name>` commands to curate your favorites. Both require auth and are idempotent (starring an already-starred skill, or unstarring one you haven't starred, succeeds without error).
15
+ - Add `--favorites` flag to `search` — query only the skills you've starred (`scope=favorites`). Routes through the smart dispatcher; a bare `search --favorites` (no query) browses all your starred skills. Requires auth; cannot be combined with `--exact`.
16
+
10
17
  ## [1.3.0] - 2026-06-03
11
18
 
12
19
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.3.0",
3
+ "version": "1.4.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
@@ -105,6 +105,21 @@ const patch_repo = (owner, name, fields) => catch_errors(`Failed to update ${own
105
105
  return data
106
106
  })
107
107
 
108
+ // Star / unstar a repo. Both endpoints are idempotent server-side (starring an
109
+ // already-starred repo, or unstarring an unstarred one, succeeds). The user's
110
+ // own stars power the `favorites` search scope.
111
+ const star = (owner, name) => catch_errors(`Failed to star ${owner}/${name}`, async () => {
112
+ const [errors, data] = await client.post(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/star`)
113
+ if (errors) throw errors[errors.length - 1]
114
+ return data
115
+ })
116
+
117
+ const unstar = (owner, name) => catch_errors(`Failed to unstar ${owner}/${name}`, async () => {
118
+ const [errors, data] = await client.del(`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/star`)
119
+ if (errors) throw errors[errors.length - 1]
120
+ return data
121
+ })
122
+
108
123
  const compare = (owner, repo, base_commit) => catch_errors(`Compare ${owner}/${repo} failed`, async () => {
109
124
  const [errors, data] = await client.post(`/repos/${owner}/${repo}/compare`, { base_commit })
110
125
  if (errors) throw errors[errors.length - 1]
@@ -195,4 +210,4 @@ const semantic_search = (query, options = {}) => catch_errors('Semantic search f
195
210
  return data
196
211
  })
197
212
 
198
- 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 }
213
+ module.exports = { search, dispatch_search, semantic_search, resolve_dependencies, clone, push, get_refs, get_repo, check_updates, del_repo, patch_repo, star, unstar, compare, get_blob, get_tree }
@@ -169,3 +169,51 @@ describe('repos.get_blob — content integrity verification', () => {
169
169
  assert.strictEqual(data.size, content.length)
170
170
  })
171
171
  })
172
+
173
+ // Capturing client stub: records (method, path) for each call so star/unstar
174
+ // path construction can be asserted. Mirrors stub_client above.
175
+ const stub_client_capture = () => {
176
+ const calls = []
177
+ const record = (method) => async (path) => { calls.push([method, path]); return [null, { message: 'ok' }] }
178
+ const client_path = require.resolve('./client')
179
+ require.cache[client_path] = {
180
+ id: client_path,
181
+ filename: client_path,
182
+ loaded: true,
183
+ exports: {
184
+ get: record('get'),
185
+ post: record('post'),
186
+ put: record('put'),
187
+ patch: record('patch'),
188
+ del: record('del'),
189
+ request: record('request'),
190
+ get_base_url: () => 'http://test'
191
+ }
192
+ }
193
+ delete require.cache[require.resolve('./repos')]
194
+ return { repos: require('./repos'), calls }
195
+ }
196
+
197
+ describe('repos.star / repos.unstar — endpoint + path', () => {
198
+ afterEach(restore)
199
+
200
+ it('star() POSTs to /repos/<owner>/<name>/star', async () => {
201
+ const { repos, calls } = stub_client_capture()
202
+ const [err] = await repos.star('acme', 'deploy-aws')
203
+ assert.strictEqual(err, null)
204
+ assert.deepStrictEqual(calls[0], ['post', '/repos/acme/deploy-aws/star'])
205
+ })
206
+
207
+ it('unstar() DELETEs /repos/<owner>/<name>/star', async () => {
208
+ const { repos, calls } = stub_client_capture()
209
+ const [err] = await repos.unstar('acme', 'deploy-aws')
210
+ assert.strictEqual(err, null)
211
+ assert.deepStrictEqual(calls[0], ['del', '/repos/acme/deploy-aws/star'])
212
+ })
213
+
214
+ it('URL-encodes owner and name in the star path', async () => {
215
+ const { repos, calls } = stub_client_capture()
216
+ await repos.star('acme corp', 'deploy/aws')
217
+ assert.strictEqual(calls[0][1], `/repos/${encodeURIComponent('acme corp')}/${encodeURIComponent('deploy/aws')}/star`)
218
+ })
219
+ })
@@ -16,12 +16,13 @@ fuzzy slug, and "workspace/skill" form goes to fuzzy scoped (both parts are
16
16
  typo-tolerant). Use --exact to force keyword-only FTS instead.
17
17
 
18
18
  Arguments:
19
- query Search term (optional with --mine, --personal, or --workspace)
19
+ query Search term (optional with --mine, --personal, --workspace, or --favorites)
20
20
 
21
21
  Options:
22
22
  -w, --workspace <slug> Search within specific workspace(s) (comma-separated)
23
23
  --mine Search across all your workspaces
24
24
  --personal Search only your personal workspace
25
+ --favorites Search only skills you've starred (requires auth)
25
26
  --tags <tags> Filter by tags (comma-separated)
26
27
  --type <type> Filter by type (skill, kit)
27
28
  --exact Force keyword-only FTS matching (skip smart routing)
@@ -42,6 +43,8 @@ Examples:
42
43
  happyskills search deploy-aws --limit 10 # → fuzzy slug (typo-tolerant)
43
44
  happyskills search letta-ai/remotion --limit 5 # → fuzzy scoped
44
45
  happyskills search --mine --limit 20
46
+ happyskills search --favorites --limit 20 # → your starred skills
47
+ happyskills search deploy --favorites --limit 10 # → favorites matching "deploy"
45
48
  happyskills search deploy --workspace acme --limit 50
46
49
  happyskills search --type kit --limit 10
47
50
  happyskills search deploy --exact --limit 10 # → keyword FTS only`
@@ -380,15 +383,19 @@ const run = (args) => catch_errors('Search failed', async () => {
380
383
  }
381
384
 
382
385
  const query = args._.join(' ') || null
383
- const { mine, personal, workspace, tags, type } = args.flags
386
+ const { mine, personal, workspace, tags, type, favorites } = args.flags
384
387
  const is_exact = !!args.flags.exact
385
388
  const with_rerank = !!args.flags['with-rerank']
386
- // --smart / -S accepted for backward compat (now the default when a query is provided)
387
- const use_smart = query && !is_exact
388
- const has_scope_flag = mine || personal || workspace
389
+ // --smart / -S accepted for backward compat (now the default when a query is provided).
390
+ // --favorites also forces the smart (POST /repos:search) path even with no query:
391
+ // favorites is a dispatcher-only scope, so it must never fall through to the
392
+ // legacy keyword GET, which doesn't understand it. A bare `--favorites` browses
393
+ // the user's starred skills (list mode).
394
+ const use_smart = (query || favorites) && !is_exact
395
+ const has_scope_flag = mine || personal || workspace || favorites
389
396
 
390
397
  if (!query && !has_scope_flag && !tags && !type) {
391
- throw new UsageError('Please provide a search query or use --mine, --personal, or --workspace.')
398
+ throw new UsageError('Please provide a search query or use --mine, --personal, --workspace, or --favorites.')
392
399
  }
393
400
 
394
401
  if (!args.flags.limit) {
@@ -405,9 +412,15 @@ const run = (args) => catch_errors('Search failed', async () => {
405
412
  throw new UsageError('--with-rerank cannot be combined with --exact. The rerank protocol applies only to semantic-mode dispatches.')
406
413
  }
407
414
 
408
- const scope = mine ? 'mine' : personal ? 'personal' : undefined
415
+ // Favorites is a dispatcher-only scope (POST /repos:search); --exact forces the
416
+ // legacy keyword endpoint, which doesn't support it. Refuse the combination.
417
+ if (favorites && is_exact) {
418
+ throw new UsageError('--favorites cannot be combined with --exact. Favorites search uses the smart dispatcher, not keyword-only FTS.')
419
+ }
420
+
421
+ const scope = favorites ? 'favorites' : mine ? 'mine' : personal ? 'personal' : undefined
409
422
 
410
- if (mine || personal) {
423
+ if (mine || personal || favorites) {
411
424
  const [, token_data] = await load_token()
412
425
  if (!token_data) {
413
426
  throw new AuthError('Authentication required. Run `happyskills login` first.')
@@ -9,7 +9,7 @@
9
9
  // short names (`clarify`, `present_to_user` alone) no longer apply where the
10
10
  // new enum supersedes them.
11
11
 
12
- const { describe, it } = require('node:test')
12
+ const { describe, it, afterEach } = require('node:test')
13
13
  const assert = require('node:assert/strict')
14
14
  const { build_search_next_step } = require('./search')
15
15
  const {
@@ -146,3 +146,62 @@ describe('build_search_next_step — envelope shape', () => {
146
146
  assert.strictEqual(opts[3].refined_query_hint, null)
147
147
  })
148
148
  })
149
+
150
+ // --- search --favorites wiring (spec 260601-02 § 4.3) ---------------------
151
+ // Stub the API client + token store so run() exercises the flag-routing logic
152
+ // without a network call. favorites is a dispatcher-only scope, so the key
153
+ // assertions are: it sets scope='favorites' on dispatch_search (POST) and never
154
+ // falls through to the keyword GET, and a bare --favorites (no query) is allowed.
155
+
156
+ const stub_search_deps = (capture) => {
157
+ const repos_path = require.resolve('../api/repos')
158
+ require.cache[repos_path] = {
159
+ id: repos_path, filename: repos_path, loaded: true,
160
+ exports: {
161
+ dispatch_search: async (query, opts) => {
162
+ capture.dispatch = { query, opts }
163
+ return [null, { data: [], mode: 'list', match_notice: null }]
164
+ },
165
+ search: async (query, opts) => { capture.keyword = { query, opts }; return [null, []] },
166
+ },
167
+ }
168
+ const token_path = require.resolve('../auth/token_store')
169
+ require.cache[token_path] = {
170
+ id: token_path, filename: token_path, loaded: true,
171
+ exports: {
172
+ load_token: async () => [null, { token: 'test-token' }],
173
+ require_token: async () => ({ token: 'test-token' }),
174
+ save_token: async () => {}, clear_token: async () => {},
175
+ },
176
+ }
177
+ delete require.cache[require.resolve('./search')]
178
+ return require('./search')
179
+ }
180
+
181
+ const restore_search_deps = () => {
182
+ delete require.cache[require.resolve('../api/repos')]
183
+ delete require.cache[require.resolve('../auth/token_store')]
184
+ delete require.cache[require.resolve('./search')]
185
+ }
186
+
187
+ describe('search --favorites — scope wiring', () => {
188
+ afterEach(restore_search_deps)
189
+
190
+ it('bare --favorites (no query) is allowed and dispatches scope=favorites via POST, not keyword GET', async () => {
191
+ const capture = {}
192
+ const search = stub_search_deps(capture)
193
+ await search.run({ _: [], flags: { favorites: true, limit: '20', json: true } })
194
+ assert.ok(capture.dispatch, 'dispatch_search (POST) must be called for favorites')
195
+ assert.strictEqual(capture.keyword, undefined, 'keyword GET must NOT be used for favorites')
196
+ assert.strictEqual(capture.dispatch.opts.scope, 'favorites')
197
+ assert.strictEqual(capture.dispatch.query, null, 'bare --favorites sends a null query (list mode)')
198
+ })
199
+
200
+ it('--favorites with a query keeps scope=favorites and forwards the query', async () => {
201
+ const capture = {}
202
+ const search = stub_search_deps(capture)
203
+ await search.run({ _: ['deploy'], flags: { favorites: true, limit: '10', json: true } })
204
+ assert.strictEqual(capture.dispatch.opts.scope, 'favorites')
205
+ assert.strictEqual(capture.dispatch.query, 'deploy')
206
+ })
207
+ })
@@ -0,0 +1,82 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const { star } = require('../api/repos')
3
+ const { require_token } = require('../auth/token_store')
4
+ const { print_help, print_json, print_success } = require('../ui/output')
5
+ const { exit_with_error, UsageError } = require('../utils/errors')
6
+ const { EXIT_CODES } = require('../constants')
7
+
8
+ const HELP_TEXT = `Usage: happyskills star <owner/name> [options]
9
+
10
+ Star a skill. Your starred skills are your favorites — search them with
11
+ \`happyskills search --favorites\`. Idempotent: starring an already-starred
12
+ skill succeeds without error.
13
+
14
+ Arguments:
15
+ owner/name Skill to star (e.g., acme/deploy-aws)
16
+
17
+ Options:
18
+ --json Output as JSON
19
+
20
+ Examples:
21
+ happyskills star acme/deploy-aws
22
+ happyskills star acme/deploy-aws --json`
23
+
24
+ const run = (args) => catch_errors('Star failed', async () => {
25
+ if (args.flags._show_help) {
26
+ print_help(HELP_TEXT)
27
+ return process.exit(EXIT_CODES.SUCCESS)
28
+ }
29
+
30
+ const skill = args._[0]
31
+ if (!skill) {
32
+ throw new UsageError('Please specify a skill to star (e.g., happyskills star acme/deploy-aws).')
33
+ }
34
+ if (!skill.includes('/')) {
35
+ throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
36
+ }
37
+
38
+ await require_token()
39
+
40
+ const [owner, name] = skill.split('/')
41
+ const [errors, result] = await star(owner, name)
42
+ if (errors) throw errors[errors.length - 1]
43
+
44
+ if (args.flags.json) {
45
+ print_json({ data: { starred: true, skill, ...(result || {}) } })
46
+ return
47
+ }
48
+
49
+ print_success(`Starred "${skill}". Browse your favorites with: happyskills search --favorites`)
50
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
51
+
52
+ const schema = {
53
+ name: 'star',
54
+ audience: 'consumer',
55
+ purpose: 'Star a skill. Starred skills power the favorites search scope. Idempotent.',
56
+ mutation: true,
57
+ interactive_in_text_mode: false,
58
+ input: {
59
+ positional: [
60
+ { name: 'skill', required: true, type: 'string', pattern: '<owner>/<name>' },
61
+ ],
62
+ flags: [
63
+ { name: 'json', type: 'boolean', default: false },
64
+ ],
65
+ },
66
+ output: {
67
+ data_shape: {
68
+ starred: 'boolean',
69
+ skill: 'string',
70
+ },
71
+ },
72
+ errors: [
73
+ { code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
74
+ { code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
75
+ ],
76
+ examples: [
77
+ 'happyskills star acme/deploy-aws',
78
+ 'happyskills star acme/deploy-aws --json',
79
+ ],
80
+ }
81
+
82
+ module.exports = { run, schema }
@@ -0,0 +1,81 @@
1
+ const { error: { catch_errors } } = require('puffy-core')
2
+ const { unstar } = require('../api/repos')
3
+ const { require_token } = require('../auth/token_store')
4
+ const { print_help, print_json, print_success } = require('../ui/output')
5
+ const { exit_with_error, UsageError } = require('../utils/errors')
6
+ const { EXIT_CODES } = require('../constants')
7
+
8
+ const HELP_TEXT = `Usage: happyskills unstar <owner/name> [options]
9
+
10
+ Remove a skill from your favorites. Idempotent: unstarring a skill you have
11
+ not starred succeeds without error.
12
+
13
+ Arguments:
14
+ owner/name Skill to unstar (e.g., acme/deploy-aws)
15
+
16
+ Options:
17
+ --json Output as JSON
18
+
19
+ Examples:
20
+ happyskills unstar acme/deploy-aws
21
+ happyskills unstar acme/deploy-aws --json`
22
+
23
+ const run = (args) => catch_errors('Unstar failed', async () => {
24
+ if (args.flags._show_help) {
25
+ print_help(HELP_TEXT)
26
+ return process.exit(EXIT_CODES.SUCCESS)
27
+ }
28
+
29
+ const skill = args._[0]
30
+ if (!skill) {
31
+ throw new UsageError('Please specify a skill to unstar (e.g., happyskills unstar acme/deploy-aws).')
32
+ }
33
+ if (!skill.includes('/')) {
34
+ throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
35
+ }
36
+
37
+ await require_token()
38
+
39
+ const [owner, name] = skill.split('/')
40
+ const [errors, result] = await unstar(owner, name)
41
+ if (errors) throw errors[errors.length - 1]
42
+
43
+ if (args.flags.json) {
44
+ print_json({ data: { starred: false, skill, ...(result || {}) } })
45
+ return
46
+ }
47
+
48
+ print_success(`Unstarred "${skill}".`)
49
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
50
+
51
+ const schema = {
52
+ name: 'unstar',
53
+ audience: 'consumer',
54
+ purpose: 'Remove a skill from your favorites (unstar). Idempotent.',
55
+ mutation: true,
56
+ interactive_in_text_mode: false,
57
+ input: {
58
+ positional: [
59
+ { name: 'skill', required: true, type: 'string', pattern: '<owner>/<name>' },
60
+ ],
61
+ flags: [
62
+ { name: 'json', type: 'boolean', default: false },
63
+ ],
64
+ },
65
+ output: {
66
+ data_shape: {
67
+ starred: 'boolean',
68
+ skill: 'string',
69
+ },
70
+ },
71
+ errors: [
72
+ { code: 'USAGE_ERROR', next_step: { kind: 'routing', action: 'discover_schema' } },
73
+ { code: 'AUTH_REQUIRED', next_step: { kind: 'recovery', action: 'login' } },
74
+ ],
75
+ examples: [
76
+ 'happyskills unstar acme/deploy-aws',
77
+ 'happyskills unstar acme/deploy-aws --json',
78
+ ],
79
+ }
80
+
81
+ module.exports = { run, schema }
package/src/constants.js CHANGED
@@ -51,6 +51,8 @@ const COMMANDS = [
51
51
  'visibility',
52
52
  'list',
53
53
  'search',
54
+ 'star',
55
+ 'unstar',
54
56
  'check',
55
57
  'status',
56
58
  'pull',
package/src/index.js CHANGED
@@ -103,6 +103,8 @@ Commands:
103
103
  visibility <owner/skill> Check or set visibility (alias: vis)
104
104
  list List installed skills (alias: ls)
105
105
  search <query> Search the registry (alias: s)
106
+ star <owner/skill> Star a skill (add to your favorites)
107
+ unstar <owner/skill> Remove a skill from your favorites
106
108
  postlex Apply deterministic rerank finalization (agent-only; see happyskills-help skill)
107
109
  versions <owner/skill> List all published versions of a skill
108
110
  changelog <owner/skill> Print a skill's CHANGELOG.md