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.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/agents/index.js +3 -1
- package/src/agents/linker.test.js +202 -0
- package/src/agents/status.js +42 -0
- package/src/agents/status.test.js +127 -0
- package/src/auth/token_store.js +44 -13
- package/src/auth/token_store.test.js +197 -1
- package/src/commands/disable.js +105 -0
- package/src/commands/enable.js +114 -0
- package/src/commands/list.js +24 -8
- package/src/constants.js +6 -2
- package/src/engine/installer.js +33 -8
- package/src/index.js +2 -0
- package/src/integration/cli.test.js +261 -0
|
@@ -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 }
|
package/src/commands/list.js
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|
package/src/engine/installer.js
CHANGED
|
@@ -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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|