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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -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
+ })
@@ -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.requested_by?.includes('__root__'))
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 directly installed skills found.')
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 rows = results.map(r => [
106
- r.skill,
107
- r.installed,
108
- r.latest,
109
- (status_colors[r.status] || ((s) => s))(r.status)
110
- ])
111
-
112
- print_table(['Skill', 'Installed', 'Latest', 'Status'], rows)
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 }
@@ -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 }
@@ -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.requested_by?.includes('__root__'))
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 = {
@@ -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
@@ -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
+ })
@@ -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
- module.exports = { read_lock, get_locked_skill, get_all_locked_skills }
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 }
@@ -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
+ })