happyskills 0.45.1 → 0.46.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,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.46.1] - 2026-05-20
11
+
12
+ ### Fixed
13
+ - Fix `happyskills agents add` ignoring two categories of skills that should be mirrored into the newly added agent's folder: **kits** (skills with `type: kit` in the lock file — previously excluded by a copy-pasted "not agent-invocable" filter that conflated *invocation routing* with *filesystem presence*) and **locally-authored skills that were never published** (skills scaffolded via `happyskills init` and kept under source control but not pushed to the registry — they exist on disk in `.agents/skills/<name>/` but have no lock entry, so the lock-based enumeration missed them). The `add` subcommand now enumerates `.agents/skills/<name>/` directories directly, so anything physically present in the canonical location — regardless of whether it's lock-managed, a kit, or a private local-only skill — is symlinked into the new agent's folder.
14
+ - Fix `happyskills agents remove` ignoring the same two categories — it previously read short names from the lock file, so a project containing kits or unpublished skills would leave dangling symlinks in the removed agent's folder. The subcommand now reads canonical names from `.agents/skills/<name>/` so every skill that could have been mirrored can also be unlinked.
15
+ - Fix `happyskills agents list` undercounting the `Linked` column for the same reason — count is now against the canonical on-disk list so kits and unpublished skills are included.
16
+
17
+ ## [0.46.0] - 2026-05-20
18
+
19
+ ### Added
20
+ - Add `happyskills agents` command for project-scoped agent configuration with three subcommands: `agents list` (default — show every supported agent with configured/linked status for the current scope), `agents add <ids>` (create `.<agent>/skills/` and mirror every currently-enabled installed skill as symlinks), and `agents remove <ids>` (remove the CLI-managed symlinks; delete the `skills/` folder if it ends up empty; never touch the agent's other state). Accepts comma- or space-separated agent ids (`claude,cursor` or `claude cursor`), supports `-g` for global scope and `--json` everywhere. The common use case is trying a new agentic client (e.g., Codex) on a single project without changing the user-global default — `happyskills agents add codex` makes that one-line. `add` respects per-skill disabled state: skills with no symlinks in any other configured agent are skipped with a clear message pointing at `enable`. Kits are not mirrored (not agent-invocable).
21
+ - Add a fifth tier to the agent-resolution priority chain used by `install`, `uninstall`, `update`, `enable`, `disable`, `init`, `convert`, `setup`, and the new `agents` command: **project-physical** now sits at tier 3, above the user-global `config agents` value. The priority chain is now (top wins): (1) `--agents` flag, (2) `HAPPYSKILLS_AGENTS` env var, (3) project-physical (any `.<agent>/skills/` in the project root, project scope only), (4) `~/.config/happyskills/config.json` agents value, (5) home-physical fallback (auto-detect from `~/.<agent>/skills/`). This is what makes `agents add` decisions stick across subsequent commands without re-passing flags — the folder IS the project's configuration, no new JSON file required.
22
+
23
+ ### Changed
24
+ - Tighten multi-agent detection to key on `.<agent>/skills/` (the subfolder), not the bare `.<agent>/` directory. `.<agent>/skills/` is unambiguously a HappySkills artifact — only the CLI ever creates it — whereas `.<agent>/` is also used by the agent itself for unrelated state (settings, history, sessions, `CLAUDE.md`, etc.). The looser previous signal would treat any project with `.claude/` as "Claude is configured here," causing installs to drop symlinks into projects where the user never asked for skill integration. **Possible behavior change for fresh projects:** a user who has Claude Code installed (`~/.claude/` exists) but has never installed any HappySkills skill in this project AND has no `~/.claude/skills/` will no longer auto-detect Claude on their very first install. Mitigations: (a) running `happyskills setup` once globally creates `~/.claude/skills/` and tier 5 takes over, (b) `happyskills agents add claude` makes the intent explicit and creates the project-level folder, (c) the `--agents` flag and `HAPPYSKILLS_AGENTS` env var continue to override unconditionally.
25
+ - Drop the `detect_paths` field from the agent registry (`cli/src/agents/registry.js`). Detection now derives from the per-agent `skills_dir` / `global_skills_dir` fields directly — one source of truth per agent.
26
+ - Update `resolve_agents()` and `detect_agents()` signatures to accept an `{ global, project_root }` options object so resolution is scope-aware. All call sites (`installer`, `uninstaller`, `enable`, `disable`, `init`, `convert`, `update`, `list`, `config`) updated to pass the right scope.
27
+
10
28
  ## [0.45.1] - 2026-05-14
11
29
 
12
30
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.45.1",
3
+ "version": "0.46.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)",
@@ -8,23 +8,37 @@ const { get_config_value } = require('../config/store')
8
8
  const home_dir = os.homedir()
9
9
 
10
10
  /**
11
- * Detect which agents are installed by checking for home-level config folders.
12
- * All checks run in parallel via Promise.all.
11
+ * Resolve the on-disk skills folder for an agent in a given scope.
13
12
  *
13
+ * @param {Agent} agent
14
+ * @param {boolean} is_global
15
+ * @param {string} project_root
16
+ * @returns {string} absolute path to .<agent>/skills/
17
+ */
18
+ const _agent_skills_path = (agent, is_global, project_root) => {
19
+ if (is_global) return path.join(home_dir, agent.global_skills_dir)
20
+ return path.join(project_root || process.cwd(), agent.skills_dir)
21
+ }
22
+
23
+ /**
24
+ * Detect which agents are configured by checking for the `.<agent>/skills/`
25
+ * folder. This is the only unambiguous signal: that subfolder is created by
26
+ * this CLI, never by the agent itself, so it carries deliberate intent.
27
+ *
28
+ * @param {object} options — { global, project_root }
14
29
  * @returns {Promise} [errors, Agent[]] — array of detected agent objects
15
30
  */
16
- const detect_agents = () => catch_errors('Agent detection failed', async () => {
31
+ const detect_agents = (options = {}) => catch_errors('Agent detection failed', async () => {
32
+ const { global: is_global = false, project_root } = options
33
+
17
34
  const checks = AGENTS.map(async (agent) => {
18
- for (const rel_path of agent.detect_paths) {
19
- const full_path = path.join(home_dir, rel_path)
20
- try {
21
- await fs.promises.access(full_path)
22
- return agent
23
- } catch {
24
- // path not found — agent not detected
25
- }
35
+ const skills_path = _agent_skills_path(agent, is_global, project_root)
36
+ try {
37
+ const stat = await fs.promises.stat(skills_path)
38
+ return stat.isDirectory() ? agent : null
39
+ } catch {
40
+ return null
26
41
  }
27
- return null
28
42
  })
29
43
 
30
44
  const results = await Promise.all(checks)
@@ -59,38 +73,51 @@ const _parse_agents_flag = (agents_str) => {
59
73
  * Resolve which agents to target. Priority:
60
74
  * 1. --agents flag (explicit per-command override)
61
75
  * 2. HAPPYSKILLS_AGENTS env var (persistent user preference)
62
- * 3. Config file agents value (~/.config/happyskills/config.json)
63
- * 4. Auto-detection (scan home dir for agent config folders)
76
+ * 3. Project-physical (only when !global) — any .<agent>/skills/ that exists
77
+ * in the project root. The folders ARE the project-level configuration.
78
+ * 4. Config file agents value (~/.config/happyskills/config.json)
79
+ * 5. Home-physical fallback — auto-detect from ~/.<agent>/skills/
64
80
  *
65
81
  * Physical files always go to the canonical `.agents/skills/` directory.
66
82
  * Each resolved agent gets a symlink from the canonical location.
67
83
  *
68
84
  * @param {string|undefined} agents_flag — from --agents CLI flag
69
- * @returns {Promise} [errors, { agents: Agent[] }]
85
+ * @param {object} options { global, project_root }
86
+ * @returns {Promise} [errors, { agents: Agent[], source: string }]
70
87
  */
71
- const resolve_agents = (agents_flag) => catch_errors('Agent resolution failed', async () => {
72
- // 1. Explicit --agents flag takes highest priority
88
+ const resolve_agents = (agents_flag, options = {}) => catch_errors('Agent resolution failed', async () => {
89
+ const { global: is_global = false, project_root } = options
90
+
91
+ // 1. Explicit --agents flag
73
92
  if (agents_flag) {
74
- return _parse_agents_flag(agents_flag)
93
+ return { ...(_parse_agents_flag(agents_flag)), source: 'flag' }
75
94
  }
76
95
 
77
96
  // 2. HAPPYSKILLS_AGENTS env var
78
97
  const env_agents = process.env.HAPPYSKILLS_AGENTS
79
98
  if (env_agents) {
80
- return _parse_agents_flag(env_agents)
99
+ return { ...(_parse_agents_flag(env_agents)), source: 'env' }
100
+ }
101
+
102
+ // 3. Project-physical (project scope only)
103
+ if (!is_global) {
104
+ const [, project_detected] = await detect_agents({ global: false, project_root })
105
+ if (project_detected && project_detected.length > 0) {
106
+ return { agents: project_detected, source: 'project' }
107
+ }
81
108
  }
82
109
 
83
- // 3. Config file
110
+ // 4. Config file
84
111
  const [, config_agents] = await get_config_value('agents')
85
112
  if (config_agents) {
86
- return _parse_agents_flag(config_agents)
113
+ return { ...(_parse_agents_flag(config_agents)), source: 'config' }
87
114
  }
88
115
 
89
- // 4. Auto-detect
90
- const [errors, detected] = await detect_agents()
116
+ // 5. Home-physical fallback (legacy auto-detect)
117
+ const [errors, home_detected] = await detect_agents({ global: true })
91
118
  if (errors) throw errors[0]
92
119
 
93
- return { agents: detected }
120
+ return { agents: home_detected, source: 'home' }
94
121
  })
95
122
 
96
123
  module.exports = { detect_agents, resolve_agents }
@@ -4,6 +4,11 @@
4
4
  * Physical skill files live in the canonical `.agents/skills/` directory.
5
5
  * Each agent listed here gets a symlink from `.agents/skills/` to its own skills directory.
6
6
  * Adding a new agent = adding one object to the AGENTS array.
7
+ *
8
+ * Detection signal: an agent is considered configured for a given scope when
9
+ * `<scope>/<skills_dir>` (project) or `<home>/<global_skills_dir>` (global) exists
10
+ * on disk. The `.<agent>/skills/` subfolder is unambiguous — it is only ever
11
+ * created by this CLI, never by the agent itself.
7
12
  */
8
13
 
9
14
  const AGENTS = [
@@ -11,57 +16,49 @@ const AGENTS = [
11
16
  id: 'claude',
12
17
  display_name: 'Claude Code',
13
18
  skills_dir: '.claude/skills',
14
- global_skills_dir: '.claude/skills',
15
- detect_paths: ['.claude']
19
+ global_skills_dir: '.claude/skills'
16
20
  },
17
21
  {
18
22
  id: 'cursor',
19
23
  display_name: 'Cursor',
20
24
  skills_dir: '.cursor/skills',
21
- global_skills_dir: '.cursor/skills',
22
- detect_paths: ['.cursor']
25
+ global_skills_dir: '.cursor/skills'
23
26
  },
24
27
  {
25
28
  id: 'windsurf',
26
29
  display_name: 'Windsurf',
27
30
  skills_dir: '.windsurf/skills',
28
- global_skills_dir: '.windsurf/skills',
29
- detect_paths: ['.windsurf']
31
+ global_skills_dir: '.windsurf/skills'
30
32
  },
31
33
  {
32
34
  id: 'codex',
33
35
  display_name: 'Codex',
34
36
  skills_dir: '.codex/skills',
35
- global_skills_dir: '.codex/skills',
36
- detect_paths: ['.codex']
37
+ global_skills_dir: '.codex/skills'
37
38
  },
38
39
  {
39
40
  id: 'copilot',
40
41
  display_name: 'GitHub Copilot',
41
42
  skills_dir: '.github/skills',
42
- global_skills_dir: '.github/skills',
43
- detect_paths: ['.github/copilot']
43
+ global_skills_dir: '.github/skills'
44
44
  },
45
45
  {
46
46
  id: 'aider',
47
47
  display_name: 'Aider',
48
48
  skills_dir: '.aider/skills',
49
- global_skills_dir: '.aider/skills',
50
- detect_paths: ['.aider']
49
+ global_skills_dir: '.aider/skills'
51
50
  },
52
51
  {
53
52
  id: 'cline',
54
53
  display_name: 'Cline',
55
54
  skills_dir: '.cline/skills',
56
- global_skills_dir: '.cline/skills',
57
- detect_paths: ['.cline']
55
+ global_skills_dir: '.cline/skills'
58
56
  },
59
57
  {
60
58
  id: 'roo',
61
59
  display_name: 'Roo Code',
62
60
  skills_dir: '.roo/skills',
63
- global_skills_dir: '.roo/skills',
64
- detect_paths: ['.roo']
61
+ global_skills_dir: '.roo/skills'
65
62
  }
66
63
  ]
67
64
 
@@ -0,0 +1,346 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
4
+ const { AGENTS, get_agent, get_all_agent_ids } = require('../agents/registry')
5
+ const { detect_agents } = require('../agents/detector')
6
+ const { link_to_agents, unlink_from_agents } = require('../agents/linker')
7
+ const { is_skill_enabled } = require('../agents/status')
8
+ const { read_lock, get_all_locked_skills } = require('../lock/reader')
9
+ const { file_exists } = require('../utils/fs')
10
+ const { find_project_root, lock_root, skills_dir, skill_install_dir, agent_skills_dir } = require('../config/paths')
11
+ const { print_help, print_json, print_success, print_warn, print_info, print_table, code } = require('../ui/output')
12
+ const { green, dim } = require('../ui/colors')
13
+ const { exit_with_error, UsageError } = require('../utils/errors')
14
+ const { EXIT_CODES } = require('../constants')
15
+
16
+ const HELP_TEXT = `Usage: happyskills agents <subcommand> [args] [options]
17
+
18
+ Configure which agentic clients (Claude Code, Cursor, Codex, ...) receive
19
+ the skills installed in this project. Detection is filesystem-based: an
20
+ agent is "configured" for this project when .<agent>/skills/ exists.
21
+
22
+ Subcommands:
23
+ happyskills agents Alias for 'list'
24
+ happyskills agents list Show every supported agent + its status here
25
+ happyskills agents add <ids> Create .<agent>/skills/ and mirror every
26
+ enabled skill into it (symlinks)
27
+ happyskills agents remove <ids> Remove .<agent>/skills/ symlinks for this
28
+ project. Physical files in .agents/skills/
29
+ are untouched. The agent's other state
30
+ (settings, history, ...) is left alone.
31
+
32
+ <ids> is a comma- or space-separated list of agent ids:
33
+ ${get_all_agent_ids().join(', ')}
34
+
35
+ Options:
36
+ -g, --global Operate on ~/.<agent>/skills/ instead of project-local
37
+ --json Output as JSON
38
+
39
+ Examples:
40
+ happyskills agents add codex
41
+ happyskills agents add codex,cursor
42
+ happyskills agents add codex cursor
43
+ happyskills agents remove codex
44
+ happyskills agents list
45
+ happyskills agents add codex -g`
46
+
47
+ const _parse_agent_ids = (raw_args) => {
48
+ const ids = raw_args.flatMap(a => a.split(',')).map(s => s.trim()).filter(Boolean)
49
+ if (ids.length === 0) {
50
+ throw new UsageError(`Please specify at least one agent id. Available: ${get_all_agent_ids().join(', ')}`)
51
+ }
52
+ const resolved = []
53
+ const unknown = []
54
+ for (const id of ids) {
55
+ const agent = get_agent(id)
56
+ if (agent) resolved.push(agent)
57
+ else unknown.push(id)
58
+ }
59
+ if (unknown.length > 0) {
60
+ throw new UsageError(`Unknown agent(s): ${unknown.join(', ')}. Available: ${get_all_agent_ids().join(', ')}`)
61
+ }
62
+ return resolved
63
+ }
64
+
65
+ /**
66
+ * Enumerate every skill physically present in the canonical .agents/skills/
67
+ * directory for the given scope. Covers all three categories of "skill that
68
+ * lives in this project":
69
+ * 1. Lock-managed skills (installed from the registry)
70
+ * 2. Kits (lock-managed or not — kits have a SKILL.md without frontmatter,
71
+ * so a frontmatter-aware scan would miss them; we enumerate directories
72
+ * directly to catch them)
73
+ * 3. Locally-authored skills that were created via `happyskills init` but
74
+ * never published — they exist on disk but have no lock entry
75
+ *
76
+ * Returns short directory names (e.g. "deploy-aws"), not owner-qualified names.
77
+ */
78
+ const _list_canonical_skill_names = async (is_global, project_root) => {
79
+ const base_dir = skills_dir(is_global, project_root)
80
+ try {
81
+ const entries = await fs.promises.readdir(base_dir, { withFileTypes: true })
82
+ return entries
83
+ .filter(e => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith('.'))
84
+ .map(e => e.name)
85
+ } catch {
86
+ return []
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Build a short-name → display-name map from the lock file so on-disk skills
92
+ * that happen to be lock-managed are shown with their owner-qualified name.
93
+ * Skills not in the lock fall back to their short directory name.
94
+ */
95
+ const _build_display_name_map = (lock_data) => {
96
+ const lock_skills = get_all_locked_skills(lock_data)
97
+ const map = new Map()
98
+ for (const full_name of Object.keys(lock_skills)) {
99
+ const short = full_name.split('/')[1] || full_name
100
+ map.set(short, full_name)
101
+ }
102
+ return map
103
+ }
104
+
105
+ /**
106
+ * For each skill physically present in the canonical .agents/skills/ directory,
107
+ * decide whether it should be mirrored into the newly added agent. A skill is
108
+ * mirrored when it is currently enabled — i.e., a symlink for it exists in at
109
+ * least one *other* already-configured agent.
110
+ *
111
+ * When no other agent folders exist yet (fresh project bootstrap), every skill
112
+ * present on disk is mirrored.
113
+ *
114
+ * Includes kits (which have SKILL.md without frontmatter — still need agent
115
+ * symlinks for filesystem parity) AND locally-authored skills that were never
116
+ * published (no lock entry, but legitimate project skills).
117
+ */
118
+ const _select_skills_to_mirror = async (lock_data, is_global, project_root, new_agent) => {
119
+ const display_names = _build_display_name_map(lock_data)
120
+ const candidates = await _list_canonical_skill_names(is_global, project_root)
121
+
122
+ const [, all_detected] = await detect_agents({ global: is_global, project_root })
123
+ const other_detected = (all_detected || []).filter(a => a.id !== new_agent.id)
124
+
125
+ const selected = []
126
+ const skipped_disabled = []
127
+
128
+ for (const short of candidates) {
129
+ const full_name = display_names.get(short) || short
130
+
131
+ if (other_detected.length === 0) {
132
+ selected.push({ full_name, short })
133
+ continue
134
+ }
135
+
136
+ const [, enabled] = await is_skill_enabled(short, other_detected, is_global, project_root)
137
+ if (enabled) selected.push({ full_name, short })
138
+ else skipped_disabled.push(full_name)
139
+ }
140
+
141
+ return { selected, skipped_disabled }
142
+ }
143
+
144
+ const _add = async (raw_args, args) => {
145
+ const agents = _parse_agent_ids(raw_args)
146
+ const is_global = args.flags.global || false
147
+ const project_root = find_project_root()
148
+ const is_json = args.flags.json || false
149
+ const base_dir = skills_dir(is_global, project_root)
150
+
151
+ const [, lock_data] = await read_lock(lock_root(is_global, project_root))
152
+
153
+ const per_agent = []
154
+
155
+ for (const agent of agents) {
156
+ const target_root = agent_skills_dir(agent, is_global, project_root)
157
+ await fs.promises.mkdir(target_root, { recursive: true })
158
+
159
+ const { selected, skipped_disabled } = await _select_skills_to_mirror(lock_data, is_global, project_root, agent)
160
+ const linked = []
161
+
162
+ for (const { full_name, short } of selected) {
163
+ const source = skill_install_dir(base_dir, short)
164
+ const [, exists] = await file_exists(source)
165
+ if (!exists) {
166
+ if (!is_json) print_warn(`Skipping ${full_name}: source missing at ${source} (re-install required)`)
167
+ continue
168
+ }
169
+ const [link_err] = await link_to_agents(source, [agent], { global: is_global, project_root, skill_name: short })
170
+ if (link_err) {
171
+ if (!is_json) print_warn(`Failed to link ${full_name} into ${agent.display_name}`)
172
+ continue
173
+ }
174
+ linked.push(full_name)
175
+ }
176
+
177
+ per_agent.push({ agent_id: agent.id, status: 'configured', linked, skipped_disabled })
178
+
179
+ if (!is_json) {
180
+ const scope = is_global ? 'globally' : 'in this project'
181
+ print_success(`${agent.display_name} configured ${scope} (${target_root})`)
182
+ if (linked.length > 0) print_info(` Linked ${linked.length} skill(s): ${linked.join(', ')}`)
183
+ if (skipped_disabled.length > 0) {
184
+ print_info(` Skipped ${skipped_disabled.length} disabled skill(s): ${skipped_disabled.join(', ')}`)
185
+ print_info(` Re-enable with: ${code('happyskills enable <skill>')}`)
186
+ }
187
+ }
188
+ }
189
+
190
+ if (is_json) {
191
+ print_json({ data: { added: per_agent, global: is_global } })
192
+ }
193
+ }
194
+
195
+ const _remove = async (raw_args, args) => {
196
+ const agents = _parse_agent_ids(raw_args)
197
+ const is_global = args.flags.global || false
198
+ const project_root = find_project_root()
199
+ const is_json = args.flags.json || false
200
+
201
+ const short_names = await _list_canonical_skill_names(is_global, project_root)
202
+
203
+ const per_agent = []
204
+
205
+ for (const agent of agents) {
206
+ const target_root = agent_skills_dir(agent, is_global, project_root)
207
+ const [, exists] = await file_exists(target_root)
208
+ if (!exists) {
209
+ per_agent.push({ agent_id: agent.id, status: 'not_configured' })
210
+ if (!is_json) print_info(`${agent.display_name}: nothing to remove (no ${target_root})`)
211
+ continue
212
+ }
213
+
214
+ const unlinked = []
215
+ if (short_names.length > 0) {
216
+ const [unlink_err, results] = await unlink_from_agents_each(short_names, agent, is_global, project_root)
217
+ if (unlink_err) {
218
+ if (!is_json) print_warn(`Failed to remove some symlinks from ${agent.display_name}`)
219
+ }
220
+ for (const r of (results || [])) {
221
+ if (r.removed) unlinked.push(r.skill_name)
222
+ }
223
+ }
224
+
225
+ // If the agent's skills folder is now empty, remove it (leave the parent .<agent>/ alone)
226
+ let removed_folder = false
227
+ try {
228
+ const entries = await fs.promises.readdir(target_root)
229
+ if (entries.length === 0) {
230
+ await fs.promises.rmdir(target_root)
231
+ removed_folder = true
232
+ }
233
+ } catch {
234
+ // best-effort cleanup
235
+ }
236
+
237
+ per_agent.push({ agent_id: agent.id, status: 'removed', unlinked, removed_folder })
238
+
239
+ if (!is_json) {
240
+ print_success(`${agent.display_name} disconnected from this ${is_global ? 'machine' : 'project'}`)
241
+ if (unlinked.length > 0) print_info(` Removed ${unlinked.length} symlink(s)`)
242
+ if (!removed_folder) print_info(` Folder ${target_root} kept (non-empty — left untouched)`)
243
+ }
244
+ }
245
+
246
+ if (is_json) {
247
+ print_json({ data: { removed: per_agent, global: is_global } })
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Unlink one agent across many skills. Returns per-skill results.
253
+ */
254
+ const unlink_from_agents_each = async (short_names, agent, is_global, project_root) => {
255
+ const results = []
256
+ let last_err = null
257
+ for (const short of short_names) {
258
+ const [err, res] = await unlink_from_agents(short, [agent], { global: is_global, project_root })
259
+ if (err) { last_err = err; continue }
260
+ const item = (res || [])[0]
261
+ if (item) results.push({ skill_name: short, removed: !!item.removed })
262
+ }
263
+ return [last_err, results]
264
+ }
265
+
266
+ const _list = async (args) => {
267
+ const is_global = args.flags.global || false
268
+ const project_root = find_project_root()
269
+ const is_json = args.flags.json || false
270
+
271
+ const [, detected] = await detect_agents({ global: is_global, project_root })
272
+ const detected_ids = new Set((detected || []).map(a => a.id))
273
+
274
+ const short_names = await _list_canonical_skill_names(is_global, project_root)
275
+
276
+ const rows = []
277
+ for (const agent of AGENTS) {
278
+ const target_root = agent_skills_dir(agent, is_global, project_root)
279
+ const is_detected = detected_ids.has(agent.id)
280
+ let linked_count = 0
281
+ if (is_detected && short_names.length > 0) {
282
+ for (const short of short_names) {
283
+ const [, enabled] = await is_skill_enabled(short, [agent], is_global, project_root)
284
+ if (enabled) linked_count++
285
+ }
286
+ }
287
+ rows.push({ agent, target_root, configured: is_detected, linked_count })
288
+ }
289
+
290
+ if (is_json) {
291
+ print_json({
292
+ data: {
293
+ scope: is_global ? 'global' : 'project',
294
+ agents: rows.map(r => ({
295
+ id: r.agent.id,
296
+ display_name: r.agent.display_name,
297
+ skills_dir: r.target_root,
298
+ configured: r.configured,
299
+ linked_skills: r.linked_count
300
+ }))
301
+ }
302
+ })
303
+ return
304
+ }
305
+
306
+ const table_rows = rows.map(r => [
307
+ r.agent.id,
308
+ r.agent.display_name,
309
+ r.configured ? green('yes') : dim('no'),
310
+ r.configured ? String(r.linked_count) : dim('—'),
311
+ r.target_root
312
+ ])
313
+ print_table(['ID', 'Agent', 'Configured', 'Linked', 'Folder'], table_rows)
314
+ console.log()
315
+ print_info(`Scope: ${is_global ? 'global (~)' : 'project (' + project_root + ')'}`)
316
+ print_info(`Add an agent here: ${code('happyskills agents add <id>')}`)
317
+ }
318
+
319
+ const run = (args) => catch_errors('Agents command failed', async () => {
320
+ if (args.flags._show_help) {
321
+ print_help(HELP_TEXT)
322
+ return process.exit(EXIT_CODES.SUCCESS)
323
+ }
324
+
325
+ const sub = args._.shift()
326
+ const rest = args._
327
+
328
+ if (!sub || sub === 'list' || sub === 'ls') {
329
+ await _list(args)
330
+ return
331
+ }
332
+
333
+ if (sub === 'add') {
334
+ await _add(rest, args)
335
+ return
336
+ }
337
+
338
+ if (sub === 'remove' || sub === 'rm') {
339
+ await _remove(rest, args)
340
+ return
341
+ }
342
+
343
+ throw new UsageError(`Unknown subcommand: '${sub}'. Use add, remove, or list.`)
344
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
345
+
346
+ module.exports = { run }
@@ -54,7 +54,7 @@ const _validate_agent_ids = (agents_str) => {
54
54
  }
55
55
 
56
56
  const _list_agents = async (config, is_json) => {
57
- const [, detected] = await detect_agents()
57
+ const [, detected] = await detect_agents({ global: true })
58
58
  const detected_ids = new Set((detected || []).map(a => a.id))
59
59
  const config_agents = config.agents ? config.agents.split(',').map(s => s.trim()) : null
60
60
  const default_ids = config_agents ? new Set(config_agents) : null
@@ -215,7 +215,7 @@ const run = (args) => catch_errors('Convert failed', async () => {
215
215
 
216
216
  // Link to detected agents (non-fatal — warnings only)
217
217
  const linked_agents = []
218
- const [agents_err, agents_result] = await resolve_agents(args.flags.agents || undefined)
218
+ const [agents_err, agents_result] = await resolve_agents(args.flags.agents || undefined, { global: is_global, project_root })
219
219
  if (!agents_err && agents_result?.agents?.length > 0) {
220
220
  pub_spinner.update(`Linking to ${agents_result.agents.length} agent(s)...`)
221
221
  const [link_errs] = await link_to_agents(skill_dir, agents_result.agents, { global: is_global, project_root, skill_name })
@@ -57,7 +57,7 @@ const run = (args) => catch_errors('Disable failed', async () => {
57
57
  const is_global = args.flags.global || false
58
58
  const project_root = find_project_root()
59
59
 
60
- const [agents_err, agents_result] = await resolve_agents(args.flags.agents)
60
+ const [agents_err, agents_result] = await resolve_agents(args.flags.agents, { global: is_global, project_root })
61
61
  if (agents_err) throw e('Agent resolution failed', agents_err)
62
62
  const { agents } = agents_result
63
63
 
@@ -57,7 +57,7 @@ const run = (args) => catch_errors('Enable failed', async () => {
57
57
  const project_root = find_project_root()
58
58
  const base_dir = skills_dir(is_global, project_root)
59
59
 
60
- const [agents_err, agents_result] = await resolve_agents(args.flags.agents)
60
+ const [agents_err, agents_result] = await resolve_agents(args.flags.agents, { global: is_global, project_root })
61
61
  if (agents_err) throw e('Agent resolution failed', agents_err)
62
62
  const { agents } = agents_result
63
63
 
@@ -109,7 +109,7 @@ const run = (args) => catch_errors('Init failed', async () => {
109
109
 
110
110
  // Link to detected agents (non-fatal — warnings only)
111
111
  const linked_agents = []
112
- const [agents_err, agents_result] = await resolve_agents(args.flags.agents || undefined)
112
+ const [agents_err, agents_result] = await resolve_agents(args.flags.agents || undefined, { global: is_global, project_root })
113
113
  if (!agents_err && agents_result?.agents?.length > 0) {
114
114
  const [link_errs] = await link_to_agents(dir, agents_result.agents, { global: is_global, project_root, skill_name: final_name })
115
115
  if (link_errs) {
@@ -42,7 +42,7 @@ const run = (args) => catch_errors('List failed', async () => {
42
42
  const managed_entries = Object.entries(skills)
43
43
 
44
44
  // Resolve agents and build enabled/disabled map for managed skills
45
- const [, agents_result] = await resolve_agents(args.flags.agents)
45
+ const [, agents_result] = await resolve_agents(args.flags.agents, { global: is_global, project_root })
46
46
  const agents = agents_result?.agents || []
47
47
  const managed_short_names = managed_entries.map(([k]) => k.split('/')[1])
48
48
  const [, enabled_map] = agents.length > 0
@@ -184,7 +184,7 @@ const run = (args) => catch_errors('Update failed', async () => {
184
184
  const drifted = results.filter(r => r.status === 'drift')
185
185
 
186
186
  // Verify and repair symlinks (even when nothing is outdated)
187
- const [, agents_data] = await resolve_agents(args.flags.agents)
187
+ const [, agents_data] = await resolve_agents(args.flags.agents, { global: is_global, project_root })
188
188
  const detected_agents = agents_data?.agents || []
189
189
  let symlink_repairs = []
190
190
  if (detected_agents.length > 0) {
package/src/constants.js CHANGED
@@ -72,7 +72,8 @@ const COMMANDS = [
72
72
  'enable',
73
73
  'disable',
74
74
  'versions',
75
- 'changelog'
75
+ 'changelog',
76
+ 'agents'
76
77
  ]
77
78
 
78
79
  module.exports = {
@@ -27,7 +27,7 @@ const install = (skill, options = {}) => catch_errors('Install failed', async ()
27
27
  const base_dir = skills_dir(is_global, project_root)
28
28
 
29
29
  // Resolve target agents
30
- const [agents_err, agents_result] = await resolve_agents(agents_flag)
30
+ const [agents_err, agents_result] = await resolve_agents(agents_flag, { global: is_global, project_root })
31
31
  if (agents_err) throw e('Agent resolution failed', agents_err)
32
32
  const { agents } = agents_result
33
33
  const temp_dir = tmp_dir(base_dir)
@@ -23,7 +23,7 @@ const uninstall = (skill, options = {}) => catch_errors('Uninstall failed', asyn
23
23
  const { global: is_global = false, project_root, agents: agents_flag } = options
24
24
 
25
25
  // Resolve target agents
26
- const [agents_err, agents_result] = await resolve_agents(agents_flag)
26
+ const [agents_err, agents_result] = await resolve_agents(agents_flag, { global: is_global, project_root })
27
27
  if (agents_err) throw e('Agent resolution failed', agents_err)
28
28
  const { agents } = agents_result
29
29
  const base_dir = skills_dir(is_global, project_root)
package/src/index.js CHANGED
@@ -108,6 +108,7 @@ Commands:
108
108
  access <sub> Manage group skill access (list, grant, revoke, set)
109
109
  enable <skill> [...] Enable disabled skills (alias: on)
110
110
  disable <skill> [...] Disable skills without uninstalling (alias: off)
111
+ agents <sub> Configure agentic clients for this project (list, add, remove)
111
112
  login Authenticate with the registry
112
113
  logout Clear stored credentials
113
114
  whoami Show current user