happyskills 0.26.0 → 0.27.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 +5 -0
- package/package.json +1 -1
- package/src/commands/list.js +20 -4
- package/src/utils/skill_scanner.js +77 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.27.0] - 2026-04-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Add agent-orphan skill detection to `list` command — scans all agent-specific skill directories (`.claude/skills/`, `.cursor/skills/`, etc.) for skills placed directly in agent folders without going through HappySkills, and reports them as `agent_orphans` in JSON output or `agent-orphan (<agent>)` source in table output
|
|
14
|
+
|
|
10
15
|
## [0.26.0] - 2026-04-01
|
|
11
16
|
|
|
12
17
|
### Added
|
package/package.json
CHANGED
package/src/commands/list.js
CHANGED
|
@@ -2,7 +2,8 @@ const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
|
|
|
2
2
|
const { read_lock, get_all_locked_skills } = require('../lock/reader')
|
|
3
3
|
const { skills_dir, skill_install_dir, find_project_root, lock_root } = require('../config/paths')
|
|
4
4
|
const { file_exists, read_json } = require('../utils/fs')
|
|
5
|
-
const { scan_skills_dir } = require('../utils/skill_scanner')
|
|
5
|
+
const { scan_skills_dir, scan_agent_orphan_skills } = require('../utils/skill_scanner')
|
|
6
|
+
const { AGENTS } = require('../agents/registry')
|
|
6
7
|
const { print_help, print_table, print_json, print_info } = require('../ui/output')
|
|
7
8
|
const { exit_with_error } = require('../utils/errors')
|
|
8
9
|
const { EXIT_CODES, SKILL_JSON, SKILL_TYPES } = require('../constants')
|
|
@@ -40,9 +41,14 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
40
41
|
const managed_names = new Set(managed_entries.map(([k]) => k.split('/')[1]))
|
|
41
42
|
const external_skills = (disk_skills || []).filter(s => !managed_names.has(s.name))
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
// Scan agent-specific dirs for skills placed directly in agent folders (not symlinked from .agents/skills/)
|
|
45
|
+
const all_known_names = new Set([...managed_names, ...external_skills.map(s => s.name)])
|
|
46
|
+
const [, agent_orphans] = await scan_agent_orphan_skills(AGENTS, is_global, project_root, all_known_names)
|
|
47
|
+
const orphan_skills = agent_orphans || []
|
|
48
|
+
|
|
49
|
+
if (managed_entries.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
|
|
44
50
|
if (args.flags.json) {
|
|
45
|
-
print_json({ data: { skills: {}, external: [] } })
|
|
51
|
+
print_json({ data: { skills: {}, external: [], agent_orphans: [] } })
|
|
46
52
|
return
|
|
47
53
|
}
|
|
48
54
|
print_info('No skills installed.')
|
|
@@ -69,7 +75,12 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
69
75
|
skills_map[name] = { version: data.version, type, source, status }
|
|
70
76
|
}
|
|
71
77
|
const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
|
|
72
|
-
|
|
78
|
+
const agent_orphan_list = orphan_skills.map(s => ({
|
|
79
|
+
name: s.name,
|
|
80
|
+
description: s.description || '',
|
|
81
|
+
agents: s.agents
|
|
82
|
+
}))
|
|
83
|
+
print_json({ data: { skills: skills_map, external, agent_orphans: agent_orphan_list } })
|
|
73
84
|
return
|
|
74
85
|
}
|
|
75
86
|
|
|
@@ -88,6 +99,11 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
88
99
|
rows.push([s.name, '-', 'external', 'installed'])
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
for (const s of orphan_skills) {
|
|
103
|
+
const agent_label = s.agents.map(a => a.name).join(', ')
|
|
104
|
+
rows.push([s.name, '-', `agent-orphan (${agent_label})`, 'installed'])
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
print_table(['Skill', 'Version', 'Source', 'Status'], rows)
|
|
92
108
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
93
109
|
|
|
@@ -53,4 +53,80 @@ const scan_skills_dir = (base_dir) => catch_errors('Failed to scan skills direct
|
|
|
53
53
|
return skills
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Scans agent-specific skill directories for skills that are NOT symlinks
|
|
58
|
+
* back to the canonical .agents/skills/ directory. These are "agent-orphan"
|
|
59
|
+
* skills — placed directly in an agent folder without going through HappySkills.
|
|
60
|
+
*
|
|
61
|
+
* @param {Array} agents - Agent definitions from the registry (AGENTS array)
|
|
62
|
+
* @param {boolean} is_global - Whether to scan global or project-level dirs
|
|
63
|
+
* @param {string} project_root - Project root path
|
|
64
|
+
* @param {Set<string>} known_names - Names already tracked (managed + external from canonical dir)
|
|
65
|
+
* @returns {[Error|null, Array<{name:string, description:string, agent_id:string, agent_name:string}>]}
|
|
66
|
+
*/
|
|
67
|
+
const scan_agent_orphan_skills = (agents, is_global, project_root, known_names) => catch_errors('Failed to scan agent skill directories', async () => {
|
|
68
|
+
const orphans = []
|
|
69
|
+
|
|
70
|
+
for (const agent of agents) {
|
|
71
|
+
const agent_dir = is_global
|
|
72
|
+
? path.join(require('os').homedir(), agent.global_skills_dir)
|
|
73
|
+
: path.join(project_root || process.cwd(), agent.skills_dir)
|
|
74
|
+
|
|
75
|
+
let entries
|
|
76
|
+
try {
|
|
77
|
+
entries = await fs.promises.readdir(agent_dir, { withFileTypes: true })
|
|
78
|
+
} catch {
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
84
|
+
if (entry.name.startsWith('.') || SKIP_ENTRIES.has(entry.name)) continue
|
|
85
|
+
|
|
86
|
+
// Skip symlinks — those are managed by HappySkills
|
|
87
|
+
if (entry.isSymbolicLink()) continue
|
|
88
|
+
|
|
89
|
+
// Skip skills already known from the canonical dir or lock file
|
|
90
|
+
if (known_names.has(entry.name)) continue
|
|
91
|
+
|
|
92
|
+
const dir = path.join(agent_dir, entry.name)
|
|
93
|
+
const skill_md_path = path.join(dir, 'SKILL.md')
|
|
94
|
+
|
|
95
|
+
let content
|
|
96
|
+
try {
|
|
97
|
+
content = await fs.promises.readFile(skill_md_path, 'utf-8')
|
|
98
|
+
} catch {
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const frontmatter = parse_frontmatter(content)
|
|
103
|
+
if (!frontmatter || !frontmatter.name) continue
|
|
104
|
+
|
|
105
|
+
orphans.push({
|
|
106
|
+
name: frontmatter.name,
|
|
107
|
+
description: frontmatter.description || '',
|
|
108
|
+
agent_id: agent.id,
|
|
109
|
+
agent_name: agent.display_name
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Deduplicate — the same skill dir name might appear in multiple agent dirs.
|
|
115
|
+
// Keep the first occurrence and collect all agent IDs.
|
|
116
|
+
const by_name = new Map()
|
|
117
|
+
for (const s of orphans) {
|
|
118
|
+
if (by_name.has(s.name)) {
|
|
119
|
+
by_name.get(s.name).agents.push({ id: s.agent_id, name: s.agent_name })
|
|
120
|
+
} else {
|
|
121
|
+
by_name.set(s.name, {
|
|
122
|
+
name: s.name,
|
|
123
|
+
description: s.description,
|
|
124
|
+
agents: [{ id: s.agent_id, name: s.agent_name }]
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return [...by_name.values()]
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
module.exports = { scan_skills_dir, scan_agent_orphan_skills, parse_frontmatter }
|