happyskills 1.10.0 → 1.10.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,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.10.1] - 2026-06-09
11
+
12
+ ### Fixed
13
+
14
+ - Fix `uninstall` leaving deep transitive dependencies stranded. Orphan pruning ran a single pass, so removing a skill (or kit / constellation core) pruned its direct dependencies but left behind any *grandchild* dependency whose only parent was one of those now-pruned dependencies — it accumulated as unused cruft in `.agents/skills/` and the lock file. Pruning now cascades to a fixed point, removing the full chain of newly-orphaned dependencies. The data-loss guard is preserved: a dependency that any surviving skill still declares (e.g. a shared satellite like `happyskills-design`) is never pruned, and directly-installed (`__root__`) skills are never swept up.
15
+
10
16
  ## [1.10.0] - 2026-06-06
11
17
 
12
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "1.10.0",
3
+ "version": "1.10.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)",
@@ -36,6 +36,37 @@ const find_orphans = (skills, removed_skill) => {
36
36
  return orphans
37
37
  }
38
38
 
39
+ // Cascading orphan detection. `find_orphans` is single-pass by design (its unit
40
+ // contract): it only reports the DIRECT orphans of `removed_skill`. But pruning a
41
+ // direct orphan A can in turn strand A's own private sub-dependency B — a grandchild
42
+ // that nothing else needs once A is gone. A single pass leaves B behind because A is
43
+ // still in the map (so A both counts as a live requester of B and, via its
44
+ // `dependencies`, as a "surviving declarer" of B).
45
+ //
46
+ // This wrapper closes that gap WITHOUT weakening the data-loss guard. It runs
47
+ // `find_orphans` to a fixed point over a shrinking working copy: each round, the
48
+ // orphans found are physically removed from the map, so the next round sees them as
49
+ // gone — both as requesters (`skills[r]` is now false) and as declarers
50
+ // (`declared_by_survivor` no longer finds them). A skill that ANY surviving skill
51
+ // still declares is never pruned, exactly as before — only skills whose entire
52
+ // requirement chain has been removed cascade out. Terminates because every round
53
+ // deletes at least one entry from a finite map.
54
+ const find_orphans_cascading = (skills, removed_skill) => {
55
+ const working = { ...skills }
56
+ delete working[removed_skill]
57
+
58
+ const all_orphans = []
59
+ let round = find_orphans(working, removed_skill)
60
+ while (round.length > 0) {
61
+ for (const name of round) {
62
+ all_orphans.push(name)
63
+ delete working[name]
64
+ }
65
+ round = find_orphans(working, removed_skill)
66
+ }
67
+ return all_orphans
68
+ }
69
+
39
70
  const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', async () => {
40
71
  const { global: is_global = false, project_root, agents: agents_flag } = options
41
72
 
@@ -64,7 +95,7 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
64
95
  }
65
96
  }
66
97
 
67
- const orphans = find_orphans(all_skills, skill)
98
+ const orphans = find_orphans_cascading(all_skills, skill)
68
99
  const to_remove = [skill, ...orphans]
69
100
 
70
101
  for (const name of to_remove) {
@@ -97,4 +128,4 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
97
128
  return { removed: to_remove, orphans_pruned: orphans }
98
129
  })
99
130
 
100
- module.exports = { uninstall, find_orphans }
131
+ module.exports = { uninstall, find_orphans, find_orphans_cascading }
@@ -1,6 +1,6 @@
1
1
  const { describe, it } = require('node:test')
2
2
  const assert = require('node:assert')
3
- const { find_orphans } = require('./uninstaller')
3
+ const { find_orphans, find_orphans_cascading } = require('./uninstaller')
4
4
 
