happyskills 0.35.2 → 0.35.4

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,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.4] - 2026-04-11
11
+
12
+ ### Fixed
13
+ - Fix `refresh` symlink repair skipping dependency-installed skills — only directly-installed skills had their symlinks validated; transitive dependencies with stale symlinks were left untouched
14
+
15
+ ## [0.35.3] - 2026-04-11
16
+
17
+ ### Fixed
18
+ - Fix `refresh` and `install` silently leaving stale symlinks for up-to-date skills — symlinks copied from another project (e.g., absolute paths pointing to a different directory) were never validated or repaired unless the skill version changed; now all symlinks are verified and repaired regardless of version status
19
+
10
20
  ## [0.35.2] - 2026-04-11
11
21
 
12
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.35.2",
3
+ "version": "0.35.4",
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)",
@@ -1,11 +1,11 @@
1
1
  const { AGENTS, get_agent, get_all_agent_ids } = require('./registry')
2
2
  const { detect_agents, resolve_agents } = require('./detector')
3
- const { link_to_agents, unlink_from_agents, is_symlink } = require('./linker')
3
+ const { link_to_agents, unlink_from_agents, is_symlink, verify_and_repair_symlinks } = require('./linker')
4
4
  const { is_skill_enabled, get_skills_enabled_map } = require('./status')
5
5
 
6
6
  module.exports = {
7
7
  AGENTS, get_agent, get_all_agent_ids,
8
8
  detect_agents, resolve_agents,
9
- link_to_agents, unlink_from_agents, is_symlink,
9
+ link_to_agents, unlink_from_agents, is_symlink, verify_and_repair_symlinks,
10
10
  is_skill_enabled, get_skills_enabled_map
11
11
  }
@@ -105,6 +105,60 @@ const link_to_agents = (source_dir, agents, options = {}) => catch_errors('Agent
105
105
  return results
106
106
  })
107
107
 
