happyskills 0.51.0 → 0.52.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,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.52.1] - 2026-05-25
11
+
12
+ ### Fixed
13
+
14
+ - **`release` now validates kits against the kit contract (README.md) instead of the skill contract** (`cli/src/commands/release.js`). `run_validation()` was calling `validate_skill_md(dir, basename, null)` — hard-coding the third argument to `null` instead of reading the `type` from `skill.json`. For kit repos this meant the skill branch fired even though the kit had no `SKILL.md`, so `release` failed with `"SKILL.md not found"` immediately after CLI v0.52.0 moved kits to `README.md`. Discovered while migrating `nicolasdao/_kit-doc-essentials` from the old to the new format. Now reads `manifest.type` from `skill.json` first and passes it through to both `validate_skill_md` and `validate_cross`. The standalone `validate` command was unaffected — it was already wiring `skill_type` through correctly.
15
+
16
+ ## [0.52.0] - 2026-05-25
17
+
18
+ ### Added
19
+
20
+ - **Schema-driven SKILL.md frontmatter validator with line-pinpointed, auto-correctable errors** (`cli/src/validation/frontmatter_schema.js`, `cli/src/utils/yaml_frontmatter.js`, rewritten `cli/src/validation/skill_md_rules.js`). Every known frontmatter field is now declared once in a single schema (`name`, `description`, `argument-hint`, `compatibility`, `allowed-tools`, `disable-model-invocation`, `user-invocable`, `context`, `agent`, `keywords`) with its accepted YAML types and constraints. One generic pass type-checks every field, applies field-specific rules (max length, pattern, enum, forbidden chars), and flags unknown fields with a Levenshtein-1 did-you-mean (typos like `argument_hint` → `argument-hint`). Unknown fields that aren't near a known one pass silently for forward-compat with future Anthropic additions.
21
+ - **Typed YAML frontmatter parser** — a new `parse_typed()` returns each field with its parsed value AND its YAML-inferred type (`string` / `array` / `object` / `boolean` / `null` / `number` / `block_scalar`), plus the 1-based file-line number, the raw text, and a `quoted` marker (`plain` / `single` / `double`). The legacy plain-string `parse_frontmatter` is preserved for downstream callers (`convert`, `scan_skills_dir`). This is what lets the validator see what Claude Code's stricter loader sees — e.g. `argument-hint: [foo]` parsing as `["foo"]` rather than `"[foo]"`.
22
+ - **Auto-correct contract on every frontmatter error**. Each error result now ships, in addition to `file` + `field` + `rule` + `severity` + `message`: a `line` (file line, not body line — matches `head -n N`), an `expected` and `actual` for type mismatches, a `character` + `character_name` + `char_index` for forbidden-char errors, a `suggestion` for did-you-mean warnings, and — where applicable — a literal `fix` line the caller (LLM or human) can write back verbatim (e.g. `fix: 'argument-hint: "[question or topic]"'`). Every error message also leads with `SKILL.md line N: ...` so the file-line locator is in the prose too. Locked in by a new `error contract for LLM consumers` test suite.
23
+
24
+ ### Changed
25
+
26
+ - **Frontmatter type mismatches on known string fields are now hard errors at the publish gate**, fixing the bug in which `argument-hint: [question or topic]` (no quotes) shipped as the SKILL.md of `init-context` and several other authored-in-place skills. YAML parses the bracketed form as a flow sequence (an array), so Claude Code's loader silently dropped or warned about the skill. The new validator catches this class deterministically — bracket-bug (`[...]` → array), brace-bug (`{...}` → object), boolean-as-string (`disable-model-invocation: maybe`), forbidden-char-in-known-field (e.g. unquoted colon in `description`) — and emits an actionable fix line. Promoted from warning → error: `disable-model-invocation` / `user-invocable` with a non-boolean value (was a soft warning; the loader treats it as a strict YAML boolean, so a non-boolean was always a contract violation). Bug-side fix: `init-context/SKILL.md` `argument-hint` re-quoted.
27
+ - **Kits now use `README.md` instead of a frontmatter-less `SKILL.md`** (`cli/src/commands/init.js`, `cli/src/constants.js`, `cli/src/utils/skill_scanner.js`, `cli/src/validation/skill_md_rules.js`). The previous design kept kits invisible to Claude Code by writing a `SKILL.md` with no YAML frontmatter — Claude silently ignored it, but Codex and Gemini correctly treat a frontmatter-less `SKILL.md` as a malformed skill and emit warnings on every load. Splitting kits onto `README.md` removes the warning class entirely: every agent runtime ignores `README.md`, no runtime-specific exemption is required, and the kit's invisibility is now a structural property (no `SKILL.md` present) instead of a runtime trick.
28
+ - `init --kit` now writes `README.md` and lists it in `files_created`. Help text updated.
29
+ - `scan_skills_dir` now reads `skill.json` first; for a kit manifest, it requires `README.md` and derives identity from `skill.json` (kits have no `SKILL.md` frontmatter to read `name` / `description` from). Regular skills continue to surface via `SKILL.md` frontmatter, unchanged.
30
+ - `validate` for kits requires a `README.md` and rejects any `SKILL.md` found inside a kit directory — its presence would re-expose the kit to agent auto-invocation and re-trigger the Codex/Gemini warnings the new layout exists to avoid.
31
+ - `README_MD = 'README.md'` added to `cli/src/constants.js`.
32
+
33
+ ### Rationale
34
+
35
+ The frontmatter-less `SKILL.md` hack worked in Claude Code only — it relied on Claude silently dropping skills with no `description` field. Codex and Gemini, which faithfully implement the public skill spec, treat the same file as malformed and warn on every session. That conflicts with the mission's multi-agent posture (skills must work across Claude Code, Cursor, Windsurf, Codex, etc.) and the principal-surface promise of "no unexplained jargon" (the user sees warnings about their kit that they can't act on). Switching to `README.md` aligns the kit format with primitives every tool already understands, and turns invisibility into a structural property of the kit folder rather than a runtime-specific exemption.
36
+
37
+ Existing kits on the registry (a small handful at the time of writing) are migrated by hand — there is no auto-migration. Once republished with `README.md`, future installs land the kit in the new shape.
38
+
10
39
  ## [0.51.0] - 2026-05-25
