happyskills 0.30.1 → 0.31.1
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 +22 -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/commands/check.js +28 -20
- package/src/commands/disable.js +105 -0
- package/src/commands/enable.js +114 -0
- package/src/commands/list.js +24 -8
- package/src/commands/update.js +4 -5
- 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
- package/src/lock/reader.js +8 -2
- package/src/lock/reader.test.js +37 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.31.1] - 2026-04-07
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Fix `check` and `update --all` ignoring kit dependencies and transitive dependencies — previously only root-level skills were checked, hiding available updates for skills installed via kits or as dependencies of other skills
|
|
14
|
+
- Fix pre-existing test failure in lock reader caused by lazy `require` of `print_warn` breaking under Jest environment teardown
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Add `via` field to `check` and `update` JSON output showing which parent skill or kit pulled in each dependency (`null` for directly installed skills)
|
|
18
|
+
- Add "Via" column to `check` table output (only shown when dependencies are present)
|
|
19
|
+
|
|
20
|
+
## [0.31.0] - 2026-04-06
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Add `enable` command (alias: `on`) to restore agent symlinks for previously disabled skills
|
|
24
|
+
- Add `disable` command (alias: `off`) to remove agent symlinks without uninstalling — physical files and lock entries are preserved
|
|
25
|
+
- Add Enabled column to `list` output showing whether each managed skill has active agent symlinks
|
|
26
|
+
- Add `enabled` boolean field to `list --json` output for managed skills
|
|
27
|
+
- Add `--agents` flag to `list` for controlling which agents are checked for enabled/disabled detection
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- Install, update, and refresh now respect disabled state — disabled skills are not re-linked after updates
|
|
31
|
+
|
|
10
32
|
## [0.30.1] - 2026-04-05
|
|
11
33
|
|
|
12
34
|
### Fixed
|
package/package.json
CHANGED
package/src/agents/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
const { AGENTS, get_agent, get_all_agent_ids } = require('./registry')
|
|
2
2
|
const { detect_agents, resolve_agents } = require('./detector')
|
|
3
3
|
const { link_to_agents, unlink_from_agents, is_symlink } = require('./linker')
|
|
4
|
+
const { is_skill_enabled, get_skills_enabled_map } = require('./status')
|
|
4
5
|
|
|
5
6
|
module.exports = {
|
|
6
7
|
AGENTS, get_agent, get_all_agent_ids,
|
|
7
8
|
detect_agents, resolve_agents,
|
|
8
|
-
link_to_agents, unlink_from_agents, is_symlink
|
|
9
|
+
link_to_agents, unlink_from_agents, is_symlink,
|
|
10
|
+
is_skill_enabled, get_skills_enabled_map
|
|
9
11
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const os = require('os')
|
|
7
|
+
|
|
8
|
+
const { link_to_agents, unlink_from_agents, is_symlink } = require('./linker')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-linker-test-'))
|
|
11
|
+
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
12
|
+
|
|
13
|
+
const make_agent = (id, skills_dir_rel) => ({
|
|
14
|
+
id,
|
|
15
|
+
display_name: id,
|
|
16
|
+
skills_dir: skills_dir_rel,
|
|
17
|
+
global_skills_dir: skills_dir_rel,
|
|
18
|
+
detect_paths: []
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe('is_symlink', () => {
|
|
22
|
+
let tmp
|
|
23
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
24
|
+
afterEach(() => { cleanup(tmp) })
|
|
25
|
+
|
|
26
|
+
it('returns true for a symlink', async () => {
|
|
27
|
+
const target = path.join(tmp, 'target')
|
|
28
|
+
const link = path.join(tmp, 'link')
|
|
29
|
+
fs.mkdirSync(target)
|
|
30
|
+
fs.symlinkSync(target, link, 'junction')
|
|
31
|
+
|
|
32
|
+
const [errors, result] = await is_symlink(link)
|
|
33
|
+
assert.equal(errors, null)
|
|
34
|
+
assert.equal(result, true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('returns false for a regular directory', async () => {
|
|
38
|
+
const dir = path.join(tmp, 'regular')
|
|
39
|
+
fs.mkdirSync(dir)
|
|
40
|
+
|
|
41
|
+
const [errors, result] = await is_symlink(dir)
|
|
42
|
+
assert.equal(errors, null)
|
|
43
|
+
assert.equal(result, false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns false for a non-existent path', async () => {
|
|
47
|
+
const [errors, result] = await is_symlink(path.join(tmp, 'nope'))
|
|
48
|
+
assert.equal(errors, null)
|
|
49
|
+
assert.equal(result, false)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('link_to_agents', () => {
|
|
54
|
+
let tmp
|
|
55
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
56
|
+
afterEach(() => { cleanup(tmp) })
|
|
57
|
+
|
|
58
|
+
it('creates symlinks in agent directories', async () => {
|
|
59
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
60
|
+
fs.mkdirSync(source, { recursive: true })
|
|
61
|
+
fs.writeFileSync(path.join(source, 'SKILL.md'), 'test')
|
|
62
|
+
|
|
63
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
64
|
+
const [errors, results] = await link_to_agents(source, agents, { global: false, project_root: tmp, skill_name: 'deploy-aws' })
|
|
65
|
+
|
|
66
|
+
assert.equal(errors, null)
|
|
67
|
+
assert.equal(results.length, 1)
|
|
68
|
+
assert.equal(results[0].agent_id, 'claude')
|
|
69
|
+
assert.equal(results[0].method, 'symlink')
|
|
70
|
+
|
|
71
|
+
const link_path = path.join(tmp, '.claude', 'skills', 'deploy-aws')
|
|
72
|
+
const stat = fs.lstatSync(link_path)
|
|
73
|
+
assert.ok(stat.isSymbolicLink())
|
|
74
|
+
assert.equal(fs.readlinkSync(link_path), source)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('creates symlinks for multiple agents', async () => {
|
|
78
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
79
|
+
fs.mkdirSync(source, { recursive: true })
|
|
80
|
+
|
|
81
|
+
const agents = [
|
|
82
|
+
make_agent('claude', '.claude/skills'),
|
|
83
|
+
make_agent('cursor', '.cursor/skills')
|
|
84
|
+
]
|
|
85
|
+
const [errors, results] = await link_to_agents(source, agents, { global: false, project_root: tmp, skill_name: 'deploy-aws' })
|
|
86
|
+
|
|
87
|
+
assert.equal(errors, null)
|
|
88
|
+
assert.equal(results.length, 2)
|
|
89
|
+
|
|
90
|
+
for (const agent of agents) {
|
|
91
|
+
const link_path = path.join(tmp, agent.skills_dir, 'deploy-aws')
|
|
92
|
+
assert.ok(fs.lstatSync(link_path).isSymbolicLink())
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('skips if symlink already points to the correct source', async () => {
|
|
97
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
98
|
+
fs.mkdirSync(source, { recursive: true })
|
|
99
|
+
|
|
100
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
101
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
102
|
+
fs.symlinkSync(source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
103
|
+
|
|
104
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
105
|
+
const [errors, results] = await link_to_agents(source, agents, { global: false, project_root: tmp, skill_name: 'deploy-aws' })
|
|
106
|
+
|
|
107
|
+
assert.equal(errors, null)
|
|
108
|
+
assert.equal(results[0].skipped, true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('replaces stale symlink pointing elsewhere', async () => {
|
|
112
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
113
|
+
fs.mkdirSync(source, { recursive: true })
|
|
114
|
+
|
|
115
|
+
const old_source = path.join(tmp, 'old-location')
|
|
116
|
+
fs.mkdirSync(old_source, { recursive: true })
|
|
117
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
118
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
119
|
+
fs.symlinkSync(old_source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
120
|
+
|
|
121
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
122
|
+
const [errors, results] = await link_to_agents(source, agents, { global: false, project_root: tmp, skill_name: 'deploy-aws' })
|
|
123
|
+
|
|
124
|
+
assert.equal(errors, null)
|
|
125
|
+
assert.equal(results[0].method, 'symlink')
|
|
126
|
+
assert.equal(results[0].skipped, undefined)
|
|
127
|
+
|
|
128
|
+
const link_path = path.join(tmp, '.claude', 'skills', 'deploy-aws')
|
|
129
|
+
assert.equal(fs.readlinkSync(link_path), source)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('unlink_from_agents', () => {
|
|
134
|
+
let tmp
|
|
135
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
136
|
+
afterEach(() => { cleanup(tmp) })
|
|
137
|
+
|
|
138
|
+
it('removes symlinks from agent directories', async () => {
|
|
139
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
140
|
+
fs.mkdirSync(source, { recursive: true })
|
|
141
|
+
|
|
142
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
143
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
144
|
+
fs.symlinkSync(source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
145
|
+
|
|
146
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
147
|
+
const [errors, results] = await unlink_from_agents('deploy-aws', agents, { global: false, project_root: tmp })
|
|
148
|
+
|
|
149
|
+
assert.equal(errors, null)
|
|
150
|
+
assert.equal(results[0].removed, true)
|
|
151
|
+
assert.ok(!fs.existsSync(path.join(agent_dir, 'deploy-aws')))
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('reports removed=false when symlink does not exist', async () => {
|
|
155
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
156
|
+
const [errors, results] = await unlink_from_agents('deploy-aws', agents, { global: false, project_root: tmp })
|
|
157
|
+
|
|
158
|
+
assert.equal(errors, null)
|
|
159
|
+
assert.equal(results[0].removed, false)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('removes from multiple agents', async () => {
|
|
163
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
164
|
+
fs.mkdirSync(source, { recursive: true })
|
|
165
|
+
|
|
166
|
+
const agents = [
|
|
167
|
+
make_agent('claude', '.claude/skills'),
|
|
168
|
+
make_agent('cursor', '.cursor/skills')
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
for (const agent of agents) {
|
|
172
|
+
const agent_dir = path.join(tmp, agent.skills_dir)
|
|
173
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
174
|
+
fs.symlinkSync(source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const [errors, results] = await unlink_from_agents('deploy-aws', agents, { global: false, project_root: tmp })
|
|
178
|
+
|
|
179
|
+
assert.equal(errors, null)
|
|
180
|
+
assert.ok(results.every(r => r.removed === true))
|
|
181
|
+
for (const agent of agents) {
|
|
182
|
+
assert.ok(!fs.existsSync(path.join(tmp, agent.skills_dir, 'deploy-aws')))
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('does not remove the canonical source directory', async () => {
|
|
187
|
+
const source = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
188
|
+
fs.mkdirSync(source, { recursive: true })
|
|
189
|
+
fs.writeFileSync(path.join(source, 'SKILL.md'), 'test')
|
|
190
|
+
|
|
191
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
192
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
193
|
+
fs.symlinkSync(source, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
194
|
+
|
|
195
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
196
|
+
await unlink_from_agents('deploy-aws', agents, { global: false, project_root: tmp })
|
|
197
|
+
|
|
198
|
+
// Canonical source must still exist
|
|
199
|
+
assert.ok(fs.existsSync(source))
|
|
200
|
+
assert.ok(fs.existsSync(path.join(source, 'SKILL.md')))
|
|
201
|
+
})
|
|
202
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const { error: { catch_errors } } = require('puffy-core')
|
|
2
|
+
const { is_symlink } = require('./linker')
|
|
3
|
+
const { agent_skill_install_dir } = require('../config/paths')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check whether a skill is enabled (has at least one symlink in an agent folder).
|
|
7
|
+
*
|
|
8
|
+
* @param {string} skill_name — short name (e.g. "deploy-aws")
|
|
9
|
+
* @param {Agent[]} agents — detected agent objects
|
|
10
|
+
* @param {boolean} is_global
|
|
11
|
+
* @param {string} project_root
|
|
12
|
+
* @returns {Promise} [errors, boolean]
|
|
13
|
+
*/
|
|
14
|
+
const is_skill_enabled = (skill_name, agents, is_global, project_root) => catch_errors('Skill enabled check failed', async () => {
|
|
15
|
+
for (const agent of agents) {
|
|
16
|
+
const target = agent_skill_install_dir(agent, is_global, project_root, skill_name)
|
|
17
|
+
const [, link] = await is_symlink(target)
|
|
18
|
+
if (link) return true
|
|
19
|
+
}
|
|
20
|
+
return false
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Batch-check enabled status for multiple skills.
|
|
25
|
+
*
|
|
26
|
+
* @param {string[]} skill_names — array of short names
|
|
27
|
+
* @param {Agent[]} agents — detected agent objects
|
|
28
|
+
* @param {boolean} is_global
|
|
29
|
+
* @param {string} project_root
|
|
30
|
+
* @returns {Promise} [errors, Map<string, boolean>] — skill_name → enabled
|
|
31
|
+
*/
|
|
32
|
+
const get_skills_enabled_map = (skill_names, agents, is_global, project_root) => catch_errors('Skill enabled map failed', async () => {
|
|
33
|
+
const map = new Map()
|
|
34
|
+
const checks = skill_names.map(async (name) => {
|
|
35
|
+
const [, enabled] = await is_skill_enabled(name, agents, is_global, project_root)
|
|
36
|
+
map.set(name, enabled === true)
|
|
37
|
+
})
|
|
38
|
+
await Promise.all(checks)
|
|
39
|
+
return map
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
module.exports = { is_skill_enabled, get_skills_enabled_map }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
3
|
+
const assert = require('node:assert/strict')
|
|
4
|
+
const fs = require('fs')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const os = require('os')
|
|
7
|
+
|
|
8
|
+
const { is_skill_enabled, get_skills_enabled_map } = require('./status')
|
|
9
|
+
|
|
10
|
+
const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-status-test-'))
|
|
11
|
+
const cleanup = (dir) => fs.rmSync(dir, { recursive: true, force: true })
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Helper: create a fake agent definition for testing.
|
|
15
|
+
*/
|
|
16
|
+
const make_agent = (id, skills_dir_rel) => ({
|
|
17
|
+
id,
|
|
18
|
+
display_name: id,
|
|
19
|
+
skills_dir: skills_dir_rel,
|
|
20
|
+
global_skills_dir: skills_dir_rel,
|
|
21
|
+
detect_paths: []
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('is_skill_enabled', () => {
|
|
25
|
+
let tmp
|
|
26
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
27
|
+
afterEach(() => { cleanup(tmp) })
|
|
28
|
+
|
|
29
|
+
it('returns true when a symlink exists in an agent folder', async () => {
|
|
30
|
+
const canonical = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
31
|
+
fs.mkdirSync(canonical, { recursive: true })
|
|
32
|
+
fs.writeFileSync(path.join(canonical, 'SKILL.md'), '---\nname: deploy-aws\n---\n')
|
|
33
|
+
|
|
34
|
+
const agent_dir = path.join(tmp, '.claude', 'skills')
|
|
35
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
36
|
+
fs.symlinkSync(canonical, path.join(agent_dir, 'deploy-aws'), 'junction')
|
|
37
|
+
|
|
38
|
+
const agent = make_agent('claude', '.claude/skills')
|
|
39
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', [agent], false, tmp)
|
|
40
|
+
assert.equal(errors, null)
|
|
41
|
+
assert.equal(enabled, true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns false when no symlink exists in any agent folder', async () => {
|
|
45
|
+
const canonical = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
46
|
+
fs.mkdirSync(canonical, { recursive: true })
|
|
47
|
+
|
|
48
|
+
const agent = make_agent('claude', '.claude/skills')
|
|
49
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', [agent], false, tmp)
|
|
50
|
+
assert.equal(errors, null)
|
|
51
|
+
assert.equal(enabled, false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('returns false when agent folder does not exist', async () => {
|
|
55
|
+
const agent = make_agent('claude', '.claude/skills')
|
|
56
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', [agent], false, tmp)
|
|
57
|
+
assert.equal(errors, null)
|
|
58
|
+
assert.equal(enabled, false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns true if any one of multiple agents has the symlink', async () => {
|
|
62
|
+
const canonical = path.join(tmp, '.agents', 'skills', 'deploy-aws')
|
|
63
|
+
fs.mkdirSync(canonical, { recursive: true })
|
|
64
|
+
|
|
65
|
+
// Only cursor has the symlink, not claude
|
|
66
|
+
const cursor_dir = path.join(tmp, '.cursor', 'skills')
|
|
67
|
+
fs.mkdirSync(cursor_dir, { recursive: true })
|
|
68
|
+
fs.symlinkSync(canonical, path.join(cursor_dir, 'deploy-aws'), 'junction')
|
|
69
|
+
|
|
70
|
+
const agents = [
|
|
71
|
+
make_agent('claude', '.claude/skills'),
|
|
72
|
+
make_agent('cursor', '.cursor/skills')
|
|
73
|
+
]
|
|
74
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', agents, false, tmp)
|
|
75
|
+
assert.equal(errors, null)
|
|
76
|
+
assert.equal(enabled, true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('returns false with an empty agents array', async () => {
|
|
80
|
+
const [errors, enabled] = await is_skill_enabled('deploy-aws', [], false, tmp)
|
|
81
|
+
assert.equal(errors, null)
|
|
82
|
+
assert.equal(enabled, false)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('get_skills_enabled_map', () => {
|
|
87
|
+
let tmp
|
|
88
|
+
beforeEach(() => { tmp = make_tmp() })
|
|
89
|
+
afterEach(() => { cleanup(tmp) })
|
|
90
|
+
|
|
91
|
+
it('returns a map with correct enabled/disabled status for multiple skills', async () => {
|
|
92
|
+
const agents_skills = path.join(tmp, '.agents', 'skills')
|
|
93
|
+
const skill_a = path.join(agents_skills, 'skill-a')
|
|
94
|
+
const skill_b = path.join(agents_skills, 'skill-b')
|
|
95
|
+
fs.mkdirSync(skill_a, { recursive: true })
|
|
96
|
+
fs.mkdirSync(skill_b, { recursive: true })
|
|
97
|
+
|
|
98
|
+
// Only skill-a is symlinked (enabled)
|
|
99
|
+
const claude_dir = path.join(tmp, '.claude', 'skills')
|
|
100
|
+
fs.mkdirSync(claude_dir, { recursive: true })
|
|
101
|
+
fs.symlinkSync(skill_a, path.join(claude_dir, 'skill-a'), 'junction')
|
|
102
|
+
|
|
103
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
104
|
+
const [errors, map] = await get_skills_enabled_map(['skill-a', 'skill-b'], agents, false, tmp)
|
|
105
|
+
|
|
106
|
+
assert.equal(errors, null)
|
|
107
|
+
assert.equal(map.get('skill-a'), true)
|
|
108
|
+
assert.equal(map.get('skill-b'), false)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('returns all false when no symlinks exist', async () => {
|
|
112
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
113
|
+
const [errors, map] = await get_skills_enabled_map(['foo', 'bar'], agents, false, tmp)
|
|
114
|
+
|
|
115
|
+
assert.equal(errors, null)
|
|
116
|
+
assert.equal(map.get('foo'), false)
|
|
117
|
+
assert.equal(map.get('bar'), false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('handles empty skill list', async () => {
|
|
121
|
+
const agents = [make_agent('claude', '.claude/skills')]
|
|
122
|
+
const [errors, map] = await get_skills_enabled_map([], agents, false, tmp)
|
|
123
|
+
|
|
124
|
+
assert.equal(errors, null)
|
|
125
|
+
assert.equal(map.size, 0)
|
|
126
|
+
})
|
|
127
|
+
})
|
package/src/commands/check.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
2
|
-
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
2
|
+
const { read_lock, get_all_locked_skills, get_via } = require('../lock/reader')
|
|
3
3
|
const repos_api = require('../api/repos')
|
|
4
4
|
const { print_help, print_table, print_json, print_info, print_success, print_warn, print_hint, code } = require('../ui/output')
|
|
5
5
|
const { green, yellow, red } = require('../ui/colors')
|
|
@@ -44,15 +44,15 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
44
44
|
|
|
45
45
|
const target_skill = args._[0]
|
|
46
46
|
const to_check = target_skill
|
|
47
|
-
? entries.filter(([name]) => name === target_skill)
|
|
48
|
-
: entries.filter(([, data]) => data
|
|
47
|
+
? entries.filter(([name, data]) => name === target_skill && data !== null)
|
|
48
|
+
: entries.filter(([, data]) => data !== null)
|
|
49
49
|
|
|
50
50
|
if (to_check.length === 0) {
|
|
51
51
|
if (args.flags.json) {
|
|
52
52
|
print_json({ data: { results: [], outdated_count: 0, up_to_date_count: 0 } })
|
|
53
53
|
return
|
|
54
54
|
}
|
|
55
|
-
print_info(target_skill ? `${target_skill} is not installed.` : 'No
|
|
55
|
+
print_info(target_skill ? `${target_skill} is not installed.` : 'No installed skills found.')
|
|
56
56
|
return
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -62,25 +62,26 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
62
62
|
const results = []
|
|
63
63
|
if (batch_err) {
|
|
64
64
|
for (const [name, data] of to_check) {
|
|
65
|
-
results.push({ skill: name, installed: data.version, latest: 'error', status: 'error' })
|
|
65
|
+
results.push({ skill: name, installed: data.version, latest: 'error', status: 'error', via: get_via(data) })
|
|
66
66
|
}
|
|
67
67
|
} else {
|
|
68
68
|
for (const [name, data] of to_check) {
|
|
69
69
|
const info = batch_data?.results?.[name]
|
|
70
|
+
const via = get_via(data)
|
|
70
71
|
const has_conflicts = (data.conflict_files || []).length > 0
|
|
71
72
|
if (has_conflicts) {
|
|
72
|
-
results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'conflicts' })
|
|
73
|
+
results.push({ skill: name, installed: data.version, latest: info?.latest_version || '-', status: 'conflicts', via })
|
|
73
74
|
} else if (info?.access_denied) {
|
|
74
|
-
results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access' })
|
|
75
|
+
results.push({ skill: name, installed: data.version, latest: '-', status: 'no-access', via })
|
|
75
76
|
} else if (!info || !info.latest_version) {
|
|
76
|
-
results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown' })
|
|
77
|
+
results.push({ skill: name, installed: data.version, latest: '-', status: 'unknown', via })
|
|
77
78
|
} else if (data.base_commit && info.commit && data.base_commit !== info.commit) {
|
|
78
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
79
|
+
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated', via })
|
|
79
80
|
} else if (!data.base_commit && info.latest_version !== data.version) {
|
|
80
81
|
// Fallback to version comparison for old lock files without base_commit
|
|
81
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated' })
|
|
82
|
+
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'outdated', via })
|
|
82
83
|
} else {
|
|
83
|
-
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date' })
|
|
84
|
+
results.push({ skill: name, installed: data.version, latest: info.latest_version, status: 'up-to-date', via })
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
}
|
|
@@ -102,14 +103,21 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
102
103
|
'unknown': (s) => s
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
106
|
+
const has_via = results.some(r => r.via)
|
|
107
|
+
const rows = results.map(r => {
|
|
108
|
+
const row = [
|
|
109
|
+
r.skill,
|
|
110
|
+
r.installed,
|
|
111
|
+
r.latest,
|
|
112
|
+
(status_colors[r.status] || ((s) => s))(r.status)
|
|
113
|
+
]
|
|
114
|
+
if (has_via) row.push(r.via || '-')
|
|
115
|
+
return row
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const headers = ['Skill', 'Installed', 'Latest', 'Status']
|
|
119
|
+
if (has_via) headers.push('Via')
|
|
120
|
+
print_table(headers, rows)
|
|
113
121
|
|
|
114
122
|
const outdated = results.filter(r => r.status === 'outdated')
|
|
115
123
|
const conflicts = results.filter(r => r.status === 'conflicts')
|
|
@@ -121,7 +129,7 @@ const run = (args) => catch_errors('Check failed', async () => {
|
|
|
121
129
|
}
|
|
122
130
|
if (outdated.length > 0) {
|
|
123
131
|
console.log()
|
|
124
|
-
print_info(`Run ${code('happyskills update')} to upgrade ${outdated.length} skill(s).`)
|
|
132
|
+
print_info(`Run ${code('happyskills update --all')} to upgrade ${outdated.length} skill(s).`)
|
|
125
133
|
} else if (conflicts.length === 0 && results.every(r => r.status === 'up-to-date')) {
|
|
126
134
|
console.log()
|
|
127
135
|
print_success('All skills are up to date.')
|
|
@@ -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/commands/update.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
const path = require('path')
|
|
2
1
|
const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
3
2
|
const { install } = require('../engine/installer')
|
|
4
|
-
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
3
|
+
const { read_lock, get_all_locked_skills, get_via } = require('../lock/reader')
|
|
5
4
|
const { detect_status } = require('../merge/detector')
|
|
6
5
|
const { print_help, print_success, print_info, print_warn, print_json } = require('../ui/output')
|
|
7
6
|
const { exit_with_error, UsageError } = require('../utils/errors')
|
|
@@ -48,7 +47,7 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
48
47
|
|
|
49
48
|
const to_update = target_skill
|
|
50
49
|
? [[target_skill, skills[target_skill]]]
|
|
51
|
-
: Object.entries(skills).filter(([, data]) => data
|
|
50
|
+
: Object.entries(skills).filter(([, data]) => data !== null)
|
|
52
51
|
|
|
53
52
|
if (to_update.length === 0) {
|
|
54
53
|
if (args.flags.json) {
|
|
@@ -96,9 +95,9 @@ const run = (args) => catch_errors('Update failed', async () => {
|
|
|
96
95
|
const [errors, result] = await install(name, options)
|
|
97
96
|
if (errors) throw e(`Update ${name} failed`, errors)
|
|
98
97
|
if (!result.no_op) {
|
|
99
|
-
updated.push({ skill: name, from: before_version, to: result.version })
|
|
98
|
+
updated.push({ skill: name, from: before_version, to: result.version, via: get_via(data) })
|
|
100
99
|
} else {
|
|
101
|
-
already_up_to_date.push({ skill: name, version: result.version })
|
|
100
|
+
already_up_to_date.push({ skill: name, version: result.version, via: get_via(data) })
|
|
102
101
|
}
|
|
103
102
|
}
|
|
104
103
|
|
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
|
|
@@ -688,3 +688,264 @@ describe('CLI — delete command', () => {
|
|
|
688
688
|
}
|
|
689
689
|
})
|
|
690
690
|
})
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
// Enable / Disable
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Helper: scaffold a project directory with a lock file and skill dirs that
|
|
698
|
+
* mimic a real HappySkills install. Returns { project_root, cleanup }.
|
|
699
|
+
*
|
|
700
|
+
* Structure:
|
|
701
|
+
* <tmp>/
|
|
702
|
+
* ├── skills-lock.json
|
|
703
|
+
* ├── .agents/skills/<skill_name>/SKILL.md (canonical)
|
|
704
|
+
* └── .claude/skills/<skill_name> → symlink (agent link)
|
|
705
|
+
*/
|
|
706
|
+
const scaffold_project = (skills) => {
|
|
707
|
+
const root = make_tmp()
|
|
708
|
+
const lock_skills = {}
|
|
709
|
+
|
|
710
|
+
for (const s of skills) {
|
|
711
|
+
const canonical = path.join(root, '.agents', 'skills', s.short)
|
|
712
|
+
fs.mkdirSync(canonical, { recursive: true })
|
|
713
|
+
fs.writeFileSync(path.join(canonical, 'SKILL.md'), `---\nname: ${s.short}\ndescription: test skill\n---\nTest`)
|
|
714
|
+
|
|
715
|
+
if (s.enabled !== false) {
|
|
716
|
+
const agent_dir = path.join(root, '.claude', 'skills')
|
|
717
|
+
fs.mkdirSync(agent_dir, { recursive: true })
|
|
718
|
+
fs.symlinkSync(canonical, path.join(agent_dir, s.short), 'junction')
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
lock_skills[s.full] = {
|
|
722
|
+
version: s.version || '1.0.0',
|
|
723
|
+
type: 'skill',
|
|
724
|
+
ref: `refs/tags/v${s.version || '1.0.0'}`,
|
|
725
|
+
commit: 'abc123',
|
|
726
|
+
integrity: null,
|
|
727
|
+
base_commit: 'abc123',
|
|
728
|
+
base_integrity: null,
|
|
729
|
+
requested_by: ['__root__'],
|
|
730
|
+
dependencies: {}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
fs.writeFileSync(path.join(root, 'skills-lock.json'), JSON.stringify({
|
|
735
|
+
lockVersion: 2,
|
|
736
|
+
generatedAt: new Date().toISOString(),
|
|
737
|
+
skills: lock_skills
|
|
738
|
+
}, null, '\t'))
|
|
739
|
+
|
|
740
|
+
return { root, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
describe('enable / disable commands', () => {
|
|
744
|
+
it('enable --help exits 0', () => {
|
|
745
|
+
const { code, stdout } = run(['enable', '--help'])
|
|
746
|
+
assert.strictEqual(code, 0)
|
|
747
|
+
assert.ok(stdout.includes('Usage: happyskills enable'))
|
|
748
|
+
})
|
|
749
|
+
|
|
750
|
+
it('disable --help exits 0', () => {
|
|
751
|
+
const { code, stdout } = run(['disable', '--help'])
|
|
752
|
+
assert.strictEqual(code, 0)
|
|
753
|
+
assert.ok(stdout.includes('Usage: happyskills disable'))
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it('"on" alias resolves to enable', () => {
|
|
757
|
+
const { code, stdout } = run(['on', '--help'])
|
|
758
|
+
assert.strictEqual(code, 0)
|
|
759
|
+
assert.ok(stdout.includes('Usage: happyskills enable'))
|
|
760
|
+
})
|
|
761
|
+
|
|
762
|
+
it('"off" alias resolves to disable', () => {
|
|
763
|
+
const { code, stdout } = run(['off', '--help'])
|
|
764
|
+
assert.strictEqual(code, 0)
|
|
765
|
+
assert.ok(stdout.includes('Usage: happyskills disable'))
|
|
766
|
+
})
|
|
767
|
+
|
|
768
|
+
it('disable without arguments exits with usage error', () => {
|
|
769
|
+
const { code, stderr } = run(['disable', '--json'])
|
|
770
|
+
assert.strictEqual(code, 2)
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
it('enable without arguments exits with usage error', () => {
|
|
774
|
+
const { code, stderr } = run(['enable', '--json'])
|
|
775
|
+
assert.strictEqual(code, 2)
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
it('disable removes agent symlink, enable restores it', () => {
|
|
779
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
780
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws' }
|
|
781
|
+
])
|
|
782
|
+
try {
|
|
783
|
+
const link_path = path.join(root, '.claude', 'skills', 'deploy-aws')
|
|
784
|
+
|
|
785
|
+
// Verify symlink exists before disable
|
|
786
|
+
assert.ok(fs.lstatSync(link_path).isSymbolicLink())
|
|
787
|
+
|
|
788
|
+
// Disable — force agents to claude only (auto-detect won't find .claude in tmp)
|
|
789
|
+
const d = run(['disable', 'acme/deploy-aws', '--agents', 'claude'], {}, { cwd: root })
|
|
790
|
+
assert.strictEqual(d.code, 0, `disable failed: ${d.stderr}`)
|
|
791
|
+
assert.ok(!fs.existsSync(link_path), 'symlink should be removed after disable')
|
|
792
|
+
|
|
793
|
+
// Canonical dir must still exist
|
|
794
|
+
assert.ok(fs.existsSync(path.join(root, '.agents', 'skills', 'deploy-aws')))
|
|
795
|
+
|
|
796
|
+
// Enable
|
|
797
|
+
const e = run(['enable', 'acme/deploy-aws', '--agents', 'claude'], {}, { cwd: root })
|
|
798
|
+
assert.strictEqual(e.code, 0, `enable failed: ${e.stderr}`)
|
|
799
|
+
assert.ok(fs.lstatSync(link_path).isSymbolicLink(), 'symlink should be restored after enable')
|
|
800
|
+
} finally {
|
|
801
|
+
clean()
|
|
802
|
+
}
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('disable an already-disabled skill warns but does not fail', () => {
|
|
806
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
807
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws', enabled: false }
|
|
808
|
+
])
|
|
809
|
+
try {
|
|
810
|
+
const { code, stderr } = run(['disable', 'acme/deploy-aws', '--agents', 'claude'], {}, { cwd: root })
|
|
811
|
+
assert.strictEqual(code, 0)
|
|
812
|
+
assert.ok(stderr.includes('already disabled'))
|
|
813
|
+
} finally {
|
|
814
|
+
clean()
|
|
815
|
+
}
|
|
816
|
+
})
|
|
817
|
+
|
|
818
|
+
it('enable an already-enabled skill warns but does not fail', () => {
|
|
819
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
820
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws', enabled: true }
|
|
821
|
+
])
|
|
822
|
+
try {
|
|
823
|
+
const { code, stderr } = run(['enable', 'acme/deploy-aws', '--agents', 'claude'], {}, { cwd: root })
|
|
824
|
+
assert.strictEqual(code, 0)
|
|
825
|
+
assert.ok(stderr.includes('already enabled'))
|
|
826
|
+
} finally {
|
|
827
|
+
clean()
|
|
828
|
+
}
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
it('disable multiple skills at once', () => {
|
|
832
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
833
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws' },
|
|
834
|
+
{ full: 'acme/monitoring', short: 'monitoring' }
|
|
835
|
+
])
|
|
836
|
+
try {
|
|
837
|
+
const { code } = run(['disable', 'acme/deploy-aws', 'acme/monitoring', '--agents', 'claude'], {}, { cwd: root })
|
|
838
|
+
assert.strictEqual(code, 0)
|
|
839
|
+
|
|
840
|
+
assert.ok(!fs.existsSync(path.join(root, '.claude', 'skills', 'deploy-aws')))
|
|
841
|
+
assert.ok(!fs.existsSync(path.join(root, '.claude', 'skills', 'monitoring')))
|
|
842
|
+
} finally {
|
|
843
|
+
clean()
|
|
844
|
+
}
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('disable accepts short names', () => {
|
|
848
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
849
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws' }
|
|
850
|
+
])
|
|
851
|
+
try {
|
|
852
|
+
const { code } = run(['disable', 'deploy-aws', '--agents', 'claude'], {}, { cwd: root })
|
|
853
|
+
assert.strictEqual(code, 0)
|
|
854
|
+
assert.ok(!fs.existsSync(path.join(root, '.claude', 'skills', 'deploy-aws')))
|
|
855
|
+
} finally {
|
|
856
|
+
clean()
|
|
857
|
+
}
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
it('enable accepts short names', () => {
|
|
861
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
862
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws', enabled: false }
|
|
863
|
+
])
|
|
864
|
+
try {
|
|
865
|
+
const { code } = run(['enable', 'deploy-aws', '--agents', 'claude'], {}, { cwd: root })
|
|
866
|
+
assert.strictEqual(code, 0)
|
|
867
|
+
assert.ok(fs.lstatSync(path.join(root, '.claude', 'skills', 'deploy-aws')).isSymbolicLink())
|
|
868
|
+
} finally {
|
|
869
|
+
clean()
|
|
870
|
+
}
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
it('disable a non-existent skill warns but does not fail', () => {
|
|
874
|
+
const { root, cleanup: clean } = scaffold_project([])
|
|
875
|
+
try {
|
|
876
|
+
const { code, stderr } = run(['disable', 'acme/nope', '--agents', 'claude'], {}, { cwd: root })
|
|
877
|
+
assert.strictEqual(code, 0)
|
|
878
|
+
assert.ok(stderr.includes('not a HappySkills-managed skill'))
|
|
879
|
+
} finally {
|
|
880
|
+
clean()
|
|
881
|
+
}
|
|
882
|
+
})
|
|
883
|
+
|
|
884
|
+
it('disable --json returns structured results', () => {
|
|
885
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
886
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws' }
|
|
887
|
+
])
|
|
888
|
+
try {
|
|
889
|
+
const { code, stdout } = run(['disable', 'acme/deploy-aws', '--agents', 'claude', '--json'], {}, { cwd: root })
|
|
890
|
+
assert.strictEqual(code, 0)
|
|
891
|
+
const out = parse_json_output(stdout, 'disable --json')
|
|
892
|
+
assert.ok(out.data)
|
|
893
|
+
assert.ok(Array.isArray(out.data.results))
|
|
894
|
+
assert.strictEqual(out.data.results[0].skill, 'acme/deploy-aws')
|
|
895
|
+
assert.strictEqual(out.data.results[0].status, 'disabled')
|
|
896
|
+
} finally {
|
|
897
|
+
clean()
|
|
898
|
+
}
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
it('enable --json returns structured results', () => {
|
|
902
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
903
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws', enabled: false }
|
|
904
|
+
])
|
|
905
|
+
try {
|
|
906
|
+
const { code, stdout } = run(['enable', 'acme/deploy-aws', '--agents', 'claude', '--json'], {}, { cwd: root })
|
|
907
|
+
assert.strictEqual(code, 0)
|
|
908
|
+
const out = parse_json_output(stdout, 'enable --json')
|
|
909
|
+
assert.ok(out.data)
|
|
910
|
+
assert.ok(Array.isArray(out.data.results))
|
|
911
|
+
assert.strictEqual(out.data.results[0].skill, 'acme/deploy-aws')
|
|
912
|
+
assert.strictEqual(out.data.results[0].status, 'enabled')
|
|
913
|
+
} finally {
|
|
914
|
+
clean()
|
|
915
|
+
}
|
|
916
|
+
})
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
describe('list — enabled column', () => {
|
|
920
|
+
it('list --json includes enabled field for managed skills', () => {
|
|
921
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
922
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws', enabled: true },
|
|
923
|
+
{ full: 'acme/monitoring', short: 'monitoring', enabled: false }
|
|
924
|
+
])
|
|
925
|
+
try {
|
|
926
|
+
const { code, stdout } = run(['list', '--json', '--agents', 'claude'], {}, { cwd: root })
|
|
927
|
+
assert.strictEqual(code, 0)
|
|
928
|
+
const out = parse_json_output(stdout, 'list --json')
|
|
929
|
+
assert.strictEqual(out.data.skills['acme/deploy-aws'].enabled, true)
|
|
930
|
+
assert.strictEqual(out.data.skills['acme/monitoring'].enabled, false)
|
|
931
|
+
} finally {
|
|
932
|
+
clean()
|
|
933
|
+
}
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
it('list table output shows enabled/disabled labels', () => {
|
|
937
|
+
const { root, cleanup: clean } = scaffold_project([
|
|
938
|
+
{ full: 'acme/deploy-aws', short: 'deploy-aws', enabled: true },
|
|
939
|
+
{ full: 'acme/monitoring', short: 'monitoring', enabled: false }
|
|
940
|
+
])
|
|
941
|
+
try {
|
|
942
|
+
const { code, stdout } = run(['list', '--agents', 'claude'], {}, { cwd: root })
|
|
943
|
+
assert.strictEqual(code, 0)
|
|
944
|
+
assert.ok(stdout.includes('Enabled'), 'table should have Enabled header')
|
|
945
|
+
assert.ok(stdout.includes('enabled'), 'should show enabled label')
|
|
946
|
+
assert.ok(stdout.includes('disabled'), 'should show disabled label')
|
|
947
|
+
} finally {
|
|
948
|
+
clean()
|
|
949
|
+
}
|
|
950
|
+
})
|
|
951
|
+
})
|
package/src/lock/reader.js
CHANGED
|
@@ -2,6 +2,7 @@ const { error: { catch_errors } } = require('puffy-core')
|
|
|
2
2
|
const { read_json } = require('../utils/fs')
|
|
3
3
|
const { lock_file_path } = require('../config/paths')
|
|
4
4
|
const { LOCK_VERSION } = require('../constants')
|
|
5
|
+
const { print_warn } = require('../ui/output')
|
|
5
6
|
|
|
6
7
|
const read_lock = (project_root) => catch_errors('Failed to read lock file', async () => {
|
|
7
8
|
const lock_path = lock_file_path(project_root)
|
|
@@ -9,7 +10,6 @@ const read_lock = (project_root) => catch_errors('Failed to read lock file', asy
|
|
|
9
10
|
if (errors) return null
|
|
10
11
|
|
|
11
12
|
if (data.lockVersion !== LOCK_VERSION) {
|
|
12
|
-
const { print_warn } = require('../ui/output')
|
|
13
13
|
print_warn(`Lock file version mismatch (found ${data.lockVersion}, expected ${LOCK_VERSION}). Consider running with --fresh.`)
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -26,4 +26,10 @@ const get_all_locked_skills = (lock_data) => {
|
|
|
26
26
|
return lock_data.skills
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
const get_via = (data) => {
|
|
30
|
+
const requester = data?.requested_by?.[0]
|
|
31
|
+
if (!requester || requester === '__root__') return null
|
|
32
|
+
return requester
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = { read_lock, get_locked_skill, get_all_locked_skills, get_via }
|
package/src/lock/reader.test.js
CHANGED
|
@@ -5,7 +5,7 @@ const os = require('os')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const fs = require('fs')
|
|
7
7
|
|
|
8
|
-
const { read_lock, get_locked_skill, get_all_locked_skills } = require('./reader')
|
|
8
|
+
const { read_lock, get_locked_skill, get_all_locked_skills, get_via } = require('./reader')
|
|
9
9
|
const { LOCK_VERSION } = require('../constants')
|
|
10
10
|
|
|
11
11
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -126,3 +126,39 @@ describe('get_all_locked_skills', () => {
|
|
|
126
126
|
assert.deepEqual(get_all_locked_skills({ skills: {} }), {})
|
|
127
127
|
})
|
|
128
128
|
})
|
|
129
|
+
|
|
130
|
+
// ─── get_via ────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe('get_via', () => {
|
|
133
|
+
it('returns null for a directly installed skill (__root__)', () => {
|
|
134
|
+
assert.strictEqual(get_via({ requested_by: ['__root__'] }), null)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('returns the parent skill name for a dependency', () => {
|
|
138
|
+
assert.strictEqual(get_via({ requested_by: ['acme/deploy-aws'] }), 'acme/deploy-aws')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('returns the kit name for a kit dependency', () => {
|
|
142
|
+
assert.strictEqual(get_via({ requested_by: ['acme/_kit-react'] }), 'acme/_kit-react')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns the first requester when multiple exist', () => {
|
|
146
|
+
assert.strictEqual(get_via({ requested_by: ['acme/foo', 'acme/bar'] }), 'acme/foo')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns null when requested_by is empty', () => {
|
|
150
|
+
assert.strictEqual(get_via({ requested_by: [] }), null)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('returns null when requested_by is missing', () => {
|
|
154
|
+
assert.strictEqual(get_via({}), null)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('returns null when data is null', () => {
|
|
158
|
+
assert.strictEqual(get_via(null), null)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('returns null when data is undefined', () => {
|
|
162
|
+
assert.strictEqual(get_via(undefined), null)
|
|
163
|
+
})
|
|
164
|
+
})
|