happyskills 0.38.0 → 0.39.0

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,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.39.0] - 2026-05-01
11
+
12
+ ### Removed
13
+ - **BREAKING:** Remove the `refresh` command (and its `r` alias) entirely. Its smart-batch-check behavior is now the default for `update` — see the Changed entry below. The `r` alias is also removed. Anyone scripting `happyskills refresh ...` should switch to `happyskills update --all -y --json` (same outcome, same JSON shape under `.data.results`).
14
+
15
+ ### Changed
16
+ - **BREAKING (behavior):** `update [--all]` is now smart by default. It runs one `POST /repos:check-updates` batch call against the registry and only re-installs skills that are actually outdated. Skills already at the latest version are left alone (no download). Previously `update --all` always re-installed every locked skill regardless of version. Pass `--force` to opt back into the legacy "always re-install" behavior (now also overwrites local modifications, replacing the previous narrow meaning of `--force`).
17
+ - For `--all`, only **root-level** skills are checked (transitive dependencies follow their parents through the install pipeline). For a specific target, any locked skill works.
18
+ - `update --json` output shape now mirrors what `refresh --json` returned: `{ results, outdated_count, up_to_date_count, updated, skipped, already_up_to_date, errors }` plus `symlink_repairs`. With `--force`, the shape is the simpler `{ updated, count, forced: true }`.
19
+
20
+ ### Migration
21
+ - Replace `happyskills refresh` → `happyskills update --all`
22
+ - Replace `happyskills refresh -y --json` → `happyskills update --all -y --json`
23
+ - If you actually wanted the old `update --all` behavior (force re-install regardless of version), now use `happyskills update --all --force`
24
+
10
25
  ## [0.38.0] - 2026-05-01
11
26
 
12
27
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
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,31 +1,65 @@
1
1
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
2
  const { install } = require('../engine/installer')
3
3
  const { read_lock, get_all_locked_skills, get_via } = require('../lock/reader')
4
+ const { read_manifest } = require('../manifest/reader')
4
5
  const { detect_status } = require('../merge/detector')
5
- const { print_help, print_success, print_info, print_warn, print_json } = require('../ui/output')
6
+ const repos_api = require('../api/repos')
7
+ const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
8
+ const { green, yellow, red } = require('../ui/colors')
9
+ const { create_spinner } = require('../ui/spinner')
6
10
  const { exit_with_error, UsageError } = require('../utils/errors')
7
11
  const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
8
- const { EXIT_CODES } = require('../constants')
12
+ const { EXIT_CODES, SKILL_TYPES } = require('../constants')
13
+ const { resolve_agents, verify_and_repair_symlinks } = require('../agents')
14
+ const { is_skill_enabled } = require('../agents/status')
9
15
 
10
16
  const HELP_TEXT = `Usage: happyskills update [owner/skill|--all] [options]
11
17
 
12
- Upgrade skills to latest compatible versions.
18
+ Bring installed skills up to date. By default, checks each skill against the
19
+ registry (one batch API call) and only re-installs skills that are actually
20
+ outdated. Skills already at the latest version are left alone — no download,
21
+ no work. Use --force to re-install regardless of version.
13
22
 
14
23
  Arguments:
15
- owner/skill Update specific skill (optional)
24
+ owner/skill Update a specific skill (optional)
16
25
 
17
26
  Options:
18
- --all Update all installed skills
27
+ --all Update all installed root-level skills
28
+ --force Re-install regardless of version. Also overwrites skills
29
+ with local modifications.
19
30
  -g, --global Update globally installed skills
20
31
  -y, --yes Skip confirmation prompts
32
+ --agents <list> Target specific agents (comma-separated). Default: auto-detect
21
33
  --json Output as JSON
22
34
 
23
35
  Aliases: up
24
36
 
25
37
  Examples:
26
- happyskills update acme/deploy-aws
27
- happyskills up --all
28
- happyskills up -g --all`
38
+ happyskills update acme/deploy-aws # update one skill if outdated
39
+ happyskills up --all # check + update all outdated skills
40
+ happyskills up --all --force # force re-install everything
41
+ happyskills up --all -y --json # batch mode, no prompt`
42
+
43
+ /**
44
+ * Returns true if the lock entry's dependencies differ from the installed
45
+ * skill.json's dependencies — indicates the lock file is stale relative to
46
+ * the on-disk manifest.
47
+ */
48
+ const has_dependency_drift = (lock_entry, manifest) => {
49
+ if (!manifest) return false
50
+ const lock_deps = JSON.stringify(lock_entry.dependencies || {})
51
+ const manifest_deps = JSON.stringify(manifest.dependencies || {})
52
+ return lock_deps !== manifest_deps
53
+ }
54
+
55
+ const confirm_prompt = (question) => new Promise((resolve) => {
56
+ const readline = require('readline')
57
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
58
+ rl.question(question, (answer) => {
59
+ rl.close()
60
+ resolve(answer.trim().toLowerCase())
61
+ })
62
+ })
29
63
 
