happyskills 0.35.1 → 0.35.3

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,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.35.3] - 2026-04-11
11
+
12
+ ### Fixed
13
+ - 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
14
+
15
+ ## [0.35.2] - 2026-04-11
16
+
17
+ ### Added
18
+ - Add match quality labels to smart search output — each result shows its confidence level (Strong match, Good match, Partial match, Weak match) based on cosine similarity
19
+ - Add weak-match notice when no results have strong or good match quality, warning that results may not match the user's intent
20
+ - Add `match_quality` and `match_notice` fields to JSON search output
21
+
10
22
  ## [0.35.1] - 2026-04-10
11
23
 
12
24
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.35.1",
3
+ "version": "0.35.3",
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 to_check) {
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: {
@@ -46,6 +46,13 @@ const QUALITY_TIERS = [
46
46
  { min: 20, label: 'Low quality', color: yellow },
47
47
  ]
48
48
 
49
+ const MATCH_QUALITY_LABELS = {
50
+ strong: { label: 'Strong match', color: cyan },
51
+ good: { label: 'Good match', color: cyan },
52
+ partial: { label: 'Partial match', color: yellow },
53
+ weak: { label: 'Weak match', color: yellow },
54
+ }
55
+
49
56
  const get_quality_label = (score) => {
50
57
  if (score == null) return null
51
58
  for (const tier of QUALITY_TIERS) {
@@ -67,10 +74,12 @@ const format_smart_result = (item, index) => {
67
74
  const name = `${item.workspace_slug}/${item.name}`
68
75
  const stars = item.star_count || 0
69
76
  const tier = get_quality_label(item.quality_score)
77
+ const match = item.match_quality ? MATCH_QUALITY_LABELS[item.match_quality] : null
70
78
 
71
79
  const star_str = `★ ${stars}`
72
80
  const quality_str = tier ? tier.color(tier.label) : ''
73
- const meta_parts = [star_str, quality_str].filter(Boolean).join(' · ')
81
+ const match_str = match ? match.color(match.label) : ''
82
+ const meta_parts = [match_str, star_str, quality_str].filter(Boolean).join(' · ')
74
83
 
75
84
  const num = ` ${String(index + 1).padStart(2)}. `
76
85
  const name_and_meta = `${bold(name)}${meta_parts ? ` ${dim(meta_parts)}` : ''}`
@@ -114,6 +123,13 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
114
123
  return
115
124
  }
116
125
 
126
+ const has_strong_or_good = items.some(item =>
127
+ item.match_quality === 'strong' || item.match_quality === 'good'
128
+ )
129
+ const match_notice = !has_strong_or_good && items.length > 0
130
+ ? 'No strong matches found. The results below are the closest available but may not match your intent.'
131
+ : null
132
+
117
133
  if (args.flags.json) {
118
134
  const mapped = items.map(item => ({
119
135
  skill: `${item.workspace_slug}/${item.name}`,
@@ -126,17 +142,23 @@ const run_smart_search = (args, query, options) => catch_errors('Smart search fa
126
142
  quality_score: item.quality_score != null ? item.quality_score : null,
127
143
  quality_tier: get_quality_tier_name(item.quality_score),
128
144
  relevance_score: item.relevance_score != null ? item.relevance_score : null,
145
+ match_quality: item.match_quality || null,
129
146
  tags: item.tags || [],
130
147
  download_count: item.download_count || 0,
131
148
  created_at: item.created_at,
132
149
  updated_at: item.updated_at,
133
150
  }))
134
- print_json({ data: { query, mode: 'smart', results: mapped, count: mapped.length } })
151
+ const data = { query, mode: 'smart', results: mapped, count: mapped.length }
152
+ if (match_notice) data.match_notice = match_notice
153
+ print_json({ data })
135
154
  return
136
155
  }
137
156
 
138
157
  // Human-readable smart output
139
158
  console.log(`\n${bold(`Skills for: "${query}"`)}\n`)
159
+ if (match_notice) {
160
+ console.log(` ${yellow(match_notice)}\n`)
161
+ }
140
162
  items.forEach((item, i) => {
141
163
  console.log(format_smart_result(item, i))
142
164
  if (i < items.length - 1) console.log('')
@@ -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: [] }