happyskills 0.51.0 → 0.52.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 +23 -0
- package/package.json +1 -1
- package/src/commands/init.js +13 -6
- package/src/constants.js +2 -0
- package/src/utils/skill_scanner.js +34 -13
- package/src/utils/skill_scanner.test.js +61 -0
- package/src/utils/yaml_frontmatter.js +175 -0
- package/src/utils/yaml_frontmatter.test.js +110 -0
- package/src/validation/frontmatter_schema.js +107 -0
- package/src/validation/skill_md_rules.js +254 -110
- package/src/validation/skill_md_rules.test.js +350 -11
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.52.0] - 2026-05-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
- **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]"`.
|
|
16
|
+
- **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.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- **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.
|
|
21
|
+
- **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.
|
|
22
|
+
- `init --kit` now writes `README.md` and lists it in `files_created`. Help text updated.
|
|
23
|
+
- `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.
|
|
24
|
+
- `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.
|
|
25
|
+
- `README_MD = 'README.md'` added to `cli/src/constants.js`.
|
|
26
|
+
|
|
27
|
+
### Rationale
|
|
28
|
+
|
|
29
|
+
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.
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
10
33
|
## [0.51.0] - 2026-05-25
|
|
11
34
|
|
|
12
35
|
### Added
|
package/package.json
CHANGED
package/src/commands/init.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
104
|
-
if (md_err) throw e(
|
|
105
|
-
files_created.push(
|
|
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(` ${
|
|
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
|
}
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
manifest = JSON.parse(raw)
|
|
87
|
+
content = await fs.promises.readFile(skill_md_path, 'utf-8')
|
|
70
88
|
} catch {
|
|
71
|
-
|
|
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
|
+
}
|