happyskills 0.34.0 → 0.35.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 CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.1] - 2026-04-10
11
+
12
+ ### Fixed
13
+ - Fix multi-agent skill symlinks using absolute paths instead of relative — absolute symlinks break when the repo is cloned or pulled on a different machine; now uses `path.relative()` for portable symlinks
14
+ - Fix enable skip-check failing to recognize existing relative symlinks — resolves both relative and absolute link targets before comparing
15
+
16
+ ## [0.35.0] - 2026-04-10
17
+
18
+ ### Added
19
+ - Add multi-skill `install` — pass multiple skills in one command (e.g., `happyskills install acme/foo acme/bar@1.0 teamb/baz`)
20
+ - Add multi-skill `uninstall` — remove multiple skills in one command (e.g., `happyskills uninstall acme/foo acme/bar`)
21
+ - Add per-skill inline `@version` pinning for multi-skill install (e.g., `acme/foo@1.2.0 acme/bar@latest`)
22
+ - Add graceful error handling for batch operations — individual failures print a warning and do not interrupt remaining skills
23
+ - Add `--version` flag guard — rejects ambiguous usage with multiple skills, directs to inline `@version` syntax
24
+ - Add `errors` array in JSON output for partial failures alongside successful `data`
25
+
10
26
  ## [0.34.0] - 2026-04-09
11
27
 
12
28
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.34.0",
3
+ "version": "0.35.1",
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)",
@@ -43,8 +43,10 @@ const _exists = async (target_path) => {
43
43
  */
44
44
  const _link_or_copy = async (source, target) => {
45
45
  try {
46
+ // Use relative path so symlinks work across machines (clones/pulls)
47
+ const relative_source = path.relative(path.dirname(target), source)
46
48
  // 'junction' works on Windows without admin; ignored on macOS/Linux
47
- await fs.promises.symlink(source, target, 'junction')
49
+ await fs.promises.symlink(relative_source, target, 'junction')
48
50
  return { method: 'symlink' }
49
51
  } catch (err) {
50
52
  if (err.code === 'EPERM' || err.code === 'ENOTSUP') {
@@ -77,7 +79,9 @@ const link_to_agents = (source_dir, agents, options = {}) => catch_errors('Agent
77
79
  if (is_link) {
78
80
  try {
79
81
  const link_target = await fs.promises.readlink(target)
80
- if (link_target === source_dir) {
82
+ // Resolve relative or absolute symlink to compare against source
83
+ const resolved_link = path.resolve(path.dirname(target), link_target)
84
+ if (resolved_link === source_dir) {
81
85
  results.push({ agent_id: agent.id, path: target, method: 'symlink', skipped: true })
82
86
  continue
83
87
  }
@@ -71,7 +71,9 @@ describe('link_to_agents', () => {
71
71
  const link_path = path.join(tmp, '.claude', 'skills', 'deploy-aws')
72
72
  const stat = fs.lstatSync(link_path)
73
73
  assert.ok(stat.isSymbolicLink())
74
- assert.equal(fs.readlinkSync(link_path), source)
74
+ // Symlink should be relative, not absolute
75
+ const link_target = fs.readlinkSync(link_path)
76
+ assert.equal(link_target, path.relative(path.dirname(link_path), source))
75
77
  })
76
78
 
77
79
  it('creates symlinks for multiple agents', async () => {
@@ -126,7 +128,9 @@ describe('link_to_agents', () => {
126
128
  assert.equal(results[0].skipped, undefined)
127
129
 
128
130
  const link_path = path.join(tmp, '.claude', 'skills', 'deploy-aws')
129
- assert.equal(fs.readlinkSync(link_path), source)
131
+ // Symlink should be relative, not absolute
132
+ const link_target = fs.readlinkSync(link_path)
133
+ assert.equal(link_target, path.relative(path.dirname(link_path), source))
130
134
  })
131
135
  })
132
136
 
@@ -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
 
@@ -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 } })