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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -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
- if (managed_entries.length === 0 && external_skills.length === 0) {
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
- print_json({ data: { skills: skills_map, external } })
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
 
@@ -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
- for (const w of validation_warnings) {
129
- print_warn(`${w.file || 'General'}${w.field ? ` (${w.field})` : ''}: ${w.message}`)
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 }
@@ -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
- module.exports = { scan_skills_dir, parse_frontmatter }
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('warns for executable code in references/*.md', async () => {
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
- const check = results.find(r => r.rule === 'no_executable_code' && r.file === 'references/deploy.md')
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
  })