happyskills 0.8.0 → 0.9.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 CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-03-10
11
+
12
+ ### Added
13
+ - Add `validate` command to check a skill against all rules from the agentskills.io spec in one pass — validates SKILL.md frontmatter (name, description, optional fields, line count), skill.json (name, version, description, keywords, dependencies, systemDependencies), and cross-file consistency (name match, executable code detection); supports `--json` and `-g` flags (alias: `v`)
14
+ - Add SKILL.md frontmatter validation warnings to `publish` and `convert` commands — warn about missing `name` or `description` fields before publishing
15
+
16
+ ### Changed
17
+ - Change `init` scaffolding to generate SKILL.md with proper YAML frontmatter (`name` and `description` fields) instead of a bare markdown heading
18
+
10
19
  ## [0.8.0] - 2026-03-08
11
20
 
12
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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)",
@@ -13,7 +13,7 @@ const { hash_directory } = require('../lock/integrity')
13
13
  const { skills_dir, find_project_root, lock_root } = require('../config/paths')
14
14
  const { file_exists } = require('../utils/fs')
15
15
  const { create_spinner } = require('../ui/spinner')
16
- const { print_help, print_success, print_error, print_info, print_label, print_json } = require('../ui/output')
16
+ const { print_help, print_success, print_error, print_info, print_warn, print_label, print_json } = require('../ui/output')
17
17
  const { exit_with_error, UsageError, CliError } = require('../utils/errors')
18
18
  const { EXIT_CODES, SKILL_MD } = require('../constants')
19
19
 
