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 +18 -0
- package/package.json +1 -1
- package/src/agents/detector.js +51 -24
- package/src/agents/registry.js +13 -16
- package/src/commands/agents.js +346 -0
- package/src/commands/config.js +1 -1
- package/src/commands/convert.js +1 -1
- package/src/commands/disable.js +1 -1
- package/src/commands/enable.js +1 -1
- package/src/commands/init.js +1 -1
- package/src/commands/list.js +1 -1
- package/src/commands/update.js +1 -1
- package/src/constants.js +2 -1
- package/src/engine/installer.js +1 -1
- package/src/engine/uninstaller.js +1 -1
- package/src/index.js +1 -0
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
package/src/agents/detector.js
CHANGED
|
@@ -8,23 +8,37 @@ const { get_config_value } = require('../config/store')
|
|
|
8
8
|
const home_dir = os.homedir()
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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.
|
|
63
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
90
|
-
const [errors,
|
|
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:
|
|
120
|
+
return { agents: home_detected, source: 'home' }
|
|
94
121
|
})
|
|
95
122
|
|
|
96
123
|
module.exports = { detect_agents, resolve_agents }
|
package/src/agents/registry.js
CHANGED
|
@@ -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 }
|
package/src/commands/config.js
CHANGED
|
@@ -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
|
package/src/commands/convert.js
CHANGED
|
@@ -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 })
|
package/src/commands/disable.js
CHANGED
|
@@ -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
|
|
package/src/commands/enable.js
CHANGED
|
@@ -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
|
|
package/src/commands/init.js
CHANGED
|
@@ -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) {
|
package/src/commands/list.js
CHANGED
|
@@ -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
|
package/src/commands/update.js
CHANGED
|
@@ -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
package/src/engine/installer.js
CHANGED
|
@@ -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
|