happyskills 1.0.1 → 1.0.2

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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.0.2] - 2026-06-01
11
+
12
+ ### Fixed
13
+
14
+ - Stop `uninstall` from deleting a shared dependency that another installed skill still needs. When a skill depended on by two parents (e.g. `happyskills-design`, required by both `happyskills` and another skill) was resolved across separate install passes, the second pass overwrote its `requested_by` instead of unioning the parents — so uninstalling one parent orphan-pruned the dependency even though the other parent still declared it. Two fixes: `requested_by` is now unioned across install passes, and the orphan pruner additionally cross-checks every surviving skill's `dependencies` map before removing anything.
15
+
10
16
  ## [1.0.1] - 2026-06-01
11
17
 
12
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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)",
@@ -22,6 +22,34 @@ const _format_warnings = (missing_deps) => (missing_deps || []).map(dep => {
22
22
  return `Missing system dependency: ${dep.name} required by ${dep.skill}${hint}`
23
23
  })
24
24
 
25
+ // Order-preserving, de-duplicated union of requester lists.
26
+ const _union = (...lists) => {
27
+ const seen = new Set()
28
+ const out = []
29
+ for (const list of lists) {
30
+ for (const requester of (list || [])) {
31
+ if (!seen.has(requester)) {
32
+ seen.add(requester)
33
+ out.push(requester)
34
+ }
35
+ }
36
+ }
37
+ return out
38
+ }
39
+
40
+ // Compute the `requested_by` for a package's lock entry. A package is requested
41
+ // either by the user directly (`__root__`) when it IS the skill being installed,
42
+ // or by `root_skill` when it is a (transitive) dependency. We union that with
43
+ // whatever the lock already recorded so a SHARED dependency keeps every parent
44
+ // across separate install passes — install() resolves one root's tree at a time,
45
+ // so a dependency of two roots (e.g. happyskills + create-release-skill both
46
+ // needing happyskills-design) would otherwise have its `requested_by` clobbered
47
+ // by the last pass, and later be wrongly orphan-pruned on uninstall.
48
+ const merge_requested_by = (prev_requested_by, pkg_skill, root_skill) => {
49
+ const own = pkg_skill === root_skill ? ['__root__'] : [root_skill]
50
+ return _union(own, prev_requested_by)
51
+ }
52
+
25
53
  const install = (skill, options = {}) => catch_errors('Install failed', async () => {
26
54
  const { version, global: is_global = false, force = false, fresh = false, project_root, agents: agents_flag } = options
27
55
  const base_dir = skills_dir(is_global, project_root)
@@ -246,7 +274,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
246
274
  integrity: integrity || null,
247
275
  base_commit: pkg.commit || null,
248
276
  base_integrity: integrity || null,
249
- requested_by: pkg.skill === skill ? ['__root__'] : [skill],
277
+ requested_by: merge_requested_by(lock_data?.skills?.[pkg.skill]?.requested_by, pkg.skill, skill),
250
278
  dependencies: pkg.dependencies || {},
251
279
  ...(pkg_type ? { type: pkg_type } : {}),
252
280
  ...(pkg.forced ? { forced: true } : {})
@@ -378,4 +406,4 @@ const install_from_lock = (lock_data, options = {}) => catch_errors('Install fro
378
406
  return { source: 'skills-lock.json', installed: all_installed, skipped: all_skipped, warnings: all_warnings }
379
407
  })
380
408
 
381
- module.exports = { install, install_from_manifest, install_from_lock }
409
+ module.exports = { install, install_from_manifest, install_from_lock, merge_requested_by }
@@ -0,0 +1,41 @@
1
+ const { describe, it } = require('node:test')
2
+ const assert = require('node:assert')
3
+ const { merge_requested_by } = require('./installer')
4
+
5
+ describe('merge_requested_by', () => {
6
+ it('records the root skill for a fresh dependency', () => {
7
+ const result = merge_requested_by(undefined, 'happyskillsai/happyskills-design', 'happyskillsai/happyskills')
8
+ assert.deepStrictEqual(result, ['happyskillsai/happyskills'])
9
+ })
10
+
11
+ it('records __root__ when the package IS the skill being installed', () => {
12
+ const result = merge_requested_by(['__root__'], 'happyskillsai/happyskills', 'happyskillsai/happyskills')
13
+ assert.deepStrictEqual(result, ['__root__'])
14
+ })
15
+
16
+ it('unions a shared dependency across separate install passes', () => {
17
+ // Pass 1 wrote design as a dependency of happyskills.
18
+ // Pass 2 re-resolves design as a dependency of create-release-skill and
19
+ // must NOT clobber the existing parent — both must be retained.
20
+ const result = merge_requested_by(
21
+ ['happyskillsai/happyskills'],
22
+ 'happyskillsai/happyskills-design',
23
+ 'nicolasdao/create-release-skill'
24
+ )
25
+ assert.deepStrictEqual(result, ['nicolasdao/create-release-skill', 'happyskillsai/happyskills'])
26
+ })
27
+
28
+ it('de-duplicates when the same requester reappears', () => {
29
+ const result = merge_requested_by(
30
+ ['happyskillsai/happyskills'],
31
+ 'happyskillsai/happyskills-design',
32
+ 'happyskillsai/happyskills'
33
+ )
34
+ assert.deepStrictEqual(result, ['happyskillsai/happyskills'])
35
+ })
36
+
37
+ it('tolerates a null/empty previous list', () => {
38
+ assert.deepStrictEqual(merge_requested_by(null, 'a/dep', 'a/root'), ['a/root'])
39
+ assert.deepStrictEqual(merge_requested_by([], 'a/dep', 'a/root'), ['a/root'])
40
+ })
41
+ })
@@ -7,12 +7,29 @@ const { resolve_agents, unlink_from_agents } = require('../agents')
7
7
  const { print_success, print_info } = require('../ui/output')
8
8
 
9
9
  const find_orphans = (skills, removed_skill) => {
10
+ // A skill is still needed if a SURVIVING skill (anything other than the one
11
+ // being removed) declares it in its `dependencies` map. The dependencies map
12
+ // mirrors each skill's skill.json and is the authoritative statement of what a
13
+ // skill needs — independent of `requested_by`, which can be stale or clobbered
14
+ // when a shared dependency is resolved across multiple install passes. Without
15
+ // this cross-check, a dependency whose `requested_by` was overwritten to point
16
+ // only at the removed skill gets pruned even though another installed skill
17
+ // still requires it (the happyskills-design data-loss bug).
18
+ const declared_by_survivor = (name) => {
19
+ for (const [other_name, other] of Object.entries(skills)) {
20
+ if (other_name === removed_skill) continue
21
+ const deps = (other && other.dependencies) || {}
22
+ if (Object.prototype.hasOwnProperty.call(deps, name)) return true
23
+ }
24
+ return false
25
+ }
26
+
10
27
  const orphans = []
11
28
  for (const [name, data] of Object.entries(skills)) {
12
29
  if (name === removed_skill) continue
13
30
  const requested_by = data.requested_by || []
14
31
  const remaining = requested_by.filter(r => r === '__root__' || (r !== removed_skill && skills[r]))
15
- if (remaining.length === 0) {
32
+ if (remaining.length === 0 && !declared_by_survivor(name)) {
16
33
  orphans.push(name)
17
34
  }
18
35
  }
@@ -95,4 +95,42 @@ describe('find_orphans', () => {
95
95
  const result = find_orphans(skills, 'acme/other')
96
96
  assert.ok(!result.includes('acme/deploy'))
97
97
  })
98
+
99
+ it('keeps a shared dependency a surviving skill still declares, even when requested_by points only at the removed skill', () => {
100
+ // Reproduces the happyskills-design data-loss bug: design's requested_by was
101
+ // clobbered to point only at create-release-skill, but happyskills still
102
+ // declares it in its dependencies map. Uninstalling create-release-skill must
103
+ // NOT prune design.
104
+ const skills = {
105
+ 'happyskillsai/happyskills': {
106
+ requested_by: ['__root__'],
107
+ dependencies: { 'happyskillsai/happyskills-design': '^0.1.0' }
108
+ },
109
+ 'nicolasdao/create-release-skill': {
110
+ requested_by: ['__root__'],
111
+ dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
112
+ },
113
+ 'happyskillsai/happyskills-design': {
114
+ requested_by: ['nicolasdao/create-release-skill']
115
+ }
116
+ }
117
+ const result = find_orphans(skills, 'nicolasdao/create-release-skill')
118
+ assert.ok(!result.includes('happyskillsai/happyskills-design'))
119
+ })
120
+
121
+ it('prunes a dependency once no surviving skill declares it', () => {
122
+ // Same shape, but happyskills does NOT declare design — removing its sole
123
+ // remaining requester should orphan it.
124
+ const skills = {
125
+ 'nicolasdao/create-release-skill': {
126
+ requested_by: ['__root__'],
127
+ dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
128
+ },
129
+ 'happyskillsai/happyskills-design': {
130
+ requested_by: ['nicolasdao/create-release-skill']
131
+ }
132
+ }
133
+ const result = find_orphans(skills, 'nicolasdao/create-release-skill')
134
+ assert.ok(result.includes('happyskillsai/happyskills-design'))
135
+ })
98
136
  })