@@ -99,6 +99,25 @@ const run = (args) => catch_errors('Convert failed', async () => {
99
99
  ? args.flags.keywords.split(',').map(k => k.trim()).filter(Boolean)
100
100
  : fm_keywords
101
101
 
102
+ // Warn about missing frontmatter fields that affect auto-invocation
103
+ const frontmatter_warnings = []
104
+ if (!frontmatter) {
105
+ frontmatter_warnings.push('SKILL.md has no YAML frontmatter (---). Without frontmatter, Claude cannot auto-invoke this skill. Add a frontmatter block with name and description fields.')
106
+ } else {
107
+ if (!frontmatter.name) {
108
+ frontmatter_warnings.push('SKILL.md frontmatter is missing "name". Add name: <skill-name> to the frontmatter.')
109
+ }
110
+ if (!frontmatter.description) {
111
+ frontmatter_warnings.push('SKILL.md frontmatter is missing "description". The description is the #1 factor for Claude auto-invocation quality. Without it, the skill will not be auto-invoked.')
112
+ }
113
+ }
114
+
115
+ if (frontmatter_warnings.length > 0) {
116
+ for (const warning of frontmatter_warnings) {
117
+ print_warn(warning)
118
+ }
119
+ }
120
+
102
121
  const spinner = create_spinner('Resolving workspace...')
103
122
 
104
123
  const [ws_err, workspaces] = await workspaces_api.list_workspaces()
@@ -160,12 +179,14 @@ const run = (args) => catch_errors('Convert failed', async () => {
160
179
  pub_spinner.succeed(`Converted ${full_name}@${version}`)
161
180
 
162
181
  if (args.flags.json) {
163
- print_json({ data: {
182
+ const json_data = {
164
183
  skill: full_name,
165
184
  version,
166
185
  workspace: workspace.slug,
167
186
  description: description || ''
168
- } })
187
+ }
188
+ if (frontmatter_warnings.length > 0) json_data.warnings = frontmatter_warnings
189
+ print_json({ data: json_data })
169
190
  return
170
191
  }
171
192
 
@@ -24,11 +24,12 @@ Examples:
24
24
  happyskills init
25
25
  happyskills init my-deploy-skill`
26
26
 
27
- const create_skill_md = (name) => `# ${name}
27
+ const create_skill_md = (name) => `---
28
+ name: ${name}
29
+ description: Describe what this skill does and when to invoke it
30
+ ---
28
31
 
29
- ## Description
30
-
31
- Describe what this skill does for the AI agent.
32
+ # ${name}
32
33
 
33
34
  ## Instructions
34
35
 
@@ -1,3 +1,5 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
1
3
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
4
  const { read_manifest } = require('../manifest/reader')
3
5
  const { write_manifest } = require('../manifest/writer')
@@ -13,10 +15,11 @@ const { find_project_root } = require('../config/paths')
13
15
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
14
16
  const { write_lock, update_lock_skills } = require('../lock/writer')
15
17
  const { hash_directory } = require('../lock/integrity')
18
+ const { parse_frontmatter } = require('../utils/skill_scanner')
16
19
  const { create_spinner } = require('../ui/spinner')
17
20
  const { print_help, print_success, print_error, print_warn, print_hint, print_json, code } = require('../ui/output')
18
21
  const { exit_with_error, UsageError, CliError } = require('../utils/errors')
19
- const { EXIT_CODES } = require('../constants')
22
+ const { EXIT_CODES, SKILL_MD } = require('../constants')
20
23
 
21
24
  const HELP_TEXT = `Usage: happyskills publish <skill-name> [options]
22
25
 
@@ -87,6 +90,31 @@ const run = (args) => catch_errors('Publish failed', async () => {
87
90
  throw new CliError(`Invalid skill.json: ${validation.errors.join(', ')}`, EXIT_CODES.ERROR)
88
91
  }
89
92
 
93
+ // Validate SKILL.md frontmatter — warn about missing name/description
94
+ const frontmatter_warnings = []
95
+ try {
96
+ const skill_md_content = await fs.promises.readFile(path.join(dir, SKILL_MD), 'utf-8')
97
+ const frontmatter = parse_frontmatter(skill_md_content)
98
+ if (!frontmatter) {
99
+ frontmatter_warnings.push('SKILL.md has no YAML frontmatter (---). Without frontmatter, Claude cannot auto-invoke this skill.')
100
+ } else {
101
+ if (!frontmatter.name) {
102
+ frontmatter_warnings.push('SKILL.md frontmatter is missing "name". Add name: <skill-name> to the frontmatter.')
103
+ }
104
+ if (!frontmatter.description) {
105
+ frontmatter_warnings.push('SKILL.md frontmatter is missing "description". The description is the #1 factor for Claude auto-invocation quality. Without it, the skill will not be auto-invoked.')
106
+ }
107
+ }
108
+ } catch {
109
+ frontmatter_warnings.push(`No ${SKILL_MD} found. The skill may not work correctly.`)
110
+ }
111
+
112
+ if (frontmatter_warnings.length > 0) {
113
+ for (const warning of frontmatter_warnings) {
114
+ print_warn(warning)
115
+ }
116
+ }
117
+
90
118
  const spinner = create_spinner('Preparing to publish...')
91
119
 
92
120
  let owner = args.flags.workspace
@@ -164,13 +192,15 @@ const run = (args) => catch_errors('Publish failed', async () => {
164
192
  }
165
193
 
166
194
  if (args.flags.json) {
167
- print_json({ data: {
195
+ const json_data = {
168
196
  skill: full_name,
169
197
  version: manifest.version,
170
198
  ref: push_data?.ref || `refs/tags/v${manifest.version}`,
171
199
  commit: push_data?.commit || null,
172
200
  bumped_from
173
- } })
201
+ }
202
+ if (frontmatter_warnings.length > 0) json_data.warnings = frontmatter_warnings
203
+ print_json({ data: json_data })
174
204
  return
175
205
  }
176
206
 
@@ -0,0 +1,148 @@
1
+ const path = require('path')
2
+ const { error: { catch_errors } } = require('puffy-core')
3
+ const { validate_skill_md } = require('../validation/skill_md_rules')
4
+ const { validate_skill_json } = require('../validation/skill_json_rules')
5
+ const { validate_cross } = require('../validation/cross_rules')
6
+ const { file_exists } = require('../utils/fs')
7
+ const { skills_dir, find_project_root } = require('../config/paths')
8
+ const { print_help, print_json } = require('../ui/output')
9
+ const { bold, green, yellow, red, dim } = require('../ui/colors')
10
+ const { exit_with_error, UsageError } = require('../utils/errors')
11
+ const { EXIT_CODES, SKILL_MD } = require('../constants')
12
+
13
+ const HELP_TEXT = `Usage: happyskills validate <skill-name> [options]
14
+
15
+ Validate a skill against all rules from the agentskills.io spec.
16
+
17
+ Arguments:
18
+ skill-name Name of the skill to validate
19
+
20
+ Options:
21
+ -g, --global Look in global skills (~/.claude/skills/)
22
+ --json Output as JSON
23
+
24
+ Aliases: v
25
+
26
+ Examples:
27
+ happyskills validate my-skill
28
+ happyskills validate deploy-aws -g
29
+ happyskills v my-skill --json`
30
+
31
+ const resolve_validate_dir = async (skill_name, is_global) => {
32
+ const project_root = find_project_root()
33
+ const base = skills_dir(is_global, project_root)
34
+ const dir = path.join(base, skill_name)
35
+
36
+ // Check if directory exists (don't require skill.json — that's what validate checks)
37
+ const md_path = path.join(dir, SKILL_MD)
38
+ const json_path = path.join(dir, 'skill.json')
39
+
40
+ const [, md_exists] = await file_exists(md_path)
41
+ const [, json_exists] = await file_exists(json_path)
42
+
43
+ if (!md_exists && !json_exists) {
44
+ throw new UsageError(`Skill "${skill_name}" not found at ${dir}`)
45
+ }
46
+
47
+ return dir
48
+ }
49
+
50
+ const format_human = (skill_name, all_results) => {
51
+ console.log(`\nValidating skill "${bold(skill_name)}"...\n`)
52
+
53
+ const groups = {}
54
+ for (const r of all_results) {
55
+ const key = r.file || 'General'
56
+ if (!groups[key]) groups[key] = []
57
+ groups[key].push(r)
58
+ }
59
+
60
+ for (const [file, items] of Object.entries(groups)) {
61
+ console.log(` ${bold(file)}`)
62
+ for (const item of items) {
63
+ if (item.severity === 'pass') {
64
+ console.log(` ${green('✓')} ${item.message}`)
65
+ } else if (item.severity === 'warning') {
66
+ console.log(` ${yellow('⚠')} ${item.message}`)
67
+ } else {
68
+ console.log(` ${red('✗')} ${item.message}`)
69
+ }
70
+ }
71
+ console.log()
72
+ }
73
+
74
+ const errors = all_results.filter(r => r.severity === 'error')
75
+ const warnings = all_results.filter(r => r.severity === 'warning')
76
+ const passed = all_results.filter(r => r.severity === 'pass')
77
+
78
+ const parts = []
79
+ if (warnings.length > 0) parts.push(yellow(`${warnings.length} warning${warnings.length === 1 ? '' : 's'}`))
80
+ if (errors.length > 0) parts.push(red(`${errors.length} error${errors.length === 1 ? '' : 's'}`))
81
+ if (errors.length === 0 && warnings.length === 0) parts.push(green('all checks passed'))
82
+
83
+ console.log(dim(`${passed.length} passed, `) + parts.join(', '))
84
+ }
85
+
86
+ const format_json = (skill_name, all_results) => {
87
+ const errors = all_results.filter(r => r.severity === 'error').map(({ severity, ...rest }) => rest)
88
+ const warnings = all_results.filter(r => r.severity === 'warning').map(({ severity, ...rest }) => rest)
89
+ const passed = all_results.filter(r => r.severity === 'pass')
90
+ const failed = all_results.filter(r => r.severity === 'error')
91
+ const warned = all_results.filter(r => r.severity === 'warning')
92
+
93
+ print_json({
94
+ data: {
95
+ skill: skill_name,
96
+ valid: failed.length === 0,
97
+ errors,
98
+ warnings,
99
+ checks_passed: passed.length,
100
+ checks_failed: failed.length,
101
+ checks_warned: warned.length
102
+ }
103
+ })
104
+ }
105
+
106
+ const run = (args) => catch_errors('Validate failed', async () => {
107
+ if (args.flags._show_help) {
108
+ print_help(HELP_TEXT)
109
+ return process.exit(EXIT_CODES.SUCCESS)
110
+ }
111
+
112
+ const skill_name = args._[0]
113
+ if (!skill_name) {
114
+ throw new UsageError('Skill name required. Usage: happyskills validate <skill-name>')
115
+ }
116
+
117
+ const is_global = !!args.flags.global
118
+ const skill_dir = await resolve_validate_dir(skill_name, is_global)
119
+ const dir_name = path.basename(skill_dir)
120
+
121
+ // Run all rule modules
122
+ const [md_err, md_data] = await validate_skill_md(skill_dir, dir_name)
123
+ if (md_err) throw md_err
124
+
125
+ const [json_err, json_data] = await validate_skill_json(skill_dir)
126
+ if (json_err) throw json_err
127
+
128
+ const [cross_err, cross_results] = await validate_cross(
129
+ skill_dir,
130
+ md_data.frontmatter,
131
+ json_data.manifest,
132
+ md_data.content
133
+ )
134
+ if (cross_err) throw cross_err
135
+
136
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results]
137
+
138
+ if (args.flags.json) {
139
+ format_json(skill_name, all_results)
140
+ } else {
141
+ format_human(skill_name, all_results)
142
+ }
143
+
144
+ const has_errors = all_results.some(r => r.severity === 'error')
145
+ process.exit(has_errors ? EXIT_CODES.ERROR : EXIT_CODES.SUCCESS)
146
+ }).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
147
+
148
+ module.exports = { run }
package/src/constants.js CHANGED
@@ -28,7 +28,8 @@ const COMMAND_ALIASES = {
28
28
  s: 'search',
29
29
  r: 'refresh',
30
30
  up: 'update',
31
- pub: 'publish'
31
+ pub: 'publish',
32
+ v: 'validate'
32
33
  }
33
34
 
34
35
  const COMMANDS = [
@@ -48,6 +49,7 @@ const COMMANDS = [
48
49
  'whoami',
49
50
  'fork',
50
51
  'setup',
52
+ 'validate',
51
53
  'self-update'
52
54
  ]
53
55
 
package/src/index.js CHANGED
@@ -93,6 +93,7 @@ Commands:
93
93
  refresh Check + update all outdated skills (alias: r)
94
94
  update [owner/skill] Upgrade to latest versions (alias: up)
95
95
  publish Push skill to registry (alias: pub)
96
+ validate <skill-name> Validate skill against all rules (alias: v)
96
97
  fork <owner/skill> Fork a skill to your workspace
97
98
  setup Install the HappySkills CLI skill globally
98
99
  self-update Upgrade happyskills CLI to latest version
@@ -92,7 +92,7 @@ describe('CLI — global flags', () => {
92
92
 
93
93
  it('--help lists all expected commands', () => {
94
94
  const { stdout } = run(['--help'])
95
- const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', 'refresh', 'update', 'publish', 'fork', 'login', 'logout', 'whoami', 'init', 'setup', 'self-update']
95
+ const expected_commands = ['install', 'uninstall', 'list', 'search', 'check', 'refresh', 'update', 'publish', 'validate', 'fork', 'login', 'logout', 'whoami', 'init', 'setup', 'self-update']
96
96
  for (const cmd of expected_commands) {
97
97
  assert.ok(stdout.includes(cmd), `help output should mention "${cmd}"`)
98
98
  }
@@ -165,6 +165,9 @@ describe('CLI — command --help', () => {
165
165
  ['refresh', 'Aliases:'],
166
166
  ['update', 'Aliases:'],
167
167
  ['publish', 'Aliases:'],
168
+ ['validate', 'Arguments:'],
169
+ ['validate', 'Aliases:'],
170
+ ['validate', 'Examples:'],
168
171
  ['fork', 'Arguments:'],
169
172
  ['init', 'Examples:'],
170
173
  ['setup', 'Options:'],
@@ -199,6 +202,7 @@ describe('CLI — command aliases', () => {
199
202
  ['r', 'refresh'],
200
203
  ['up', 'update'],
201
204
  ['pub', 'publish'],
205
+ ['v', 'validate'],
202
206
  ]
203
207
 
204
208
  for (const [alias, canonical] of alias_cases) {
@@ -478,3 +482,127 @@ describe('CLI — self-update command', () => {
478
482
  assert.strictEqual(out.error.exit_code, 4)
479
483
  })
480
484
  })
485
+
486
+ // ─── validate command ─────────────────────────────────────────────────────────
487
+
488
+ describe('CLI — validate command', () => {
489
+ it('validate --help exits 0 and shows usage', () => {
490
+ const { stdout, code } = run(['validate', '--help'])
491
+ assert.strictEqual(code, 0)
492
+ assert.ok(stdout.includes('Arguments:'))
493
+ assert.ok(stdout.includes('Aliases:'))
494
+ assert.ok(stdout.includes('Examples:'))
495
+ })
496
+
497
+ it('v alias resolves to validate (shows correct --help)', () => {
498
+ const { stdout, code } = run(['v', '--help'])
499
+ assert.strictEqual(code, 0)
500
+ assert.ok(stdout.toLowerCase().includes('validate'))
501
+ })
502
+
503
+ it('exits 2 when no skill name is given', () => {
504
+ const { stderr, code } = run(['validate'])
505
+ assert.strictEqual(code, 2)
506
+ assert.ok(stderr.includes('Skill name required'))
507
+ })
508
+
509
+ it('exits 2 when skill does not exist', () => {
510
+ const tmp = make_tmp()
511
+ try {
512
+ const { code } = run(['validate', 'nonexistent'], {}, { cwd: tmp })
513
+ assert.strictEqual(code, 2)
514
+ } finally {
515
+ fs.rmSync(tmp, { recursive: true, force: true })
516
+ }
517
+ })
518
+
519
+ it('exits 2 (JSON) when skill does not exist', () => {
520
+ const tmp = make_tmp()
521
+ try {
522
+ const { stdout, code } = run(['validate', 'nonexistent', '--json'], {}, { cwd: tmp })
523
+ assert.strictEqual(code, 2)
524
+ const out = parse_json_output(stdout, 'validate nonexistent --json')
525
+ assert.ok('error' in out)
526
+ assert.strictEqual(out.error.code, 'USAGE_ERROR')
527
+ } finally {
528
+ fs.rmSync(tmp, { recursive: true, force: true })
529
+ }
530
+ })
531
+
532
+ it('exits 0 for a valid skill and reports all checks passed', () => {
533
+ const tmp = make_tmp()
534
+ const skill_dir = path.join(tmp, '.claude', 'skills', 'test-skill')
535
+ fs.mkdirSync(skill_dir, { recursive: true })
536
+ fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: test-skill\ndescription: A valid test skill for unit testing\n---\n\n# Test Skill\n')
537
+ fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({
538
+ name: 'test-skill', version: '1.0.0', description: 'A test skill',
539
+ keywords: ['testing']
540
+ }, null, '\t'))
541
+ try {
542
+ const { stdout, code } = run(['validate', 'test-skill'], {}, { cwd: tmp })
543
+ assert.strictEqual(code, 0)
544
+ assert.ok(stdout.includes('test-skill'))
545
+ } finally {
546
+ fs.rmSync(tmp, { recursive: true, force: true })
547
+ }
548
+ })
549
+
550
+ it('exits 1 for an invalid skill', () => {
551
+ const tmp = make_tmp()
552
+ const skill_dir = path.join(tmp, '.claude', 'skills', 'bad-skill')
553
+ fs.mkdirSync(skill_dir, { recursive: true })
554
+ fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: bad-skill\n---\n')
555
+ fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({ name: 'bad-skill' }, null, '\t'))
556
+ try {
557
+ const { code } = run(['validate', 'bad-skill'], {}, { cwd: tmp })
558
+ assert.strictEqual(code, 1)
559
+ } finally {
560
+ fs.rmSync(tmp, { recursive: true, force: true })
561
+ }
562
+ })
563
+
564
+ it('--json returns correct schema for a valid skill', () => {
565
+ const tmp = make_tmp()
566
+ const skill_dir = path.join(tmp, '.claude', 'skills', 'test-skill')
567
+ fs.mkdirSync(skill_dir, { recursive: true })
568
+ fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: test-skill\ndescription: A valid test skill for unit testing\n---\n\n# Test\n')
569
+ fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({
570
+ name: 'test-skill', version: '1.0.0', description: 'A test skill',
571
+ keywords: ['testing']
572
+ }, null, '\t'))
573
+ try {
574
+ const { stdout, code } = run(['validate', 'test-skill', '--json'], {}, { cwd: tmp })
575
+ assert.strictEqual(code, 0)
576
+ const out = parse_json_output(stdout, 'validate --json valid skill')
577
+ assert.ok('data' in out)
578
+ assert.strictEqual(out.data.skill, 'test-skill')
579
+ assert.strictEqual(out.data.valid, true)
580
+ assert.ok(Array.isArray(out.data.errors))
581
+ assert.ok(Array.isArray(out.data.warnings))
582
+ assert.strictEqual(typeof out.data.checks_passed, 'number')
583
+ assert.strictEqual(typeof out.data.checks_failed, 'number')
584
+ assert.strictEqual(typeof out.data.checks_warned, 'number')
585
+ assert.strictEqual(out.data.checks_failed, 0)
586
+ } finally {
587
+ fs.rmSync(tmp, { recursive: true, force: true })
588
+ }
589
+ })
590
+
591
+ it('--json returns errors for an invalid skill', () => {
592
+ const tmp = make_tmp()
593
+ const skill_dir = path.join(tmp, '.claude', 'skills', 'bad-skill')
594
+ fs.mkdirSync(skill_dir, { recursive: true })
595
+ fs.writeFileSync(path.join(skill_dir, 'SKILL.md'), '---\nname: bad-skill\n---\n')
596
+ fs.writeFileSync(path.join(skill_dir, 'skill.json'), JSON.stringify({ name: 'bad-skill' }, null, '\t'))
597
+ try {
598
+ const { stdout, code } = run(['validate', 'bad-skill', '--json'], {}, { cwd: tmp })
599
+ assert.strictEqual(code, 1)
600
+ const out = parse_json_output(stdout, 'validate --json invalid skill')
601
+ assert.strictEqual(out.data.valid, false)
602
+ assert.ok(out.data.errors.length > 0)
603
+ assert.ok(out.data.checks_failed > 0)
604
+ } finally {
605
+ fs.rmSync(tmp, { recursive: true, force: true })
606
+ }
607
+ })
608
+ })
@@ -0,0 +1,76 @@
1
+ const fs = require('fs')
2
+ const path = require('path')
3
+ const { error: { catch_errors } } = require('puffy-core')
4
+
5
+ const EXEC_LANGS = new Set(['python', 'bash', 'sh', 'javascript', 'js', 'typescript', 'ts', 'ruby', 'go', 'rust'])
6
+ const SCRIPT_MARKERS = /^\s*(#!\/|import |from |require\(|def |class |function |const |let |var |export |module\.|package |fn |func |pub fn)/
7
+
8
+ const result = (file, field, rule, severity, message, value) => ({
9
+ file, field, rule, severity, message, ...(value !== undefined ? { value } : {})
10
+ })
11
+
12
+ const scan_code_blocks = (content, file_label) => {
13
+ const results = []
14
+ const block_rx = /```(\w+)?\n([\s\S]*?)```/g
15
+ let match
16
+
17
+ while ((match = block_rx.exec(content)) !== null) {
18
+ const lang = (match[1] || '').toLowerCase()
19
+ if (!EXEC_LANGS.has(lang)) continue
20
+
21
+ const body = match[2]
22
+ const lines = body.split('\n')
23
+ const line_count = lines.filter(l => l.trim()).length
24
+ const marker_count = lines.filter(l => SCRIPT_MARKERS.test(l)).length
25
+
26
+ if (marker_count >= 2 || line_count > 10) {
27
+ results.push(result(file_label, null, 'no_executable_code', 'warning',
28
+ `Found ${lang} code block (${line_count} lines) that looks like an executable script — consider moving to scripts/`,
29
+ lang
30
+ ))
31
+ }
32
+ }
33
+
34
+ return results
35
+ }
36
+
37
+ const validate_cross = (skill_dir, frontmatter, manifest, skill_md_content) => catch_errors('Failed cross-file validation', async () => {
38
+ const results = []
39
+
40
+ // Name match
41
+ if (frontmatter && manifest) {
42
+ if (frontmatter.name !== manifest.name) {
43
+ results.push(result('Cross-file', 'name', 'name_match', 'warning',
44
+ `SKILL.md name "${frontmatter.name}" does not match skill.json name "${manifest.name}"`,
45
+ `${frontmatter.name} vs ${manifest.name}`
46
+ ))
47
+ } else {
48
+ results.push(result('Cross-file', 'name', 'name_match', 'pass',
49
+ `Names match: "${frontmatter.name}"`
50
+ ))
51
+ }
52
+ }
53
+
54
+ // Executable code in SKILL.md
55
+ if (skill_md_content) {
56
+ results.push(...scan_code_blocks(skill_md_content, 'SKILL.md'))
57
+ }
58
+
59
+ // Executable code in references/*.md
60
+ const refs_dir = path.join(skill_dir, 'references')
61
+ try {
62
+ const entries = await fs.promises.readdir(refs_dir, { withFileTypes: true })
63
+ for (const entry of entries) {
64
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
65
+ const ref_path = path.join(refs_dir, entry.name)
66
+ const ref_content = await fs.promises.readFile(ref_path, 'utf-8')
67
+ results.push(...scan_code_blocks(ref_content, `references/${entry.name}`))
68
+ }
69
+ } catch {
70
+ // No references dir — that's fine
71
+ }
72
+
73
+ return results
74
+ })
75
+
76
+ module.exports = { validate_cross }
@@ -0,0 +1,88 @@
1
+ 'use strict'
2
+ const { describe, it, beforeEach, afterEach } = require('node:test')
3
+ const assert = require('node:assert/strict')
4
+ const fs = require('fs')
5
+ const os = require('os')
6
+ const path = require('path')
7
+
8
+ const { validate_cross } = require('./cross_rules')
9
+
10
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'hs-validate-cross-'))
11
+
12
+ let tmp
13
+ beforeEach(() => { tmp = make_tmp() })
14
+ afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }) })
15
+
16
+ describe('validate_cross — name match', () => {
17
+ it('passes when names match', async () => {
18
+ const [err, results] = await validate_cross(tmp, { name: 'my-skill' }, { name: 'my-skill' }, '')
19
+ assert.ifError(err)
20
+ const check = results.find(r => r.rule === 'name_match')
21
+ assert.strictEqual(check.severity, 'pass')
22
+ })
23
+
24
+ it('warns when names differ', async () => {
25
+ const [err, results] = await validate_cross(tmp, { name: 'skill-a' }, { name: 'skill-b' }, '')
26
+ assert.ifError(err)
27
+ const check = results.find(r => r.rule === 'name_match')
28
+ assert.strictEqual(check.severity, 'warning')
29
+ assert.ok(check.message.includes('skill-a'))
30
+ assert.ok(check.message.includes('skill-b'))
31
+ })
32
+
33
+ it('skips name match when frontmatter is null', async () => {
34
+ const [err, results] = await validate_cross(tmp, null, { name: 'my-skill' }, '')
35
+ assert.ifError(err)
36
+ assert.ok(!results.some(r => r.rule === 'name_match'))
37
+ })
38
+
39
+ it('skips name match when manifest is null', async () => {
40
+ const [err, results] = await validate_cross(tmp, { name: 'my-skill' }, null, '')
41
+ assert.ifError(err)
42
+ assert.ok(!results.some(r => r.rule === 'name_match'))
43
+ })
44
+ })
45
+
46
+ describe('validate_cross — executable code detection', () => {
47
+ it('warns for a long python code block in SKILL.md', async () => {
48
+ const code_block = '```python\nimport os\nimport sys\n' + 'print("hello")\n'.repeat(10) + '```'
49
+ const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, code_block)
50
+ assert.ifError(err)
51
+ const check = results.find(r => r.rule === 'no_executable_code')
52
+ assert.strictEqual(check.severity, 'warning')
53
+ assert.ok(check.message.includes('python'))
54
+ })
55
+
56
+ it('does not flag short documentation code blocks', async () => {
57
+ const code_block = '```python\nprint("hello")\n```'
58
+ const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, code_block)
59
+ assert.ifError(err)
60
+ assert.ok(!results.some(r => r.rule === 'no_executable_code'))
61
+ })
62
+
63
+ it('does not flag non-executable language blocks', async () => {
64
+ const code_block = '```yaml\nname: test\nversion: 1.0.0\n```'
65
+ const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, code_block)
66
+ assert.ifError(err)
67
+ assert.ok(!results.some(r => r.rule === 'no_executable_code'))
68
+ })
69
+
70
+ it('warns for executable code in references/*.md', async () => {
71
+ const refs_dir = path.join(tmp, 'references')
72
+ fs.mkdirSync(refs_dir)
73
+ const code = '```bash\n#!/bin/bash\nimport os\nset -e\necho "deploy"\n' + 'echo "step"\n'.repeat(8) + '```'
74
+ fs.writeFileSync(path.join(refs_dir, 'deploy.md'), code)
75
+
76
+ const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, '')
77
+ assert.ifError(err)
78
+ const check = results.find(r => r.rule === 'no_executable_code' && r.file === 'references/deploy.md')
79
+ assert.strictEqual(check.severity, 'warning')
80
+ })
81
+
82
+ it('does not error when references dir is missing', async () => {
83
+ const [err, results] = await validate_cross(tmp, { name: 'x' }, { name: 'x' }, '')
84
+ assert.ifError(err)
85
+ // Should not throw or produce error results — just no code block warnings
86
+ assert.ok(!results.some(r => r.severity === 'error'))
87
+ })
88
+ })