happyskills 0.8.0 → 0.9.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,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.1] - 2026-03-10
11
+
12
+ ### Changed
13
+ - Change `publish` to run full skill validation (SKILL.md + skill.json + cross-file rules) before publishing, blocking on errors with structured `VALIDATION_FAILED` output in JSON mode — replaces the previous ad-hoc manifest validation and frontmatter warnings
14
+
15
+ ## [0.9.0] - 2026-03-10
16
+
17
+ ### Added
18
+ - 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`)
19
+ - Add SKILL.md frontmatter validation warnings to `publish` and `convert` commands — warn about missing `name` or `description` fields before publishing
20
+
21
+ ### Changed
22
+ - Change `init` scaffolding to generate SKILL.md with proper YAML frontmatter (`name` and `description` fields) instead of a bare markdown heading
23
+
10
24
  ## [0.8.0] - 2026-03-08
11
25
 
12
26
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.8.0",
3
+ "version": "0.9.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)",
@@ -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,7 +1,7 @@
1
+ const path = require('path')
1
2
  const { error: { catch_errors, wrap_errors: e } } = require('puffy-core')
2
3
  const { read_manifest } = require('../manifest/reader')
3
4
  const { write_manifest } = require('../manifest/writer')
4
- const { validate_manifest } = require('../manifest/validator')
5
5
  const { require_token } = require('../auth/token_store')
6
6
  const repos_api = require('../api/repos')
7
7
  const workspaces_api = require('../api/workspaces')
@@ -13,6 +13,9 @@ const { find_project_root } = require('../config/paths')
13
13
  const { read_lock, get_all_locked_skills } = require('../lock/reader')
14
14
  const { write_lock, update_lock_skills } = require('../lock/writer')
15
15
  const { hash_directory } = require('../lock/integrity')
16
+ const { validate_skill_md } = require('../validation/skill_md_rules')
17
+ const { validate_skill_json } = require('../validation/skill_json_rules')
18
+ const { validate_cross } = require('../validation/cross_rules')
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')
@@ -82,9 +85,39 @@ const run = (args) => catch_errors('Publish failed', async () => {
82
85
  if (write_err) throw e('Failed to update manifest version', write_err)
83
86
  }
84
87
 
85
- const validation = validate_manifest(manifest)
86
- if (!validation.valid) {
87
- throw new CliError(`Invalid skill.json: ${validation.errors.join(', ')}`, EXIT_CODES.ERROR)
88
+ // Run full validation block on errors, show warnings
89
+ const dir_name = path.basename(dir)
90
+ const [md_err, md_data] = await validate_skill_md(dir, dir_name)
91
+ if (md_err) throw md_err
92
+ const [json_err, json_data] = await validate_skill_json(dir)
93
+ if (json_err) throw json_err
94
+ const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content)
95
+ if (cross_err) throw cross_err
96
+
97
+ const all_results = [...md_data.results, ...json_data.results, ...cross_results]
98
+ const validation_errors = all_results.filter(r => r.severity === 'error')
99
+ const validation_warnings = all_results.filter(r => r.severity === 'warning')
100
+
101
+ if (validation_errors.length > 0) {
102
+ const { is_json_mode } = require('../state')
103
+ if (is_json_mode()) {
104
+ const structured_errors = validation_errors.map(({ severity, ...rest }) => rest)
105
+ print_json({
106
+ error: {
107
+ code: 'VALIDATION_FAILED',
108
+ message: `Skill failed validation with ${validation_errors.length} error(s). Fix these issues and try again.`,
109
+ exit_code: EXIT_CODES.ERROR,
110
+ validation_errors: structured_errors
111
+ }
112
+ })
113
+ return process.exit(EXIT_CODES.ERROR)
114
+ }
115
+ const error_msgs = validation_errors.map(r => `${r.file || 'General'}${r.field ? ` (${r.field})` : ''}: ${r.message}`)
116
+ 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)
117
+ }
118
+
119
+ for (const w of validation_warnings) {
120
+ print_warn(`${w.file || 'General'}${w.field ? ` (${w.field})` : ''}: ${w.message}`)
88
121
  }
89
122
 
90
123
  const spinner = create_spinner('Preparing to publish...')
@@ -164,13 +197,15 @@ const run = (args) => catch_errors('Publish failed', async () => {
164
197
  }
165
198
 
166
199
  if (args.flags.json) {
167
- print_json({ data: {
200
+ const json_data = {
168
201
  skill: full_name,
169
202
  version: manifest.version,
170
203
  ref: push_data?.ref || `refs/tags/v${manifest.version}`,
171
204
  commit: push_data?.commit || null,
172
205
  bumped_from
173
- } })
206
+ }
207
+ if (validation_warnings.length > 0) json_data.warnings = validation_warnings.map(w => w.message)
208
+ print_json({ data: json_data })
174
209
  return
175
210
  }
176
211
 
@@ -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
+ })