skill-guide 0.2.1 → 0.3.1

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/scan-skills.js CHANGED
@@ -122,7 +122,7 @@ function parseFrontmatter(content) {
122
122
  continue;
123
123
  }
124
124
  // End of multiline block
125
- if (multilineType === '|' || multilineType === '>') {
125
+ if (/^[>|]/.test(multilineType)) {
126
126
  if (multilineValue && !Array.isArray(result[currentKey])) {
127
127
  result[currentKey] = multilineValue.trim();
128
128
  }
@@ -137,8 +137,9 @@ function parseFrontmatter(content) {
137
137
  currentKey = kvMatch[1];
138
138
  let val = kvMatch[2].trim();
139
139
 
140
- // Multi-line indicators
141
- if (val === '|' || val === '>') {
140
+ // Multi-line indicators — skip if value is quoted
141
+ const isQuoted = (val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"));
142
+ if (!isQuoted && /^[>|](-|\+)?$/.test(val)) {
142
143
  inMultiline = true;
143
144
  multilineType = val;
144
145
  multilineValue = '';
@@ -202,7 +203,16 @@ const CATEGORY_MAP = [
202
203
  { category: 'development', keywords: /\b(develop|build|debug|investigate|plan|brainstorm|feature|implement)\b/i },
203
204
  ];
204
205
 
205
- function categorize(name, description, triggers) {
206
+ function categorize(name, description, triggers, tags) {
207
+ // Priority 1: tags match
208
+ if (tags && tags.length > 0) {
209
+ const tagText = tags.join(' ');
210
+ for (const { category, keywords } of CATEGORY_MAP) {
211
+ if (keywords.test(tagText)) return category;
212
+ }
213
+ }
214
+
215
+ // Priority 2: description match
206
216
  const text = [name, description, ...(triggers || [])].join(' ');
207
217
  for (const { category, keywords } of CATEGORY_MAP) {
208
218
  if (keywords.test(text)) return category;
@@ -220,6 +230,26 @@ function smartTruncate(text, maxLen) {
220
230
  return truncated + '...';
221
231
  }
222
232
 
233
+ // ---------------------------------------------------------------------------
234
+ // Layer 1.5: Extract summary (first body paragraph before headings)
235
+ // ---------------------------------------------------------------------------
236
+ function extractSummary(bodyContent) {
237
+ const paragraphs = bodyContent.split(/\n\s*\n/);
238
+ for (const para of paragraphs) {
239
+ const trimmed = para.trim();
240
+ if (!trimmed) continue;
241
+ if (trimmed.startsWith('##')) break;
242
+ if (trimmed.startsWith('```')) continue;
243
+ if (trimmed.match(/^!\[/)) continue;
244
+ if (trimmed.startsWith('|')) continue;
245
+ const text = trimmed.replace(/\s+/g, ' ').trim();
246
+ if (text.length >= 20) {
247
+ return smartTruncate(text, 200);
248
+ }
249
+ }
250
+ return '';
251
+ }
252
+
223
253
  // ---------------------------------------------------------------------------
224
254
  // Layer 2: Extract sections (## headings with first paragraph)
225
255
  // ---------------------------------------------------------------------------
@@ -392,12 +422,16 @@ function loadSkill(dir, mdFile) {
392
422
  let allowedTools = fm['allowed-tools'] || fm.allowedTools || [];
393
423
  if (typeof allowedTools === 'string') allowedTools = [allowedTools];
394
424
 
425
+ let tags = fm.tags || [];
426
+ if (typeof tags === 'string') tags = [tags];
427
+
395
428
  return {
396
429
  name,
397
430
  description,
398
- category: categorize(name, description, triggers),
431
+ category: categorize(name, description, triggers, tags),
399
432
  triggers,
400
433
  allowedTools,
434
+ tags,
401
435
  version: fm.version || '',
402
436
  dir,
403
437
  _mdFile: mdFile,
@@ -454,18 +488,70 @@ function loadFullData(skill) {
454
488
  } catch (_) { content = ''; }
455
489
  }
456
490
 
457
- const sections = extractSections(content);
458
- const contextual = extractContextual(content);
491
+ // Strip frontmatter before body extraction
492
+ const bodyContent = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
493
+
494
+ const sections = extractSections(bodyContent);
495
+ const contextual = extractContextual(bodyContent);
496
+ const summary = extractSummary(bodyContent);
459
497
 
460
498
  return {
461
499
  ...skill,
462
500
  sections,
501
+ summary,
463
502
  howItWorks: contextual.howItWorks,
464
503
  whenToUse: contextual.whenToUse,
465
504
  limitations: contextual.limitations,
466
505
  };
467
506
  }
468
507
 
508
+ // ---------------------------------------------------------------------------
509
+ // Compute completeness score (0-100) for documentation quality
510
+ // ---------------------------------------------------------------------------
511
+ const GARBAGE_PATTERNS = /^[>|](-|\+)?$|^---|^\s*$|^category:|^tags:/;
512
+
513
+ function computeCompleteness(skill, full) {
514
+ let score = 0;
515
+
516
+ // description: 20 points — non-empty and not a YAML artifact
517
+ if (skill.description && skill.description.length > 2 && !GARBAGE_PATTERNS.test(skill.description)) {
518
+ score += 20;
519
+ }
520
+
521
+ // summary: 20 points — non-empty and not a YAML artifact
522
+ if (full && full.summary && full.summary.length > 0 && !GARBAGE_PATTERNS.test(full.summary)) {
523
+ score += 20;
524
+ }
525
+
526
+ // whenToUse: 20 points — non-empty and no YAML leakage
527
+ if (full && full.whenToUse && full.whenToUse.length > 20 && !full.whenToUse.startsWith('---')) {
528
+ score += 20;
529
+ }
530
+
531
+ // howItWorks: 10 points — no YAML metadata
532
+ if (full && full.howItWorks && full.howItWorks.length > 20 &&
533
+ !/^---/.test(full.howItWorks) && !/^category:/.test(full.howItWorks) && !/^tags:/.test(full.howItWorks)) {
534
+ score += 10;
535
+ }
536
+
537
+ // tags: 10 points
538
+ if (skill.tags && skill.tags.length > 0) {
539
+ score += 10;
540
+ }
541
+
542
+ // triggers: 10 points
543
+ if (skill.triggers && skill.triggers.length > 0) {
544
+ score += 10;
545
+ }
546
+
547
+ // limitations: 10 points
548
+ if (full && full.limitations && full.limitations.length > 10) {
549
+ score += 10;
550
+ }
551
+
552
+ return score;
553
+ }
554
+
469
555
  // ---------------------------------------------------------------------------
470
556
  // Clean skill for output (remove internal fields)
471
557
  // ---------------------------------------------------------------------------
@@ -476,22 +562,107 @@ function cleanSkill(skill, includeFull) {
476
562
  category: skill.category,
477
563
  sources: skill.sources,
478
564
  triggers: skill.triggers,
565
+ tags: skill.tags,
479
566
  allowedTools: skill.allowedTools,
480
567
  version: skill.version,
481
568
  dir: skill.dir.replace(HOME, '~'),
569
+ tokenCost: estimateTokens(skill.description),
482
570
  };
483
571
 
484
572
  if (includeFull) {
485
573
  const full = loadFullData(skill);
486
574
  base.sections = full.sections;
575
+ base.summary = full.summary;
487
576
  base.howItWorks = full.howItWorks;
488
577
  base.whenToUse = full.whenToUse;
489
578
  base.limitations = full.limitations;
579
+ base.completeness = computeCompleteness(skill, full);
490
580
  }
491
581
 
492
582
  return base;
493
583
  }
494
584
 
585
+ function estimateTokens(text) {
586
+ if (!text) return 0;
587
+ // Rough estimate: ~4 characters per token for English text
588
+ // This is conservative; actual tokenization varies by model
589
+ return Math.ceil(text.length / 4);
590
+ }
591
+
592
+ function computeHealthStats(skills) {
593
+ const CONTEXT_WINDOW = 200_000; // Claude's context window
594
+ const DESCRIPTION_BUDGET = 16_000; // ~1% of context for skill descriptions
595
+ const STALE_DAYS = 30;
596
+
597
+ let totalDescriptionLength = 0;
598
+ let totalTokenEstimate = 0;
599
+ const staleSkills = [];
600
+ const securityFlags = [];
601
+ const duplicates = new Map();
602
+
603
+ for (const skill of skills) {
604
+ // Token cost
605
+ const descLen = (skill.description || '').length;
606
+ totalDescriptionLength += descLen;
607
+ totalTokenEstimate += estimateTokens(skill.description);
608
+
609
+ // Stale detection (based on file mtime)
610
+ try {
611
+ const stat = fs.statSync(skill._mdFile);
612
+ const daysSinceModified = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24);
613
+ if (daysSinceModified > STALE_DAYS) {
614
+ staleSkills.push({
615
+ name: skill.name,
616
+ daysSinceModified: Math.floor(daysSinceModified),
617
+ lastModified: stat.mtime.toISOString().slice(0, 10),
618
+ });
619
+ }
620
+ } catch (_) { /* ignore stat errors */ }
621
+
622
+ // Security red flags (simple patterns)
623
+ const content = (skill._content || '').toLowerCase();
624
+ const flags = [];
625
+ if (content.includes('curl ') && content.includes(' | ')) flags.push('pipe-from-curl');
626
+ if (content.includes('eval(') || content.includes('exec(')) flags.push('eval-exec');
627
+ if (content.includes('api_key') || content.includes('apikey') || content.includes('token')) flags.push('handles-secrets');
628
+ if (content.includes('rm -rf') || content.includes('rmdir /s')) flags.push('destructive-commands');
629
+ if (flags.length > 0) {
630
+ securityFlags.push({ name: skill.name, flags });
631
+ }
632
+
633
+ // Duplicate detection (by normalized name)
634
+ const normalizedName = skill.name.toLowerCase().replace(/[^a-z0-9]/g, '');
635
+ if (duplicates.has(normalizedName)) {
636
+ duplicates.get(normalizedName).push(skill.name);
637
+ } else {
638
+ duplicates.set(normalizedName, [skill.name]);
639
+ }
640
+ }
641
+
642
+ // Filter out non-duplicates
643
+ const duplicateGroups = [...duplicates.entries()]
644
+ .filter(([, names]) => names.length > 1)
645
+ .map(([normalized, names]) => ({ normalized, names }));
646
+
647
+ // Hidden skills calculation
648
+ const hiddenCount = DESCRIPTION_BUDGET > 0
649
+ ? Math.max(0, Math.floor((totalDescriptionLength - DESCRIPTION_BUDGET) / 100))
650
+ : 0;
651
+
652
+ return {
653
+ totalSkills: skills.length,
654
+ totalDescriptionLength,
655
+ totalTokenEstimate,
656
+ descriptionBudget: DESCRIPTION_BUDGET,
657
+ budgetUsedPercent: Math.round((totalDescriptionLength / DESCRIPTION_BUDGET) * 100),
658
+ hiddenSkillEstimate: Math.min(hiddenCount, skills.length),
659
+ staleSkills: staleSkills.sort((a, b) => b.daysSinceModified - a.daysSinceModified),
660
+ securityFlags,
661
+ duplicateGroups,
662
+ contextWindowPercent: Math.round((totalTokenEstimate / CONTEXT_WINDOW) * 100 * 100) / 100,
663
+ };
664
+ }
665
+
495
666
  function normalizeSkillName(name) {
496
667
  return String(name || '').replace(/^[^:]+:/, '').toLowerCase();
497
668
  }