skill-guide 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.
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "skill-guide",
3
+ "version": "0.2.0",
4
+ "description": "Scan 6+ skill directories across Claude Code, Codex, and cc-switch, then generate beautiful HTML slide presentations.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "bin": {
8
+ "skill-guide": "bin/skill-guide",
9
+ "skill-guide-scan": "scan-skills.js"
10
+ },
11
+ "files": [
12
+ "agents/openai.yaml",
13
+ "bin/skill-guide",
14
+ "SKILL.md",
15
+ "skill-guide.js",
16
+ "scan-skills.js",
17
+ "demo.html",
18
+ "demo.gif",
19
+ "social-preview.png",
20
+ "demo-*.png",
21
+ "README.md",
22
+ "CHANGELOG.md",
23
+ "CONTRIBUTING.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "scan": "node scan-skills.js --list",
28
+ "scan:refresh": "node scan-skills.js --refresh --list",
29
+ "scan:skill": "node scan-skills.js --skill",
30
+ "scan:search": "node scan-skills.js --search",
31
+ "scan:full": "node scan-skills.js --full",
32
+ "guide": "node skill-guide.js --open",
33
+ "doctor": "node skill-guide.js --doctor",
34
+ "test": "node --check scan-skills.js && node --check skill-guide.js && node --test test/*.test.js && node scan-skills.js --list >/dev/null && node scan-skills.js --search security >/dev/null && node scan-skills.js --full >/dev/null"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "keywords": [
40
+ "claude-code",
41
+ "codex",
42
+ "agent-skills",
43
+ "skills",
44
+ "skill",
45
+ "skill-discovery",
46
+ "skill-scanner",
47
+ "skill-browser",
48
+ "claude-code-skills",
49
+ "codex-skills",
50
+ "html-slides",
51
+ "developer-tools",
52
+ "agent-tools",
53
+ "cli"
54
+ ],
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/gtskevin/skill-guide.git"
58
+ },
59
+ "bugs": {
60
+ "url": "https://github.com/gtskevin/skill-guide/issues"
61
+ },
62
+ "homepage": "https://gtskevin.github.io/skill-guide/"
63
+ }
package/scan-skills.js ADDED
@@ -0,0 +1,580 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const os = require('os');
7
+ const crypto = require('crypto');
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // CLI parsing
11
+ // ---------------------------------------------------------------------------
12
+ const args = process.argv.slice(2);
13
+
14
+ function getArgValue(flag) {
15
+ const idx = args.indexOf(flag);
16
+ if (idx === -1) return null;
17
+ return args[idx + 1] || null;
18
+ }
19
+
20
+ function hasFlag(flag) {
21
+ return args.includes(flag);
22
+ }
23
+
24
+ const mode = hasFlag('--list') ? 'list'
25
+ : hasFlag('--skill') ? 'skill'
26
+ : hasFlag('--search') ? 'search'
27
+ : hasFlag('--full') ? 'full'
28
+ : null;
29
+
30
+ if (!mode) {
31
+ process.stderr.write(
32
+ 'Usage:\n' +
33
+ ' scan-skills.js --list # name + description + category\n' +
34
+ ' scan-skills.js --skill <name> # full data for one skill\n' +
35
+ ' scan-skills.js --search <query> # match triggers + description\n' +
36
+ ' scan-skills.js --full # all skills with full data\n' +
37
+ ' scan-skills.js --refresh # force re-scan (combine with any mode)\n'
38
+ );
39
+ process.exit(1);
40
+ }
41
+
42
+ const skillName = getArgValue('--skill');
43
+ const searchQuery = getArgValue('--search');
44
+ const refresh = hasFlag('--refresh');
45
+
46
+ if (mode === 'skill' && !skillName) {
47
+ process.stderr.write('Error: --skill requires a name argument\n');
48
+ process.exit(1);
49
+ }
50
+ if (mode === 'search' && !searchQuery) {
51
+ process.stderr.write('Error: --search requires a query argument\n');
52
+ process.exit(1);
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Cache helpers
57
+ // ---------------------------------------------------------------------------
58
+ const CACHE_DIR = path.join(os.tmpdir(), 'claude');
59
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
60
+
61
+ function getCacheKey() {
62
+ const roots = SCAN_SOURCES.map((source) => `${source.label}:${path.resolve(source.dir)}`).join('|');
63
+ return crypto.createHash('sha1').update(roots).digest('hex').slice(0, 12);
64
+ }
65
+
66
+ function getCacheFile() {
67
+ return path.join(CACHE_DIR, `skill-guide-cache-${getCacheKey()}.json`);
68
+ }
69
+
70
+ function readCache() {
71
+ try {
72
+ const CACHE_FILE = getCacheFile();
73
+ const raw = fs.readFileSync(CACHE_FILE, 'utf8');
74
+ const cached = JSON.parse(raw);
75
+ if (Date.now() - cached._ts < CACHE_TTL_MS) {
76
+ return cached;
77
+ }
78
+ } catch (_) { /* ignore */ }
79
+ return null;
80
+ }
81
+
82
+ function writeCache(data) {
83
+ try {
84
+ const CACHE_FILE = getCacheFile();
85
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
86
+ data._ts = Date.now();
87
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(data), 'utf8');
88
+ } catch (_) { /* ignore */ }
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Frontmatter parser (regex-based, no YAML library)
93
+ // ---------------------------------------------------------------------------
94
+ function parseFrontmatter(content) {
95
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
96
+ if (!match) return {};
97
+ const raw = match[1];
98
+ const result = {};
99
+ let currentKey = null;
100
+ let inMultiline = false;
101
+ let multilineValue = '';
102
+ let multilineType = '';
103
+
104
+ const lines = raw.split('\n');
105
+ for (const line of lines) {
106
+ // Multi-line continuation
107
+ if (inMultiline) {
108
+ if (line.trim() === '' && multilineValue.length > 0) {
109
+ multilineValue += '\n';
110
+ continue;
111
+ }
112
+ if (line.startsWith(' ') || line.startsWith('\t')) {
113
+ const val = line.trim();
114
+ if (val.startsWith('- ')) {
115
+ if (!Array.isArray(result[currentKey])) {
116
+ result[currentKey] = [];
117
+ }
118
+ result[currentKey].push(stripQuotes(val.slice(2)));
119
+ } else {
120
+ multilineValue += (multilineValue ? '\n' : '') + val;
121
+ }
122
+ continue;
123
+ }
124
+ // End of multiline block
125
+ if (multilineType === '|' || multilineType === '>') {
126
+ if (multilineValue && !Array.isArray(result[currentKey])) {
127
+ result[currentKey] = multilineValue.trim();
128
+ }
129
+ }
130
+ inMultiline = false;
131
+ multilineValue = '';
132
+ }
133
+
134
+ // Key: value
135
+ const kvMatch = line.match(/^([a-zA-Z0-9_-]+)\s*:\s*(.*)/);
136
+ if (kvMatch) {
137
+ currentKey = kvMatch[1];
138
+ let val = kvMatch[2].trim();
139
+
140
+ // Multi-line indicators
141
+ if (val === '|' || val === '>') {
142
+ inMultiline = true;
143
+ multilineType = val;
144
+ multilineValue = '';
145
+ result[currentKey] = '';
146
+ continue;
147
+ }
148
+
149
+ // Inline list [a, b, c]
150
+ if (val.startsWith('[') && val.endsWith(']')) {
151
+ result[currentKey] = val.slice(1, -1).split(',').map(s => stripQuotes(s.trim())).filter(Boolean);
152
+ continue;
153
+ }
154
+
155
+ // Empty value means next lines could be list
156
+ if (val === '') {
157
+ result[currentKey] = [];
158
+ continue;
159
+ }
160
+
161
+ result[currentKey] = stripQuotes(val);
162
+ continue;
163
+ }
164
+
165
+ // List item continuation
166
+ const listItem = line.match(/^\s+-\s+(.*)/);
167
+ if (listItem && currentKey) {
168
+ if (!Array.isArray(result[currentKey])) {
169
+ result[currentKey] = [];
170
+ }
171
+ result[currentKey].push(stripQuotes(listItem[1]));
172
+ }
173
+ }
174
+
175
+ // Finalize trailing multiline
176
+ if (inMultiline && multilineValue && currentKey && !Array.isArray(result[currentKey])) {
177
+ result[currentKey] = multilineValue.trim();
178
+ }
179
+
180
+ return result;
181
+ }
182
+
183
+ function stripQuotes(val) {
184
+ if (typeof val !== 'string') return val;
185
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
186
+ return val.slice(1, -1);
187
+ }
188
+ return val;
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Auto-categorization
193
+ // ---------------------------------------------------------------------------
194
+ const CATEGORY_MAP = [
195
+ { category: 'testing', keywords: /\b(test|tdd|spec|e2e|qa|assert|jest|vitest|playwright)\b/i },
196
+ { category: 'design', keywords: /\b(design|ui|ux|css|style|color|layout|figma|theme|visual|interface)\b/i },
197
+ { category: 'security', keywords: /\b(security|audit|vuln|owasp|xss|inject|auth|encrypt|cve)\b/i },
198
+ { category: 'documentation', keywords: /\b(doc|readme|changelog|api-doc|markdown|mdx|writing)\b/i },
199
+ { category: 'automation', keywords: /\b(automat|script|batch|loop|cron|schedule|workflow)\b/i },
200
+ { category: 'deployment', keywords: /\b(deploy|release|ci.?cd|docker|kubernetes|infra|nginx|vercel)\b/i },
201
+ { category: 'code-quality', keywords: /\b(review|lint|refactor|simplif|clean|format|pattern)\b/i },
202
+ { category: 'development', keywords: /\b(develop|build|debug|investigate|plan|brainstorm|feature|implement)\b/i },
203
+ ];
204
+
205
+ function categorize(name, description, triggers) {
206
+ const text = [name, description, ...(triggers || [])].join(' ');
207
+ for (const { category, keywords } of CATEGORY_MAP) {
208
+ if (keywords.test(text)) return category;
209
+ }
210
+ return 'other';
211
+ }
212
+
213
+ function smartTruncate(text, maxLen) {
214
+ if (text.length <= maxLen) return text;
215
+ const truncated = text.slice(0, maxLen);
216
+ const lastSentence = Math.max(truncated.lastIndexOf('. '), truncated.lastIndexOf('! '), truncated.lastIndexOf('? '));
217
+ if (lastSentence > maxLen * 0.5) return truncated.slice(0, lastSentence + 1);
218
+ const lastSpace = truncated.lastIndexOf(' ');
219
+ if (lastSpace > maxLen * 0.5) return truncated.slice(0, lastSpace) + '...';
220
+ return truncated + '...';
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Layer 2: Extract sections (## headings with first paragraph)
225
+ // ---------------------------------------------------------------------------
226
+ function extractSections(content) {
227
+ const sections = [];
228
+ // Remove code blocks
229
+ const cleaned = content.replace(/```[\s\S]*?```/g, '');
230
+ const lines = cleaned.split('\n');
231
+ let currentHeading = null;
232
+ let para = '';
233
+
234
+ for (const line of lines) {
235
+ const headingMatch = line.match(/^##\s+(.+)/);
236
+ if (headingMatch) {
237
+ // Save previous section
238
+ if (currentHeading && para.trim().length > 0) {
239
+ sections.push({ title: currentHeading, summary: smartTruncate(para.trim(), 600) });
240
+ if (sections.length >= 15) break;
241
+ }
242
+ currentHeading = headingMatch[1].trim();
243
+ para = '';
244
+ continue;
245
+ }
246
+
247
+ if (!currentHeading) continue;
248
+
249
+ // Skip table lines, image lines
250
+ if (line.startsWith('|') || line.match(/!\[.*\]/)) continue;
251
+ if (line.trim() === '') {
252
+ if (para.trim().length >= 20) continue; // end of paragraph
253
+ continue;
254
+ }
255
+ // Skip sub-headings inside section
256
+ if (line.startsWith('###')) continue;
257
+
258
+ para += (para ? ' ' : '') + line.trim();
259
+ }
260
+
261
+ // Last section
262
+ if (currentHeading && para.trim().length > 0 && sections.length < 15) {
263
+ sections.push({ title: currentHeading, summary: smartTruncate(para.trim(), 600) });
264
+ }
265
+
266
+ return sections;
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Layer 3: Extract contextual paragraphs
271
+ // ---------------------------------------------------------------------------
272
+ const PATTERN_MAP = {
273
+ whenToUse: /when\s+(?:to\s+)?use|何时用|适用场景/i,
274
+ howItWorks: /how\s+it\s+works|运作原理|how\s+to\s+use|workflow|流程/i,
275
+ limitations: /limit|局限|not\s+do|anti.?pattern|caveat|注意事项/i,
276
+ };
277
+
278
+ function extractContextual(content) {
279
+ const result = { whenToUse: '', howItWorks: '', limitations: '' };
280
+
281
+ // Remove code blocks first
282
+ const cleaned = content.replace(/```[\s\S]*?```/g, '');
283
+ const paragraphs = cleaned.split(/\n\s*\n/);
284
+
285
+ for (const para of paragraphs) {
286
+ const trimmed = para.trim();
287
+ const lines = trimmed.split('\n').filter(l => !l.startsWith('|') && !l.match(/^!\[/) && l.trim().length > 0);
288
+ const text = lines.join(' ').trim();
289
+ if (text.length < 20 || text.length > 800) continue;
290
+
291
+ for (const [key, regex] of Object.entries(PATTERN_MAP)) {
292
+ if (!result[key] && regex.test(text)) {
293
+ result[key] = text.slice(0, 500);
294
+ }
295
+ }
296
+ }
297
+
298
+ return result;
299
+ }
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // Skill scanning
303
+ // ---------------------------------------------------------------------------
304
+ const HOME = os.homedir();
305
+ const CODEX_HOME = process.env.CODEX_HOME || path.join(HOME, '.codex');
306
+
307
+ function buildScanSources() {
308
+ const rawSources = [
309
+ { dir: path.join(HOME, '.claude', 'skills'), label: 'claude-user', priority: 1, depth: 1 },
310
+ { dir: path.join(CODEX_HOME, 'skills', '.system'), label: 'openai-system', priority: 0, depth: 1 },
311
+ { dir: path.join(CODEX_HOME, 'skills'), label: 'codex-user', priority: 1, depth: 1 },
312
+ { dir: path.join(HOME, '.cc-switch', 'skills'), label: 'cc-switch', priority: 2, depth: 1 },
313
+ { dir: path.join(HOME, '.claude', 'plugins', 'marketplaces'), label: 'claude-plugin', priority: 3, depth: 2 },
314
+ { dir: path.join(CODEX_HOME, 'plugins', 'cache'), label: 'codex-plugin', priority: 3, depth: 4 },
315
+ ];
316
+
317
+ const seen = new Set();
318
+ return rawSources.filter((source) => {
319
+ const key = path.resolve(source.dir);
320
+ if (seen.has(key)) return false;
321
+ seen.add(key);
322
+ return true;
323
+ });
324
+ }
325
+
326
+ const SCAN_SOURCES = buildScanSources();
327
+
328
+ function tryStatDir(p) {
329
+ try { return fs.statSync(p).isDirectory(); } catch (_) { return false; }
330
+ }
331
+
332
+ function scanDirectory(dirPath, maxDepth, currentDepth, options = {}) {
333
+ if (currentDepth > maxDepth) return [];
334
+ let entries;
335
+ try {
336
+ entries = fs.readdirSync(dirPath, { withFileTypes: true });
337
+ } catch (_) { return []; }
338
+
339
+ const results = [];
340
+ for (const entry of entries) {
341
+ // Follow symlinks — entry.isDirectory() is false for symlinks without this
342
+ const isDir = entry.isDirectory() || (entry.isSymbolicLink() && tryStatDir(path.join(dirPath, entry.name)));
343
+ if (!isDir) continue;
344
+ if (!options.includeHidden && entry.name.startsWith('.')) continue;
345
+
346
+ const subDir = path.join(dirPath, entry.name);
347
+
348
+ // Check for SKILL.md or README.md
349
+ const skillMd = path.join(subDir, 'SKILL.md');
350
+ const readmeMd = path.join(subDir, 'README.md');
351
+ let mdFile = null;
352
+
353
+ if (fileExists(skillMd)) {
354
+ mdFile = skillMd;
355
+ } else if (fileExists(readmeMd)) {
356
+ mdFile = readmeMd;
357
+ }
358
+
359
+ if (mdFile) {
360
+ results.push({ dir: subDir, mdFile });
361
+ }
362
+
363
+ // Recurse deeper
364
+ if (currentDepth < maxDepth) {
365
+ const deeper = scanDirectory(subDir, maxDepth, currentDepth + 1, options);
366
+ results.push(...deeper);
367
+ }
368
+ }
369
+ return results;
370
+ }
371
+
372
+ function fileExists(p) {
373
+ try { return fs.statSync(p).isFile(); } catch (_) { return false; }
374
+ }
375
+
376
+ function loadSkill(dir, mdFile) {
377
+ let content;
378
+ try {
379
+ content = fs.readFileSync(mdFile, 'utf8');
380
+ } catch (_) { return null; }
381
+
382
+ const fm = parseFrontmatter(content);
383
+ const name = fm.name || path.basename(dir);
384
+ const description = (fm.description || '').slice(0, 500);
385
+
386
+ // Skill is valid if it has name or description in frontmatter
387
+ if (!fm.name && !fm.description) return null;
388
+
389
+ let triggers = fm.triggers || [];
390
+ if (typeof triggers === 'string') triggers = [triggers];
391
+
392
+ let allowedTools = fm['allowed-tools'] || fm.allowedTools || [];
393
+ if (typeof allowedTools === 'string') allowedTools = [allowedTools];
394
+
395
+ return {
396
+ name,
397
+ description,
398
+ category: categorize(name, description, triggers),
399
+ triggers,
400
+ allowedTools,
401
+ version: fm.version || '',
402
+ dir,
403
+ _mdFile: mdFile,
404
+ _content: content,
405
+ };
406
+ }
407
+
408
+ function scanAllSkills() {
409
+ const allSkills = {};
410
+ const sourceCounts = Object.fromEntries(SCAN_SOURCES.map((source) => [source.label, 0]));
411
+
412
+ for (const source of SCAN_SOURCES) {
413
+ const dirs = scanDirectory(source.dir, source.depth, 0, { includeHidden: source.includeHidden });
414
+ for (const { dir, mdFile } of dirs) {
415
+ const skill = loadSkill(dir, mdFile);
416
+ if (!skill) continue;
417
+
418
+ skill._source = source.label;
419
+ sourceCounts[source.label] = (sourceCounts[source.label] || 0) + 1;
420
+
421
+ const key = skill.name.toLowerCase();
422
+ if (allSkills[key]) {
423
+ // Keep higher priority, track all sources
424
+ const existing = allSkills[key];
425
+ if (!existing.sources.includes(source.label)) {
426
+ existing.sources.push(source.label);
427
+ }
428
+ if (source.priority < existing._priority) {
429
+ // Replace with higher priority data
430
+ skill.sources = existing.sources;
431
+ skill._priority = source.priority;
432
+ allSkills[key] = skill;
433
+ }
434
+ } else {
435
+ skill.sources = [source.label];
436
+ skill._priority = source.priority;
437
+ allSkills[key] = skill;
438
+ }
439
+ }
440
+ }
441
+
442
+ return { skills: Object.values(allSkills), sourceCounts };
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Load full data (Layer 2 + 3) for a specific skill
447
+ // ---------------------------------------------------------------------------
448
+ function loadFullData(skill) {
449
+ // Use cached content if available, otherwise re-read
450
+ let content = skill._content;
451
+ if (!content) {
452
+ try {
453
+ content = fs.readFileSync(skill._mdFile, 'utf8');
454
+ } catch (_) { content = ''; }
455
+ }
456
+
457
+ const sections = extractSections(content);
458
+ const contextual = extractContextual(content);
459
+
460
+ return {
461
+ ...skill,
462
+ sections,
463
+ howItWorks: contextual.howItWorks,
464
+ whenToUse: contextual.whenToUse,
465
+ limitations: contextual.limitations,
466
+ };
467
+ }
468
+
469
+ // ---------------------------------------------------------------------------
470
+ // Clean skill for output (remove internal fields)
471
+ // ---------------------------------------------------------------------------
472
+ function cleanSkill(skill, includeFull) {
473
+ const base = {
474
+ name: skill.name,
475
+ description: skill.description,
476
+ category: skill.category,
477
+ sources: skill.sources,
478
+ triggers: skill.triggers,
479
+ allowedTools: skill.allowedTools,
480
+ version: skill.version,
481
+ dir: skill.dir.replace(HOME, '~'),
482
+ };
483
+
484
+ if (includeFull) {
485
+ const full = loadFullData(skill);
486
+ base.sections = full.sections;
487
+ base.howItWorks = full.howItWorks;
488
+ base.whenToUse = full.whenToUse;
489
+ base.limitations = full.limitations;
490
+ }
491
+
492
+ return base;
493
+ }
494
+
495
+ // ---------------------------------------------------------------------------
496
+ // Main
497
+ // ---------------------------------------------------------------------------
498
+ function main() {
499
+ let scanResult;
500
+
501
+ // Try cache first
502
+ if (!refresh) {
503
+ const cached = readCache();
504
+ if (cached && cached.skills) {
505
+ scanResult = cached;
506
+ }
507
+ }
508
+
509
+ if (!scanResult) {
510
+ scanResult = scanAllSkills();
511
+ // Cache the raw result
512
+ const toCache = {
513
+ scanDate: new Date().toISOString().slice(0, 10),
514
+ totalCount: scanResult.skills.length,
515
+ sourceCounts: scanResult.sourceCounts,
516
+ skills: scanResult.skills.map(s => ({ ...s })),
517
+ };
518
+ writeCache(toCache);
519
+ }
520
+
521
+ // Fix scanDate if from cache
522
+ if (!scanResult.scanDate) {
523
+ scanResult.scanDate = new Date().toISOString().slice(0, 10);
524
+ }
525
+
526
+ const skills = scanResult.skills;
527
+ const sourceCounts = scanResult.sourceCounts || {};
528
+
529
+ let output;
530
+
531
+ if (mode === 'list') {
532
+ output = {
533
+ scanDate: scanResult.scanDate,
534
+ totalCount: skills.length,
535
+ sources: sourceCounts,
536
+ skills: skills.map(s => cleanSkill(s, false)),
537
+ };
538
+ } else if (mode === 'skill') {
539
+ // Strip plugin prefix (e.g. "everything-claude-code:tdd-workflow" → "tdd-workflow")
540
+ const bareName = skillName.replace(/^[^:]+:/, '');
541
+ const found = skills.find(s => s.name.toLowerCase() === bareName.toLowerCase()
542
+ || s.name.toLowerCase() === skillName.toLowerCase());
543
+ if (!found) {
544
+ output = { error: `Skill "${skillName}" not found`, skills: [] };
545
+ } else {
546
+ output = {
547
+ scanDate: scanResult.scanDate,
548
+ totalCount: skills.length,
549
+ sources: sourceCounts,
550
+ skills: [cleanSkill(found, true)],
551
+ };
552
+ }
553
+ } else if (mode === 'search') {
554
+ const q = searchQuery.toLowerCase();
555
+ const matched = skills.filter(s => {
556
+ const nameMatch = s.name.toLowerCase().includes(q);
557
+ const descMatch = s.description.toLowerCase().includes(q);
558
+ const triggerMatch = s.triggers.some(t => t.toLowerCase().includes(q));
559
+ return nameMatch || descMatch || triggerMatch;
560
+ });
561
+ output = {
562
+ scanDate: scanResult.scanDate,
563
+ totalCount: skills.length,
564
+ matchedCount: matched.length,
565
+ sources: sourceCounts,
566
+ skills: matched.map(s => cleanSkill(s, false)),
567
+ };
568
+ } else if (mode === 'full') {
569
+ output = {
570
+ scanDate: scanResult.scanDate,
571
+ totalCount: skills.length,
572
+ sources: sourceCounts,
573
+ skills: skills.map(s => cleanSkill(s, true)),
574
+ };
575
+ }
576
+
577
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
578
+ }
579
+
580
+ main();