5
5
  describe('find_orphans', () => {
6
6
  it('returns empty when all skills have requesters in the skills map', () => {
@@ -134,3 +134,122 @@ describe('find_orphans', () => {
134
134
  assert.ok(result.includes('happyskillsai/happyskills-design'))
135
135
  })
136
136
  })
137
+
138
+ describe('find_orphans_cascading', () => {
139
+ it('cascades: prunes a grandchild stranded once its only parent is orphaned', () => {
140
+ // Kit K -> A -> B. B is A's private sub-dependency (not listed directly by the
141
+ // kit). Uninstalling K must prune A (direct orphan) AND B (cascaded orphan).
142
+ // This is the exact case single-pass find_orphans leaves stranded.
143
+ const skills = {
144
+ 'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
145
+ 'acme/a': { requested_by: ['acme/_kit-x'], dependencies: { 'acme/b': '^1.0.0' } },
146
+ 'acme/b': { requested_by: ['acme/_kit-x'], dependencies: {} }
147
+ }
148
+ const result = find_orphans_cascading(skills, 'acme/_kit-x')
149
+ assert.ok(result.includes('acme/a'), 'direct orphan A should be pruned')
150
+ assert.ok(result.includes('acme/b'), 'cascaded orphan B should be pruned')
151
+ })
152
+
153
+ it('still protects a shared dependency a surviving skill declares (no over-pruning under cascade)', () => {
154
+ // The data-loss guard must survive the cascade: design is declared by the
155
+ // surviving happyskills, so uninstalling create-release-skill must NOT prune it
156
+ // even though we now iterate to a fixed point.
157
+ const skills = {
158
+ 'happyskillsai/happyskills': {
159
+ requested_by: ['__root__'],
160
+ dependencies: { 'happyskillsai/happyskills-design': '^0.1.0' }
161
+ },
162
+ 'nicolasdao/create-release-skill': {
163
+ requested_by: ['__root__'],
164
+ dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
165
+ },
166
+ 'happyskillsai/happyskills-design': {
167
+ requested_by: ['nicolasdao/create-release-skill']
168
+ }
169
+ }
170
+ const result = find_orphans_cascading(skills, 'nicolasdao/create-release-skill')
171
+ assert.ok(!result.includes('happyskillsai/happyskills-design'))
172
+ })
173
+
174
+ it('never prunes a user-installed (__root__) skill, even mid-cascade', () => {
175
+ // K -> A, but A is ALSO directly installed by the user. Removing K orphans
176
+ // nothing the user still wants: A is __root__-anchored and stays.
177
+ const skills = {
178
+ 'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
179
+ 'acme/a': { requested_by: ['__root__', 'acme/_kit-x'], dependencies: {} }
180
+ }
181
+ const result = find_orphans_cascading(skills, 'acme/_kit-x')
182
+ assert.deepStrictEqual(result, [])
183
+ })
184
+
185
+ it('halts the cascade at a still-shared mid-chain dependency', () => {
186
+ // K -> A -> B, but B is ALSO required by an unrelated surviving skill S.
187
+ // Removing K prunes A, but B must stay because S still needs it.
188
+ const skills = {
189
+ 'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1.0.0' } },
190
+ 'acme/a': { requested_by: ['acme/_kit-x'], dependencies: { 'acme/b': '^1.0.0' } },
191
+ 'acme/s': { requested_by: ['__root__'], dependencies: { 'acme/b': '^1.0.0' } },
192
+ 'acme/b': { requested_by: ['acme/_kit-x', 'acme/s'], dependencies: {} }
193
+ }
194
+ const result = find_orphans_cascading(skills, 'acme/_kit-x')
195
+ assert.ok(result.includes('acme/a'), 'A is orphaned')
196
+ assert.ok(!result.includes('acme/b'), 'B is still needed by surviving acme/s')
197
+ })
198
+
199
+ it('constellation: uninstalling the core prunes bundled satellites, keeps shared + opt-in ones', () => {
200
+ // Models the real happyskills constellation: the core declares 5 bundled
201
+ // satellites as dependencies. happyskills-design is ALSO declared by a surviving
202
+ // skill (create-release-skill). collab + stats are OPT-IN — installed directly
203
+ // (__root__), never dependencies of the core.
204
+ const skills = {
205
+ 'happyskillsai/happyskills': {
206
+ requested_by: ['__root__'],
207
+ dependencies: {
208
+ 'happyskillsai/happyskills-design': '^0.1.0',
209
+ 'happyskillsai/happyskills-publish': '^0.1.0',
210
+ 'happyskillsai/happyskills-sync': '^0.1.0',
211
+ 'happyskillsai/happyskills-search': '^0.1.0',
212
+ 'happyskillsai/happyskills-help': '^0.1.0'
213
+ }
214
+ },
215
+ 'nicolasdao/create-release-skill': {
216
+ requested_by: ['__root__'],
217
+ dependencies: { 'happyskillsai/happyskills-design': '^0.9.0' }
218
+ },
219
+ 'happyskillsai/happyskills-design': { requested_by: ['happyskillsai/happyskills', 'nicolasdao/create-release-skill'], dependencies: {} },
220
+ 'happyskillsai/happyskills-publish': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
221
+ 'happyskillsai/happyskills-sync': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
222
+ 'happyskillsai/happyskills-search': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
223
+ 'happyskillsai/happyskills-help': { requested_by: ['happyskillsai/happyskills'], dependencies: {} },
224
+ 'happyskillsai/happyskills-collab': { requested_by: ['__root__'], dependencies: {} },
225
+ 'happyskillsai/happyskills-stats': { requested_by: ['__root__'], dependencies: {} }
226
+ }
227
+ const result = find_orphans_cascading(skills, 'happyskillsai/happyskills').sort()
228
+ assert.deepStrictEqual(result, [
229
+ 'happyskillsai/happyskills-help',
230
+ 'happyskillsai/happyskills-publish',
231
+ 'happyskillsai/happyskills-search',
232
+ 'happyskillsai/happyskills-sync'
233
+ ])
234
+ // design is shared (create-release-skill still declares it) → kept
235
+ assert.ok(!result.includes('happyskillsai/happyskills-design'))
236
+ // collab + stats are opt-in (__root__) → never swept up by a core uninstall
237
+ assert.ok(!result.includes('happyskillsai/happyskills-collab'))
238
+ assert.ok(!result.includes('happyskillsai/happyskills-stats'))
239
+ })
240
+
241
+ it('matches find_orphans for a flat kit (no nested sub-dependencies)', () => {
242
+ // The common curated-kit case: K directly lists A, B, C and none depend on each
243
+ // other. Single-pass and cascading must agree — cascade changes nothing here.
244
+ const skills = {
245
+ 'acme/_kit-x': { requested_by: ['__root__'], dependencies: { 'acme/a': '^1', 'acme/b': '^1', 'acme/c': '^1' } },
246
+ 'acme/a': { requested_by: ['acme/_kit-x'], dependencies: {} },
247
+ 'acme/b': { requested_by: ['acme/_kit-x'], dependencies: {} },
248
+ 'acme/c': { requested_by: ['acme/_kit-x'], dependencies: {} }
249
+ }
250
+ const single = find_orphans(skills, 'acme/_kit-x').sort()
251
+ const cascaded = find_orphans_cascading(skills, 'acme/_kit-x').sort()
252
+ assert.deepStrictEqual(cascaded, ['acme/a', 'acme/b', 'acme/c'])
253
+ assert.deepStrictEqual(cascaded, single)
254
+ })
255
+ })