108
+ /**
109
+ * Verify and repair symlinks for a list of skills across all agents.
110
+ * For each skill, checks that each agent's symlink points to the correct canonical source.
111
+ * Repairs symlinks that are missing, broken, absolute, or pointing to the wrong location.
112
+ *
113
+ * @param {Array<{ skill_name: string, source_dir: string }>} skills — skill name + canonical dir
114
+ * @param {Agent[]} agents — array of agent objects to verify
115
+ * @param {object} options — { global, project_root }
116
+ * @returns {Promise} [errors, { repaired: Array<{ skill_name, agent_id }>, already_correct: number }]
117
+ */
118
+ const verify_and_repair_symlinks = (skills, agents, options = {}) => catch_errors('Symlink verification failed', async () => {
119
+ const { global: is_global = false, project_root } = options
120
+ const repaired = []
121
+ let already_correct = 0
122
+
123
+ for (const { skill_name, source_dir } of skills) {
124
+ for (const agent of agents) {
125
+ const target = agent_skill_install_dir(agent, is_global, project_root, skill_name)
126
+
127
+ const [, is_link] = await is_symlink(target)
128
+ if (is_link) {
129
+ try {
130
+ const link_target = await fs.promises.readlink(target)
131
+ const resolved_link = path.resolve(path.dirname(target), link_target)
132
+ if (resolved_link === source_dir) {
133
+ already_correct++
134
+ continue
135
+ }
136
+ } catch {
137
+ // readlink failed — remove and recreate
138
+ }
139
+ } else if (!(await _exists(target))) {
140
+ // Symlink missing — create it
141
+ } else {
142
+ // Target exists but is not a symlink (physical dir) — skip, don't destroy user data
143
+ already_correct++
144
+ continue
145
+ }
146
+
147
+ // Remove stale/broken symlink
148
+ if (await _exists(target)) {
149
+ await remove_dir(target)
150
+ }
151
+
152
+ // Ensure parent dir exists and create correct symlink
153
+ await ensure_dir(path.dirname(target))
154
+ await _link_or_copy(source_dir, target)
155
+ repaired.push({ skill_name, agent_id: agent.id })
156
+ }
157
+ }
158
+
159
+ return { repaired, already_correct }
160
+ })
161
+
108
162
  /**
109
163
  * Unlink a skill from agents. Removes symlinks or physical copies.
110
164
  *
@@ -138,4 +192,4 @@ const unlink_from_agents = (skill_name, agents, options = {}) => catch_errors('A
138
192
  return results
139
193
  })
140
194
 
141
- module.exports = { link_to_agents, unlink_from_agents, is_symlink }
195
+ module.exports = { link_to_agents, unlink_from_agents, is_symlink, verify_and_repair_symlinks }
@@ -5,7 +5,7 @@ const fs = require('fs')
5
5
  const path = require('path')
6
6
  const os = require('os')
7
7
 
8
- const { link_to_agents, unlink_from_agents, is_symlink } = require('./linker')
8
+ const { link_to_agents, unlink_from_agents, is_symlink, verify_and_repair_symlinks } = require('./linker')
9
9
 
10
10
  const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-linker-test-'))
11
11
  const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
@@ -204,3 +204,179 @@ describe('unlink_from_agents', () => {
204
204
  assert.ok(fs.existsSync(path.join(source, 'SKILL.md')))
205
205
  })
206
206
  })
207
+
208
+ describe('verify_and_repair_symlinks', () => {
209
+ let tmp
210
+ beforeEach(() => { tmp = make_tmp() })
211
+ afterEach(() => { cleanup(tmp) })
212
+
213
+ it('reports already_correct when symlinks point to the right place', async () => {
214
+ const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
215
+ fs.mkdirSync(source, { recursive: true })
216
+
217
+ const agent_dir = path.join(tmp, '.claude', 'skills')
218
+ fs.mkdirSync(agent_dir, { recursive: true })
219
+ const relative = path.relative(agent_dir, source)
220
+ fs.symlinkSync(relative, path.join(agent_dir, 'deploy-aws'), 'junction')
221
+
222
+ const agents = [make_agent('claude', '.claude/skills')]
223
+ const [errors, result] = await verify_and_repair_symlinks(
224
+ [{ skill_name: 'deploy-aws', source_dir: source }],
225
+ agents,
226
+ { global: false, project_root: tmp }
227
+ )
228
+
229
+ assert.equal(errors, null)
230
+ assert.equal(result.repaired.length, 0)
231
+ assert.equal(result.already_correct, 1)
232
+ })
233
+
234
+ it('repairs symlink pointing to wrong project (absolute path)', async () => {
235
+ const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
236
+ fs.mkdirSync(source, { recursive: true })
237
+
238
+ // Create a symlink pointing to a different project (absolute path)
239
+ const wrong_source = path.join(tmp, 'other-project', '.agents', 'skills', 'deploy-aws')
240
+ fs.mkdirSync(wrong_source, { recursive: true })
241
+ const agent_dir = path.join(tmp, '.claude', 'skills')
242
+ fs.mkdirSync(agent_dir, { recursive: true })
243
+ fs.symlinkSync(wrong_source, path.join(agent_dir, 'deploy-aws'), 'junction')
244
+
245
+ const agents = [make_agent('claude', '.claude/skills')]
246
+ const [errors, result] = await verify_and_repair_symlinks(
247
+ [{ skill_name: 'deploy-aws', source_dir: source }],
248
+ agents,
249
+ { global: false, project_root: tmp }
250
+ )
251
+
252
+ assert.equal(errors, null)
253
+ assert.equal(result.repaired.length, 1)
254
+ assert.equal(result.repaired[0].skill_name, 'deploy-aws')
255
+ assert.equal(result.repaired[0].agent_id, 'claude')
256
+
257
+ // Verify the symlink now points to the correct source with a relative path
258
+ const link_path = path.join(agent_dir, 'deploy-aws')
259
+ const link_target = fs.readlinkSync(link_path)
260
+ assert.equal(link_target, path.relative(agent_dir, source))
261
+ })
262
+
263
+ it('creates missing symlinks', async () => {
264
+ const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
265
+ fs.mkdirSync(source, { recursive: true })
266
+
267
+ // No symlink exists in agent dir at all
268
+ const agents = [make_agent('claude', '.claude/skills')]
269
+ const [errors, result] = await verify_and_repair_symlinks(
270
+ [{ skill_name: 'deploy-aws', source_dir: source }],
271
+ agents,
272
+ { global: false, project_root: tmp }
273
+ )
274
+
275
+ assert.equal(errors, null)
276
+ assert.equal(result.repaired.length, 1)
277
+
278
+ const link_path = path.join(tmp, '.claude', 'skills', 'deploy-aws')
279
+ assert.ok(fs.lstatSync(link_path).isSymbolicLink())
280
+ const link_target = fs.readlinkSync(link_path)
281
+ assert.equal(link_target, path.relative(path.dirname(link_path), source))
282
+ })
283
+
284
+ it('repairs broken symlinks (dangling)', async () => {
285
+ const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
286
+ fs.mkdirSync(source, { recursive: true })
287
+
288
+ // Create a symlink to a non-existent location
289
+ const agent_dir = path.join(tmp, '.claude', 'skills')
290
+ fs.mkdirSync(agent_dir, { recursive: true })
291
+ fs.symlinkSync('/nonexistent/path/deploy-aws', path.join(agent_dir, 'deploy-aws'), 'junction')
292
+
293
+ const agents = [make_agent('claude', '.claude/skills')]
294
+ const [errors, result] = await verify_and_repair_symlinks(
295
+ [{ skill_name: 'deploy-aws', source_dir: source }],
296
+ agents,
297
+ { global: false, project_root: tmp }
298
+ )
299
+
300
+ assert.equal(errors, null)
301
+ assert.equal(result.repaired.length, 1)
302
+
303
+ const link_path = path.join(agent_dir, 'deploy-aws')
304
+ const link_target = fs.readlinkSync(link_path)
305
+ assert.equal(link_target, path.relative(agent_dir, source))
306
+ })
307
+
308
+ it('handles multiple skills and multiple agents', async () => {
309
+ const skill_a = path.join(tmp, '.agents', 'skills', 'skill-a')
310
+ const skill_b = path.join(tmp, '.agents', 'skills', 'skill-b')
311
+ fs.mkdirSync(skill_a, { recursive: true })
312
+ fs.mkdirSync(skill_b, { recursive: true })
313
+
314
+ // skill-a has correct symlink in claude, missing in cursor
315
+ const claude_dir = path.join(tmp, '.claude', 'skills')
316
+ fs.mkdirSync(claude_dir, { recursive: true })
317
+ const relative_a = path.relative(claude_dir, skill_a)
318
+ fs.symlinkSync(relative_a, path.join(claude_dir, 'skill-a'), 'junction')
319
+
320
+ // skill-b has wrong symlink in claude
321
+ const wrong_b = path.join(tmp, 'other-project', '.agents', 'skills', 'skill-b')
322
+ fs.mkdirSync(wrong_b, { recursive: true })
323
+ fs.symlinkSync(wrong_b, path.join(claude_dir, 'skill-b'), 'junction')
324
+
325
+ const agents = [
326
+ make_agent('claude', '.claude/skills'),
327
+ make_agent('cursor', '.cursor/skills')
328
+ ]
329
+ const [errors, result] = await verify_and_repair_symlinks(
330
+ [
331
+ { skill_name: 'skill-a', source_dir: skill_a },
332
+ { skill_name: 'skill-b', source_dir: skill_b }
333
+ ],
334
+ agents,
335
+ { global: false, project_root: tmp }
336
+ )
337
+
338
+ assert.equal(errors, null)
339
+ // skill-a correct in claude (1 correct), missing in cursor (1 repair)
340
+ // skill-b wrong in claude (1 repair), missing in cursor (1 repair)
341
+ assert.equal(result.already_correct, 1)
342
+ assert.equal(result.repaired.length, 3)
343
+
344
+ // Verify all symlinks are now correct
345
+ for (const agent of agents) {
346
+ for (const name of ['skill-a', 'skill-b']) {
347
+ const link_path = path.join(tmp, agent.skills_dir, name)
348
+ assert.ok(fs.lstatSync(link_path).isSymbolicLink(), `${agent.id}/${name} should be a symlink`)
349
+ const source_dir = path.join(tmp, '.agents', 'skills', name)
350
+ const resolved = path.resolve(path.dirname(link_path), fs.readlinkSync(link_path))
351
+ assert.equal(resolved, source_dir, `${agent.id}/${name} should resolve to canonical dir`)
352
+ }
353
+ }
354
+ })
355
+
356
+ it('does not destroy physical directories (non-symlink)', async () => {
357
+ const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
358
+ fs.mkdirSync(source, { recursive: true })
359
+
360
+ // Agent dir has a physical directory (not a symlink) — should be left alone
361
+ const agent_dir = path.join(tmp, '.claude', 'skills')
362
+ const physical = path.join(agent_dir, 'deploy-aws')
363
+ fs.mkdirSync(physical, { recursive: true })
364
+ fs.writeFileSync(path.join(physical, 'SKILL.md'), 'user content')
365
+
366
+ const agents = [make_agent('claude', '.claude/skills')]
367
+ const [errors, result] = await verify_and_repair_symlinks(
368
+ [{ skill_name: 'deploy-aws', source_dir: source }],
369
+ agents,
370
+ { global: false, project_root: tmp }
371
+ )
372
+
373
+ assert.equal(errors, null)
374
+ // Physical dir should be counted as correct (not destroyed)
375
+ assert.equal(result.repaired.length, 0)
376
+ assert.equal(result.already_correct, 1)
377
+
378
+ // User content should still be there
379
+ assert.ok(fs.existsSync(path.join(physical, 'SKILL.md')))
380
+ assert.equal(fs.readFileSync(path.join(physical, 'SKILL.md'), 'utf-8'), 'user content')
381
+ })
382
+ })
@@ -10,6 +10,8 @@ const { create_spinner } = require('../ui/spinner')
10
10
  const { exit_with_error } = require('../utils/errors')
11
11
  const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
12
12
  const { EXIT_CODES } = require('../constants')
13
+ const { resolve_agents, verify_and_repair_symlinks } = require('../agents')
14
+ const { is_skill_enabled } = require('../agents/status')
13
15
 
14
16
  const HELP_TEXT = `Usage: happyskills refresh [options]
15
17
 
@@ -74,6 +76,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
74
76
  const spinner = !args.flags.json ? create_spinner('Checking for updates…') : null
75
77
  const skill_names = to_check.map(([name]) => name)
76
78
  const [batch_err, batch_data] = await repos_api.check_updates(skill_names)
79
+ const base_dir = skills_dir(is_global, project_root)
77
80
 
78
81
  const results = []
79
82
  if (batch_err) {
@@ -83,7 +86,6 @@ const run = (args) => catch_errors('Refresh failed', async () => {
83
86
  }
84
87
  } else {
85
88
  spinner?.succeed('Checked for updates')
86
- const base_dir = skills_dir(is_global, project_root)
87
89
  // Read all installed manifests in parallel to check for dependency drift
88
90
  const manifest_map = {}
89
91
  await Promise.all(to_check.map(async ([name]) => {
@@ -115,21 +117,51 @@ const run = (args) => catch_errors('Refresh failed', async () => {
115
117
  const outdated = results.filter(r => r.status === 'outdated')
116
118
  const up_to_date = results.filter(r => r.status === 'up-to-date')
117
119
 
118
- // 2. If nothing to update, report and exit
120
+ // 2. Verify and repair symlinks for all skills (even when nothing is outdated)
121
+ const [, agents_data] = await resolve_agents(args.flags.agents)
122
+ const detected_agents = agents_data?.agents || []
123
+ let symlink_repairs = []
124
+ if (detected_agents.length > 0) {
125
+ const skills_to_check = []
126
+ for (const [name] of entries) {
127
+ const short_name = name.split('/')[1] || name
128
+ const source_dir = skill_install_dir(base_dir, short_name)
129
+ const [, enabled] = await is_skill_enabled(short_name, detected_agents, is_global, project_root)
130
+ if (enabled) {
131
+ skills_to_check.push({ skill_name: short_name, source_dir })
132
+ }
133
+ }
134
+ if (skills_to_check.length > 0) {
135
+ const link_spinner = !args.flags.json ? create_spinner('Verifying symlinks…') : null
136
+ const [, repair_result] = await verify_and_repair_symlinks(skills_to_check, detected_agents, { global: is_global, project_root })
137
+ symlink_repairs = repair_result?.repaired || []
138
+ if (symlink_repairs.length > 0) {
139
+ link_spinner?.succeed(`Repaired ${symlink_repairs.length} symlink(s)`)
140
+ } else {
141
+ link_spinner?.succeed('All symlinks verified')
142
+ }
143
+ }
144
+ }
145
+
146
+ // 3. If nothing to update, report and exit
119
147
  if (outdated.length === 0) {
120
148
  if (args.flags.json) {
121
- print_json({ data: { results, outdated_count: 0, up_to_date_count: up_to_date.length, updated: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), errors: [] } })
149
+ print_json({ data: { results, outdated_count: 0, up_to_date_count: up_to_date.length, updated: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), symlink_repairs, errors: [] } })
122
150
  return
123
151
  }
124
152
  print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
125
153
  r.skill, r.installed, r.latest, green(r.status)
126
154
  ]))
127
155
  console.log()
128
- print_success('All skills are up to date.')
156
+ if (symlink_repairs.length > 0) {
157
+ print_success(`All skills are up to date. Repaired ${symlink_repairs.length} symlink(s).`)
158
+ } else {
159
+ print_success('All skills are up to date.')
160
+ }
129
161
  return
130
162
  }
131
163
 
132
- // 3. Show results table (non-json)
164
+ // 4. Show results table (non-json)
133
165
  if (!args.flags.json) {
134
166
  const status_colors = { 'up-to-date': green, 'outdated': yellow, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
135
167
  print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
@@ -139,7 +171,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
139
171
  print_info(`${outdated.length} skill(s) can be updated.`)
140
172
  }
141
173
 
142
- // 4. Confirm update
174
+ // 5. Confirm update
143
175
  const should_update = auto_yes || !process.stdin.isTTY
144
176
  if (!should_update && !args.flags.json) {
145
177
  const answer = await confirm_prompt(`\nUpdate ${outdated.length} skill(s)? [y/N] `)
@@ -149,8 +181,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
149
181
  }
150
182
  }
151
183
 
152
- // 5. Detect local modifications for all outdated skills in parallel
153
- const base_dir = skills_dir(is_global, project_root)
184
+ // 6. Detect local modifications for all outdated skills in parallel
154
185
  const detections = await Promise.all(outdated.map(r => {
155
186
  const lock_entry = skills[r.skill]
156
187
  const short_name = r.skill.split('/')[1] || r.skill
@@ -168,7 +199,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
168
199
  }
169
200
  }
170
201
 
171
- // 6. Update safe skills
202
+ // 7. Update safe skills
172
203
  const options = { global: is_global, fresh: true, agents: args.flags.agents || undefined, project_root }
173
204
  const updated = []
174
205
  const update_errors = []
@@ -187,7 +218,7 @@ const run = (args) => catch_errors('Refresh failed', async () => {
187
218
  }
188
219
  }
189
220
 
190
- // 7. Output results
221
+ // 8. Output results
191
222
  if (args.flags.json) {
192
223
  print_json({
193
224
  data: {
@@ -11,7 +11,7 @@ const { write_lock, update_lock_skills } = require('../lock/writer')
11
11
  const { skills_dir, tmp_dir, skill_install_dir, lock_root } = require('../config/paths')
12
12
  const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs')
13
13
  const { SKILL_JSON } = require('../constants')
14
- const { resolve_agents, link_to_agents } = require('../agents')
14
+ const { resolve_agents, link_to_agents, verify_and_repair_symlinks } = require('../agents')
15
15
  const { is_skill_enabled } = require('../agents/status')
16
16
  const { create_spinner } = require('../ui/spinner')
17
17
  const { print_success, print_warn, print_info } = require('../ui/output')
@@ -42,6 +42,18 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
42
42
  if (exists) {
43
43
  const [, valid] = locked.integrity ? await verify_integrity(install_dir, locked.integrity) : [null, true]
44
44
  if (valid !== false) {
45
+ // Verify and repair symlinks even for already-installed skills
46
+ if (agents.length > 0) {
47
+ const name = skill.split('/')[1]
48
+ const [, enabled] = await is_skill_enabled(name, agents, is_global, project_root)
49
+ if (enabled) {
50
+ await verify_and_repair_symlinks(
51
+ [{ skill_name: name, source_dir: install_dir }],
52
+ agents,
53
+ { global: is_global, project_root }
54
+ )
55
+ }
56
+ }
45
57
  print_success(`${skill}@${locked.version} already installed`)
46
58
  return {
47
59
  skill,
@@ -91,6 +103,20 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
91
103
  }
92
104
 
93
105
  if (packages_to_install.length === 0) {
106
+ // Verify and repair symlinks for all skipped packages
107
+ if (agents.length > 0) {
108
+ const skills_to_verify = []
109
+ for (const pkg of packages) {
110
+ const name = pkg.skill.split('/')[1]
111
+ const [, enabled] = await is_skill_enabled(name, agents, is_global, project_root)
112
+ if (enabled) {
113
+ skills_to_verify.push({ skill_name: name, source_dir: skill_install_dir(base_dir, name) })
114
+ }
115
+ }
116
+ if (skills_to_verify.length > 0) {
117
+ await verify_and_repair_symlinks(skills_to_verify, agents, { global: is_global, project_root })
118
+ }
119
+ }
94
120
  spinner.succeed(`${skill}@${packages[0]?.version} already up to date`)
95
121
  const skipped = packages.map(p => ({ skill: p.skill, version: p.version, reason: 'already installed' }))
96
122
  return { skill, version: packages[0]?.version, no_op: true, installed: [], skipped, warnings: [], forced: [] }