happyskills 1.12.1 → 1.13.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 +8 -0
- package/package.json +1 -1
- package/src/commands/list.js +177 -94
- package/src/commands/update.js +155 -94
- package/src/index.js +23 -0
- package/src/integration/list_scopes.test.js +171 -0
- package/src/integration/update_scopes.test.js +100 -0
- package/src/ui/envelope.js +21 -1
- package/src/ui/envelope.test.js +63 -0
- package/src/utils/skill_update_check.js +150 -0
- package/src/utils/skill_update_check.test.js +118 -0
package/src/commands/update.js
CHANGED
|
@@ -26,6 +26,8 @@ Arguments:
|
|
|
26
26
|
|
|
27
27
|
Options:
|
|
28
28
|
--all Update all installed root-level skills
|
|
29
|
+
--all-scopes Update BOTH project-local and global skills (in that order),
|
|
30
|
+
reporting per-scope results. Overrides -g.
|
|
29
31
|
--force Re-install regardless of version. Also overwrites skills
|
|
30
32
|
with local modifications.
|
|
31
33
|
-g, --global Update globally installed skills
|
|
@@ -37,7 +39,9 @@ Aliases: up
|
|
|
37
39
|
|
|
38
40
|
Examples:
|
|
39
41
|
happyskills update acme/deploy-aws # update one skill if outdated
|
|
40
|
-
happyskills up --all # check + update all outdated skills
|
|
42
|
+
happyskills up --all # check + update all outdated skills (local)
|
|
43
|
+
happyskills up --all -g # ...global scope
|
|
44
|
+
happyskills up --all --all-scopes # ...both scopes, per-scope report
|
|
41
45
|
happyskills up --all --force # force re-install everything
|
|
42
46
|
happyskills up --all -y --json # batch mode, no prompt`
|
|
43
47
|
|
|
@@ -62,26 +66,28 @@ const confirm_prompt = (question) => new Promise((resolve) => {
|
|
|
62
66
|
})
|
|
63
67
|
})
|
|
64
68
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const auto_yes
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
69
|
+
/**
|
|
70
|
+
* Run the full update flow for ONE scope (project-local or global) and return
|
|
71
|
+
* `{ scope, data }` — where `data` is exactly the JSON `data` payload that
|
|
72
|
+
* single-scope `update` has always emitted for that path (force / empty /
|
|
73
|
+
* smart). In text mode it prints that scope's output directly; under
|
|
74
|
+
* `multi_scope` it prefixes a scope header and scopes its confirmation prompt.
|
|
75
|
+
* The caller (run) either prints `data` as-is (single scope, byte-identical to
|
|
76
|
+
* the historical contract) or aggregates the per-scope objects (--all-scopes).
|
|
77
|
+
*/
|
|
78
|
+
const run_scope = (scope_is_global, ctx) => catch_errors('Update scope failed', async () => {
|
|
79
|
+
const { project_root, target_skill, force, auto_yes, json_mode, agents_flag, multi_scope } = ctx
|
|
80
|
+
const scope = scope_is_global ? 'global' : 'local'
|
|
81
|
+
const print_header = () => {
|
|
82
|
+
if (multi_scope && !json_mode) {
|
|
83
|
+
console.log()
|
|
84
|
+
print_info(`── ${scope_is_global ? 'Global' : 'Local'} scope ──`)
|
|
85
|
+
}
|
|
80
86
|
}
|
|
81
87
|
|
|
82
|
-
const [, lock_data] = await read_lock(lock_root(
|
|
88
|
+
const [, lock_data] = await read_lock(lock_root(scope_is_global, project_root))
|
|
83
89
|
const skills = get_all_locked_skills(lock_data)
|
|
84
|
-
const base_dir = skills_dir(
|
|
90
|
+
const base_dir = skills_dir(scope_is_global, project_root)
|
|
85
91
|
|
|
86
92
|
// Determine which skills to consider.
|
|
87
93
|
// For --all, only root-level skills (transitive deps come along through their parents).
|
|
@@ -91,24 +97,24 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
91
97
|
: Object.entries(skills).filter(([, data]) => data && data.requested_by?.includes('__root__'))
|
|
92
98
|
|
|
93
99
|
if (candidates.length === 0) {
|
|
94
|
-
|
|
100
|
+
// A missing target is only a hard error in single-scope mode. Across
|
|
101
|
+
// scopes, "not installed in this scope" is normal — report it empty.
|
|
102
|
+
if (target_skill && !multi_scope) {
|
|
95
103
|
throw new UsageError(`${target_skill} is not installed. Run 'happyskills install ${target_skill}' first.`)
|
|
96
104
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
print_info('No skills installed.')
|
|
102
|
-
return
|
|
105
|
+
print_header()
|
|
106
|
+
if (!json_mode) print_info(multi_scope ? `No skills installed in the ${scope} scope.` : 'No skills installed.')
|
|
107
|
+
return { scope, data: { results: [], outdated_count: 0, up_to_date_count: 0, updated: [], already_up_to_date: [], errors: [] } }
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
// ─── --force path: skip the version check, re-install everything ─────────
|
|
106
111
|
if (force) {
|
|
107
|
-
|
|
112
|
+
print_header()
|
|
113
|
+
const options = { global: scope_is_global, fresh: true, force: true, agents: agents_flag || undefined, project_root }
|
|
108
114
|
const updated = []
|
|
109
115
|
for (const [name, data] of candidates) {
|
|
110
116
|
const before_version = data?.version || null
|
|
111
|
-
const spinner = !
|
|
117
|
+
const spinner = !json_mode ? create_spinner(`Re-installing ${name}…`) : null
|
|
112
118
|
const [errors, result] = await install(name, options)
|
|
113
119
|
if (errors) {
|
|
114
120
|
spinner?.fail(`Failed to re-install ${name}`)
|
|
@@ -117,18 +123,15 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
117
123
|
spinner?.succeed(`Re-installed ${name}@${result.version}`)
|
|
118
124
|
updated.push({ skill: name, from: before_version, to: result.version, via: get_via(data) })
|
|
119
125
|
}
|
|
120
|
-
if (
|
|
121
|
-
print_json({ data: { updated, count: updated.length, forced: true } })
|
|
122
|
-
return
|
|
123
|
-
}
|
|
124
|
-
if (updated.length > 0) {
|
|
126
|
+
if (!json_mode && updated.length > 0) {
|
|
125
127
|
print_success(`Re-installed ${updated.length} skill(s) (--force).`)
|
|
126
128
|
}
|
|
127
|
-
return
|
|
129
|
+
return { scope, data: { updated, count: updated.length, forced: true } }
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
// ─── Default smart path: batch-check, then install only outdated ─────────
|
|
131
|
-
|
|
133
|
+
print_header()
|
|
134
|
+
const spinner = !json_mode ? create_spinner('Checking for updates…') : null
|
|
132
135
|
const skill_names = candidates.map(([name]) => name)
|
|
133
136
|
const [batch_err, batch_data] = await repos_api.check_updates(skill_names)
|
|
134
137
|
|
|
@@ -184,7 +187,7 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
184
187
|
const drifted = results.filter(r => r.status === 'drift')
|
|
185
188
|
|
|
186
189
|
// Verify and repair symlinks (even when nothing is outdated)
|
|
187
|
-
const [, agents_data] = await resolve_agents(
|
|
190
|
+
const [, agents_data] = await resolve_agents(agents_flag, { global: scope_is_global, project_root })
|
|
188
191
|
const detected_agents = agents_data?.agents || []
|
|
189
192
|
let symlink_repairs = []
|
|
190
193
|
if (detected_agents.length > 0) {
|
|
@@ -193,14 +196,14 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
193
196
|
if (data?.type === SKILL_TYPES.KIT) continue
|
|
194
197
|
const short_name = name.split('/')[1] || name
|
|
195
198
|
const source_dir = skill_install_dir(base_dir, short_name)
|
|
196
|
-
const [, enabled] = await is_skill_enabled(short_name, detected_agents,
|
|
199
|
+
const [, enabled] = await is_skill_enabled(short_name, detected_agents, scope_is_global, project_root)
|
|
197
200
|
if (enabled) {
|
|
198
201
|
skills_to_check.push({ skill_name: short_name, source_dir })
|
|
199
202
|
}
|
|
200
203
|
}
|
|
201
204
|
if (skills_to_check.length > 0) {
|
|
202
|
-
const link_spinner = !
|
|
203
|
-
const [, repair_result] = await verify_and_repair_symlinks(skills_to_check, detected_agents, { global:
|
|
205
|
+
const link_spinner = !json_mode ? create_spinner('Verifying symlinks…') : null
|
|
206
|
+
const [, repair_result] = await verify_and_repair_symlinks(skills_to_check, detected_agents, { global: scope_is_global, project_root })
|
|
204
207
|
symlink_repairs = repair_result?.repaired || []
|
|
205
208
|
if (symlink_repairs.length > 0) {
|
|
206
209
|
link_spinner?.succeed(`Repaired ${symlink_repairs.length} symlink(s)`)
|
|
@@ -212,30 +215,28 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
212
215
|
|
|
213
216
|
// If nothing to update, report and exit
|
|
214
217
|
if (outdated.length === 0) {
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
218
|
+
if (!json_mode) {
|
|
219
|
+
print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
|
|
220
|
+
r.skill, r.installed, r.latest, r.status === 'drift' ? red('drift') : green(r.status)
|
|
221
|
+
]))
|
|
222
|
+
console.log()
|
|
223
|
+
if (drifted.length > 0) {
|
|
224
|
+
print_warn(`${drifted.length} skill(s) drifted: lock and on-disk skill.json disagree.`)
|
|
225
|
+
for (const r of drifted) {
|
|
226
|
+
console.error(` - ${r.skill} (lock ${r.drift?.lock_version}, disk ${r.drift?.disk_version || 'none'})`)
|
|
227
|
+
}
|
|
228
|
+
print_hint(`Restore each one with ${code('happyskills install <skill> --fresh')} before updating.`)
|
|
229
|
+
} else if (symlink_repairs.length > 0) {
|
|
230
|
+
print_success(`All skills are up to date. Repaired ${symlink_repairs.length} symlink(s).`)
|
|
231
|
+
} else {
|
|
232
|
+
print_success(multi_scope ? `All ${scope} skills are up to date.` : 'All skills are up to date.')
|
|
227
233
|
}
|
|
228
|
-
print_hint(`Restore each one with ${code('happyskills install <skill> --fresh')} before updating.`)
|
|
229
|
-
} else if (symlink_repairs.length > 0) {
|
|
230
|
-
print_success(`All skills are up to date. Repaired ${symlink_repairs.length} symlink(s).`)
|
|
231
|
-
} else {
|
|
232
|
-
print_success('All skills are up to date.')
|
|
233
234
|
}
|
|
234
|
-
return
|
|
235
|
+
return { scope, data: { results, outdated_count: 0, up_to_date_count: up_to_date.length, drift_count: drifted.length, updated: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), symlink_repairs, errors: [] } }
|
|
235
236
|
}
|
|
236
237
|
|
|
237
238
|
// Show results table (non-json)
|
|
238
|
-
if (!
|
|
239
|
+
if (!json_mode) {
|
|
239
240
|
const status_colors = { 'up-to-date': green, 'outdated': yellow, 'drift': red, 'no-access': yellow, 'error': red, 'unknown': (s) => s }
|
|
240
241
|
print_table(['Skill', 'Installed', 'Latest', 'Status'], results.map(r => [
|
|
241
242
|
r.skill, r.installed, r.latest, (status_colors[r.status] || ((s) => s))(r.status)
|
|
@@ -248,16 +249,16 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
248
249
|
}
|
|
249
250
|
print_hint(`Restore each one with ${code('happyskills install <skill> --fresh')} to update them.`)
|
|
250
251
|
}
|
|
251
|
-
print_info(`${outdated.length} skill(s) can be updated.`)
|
|
252
|
+
print_info(`${outdated.length} skill(s) can be updated${multi_scope ? ` in the ${scope} scope` : ''}.`)
|
|
252
253
|
}
|
|
253
254
|
|
|
254
255
|
// Confirm
|
|
255
256
|
const should_update = auto_yes || !process.stdin.isTTY
|
|
256
|
-
if (!should_update && !
|
|
257
|
-
const answer = await confirm_prompt(`\nUpdate ${outdated.length} skill(s)? [y/N] `)
|
|
257
|
+
if (!should_update && !json_mode) {
|
|
258
|
+
const answer = await confirm_prompt(`\nUpdate ${outdated.length} skill(s)${multi_scope ? ` in the ${scope} scope` : ''}? [y/N] `)
|
|
258
259
|
if (answer !== 'y' && answer !== 'yes') {
|
|
259
260
|
print_info('Skipped.')
|
|
260
|
-
return
|
|
261
|
+
return { scope, data: { results, outdated_count: outdated.length, up_to_date_count: up_to_date.length, drift_count: drifted.length, updated: [], skipped: [], already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })), symlink_repairs, errors: [], canceled: true } }
|
|
261
262
|
}
|
|
262
263
|
}
|
|
263
264
|
|
|
@@ -266,7 +267,7 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
266
267
|
const lock_entry = skills[r.skill]
|
|
267
268
|
const short_name = r.skill.split('/')[1] || r.skill
|
|
268
269
|
const dir = skill_install_dir(base_dir, short_name)
|
|
269
|
-
return detect_status(lock_entry, dir, { skill_name: r.skill, project_root, is_global }).then(([, det]) => ({ r, det }))
|
|
270
|
+
return detect_status(lock_entry, dir, { skill_name: r.skill, project_root, is_global: scope_is_global }).then(([, det]) => ({ r, det }))
|
|
270
271
|
}))
|
|
271
272
|
|
|
272
273
|
const skipped = []
|
|
@@ -280,12 +281,12 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
280
281
|
}
|
|
281
282
|
|
|
282
283
|
// Install only the outdated, non-modified skills
|
|
283
|
-
const options = { global:
|
|
284
|
+
const options = { global: scope_is_global, fresh: true, agents: agents_flag || undefined, project_root }
|
|
284
285
|
const updated = []
|
|
285
286
|
const update_errors = []
|
|
286
287
|
|
|
287
288
|
for (const r of safe_to_update) {
|
|
288
|
-
const update_spinner = !
|
|
289
|
+
const update_spinner = !json_mode ? create_spinner(`Updating ${r.skill}…`) : null
|
|
289
290
|
const [errors, result] = await install(r.skill, options)
|
|
290
291
|
if (errors) {
|
|
291
292
|
update_spinner?.fail(`Failed to update ${r.skill}`)
|
|
@@ -298,51 +299,108 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
298
299
|
}
|
|
299
300
|
}
|
|
300
301
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
up_to_date_count: up_to_date.length,
|
|
308
|
-
drift_count: drifted.length,
|
|
309
|
-
updated,
|
|
310
|
-
skipped,
|
|
311
|
-
already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
|
|
312
|
-
errors: update_errors
|
|
302
|
+
if (!json_mode) {
|
|
303
|
+
if (skipped.length > 0) {
|
|
304
|
+
console.log()
|
|
305
|
+
print_warn(`Skipped ${skipped.length} skill(s) with local modifications:`)
|
|
306
|
+
for (const s of skipped) {
|
|
307
|
+
print_info(` ${s.skill}`)
|
|
313
308
|
}
|
|
314
|
-
|
|
315
|
-
|
|
309
|
+
print_hint(`Use ${code('happyskills pull <skill>')} to merge remote changes.`)
|
|
310
|
+
}
|
|
311
|
+
if (updated.length > 0) {
|
|
312
|
+
console.log()
|
|
313
|
+
print_success(`Updated ${updated.length} skill(s)${multi_scope ? ` in the ${scope} scope` : ''}.`)
|
|
314
|
+
}
|
|
315
|
+
if (update_errors.length > 0) {
|
|
316
|
+
console.log()
|
|
317
|
+
print_info(`${update_errors.length} skill(s) failed to update. Run ${code('happyskills check')} for details.`)
|
|
318
|
+
}
|
|
316
319
|
}
|
|
317
320
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
321
|
+
return { scope, data: {
|
|
322
|
+
results,
|
|
323
|
+
outdated_count: outdated.length,
|
|
324
|
+
up_to_date_count: up_to_date.length,
|
|
325
|
+
drift_count: drifted.length,
|
|
326
|
+
updated,
|
|
327
|
+
skipped,
|
|
328
|
+
already_up_to_date: up_to_date.map(r => ({ skill: r.skill, version: r.installed })),
|
|
329
|
+
errors: update_errors,
|
|
330
|
+
} }
|
|
331
|
+
}).then(([errors, result]) => {
|
|
332
|
+
if (errors) throw e('Update scope failed', errors)
|
|
333
|
+
return result
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const run = (args) => catch_errors('Update failed', async () => {
|
|
337
|
+
if (args.flags._show_help) {
|
|
338
|
+
print_help(HELP_TEXT)
|
|
339
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
325
340
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
341
|
+
|
|
342
|
+
const project_root = find_project_root()
|
|
343
|
+
const target_skill = args._[0]
|
|
344
|
+
const update_all = args.flags.all || false
|
|
345
|
+
const force = args.flags.force || false
|
|
346
|
+
const auto_yes = args.flags.yes || false
|
|
347
|
+
const is_global = args.flags.global || false
|
|
348
|
+
const all_scopes = args.flags['all-scopes'] || false
|
|
349
|
+
const json_mode = args.flags.json || false
|
|
350
|
+
|
|
351
|
+
if (!target_skill && !update_all) {
|
|
352
|
+
throw new UsageError("Specify a skill to update or use --all (e.g., 'happyskills update acme/deploy-aws' or 'happyskills update --all').")
|
|
329
353
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
354
|
+
|
|
355
|
+
// --all-scopes overrides -g and runs project-local first, then global. The
|
|
356
|
+
// scopes run sequentially — these are writes, and a shared global mutation
|
|
357
|
+
// should not race a project one.
|
|
358
|
+
const scope_flags = all_scopes ? [false, true] : [is_global]
|
|
359
|
+
const ctx = { project_root, target_skill, force, auto_yes, json_mode, agents_flag: args.flags.agents, multi_scope: all_scopes }
|
|
360
|
+
|
|
361
|
+
const scope_results = []
|
|
362
|
+
for (const g of scope_flags) {
|
|
363
|
+
scope_results.push(await run_scope(g, ctx))
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!all_scopes) {
|
|
367
|
+
// Single scope: emit exactly the historical per-scope data payload.
|
|
368
|
+
if (json_mode) print_json({ data: scope_results[0].data })
|
|
369
|
+
return
|
|
333
370
|
}
|
|
371
|
+
|
|
372
|
+
// --all-scopes: per-scope results plus convenience aggregates. A skill
|
|
373
|
+
// installed in both scopes appears once under each scope's result.
|
|
374
|
+
const count_updated = (d) => d.updated?.length || 0
|
|
375
|
+
if (json_mode) {
|
|
376
|
+
const scopes = scope_results.map(r => ({ scope: r.scope, ...r.data }))
|
|
377
|
+
print_json({ data: {
|
|
378
|
+
all_scopes: true,
|
|
379
|
+
scopes,
|
|
380
|
+
updated_count: scope_results.reduce((n, r) => n + count_updated(r.data), 0),
|
|
381
|
+
outdated_count: scope_results.reduce((n, r) => n + (r.data.outdated_count || 0), 0),
|
|
382
|
+
} })
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const total_updated = scope_results.reduce((n, r) => n + count_updated(r.data), 0)
|
|
387
|
+
console.log()
|
|
388
|
+
print_success(total_updated > 0
|
|
389
|
+
? `Done — updated ${total_updated} skill(s) across the local and global scopes.`
|
|
390
|
+
: 'Done — everything is already up to date across both scopes.')
|
|
334
391
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
335
392
|
|
|
336
393
|
const schema = {
|
|
337
394
|
name: 'update',
|
|
338
395
|
audience: 'consumer',
|
|
339
|
-
purpose: 'Bring installed skills up to date by batch-checking the registry and re-installing only outdated skills.',
|
|
396
|
+
purpose: 'Bring installed skills up to date by batch-checking the registry and re-installing only outdated skills. With --all-scopes, updates project-local and global skills in one run and reports per-scope results.',
|
|
340
397
|
mutation: true,
|
|
341
398
|
interactive_in_text_mode: true,
|
|
342
399
|
input: {
|
|
343
400
|
positional: [{ name: 'skill', required: false, type: 'string', pattern: '<owner>/<name>' }],
|
|
344
401
|
flags: [
|
|
345
402
|
{ name: 'all', type: 'boolean', default: false },
|
|
403
|
+
{ name: 'all-scopes', type: 'boolean', default: false, description: 'Update both project-local and global skills; reports per-scope results. Overrides --global.' },
|
|
346
404
|
{ name: 'force', type: 'boolean', default: false },
|
|
347
405
|
{ name: 'global', type: 'boolean', default: false },
|
|
348
406
|
{ name: 'yes', type: 'boolean', default: false },
|
|
@@ -352,6 +410,7 @@ const schema = {
|
|
|
352
410
|
},
|
|
353
411
|
output: {
|
|
354
412
|
data_shape: {
|
|
413
|
+
// Single scope (default / -g): the historical payload.
|
|
355
414
|
results: 'array<{ skill: string, installed: string, latest: string, status: string }>',
|
|
356
415
|
outdated_count: 'number',
|
|
357
416
|
up_to_date_count: 'number',
|
|
@@ -360,7 +419,8 @@ const schema = {
|
|
|
360
419
|
skipped: 'array<{ skill: string, reason: string, suggestion: string }>',
|
|
361
420
|
already_up_to_date: 'array<{ skill: string, version: string }>',
|
|
362
421
|
symlink_repairs: 'array',
|
|
363
|
-
errors: 'array<{ skill: string, message: string }>'
|
|
422
|
+
errors: 'array<{ skill: string, message: string }>',
|
|
423
|
+
// With --all-scopes: { all_scopes: true, scopes: array<{ scope, ...the single-scope payload above }>, updated_count, outdated_count }
|
|
364
424
|
}
|
|
365
425
|
},
|
|
366
426
|
errors: [
|
|
@@ -368,7 +428,8 @@ const schema = {
|
|
|
368
428
|
],
|
|
369
429
|
examples: [
|
|
370
430
|
'happyskills update acme/deploy-aws',
|
|
371
|
-
'happyskills up --all -y --json'
|
|
431
|
+
'happyskills up --all -y --json',
|
|
432
|
+
'happyskills up --all --all-scopes -y --json'
|
|
372
433
|
]
|
|
373
434
|
}
|
|
374
435
|
|
package/src/index.js
CHANGED
|
@@ -237,6 +237,29 @@ const run = (argv) => {
|
|
|
237
237
|
}
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
// Skill-drift awareness — the registry-side counterpart of the npm self-check
|
|
241
|
+
// above, for INSTALLED skills. Unlike npx (which always pulls the latest CLI),
|
|
242
|
+
// installed skills are pinned in the lock by design, so the only honest mechanism
|
|
243
|
+
// is passive notification + opt-in update. The verdict is a synchronous read of a
|
|
244
|
+
// 24h cache (zero latency); a stale cache fires a background refresh for next time.
|
|
245
|
+
// It ALWAYS spans both the project-local and the global lock, so a globally-installed
|
|
246
|
+
// constellation is surfaced even during project-scoped work. Two faces, one verdict:
|
|
247
|
+
// a stderr nudge for the human (text mode) and a warnings[] entry on every command's
|
|
248
|
+
// envelope for the agent (set_skill_drift → build_envelope). Suppressed for
|
|
249
|
+
// self-update only (consistent with the npm banner).
|
|
250
|
+
if (resolved !== 'self-update') {
|
|
251
|
+
const { check_skills_for_update, format_skill_drift } = require('./utils/skill_update_check')
|
|
252
|
+
const { find_project_root } = require('./config/paths')
|
|
253
|
+
const { set_skill_drift } = require('./ui/envelope')
|
|
254
|
+
const drift = check_skills_for_update({ project_root: find_project_root() })
|
|
255
|
+
set_skill_drift(drift)
|
|
256
|
+
if (!args.flags.json && drift) {
|
|
257
|
+
process.stderr.write('\n')
|
|
258
|
+
process.stderr.write(yellow(` ${format_skill_drift(drift)}\n`))
|
|
259
|
+
process.stderr.write('\n')
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
240
263
|
const command_module = require(`./commands/${resolved}`)
|
|
241
264
|
const result = command_module.run(args)
|
|
242
265
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Integration tests for `happyskills list --all-scopes`.
|
|
4
|
+
*
|
|
5
|
+
* `list` and `list -g` each show ONE scope (project or global). `--all-scopes`
|
|
6
|
+
* merges both into a single view, tagging every entry with:
|
|
7
|
+
* - `scope` → "local" (project) | "global" (~/.agents)
|
|
8
|
+
* - `native` → true for HappySkills-managed/draft skills, false for
|
|
9
|
+
* manually-added foreign skills (external / agent-orphan)
|
|
10
|
+
*
|
|
11
|
+
* The global scope is isolated per-test by pointing $HOME at a temp dir — Node's
|
|
12
|
+
* os.homedir() honours $HOME on POSIX, and paths.js derives ~/.agents from it.
|
|
13
|
+
*
|
|
14
|
+
* Guards:
|
|
15
|
+
* - --all-scopes returns `data.skills` as an ARRAY carrying both scopes.
|
|
16
|
+
* - every entry carries the right `scope` + `native` flags.
|
|
17
|
+
* - foreign (no skill.json) skills land in `external` with native:false.
|
|
18
|
+
* - the default (single-scope) shape is unchanged: `data.skills` is an OBJECT
|
|
19
|
+
* and a global-only skill does NOT leak into the project listing.
|
|
20
|
+
*/
|
|
21
|
+
const { describe, it } = require('node:test')
|
|
22
|
+
const assert = require('node:assert/strict')
|
|
23
|
+
const { spawnSync } = require('child_process')
|
|
24
|
+
const fs = require('fs')
|
|
25
|
+
const os = require('os')
|
|
26
|
+
const path = require('path')
|
|
27
|
+
const { parse_envelope, assert_success_envelope } = require('../schema/envelope_test_helpers')
|
|
28
|
+
|
|
29
|
+
const CLI = path.resolve(__dirname, '../../bin/happyskills.js')
|
|
30
|
+
const NODE = process.execPath
|
|
31
|
+
|
|
32
|
+
const make_tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `happyskills-${tag}-`))
|
|
33
|
+
|
|
34
|
+
const run = (args, opts) => {
|
|
35
|
+
const has_mode_flag = args.includes('--json') || args.includes('--text')
|
|
36
|
+
const final_args = has_mode_flag ? args : [...args, '--text']
|
|
37
|
+
const result = spawnSync(NODE, [CLI, ...final_args], {
|
|
38
|
+
env: {
|
|
39
|
+
...process.env,
|
|
40
|
+
NO_COLOR: '1', HAPPYSKILLS_ENVELOPE_STRICT: '1',
|
|
41
|
+
HAPPYSKILLS_API_URL: 'http://localhost:0',
|
|
42
|
+
CI: '',
|
|
43
|
+
...(opts?.env || {}),
|
|
44
|
+
},
|
|
45
|
+
encoding: 'utf-8',
|
|
46
|
+
timeout: 10000,
|
|
47
|
+
cwd: opts?.cwd,
|
|
48
|
+
})
|
|
49
|
+
return { stdout: result.stdout || '', stderr: result.stderr || '', code: result.status }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parse_json = (stdout, label) => {
|
|
53
|
+
const env = parse_envelope(stdout, label)
|
|
54
|
+
assert_success_envelope(env, label)
|
|
55
|
+
return env
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Write a HappySkills-shaped (native) skill onto disk under <skills_base>/<short>.
|
|
59
|
+
const write_native_skill = (skills_base, short, version) => {
|
|
60
|
+
const dir = path.join(skills_base, short)
|
|
61
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
62
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${short}\ndescription: test skill\n---\nBody\n`)
|
|
63
|
+
fs.writeFileSync(path.join(dir, 'skill.json'), JSON.stringify({ name: short, version, type: 'skill', description: 'test skill' }, null, '\t'))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Write a foreign skill (SKILL.md, NO skill.json) → classified external/native:false.
|
|
67
|
+
const write_foreign_skill = (skills_base, short) => {
|
|
68
|
+
const dir = path.join(skills_base, short)
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
70
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\nname: ${short}\ndescription: foreign skill\n---\nBody\n`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const write_lock = (lock_path, entries) => {
|
|
74
|
+
const skills = {}
|
|
75
|
+
for (const [full, version] of entries) {
|
|
76
|
+
skills[full] = {
|
|
77
|
+
version, type: 'skill', ref: `refs/tags/v${version}`, commit: 'c',
|
|
78
|
+
integrity: null, base_commit: 'c', base_integrity: null,
|
|
79
|
+
requested_by: ['__root__'], dependencies: {},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
fs.writeFileSync(lock_path, JSON.stringify({ lockVersion: 2, generatedAt: new Date().toISOString(), skills }, null, '\t'))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Scaffold both scopes: a project root (cwd) and an isolated home ($HOME).
|
|
86
|
+
const scaffold = () => {
|
|
87
|
+
const project_root = make_tmp('list-proj')
|
|
88
|
+
const home_dir = make_tmp('list-home')
|
|
89
|
+
|
|
90
|
+
// Project scope: one managed native skill + one foreign skill.
|
|
91
|
+
const proj_skills = path.join(project_root, '.agents', 'skills')
|
|
92
|
+
write_native_skill(proj_skills, 'local-skill', '1.0.0')
|
|
93
|
+
write_foreign_skill(proj_skills, 'foreign-local')
|
|
94
|
+
write_lock(path.join(project_root, 'skills-lock.json'), [['acme/local-skill', '1.0.0']])
|
|
95
|
+
|
|
96
|
+
// Global scope: one managed native skill (lock lives at ~/.agents/skills-lock.json).
|
|
97
|
+
const global_skills = path.join(home_dir, '.agents', 'skills')
|
|
98
|
+
write_native_skill(global_skills, 'global-skill', '2.1.0')
|
|
99
|
+
write_lock(path.join(home_dir, '.agents', 'skills-lock.json'), [['acme/global-skill', '2.1.0']])
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
project_root,
|
|
103
|
+
home_dir,
|
|
104
|
+
cleanup: () => {
|
|
105
|
+
fs.rmSync(project_root, { recursive: true, force: true })
|
|
106
|
+
fs.rmSync(home_dir, { recursive: true, force: true })
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('list --all-scopes', () => {
|
|
112
|
+
it('merges project + global skills into an array, each tagged with scope + native', () => {
|
|
113
|
+
const { project_root, home_dir, cleanup } = scaffold()
|
|
114
|
+
try {
|
|
115
|
+
const { code, stdout } = run(['list', '--all-scopes', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
116
|
+
assert.strictEqual(code, 0, 'list --all-scopes should exit 0')
|
|
117
|
+
const out = parse_json(stdout, 'list --all-scopes --json')
|
|
118
|
+
|
|
119
|
+
assert.ok(Array.isArray(out.data.skills), 'data.skills must be an ARRAY in --all-scopes mode')
|
|
120
|
+
|
|
121
|
+
const local = out.data.skills.find(s => s.name === 'acme/local-skill')
|
|
122
|
+
assert.ok(local, 'project-local managed skill must be present')
|
|
123
|
+
assert.strictEqual(local.scope, 'local')
|
|
124
|
+
assert.strictEqual(local.native, true)
|
|
125
|
+
assert.strictEqual(local.version, '1.0.0')
|
|
126
|
+
|
|
127
|
+
const global = out.data.skills.find(s => s.name === 'acme/global-skill')
|
|
128
|
+
assert.ok(global, 'global managed skill must be present in the SAME view')
|
|
129
|
+
assert.strictEqual(global.scope, 'global')
|
|
130
|
+
assert.strictEqual(global.native, true)
|
|
131
|
+
assert.strictEqual(global.version, '2.1.0')
|
|
132
|
+
} finally { cleanup() }
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('classifies a foreign (no skill.json) skill as external + native:false, scoped local', () => {
|
|
136
|
+
const { project_root, home_dir, cleanup } = scaffold()
|
|
137
|
+
try {
|
|
138
|
+
const { stdout } = run(['list', '--all-scopes', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
139
|
+
const out = parse_json(stdout, 'list --all-scopes --json external')
|
|
140
|
+
assert.ok(Array.isArray(out.data.external))
|
|
141
|
+
const foreign = out.data.external.find(s => s.name === 'foreign-local')
|
|
142
|
+
assert.ok(foreign, 'foreign skill must appear under data.external')
|
|
143
|
+
assert.strictEqual(foreign.scope, 'local')
|
|
144
|
+
assert.strictEqual(foreign.native, false)
|
|
145
|
+
} finally { cleanup() }
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('default (single-scope) list keeps the object shape and does NOT leak global skills', () => {
|
|
149
|
+
const { project_root, home_dir, cleanup } = scaffold()
|
|
150
|
+
try {
|
|
151
|
+
const { stdout } = run(['list', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
152
|
+
const out = parse_json(stdout, 'list --json default')
|
|
153
|
+
assert.ok(!Array.isArray(out.data.skills) && typeof out.data.skills === 'object', 'default data.skills must remain an OBJECT')
|
|
154
|
+
assert.ok(out.data.skills['acme/local-skill'], 'project skill present')
|
|
155
|
+
assert.ok(!out.data.skills['acme/global-skill'], 'global skill must NOT appear in the project-scoped listing')
|
|
156
|
+
// Backward-compat: no scope/native fields injected in single-scope mode.
|
|
157
|
+
assert.strictEqual(out.data.skills['acme/local-skill'].scope, undefined)
|
|
158
|
+
assert.strictEqual(out.data.skills['acme/local-skill'].native, undefined)
|
|
159
|
+
} finally { cleanup() }
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('list -g shows only the global scope (object shape)', () => {
|
|
163
|
+
const { project_root, home_dir, cleanup } = scaffold()
|
|
164
|
+
try {
|
|
165
|
+
const { stdout } = run(['list', '-g', '--json'], { cwd: project_root, env: { HOME: home_dir } })
|
|
166
|
+
const out = parse_json(stdout, 'list -g --json')
|
|
167
|
+
assert.ok(out.data.skills['acme/global-skill'], 'global skill present under -g')
|
|
168
|
+
assert.ok(!out.data.skills['acme/local-skill'], 'project skill must NOT appear under -g')
|
|
169
|
+
} finally { cleanup() }
|
|
170
|
+
})
|
|
171
|
+
})
|