happyskills 0.50.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 +34 -0
- package/package.json +1 -1
- package/src/commands/init.js +14 -7
- package/src/commands/list.js +27 -5
- package/src/constants.js +2 -0
- package/src/integration/cli.test.js +38 -1
- package/src/utils/skill_scanner.js +56 -3
- 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,40 @@ 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
|
+
|
|
33
|
+
## [0.51.0] - 2026-05-25
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`list --json` now splits unclaimed on-disk skills into `data.drafts[]` and `data.external[]`** (`cli/src/commands/list.js`, `cli/src/utils/skill_scanner.js`). Drafts are skills scaffolded by `init` — they have a HappySkills-shaped `skill.json` (non-empty `name`, valid-semver `version`, `type` of `"skill"` or `"kit"`) and a `SKILL.md`, but no entry in `skills-lock.json` yet because the user hasn't published. External skills are genuinely foreign — they have a `SKILL.md` but no manifest or a foreign-shaped one, and they require `convert` before they can be published. Each draft entry includes `{ name, description, version, type }`; external entries keep the existing `{ name, description }` shape. Human output gains a `draft (unpublished)` row label and a closing hint pointing at `happyskills release` so the principal sees what to do next without having to read the docs.
|
|
38
|
+
- **`is_happyskills_shaped_manifest()` exported from `cli/src/utils/skill_scanner.js`** — small predicate that other call sites (e.g. `happyskills-publish`'s pre-flight) can use to classify a found-on-disk skill the same way `list` does.
|
|
39
|
+
|
|
40
|
+
### Rationale
|
|
41
|
+
|
|
42
|
+
Spec 260522-02: when a user scaffolded a skill with `npx happyskills init` and immediately asked an LLM agent to "publish it", the previous `list --json` output reported the skill under `data.external[]` — the same bucket used for genuinely-foreign skills. Routing skills (notably `happyskills-publish`) read this as "external, must `convert` first", inserted a redundant `convert` step into the happy path, and surfaced the words `external` and `convert` to a user who had created their skill with the official tool five minutes earlier. That violated the mission's principal-surface promise of "no unexplained jargon" and "consistency over novelty". The CLI fix is a data-layer split (`data.drafts[]` vs `data.external[]`) so every consumer — routing skills, human output, future tooling — can tell the two states apart at the source. The companion routing-skill fix lands in `happyskills-publish@0.5.0` (in the same monorepo change set), which now reads `data.drafts` and routes drafts straight to `release` without ever mentioning `convert` or `external` to the user.
|
|
43
|
+
|
|
10
44
|
## [0.50.0] - 2026-05-24
|
|
11
45
|
|
|
12
46
|
### 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,12 +135,12 @@ 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
|
}
|
|
135
142
|
console.log()
|
|
136
|
-
print_hint(`Edit these files, then run ${code(
|
|
143
|
+
print_hint(`Edit these files, then run ${code(`happyskills release ${final_name} --workspace <slug>`)} to publish.`)
|
|
137
144
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
138
145
|
|
|
139
146
|
module.exports = { run }
|
package/src/commands/list.js
CHANGED
|
@@ -51,16 +51,23 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
51
51
|
|
|
52
52
|
const [, disk_skills] = await scan_skills_dir(base_dir)
|
|
53
53
|
const managed_names = new Set(managed_short_names)
|
|
54
|
-
|
|
54
|
+
// Unclaimed skills (on disk, not in lock) split into two coherent buckets:
|
|
55
|
+
// - drafts → scaffolded by `init`, HappySkills-shaped skill.json present.
|
|
56
|
+
// Publish/release pick these up directly — no `convert` needed.
|
|
57
|
+
// - external → genuinely foreign (no skill.json, or foreign-shaped).
|
|
58
|
+
// `convert` is the path to bring these into the managed set.
|
|
59
|
+
const unclaimed = (disk_skills || []).filter(s => !managed_names.has(s.name))
|
|
60
|
+
const draft_skills = unclaimed.filter(s => s.is_draft)
|
|
61
|
+
const external_skills = unclaimed.filter(s => !s.is_draft)
|
|
55
62
|
|
|
56
63
|
// Scan agent-specific dirs for skills placed directly in agent folders (not symlinked from .agents/skills/)
|
|
57
|
-
const all_known_names = new Set([...managed_names, ...external_skills.map(s => s.name)])
|
|
64
|
+
const all_known_names = new Set([...managed_names, ...draft_skills.map(s => s.name), ...external_skills.map(s => s.name)])
|
|
58
65
|
const [, agent_orphans] = await scan_agent_orphan_skills(AGENTS, is_global, project_root, all_known_names)
|
|
59
66
|
const orphan_skills = agent_orphans || []
|
|
60
67
|
|
|
61
|
-
if (managed_entries.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
|
|
68
|
+
if (managed_entries.length === 0 && draft_skills.length === 0 && external_skills.length === 0 && orphan_skills.length === 0) {
|
|
62
69
|
if (args.flags.json) {
|
|
63
|
-
print_json({ data: { skills: {}, external: [], agent_orphans: [] } })
|
|
70
|
+
print_json({ data: { skills: {}, drafts: [], external: [], agent_orphans: [] } })
|
|
64
71
|
return
|
|
65
72
|
}
|
|
66
73
|
print_info('No skills installed.')
|
|
@@ -123,13 +130,19 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
123
130
|
}
|
|
124
131
|
skills_map[name] = entry
|
|
125
132
|
}
|
|
133
|
+
const drafts = draft_skills.map(s => ({
|
|
134
|
+
name: s.name,
|
|
135
|
+
description: s.description || '',
|
|
136
|
+
version: s.version || null,
|
|
137
|
+
type: s.type || SKILL_TYPES.SKILL
|
|
138
|
+
}))
|
|
126
139
|
const external = external_skills.map(s => ({ name: s.name, description: s.description || '' }))
|
|
127
140
|
const agent_orphan_list = orphan_skills.map(s => ({
|
|
128
141
|
name: s.name,
|
|
129
142
|
description: s.description || '',
|
|
130
143
|
agents: s.agents
|
|
131
144
|
}))
|
|
132
|
-
print_json({ data: { skills: skills_map, external, agent_orphans: agent_orphan_list } })
|
|
145
|
+
print_json({ data: { skills: skills_map, drafts, external, agent_orphans: agent_orphan_list } })
|
|
133
146
|
return
|
|
134
147
|
}
|
|
135
148
|
|
|
@@ -153,6 +166,11 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
153
166
|
rows.push([display_name, data.version, source, status_label, enabled_label])
|
|
154
167
|
}
|
|
155
168
|
|
|
169
|
+
for (const s of draft_skills) {
|
|
170
|
+
const type_label = s.type === SKILL_TYPES.KIT ? `${s.name} [kit]` : s.name
|
|
171
|
+
rows.push([type_label, s.version || '-', 'draft', 'unpublished', '-'])
|
|
172
|
+
}
|
|
173
|
+
|
|
156
174
|
for (const s of external_skills) {
|
|
157
175
|
rows.push([s.name, '-', 'external', 'installed', '-'])
|
|
158
176
|
}
|
|
@@ -175,6 +193,10 @@ const run = (args) => catch_errors('List failed', async () => {
|
|
|
175
193
|
console.log()
|
|
176
194
|
print_info(`${ahead_count} skill(s) ahead of lock — bumped locally, not yet published. Run publish when ready.`)
|
|
177
195
|
}
|
|
196
|
+
if (draft_skills.length > 0) {
|
|
197
|
+
console.log()
|
|
198
|
+
print_info(`${draft_skills.length} draft(s) ready to publish — run ${code('happyskills release <name>')} to ship.`)
|
|
199
|
+
}
|
|
178
200
|
}).then(([errors]) => { if (errors) { exit_with_error(errors); return } })
|
|
179
201
|
|
|
180
202
|
module.exports = { run }
|
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,
|
|
@@ -379,7 +379,7 @@ describe('CLI — --json: success responses use { data } envelope', () => {
|
|
|
379
379
|
// ─── --json flag: existing commands use { data } envelope ─────────────────────
|
|
380
380
|
|
|
381
381
|
describe('CLI — --json: existing json commands now use { data } envelope', () => {
|
|
382
|
-
it('list --json returns { data: { skills, external } }', () => {
|
|
382
|
+
it('list --json returns { data: { skills, drafts, external } }', () => {
|
|
383
383
|
// Run in a temp dir with no lock file — produces empty result
|
|
384
384
|
const tmp = make_tmp()
|
|
385
385
|
try {
|
|
@@ -388,14 +388,51 @@ describe('CLI — --json: existing json commands now use { data } envelope', ()
|
|
|
388
388
|
const out = parse_json_output(stdout, 'list --json empty')
|
|
389
389
|
assert.ok('data' in out, 'should have top-level "data" key')
|
|
390
390
|
assert.ok('skills' in out.data, 'data.skills should exist')
|
|
391
|
+
assert.ok('drafts' in out.data, 'data.drafts should exist')
|
|
391
392
|
assert.ok('external' in out.data, 'data.external should exist')
|
|
392
393
|
assert.ok(typeof out.data.skills === 'object' && !Array.isArray(out.data.skills))
|
|
394
|
+
assert.ok(Array.isArray(out.data.drafts))
|
|
393
395
|
assert.ok(Array.isArray(out.data.external))
|
|
394
396
|
} finally {
|
|
395
397
|
fs.rmSync(tmp, { recursive: true, force: true })
|
|
396
398
|
}
|
|
397
399
|
})
|
|
398
400
|
|
|
401
|
+
it('list --json classifies init-scaffolded skills as drafts, foreign-shaped ones as external', () => {
|
|
402
|
+
// Two on-disk skills, no lock file:
|
|
403
|
+
// - "my-draft" has a HappySkills-shaped skill.json (init-style)
|
|
404
|
+
// - "my-foreign" has only SKILL.md (foreign / hand-rolled)
|
|
405
|
+
const tmp = make_tmp()
|
|
406
|
+
try {
|
|
407
|
+
const draft_dir = path.join(tmp, '.agents', 'skills', 'my-draft')
|
|
408
|
+
fs.mkdirSync(draft_dir, { recursive: true })
|
|
409
|
+
fs.writeFileSync(path.join(draft_dir, 'SKILL.md'), '---\nname: my-draft\ndescription: a draft skill\n---\n\n# my-draft\n')
|
|
410
|
+
fs.writeFileSync(path.join(draft_dir, 'skill.json'), JSON.stringify({
|
|
411
|
+
name: 'my-draft',
|
|
412
|
+
version: '0.1.0',
|
|
413
|
+
type: 'skill',
|
|
414
|
+
description: '',
|
|
415
|
+
keywords: [],
|
|
416
|
+
dependencies: {}
|
|
417
|
+
}))
|
|
418
|
+
|
|
419
|
+
const foreign_dir = path.join(tmp, '.agents', 'skills', 'my-foreign')
|
|
420
|
+
fs.mkdirSync(foreign_dir, { recursive: true })
|
|
421
|
+
fs.writeFileSync(path.join(foreign_dir, 'SKILL.md'), '---\nname: my-foreign\ndescription: a foreign skill\n---\n\n# my-foreign\n')
|
|
422
|
+
|
|
423
|
+
const { stdout, code } = run(['list', '--json'], {}, { cwd: tmp })
|
|
424
|
+
assert.strictEqual(code, 0)
|
|
425
|
+
const out = parse_json_output(stdout, 'list --json mixed')
|
|
426
|
+
assert.strictEqual(out.data.drafts.length, 1, 'should have exactly one draft')
|
|
427
|
+
assert.strictEqual(out.data.drafts[0].name, 'my-draft')
|
|
428
|
+
assert.strictEqual(out.data.drafts[0].version, '0.1.0')
|
|
429
|
+
assert.strictEqual(out.data.external.length, 1, 'should have exactly one external')
|
|
430
|
+
assert.strictEqual(out.data.external[0].name, 'my-foreign')
|
|
431
|
+
} finally {
|
|
432
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
433
|
+
}
|
|
434
|
+
})
|
|
435
|
+
|
|
399
436
|
it('search --json usage error returns { error } not raw text', () => {
|
|
400
437
|
const { stdout, code } = run(['search', '--json'])
|
|
401
438
|
assert.strictEqual(code, 2)
|
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const path = require('path')
|
|
3
3
|
const { error: { catch_errors } } = require('puffy-core')
|
|
4
|
+
const { valid: valid_semver } = require('./semver')
|
|
5
|
+
const { SKILL_JSON, SKILL_MD, README_MD, SKILL_TYPES } = require('../constants')
|
|
4
6
|
|
|
5
7
|
const SKIP_ENTRIES = new Set(['.tmp', '.DS_Store', '.install.lock'])
|
|
6
8
|
|
|
9
|
+
// A disk-resident, unclaimed skill is a "draft" when it has a HappySkills-shaped
|
|
10
|
+
// skill.json next to its SKILL.md — i.e. the file `init` already produced.
|
|
11
|
+
// "HappySkills-shaped" means: a non-empty `name`, a valid-semver `version`, and
|
|
12
|
+
// a `type` of "skill" or "kit". This is the minimum surface `release`/`publish`
|
|
13
|
+
// can pick up on a no-lock-entry first publish without an intermediate `convert`.
|
|
14
|
+
const is_happyskills_shaped_manifest = (manifest) => {
|
|
15
|
+
if (!manifest || typeof manifest !== 'object') return false
|
|
16
|
+
if (typeof manifest.name !== 'string' || !manifest.name.trim()) return false
|
|
17
|
+
if (!valid_semver(manifest.version)) return false
|
|
18
|
+
const t = manifest.type
|
|
19
|
+
if (t && t !== SKILL_TYPES.SKILL && t !== SKILL_TYPES.KIT) return false
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
const parse_frontmatter = (content) => {
|
|
8
24
|
const match = content.match(/^---\n([\s\S]*?)\n---/)
|
|
9
25
|
if (!match) return null
|
|
@@ -32,7 +48,39 @@ const scan_skills_dir = (base_dir) => catch_errors('Failed to scan skills direct
|
|
|
32
48
|
if (entry.name.startsWith('.') || SKIP_ENTRIES.has(entry.name)) continue
|
|
33
49
|
|
|
34
50
|
const dir = path.join(base_dir, entry.name)
|
|
35
|
-
|
|
51
|
+
|
|
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
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.promises.readFile(path.join(dir, SKILL_JSON), 'utf-8')
|
|
58
|
+
manifest = JSON.parse(raw)
|
|
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
|
+
})
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Skill branch: SKILL.md is required (frontmatter is the discovery signal).
|
|
83
|
+
const skill_md_path = path.join(dir, SKILL_MD)
|
|
36
84
|
|
|
37
85
|
let content
|
|
38
86
|
try {
|
|
@@ -44,9 +92,14 @@ const scan_skills_dir = (base_dir) => catch_errors('Failed to scan skills direct
|
|
|
44
92
|
const frontmatter = parse_frontmatter(content)
|
|
45
93
|
if (!frontmatter || !frontmatter.name) continue
|
|
46
94
|
|
|
95
|
+
const is_draft = is_happyskills_shaped_manifest(manifest)
|
|
96
|
+
|
|
47
97
|
skills.push({
|
|
48
98
|
name: frontmatter.name,
|
|
49
|
-
description: frontmatter.description || ''
|
|
99
|
+
description: frontmatter.description || '',
|
|
100
|
+
is_draft,
|
|
101
|
+
version: is_draft ? manifest.version : null,
|
|
102
|
+
type: is_draft ? (manifest.type || SKILL_TYPES.SKILL) : null
|
|
50
103
|
})
|
|
51
104
|
}
|
|
52
105
|
|
|
@@ -129,4 +182,4 @@ const scan_agent_orphan_skills = (agents, is_global, project_root, known_names)
|
|
|
129
182
|
return [...by_name.values()]
|
|
130
183
|
})
|
|
131
184
|
|
|
132
|
-
module.exports = { scan_skills_dir, scan_agent_orphan_skills, parse_frontmatter }
|
|
185
|
+
module.exports = { scan_skills_dir, scan_agent_orphan_skills, parse_frontmatter, is_happyskills_shaped_manifest }
|
|
@@ -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 }
|