@veyralabs/skills 0.1.0 → 0.2.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.
Files changed (29) hide show
  1. package/bin/cli.js +66 -41
  2. package/package.json +3 -3
  3. package/skills/webcloner/SKILL.md +565 -0
  4. package/skills/webcloner/references/animation-playbook.md +292 -0
  5. package/skills/webcloner/references/behavior-spec-format.md +259 -0
  6. package/skills/webcloner/references/component-detection.md +209 -0
  7. package/skills/webcloner/references/stack-presets.md +328 -0
  8. package/skills/webcloner/scripts/compare.mjs +87 -0
  9. package/skills/webcloner/scripts/download-assets.mjs +160 -0
  10. package/skills/webcloner/scripts/extract.py +344 -0
  11. package/validate.js +14 -4
  12. /package/skills/{brandaudit → naming-suite/brandaudit}/SKILL.md +0 -0
  13. /package/skills/{brandaudit → naming-suite/brandaudit}/references/audit-framework.md +0 -0
  14. /package/skills/{brandaudit → naming-suite/brandaudit}/references/examples/sample-audits.md +0 -0
  15. /package/skills/{brandaudit → naming-suite/brandaudit}/references/rebrand-decisions.md +0 -0
  16. /package/skills/{brandaudit → naming-suite/brandaudit}/references/weakness-patterns.md +0 -0
  17. /package/skills/{competitornames → naming-suite/competitornames}/SKILL.md +0 -0
  18. /package/skills/{competitornames → naming-suite/competitornames}/references/examples/sample-analyses.md +0 -0
  19. /package/skills/{competitornames → naming-suite/competitornames}/references/pattern-analysis.md +0 -0
  20. /package/skills/{competitornames → naming-suite/competitornames}/references/whitespace-mapping.md +0 -0
  21. /package/skills/{domainforge → naming-suite/domainforge}/SKILL.md +0 -0
  22. /package/skills/{domainforge → naming-suite/domainforge}/references/brand-archetypes.md +0 -0
  23. /package/skills/{domainforge → naming-suite/domainforge}/references/examples/sample-outputs.md +0 -0
  24. /package/skills/{domainforge → naming-suite/domainforge}/references/naming-patterns.md +0 -0
  25. /package/skills/{domainforge → naming-suite/domainforge}/references/scoring-rubric.md +0 -0
  26. /package/skills/{domainforge → naming-suite/domainforge}/references/tld-strategy.md +0 -0
  27. /package/skills/{namingguide → naming-suite/namingguide}/SKILL.md +0 -0
  28. /package/skills/{namingguide → naming-suite/namingguide}/references/examples/sample-guides.md +0 -0
  29. /package/skills/{namingguide → naming-suite/namingguide}/references/guide-structure.md +0 -0
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * download-assets.mjs — Download all assets from site-manifest.json to public/
4
+ *
5
+ * Usage:
6
+ * node scripts/download-assets.mjs docs/site-manifest.json public/
7
+ *
8
+ * Requires: sharp (optional, for WebP conversion)
9
+ * npm install sharp
10
+ */
11
+
12
+ import fs from 'fs';
13
+ import path from 'path';
14
+ import { pipeline } from 'stream/promises';
15
+ import { createWriteStream, mkdirSync } from 'fs';
16
+
17
+ const [,, manifestPath = 'docs/site-manifest.json', outputDir = 'public'] = process.argv;
18
+
19
+ if (!fs.existsSync(manifestPath)) {
20
+ console.error(`Manifest not found: ${manifestPath}`);
21
+ process.exit(1);
22
+ }
23
+
24
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
25
+ const baseUrl = manifest.url;
26
+
27
+ let sharp;
28
+ try {
29
+ sharp = (await import('sharp')).default;
30
+ } catch {
31
+ console.warn('sharp not installed — skipping WebP conversion (npm install sharp to enable)');
32
+ }
33
+
34
+ function sanitizePath(url, type) {
35
+ try {
36
+ const u = new URL(url);
37
+ const ext = path.extname(u.pathname).toLowerCase() || '.bin';
38
+ const name = path.basename(u.pathname, ext) || 'asset';
39
+ const safe = name.replace(/[^a-z0-9-_]/gi, '-').slice(0, 60);
40
+ return path.join(outputDir, type, `${safe}${ext}`);
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ async function fetchAsset(url) {
47
+ const resolved = url.startsWith('http') ? url : new URL(url, baseUrl).href;
48
+ const res = await fetch(resolved, {
49
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WebCloner/1.0)' },
50
+ redirect: 'follow',
51
+ });
52
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${resolved}`);
53
+ return { res, resolved };
54
+ }
55
+
56
+ async function downloadImage(url) {
57
+ const destPath = sanitizePath(url, 'images');
58
+ if (!destPath) return null;
59
+
60
+ const ext = path.extname(destPath).toLowerCase();
61
+ const isConvertible = ['.jpg', '.jpeg', '.png'].includes(ext) && sharp;
62
+ const finalPath = isConvertible ? destPath.replace(ext, '.webp') : destPath;
63
+
64
+ if (fs.existsSync(finalPath)) return finalPath;
65
+ mkdirSync(path.dirname(finalPath), { recursive: true });
66
+
67
+ const { res } = await fetchAsset(url);
68
+ const buffer = Buffer.from(await res.arrayBuffer());
69
+
70
+ if (isConvertible) {
71
+ await sharp(buffer).webp({ quality: 85 }).toFile(finalPath);
72
+ } else {
73
+ fs.writeFileSync(finalPath, buffer);
74
+ }
75
+
76
+ return finalPath;
77
+ }
78
+
79
+ async function downloadGeneric(url, type) {
80
+ const destPath = sanitizePath(url, type);
81
+ if (!destPath || fs.existsSync(destPath)) return destPath;
82
+ mkdirSync(path.dirname(destPath), { recursive: true });
83
+
84
+ const { res } = await fetchAsset(url);
85
+ const buffer = Buffer.from(await res.arrayBuffer());
86
+ fs.writeFileSync(destPath, buffer);
87
+ return destPath;
88
+ }
89
+
90
+ async function batch(items, fn, concurrency = 4) {
91
+ const results = [];
92
+ for (let i = 0; i < items.length; i += concurrency) {
93
+ const chunk = items.slice(i, i + concurrency);
94
+ const settled = await Promise.allSettled(chunk.map(fn));
95
+ for (const r of settled) {
96
+ if (r.status === 'fulfilled') results.push({ ok: true, path: r.value });
97
+ else results.push({ ok: false, error: r.reason?.message });
98
+ }
99
+ }
100
+ return results;
101
+ }
102
+
103
+ const assets = manifest.assets || {};
104
+ const report = { images: [], videos: [], fonts: [], failed: [] };
105
+
106
+ console.log('Downloading assets...\n');
107
+
108
+ // Images
109
+ if (assets.images?.length) {
110
+ console.log(`Images: ${assets.images.length}`);
111
+ const urls = [...new Set(assets.images.map(i => i.src).filter(Boolean))];
112
+ const results = await batch(urls, downloadImage);
113
+ results.forEach((r, i) => {
114
+ if (r.ok) { report.images.push(r.path); process.stdout.write('.'); }
115
+ else { report.failed.push({ url: urls[i], error: r.error }); process.stdout.write('x'); }
116
+ });
117
+ console.log();
118
+ }
119
+
120
+ // Videos
121
+ if (assets.videos?.length) {
122
+ console.log(`\nVideos: ${assets.videos.length}`);
123
+ const urls = [...new Set(assets.videos.map(v => v.src).filter(Boolean))];
124
+ const results = await batch(urls, u => downloadGeneric(u, 'videos'), 2);
125
+ results.forEach((r, i) => {
126
+ if (r.ok) { report.videos.push(r.path); process.stdout.write('.'); }
127
+ else { report.failed.push({ url: urls[i], error: r.error }); process.stdout.write('x'); }
128
+ });
129
+ console.log();
130
+ }
131
+
132
+ // Self-hosted fonts
133
+ if (assets.fonts?.length) {
134
+ const selfHosted = assets.fonts.filter(f => !f.includes('googleapis') && !f.includes('typekit'));
135
+ if (selfHosted.length) {
136
+ console.log(`\nFonts (self-hosted): ${selfHosted.length}`);
137
+ const results = await batch(selfHosted, u => downloadGeneric(u, 'fonts'));
138
+ results.forEach((r, i) => {
139
+ if (r.ok) { report.fonts.push(r.path); process.stdout.write('.'); }
140
+ else { report.failed.push({ url: selfHosted[i], error: r.error }); process.stdout.write('x'); }
141
+ });
142
+ console.log();
143
+ }
144
+ }
145
+
146
+ // Write report
147
+ const reportPath = 'docs/assets-report.json';
148
+ mkdirSync('docs', { recursive: true });
149
+ fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
150
+
151
+ console.log(`\n--- Download Report ---`);
152
+ console.log(`Images: ${report.images.length}`);
153
+ console.log(`Videos: ${report.videos.length}`);
154
+ console.log(`Fonts: ${report.fonts.length}`);
155
+ console.log(`Failed: ${report.failed.length}`);
156
+ if (report.failed.length) {
157
+ console.log('\nFailed assets:');
158
+ report.failed.forEach(f => console.log(` ✗ ${f.url}\n ${f.error}`));
159
+ }
160
+ console.log(`\nReport: ${reportPath}`);
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ extract.py — Site manifest extractor using Scrapling.
4
+
5
+ Usage:
6
+ python scripts/extract.py <url> [--output path/to/manifest.json] [--section ".selector"]
7
+
8
+ Output: JSON manifest with DOM structure, computed CSS, assets, animations, and tech stack.
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+ from urllib.parse import urljoin, urlparse
17
+
18
+ try:
19
+ from scrapling import AsyncPlaywrightFetcher
20
+ except ImportError:
21
+ print("ERROR: scrapling not installed. Run: pip install scrapling && scrapling install")
22
+ sys.exit(1)
23
+
24
+
25
+ # CSS properties to extract for each significant element
26
+ CSS_PROPERTIES = [
27
+ "display", "position", "top", "right", "bottom", "left", "z-index",
28
+ "width", "min-width", "max-width", "height", "min-height", "max-height",
29
+ "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
30
+ "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
31
+ "flex-direction", "flex-wrap", "justify-content", "align-items", "align-self",
32
+ "flex", "flex-grow", "flex-shrink", "flex-basis", "gap", "row-gap", "column-gap",
33
+ "grid-template-columns", "grid-template-rows", "grid-column", "grid-row",
34
+ "font-family", "font-size", "font-weight", "font-style", "line-height",
35
+ "letter-spacing", "text-align", "text-transform", "text-decoration", "color",
36
+ "background-color", "background-image", "background-size", "background-position",
37
+ "border", "border-radius", "border-color", "border-width", "border-style",
38
+ "box-shadow", "opacity", "overflow", "overflow-x", "overflow-y",
39
+ "transform", "transition", "animation", "animation-name", "animation-duration",
40
+ "cursor", "pointer-events", "user-select",
41
+ "object-fit", "object-position", "aspect-ratio",
42
+ ]
43
+
44
+
45
+ ANIMATION_SIGNATURES = {
46
+ "gsap": [
47
+ "window.gsap !== undefined",
48
+ "window.ScrollTrigger !== undefined",
49
+ "window.ScrollSmoother !== undefined",
50
+ ],
51
+ "framer-motion": [
52
+ "document.querySelector('[data-framer-appear]') !== null",
53
+ "document.querySelector('[data-projection-id]') !== null",
54
+ ],
55
+ "lenis": [
56
+ "window.lenis !== undefined",
57
+ "document.documentElement.classList.contains('lenis')",
58
+ "document.querySelector('.lenis') !== null",
59
+ ],
60
+ "aos": [
61
+ "document.querySelector('[data-aos]') !== null",
62
+ ],
63
+ "swiper": [
64
+ "window.Swiper !== undefined",
65
+ "document.querySelector('.swiper') !== null",
66
+ ],
67
+ "locomotive": [
68
+ "document.querySelector('[data-scroll-container]') !== null",
69
+ "window.LocomotiveScroll !== undefined",
70
+ ],
71
+ }
72
+
73
+
74
+ EXTRACTION_SCRIPT = """
75
+ () => {
76
+ const CSS_PROPS = %s;
77
+
78
+ function getComputedStyleMap(el) {
79
+ const cs = window.getComputedStyle(el);
80
+ const result = {};
81
+ for (const prop of CSS_PROPS) {
82
+ const val = cs.getPropertyValue(prop);
83
+ if (val && val !== 'none' && val !== 'normal' && val !== 'auto' && val !== '0px' && val !== 'rgba(0, 0, 0, 0)') {
84
+ result[prop] = val;
85
+ }
86
+ }
87
+ return result;
88
+ }
89
+
90
+ function extractElement(el, depth) {
91
+ if (depth > 4) return null;
92
+ const rect = el.getBoundingClientRect();
93
+ if (rect.width === 0 && rect.height === 0) return null;
94
+
95
+ const result = {
96
+ tag: el.tagName.toLowerCase(),
97
+ id: el.id || null,
98
+ classes: Array.from(el.classList).slice(0, 10),
99
+ text: el.childNodes.length === 1 && el.childNodes[0].nodeType === 3
100
+ ? el.textContent.trim().slice(0, 200)
101
+ : null,
102
+ href: el.tagName === 'A' ? el.href : null,
103
+ src: el.tagName === 'IMG' ? el.src : null,
104
+ alt: el.tagName === 'IMG' ? el.alt : null,
105
+ styles: getComputedStyleMap(el),
106
+ bounds: {
107
+ top: Math.round(rect.top + window.scrollY),
108
+ left: Math.round(rect.left),
109
+ width: Math.round(rect.width),
110
+ height: Math.round(rect.height),
111
+ },
112
+ children: [],
113
+ };
114
+
115
+ if (depth < 3) {
116
+ for (const child of el.children) {
117
+ const childData = extractElement(child, depth + 1);
118
+ if (childData) result.children.push(childData);
119
+ }
120
+ }
121
+
122
+ return result;
123
+ }
124
+
125
+ // Extract sections (top-level structural blocks)
126
+ const sectionSelectors = [
127
+ 'header', 'nav', 'main', 'footer', 'section', 'article',
128
+ '[class*="section"]', '[class*="hero"]', '[class*="banner"]',
129
+ '[id*="section"]', '[id*="hero"]',
130
+ ];
131
+
132
+ const seen = new Set();
133
+ const sections = [];
134
+
135
+ for (const sel of sectionSelectors) {
136
+ for (const el of document.querySelectorAll(sel)) {
137
+ if (seen.has(el)) continue;
138
+ seen.add(el);
139
+ const data = extractElement(el, 0);
140
+ if (data) sections.push(data);
141
+ }
142
+ }
143
+
144
+ // Color palette from all computed background-colors
145
+ const colors = new Set();
146
+ document.querySelectorAll('*').forEach(el => {
147
+ const cs = window.getComputedStyle(el);
148
+ const bg = cs.backgroundColor;
149
+ const color = cs.color;
150
+ if (bg && bg !== 'rgba(0, 0, 0, 0)') colors.add(bg);
151
+ if (color && color !== 'rgba(0, 0, 0, 0)') colors.add(color);
152
+ });
153
+
154
+ // Typography from headings and body text
155
+ const typography = [];
156
+ ['h1','h2','h3','h4','p','a','span','li'].forEach(tag => {
157
+ const el = document.querySelector(tag);
158
+ if (el) {
159
+ const cs = window.getComputedStyle(el);
160
+ typography.push({
161
+ tag,
162
+ fontFamily: cs.fontFamily,
163
+ fontSize: cs.fontSize,
164
+ fontWeight: cs.fontWeight,
165
+ lineHeight: cs.lineHeight,
166
+ letterSpacing: cs.letterSpacing,
167
+ color: cs.color,
168
+ });
169
+ }
170
+ });
171
+
172
+ // Asset inventory
173
+ const images = Array.from(document.querySelectorAll('img')).map(img => ({
174
+ src: img.src,
175
+ alt: img.alt,
176
+ width: img.naturalWidth || img.width,
177
+ height: img.naturalHeight || img.height,
178
+ lazy: img.loading === 'lazy',
179
+ }));
180
+
181
+ const videos = Array.from(document.querySelectorAll('video')).map(v => ({
182
+ src: v.src || (v.querySelector('source') ? v.querySelector('source').src : null),
183
+ poster: v.poster,
184
+ autoplay: v.autoplay,
185
+ loop: v.loop,
186
+ muted: v.muted,
187
+ }));
188
+
189
+ const fonts = Array.from(document.querySelectorAll('link[rel="stylesheet"]'))
190
+ .map(l => l.href)
191
+ .filter(h => h.includes('fonts.googleapis') || h.includes('typekit') || h.includes('fonts.adobe'));
192
+
193
+ const svgs = Array.from(document.querySelectorAll('svg')).map((svg, i) => ({
194
+ id: svg.id || `svg-${i}`,
195
+ viewBox: svg.getAttribute('viewBox'),
196
+ html: svg.outerHTML.slice(0, 500),
197
+ }));
198
+
199
+ return { sections, colorPalette: Array.from(colors), typography, assets: { images, videos, fonts, svgs } };
200
+ }
201
+ """ % json.dumps(CSS_PROPERTIES)
202
+
203
+
204
+ async def detect_animations(page):
205
+ detected = []
206
+ for lib, checks in ANIMATION_SIGNATURES.items():
207
+ for check in checks:
208
+ try:
209
+ result = await page.evaluate(f"() => {{ try {{ return Boolean({check}); }} catch(e) {{ return false; }} }}")
210
+ if result:
211
+ detected.append(lib)
212
+ break
213
+ except Exception:
214
+ pass
215
+ return list(set(detected))
216
+
217
+
218
+ async def detect_tech_stack(page):
219
+ stack = {}
220
+
221
+ checks = {
222
+ "react": "window.__NEXT_DATA__ !== undefined || window.React !== undefined",
223
+ "nextjs": "window.__NEXT_DATA__ !== undefined",
224
+ "vue": "window.__VUE__ !== undefined || document.querySelector('[data-v-app]') !== null",
225
+ "nuxt": "window.__NUXT__ !== undefined",
226
+ "svelte": "document.querySelector('[data-svelte]') !== null",
227
+ "webflow": "document.querySelector('html[data-wf-site]') !== null",
228
+ "framer": "document.querySelector('html[data-framer-hydrate-v2]') !== null",
229
+ "shopify": "window.Shopify !== undefined",
230
+ "wordpress": "document.querySelector('meta[name=\"generator\"][content^=\"WordPress\"]') !== null",
231
+ "tailwind": "Array.from(document.querySelectorAll('*')).some(el => Array.from(el.classList).some(c => /^(flex|grid|p-|m-|text-|bg-|w-|h-)/.test(c)))",
232
+ }
233
+
234
+ for name, check in checks.items():
235
+ try:
236
+ result = await page.evaluate(f"() => {{ try {{ return Boolean({check}); }} catch(e) {{ return false; }} }}")
237
+ if result:
238
+ stack[name] = True
239
+ except Exception:
240
+ pass
241
+
242
+ return stack
243
+
244
+
245
+ async def extract(url, output_path, section_selector=None):
246
+ print(f"Extracting: {url}")
247
+
248
+ fetcher = AsyncPlaywrightFetcher(headless=True)
249
+
250
+ page_data = await fetcher.async_fetch(
251
+ url,
252
+ wait_selector="body",
253
+ wait_for="networkidle",
254
+ page_action=None,
255
+ )
256
+
257
+ # Use the underlying Playwright page for JS evaluation
258
+ async with fetcher._get_browser_context() as context:
259
+ page = await context.new_page()
260
+ await page.goto(url, wait_until="networkidle")
261
+ await page.wait_for_timeout(2000) # let animations settle
262
+
263
+ # Scroll to trigger lazy loads
264
+ await page.evaluate("""
265
+ async () => {
266
+ const height = document.body.scrollHeight;
267
+ for (let y = 0; y < height; y += 200) {
268
+ window.scrollTo(0, y);
269
+ await new Promise(r => setTimeout(r, 50));
270
+ }
271
+ window.scrollTo(0, 0);
272
+ await new Promise(r => setTimeout(r, 500));
273
+ }
274
+ """)
275
+
276
+ animations = await detect_animations(page)
277
+ tech_stack = await detect_tech_stack(page)
278
+ dom_data = await page.evaluate(EXTRACTION_SCRIPT)
279
+
280
+ # Get page title and meta
281
+ title = await page.title()
282
+ meta_desc = await page.evaluate(
283
+ "() => document.querySelector('meta[name=\"description\"]')?.content || ''"
284
+ )
285
+
286
+ # Detect breakpoints from stylesheets
287
+ breakpoints = await page.evaluate("""
288
+ () => {
289
+ const bps = new Set();
290
+ for (const sheet of document.styleSheets) {
291
+ try {
292
+ for (const rule of sheet.cssRules) {
293
+ if (rule.type === CSSRule.MEDIA_RULE) {
294
+ const match = rule.conditionText.match(/\\d+/g);
295
+ if (match) match.forEach(n => bps.add(parseInt(n)));
296
+ }
297
+ }
298
+ } catch(e) {}
299
+ }
300
+ return Array.from(bps).sort((a,b) => a-b);
301
+ }
302
+ """)
303
+
304
+ manifest = {
305
+ "url": url,
306
+ "title": title,
307
+ "description": meta_desc,
308
+ "techStack": tech_stack,
309
+ "animations": {
310
+ "libraries": animations,
311
+ },
312
+ "breakpoints": breakpoints,
313
+ "colorPalette": dom_data["colorPalette"][:30],
314
+ "typography": dom_data["typography"],
315
+ "sections": dom_data["sections"],
316
+ "assets": dom_data["assets"],
317
+ }
318
+
319
+ output_file = Path(output_path)
320
+ output_file.parent.mkdir(parents=True, exist_ok=True)
321
+ output_file.write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
322
+
323
+ print(f"\nManifest written to: {output_path}")
324
+ print(f" Sections found: {len(manifest['sections'])}")
325
+ print(f" Images found: {len(manifest['assets']['images'])}")
326
+ print(f" SVGs found: {len(manifest['assets']['svgs'])}")
327
+ print(f" Animation libs: {', '.join(animations) or 'none detected'}")
328
+ print(f" Tech stack: {', '.join(k for k,v in tech_stack.items() if v)}")
329
+ print(f" Breakpoints: {breakpoints}")
330
+
331
+
332
+ def main():
333
+ import argparse
334
+ parser = argparse.ArgumentParser(description="Extract site manifest")
335
+ parser.add_argument("url", help="Target URL")
336
+ parser.add_argument("--output", default="docs/site-manifest.json", help="Output path")
337
+ parser.add_argument("--section", default=None, help="CSS selector to scope extraction")
338
+ args = parser.parse_args()
339
+
340
+ asyncio.run(extract(args.url, args.output, args.section))
341
+
342
+
343
+ if __name__ == "__main__":
344
+ main()
package/validate.js CHANGED
@@ -11,13 +11,24 @@ const SKILLS_DIR = path.join(__dirname, 'skills');
11
11
 
12
12
  function findSkillFiles(dir) {
13
13
  const results = [];
14
-
15
14
  if (!fs.existsSync(dir)) return results;
16
15
 
17
16
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
18
17
  if (!entry.isDirectory()) continue;
19
- const candidate = path.join(dir, entry.name, 'SKILL.md');
20
- if (fs.existsSync(candidate)) results.push(candidate);
18
+ const entryPath = path.join(dir, entry.name);
19
+ const direct = path.join(entryPath, 'SKILL.md');
20
+
21
+ if (fs.existsSync(direct)) {
22
+ // Standalone skill
23
+ results.push(direct);
24
+ } else {
25
+ // Pack folder — scan one level deeper
26
+ for (const sub of fs.readdirSync(entryPath, { withFileTypes: true })) {
27
+ if (!sub.isDirectory()) continue;
28
+ const candidate = path.join(entryPath, sub.name, 'SKILL.md');
29
+ if (fs.existsSync(candidate)) results.push(candidate);
30
+ }
31
+ }
21
32
  }
22
33
 
23
34
  return results;
@@ -45,7 +56,6 @@ function parseFrontmatter(content) {
45
56
 
46
57
  if (!key || key.includes(' ')) { i++; continue; }
47
58
 
48
- // Handle block scalars (> and |)
49
59
  if (rawValue === '>' || rawValue === '|') {
50
60
  const blockLines = [];
51
61
  i++;