happyskills 0.33.1 → 0.35.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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.0] - 2026-04-10
11
+
12
+ ### Added
13
+ - Add multi-skill `install` — pass multiple skills in one command (e.g., `happyskills install acme/foo acme/bar@1.0 teamb/baz`)
14
+ - Add multi-skill `uninstall` — remove multiple skills in one command (e.g., `happyskills uninstall acme/foo acme/bar`)
15
+ - Add per-skill inline `@version` pinning for multi-skill install (e.g., `acme/foo@1.2.0 acme/bar@latest`)
16
+ - Add graceful error handling for batch operations — individual failures print a warning and do not interrupt remaining skills
17
+ - Add `--version` flag guard — rejects ambiguous usage with multiple skills, directs to inline `@version` syntax
18
+ - Add `errors` array in JSON output for partial failures alongside successful `data`
19
+
20
+ ## [0.34.0] - 2026-04-09
21
+
22
+ ### Changed
23
+ - Make smart search (semantic + quality ranking) the default when a query is provided — `search "query"` now uses hybrid vector + full-text matching instead of keyword-only
24
+
25
+ ### Added
26
+ - Add `--exact` flag to `search` command to opt out of smart search and use keyword-only matching
27
+ - Add `hint` field to `--exact --json` responses nudging users toward smart search for better results
28
+
10
29
  ## [0.33.1] - 2026-04-08
11
30
 
12
31
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.33.1",
3
+ "version": "0.35.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)",
@@ -2,14 +2,14 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { install, install_from_manifest, install_from_lock } = require('../engine/installer')
3
3
  const { read_lock } = require('../lock/reader')
4
4
  const { read_manifest } = require('../manifest/reader')
5
- const { print_help, print_hint, print_json, code } = require('../ui/output')
5
+ const { print_help, print_hint, print_json, print_warn, code } = require('../ui/output')
6
6
  const { exit_with_error, UsageError } = require('../utils/errors')
7
7
  const { find_project_root } = require('../config/paths')
8
8
  const { EXIT_CODES } = require('../constants')
9
9
 
10
- const HELP_TEXT = `Usage: happyskills install [<owner>/<skill>[@<version>]] [options]
10
+ const HELP_TEXT = `Usage: happyskills install [<owner>/<skill>[@<version>]] [...] [options]
11
11
 
12
- Install a skill and its dependencies.
12
+ Install one or more skills and their dependencies.
13
13
  Without arguments, installs all dependencies from skill.json (or skills-lock.json if no skill.json exists).
14
14
 
15
15
  Arguments:
@@ -18,8 +18,8 @@ Arguments:
18
18
  owner/skill@latest Force install of the latest version (bypasses lock skip check)
19
19
 
20
20
  Options:
21
- -g, --global Install globally (~/.claude/skills/)
22
- --version <ver> Pin to specific version (overrides inline @version)
21
+ -g, --global Install globally (~/.agents/skills/)
22
+ --version <ver> Pin to specific version (single skill only, overrides inline @version)
23
23
  --force Force install on dependency conflicts
24
24
  --fresh Ignore lock file, re-resolve from scratch
25
25
  --agents <list> Target specific agents (comma-separated, e.g., claude,cursor)
@@ -32,8 +32,8 @@ Aliases: i, add
32
32
  Examples:
33
33
  happyskills install
34
34
  happyskills install acme/deploy-aws
35
- happyskills install acme/deploy-aws@1.2.0
36
- happyskills install acme/deploy-aws@latest
35
+ happyskills install acme/deploy-aws acme/monitor acme/logging
36
+ happyskills install acme/deploy-aws@1.2.0 acme/monitor@latest
37
37
  happyskills install acme/deploy-aws --version 1.2.0
