happyskills 0.26.0 → 0.27.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 +11 -0
- package/package.json +1 -1
- package/src/commands/list.js +20 -4
- package/src/commands/publish.js +4 -4
- package/src/ui/output.js +12 -1
- package/src/ui/output.test.js +48 -1
- package/src/utils/skill_scanner.js +77 -1
- package/src/validation/cross_rules.js +0 -16
- package/src/validation/cross_rules.test.js +2 -10
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.27.1] - 2026-04-02
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Fix `no_executable_code` validator producing thousands of false-positive warnings for documentation-heavy skills — rule now scans only SKILL.md, not `references/*.md` files where code blocks are expected content
|
|
14
|
+
- Fix `publish` flooding stderr with one warning per line — warnings are now summarized into a single line grouped by rule (e.g., `⚠ 3 warnings: no_executable_code (2), name_match (1)`), and suppressed from stderr entirely in `--json` mode
|
|
15
|
+
|
|
16
|
+
## [0.27.0] - 2026-04-02
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- 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
|
|
20
|
+
|
|
10
21
|
## [0.26.0] - 2026-04-01
|
|
11
22
|
|
|
12
23
|
### 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
|
|
package/src/commands/publish.js
CHANGED
|
@@ -18,9 +18,10 @@ const { validate_skill_json } = require('../validation/skill_json_rules')
|
|
|
18
18
|
const { validate_cross } = require('../validation/cross_rules')
|
|
19
19
|
const { validate_no_conflict_markers } = require('../validation/conflict_marker_rules')
|
|
20
20
|
const { create_spinner } = require('../ui/spinner')
|
|
21
|
-
const { print_help, print_success, print_error, print_warn, print_hint, print_json, code } = require('../ui/output')
|
|
21
|
+
const { print_help, print_success, print_error, print_warn, print_hint, print_json, code, summarize_warnings } = require('../ui/output')
|
|
22
22
|
const { exit_with_error, UsageError, CliError } = require('../utils/errors')
|
|
23
23
|
const { EXIT_CODES } = require('../constants')
|
|
24
|
+
const { is_json_mode } = require('../state')
|
|
24
25
|
|
|
25
26
|
const HELP_TEXT = `Usage: happyskills publish <skill-name> [options]
|
|
26
27
|
|
|
@@ -108,7 +109,6 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
108
109
|
const validation_warnings = all_results.filter(r => r.severity === 'warning')
|
|
109
110
|
|
|
110
111
|
if (validation_errors.length > 0) {
|
|
111
|
-
const { is_json_mode } = require('../state')
|
|
112
112
|
if (is_json_mode()) {
|
|
113
113
|
const structured_errors = validation_errors.map(({ severity, ...rest }) => rest)
|
|
114
114
|
print_json({
|
|
@@ -125,8 +125,8 @@ const run = (args) => catch_errors('Publish failed', async () => {
|
|
|
125
125
|
throw new CliError(`Skill failed validation with ${validation_errors.length} error(s):\n ${error_msgs.join('\n ')}\n\nRun \`happyskills validate ${skill_name}\` for full details.`, EXIT_CODES.ERROR)
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
|
|
129
|
-
print_warn(
|
|
128
|
+
if (validation_warnings.length > 0 && !is_json_mode()) {
|
|
129
|
+
print_warn(summarize_warnings(validation_warnings, skill_name))
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
const spinner = create_spinner('Preparing to publish...')
|
package/src/ui/output.js
CHANGED
|
@@ -97,6 +97,17 @@ const print_table = (headers, rows) => {
|
|
|
97
97
|
})
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
const summarize_warnings = (warnings, skill_name) => {
|
|
101
|
+
if (!warnings || warnings.length === 0) return null
|
|
102
|
+
const by_rule = new Map()
|
|
103
|
+
for (const w of warnings) {
|
|
104
|
+
const key = w.rule || 'unknown'
|
|
105
|
+
by_rule.set(key, (by_rule.get(key) || 0) + 1)
|
|
106
|
+
}
|
|
107
|
+
const parts = [...by_rule.entries()].map(([rule, count]) => `${rule} (${count})`)
|
|
108
|
+
return `${warnings.length} warning${warnings.length === 1 ? '' : 's'}: ${parts.join(', ')} — run 'happyskills validate ${skill_name}' for details`
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
const print_json = (data) => {
|
|
101
112
|
console.log(JSON.stringify(data, null, 2))
|
|
102
113
|
}
|
|
@@ -105,4 +116,4 @@ const print_label = (label, value) => {
|
|
|
105
116
|
console.log(`${gray(label + ':')} ${value}`)
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
module.exports = { print_success, print_error, print_warn, print_info, print_hint, print_help, print_table, print_json, print_label, code, format_help, visible_len, ansi_pad, truncate }
|
|
119
|
+
module.exports = { print_success, print_error, print_warn, print_info, print_hint, print_help, print_table, print_json, print_label, code, format_help, visible_len, ansi_pad, truncate, summarize_warnings }
|
package/src/ui/output.test.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
const { describe, it, before, after, beforeEach, afterEach } = require('node:test')
|
|
3
3
|
const assert = require('node:assert/strict')
|
|
4
4
|
|
|
5
|
-
const { visible_len, ansi_pad, truncate, format_help, print_table } = require('./output')
|
|
5
|
+
const { visible_len, ansi_pad, truncate, format_help, print_table, summarize_warnings } = require('./output')
|
|
6
6
|
|
|
7
7
|
// ─── visible_len ─────────────────────────────────────────────────────────────
|
|
8
8
|
|
|
@@ -182,6 +182,53 @@ describe('print_table', () => {
|
|
|
182
182
|
})
|
|
183
183
|
})
|
|
184
184
|
|
|
185
|
+
// ─── summarize_warnings ──────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
describe('summarize_warnings', () => {
|
|
188
|
+
it('returns null for empty array', () => {
|
|
189
|
+
assert.strictEqual(summarize_warnings([], 'my-skill'), null)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('returns null for null/undefined input', () => {
|
|
193
|
+
assert.strictEqual(summarize_warnings(null, 'my-skill'), null)
|
|
194
|
+
assert.strictEqual(summarize_warnings(undefined, 'my-skill'), null)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('uses singular "warning" for a single warning', () => {
|
|
198
|
+
const warnings = [{ rule: 'name_match', message: 'names differ' }]
|
|
199
|
+
const result = summarize_warnings(warnings, 'my-skill')
|
|
200
|
+
assert.ok(result.startsWith('1 warning:'))
|
|
201
|
+
assert.ok(result.includes('name_match (1)'))
|
|
202
|
+
assert.ok(result.includes("'happyskills validate my-skill'"))
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('uses plural "warnings" for multiple warnings', () => {
|
|
206
|
+
const warnings = [
|
|
207
|
+
{ rule: 'no_executable_code', message: 'found code block' },
|
|
208
|
+
{ rule: 'no_executable_code', message: 'found another block' },
|
|
209
|
+
{ rule: 'name_match', message: 'names differ' }
|
|
210
|
+
]
|
|
211
|
+
const result = summarize_warnings(warnings, 'deploy-aws')
|
|
212
|
+
assert.ok(result.startsWith('3 warnings:'))
|
|
213
|
+
assert.ok(result.includes('no_executable_code (2)'))
|
|
214
|
+
assert.ok(result.includes('name_match (1)'))
|
|
215
|
+
assert.ok(result.includes("'happyskills validate deploy-aws'"))
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('groups by rule and counts correctly', () => {
|
|
219
|
+
const warnings = Array.from({ length: 100 }, () => ({ rule: 'no_executable_code', message: 'x' }))
|
|
220
|
+
const result = summarize_warnings(warnings, 'big-skill')
|
|
221
|
+
assert.ok(result.startsWith('100 warnings:'))
|
|
222
|
+
assert.ok(result.includes('no_executable_code (100)'))
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('falls back to "unknown" for warnings without a rule field', () => {
|
|
226
|
+
const warnings = [{ message: 'something' }]
|
|
227
|
+
const result = summarize_warnings(warnings, 'my-skill')
|
|
228
|
+
assert.ok(result.includes('unknown (1)'))
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
185
232
|
// ─── json mode silencing ──────────────────────────────────────────────────────
|
|
186
233
|
|
|
187
234
|
describe('print_success / print_info / print_hint — json mode silencing', () => {
|
|
@@ -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 }
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
const fs = require('fs')
|
|
2
|
-
const path = require('path')
|
|
3
1
|
const { error: { catch_errors } } = require('puffy-core')
|
|
4
2
|
const { SKILL_TYPES } = require('../constants')
|
|
5
3
|
|
|
@@ -61,20 +59,6 @@ const validate_cross = (skill_dir, frontmatter, manifest, skill_md_content, skil
|
|
|
61
59
|
results.push(...scan_code_blocks(skill_md_content, 'SKILL.md'))
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
// Executable code in references/*.md
|
|
65
|
-
const refs_dir = path.join(skill_dir, 'references')
|
|
66
|
-
try {
|
|
67
|
-
const entries = await fs.promises.readdir(refs_dir, { withFileTypes: true })
|
|
68
|
-
for (const entry of entries) {
|
|
69
|
-
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
70
|
-
const ref_path = path.join(refs_dir, entry.name)
|
|
71
|
-
const ref_content = await fs.promises.readFile(ref_path, 'utf-8')
|
|
72
|
-
results.push(...scan_code_blocks(ref_content, `references/${entry.name}`))
|
|
73
|
-
}
|
|
74
|
-
} catch {
|
|
75
|
-
// No references dir — that's fine
|
|
76
|
-
}
|
|
77
|
-
|
|
78
62
|
return results
|
|
79
63
|
})
|
|
80
64
|
|
|
@@ -89,7 +89,7 @@ describe('validate_cross — executable code detection', () => {
|
|
|
89
89
|
assert.ok(!results.some(r => r.rule === 'no_executable_code'))
|
|
90
90
|
})
|
|
91
91
|
|
|
92
|
-
it('
|
|
92
|
+
it('does not scan references/*.md for executable code', async () => {
|
|
93
93
|
const refs_dir = path.join(tmp, 'references')
|
|
94
94
|
fs.mkdirSync(refs_dir)
|
|
95
95
|
const code = '```bash\n#!/bin/bash\nimport os\nset -e\necho "deploy"\n' + 'echo "step"\n'.repeat(8) + '```'
|
|
@@ -97,14 +97,6 @@ describe('validate_cross — executable code detection', () => {
|
|
|
97
97
|
|
|
98
98
|
const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, '')
|
|
99
99
|
assert.ifError(err)
|
|
100
|
-
|
|
101
|
-
assert.strictEqual(check.severity, 'warning')
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('does not error when references dir is missing', async () => {
|
|
105
|
-
const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, '')
|
|
106
|
-
assert.ifError(err)
|
|
107
|
-
// Should not throw or produce error results — just no code block warnings
|
|
108
|
-
assert.ok(!results.some(r => r.severity === 'error'))
|
|
100
|
+
assert.ok(!results.some(r => r.rule === 'no_executable_code'))
|
|
109
101
|
})
|
|
110
102
|
})
|