11
40
 
12
41
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.51.0",
3
+ "version": "0.52.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)",
@@ -4,7 +4,7 @@ const { write_manifest } = require('../manifest/writer')
4
4
  const { write_file, file_exists, ensure_dir } = require('../utils/fs')
5
5
  const { print_success, print_error, print_help, print_hint, print_json, print_info, print_warn, code } = require('../ui/output')
6
6
  const { exit_with_error, CliError, UsageError } = require('../utils/errors')
7
- const { SKILL_JSON, SKILL_MD, EXIT_CODES, SKILL_TYPES, KIT_PREFIX } = require('../constants')
7
+ const { SKILL_JSON, SKILL_MD, README_MD, EXIT_CODES, SKILL_TYPES, KIT_PREFIX } = require('../constants')
8
8
  const { find_project_root, skills_dir } = require('../config/paths')
9
9
  const { resolve_agents, link_to_agents } = require('../agents')
10
10
 
@@ -15,6 +15,8 @@ Scaffold a new skill in .agents/skills/<name>/.
15
15
  Creates:
16
16
  .agents/skills/<name>/skill.json Skill manifest (name, version, deps)
17
17
  .agents/skills/<name>/SKILL.md Skill instructions for AI agents
18
+ (kits get README.md instead — kits
19
+ are not invocable by agents)
18
20
 
19
21
  Arguments:
20
22
  name Skill name (required, e.g., my-deploy-skill)
