happyskills 0.35.2 → 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 +5 -0
- package/package.json +1 -1
- package/src/agents/index.js +2 -2
- package/src/agents/linker.js +55 -1
- package/src/agents/linker.test.js +177 -1
- package/src/commands/refresh.js +41 -10
- package/src/engine/installer.js +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,11 @@ 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
|
+
|
|
10
15
|
## [0.35.2] - 2026-04-11
|
|
11
16
|
|
|
12
17
|
### Added
|
package/package.json
CHANGED
package/src/agents/index.js
CHANGED
|
@@ -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
|
}
|
package/src/agents/linker.js
CHANGED
|
@@ -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
|
+
})
|
package/src/commands/refresh.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
221
|
+
// 8. Output results
|
|
191
222
|
if (args.flags.json) {
|
|
192
223
|
print_json({
|
|
193
224
|
data: {
|
package/src/engine/installer.js
CHANGED
|
@@ -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: [] }
|