happyskills 1.12.0 → 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.
@@ -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
- const run = (args) => catch_errors('Update failed', async () => {
66
- if (args.flags._show_help) {
67
- print_help(HELP_TEXT)
68
- return process.exit(EXIT_CODES.SUCCESS)
69
- }
70
-
71
- const project_root = find_project_root()
72
- const target_skill = args._[0]
73
- const update_all = args.flags.all || false
74
- const force = args.flags.force || false
75
- const auto_yes = args.flags.yes || false
76
- const is_global = args.flags.global || false
77
-
78
- if (!target_skill && !update_all) {
79
- throw new UsageError("Specify a skill to update or use --all (e.g., 'happyskills update acme/deploy-aws' or 'happyskills update --all').")
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(is_global, project_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(is_global, project_root)
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
- if (target_skill) {
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
- if (args.flags.json) {
98
- print_json({ data: { results: [], outdated_count: 0, up_to_date_count: 0, updated: [], already_up_to_date: [], errors: [] } })
99
- return
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
- const options = { global: is_global, fresh: true, force: true, agents: args.flags.agents || undefined, project_root }
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 = !args.flags.json ? create_spinner(`Re-installing ${name}…`) : null
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 (args.flags.json) {
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
- const spinner = !args.flags.json ? create_spinner('Checking for updates…') : null
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(args.flags.agents, { global: is_global, project_root })
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, is_global, project_root)
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 = !args.flags.json ? create_spinner('Verifying symlinks…') : null
203
- const [, repair_result] = await verify_and_repair_symlinks(skills_to_check, detected_agents, { global: is_global, project_root })
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 (args.flags.json) {
216
- print_json({ 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: [] } })
217
- return
218
- }
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'})`)
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 (!args.flags.json) {
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 && !args.flags.json) {
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: is_global, fresh: true, agents: args.flags.agents || undefined, project_root }
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 = !args.flags.json ? create_spinner(`Updating ${r.skill}…`) : null
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
- // Output
302
- if (args.flags.json) {
303
- print_json({
304
- data: {
305
- results,
306
- outdated_count: outdated.length,
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
- return
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
- if (skipped.length > 0) {
319
- console.log()
320
- print_warn(`Skipped ${skipped.length} skill(s) with local modifications:`)
321
- for (const s of skipped) {
322
- print_info(` ${s.skill}`)
323
- }
324
- print_hint(`Use ${code('happyskills pull <skill>')} to merge remote changes.`)
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
- if (updated.length > 0) {
327
- console.log()
328
- print_success(`Updated ${updated.length} skill(s).`)
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
- if (update_errors.length > 0) {
331
- console.log()
332
- print_info(`${update_errors.length} skill(s) failed to update. Run ${code('happyskills check')} for details.`)
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
 
@@ -74,11 +74,24 @@ const NEXT_STEP_BY_ERROR_CODE = Object.freeze({
74
74
  'A network error interrupted the request. Retry shortly.',
75
75
  { retry_after_seconds: 5, max_attempts: 3 }
76
76
  ),
77
- RATE_LIMITED: (msg, ctx = {}) => recovery(
78
- RETRY,
79
- 'Rate limit reached. Retry after the suggested interval.',
80
- { retry_after_seconds: ctx.retry_after_seconds || 30, max_attempts: 3 }
81
- ),
77
+ // An anonymous caller throttled on a per-IP bucket gets a much higher cap (and
78
+ // their own per-user budget) once signed in, so when the API flags `login_helps`
79
+ // we steer them to authenticate instead of just retrying. Authenticated trips
80
+ // (and the pre-login /auth/* limiter) fall through to the plain retry recovery.
81
+ RATE_LIMITED: (_msg, ctx = {}) => ctx.login_helps
82
+ ? recovery(
83
+ LOGIN,
84
+ 'You hit the anonymous rate limit. Sign in or create a free account for a much higher limit, then re-run.',
85
+ {
86
+ retry_after_seconds: ctx.retry_after_seconds || 30,
87
+ commands: ['npx happyskills login --browser --json'],
88
+ }
89
+ )
90
+ : recovery(
91
+ RETRY,
92
+ 'Rate limit reached. Retry after the suggested interval.',
93
+ { retry_after_seconds: ctx.retry_after_seconds || 30, max_attempts: 3 }
94
+ ),
82
95
  DB_UNAVAILABLE: () => recovery(
83
96
  RETRY,
84
97
  'The registry database is temporarily unavailable. Retry shortly.',
@@ -7,7 +7,7 @@
7
7
  const { describe, it } = require('node:test')
8
8
  const assert = require('node:assert/strict')
9
9
  const { next_step_for_error } = require('./next_step_by_error_code')
10
- const { DISCOVER_SCHEMA, ROUTING, kind_for_action } = require('./next_step_actions')
10
+ const { DISCOVER_SCHEMA, ROUTING, RECOVERY, LOGIN, RETRY, kind_for_action } = require('./next_step_actions')
11
11
 
12
12
  describe('next_step_by_error_code — schema discovery routing', () => {
13
13
  it('discover_schema is a routing-kind action', () => {
@@ -28,3 +28,19 @@ describe('next_step_by_error_code — schema discovery routing', () => {
28
28
  })
29
29
  }
30
30
  })
31
+
32
+ describe('next_step_by_error_code — RATE_LIMITED steers anonymous callers to sign in', () => {
33
+ it('login_helps → a LOGIN recovery that points at `happyskills login`', () => {
34
+ const ns = next_step_for_error('RATE_LIMITED', 'Too many requests.', { login_helps: true, retry_after_seconds: 42 })
35
+ assert.strictEqual(ns.kind, RECOVERY)
36
+ assert.strictEqual(ns.action, LOGIN)
37
+ assert.ok(ns.context.commands.some(c => c.includes('login')), 'must point at the login command')
38
+ assert.strictEqual(ns.context.retry_after_seconds, 42)
39
+ })
40
+
41
+ it('without login_helps → a plain RETRY recovery (authenticated / auth-path trips)', () => {
42
+ const ns = next_step_for_error('RATE_LIMITED', 'Too many requests.', { retry_after_seconds: 30 })
43
+ assert.strictEqual(ns.kind, RECOVERY)
44
+ assert.strictEqual(ns.action, RETRY)
45
+ })
46
+ })
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