happyskills 0.45.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -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 +312 -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/src/validation/skill_md_rules.js +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.46.0] - 2026-05-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- 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).
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- 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.
|
|
18
|
+
- 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.
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
## [0.45.1] - 2026-05-14
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- `validate` soft-cap warning text now references the **Constellation Pattern** (formerly "Suite Pattern") and the **Constellation Decomposition Workflow** (formerly "Suite Decomposition Workflow"), and points at `happyskills-design references/constellation-pattern.md` (formerly `references/suite-pattern.md`). Terminology alignment with `happyskillsai/happyskills-design@0.8.0` — no behaviour change; the warning still fires at the same 250-char threshold and still recommends decomposition.
|
|
25
|
+
|
|
10
26
|
## [0.45.0] - 2026-05-13
|
|
11
27
|
|
|
12
28
|
### Fixed
|
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,312 @@
|
|
|
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, SKILL_TYPES } = 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
|
+
* For each installed skill, decide whether it should be mirrored into the
|
|
67
|
+
* newly added agent. A skill is mirrored when it is currently enabled — i.e.,
|
|
68
|
+
* a symlink for it exists in at least one *other* already-configured agent.
|
|
69
|
+
*
|
|
70
|
+
* When no other agent folders exist yet (fresh project bootstrap), every
|
|
71
|
+
* non-kit installed skill is mirrored.
|
|
72
|
+
*/
|
|
73
|
+
const _select_skills_to_mirror = async (lock_data, is_global, project_root, new_agent) => {
|
|
74
|
+
const all = get_all_locked_skills(lock_data)
|
|
75
|
+
const installed = Object.entries(all).filter(([, data]) => data && data.type !== SKILL_TYPES.KIT)
|
|
76
|
+
|
|
77
|
+
// Find any *other* agents that are already configured in this scope
|
|
78
|
+
const [, all_detected] = await detect_agents({ global: is_global, project_root })
|
|
79
|
+
const other_detected = (all_detected || []).filter(a => a.id !== new_agent.id)
|
|
80
|
+
|
|
81
|
+
const selected = []
|
|
82
|
+
const skipped_disabled = []
|
|
83
|
+
|
|
84
|
+
for (const [full_name, data] of installed) {
|
|
85
|
+
const short = full_name.split('/')[1] || full_name
|
|
86
|
+
|
|
87
|
+
if (other_detected.length === 0) {
|
|
88
|
+
selected.push({ full_name, short })
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const [, enabled] = await is_skill_enabled(short, other_detected, is_global, project_root)
|
|
93
|
+
if (enabled) selected.push({ full_name, short })
|
|
94
|
+
else skipped_disabled.push(full_name)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { selected, skipped_disabled }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const _add = async (raw_args, args) => {
|
|
101
|
+
const agents = _parse_agent_ids(raw_args)
|
|
102
|
+
const is_global = args.flags.global || false
|
|
103
|
+
const project_root = find_project_root()
|
|
104
|
+
const is_json = args.flags.json || false
|
|
105
|
+
const base_dir = skills_dir(is_global, project_root)
|
|
106
|
+
|
|
107
|
+
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
108
|
+
const has_lock = !!(lock_data && lock_data.skills && Object.keys(lock_data.skills).length > 0)
|
|
109
|
+
|
|
110
|
+
const per_agent = []
|
|
111
|
+
|
|
112
|
+
for (const agent of agents) {
|
|
113
|
+
const target_root = agent_skills_dir(agent, is_global, project_root)
|
|
114
|
+
await fs.promises.mkdir(target_root, { recursive: true })
|
|
115
|
+
|
|
116
|
+
if (!has_lock) {
|
|
117
|
+
per_agent.push({ agent_id: agent.id, status: 'configured', linked: [], skipped_disabled: [] })
|
|
118
|
+
continue
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { selected, skipped_disabled } = await _select_skills_to_mirror(lock_data, is_global, project_root, agent)
|
|
122
|
+
const linked = []
|
|
123
|
+
|
|
124
|
+
for (const { full_name, short } of selected) {
|
|
125
|
+
const source = skill_install_dir(base_dir, short)
|
|
126
|
+
const [, exists] = await file_exists(source)
|
|
127
|
+
if (!exists) {
|
|
128
|
+
if (!is_json) print_warn(`Skipping ${full_name}: source missing at ${source} (re-install required)`)
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
const [link_err] = await link_to_agents(source, [agent], { global: is_global, project_root, skill_name: short })
|
|
132
|
+
if (link_err) {
|
|
133
|
+
if (!is_json) print_warn(`Failed to link ${full_name} into ${agent.display_name}`)
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
linked.push(full_name)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
per_agent.push({ agent_id: agent.id, status: 'configured', linked, skipped_disabled })
|
|
140
|
+
|
|
141
|
+
if (!is_json) {
|
|
142
|
+
const scope = is_global ? 'globally' : 'in this project'
|
|
143
|
+
print_success(`${agent.display_name} configured ${scope} (${target_root})`)
|
|
144
|
+
if (linked.length > 0) print_info(` Linked ${linked.length} skill(s): ${linked.join(', ')}`)
|
|
145
|
+
if (skipped_disabled.length > 0) {
|
|
146
|
+
print_info(` Skipped ${skipped_disabled.length} disabled skill(s): ${skipped_disabled.join(', ')}`)
|
|
147
|
+
print_info(` Re-enable with: ${code('happyskills enable <skill>')}`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (is_json) {
|
|
153
|
+
print_json({ data: { added: per_agent, global: is_global } })
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const _remove = async (raw_args, args) => {
|
|
158
|
+
const agents = _parse_agent_ids(raw_args)
|
|
159
|
+
const is_global = args.flags.global || false
|
|
160
|
+
const project_root = find_project_root()
|
|
161
|
+
const is_json = args.flags.json || false
|
|
162
|
+
|
|
163
|
+
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
164
|
+
const all = get_all_locked_skills(lock_data)
|
|
165
|
+
const short_names = Object.keys(all).map(k => k.split('/')[1] || k)
|
|
166
|
+
|
|
167
|
+
const per_agent = []
|
|
168
|
+
|
|
169
|
+
for (const agent of agents) {
|
|
170
|
+
const target_root = agent_skills_dir(agent, is_global, project_root)
|
|
171
|
+
const [, exists] = await file_exists(target_root)
|
|
172
|
+
if (!exists) {
|
|
173
|
+
per_agent.push({ agent_id: agent.id, status: 'not_configured' })
|
|
174
|
+
if (!is_json) print_info(`${agent.display_name}: nothing to remove (no ${target_root})`)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const unlinked = []
|
|
179
|
+
if (short_names.length > 0) {
|
|
180
|
+
const [unlink_err, results] = await unlink_from_agents_each(short_names, agent, is_global, project_root)
|
|
181
|
+
if (unlink_err) {
|
|
182
|
+
if (!is_json) print_warn(`Failed to remove some symlinks from ${agent.display_name}`)
|
|
183
|
+
}
|
|
184
|
+
for (const r of (results || [])) {
|
|
185
|
+
if (r.removed) unlinked.push(r.skill_name)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// If the agent's skills folder is now empty, remove it (leave the parent .<agent>/ alone)
|
|
190
|
+
let removed_folder = false
|
|
191
|
+
try {
|
|
192
|
+
const entries = await fs.promises.readdir(target_root)
|
|
193
|
+
if (entries.length === 0) {
|
|
194
|
+
await fs.promises.rmdir(target_root)
|
|
195
|
+
removed_folder = true
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// best-effort cleanup
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
per_agent.push({ agent_id: agent.id, status: 'removed', unlinked, removed_folder })
|
|
202
|
+
|
|
203
|
+
if (!is_json) {
|
|
204
|
+
print_success(`${agent.display_name} disconnected from this ${is_global ? 'machine' : 'project'}`)
|
|
205
|
+
if (unlinked.length > 0) print_info(` Removed ${unlinked.length} symlink(s)`)
|
|
206
|
+
if (!removed_folder) print_info(` Folder ${target_root} kept (non-empty — left untouched)`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (is_json) {
|
|
211
|
+
print_json({ data: { removed: per_agent, global: is_global } })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Unlink one agent across many skills. Returns per-skill results.
|
|
217
|
+
*/
|
|
218
|
+
const unlink_from_agents_each = async (short_names, agent, is_global, project_root) => {
|
|
219
|
+
const results = []
|
|
220
|
+
let last_err = null
|
|
221
|
+
for (const short of short_names) {
|
|
222
|
+
const [err, res] = await unlink_from_agents(short, [agent], { global: is_global, project_root })
|
|
223
|
+
if (err) { last_err = err; continue }
|
|
224
|
+
const item = (res || [])[0]
|
|
225
|
+
if (item) results.push({ skill_name: short, removed: !!item.removed })
|
|
226
|
+
}
|
|
227
|
+
return [last_err, results]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const _list = async (args) => {
|
|
231
|
+
const is_global = args.flags.global || false
|
|
232
|
+
const project_root = find_project_root()
|
|
233
|
+
const is_json = args.flags.json || false
|
|
234
|
+
|
|
235
|
+
const [, detected] = await detect_agents({ global: is_global, project_root })
|
|
236
|
+
const detected_ids = new Set((detected || []).map(a => a.id))
|
|
237
|
+
|
|
238
|
+
const [, lock_data] = await read_lock(lock_root(is_global, project_root))
|
|
239
|
+
const all = get_all_locked_skills(lock_data)
|
|
240
|
+
const short_names = Object.keys(all).map(k => k.split('/')[1] || k)
|
|
241
|
+
|
|
242
|
+
const rows = []
|
|
243
|
+
for (const agent of AGENTS) {
|
|
244
|
+
const target_root = agent_skills_dir(agent, is_global, project_root)
|
|
245
|
+
const is_detected = detected_ids.has(agent.id)
|
|
246
|
+
let linked_count = 0
|
|
247
|
+
if (is_detected && short_names.length > 0) {
|
|
248
|
+
for (const short of short_names) {
|
|
249
|
+
const [, enabled] = await is_skill_enabled(short, [agent], is_global, project_root)
|
|
250
|
+
if (enabled) linked_count++
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
rows.push({ agent, target_root, configured: is_detected, linked_count })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (is_json) {
|
|
257
|
+
print_json({
|
|
258
|
+
data: {
|
|
259
|
+
scope: is_global ? 'global' : 'project',
|
|
260
|
+
agents: rows.map(r => ({
|
|
261
|
+
id: r.agent.id,
|
|
262
|
+
display_name: r.agent.display_name,
|
|
263
|
+
skills_dir: r.target_root,
|
|
264
|
+
configured: r.configured,
|
|
265
|
+
linked_skills: r.linked_count
|
|
266
|
+
}))
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const table_rows = rows.map(r => [
|
|
273
|
+
r.agent.id,
|
|
274
|
+
r.agent.display_name,
|
|
275
|
+
r.configured ? green('yes') : dim('no'),
|
|
276
|
+
r.configured ? String(r.linked_count) : dim('—'),
|
|
277
|
+
r.target_root
|
|
278
|
+
])
|
|
279
|
+
print_table(['ID', 'Agent', 'Configured', 'Linked', 'Folder'], table_rows)
|
|
280
|
+
console.log()
|
|
281
|
+
print_info(`Scope: ${is_global ? 'global (~)' : 'project (' + project_root + ')'}`)
|
|
282
|
+
print_info(`Add an agent here: ${code('happyskills agents add <id>')}`)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const run = (args) => catch_errors('Agents command failed', async () => {
|
|
286
|
+
if (args.flags._show_help) {
|
|
287
|
+
print_help(HELP_TEXT)
|
|
288
|
+
return process.exit(EXIT_CODES.SUCCESS)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const sub = args._.shift()
|
|
292
|
+
const rest = args._
|
|
293
|
+
|
|
294
|
+
if (!sub || sub === 'list' || sub === 'ls') {
|
|
295
|
+
await _list(args)
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (sub === 'add') {
|
|
300
|
+
await _add(rest, args)
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
305
|
+
await _remove(rest, args)
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
throw new UsageError(`Unknown subcommand: '${sub}'. Use add, remove, or list.`)
|
|
310
|
+
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
311
|
+
|
|
312
|
+
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
|
|
@@ -95,12 +95,12 @@ const validate_description = (fm) => {
|
|
|
95
95
|
|
|
96
96
|
if (desc.length > DESC_SOFT_CAP) {
|
|
97
97
|
results.push({
|
|
98
|
-
...result('description', 'soft_cap', 'warning', `Description is ${desc.length} chars (target: ${DESC_TARGET_MIN}-${DESC_TARGET_MAX}, soft cap: ${DESC_SOFT_CAP}). Above ${DESC_SOFT_CAP} chars usually signals a mega-skill — apply the
|
|
98
|
+
...result('description', 'soft_cap', 'warning', `Description is ${desc.length} chars (target: ${DESC_TARGET_MIN}-${DESC_TARGET_MAX}, soft cap: ${DESC_SOFT_CAP}). Above ${DESC_SOFT_CAP} chars usually signals a mega-skill — apply the Constellation Pattern to decompose it into a core skill plus focused satellites. Run "npx happyskills audit <name>" or ask your agent "audit this skill" — happyskills-design will walk you through the Constellation Decomposition Workflow.`, desc),
|
|
99
99
|
recommendations: [
|
|
100
|
-
'CANONICAL — Apply the
|
|
100
|
+
'CANONICAL — Apply the Constellation Pattern: decompose the skill into a core entry-point skill plus satellite skills, each owning one orthogonal verb cluster, bundled via the core skill.json dependencies. This is the answer once a description crosses the soft cap. happyskills-design implements the Constellation Decomposition Workflow end-to-end — invoke it with "audit this skill" or "decompose this mega-skill".',
|
|
101
101
|
'Alternative — Compress the description first (AUDIT/LOSSLESS/LOSSY procedure in happyskills-design references/skill-authoring.md). Buys time, but compression alone will not keep up as the API surface grows.',
|
|
102
102
|
'Alternative — Hybrid umbrella+satellites: keep one main skill for high-frequency operations, extract specialized domains into satellites.',
|
|
103
|
-
'For the canonical
|
|
103
|
+
'For the canonical Constellation Pattern reference (orthogonal verb ownership, the five-slot description grammar, failure modes, orthogonality test): happyskills-design references/constellation-pattern.md, or docs/cli-skill.md in the HappySkills repo.'
|
|
104
104
|
]
|
|
105
105
|
})
|
|
106
106
|
} else {
|