38
38
  happyskills i acme/deploy-aws -g`
39
39
 
@@ -43,20 +43,9 @@ const run = (args) => catch_errors('Install failed', async () => {
43
43
  return process.exit(EXIT_CODES.SUCCESS)
44
44
  }
45
45
 
46
- let skill = args._[0]
47
- let version = args.flags.version || undefined
48
-
49
- // Support owner/skill@version inline syntax
50
- if (skill && skill.includes('@')) {
51
- const at_idx = skill.lastIndexOf('@')
52
- const inline_version = skill.slice(at_idx + 1)
53
- skill = skill.slice(0, at_idx)
54
- if (inline_version && !args.flags.version) version = inline_version
55
- }
56
-
57
- const options = {
46
+ const skills_raw = args._
47
+ const base_options = {
58
48
  global: args.flags.global || false,
59
- version,
60
49
  force: args.flags.force || false,
61
50
  fresh: args.flags.fresh || false,
62
51
  yes: args.flags.yes || false,
@@ -64,12 +53,12 @@ const run = (args) => catch_errors('Install failed', async () => {
64
53
  project_root: find_project_root()
65
54
  }
66
55
 
67
- if (!skill) {
56
+ if (skills_raw.length === 0) {
68
57
  const [manifest_err, manifest] = await read_manifest()
69
58
  if (manifest_err) {
70
- const [, lock_data] = await read_lock(options.project_root)
59
+ const [, lock_data] = await read_lock(base_options.project_root)
71
60
  if (lock_data && lock_data.skills && Object.keys(lock_data.skills).length > 0) {
72
- const [errors, result] = await install_from_lock(lock_data, options)
61
+ const [errors, result] = await install_from_lock(lock_data, base_options)
73
62
  if (errors) throw e('Install from lock failed', errors)
74
63
  if (args.flags.json) {
75
64
  print_json({ data: result })
@@ -80,7 +69,7 @@ const run = (args) => catch_errors('Install failed', async () => {
80
69
  throw new UsageError("No skill specified and no skill.json found. Run 'happyskills init' to create a skill.json, or specify a skill to install.")
81
70
  }
82
71
 
83
- const [errors, result] = await install_from_manifest(manifest, options)
72
+ const [errors, result] = await install_from_manifest(manifest, base_options)
84
73
  if (errors) throw e('Install from manifest failed', errors)
85
74
  if (args.flags.json) {
86
75
  print_json({ data: result })
@@ -89,22 +78,62 @@ const run = (args) => catch_errors('Install failed', async () => {
89
78
  return
90
79
  }
91
80
 
92
- if (!skill.includes('/')) {
93
- throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
81
+ // Parse each argument: extract skill name and optional inline @version
82
+ const parsed = skills_raw.map(raw => {
83
+ let skill = raw, version = undefined
84
+ if (skill.includes('@')) {
85
+ const at_idx = skill.lastIndexOf('@')
86
+ version = skill.slice(at_idx + 1)
87
+ skill = skill.slice(0, at_idx)
88
+ }
89
+ return { skill, version }
90
+ })
91
+
92
+ for (const { skill } of parsed) {
93
+ if (!skill.includes('/')) {
94
+ throw new UsageError(`Skill must be in owner/name format (e.g., acme/deploy-aws). Got: ${skill}`)
95
+ }
94
96
  }
95
97
 
96
- const [errors, result] = await install(skill, options)
97
- if (errors) throw e(`Install ${skill} failed`, errors)
98
+ // --version flag only makes sense for a single skill
99
+ const flag_version = args.flags.version || undefined
100
+ if (flag_version && parsed.length > 1) {
101
+ throw new UsageError('--version flag cannot be used with multiple skills. Use inline @version syntax instead (e.g., acme/foo@1.2.0 acme/bar@2.0.0).')
102
+ }
103
+
104
+ const results = []
105
+ const failures = []
106
+ for (const { skill, version: inline_version } of parsed) {
107
+ const version = flag_version || inline_version
108
+ const [errors, result] = await install(skill, { ...base_options, version })
109
+ if (errors) {
110
+ const msg = errors[0]?.message || errors.message || String(errors)
111
+ print_warn(`Failed to install ${skill}: ${msg}`)
112
+ failures.push({ skill, error: msg })
113
+ } else {
114
+ results.push({ skill, result })
115
+ }
116
+ }
117
+
118
+ if (results.length === 0 && failures.length > 0) {
119
+ throw new Error(`All ${failures.length} install(s) failed.`)
120
+ }
98
121
 
99
122
  if (args.flags.json) {
100
- print_json({ data: {
123
+ const items = results.map(({ skill, result }) => ({
101
124
  skill,
102
125
  version: result.version,
103
126
  installed: result.installed || [],
104
127
  skipped: result.skipped || [],
105
128
  warnings: result.warnings || [],
106
129
  forced: result.forced || []
107
- } })
130
+ }))
131
+ const data = results.length === 1 && failures.length === 0 ? items[0] : items
132
+ if (failures.length > 0) {
133
+ print_json({ data, errors: failures })
134
+ } else {
135
+ print_json({ data })
136
+ }
108
137
  return
109
138
  }
110
139
 
@@ -10,6 +10,9 @@ const HELP_TEXT = `Usage: happyskills search [query] [options]
10
10
 