@@ -97,12 +99,17 @@ const run = (args) => catch_errors('Init failed', async () => {
97
99
 
98
100
  const files_created = [SKILL_JSON]
99
101
 
100
- const [, md_exists] = await file_exists(path.join(dir, SKILL_MD))
102
+ // Kits use README.md (no agent-invocable content); skills use SKILL.md.
103
+ // Splitting the file name is what keeps kits invisible to every agent
104
+ // runtime without relying on a missing-frontmatter trick that triggers
105
+ // warnings in Codex/Gemini.
106
+ const md_file = is_kit ? README_MD : SKILL_MD
107
+ const [, md_exists] = await file_exists(path.join(dir, md_file))
101
108
  if (!md_exists) {
102
109
  const md_content = is_kit ? create_kit_md(final_name) : create_skill_md(final_name)
103
- const [md_err] = await write_file(path.join(dir, SKILL_MD), md_content)
104
- if (md_err) throw e('Failed to write SKILL.md', md_err)
105
- files_created.push(SKILL_MD)
110
+ const [md_err] = await write_file(path.join(dir, md_file), md_content)
111
+ if (md_err) throw e(`Failed to write ${md_file}`, md_err)
112
+ files_created.push(md_file)
106
113
  }
107
114
 
108
115
  const label = is_kit ? 'kit' : 'skill'
@@ -128,7 +135,7 @@ const run = (args) => catch_errors('Init failed', async () => {
128
135
 
129
136
  print_success(`Initialized ${label} "${final_name}" at ${dir}`)
130
137
  console.log(` ${SKILL_JSON} — manifest`)
131
- console.log(` ${SKILL_MD} — ${is_kit ? 'kit description' : 'instructions'}`)
138
+ console.log(` ${md_file} — ${is_kit ? 'kit description' : 'instructions'}`)
132
139
  if (linked_agents.length > 0) {
133
140
  print_info(`Linked to: ${agents_result.agents.map(a => a.display_name).join(', ')}`)
134
141
  }
@@ -122,11 +122,14 @@ const compute_bump = (base_version, bump) => {
122
122
  }
123
123
 
124
124
  const run_validation = (dir, skill_name) => catch_errors('Validation failed', async () => {
125
- const [md_err, md_data] = await validate_skill_md(dir, path.basename(dir), null)
126
- if (md_err) throw md_err
125
+ // Read skill.json first so we know whether to validate against the skill
126
+ // (SKILL.md + frontmatter) or kit (README.md, no frontmatter) contract.
127
127
  const [json_err, json_data] = await validate_skill_json(dir)
128
128
  if (json_err) throw json_err
129
- const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content, json_data.manifest?.type)
129
+ const skill_type = json_data.manifest?.type
130
+ const [md_err, md_data] = await validate_skill_md(dir, path.basename(dir), skill_type)
131
+ if (md_err) throw md_err
132
+ const [cross_err, cross_results] = await validate_cross(dir, md_data.frontmatter, json_data.manifest, md_data.content, skill_type)
130
133
  if (cross_err) throw cross_err
131
134
  const [marker_err, marker_results] = await validate_no_conflict_markers(dir)
132
135
  if (marker_err) throw marker_err
package/src/constants.js CHANGED
@@ -20,6 +20,7 @@ const LOCK_VERSION = 2
20
20
 
21
21
  const SKILL_JSON = 'skill.json'
22
22
  const SKILL_MD = 'SKILL.md'
23
+ const README_MD = 'README.md'
23
24
  const LOCK_FILE = 'skills-lock.json'
24
25
  const INSTALL_LOCK = '.install.lock'
25
26
 
@@ -91,6 +92,7 @@ module.exports = {
91
92
  LOCK_VERSION,
92
93
  SKILL_JSON,
93
94
  SKILL_MD,
95
+ README_MD,
94
96
  LOCK_FILE,
95
97
  INSTALL_LOCK,
96
98
  COMMAND_ALIASES,
@@ -2,7 +2,7 @@ const fs = require('fs')
2
2
  const path = require('path')
3
3
  const { error: { catch_errors } } = require('puffy-core')
4
4
  const { valid: valid_semver } = require('./semver')
5
- const { SKILL_JSON, SKILL_TYPES } = require('../constants')
5
+ const { SKILL_JSON, SKILL_MD, README_MD, SKILL_TYPES } = require('../constants')
6
6
 
7
7
  const SKIP_ENTRIES = new Set(['.tmp', '.DS_Store', '.install.lock'])
8
8
 
@@ -48,29 +48,50 @@ const scan_skills_dir = (base_dir) => catch_errors('Failed to scan skills direct
48
48
  if (entry.name.startsWith('.') || SKIP_ENTRIES.has(entry.name)) continue
49
49
 
50
50
  const dir = path.join(base_dir, entry.name)
51
- const skill_md_path = path.join(dir, 'SKILL.md')
52
51
 
53
- let content
52
+ // Read skill.json first — it's the authoritative manifest. Kits have
53
+ // no SKILL.md at all (only README.md) so the manifest is the only
54
+ // reliable name/description source for them.
55
+ let manifest = null
54
56
  try {
55
- content = await fs.promises.readFile(skill_md_path, 'utf-8')
57
+ const raw = await fs.promises.readFile(path.join(dir, SKILL_JSON), 'utf-8')
58
+ manifest = JSON.parse(raw)
56
59
  } catch {
60
+ // No skill.json — could still be a foreign skill with a frontmatter SKILL.md. manifest stays null.
61
+ }
62
+
63
+ const is_kit_manifest = manifest && manifest.type === SKILL_TYPES.KIT
64
+
65
+ if (is_kit_manifest) {
66
+ // Kit branch: require README.md presence, derive identity from skill.json.
67
+ let readme_ok = true
68
+ try { await fs.promises.access(path.join(dir, README_MD)) } catch { readme_ok = false }
69
+ if (!readme_ok) continue
70
+
71
+ const is_draft = is_happyskills_shaped_manifest(manifest)
72
+ skills.push({
73
+ name: manifest.name,
74
+ description: manifest.description || '',
75
+ is_draft,
76
+ version: is_draft ? manifest.version : null,
77
+ type: SKILL_TYPES.KIT
78
+ })
57
79
  continue
58
80
  }
59
81
 
60
- const frontmatter = parse_frontmatter(content)
61
- if (!frontmatter || !frontmatter.name) continue
82
+ // Skill branch: SKILL.md is required (frontmatter is the discovery signal).
83
+ const skill_md_path = path.join(dir, SKILL_MD)
62
84
 
63
- // Detect whether this skill has a HappySkills-shaped manifest. If yes,
64
- // callers can classify it as a "draft" (scaffolded by `init`, not yet
65
- // claimed in the lock) rather than "external" (genuinely foreign).
66
- let manifest = null
85
+ let content
67
86
  try {
68
- const raw = await fs.promises.readFile(path.join(dir, SKILL_JSON), 'utf-8')
69
- manifest = JSON.parse(raw)
87
+ content = await fs.promises.readFile(skill_md_path, 'utf-8')
70
88
  } catch {
71
- // No skill.json — genuinely external. manifest stays null.
89
+ continue
72
90
  }
73
91
 
92
+ const frontmatter = parse_frontmatter(content)
93
+ if (!frontmatter || !frontmatter.name) continue
94
+
74
95
  const is_draft = is_happyskills_shaped_manifest(manifest)
75
96
 
76
97
  skills.push({
@@ -0,0 +1,61 @@
1
+ const { describe, it, before, after } = require('node:test')
2
+ const assert = require('node:assert')
3
+ const fs = require('node:fs')
4
+ const os = require('node:os')
5
+ const path = require('node:path')
6
+ const { scan_skills_dir } = require('./skill_scanner')
7
+
8
+ const make_tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'scanner-'))
9
+
10
+ const write_skill = (root, name, files) => {
11
+ const dir = path.join(root, name)
12
+ fs.mkdirSync(dir, { recursive: true })
13
+ for (const [file, content] of Object.entries(files)) {
14
+ fs.writeFileSync(path.join(dir, file), content)
15
+ }
16
+ return dir
17
+ }
18
+
19
+ describe('scan_skills_dir — kit drafts detected via README.md', () => {
20
+ let tmp
21
+
22
+ before(() => { tmp = make_tmp() })
23
+ after(() => { fs.rmSync(tmp, { recursive: true, force: true }) })
24
+
25
+ it('surfaces a kit (skill.json + README.md, no SKILL.md) as a draft', async () => {
26
+ write_skill(tmp, '_kit-demo', {
27
+ 'skill.json': JSON.stringify({ name: '_kit-demo', version: '0.1.0', type: 'kit', description: 'A demo kit' }),
28
+ 'README.md': '# _kit-demo\n\nThis is a kit.'
29
+ })
30
+ const [err, skills] = await scan_skills_dir(tmp)
31
+ assert.ifError(err)
32
+ const kit = skills.find(s => s.name === '_kit-demo')
33
+ assert.ok(kit, 'kit should be discovered')
34
+ assert.strictEqual(kit.type, 'kit')
35
+ assert.strictEqual(kit.is_draft, true)
36
+ assert.strictEqual(kit.version, '0.1.0')
37
+ assert.strictEqual(kit.description, 'A demo kit')
38
+ })
39
+
40
+ it('skips a kit folder that has skill.json but no README.md', async () => {
41
+ write_skill(tmp, '_kit-no-readme', {
42
+ 'skill.json': JSON.stringify({ name: '_kit-no-readme', version: '0.1.0', type: 'kit' })
43
+ })
44
+ const [err, skills] = await scan_skills_dir(tmp)
45
+ assert.ifError(err)
46
+ assert.ok(!skills.some(s => s.name === '_kit-no-readme'))
47
+ })
48
+
49
+ it('still surfaces a regular skill via SKILL.md frontmatter (unchanged)', async () => {
50
+ write_skill(tmp, 'plain', {
51
+ 'skill.json': JSON.stringify({ name: 'plain', version: '0.1.0', type: 'skill' }),
52
+ 'SKILL.md': '---\nname: plain\ndescription: A plain skill\n---\n\n# plain'
53
+ })
54
+ const [err, skills] = await scan_skills_dir(tmp)
55
+ assert.ifError(err)
56
+ const s = skills.find(x => x.name === 'plain')
57
+ assert.ok(s)
58
+ assert.strictEqual(s.type, 'skill')
59
+ assert.strictEqual(s.is_draft, true)
60
+ })
61
+ })
@@ -0,0 +1,175 @@
1
+ // Typed YAML frontmatter parser.
2
+ //
3
+ // Returns each field with its parsed value AND its YAML-inferred type, so
4
+ // validators can detect type mismatches (e.g. `argument-hint: [foo]` parsing
5
+ // as an array when a string was expected). Mirrors how a real YAML loader
6
+ // (and Claude Code's skill loader) interprets unquoted scalars.
7
+ //
8
+ // Scope: single-line scalars and flow collections in frontmatter only.
9
+ // Block scalars (|, >) and multi-line constructs are flagged as such; we do
10
+ // not assemble their multi-line bodies — frontmatter values should be single
11
+ // line, and a block scalar in frontmatter is itself a smell worth surfacing.
12
+
13
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/
14
+
15
+ const NUMBER_RE = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/
16
+ const INT_RE = /^-?(0|[1-9][0-9]*)$/
17
+
18
+ // Parse a scalar value from raw YAML text (no leading/trailing whitespace).
19
+ // Returns { value, type } where type ∈ string|array|object|boolean|null|number|block_scalar.
20
+ const parse_scalar = (raw) => {
21
+ if (raw === '' || raw === '~' || raw === 'null' || raw === 'Null' || raw === 'NULL')
22
+ return { value: null, type: 'null' }
23
+
24
+ if (raw === 'true' || raw === 'True' || raw === 'TRUE')
25
+ return { value: true, type: 'boolean' }
26
+ if (raw === 'false' || raw === 'False' || raw === 'FALSE')
27
+ return { value: false, type: 'boolean' }
28
+
29
+ if (raw[0] === '|' || raw[0] === '>')
30
+ return { value: raw, type: 'block_scalar' }
31
+
32
+ if (raw[0] === '"' && raw[raw.length - 1] === '"' && raw.length >= 2)
33
+ return { value: unescape_double(raw.slice(1, -1)), type: 'string', quoted: 'double' }
34
+
35
+ if (raw[0] === "'" && raw[raw.length - 1] === "'" && raw.length >= 2)
36
+ return { value: raw.slice(1, -1).replace(/''/g, "'"), type: 'string', quoted: 'single' }
37
+
38
+ if (raw[0] === '[' && raw[raw.length - 1] === ']')
39
+ return { value: parse_flow_sequence(raw.slice(1, -1)), type: 'array' }
40
+
41
+ if (raw[0] === '{' && raw[raw.length - 1] === '}')
42
+ return { value: parse_flow_mapping(raw.slice(1, -1)), type: 'object' }
43
+
44
+ if (NUMBER_RE.test(raw)) {
45
+ const n = Number(raw)
46
+ if (Number.isFinite(n)) return { value: n, type: 'number', is_int: INT_RE.test(raw) }
47
+ }
48
+
49
+ return { value: raw, type: 'string', quoted: 'plain' }
50
+ }
51
+
52
+ const unescape_double = (s) => s.replace(/\\(["\\nrt])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' })[c])
53
+
54
+ const split_flow = (body) => {
55
+ const items = []
56
+ let depth = 0
57
+ let in_dq = false
58
+ let in_sq = false
59
+ let start = 0
60
+ for (let i = 0; i < body.length; i++) {
61
+ const c = body[i]
62
+ if (in_dq) { if (c === '\\') i++; else if (c === '"') in_dq = false; continue }
63
+ if (in_sq) { if (c === "'") in_sq = false; continue }
64
+ if (c === '"') { in_dq = true; continue }
65
+ if (c === "'") { in_sq = true; continue }
66
+ if (c === '[' || c === '{') depth++
67
+ else if (c === ']' || c === '}') depth--
68
+ else if (c === ',' && depth === 0) { items.push(body.slice(start, i).trim()); start = i + 1 }
69
+ }
70
+ const tail = body.slice(start).trim()
71
+ if (tail !== '' || items.length > 0) items.push(tail)
72
+ return items
73
+ }
74
+
75
+ const parse_flow_sequence = (body) => split_flow(body).filter(s => s !== '').map(s => parse_scalar(s).value)
76
+
77
+ const parse_flow_mapping = (body) => {
78
+ const out = {}
79
+ for (const pair of split_flow(body)) {
80
+ if (pair === '') continue
81
+ const colon = find_top_level_colon(pair)
82
+ if (colon === -1) { out[pair] = null; continue }
83
+ const k = pair.slice(0, colon).trim()
84
+ const v = pair.slice(colon + 1).trim()
85
+ out[parse_scalar(k).value] = parse_scalar(v).value
86
+ }
87
+ return out
88
+ }
89
+
90
+ // Find the first ':' that is at top level (not inside quotes or flow brackets).
91
+ const find_top_level_colon = (line) => {
92
+ let in_dq = false, in_sq = false, depth = 0
93
+ for (let i = 0; i < line.length; i++) {
94
+ const c = line[i]
95
+ if (in_dq) { if (c === '\\') i++; else if (c === '"') in_dq = false; continue }
96
+ if (in_sq) { if (c === "'") in_sq = false; continue }
97
+ if (c === '"') { in_dq = true; continue }
98
+ if (c === "'") { in_sq = true; continue }
99
+ if (c === '[' || c === '{') { depth++; continue }
100
+ if (c === ']' || c === '}') { depth--; continue }
101
+ if (c === ':' && depth === 0) return i
102
+ }
103
+ return -1
104
+ }
105
+
106
+ // Strip a trailing unquoted YAML comment from a scalar line (everything after ' #').
107
+ // Comments inside quotes/brackets are preserved.
108
+ const strip_trailing_comment = (s) => {
109
+ let in_dq = false, in_sq = false, depth = 0
110
+ for (let i = 0; i < s.length; i++) {
111
+ const c = s[i]
112
+ if (in_dq) { if (c === '\\') i++; else if (c === '"') in_dq = false; continue }
113
+ if (in_sq) { if (c === "'") in_sq = false; continue }
114
+ if (c === '"') { in_dq = true; continue }
115
+ if (c === "'") { in_sq = true; continue }
116
+ if (c === '[' || c === '{') { depth++; continue }
117
+ if (c === ']' || c === '}') { depth--; continue }
118
+ if (c === '#' && depth === 0 && (i === 0 || /\s/.test(s[i - 1]))) return s.slice(0, i).trimEnd()
119
+ }
120
+ return s
121
+ }
122
+
123
+ // Top-level: parse a frontmatter block into { fields, errors, raw }.
124
+ // `fields` maps key → { value, type, raw, line, quoted? }.
125
+ //
126
+ // `line` is the 1-based line number IN THE SOURCE FILE (not in the body) —
127
+ // the opening `---` is line 1, the first key/value lives on line 2, and so
128
+ // on. This makes error messages directly actionable: "SKILL.md line 4" lines
129
+ // up with what an editor or `head -n 4` would show.
130
+ const parse_typed = (content) => {
131
+ const match = content.match(FRONTMATTER_RE)
132
+ if (!match) return null
133
+
134
+ const body = match[1]
135
+ const lines = body.split(/\r?\n/)
136
+ const fields = {}
137
+ const errors = []
138
+
139
+ // Body line 0 ⇒ file line 2 (file line 1 is the opening `---`).
140
+ const file_line = (i) => i + 2
141
+
142
+ for (let i = 0; i < lines.length; i++) {
143
+ const line = lines[i]
144
+ const trimmed = line.trim()
145
+ if (trimmed === '' || trimmed.startsWith('#')) continue
146
+
147
+ // Block scalars and continuation lines are not supported in this parser.
148
+ // A line with no top-level ':' is treated as a structural error.
149
+ const colon = find_top_level_colon(line)
150
+ if (colon === -1) {
151
+ errors.push({ line: file_line(i), message: `Cannot parse line (no key): ${trimmed}` })
152
+ continue
153
+ }
154
+
155
+ const key = line.slice(0, colon).trim()
156
+ if (!key) {
157
+ errors.push({ line: file_line(i), message: 'Empty key' })
158
+ continue
159
+ }
160
+
161
+ const value_text = strip_trailing_comment(line.slice(colon + 1).trim())
162
+ const parsed = parse_scalar(value_text)
163
+ fields[key] = {
164
+ value: parsed.value,
165
+ type: parsed.type,
166
+ raw: value_text,
167
+ line: file_line(i),
168
+ ...(parsed.quoted ? { quoted: parsed.quoted } : {})
169
+ }
170
+ }
171
+
172
+ return { fields, errors, raw: body }
173
+ }
174
+
175
+ module.exports = { parse_typed, parse_scalar }
@@ -0,0 +1,110 @@
1
+ const { test } = require('node:test')
2
+ const assert = require('node:assert/strict')
3
+ const { parse_typed, parse_scalar } = require('./yaml_frontmatter')
4
+
5
+ const wrap = (body) => `---\n${body}\n---\n`
6
+
7
+ test('parse_scalar: empty / null literals', () => {
8
+ assert.deepEqual(parse_scalar(''), { value: null, type: 'null' })
9
+ assert.deepEqual(parse_scalar('null'), { value: null, type: 'null' })
10
+ assert.deepEqual(parse_scalar('~'), { value: null, type: 'null' })
11
+ })
12
+
13
+ test('parse_scalar: booleans', () => {
14
+ assert.equal(parse_scalar('true').value, true)
15
+ assert.equal(parse_scalar('true').type, 'boolean')
16
+ assert.equal(parse_scalar('False').value, false)
17
+ })
18
+
19
+ test('parse_scalar: numbers', () => {
20
+ assert.deepEqual(parse_scalar('42'), { value: 42, type: 'number', is_int: true })
21
+ assert.equal(parse_scalar('1.5').value, 1.5)
22
+ })
23
+
24
+ test('parse_scalar: plain string', () => {
25
+ assert.deepEqual(parse_scalar('hello world'), { value: 'hello world', type: 'string', quoted: 'plain' })
26
+ })
27
+
28
+ test('parse_scalar: double-quoted string', () => {
29
+ const r = parse_scalar('"[question or topic]"')
30
+ assert.equal(r.value, '[question or topic]')
31
+ assert.equal(r.type, 'string')
32
+ assert.equal(r.quoted, 'double')
33
+ })
34
+
35
+ test('parse_scalar: single-quoted string', () => {
36
+ const r = parse_scalar("'foo bar'")
37
+ assert.equal(r.value, 'foo bar')
38
+ assert.equal(r.type, 'string')
39
+ assert.equal(r.quoted, 'single')
40
+ })
41
+
42
+ test('parse_scalar: unquoted brackets parse as array (the bug)', () => {
43
+ const r = parse_scalar('[question or topic]')
44
+ assert.equal(r.type, 'array')
45
+ assert.deepEqual(r.value, ['question or topic'])
46
+ })
47
+
48
+ test('parse_scalar: unquoted brace-delimited parses as object', () => {
49
+ const r = parse_scalar('{a: 1, b: 2}')
50
+ assert.equal(r.type, 'object')
51
+ assert.deepEqual(r.value, { a: 1, b: 2 })
52
+ })
53
+
54
+ test('parse_scalar: bracketed list with multiple items', () => {
55
+ const r = parse_scalar('[a, b, c]')
56
+ assert.deepEqual(r.value, ['a', 'b', 'c'])
57
+ })
58
+
59
+ test('parse_typed: full frontmatter with the bug', () => {
60
+ const content = wrap([
61
+ 'name: init-context',
62
+ 'description: ProjectMemory — Load project docs.',
63
+ 'argument-hint: [question or topic]'
64
+ ].join('\n'))
65
+
66
+ const parsed = parse_typed(content)
67
+ assert.equal(parsed.fields.name.type, 'string')
68
+ assert.equal(parsed.fields.description.type, 'string')
69
+ assert.equal(parsed.fields['argument-hint'].type, 'array')
70
+ assert.deepEqual(parsed.fields['argument-hint'].value, ['question or topic'])
71
+ })
72
+
73
+ test('parse_typed: quoted form is a string', () => {
74
+ const content = wrap('argument-hint: "[question or topic]"')
75
+ const parsed = parse_typed(content)
76
+ assert.equal(parsed.fields['argument-hint'].type, 'string')
77
+ assert.equal(parsed.fields['argument-hint'].value, '[question or topic]')
78
+ assert.equal(parsed.fields['argument-hint'].quoted, 'double')
79
+ })
80
+
81
+ test('parse_typed: trailing comment is stripped from plain scalars', () => {
82
+ const content = wrap('name: my-skill # this is a comment')
83
+ const parsed = parse_typed(content)
84
+ assert.equal(parsed.fields.name.value, 'my-skill')
85
+ })
86
+
87
+ test('parse_typed: # inside a quoted string is preserved', () => {
88
+ const content = wrap('description: "foo # bar"')
89
+ const parsed = parse_typed(content)
90
+ assert.equal(parsed.fields.description.value, 'foo # bar')
91
+ })
92
+
93
+ test('parse_typed: top-level colon inside unquoted value (deploy: production)', () => {
94
+ // `description: deploy: production` — first colon splits the key, the
95
+ // second one stays inside the value as a literal character.
96
+ const content = wrap('description: deploy: production')
97
+ const parsed = parse_typed(content)
98
+ assert.equal(parsed.fields.description.type, 'string')
99
+ assert.equal(parsed.fields.description.value, 'deploy: production')
100
+ })
101
+
102
+ test('parse_typed: returns null when no frontmatter block', () => {
103
+ assert.equal(parse_typed('no frontmatter here'), null)
104
+ })
105
+
106
+ test('parse_typed: surfaces structural errors for malformed lines', () => {
107
+ const content = wrap('name: ok\nthis line has no colon')
108
+ const parsed = parse_typed(content)
109
+ assert.ok(parsed.errors.length >= 1)
110
+ })
@@ -0,0 +1,107 @@
1
+ // Schema describing every known SKILL.md frontmatter field.
2
+ //
3
+ // Why a schema (and not per-field functions): bugs like the unquoted
4
+ // `argument-hint: [foo]` (which YAML parses as an array, not a string) are
5
+ // type mismatches. A single generic checker that compares each field's
6
+ // parsed YAML type against a declared `type` catches the whole class in one
7
+ // pass — and every new field gets the check by adding one row, not a new
8
+ // branch.
9
+ //
10
+ // Unknown fields are NOT rejected. Anthropic may add new frontmatter fields
11
+ // over time; we warn only when an unknown key looks like a typo of a known
12
+ // one (Levenshtein distance 1).
13
+
14
+ const NAME_PATTERN = /^[a-z][a-z0-9-]*$/
15
+
16
+ // Forbidden characters: YAML structural metacharacters that, even when they
17
+ // pass our parser as plain strings, break Claude Code's stricter loader or
18
+ // the skill discovery layer. See docs/gotchas/skills.md § 1.2.
19
+ const FORBIDDEN_CHARS = {
20
+ ';': 'semicolon', ':': 'colon', '#': 'hash', '{': 'left brace', '}': 'right brace',
21
+ '[': 'left bracket', ']': 'right bracket', "'": 'single quote', '"': 'double quote',
22
+ '!': 'exclamation', '&': 'ampersand', '*': 'asterisk', '%': 'percent', '|': 'pipe', '>': 'greater-than'
23
+ }
24
+
25
+ // Type categories accepted by the validator. Multi-type fields list each
26
+ // permitted type — e.g. `allowed-tools` can be a YAML string or a YAML list.
27
+ const FRONTMATTER_SCHEMA = {
28
+ name: {
29
+ types: ['string'],
30
+ required: true,
31
+ pattern: NAME_PATTERN,
32
+ pattern_message: 'must match /^[a-z][a-z0-9-]*$/',
33
+ max_length: 64,
34
+ non_empty: true,
35
+ matches_dir: true
36
+ },
37
+ description: {
38
+ types: ['string'],
39
+ required: true,
40
+ non_empty: true,
41
+ max_length: 1024,
42
+ forbidden_chars: true
43
+ },
44
+ 'argument-hint': {
45
+ types: ['string'],
46
+ forbidden_chars_in_plain_only: true
47
+ },
48
+ compatibility: {
49
+ types: ['string'],
50
+ max_length: 500,
51
+ forbidden_chars: true
52
+ },
53
+ 'allowed-tools': {
54
+ types: ['string', 'array']
55
+ },
56
+ 'disable-model-invocation': {
57
+ types: ['boolean']
58
+ },
59
+ 'user-invocable': {
60
+ types: ['boolean']
61
+ },
62
+ context: {
63
+ types: ['string'],
64
+ enum: ['fork']
65
+ },
66
+ agent: {
67
+ types: ['string']
68
+ },
69
+ keywords: {
70
+ types: ['string', 'array']
71
+ }
72
+ }
73
+
74
+ const KNOWN_FIELDS = Object.keys(FRONTMATTER_SCHEMA)
75
+
76
+ // Cheap Levenshtein for did-you-mean. Bounded at distance 2.
77
+ const levenshtein = (a, b) => {
78
+ if (a === b) return 0
79
+ if (Math.abs(a.length - b.length) > 2) return 3
80
+ const m = a.length, n = b.length
81
+ const prev = Array(n + 1).fill(0).map((_, i) => i)
82
+ const cur = Array(n + 1).fill(0)
83
+ for (let i = 1; i <= m; i++) {
84
+ cur[0] = i
85
+ for (let j = 1; j <= n; j++) {
86
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
87
+ cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)
88
+ }
89
+ for (let j = 0; j <= n; j++) prev[j] = cur[j]
90
+ }
91
+ return prev[n]
92
+ }
93
+
94
+ const did_you_mean = (key) => {
95
+ for (const known of KNOWN_FIELDS) {
96
+ if (levenshtein(key, known) === 1) return known
97
+ }
98
+ return null
99
+ }
100
+
101
+ module.exports = {
102
+ FRONTMATTER_SCHEMA,
103
+ FORBIDDEN_CHARS,
104
+ KNOWN_FIELDS,
105
+ NAME_PATTERN,
106
+ did_you_mean
107
+ }