happyskills 0.30.0 → 0.31.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.
@@ -12,7 +12,7 @@ const fs = require('fs')
12
12
  // ─────────────────────────────────────────────────────────────────────────────
13
13
 
14
14
  const { save_token, load_token, clear_token, require_token } = require('./token_store')
15
- const { AuthError } = require('../utils/errors')
15
+ const { AuthError, NetworkError } = require('../utils/errors')
16
16
 
17
17
  // ─── helpers ──────────────────────────────────────────────────────────────────
18
18
 
@@ -200,3 +200,199 @@ describe('require_token', () => {
200
200
  })
201
201
  })
202
202
  })
203
+
204
+ // ─── token refresh behavior ─────────────────────────────────────────────────
205
+ //
206
+ // _try_refresh is internal, but its behavior is observable through load_token:
207
+ // when the id_token is expired and a refresh_token exists, load_token calls
208
+ // _try_refresh which calls require('../api/auth').refresh(). We mock that
209
+ // module in require.cache to control the refresh outcome.
210
+ // ─────────────────────────────────────────────────────────────────────────────
211
+
212
+ describe('load_token — refresh behavior', () => {
213
+ const auth_path = require.resolve('../api/auth')
214
+ let orig_auth_cache
215
+
216
+ const mock_refresh = (fn) => {
217
+ orig_auth_cache = require.cache[auth_path]
218
+ require.cache[auth_path] = {
219
+ id: auth_path,
220
+ filename: auth_path,
221
+ loaded: true,
222
+ exports: { refresh: fn }
223
+ }
224
+ }
225
+
226
+ const restore_refresh = () => {
227
+ if (orig_auth_cache) require.cache[auth_path] = orig_auth_cache
228
+ else delete require.cache[auth_path]
229
+ }
230
+
231
+ const write_expired_token = (dir, overrides = {}) => {
232
+ const token = {
233
+ id_token: 'expired-id-token',
234
+ access_token: 'expired-access-token',
235
+ refresh_token: 'valid-refresh-token',
236
+ expires_in: 1,
237
+ stored_at: new Date(Date.now() - 5000).toISOString(),
238
+ ...overrides
239
+ }
240
+ fs.mkdirSync(path.join(dir, 'happyskills'), { recursive: true })
241
+ fs.writeFileSync(creds_file(dir), JSON.stringify(token), { mode: 0o600 })
242
+ return token
243
+ }
244
+
245
+ it('returns refreshed token when refresh succeeds', async () => {
246
+ await with_tmp(async (dir) => {
247
+ write_expired_token(dir)
248
+ mock_refresh(async () => [null, {
249
+ id_token: 'new-id-token',
250
+ access_token: 'new-access-token',
251
+ expires_in: 3600
252
+ }])
253
+ try {
254
+ const [err, loaded] = await load_token()
255
+ assert.strictEqual(err, null)
256
+ assert.ok(loaded, 'should return refreshed token')
257
+ assert.strictEqual(loaded.id_token, 'new-id-token')
258
+ assert.strictEqual(loaded.access_token, 'new-access-token')
259
+ assert.strictEqual(loaded.refresh_token, 'valid-refresh-token', 'original refresh_token preserved')
260
+ assert.ok(loaded.stored_at, 'stored_at should be set')
261
+ } finally {
262
+ restore_refresh()
263
+ }
264
+ })
265
+ })
266
+
267
+ it('saves refreshed tokens to disk after successful refresh', async () => {
268
+ await with_tmp(async (dir) => {
269
+ write_expired_token(dir)
270
+ mock_refresh(async () => [null, {
271
+ id_token: 'refreshed-on-disk',
272
+ access_token: 'new-access',
273
+ expires_in: 7200
274
+ }])
275
+ try {
276
+ await load_token()
277
+ const on_disk = JSON.parse(fs.readFileSync(creds_file(dir), 'utf-8'))
278
+ assert.strictEqual(on_disk.id_token, 'refreshed-on-disk')
279
+ assert.strictEqual(on_disk.refresh_token, 'valid-refresh-token', 'refresh_token preserved on disk')
280
+ assert.strictEqual(on_disk.expires_in, 7200)
281
+ assert.ok(on_disk.stored_at, 'stored_at should be updated on disk')
282
+ } finally {
283
+ restore_refresh()
284
+ }
285
+ })
286
+ })
287
+
288
+ it('preserves credentials file on transient refresh failure (NetworkError)', async () => {
289
+ await with_tmp(async (dir) => {
290
+ write_expired_token(dir)
291
+ mock_refresh(async () => [[new NetworkError('connection failed')], null])
292
+ try {
293
+ const [err, loaded] = await load_token()
294
+ assert.strictEqual(err, null)
295
+ assert.strictEqual(loaded, null, 'should return null on failed refresh')
296
+ assert.ok(fs.existsSync(creds_file(dir)), 'credentials file should be preserved for retry')
297
+ // Verify the refresh_token is still intact on disk
298
+ const on_disk = JSON.parse(fs.readFileSync(creds_file(dir), 'utf-8'))
299
+ assert.strictEqual(on_disk.refresh_token, 'valid-refresh-token')
300
+ } finally {
301
+ restore_refresh()
302
+ }
303
+ })
304
+ })
305
+
306
+ it('preserves credentials file on server error (ApiError)', async () => {
307
+ await with_tmp(async (dir) => {
308
+ write_expired_token(dir)
309
+ const { ApiError } = require('../utils/errors')
310
+ mock_refresh(async () => [[new ApiError('Internal server error', 500, 'INTERNAL_ERROR')], null])
311
+ try {
312
+ const [err, loaded] = await load_token()
313
+ assert.strictEqual(err, null)
314
+ assert.strictEqual(loaded, null)
315
+ assert.ok(fs.existsSync(creds_file(dir)), 'credentials file should be preserved on server error')
316
+ } finally {
317
+ restore_refresh()
318
+ }
319
+ })
320
+ })
321
+
322
+ it('deletes credentials file on permanent refresh failure (AuthError)', async () => {
323
+ await with_tmp(async (dir) => {
324
+ write_expired_token(dir)
325
+ mock_refresh(async () => [[new AuthError('token revoked')], null])
326
+ try {
327
+ const [err, loaded] = await load_token()
328
+ assert.strictEqual(err, null)
329
+ assert.strictEqual(loaded, null)
330
+ assert.ok(!fs.existsSync(creds_file(dir)), 'credentials file should be deleted on permanent failure')
331
+ } finally {
332
+ restore_refresh()
333
+ }
334
+ })
335
+ })
336
+
337
+ it('does not retry on permanent failure', async () => {
338
+ await with_tmp(async (dir) => {
339
+ write_expired_token(dir)
340
+ let call_count = 0
341
+ mock_refresh(async () => {
342
+ call_count++
343
+ return [[new AuthError('token expired')], null]
344
+ })
345
+ try {
346
+ await load_token()
347
+ assert.strictEqual(call_count, 1, 'should not retry on permanent failure')
348
+ } finally {
349
+ restore_refresh()
350
+ }
351
+ })
352
+ })
353
+
354
+ it('retries once on transient failure then succeeds', async () => {
355
+ await with_tmp(async (dir) => {
356
+ write_expired_token(dir)
357
+ let call_count = 0
358
+ mock_refresh(async () => {
359
+ call_count++
360
+ if (call_count === 1) return [[new NetworkError('cold start')], null]
361
+ return [null, {
362
+ id_token: 'retried-id-token',
363
+ access_token: 'retried-access-token',
364
+ expires_in: 3600
365
+ }]
366
+ })
367
+ try {
368
+ const [err, loaded] = await load_token()
369
+ assert.strictEqual(err, null)
370
+ assert.ok(loaded, 'should return refreshed token after retry')
371
+ assert.strictEqual(loaded.id_token, 'retried-id-token')
372
+ assert.strictEqual(call_count, 2, 'refresh should have been called twice')
373
+ } finally {
374
+ restore_refresh()
375
+ }
376
+ })
377
+ })
378
+
379
+ it('retries once on transient failure then gives up', async () => {
380
+ await with_tmp(async (dir) => {
381
+ write_expired_token(dir)
382
+ let call_count = 0
383
+ mock_refresh(async () => {
384
+ call_count++
385
+ return [[new NetworkError('still failing')], null]
386
+ })
387
+ try {
388
+ const [err, loaded] = await load_token()
389
+ assert.strictEqual(err, null)
390
+ assert.strictEqual(loaded, null)
391
+ assert.strictEqual(call_count, 2, 'should have retried once')
392
+ assert.ok(fs.existsSync(creds_file(dir)), 'credentials file preserved after exhausting retries')
393
+ } finally {
394
+ restore_refresh()
395
+ }
396
+ })
397
+ })
398
+ })
@@ -0,0 +1,105 @@
1
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
3
+ const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
4
+ const { resolve_agents, unlink_from_agents } = require('../agents')
5
+ const { is_skill_enabled } = require('../agents/status')
6
+ const { file_exists } = require('../utils/fs')
7
+ const { print_help, print_json, print_success, print_warn, print_info } = require('../ui/output')
8
+ const { exit_with_error, UsageError } = require('../utils/errors')
9
+ const { EXIT_CODES } = require('../constants')
10
+
11
+ const HELP_TEXT = `Usage: happyskills disable <skill> [skill2 ...] [options]
12
+
13
+ Disable one or more skills by removing their agent symlinks.
14
+ The skill files remain in .agents/skills/ and can be re-enabled at any time.
15
+
16
+ Arguments:
17
+ skill One or more skill names (owner/name or short name)
18
+
19
+ Options:
20
+ -g, --global Target globally installed skills
21
+ --agents <list> Target specific agents (comma-separated)
22
+ --json Output as JSON
23
+
24
+ Examples:
25
+ happyskills disable acme/deploy-aws
26
+ happyskills disable deploy-aws monitoring
27
+ happyskills disable acme/deploy-aws acme/monitoring -g`
28
+
29
+ /**
30
+ * Resolve a skill argument (owner/name or short name) to its full lock key and short name.
31
+ */
32
+ const resolve_skill_name = (arg, locked_skills) => {
33
+ if (arg.includes('/')) {
34
+ const short = arg.split('/')[1]
35
+ return locked_skills[arg] ? { full: arg, short } : null
36
+ }
37
+ // Search by short name
38
+ for (const key of Object.keys(locked_skills)) {
39
+ if (key.split('/')[1] === arg) {
40
+ return { full: key, short: arg }
41
+ }
42
+ }
43
+ return null
44
+ }
45
+
46
+ const run = (args) => catch_errors('Disable failed', async () => {
47
+ if (args.flags._show_help) {
48
+ print_help(HELP_TEXT)
49
+ return process.exit(EXIT_CODES.SUCCESS)
50
+ }
51
+
52
+ const skills_args = args._
53
+ if (skills_args.length === 0) {
54
+ throw new UsageError('Please specify one or more skills to disable (e.g., happyskills disable acme/deploy-aws).')
55
+ }
56
+
57
+ const is_global = args.flags.global || false
58
+ const project_root = find_project_root()
59
+
60
+ const [agents_err, agents_result] = await resolve_agents(args.flags.agents)
61
+ if (agents_err) throw e('Agent resolution failed', agents_err)
62
+ const { agents } = agents_result
63
+
64
+ const [, lock_data] = await read_lock(lock_root(is_global, project_root))
65
+ const locked_skills = get_all_locked_skills(lock_data)
66
+
67
+ const results = []
68
+
69
+ for (const arg of skills_args) {
70
+ const resolved = resolve_skill_name(arg, locked_skills)
71
+
72
+ if (!resolved) {
73
+ print_warn(`${arg} is not a HappySkills-managed skill — skipping`)
74
+ results.push({ skill: arg, status: 'not_found' })
75
+ continue
76
+ }
77
+
78
+ const { full, short } = resolved
79
+
80
+ // Check if already disabled
81
+ const [, enabled] = await is_skill_enabled(short, agents, is_global, project_root)
82
+ if (!enabled) {
83
+ print_warn(`${full} is already disabled`)
84
+ results.push({ skill: full, status: 'already_disabled' })
85
+ continue
86
+ }
87
+
88
+ // Remove symlinks from all agent folders
89
+ const [link_err] = await unlink_from_agents(short, agents, { global: is_global, project_root })
90
+ if (link_err) {
91
+ print_warn(`Failed to disable ${full}`)
92
+ results.push({ skill: full, status: 'error', message: link_err[0]?.message })
93
+ continue
94
+ }
95
+
96
+ print_success(`Disabled ${full}`)
97
+ results.push({ skill: full, status: 'disabled' })
98
+ }
99
+
100
+ if (args.flags.json) {
101
+ print_json({ data: { results } })
102
+ }
103
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
104
+
105
+ module.exports = { run }
@@ -0,0 +1,114 @@
1
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
3
+ const { find_project_root, lock_root, skills_dir, skill_install_dir } = require('../config/paths')
4
+ const { resolve_agents, link_to_agents } = require('../agents')
5
+ const { is_skill_enabled } = require('../agents/status')
6
+ const { file_exists } = require('../utils/fs')
7
+ const { print_help, print_json, print_success, print_warn, print_info } = require('../ui/output')
8
+ const { exit_with_error, UsageError } = require('../utils/errors')
9
+ const { EXIT_CODES } = require('../constants')
10
+
11
+ const HELP_TEXT = `Usage: happyskills enable <skill> [skill2 ...] [options]
12
+
13
+ Enable one or more previously disabled skills by restoring their agent symlinks.
14
+
15
+ Arguments:
16
+ skill One or more skill names (owner/name or short name)
17
+
18
+ Options:
19
+ -g, --global Target globally installed skills
20
+ --agents <list> Target specific agents (comma-separated)
21
+ --json Output as JSON
22
+
23
+ Examples:
24
+ happyskills enable acme/deploy-aws
25
+ happyskills enable deploy-aws monitoring
26
+ happyskills enable acme/deploy-aws acme/monitoring -g`
27
+
28
+ /**
29
+ * Resolve a skill argument (owner/name or short name) to its full lock key and short name.
30
+ */
31
+ const resolve_skill_name = (arg, locked_skills) => {
32
+ if (arg.includes('/')) {
33
+ const short = arg.split('/')[1]
34
+ return locked_skills[arg] ? { full: arg, short } : null
35
+ }
36
+ // Search by short name
37
+ for (const key of Object.keys(locked_skills)) {
38
+ if (key.split('/')[1] === arg) {
39
+ return { full: key, short: arg }
40
+ }
41
+ }
42
+ return null
43
+ }
44
+
45
+ const run = (args) => catch_errors('Enable failed', async () => {
46
+ if (args.flags._show_help) {
47
+ print_help(HELP_TEXT)
48
+ return process.exit(EXIT_CODES.SUCCESS)
49
+ }
50
+
51
+ const skills_args = args._
52
+ if (skills_args.length === 0) {
53
+ throw new UsageError('Please specify one or more skills to enable (e.g., happyskills enable acme/deploy-aws).')
54
+ }
55
+
56
+ const is_global = args.flags.global || false
57
+ const project_root = find_project_root()
58
+ const base_dir = skills_dir(is_global, project_root)
59
+
60
+ const [agents_err, agents_result] = await resolve_agents(args.flags.agents)
61
+ if (agents_err) throw e('Agent resolution failed', agents_err)
62
+ const { agents } = agents_result
63
+
64
+ const [, lock_data] = await read_lock(lock_root(is_global, project_root))
65
+ const locked_skills = get_all_locked_skills(lock_data)
66
+
67
+ const results = []
68
+
69
+ for (const arg of skills_args) {
70
+ const resolved = resolve_skill_name(arg, locked_skills)
71
+
72
+ if (!resolved) {
73
+ print_warn(`${arg} is not a HappySkills-managed skill — skipping`)
74
+ results.push({ skill: arg, status: 'not_found' })
75
+ continue
76
+ }
77
+
78
+ const { full, short } = resolved
79
+
80
+ // Verify the skill directory exists on disk
81
+ const dir = skill_install_dir(base_dir, short)
82
+ const [, exists] = await file_exists(dir)
83
+ if (!exists) {
84
+ print_warn(`${full} files not found on disk — run happyskills install to restore`)
85
+ results.push({ skill: full, status: 'missing' })
86
+ continue
87
+ }
88
+
89
+ // Check if already enabled
90
+ const [, enabled] = await is_skill_enabled(short, agents, is_global, project_root)
91
+ if (enabled) {
92
+ print_warn(`${full} is already enabled`)
93
+ results.push({ skill: full, status: 'already_enabled' })
94
+ continue
95
+ }
96
+
97
+ // Create symlinks to all agent folders
98
+ const [link_err] = await link_to_agents(dir, agents, { global: is_global, project_root, skill_name: short })
99
+ if (link_err) {
100
+ print_warn(`Failed to enable ${full}`)
101
+ results.push({ skill: full, status: 'error', message: link_err[0]?.message })
102
+ continue
103
+ }
104
+
105
+ print_success(`Enabled ${full}`)
106
+ results.push({ skill: full, status: 'enabled' })
107
+ }
108
+
109
+ if (args.flags.json) {
110
+ print_json({ data: { results } })
111
+ }
112
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
113
+
114
+ module.exports = { run }
@@ -4,7 +4,10 @@ const { skills_dir, skill_install_dir, find_project_root, lock_root } = require(
4
4
  const { file_exists, read_json } = require('../utils/fs')
5
5
  const { scan_skills_dir, scan_agent_orphan_skills } = require('../utils/skill_scanner')
6
6
  const { AGENTS } = require('../agents/registry')
7
+ const { resolve_agents } = require('../agents/detector')
8
+ const { get_skills_enabled_map } = require('../agents/status')
7
9
  const { print_help, print_table, print_json, print_info } = require('../ui/output')
10
+ const { green, yellow } = require('../ui/colors')
8
11
  const { exit_with_error } = require('../utils/errors')
9
12
  const { EXIT_CODES, SKILL_JSON, SKILL_TYPES } = require('../constants')
10
13
 
@@ -37,8 +40,16 @@ const run = (args) => catch_errors('List failed', async () => {
37
40
  const skills = get_all_locked_skills(lock_data)
38
41
  const managed_entries = Object.entries(skills)
39
42
 
43
+ // Resolve agents and build enabled/disabled map for managed skills
44
+ const [, agents_result] = await resolve_agents(args.flags.agents)
45
+ const agents = agents_result?.agents || []
46
+ const managed_short_names = managed_entries.map(([k]) => k.split('/')[1])
47
+ const [, enabled_map] = agents.length > 0
48
+ ? await get_skills_enabled_map(managed_short_names, agents, is_global, project_root)
49
+ : [null, new Map()]
50
+
40
51
  const [, disk_skills] = await scan_skills_dir(base_dir)
41
- const managed_names = new Set(managed_entries.map(([k]) => k.split('/')[1]))
52
+ const managed_names = new Set(managed_short_names)
42
53
  const external_skills = (disk_skills || []).filter(s => !managed_names.has(s.name))
43
54
 
44
55
  // Scan agent-specific dirs for skills placed directly in agent folders (not symlinked from .agents/skills/)
@@ -67,12 +78,14 @@ const run = (args) => catch_errors('List failed', async () => {
67
78
  if (args.flags.json) {
68
79
  const skills_map = {}
69
80
  for (const [name, data] of managed_entries) {
70
- const dir = skill_install_dir(base_dir, name.split('/')[1])
81
+ const short = name.split('/')[1]
82
+ const dir = skill_install_dir(base_dir, short)
71
83
  const [, exists] = await file_exists(dir)
72
84
  const status = exists ? 'installed' : 'missing'
73
85
  const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
74
86
  const type = await resolve_type(name, data)
75
- skills_map[name] = { version: data.version, type, source, status }
87
+ const enabled = enabled_map?.get(short) ?? true
88
+ skills_map[name] = { version: data.version, type, source, status, enabled }
76
89
  }
77
90
  const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
78
91
  const agent_orphan_list = orphan_skills.map(s => ({
@@ -86,25 +99,28 @@ const run = (args) => catch_errors('List failed', async () => {
86
99
 
87
100
  const rows = []
88
101
  for (const [name, data] of managed_entries) {
89
- const dir = skill_install_dir(base_dir, name.split('/')[1])
102
+ const short = name.split('/')[1]
103
+ const dir = skill_install_dir(base_dir, short)
90
104
  const [, exists] = await file_exists(dir)
91
105
  const status = exists ? 'installed' : 'missing'
92
106
  const source = data.requested_by?.includes('__root__') ? 'direct' : 'dep'
93
107
  const type = await resolve_type(name, data)
94
108
  const display_name = type === SKILL_TYPES.KIT ? `${name} [kit]` : name
95
- rows.push([display_name, data.version, source, status])
109
+ const enabled = enabled_map?.get(short) ?? true
110
+ const enabled_label = enabled ? green('enabled') : yellow('disabled')
111
+ rows.push([display_name, data.version, source, status, enabled_label])
96
112
  }
97
113
 
98
114
  for (const s of external_skills) {
99
- rows.push([s.name, '-', 'external', 'installed'])
115
+ rows.push([s.name, '-', 'external', 'installed', '-'])
100
116
  }
101
117
 
102
118
  for (const s of orphan_skills) {
103
119
  const agent_label = s.agents.map(a => a.name).join(', ')
104
- rows.push([s.name, '-', `agent-orphan (${agent_label})`, 'installed'])
120
+ rows.push([s.name, '-', `agent-orphan (${agent_label})`, 'installed', '-'])
105
121
  }
106
122
 
107
- print_table(['Skill', 'Version', 'Source', 'Status'], rows)
123
+ print_table(['Skill', 'Version', 'Source', 'Status', 'Enabled'], rows)
108
124
  }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
109
125
 
110
126
  module.exports = { run }
package/src/constants.js CHANGED
@@ -38,7 +38,9 @@ const COMMAND_ALIASES = {
38
38
  v: 'validate',
39
39
  del: 'delete',
40
40
  vis: 'visibility',
41
- grp: 'groups'
41
+ grp: 'groups',
42
+ on: 'enable',
43
+ off: 'disable'
42
44
  }
43
45
 
44
46
  const COMMANDS = [
@@ -68,7 +70,9 @@ const COMMANDS = [
68
70
  'config',
69
71
  'people',
70
72
  'groups',
71
- 'access'
73
+ 'access',
74
+ 'enable',
75
+ 'disable'
72
76
  ]
73
77
 
74
78
  module.exports = {
@@ -12,6 +12,7 @@ const { skills_dir, tmp_dir, skill_install_dir, lock_root } = require('../config
12
12
  const { ensure_dir, remove_dir, file_exists, read_json } = require('../utils/fs')
13
13
  const { SKILL_JSON } = require('../constants')
14
14
  const { resolve_agents, link_to_agents } = require('../agents')
15
+ const { is_skill_enabled } = require('../agents/status')
15
16
  const { create_spinner } = require('../ui/spinner')
16
17
  const { print_success, print_warn, print_info } = require('../ui/output')
17
18
 
@@ -95,6 +96,20 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
95
96
  return { skill, version: packages[0]?.version, no_op: true, installed: [], skipped, warnings: [], forced: [] }
96
97
  }
97
98
 
99
+ // Capture disabled state before modifying disk — disabled skills stay disabled on update
100
+ const disabled_skills = new Set()
101
+ if (lock_data && agents.length > 0) {
102
+ await Promise.all(packages_to_install.map(async (pkg) => {
103
+ const name = pkg.skill.split('/')[1]
104
+ const dir = skill_install_dir(base_dir, name)
105
+ const [, exists] = await file_exists(dir)
106
+ if (exists) {
107
+ const [, enabled] = await is_skill_enabled(name, agents, is_global, project_root)
108
+ if (!enabled) disabled_skills.add(name)
109
+ }
110
+ }))
111
+ }
112
+
98
113
  spinner.update(`Downloading ${packages_to_install.length} package(s)...`)
99
114
 
100
115
  const [cleanup_err] = await remove_dir(temp_dir)
@@ -150,14 +165,23 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
150
165
  }
151
166
 
152
167
  // Link to detected agents (non-fatal — warnings only)
168
+ // Skip linking for skills that were disabled before this install/update
153
169
  if (agents.length > 0) {
154
- spinner.update(`Linking to ${agents.length} agent(s)...`)
155
- for (const { pkg } of downloaded) {
156
- const name = pkg.skill.split('/')[1]
157
- const source = skill_install_dir(base_dir, name)
158
- const [link_errs] = await link_to_agents(source, agents, { global: is_global, project_root, skill_name: name })
159
- if (link_errs) {
160
- print_warn(`Warning: failed to link ${pkg.skill} to some agents`)
170
+ const to_link = downloaded.filter(({ pkg }) => !disabled_skills.has(pkg.skill.split('/')[1]))
171
+ if (to_link.length > 0) {
172
+ spinner.update(`Linking to ${agents.length} agent(s)...`)
173
+ for (const { pkg } of to_link) {
174
+ const name = pkg.skill.split('/')[1]
175
+ const source = skill_install_dir(base_dir, name)
176
+ const [link_errs] = await link_to_agents(source, agents, { global: is_global, project_root, skill_name: name })
177
+ if (link_errs) {
178
+ print_warn(`Warning: failed to link ${pkg.skill} to some agents`)
179
+ }
180
+ }
181
+ }
182
+ if (disabled_skills.size > 0) {
183
+ for (const name of disabled_skills) {
184
+ print_info(`${name} remains disabled — use happyskills enable to re-enable`)
161
185
  }
162
186
  }
163
187
  }
@@ -196,7 +220,8 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
196
220
 
197
221
  spinner.succeed(`Installed ${packages_to_install.length} package(s)`)
198
222
 
199
- if (agents.length > 0) {
223
+ const linked_count = downloaded.length - disabled_skills.size
224
+ if (agents.length > 0 && linked_count > 0) {
200
225
  print_info(`Linked to: ${agents.map(a => a.display_name).join(', ')}`)
201
226
  }
202
227
 
package/src/index.js CHANGED
@@ -105,6 +105,8 @@ Commands:
105
105
  people <sub> Manage workspace members (list, add, remove, role, search)
106
106
  groups <sub> Manage workspace groups (list, create, delete, show, add, remove, default)
107
107
  access <sub> Manage group skill access (list, grant, revoke, set)
108
+ enable <skill> [...] Enable disabled skills (alias: on)
109
+ disable <skill> [...] Disable skills without uninstalling (alias: off)
108
110
  login Authenticate with the registry
109
111
  logout Clear stored credentials
110
112
  whoami Show current user