11
11
  Search the skill registry.
12
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.
15
+
13
16
  Arguments:
14
17
  query Search term (optional with --mine, --personal, or --workspace)
15
18
 
@@ -19,21 +22,22 @@ Options:
19
22
  --personal Search only your personal workspace
20
23
  --tags <tags> Filter by tags (comma-separated)
21
24
  --type <type> Filter by type (skill, kit)
22
- --smart, -S Use semantic search (vector + text + quality ranking)
23
- --limit <n> Max results (default: 10, max: 50) — used with --smart
24
- --min-quality <n> Minimum quality score 0-100 — used with --smart
25
+ --exact Use keyword-only matching instead of smart search
26
+ --limit <n> Max results (default: 10, max: 50)
27
+ --min-quality <n> Minimum quality score 0-100
25
28
  --json Output as JSON
26
29
 
27
30
  Aliases: s
28
31
 
29
32
  Examples:
30
33
  happyskills search deploy
34
+ happyskills search "REST API with Node.js"
31
35
  happyskills search --mine
32
- happyskills search "REST API with Node.js" --smart
33
36
  happyskills search deploy --workspace acme
34
37
  happyskills search --type kit
35
38
  happyskills s --personal --json
36
- happyskills search "deploy to AWS" -S --limit 5`
39
+ happyskills search "deploy to AWS" --limit 5
40
+ happyskills search deploy --exact`
37
41
 
38
42
  const QUALITY_TIERS = [
39
43
  { min: 80, label: 'High quality', color: cyan },
@@ -140,7 +144,7 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
140
144
  console.log(`\n${gray(`Showing ${items.length} result${items.length === 1 ? '' : 's'}. Install with: happyskills install <owner>/<name>`)}\n`)
141
145
  })
142
146
 
