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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "happyskills",
3
- "version": "0.50.0",
3
+ "version": "0.52.0",
4
4
  "description": "Package manager for AI agent skills",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "author": "Nicolas Dao <nic@cloudlesslabs.com> (https://cloudlesslabs.com)",
@@ -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,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(` ${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
  }
135
142
  console.log()
136
- print_hint(`Edit these files, then run ${code('happyskills publish')} to share.`)
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 }
@@ -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
- const external_skills = (disk_skills || []).filter(s => !managed_names.has(s.name))
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
- const skill_md_path = path.join(dir, 'SKILL.md')
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 }