30
64
  const run = (args) => catch_errors('Update failed', async () => {
31
65
  if (args.flags._show_help) {
@@ -36,80 +70,241 @@ const run = (args) => catch_errors('Update failed', async () => {
36
70
  const project_root = find_project_root()
37
71
  const target_skill = args._[0]
38
72
  const update_all = args.flags.all || false
73
+ const force = args.flags.force || false
74
+ const auto_yes = args.flags.yes || false
75
+ const is_global = args.flags.global || false
39
76
 
40
77
  if (!target_skill && !update_all) {
41
78
  throw new UsageError("Specify a skill to update or use --all (e.g., 'happyskills update acme/deploy-aws' or 'happyskills update --all').")
42
79
  }
43
80
 
44
- const is_global = args.flags.global || false
45
81
  const [, lock_data] = await read_lock(lock_root(is_global, project_root))
46
82
  const skills = get_all_locked_skills(lock_data)
83
+ const base_dir = skills_dir(is_global, project_root)
47
84
 
48
- const to_update = target_skill
49
- ? [[target_skill, skills[target_skill]]]
50
- : Object.entries(skills).filter(([, data]) => data !== null)
85
+ // Determine which skills to consider.
86
+ // For --all, only root-level skills (transitive deps come along through their parents).
87
+ // For a specific target, allow any locked skill.
88
+ const candidates = target_skill
89
+ ? (skills[target_skill] ? [[target_skill, skills[target_skill]]] : [])
90
+ : Object.entries(skills).filter(([, data]) => data && data.requested_by?.includes('__root__'))
51
91
 
52
- if (to_update.length === 0) {
92
+ if (candidates.length === 0) {
93
+ if (target_skill) {
94
+ throw new UsageError(`${target_skill} is not installed. Run 'happyskills install ${target_skill}' first.`)
95
+ }
53
96
  if (args.flags.json) {
54
- print_json({ data: { updated: [], already_up_to_date: [], count: 0 } })
97
+ print_json({ data: { results: [], outdated_count: 0, up_to_date_count: 0, updated: [], already_up_to_date: [], errors: [] } })
55
98
  return
56
99
  }
57
- print_info('No skills to update.')
100
+ print_info('No skills installed.')
58
101
  return
59
102
  }
60
103
 
61
- const options = {
62
- global: is_global,
63
- fresh: true,
64
- agents: args.flags.agents || undefined,
65
- project_root
104
+ // ─── --force path: skip the version check, re-install everything ─────────
105
+ if (force) {
106
+ const options = { global: is_global, fresh: true, force: true, agents: args.flags.agents || undefined, project_root }
107
+ const updated = []
108
+ for (const [name, data] of candidates) {
109
+ const before_version = data?.version || null
110
+ const spinner = !args.flags.json ? create_spinner(`Re-installing ${name}…`) : null
111
+ const [errors, result] = await install(name, options)
112
+ if (errors) {
113
+ spinner?.fail(`Failed to re-install ${name}`)
114
+ throw e(`Update ${name} failed`, errors)
115
+ }
116
+ spinner?.succeed(`Re-installed ${name}@${result.version}`)
117
+ updated.push({ skill: name, from: before_version, to: result.version, via: get_via(data) })
118
+ }
119
+ if (args.flags.json) {
120
+ print_json({ data: { updated, count: updated.length, forced: true } })
121
+ return
122
+ }
123
+ if (updated.length > 0) {
124
+ print_success(`Re-installed ${updated.length} skill(s) (--force).`)
125
+ }
126
+ return
66
127
  }
67
128
 
68
- const updated = []
69
- const already_up_to_date = []
129
+ // ─── Default smart path: batch-check, then install only outdated ─────────
130
+ const spinner = !args.flags.json ? create_spinner('Checking for updates…') : null
131
+ const skill_names = candidates.map(([name]) => name)
132
+ const [batch_err, batch_data] = await repos_api.check_updates(skill_names)
70
133
 
71
- const base_dir = skills_dir(is_global, project_root)
72
- const force = args.flags.force || false
134
+ const results = []
135
+ if (batch_err) {
136
+ spinner?.fail('Failed to check for updates')
137
+ for (const [name, data] of candidates) {
138
+ results.push({ skill: name, installed: data.version, latest: 'error', status: 'error' })
139
+ }
140
+ } else {
141
+ spinner?.succeed('Checked for updates')
73
142
 
74
- // Detect local modifications for all skills in parallel
75
- const modified_set = new Set()
76
- if (!force) {
77
- const detections = await Promise.all(to_update.map(([name, data]) => {
78
- if (!data) return { name, modified: false }
143
+ // Read installed manifests in parallel for dependency-drift detection
144
+ const manifest_map = {}
145
+ await Promise.all(candidates.map(async ([name]) => {
79
146
  const short_name = name.split('/')[1] || name
80
147
  const dir = skill_install_dir(base_dir, short_name)
81
- return detect_status(data, dir).then(([, det]) => ({ name, modified: det?.local_modified || false }))
148
+ const [, manifest] = await read_manifest(dir)
149
+ if (manifest) manifest_map[name] = manifest
82
150
  }))
83
- for (const { name, modified } of detections) {
84
- if (modified) modified_set.add(name)
151
+
152
+ for (const [name, data] of candidates) {
153
+ const info = batch_data?.results?.[name]
154
+ if (info?.access_denied) {
155
+ results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
156
+ } else if (!info || !info.latest_version) {
157
+ results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
158
+ } else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
159
+ results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
160
+ } else if (!data.base_commit && info.latest_version !== data.version) {
161
+ // Fallback to version comparison for old lock files without base_commit
162
+ results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
163
+ } else if (has_dependency_drift(data, manifest_map[name])) {
164
+ // Lock dependencies are stale compared to installed skill.json
165
+ results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
166
+ } else {
167
+ results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
168
+ }
85
169
  }
86
170
  }
87
171
 
88
- for (const [name, data] of to_update) {
89
- if (modified_set.has(name)) {
90
- print_warn(`${name} has local modifications. Use --force to discard, or 'happyskills pull' to merge.`)
91
- continue
172
+ const outdated = results.filter(r => r.status === 'outdated')
173
+ const up_to_date = results.filter(r => r.status === 'up-to-date')
174
+
175
+ // Verify and repair symlinks (even when nothing is outdated)
176
+ const [, agents_data] = await resolve_agents(args.flags.agents)
177
+ const detected_agents = agents_data?.agents || []
178
+ let symlink_repairs = []
179
+ if (detected_agents.length > 0) {
180
+ const skills_to_check = []
181
+ for (const [name, data] of Object.entries(skills)) {
182
+ if (data?.type === SKILL_TYPES.KIT) continue
183
+ const short_name = name.split('/')[1] || name
184
+ const source_dir = skill_install_dir(base_dir, short_name)
185
+ const [, enabled] = await is_skill_enabled(short_name, detected_agents, is_global, project_root)
186
+ if (enabled) {
187
+ skills_to_check.push({ skill_name: short_name, source_dir })
188
+ }
92
189
  }
190
+ if (skills_to_check.length > 0) {
191
+ const link_spinner = !args.flags.json ? create_spinner('Verifying symlinks…') : null
192
+ const [, repair_result] = await verify_and_repair_symlinks(skills_to_check, detected_agents, { global: is_global, project_root })
193
+ symlink_repairs = repair_result?.repaired || []
194
+ if (symlink_repairs.length > 0) {
195
+ link_spinner?.succeed(`Repaired ${symlink_repairs.length} symlink(s)`)
196
+ } else {
197
+ link_spinner?.succeed('All symlinks verified')
198
+ }
199
+ }
200
+ }
93
201
 
94
- const before_version = data?.version || null
95
- const [errors, result] = await install(name, options)
96
- if (errors) throw e(`Update ${name} failed`, errors)
97
- if (!result.no_op) {
98
- updated.push({ skill: name, from: before_version, to: result.version, via: get_via(data) })
202
+ // If nothing to update, report and exit
203
+ if (outdated.length === 0) {
204
+ if (args.flags.json) {
205
+ 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: [] } })
206
+ return
207
+ }
208
+ print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
209
+ r.skill, r.installed, r.latest, green(r.status)
210
+ ]))
211
+ console.log()
212
+ if (symlink_repairs.length > 0) {
213
+ print_success(`All skills are up to date. Repaired ${symlink_repairs.length} symlink(s).`)
99
214
  } else {
100
- already_up_to_date.push({ skill: name, version: result.version, via: get_via(data) })
215
+ print_success('All skills are up to date.')
101
216
  }
217
+ return
102
218
  }
103
219
 
220
+ // Show results table (non-json)
221
+ if (!args.flags.json) {
222
+ const status_colors = { 'up-to-date': green, 'outdated': yellow, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
223
+ print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
224
+ r.skill, r.installed, r.latest, (status_colors[r.status] || ((s) => s))(r.status)
225
+ ]))
226
+ console.log()
227
+ print_info(`${outdated.length} skill(s) can be updated.`)
228
+ }
229
+
230
+ // Confirm
231
+ const should_update = auto_yes || !process.stdin.isTTY
232
+ if (!should_update && !args.flags.json) {
233
+ const answer = await confirm_prompt(`\nUpdate ${outdated.length} skill(s)? [y/N] `)
234
+ if (answer !== 'y' && answer !== 'yes') {
235
+ print_info('Skipped.')
236
+ return
237
+ }
238
+ }
239
+
240
+ // Detect local modifications on the outdated set
241
+ const detections = await Promise.all(outdated.map(r => {
242
+ const lock_entry = skills[r.skill]
243
+ const short_name = r.skill.split('/')[1] || r.skill
244
+ const dir = skill_install_dir(base_dir, short_name)
245
+ return detect_status(lock_entry, dir).then(([, det]) => ({ r, det }))
246
+ }))
247
+
248
+ const skipped = []
249
+ const safe_to_update = []
250
+ for (const { r, det } of detections) {
251
+ if (det?.local_modified) {
252
+ skipped.push({ skill: r.skill, reason: 'local_modifications', suggestion: `happyskills pull ${r.skill}` })
253
+ } else {
254
+ safe_to_update.push(r)
255
+ }
256
+ }
257
+
258
+ // Install only the outdated, non-modified skills
259
+ const options = { global: is_global, fresh: true, agents: args.flags.agents || undefined, project_root }
260
+ const updated = []
261
+ const update_errors = []
262
+
263
+ for (const r of safe_to_update) {
264
+ const update_spinner = !args.flags.json ? create_spinner(`Updating ${r.skill}…`) : null
265
+ const [errors, result] = await install(r.skill, options)
266
+ if (errors) {
267
+ update_spinner?.fail(`Failed to update ${r.skill}`)
268
+ update_errors.push({ skill: r.skill, message: errors[errors.length - 1]?.message || 'Unknown error' })
269
+ } else if (!result.no_op) {
270
+ update_spinner?.succeed(`Updated ${r.skill} ${r.installed} → ${result.version}`)
271
+ updated.push({ skill: r.skill, from: r.installed, to: result.version })
272
+ } else {
273
+ update_spinner?.succeed(`${r.skill} already up to date`)
274
+ }
275
+ }
276
+
277
+ // Output
104
278
  if (args.flags.json) {
105
- print_json({ data: { updated, already_up_to_date, count: updated.length } })
279
+ print_json({
280
+ data: {
281
+ results,
282
+ outdated_count: outdated.length,
283
+ up_to_date_count: up_to_date.length,
284
+ updated,
285
+ skipped,
286
+ already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
287
+ errors: update_errors
288
+ }
289
+ })
106
290
  return
107
291
  }
108
292
 
293
+ if (skipped.length > 0) {
294
+ console.log()
295
+ print_warn(`Skipped ${skipped.length} skill(s) with local modifications:`)
296
+ for (const s of skipped) {
297
+ print_info(` ${s.skill}`)
298
+ }
299
+ print_hint(`Use ${code('happyskills pull <skill>')} to merge remote changes.`)
300
+ }
109
301
  if (updated.length > 0) {
110
- print_success(`Updated ${updated.length} skill(s)`)
111
- } else {
112
- print_success('All skills are already up to date.')
302
+ console.log()
303
+ print_success(`Updated ${updated.length} skill(s).`)
304
+ }
305
+ if (update_errors.length > 0) {
306
+ console.log()
307
+ print_info(`${update_errors.length} skill(s) failed to update. Run ${code('happyskills check')} for details.`)
113
308
  }
114
309
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
115
310
 
package/src/constants.js CHANGED
@@ -30,7 +30,6 @@ const COMMAND_ALIASES = {
30
30
  remove: 'uninstall',
31
31
  ls: 'list',
32
32
  s: 'search',
33
- r: 'refresh',
34
33
  st: 'status',
35
34
  d: 'diff',
36
35
  up: 'update',
@@ -52,7 +51,6 @@ const COMMANDS = [
52
51
  'list',
53
52
  'search',
54
53
  'check',
55
- 'refresh',
56
54
  'status',
57
55
  'pull',
58
56
  'diff',
package/src/index.js CHANGED
@@ -92,11 +92,10 @@ Commands:
92
92
  list List installed skills (alias: ls)
93
93
  search <query> Search the registry (alias: s)
94
94
  check [owner/skill] Check for available updates
95
- refresh Check + update all outdated skills (alias: r)
96
95
  status [owner/skill] Show divergence status (alias: st)
97
96
  pull <owner/skill> Pull remote changes and merge
98
97
  diff <owner/skill> Show file-level differences (alias: d)
99
- update [owner/skill] Upgrade to latest versions (alias: up)
98
+ update [owner/skill] Update outdated skills (alias: up). Smart by default; --force to re-install regardless
100
99
  publish Push skill to registry (alias: pub)
101
100
  validate <skill-name> Validate skill against all rules (alias: v)
102
101
  fork <owner/skill> Fork a skill to your workspace
@@ -92,7 +92,7 @@ describe('CLI — global flags', () => {
92
92
 
93
93
  it('--help lists all expected commands', () => {
94
94
  const { stdout } = run(['--help'])
95
- const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', 'refresh', 'update', 'publish', 'validate', 'fork', 'login', 'logout', 'whoami', 'init', 'setup', 'self-update']
95
+ const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', 'update', 'publish', 'validate', 'fork', 'login', 'logout', 'whoami', 'init', 'setup', 'self-update']
96
96
  for (const cmd of expected_commands) {
97
97
  assert.ok(stdout.includes(cmd), `help output should mention "${cmd}"`)
98
98
  }
@@ -160,10 +160,8 @@ describe('CLI — command --help', () => {
160
160
  ['search', 'Arguments:'],
161
161
  ['search', 'Aliases:'],
162
162
  ['check', 'Examples:'],
163
- ['refresh', 'Options:'],
164
- ['refresh', 'Examples:'],
165
- ['refresh', 'Aliases:'],
166
163
  ['update', 'Aliases:'],
164
+ ['update', '--force'],
167
165
  ['publish', 'Aliases:'],
168
166
  ['validate', 'Arguments:'],
169
167
  ['validate', 'Aliases:'],
@@ -199,7 +197,6 @@ describe('CLI — command aliases', () => {
199
197
  ['remove', 'uninstall'],
200
198
  ['ls', 'list'],
201
199
  ['s', 'search'],
202
- ['r', 'refresh'],
203
200
  ['up', 'update'],
204
201
  ['pub', 'publish'],
205
202
  ['v', 'validate'],
@@ -423,15 +420,15 @@ describe('CLI — --json: existing json commands now use { data } envelope', ()
423
420
  })
424
421
  })
425
422
 
426
- // ─── refresh command ──────────────────────────────────────────────────────────
423
+ // ─── update --all (smart batch-check) ─────────────────────────────────────────
427
424
 
428
- describe('CLI — --json: refresh command', () => {
429
- it('refresh --json with no skills returns { data: { results, outdated_count, ... } }', () => {
425
+ describe('CLI — --json: update --all command', () => {
426
+ it('update --all --json with no installed skills returns { data: { results, outdated_count, ... } }', () => {
430
427
  const tmp = make_tmp()
431
428
  try {
432
- const { stdout, code } = run(['refresh', '--json'], {}, { cwd: tmp })
429
+ const { stdout, code } = run(['update', '--all', '--json'], {}, { cwd: tmp })
433
430
  assert.strictEqual(code, 0)
434
- const out = parse_json_output(stdout, 'refresh --json empty')
431
+ const out = parse_json_output(stdout, 'update --all --json empty')
435
432
  assert.ok('data' in out)
436
433
  assert.ok(Array.isArray(out.data.results))
437
434
  assert.strictEqual(out.data.outdated_count, 0)
@@ -1,256 +0,0 @@
1
- const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
- const { install } = require('../engine/installer')
3
- const { read_lock, get_all_locked_skills } = require('../lock/reader')
4
- const { read_manifest } = require('../manifest/reader')
5
- const { detect_status } = require('../merge/detector')
6
- const repos_api = require('../api/repos')
7
- const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
8
- const { green, yellow, red } = require('../ui/colors')
9
- const { create_spinner } = require('../ui/spinner')
10
- const { exit_with_error } = require('../utils/errors')
11
- const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
12
- const { EXIT_CODES, SKILL_TYPES } = require('../constants')
13
- const { resolve_agents, verify_and_repair_symlinks } = require('../agents')
14
- const { is_skill_enabled } = require('../agents/status')
15
-
16
- const HELP_TEXT = `Usage: happyskills refresh [options]
17
-
18
- Check all installed skills for updates and upgrade outdated ones.
19
-
20
- Options:
21
- -g, --global Refresh globally installed skills
22
- -y, --yes Skip confirmation prompts
23
- --json Output as JSON
24
-
25
- Aliases: r
26
-
27
- Examples:
28
- happyskills refresh
29
- happyskills refresh -y
30
- happyskills refresh -g -y --json`
31
-
32
- /**
33
- * Returns true if the lock entry's dependencies differ from the installed skill.json's dependencies.
34
- */
35
- const has_dependency_drift = (lock_entry, manifest) => {
36
- if (!manifest) return false
37
- const lock_deps = JSON.stringify(lock_entry.dependencies || {})
38
- const manifest_deps = JSON.stringify(manifest.dependencies || {})
39
- return lock_deps !== manifest_deps
40
- }
41
-
42
- const confirm_prompt = (question) => new Promise((resolve) => {
43
- const readline = require('readline')
44
- const rl = readline.createInterface({ input: process.stdin, output: process.stderr })
45
- rl.question(question, (answer) => {
46
- rl.close()
47
- resolve(answer.trim().toLowerCase())
48
- })
49
- })
50
-
51
- const run = (args) => catch_errors('Refresh failed', async () => {
52
- if (args.flags._show_help) {
53
- print_help(HELP_TEXT)
54
- return process.exit(EXIT_CODES.SUCCESS)
55
- }
56
-
57
- const is_global = args.flags.global || false
58
- const auto_yes = args.flags.yes || false
59
- const project_root = find_project_root()
60
- const [, lock_data] = await read_lock(lock_root(is_global, project_root))
61
- const skills = get_all_locked_skills(lock_data)
62
- const entries = Object.entries(skills)
63
-
64
- const to_check = entries.filter(([, data]) => data.requested_by?.includes('__root__'))
65
-
66
- if (to_check.length === 0) {
67
- if (args.flags.json) {
68
- print_json({ data: { results: [], outdated_count: 0, up_to_date_count: 0, updated: [], already_up_to_date: [], errors: [] } })
69
- return
70
- }
71
- print_info('No skills installed.')
72
- return
73
- }
74
-
75
- // 1. Check for updates
76
- const spinner = !args.flags.json ? create_spinner('Checking for updates…') : null
77
- const skill_names = to_check.map(([name]) => name)
78
- const [batch_err, batch_data] = await repos_api.check_updates(skill_names)
79
- const base_dir = skills_dir(is_global, project_root)
80
-
81
- const results = []
82
- if (batch_err) {
83
- spinner?.fail('Failed to check for updates')
84
- for (const [name, data] of to_check) {
85
- results.push({ skill: name, installed: data.version, latest: 'error', status: 'error' })
86
- }
87
- } else {
88
- spinner?.succeed('Checked for updates')
89
- // Read all installed manifests in parallel to check for dependency drift
90
- const manifest_map = {}
91
- await Promise.all(to_check.map(async ([name]) => {
92
- const short_name = name.split('/')[1] || name
93
- const dir = skill_install_dir(base_dir, short_name)
94
- const [, manifest] = await read_manifest(dir)
95
- if (manifest) manifest_map[name] = manifest
96
- }))
97
- for (const [name, data] of to_check) {
98
- const info = batch_data?.results?.[name]
99
- if (info?.access_denied) {
100
- results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
101
- } else if (!info || !info.latest_version) {
102
- results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
103
- } else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
104
- results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
105
- } else if (!data.base_commit && info.latest_version !== data.version) {
106
- // Fallback to version comparison for old lock files without base_commit
107
- results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
108
- } else if (has_dependency_drift(data, manifest_map[name])) {
109
- // Lock dependencies are stale compared to installed skill.json
110
- results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
111
- } else {
112
- results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
113
- }
114
- }
115
- }
116
-
117
- const outdated = results.filter(r => r.status === 'outdated')
118
- const up_to_date = results.filter(r => r.status === 'up-to-date')
119
-
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, data] of entries) {
127
- if (data.type === SKILL_TYPES.KIT) continue
128
- const short_name = name.split('/')[1] || name
129
- const source_dir = skill_install_dir(base_dir, short_name)
130
- const [, enabled] = await is_skill_enabled(short_name, detected_agents, is_global, project_root)
131
- if (enabled) {
132
- skills_to_check.push({ skill_name: short_name, source_dir })
133
- }
134
- }
135
- if (skills_to_check.length > 0) {
136
- const link_spinner = !args.flags.json ? create_spinner('Verifying symlinks…') : null
137
- const [, repair_result] = await verify_and_repair_symlinks(skills_to_check, detected_agents, { global: is_global, project_root })
138
- symlink_repairs = repair_result?.repaired || []
139
- if (symlink_repairs.length > 0) {
140
- link_spinner?.succeed(`Repaired ${symlink_repairs.length} symlink(s)`)
141
- } else {
142
- link_spinner?.succeed('All symlinks verified')
143
- }
144
- }
145
- }
146
-
147
- // 3. If nothing to update, report and exit
148
- if (outdated.length === 0) {
149
- if (args.flags.json) {
150
- 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: [] } })
151
- return
152
- }
153
- print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
154
- r.skill, r.installed, r.latest, green(r.status)
155
- ]))
156
- console.log()
157
- if (symlink_repairs.length > 0) {
158
- print_success(`All skills are up to date. Repaired ${symlink_repairs.length} symlink(s).`)
159
- } else {
160
- print_success('All skills are up to date.')
161
- }
162
- return
163
- }
164
-
165
- // 4. Show results table (non-json)
166
- if (!args.flags.json) {
167
- const status_colors = { 'up-to-date': green, 'outdated': yellow, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
168
- print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
169
- r.skill, r.installed, r.latest, (status_colors[r.status] || ((s) => s))(r.status)
170
- ]))
171
- console.log()
172
- print_info(`${outdated.length} skill(s) can be updated.`)
173
- }
174
-
175
- // 5. Confirm update
176
- const should_update = auto_yes || !process.stdin.isTTY
177
- if (!should_update && !args.flags.json) {
178
- const answer = await confirm_prompt(`\nUpdate ${outdated.length} skill(s)? [y/N] `)
179
- if (answer !== 'y' && answer !== 'yes') {
180
- print_info('Skipped.')
181
- return
182
- }
183
- }
184
-
185
- // 6. Detect local modifications for all outdated skills in parallel
186
- const detections = await Promise.all(outdated.map(r => {
187
- const lock_entry = skills[r.skill]
188
- const short_name = r.skill.split('/')[1] || r.skill
189
- const dir = skill_install_dir(base_dir, short_name)
190
- return detect_status(lock_entry, dir).then(([, det]) => ({ r, det }))
191
- }))
192
-
193
- const skipped = []
194
- const safe_to_update = []
195
- for (const { r, det } of detections) {
196
- if (det?.local_modified) {
197
- skipped.push({ skill: r.skill, reason: 'local_modifications', suggestion: `happyskills pull ${r.skill}` })
198
- } else {
199
- safe_to_update.push(r)
200
- }
201
- }
202
-
203
- // 7. Update safe skills
204
- const options = { global: is_global, fresh: true, agents: args.flags.agents || undefined, project_root }
205
- const updated = []
206
- const update_errors = []
207
-
208
- for (const r of safe_to_update) {
209
- const update_spinner = !args.flags.json ? create_spinner(`Updating ${r.skill}…`) : null
210
- const [errors, result] = await install(r.skill, options)
211
- if (errors) {
212
- update_spinner?.fail(`Failed to update ${r.skill}`)
213
- update_errors.push({ skill: r.skill, message: errors[errors.length - 1]?.message || 'Unknown error' })
214
- } else if (!result.no_op) {
215
- update_spinner?.succeed(`Updated ${r.skill} ${r.installed} → ${result.version}`)
216
- updated.push({ skill: r.skill, from: r.installed, to: result.version })
217
- } else {
218
- update_spinner?.succeed(`${r.skill} already up to date`)
219
- }
220
- }
221
-
222
- // 8. Output results
223
- if (args.flags.json) {
224
- print_json({
225
- data: {
226
- results,
227
- outdated_count: outdated.length,
228
- up_to_date_count: up_to_date.length,
229
- updated,
230
- skipped,
231
- already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
232
- errors: update_errors
233
- }
234
- })
235
- return
236
- }
237
-
238
- if (skipped.length > 0) {
239
- console.log()
240
- print_warn(`Skipped ${skipped.length} skill(s) with local modifications:`)
241
- for (const s of skipped) {
242
- print_info(` ${s.skill}`)
243
- }
244
- print_hint(`Use ${code('happyskills pull <skill>')} to merge remote changes.`)
245
- }
246
- if (updated.length > 0) {
247
- console.log()
248
- print_success(`Updated ${updated.length} skill(s).`)
249
- }
250
- if (update_errors.length > 0) {
251
- console.log()
252
- print_info(`${update_errors.length} skill(s) failed to update. Run ${code('happyskills check')} for details.`)
253
- }
254
- }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
255
-
256
- module.exports = { run }