143
- const run_keyword_search = (args, query, options) => catch_errors('Keyword search failed', async () => {
147
+ const run_keyword_search = (args, query, options, { is_exact } = {}) => catch_errors('Keyword search failed', async () => {
144
148
  const [errors, results] = await repos_api.search(query, options)
145
149
  if (errors) throw e('Search failed', errors)
146
150
 
@@ -149,7 +153,9 @@ const run_keyword_search = (args, query, options) => catch_errors('Keyword searc
149
153
 
150
154
  if (items.length === 0) {
151
155
  if (args.flags.json) {
152
- print_json({ data: { query, scope: effective_scope, results: [], count: 0 } })
156
+ const data = { query, scope: effective_scope, results: [], count: 0 }
157
+ if (is_exact) data.hint = 'Smart search (default) uses semantic matching for better results. Remove --exact to use it.'
158
+ print_json({ data })
153
159
  return
154
160
  }
155
161
  const context = query ? ` for "${query}"` : ''
@@ -165,7 +171,9 @@ const run_keyword_search = (args, query, options) => catch_errors('Keyword searc
165
171
  version: item.latest_version || item.version || '-',
166
172
  visibility: item.visibility || 'public'
167
173
  }))
168
- print_json({ data: { query, scope: effective_scope, results: mapped, count: mapped.length } })
174
+ const data = { query, scope: effective_scope, results: mapped, count: mapped.length }
175
+ if (is_exact) data.hint = 'Smart search (default) uses semantic matching for better results. Remove --exact to use it.'
176
+ print_json({ data })
169
177
  return
170
178
  }
171
179
 
@@ -191,13 +199,11 @@ const run = (args) => catch_errors('Search failed', async () => {
191
199
 
192
200
  const query = args._.join(' ') || null
193
201
  const { mine, personal, workspace, tags, type } = args.flags
194
- const is_smart = !!(args.flags.smart || args.flags.S)
202
+ const is_exact = !!args.flags.exact
203
+ // --smart / -S accepted for backward compat (now the default when a query is provided)
204
+ const use_smart = query && !is_exact
195
205
  const has_scope_flag = mine || personal || workspace
196
206
 
197
- if (is_smart && !query) {
198
- throw new UsageError('--smart requires a search query.')
199
- }
200
-
201
207
  if (!query && !has_scope_flag && !tags && !type) {
202
208
  throw new UsageError('Please provide a search query or use --mine, --personal, or --workspace.')
203
209
  }
@@ -221,7 +227,7 @@ const run = (args) => catch_errors('Search failed', async () => {
221
227
  if (tags) options.tags = tags
222
228
  if (type) options.type = type
223
229
 
224
- if (is_smart) {
230
+ if (use_smart) {
225
231
  const [errors] = await run_smart_search(args, query, options)
226
232
  if (errors) throw e('Smart search failed', errors)
227
233
  } else {
@@ -231,7 +237,7 @@ const run = (args) => catch_errors('Search failed', async () => {
231
237
  kw_options.workspace = options.workspace_slug
232
238
  delete kw_options.workspace_slug
233
239
  }
234
- const [errors] = await run_keyword_search(args, query, kw_options)
240
+ const [errors] = await run_keyword_search(args, query, kw_options, { is_exact })
235
241
  if (errors) throw e('Search failed', errors)
236
242
  }
237
243
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
@@ -1,16 +1,16 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { uninstall } = require('../engine/uninstaller')
3
- const { print_help, print_json } = require('../ui/output')
3
+ const { print_help, print_json, print_warn } = require('../ui/output')
4
4
  const { exit_with_error, UsageError } = require('../utils/errors')
5
5
  const { find_project_root } = require('../config/paths')
6
6
  const { EXIT_CODES } = require('../constants')
7
7
 
8
- const HELP_TEXT = `Usage: happyskills uninstall <owner/skill> [options]
8
+ const HELP_TEXT = `Usage: happyskills uninstall <owner/skill> [...] [options]
9
9
 
10
- Remove a skill and prune orphaned dependencies.
10
+ Remove one or more skills and prune orphaned dependencies.
11
11
 
12
12
  Arguments:
13
- owner/skill Skill to remove (e.g., acme/deploy-aws)
13
+ owner/skill Skill(s) to remove (e.g., acme/deploy-aws teamb/logging)
14
14
 
15
15
  Options:
16
16
  -g, --global Remove from global scope
@@ -23,6 +23,7 @@ Aliases: rm, remove
23
23
 
24
24
  Examples:
25
25
  happyskills uninstall acme/deploy-aws
26
+ happyskills uninstall acme/deploy-aws acme/monitor acme/logging
26
27
  happyskills rm acme/deploy-aws -g`
27
28
 
28
29
  const run = (args) => catch_errors('Uninstall failed', async () => {
@@ -31,13 +32,15 @@ const run = (args) => catch_errors('Uninstall failed', async () => {
31
32
  return process.exit(EXIT_CODES.SUCCESS)
32
33
  }
33
34
 
34
- const skill = args._[0]
35
- if (!skill) {
35
+ const skills_raw = args._
36
+ if (skills_raw.length === 0) {
36
37
  throw new UsageError('Please specify a skill to uninstall (e.g., happyskills uninstall acme/deploy-aws).')
37
38
  }
38
39
 
39
- if (!skill.includes('/')) {
40
- throw new UsageError('Skill must be in owner/name format (e.g., acme/deploy-aws).')
40
+ for (const skill of skills_raw) {
41
+ if (!skill.includes('/')) {
42
+ throw new UsageError(`Skill must be in owner/name format (e.g., acme/deploy-aws). Got: ${skill}`)
43
+ }
41
44
  }
42
45
 
43
46
  const options = {
@@ -46,15 +49,35 @@ const run = (args) => catch_errors('Uninstall failed', async () => {
46
49
  project_root: find_project_root()
47
50
  }
48
51
 
49
- const [errors, result] = await uninstall(skill, options)
50
- if (errors) throw e(`Uninstall ${skill} failed`, errors)
52
+ const results = []
53
+ const failures = []
54
+ for (const skill of skills_raw) {
55
+ const [errors, result] = await uninstall(skill, options)
56
+ if (errors) {
57
+ const msg = errors[0]?.message || errors.message || String(errors)
58
+ print_warn(`${skill}: ${msg}`)
59
+ failures.push({ skill, error: msg })
60
+ } else {
61
+ results.push({ skill, result })
62
+ }
63
+ }
64
+
65
+ if (results.length === 0 && failures.length > 0) {
66
+ throw new Error(`All ${failures.length} uninstall(s) failed.`)
67
+ }
51
68
 
52
69
  if (args.flags.json) {
53
- print_json({ data: {
70
+ const items = results.map(({ skill, result }) => ({
54
71
  skill,
55
72
  removed: result.removed || [],
56
73
  orphans_pruned: result.orphans_pruned || []
57
- } })
74
+ }))
75
+ const data = results.length === 1 && failures.length === 0 ? items[0] : items
76
+ if (failures.length > 0) {
77
+ print_json({ data, errors: failures })
78
+ } else {
79
+ print_json({ data })
80
+ }
58
81
  return
59
82
  }
60
83
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })