@studiomeyer-io/skilldoctor 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1540 @@
1
+ import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
2
+ import { basename, resolve, join, sep, posix, relative, dirname } from 'path';
3
+ import { parse, YAMLParseError } from 'yaml';
4
+ import { createHash } from 'crypto';
5
+
6
+ // src/types.ts
7
+ var SEVERITY_RANK = {
8
+ info: 1,
9
+ warning: 2,
10
+ error: 3
11
+ };
12
+ var OPENING_FENCE = /^---[ \t]*\r?\n/;
13
+ function extractFrontmatter(raw) {
14
+ if (!OPENING_FENCE.test(raw)) {
15
+ return {
16
+ frontmatter: {
17
+ present: false,
18
+ raw: "",
19
+ data: void 0,
20
+ error: void 0,
21
+ startLine: 0,
22
+ endLine: 0
23
+ },
24
+ body: raw,
25
+ bodyStartLine: 1
26
+ };
27
+ }
28
+ const lines = raw.split(/\r?\n/);
29
+ let closingIndex = -1;
30
+ for (let i = 1; i < lines.length; i++) {
31
+ if (/^---[ \t]*$/.test(lines[i] ?? "")) {
32
+ closingIndex = i;
33
+ break;
34
+ }
35
+ }
36
+ if (closingIndex === -1) {
37
+ const fmLines2 = lines.slice(1);
38
+ const fmRaw2 = fmLines2.join("\n");
39
+ return {
40
+ frontmatter: {
41
+ present: true,
42
+ raw: fmRaw2,
43
+ data: void 0,
44
+ error: "Unterminated frontmatter: opening '---' has no closing '---'.",
45
+ startLine: 2,
46
+ endLine: lines.length
47
+ },
48
+ body: "",
49
+ bodyStartLine: lines.length + 1
50
+ };
51
+ }
52
+ const fmLines = lines.slice(1, closingIndex);
53
+ const fmRaw = fmLines.join("\n");
54
+ const bodyLines = lines.slice(closingIndex + 1);
55
+ const body = bodyLines.join("\n");
56
+ const bodyStartLine = closingIndex + 2;
57
+ let data;
58
+ let error;
59
+ try {
60
+ const parsed = parse(fmRaw, {
61
+ // Defensive: keep parsing strict-ish but lenient on duplicate keys so we
62
+ // can detect duplicates ourselves rather than throwing.
63
+ uniqueKeys: false,
64
+ strict: false
65
+ });
66
+ if (parsed === null || parsed === void 0) {
67
+ data = {};
68
+ } else if (typeof parsed === "object" && !Array.isArray(parsed)) {
69
+ data = parsed;
70
+ } else {
71
+ error = "Frontmatter must be a YAML mapping (key: value pairs).";
72
+ }
73
+ } catch (e) {
74
+ if (e instanceof YAMLParseError) {
75
+ error = e.message.split("\n")[0] ?? e.message;
76
+ } else if (e instanceof Error) {
77
+ error = e.message;
78
+ } else {
79
+ error = "Unknown YAML parse error.";
80
+ }
81
+ }
82
+ return {
83
+ frontmatter: {
84
+ present: true,
85
+ raw: fmRaw,
86
+ data,
87
+ error,
88
+ startLine: 2,
89
+ endLine: closingIndex + 1
90
+ // 1-based line of the closing fence
91
+ },
92
+ body,
93
+ bodyStartLine
94
+ };
95
+ }
96
+ function normalizePath(filePath) {
97
+ return filePath.replace(/\\/g, "/").toLowerCase();
98
+ }
99
+ function detectKind(filePath, frontmatter) {
100
+ const norm = normalizePath(filePath);
101
+ const base = basename(norm);
102
+ if (base === "agents.md") return "agents-md";
103
+ if (base === "skill.md") return "skill";
104
+ const inAgentsDir = /(^|\/)agents\/[^/]+\.md$/.test(norm);
105
+ const data = frontmatter.data;
106
+ const hasName = !!data && typeof data["name"] === "string";
107
+ const hasDescription = !!data && typeof data["description"] === "string";
108
+ if (inAgentsDir && frontmatter.present && (hasName || hasDescription)) {
109
+ return "subagent";
110
+ }
111
+ if (frontmatter.present && hasName && hasDescription) {
112
+ return "skill";
113
+ }
114
+ return "unknown";
115
+ }
116
+ function parseFile(filePath, raw, forceKind) {
117
+ if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
118
+ const { frontmatter, body, bodyStartLine } = extractFrontmatter(raw);
119
+ const kind = forceKind ?? detectKind(filePath, frontmatter);
120
+ return { filePath, kind, frontmatter, body, bodyStartLine, raw };
121
+ }
122
+
123
+ // src/registry.ts
124
+ var RULES = [
125
+ // ---- Lint rules (frontmatter / structure) ---------------------------------
126
+ {
127
+ ruleId: "skill/missing-name",
128
+ title: "Missing name",
129
+ category: "lint",
130
+ defaultSeverity: "error",
131
+ description: "A skill or subagent frontmatter must declare a non-empty `name`. The Agent Skills spec requires it; Claude Code falls back to the directory name but a missing name is fragile.",
132
+ fixable: false
133
+ },
134
+ {
135
+ ruleId: "skill/invalid-name",
136
+ title: "Invalid name format",
137
+ category: "lint",
138
+ defaultSeverity: "error",
139
+ description: "`name` must be 1-64 lowercase characters using only a-z, 0-9 and hyphens, with no leading/trailing/consecutive hyphens (Agent Skills spec).",
140
+ fixable: false
141
+ },
142
+ {
143
+ ruleId: "skill/name-dir-mismatch",
144
+ title: "Name does not match directory",
145
+ category: "lint",
146
+ defaultSeverity: "warning",
147
+ description: "The Agent Skills spec says a skill's `name` must match its parent directory name. A mismatch breaks invocation in some clients.",
148
+ fixable: false
149
+ },
150
+ {
151
+ ruleId: "skill/missing-description",
152
+ title: "Missing description",
153
+ category: "lint",
154
+ defaultSeverity: "error",
155
+ description: "`description` is required and is what an agent uses to decide when to load the skill. Without it the skill is effectively invisible.",
156
+ fixable: true
157
+ },
158
+ {
159
+ ruleId: "skill/empty-description",
160
+ title: "Empty description",
161
+ category: "lint",
162
+ defaultSeverity: "error",
163
+ description: "`description` is present but blank. It must be non-empty.",
164
+ fixable: true
165
+ },
166
+ {
167
+ ruleId: "skill/description-too-short",
168
+ title: "Description too short",
169
+ category: "lint",
170
+ defaultSeverity: "warning",
171
+ description: "A very short description gives the agent almost no signal about when to use the skill. Describe both what it does and when to use it.",
172
+ fixable: false
173
+ },
174
+ {
175
+ ruleId: "skill/description-too-long",
176
+ title: "Description too long",
177
+ category: "lint",
178
+ defaultSeverity: "warning",
179
+ description: "`description` exceeds the 1024-character spec limit (Claude Code truncates the combined description+when_to_use at 1,536 chars in the skill listing).",
180
+ fixable: false
181
+ },
182
+ {
183
+ ruleId: "skill/vague-description",
184
+ title: "Vague description",
185
+ category: "lint",
186
+ defaultSeverity: "info",
187
+ description: "The description is generic (e.g. 'helps with things') and lacks trigger keywords. Agents match descriptions to tasks, so specificity matters.",
188
+ fixable: false
189
+ },
190
+ {
191
+ ruleId: "skill/empty-body",
192
+ title: "Empty body",
193
+ category: "lint",
194
+ defaultSeverity: "warning",
195
+ description: "The markdown body (the actual instructions) is empty or whitespace-only. A skill with no instructions does nothing.",
196
+ fixable: false
197
+ },
198
+ {
199
+ ruleId: "skill/frontmatter-schema",
200
+ title: "Frontmatter schema error",
201
+ category: "lint",
202
+ defaultSeverity: "error",
203
+ description: "The YAML frontmatter could not be parsed, is not a mapping, or a known field has the wrong type.",
204
+ fixable: false
205
+ },
206
+ {
207
+ ruleId: "skill/unknown-field",
208
+ title: "Unknown frontmatter field",
209
+ category: "lint",
210
+ defaultSeverity: "info",
211
+ description: "A frontmatter field is not part of the Agent Skills spec or the known Claude Code extensions. Handled leniently (info) since clients may add their own fields.",
212
+ fixable: false
213
+ },
214
+ {
215
+ ruleId: "skill/duplicate-key",
216
+ title: "Duplicate frontmatter key",
217
+ category: "lint",
218
+ defaultSeverity: "warning",
219
+ description: "A frontmatter key appears more than once. YAML keeps the last value, silently dropping the earlier one.",
220
+ fixable: false
221
+ },
222
+ {
223
+ ruleId: "skill/trailing-whitespace",
224
+ title: "Trailing whitespace in frontmatter",
225
+ category: "lint",
226
+ defaultSeverity: "info",
227
+ description: "A frontmatter line has trailing whitespace. Cosmetic, but mechanically fixable.",
228
+ fixable: true
229
+ },
230
+ {
231
+ ruleId: "skill/duplicate-name",
232
+ title: "Duplicate skill name in set",
233
+ category: "lint",
234
+ defaultSeverity: "error",
235
+ description: "Two files in the analyzed set declare the same `name`. Clients keep one and silently discard the other.",
236
+ fixable: false
237
+ },
238
+ // ---- Least-privilege / tool-grant rules -----------------------------------
239
+ {
240
+ ruleId: "tools/wildcard-grant",
241
+ title: "Wildcard tool grant",
242
+ category: "lint",
243
+ defaultSeverity: "warning",
244
+ description: "The tool grant includes a bare `*` / `all` wildcard. Least-privilege: grant only the specific tools the skill needs.",
245
+ fixable: false
246
+ },
247
+ {
248
+ ruleId: "tools/over-broad-for-readonly",
249
+ title: "Over-broad tools for a read-only skill",
250
+ category: "lint",
251
+ defaultSeverity: "warning",
252
+ description: "The description implies a read-only/docs task but the skill grants write/exec/network tools (e.g. Bash, Write, Edit, WebFetch). Least-privilege violation.",
253
+ fixable: false
254
+ },
255
+ {
256
+ ruleId: "tools/duplicate-tool",
257
+ title: "Duplicate tool in grant",
258
+ category: "lint",
259
+ defaultSeverity: "info",
260
+ description: "The same tool is listed more than once in the tool grant.",
261
+ fixable: true
262
+ },
263
+ // ---- Security scan rules --------------------------------------------------
264
+ {
265
+ ruleId: "sec/prompt-injection",
266
+ title: "Prompt-injection phrasing",
267
+ category: "security",
268
+ defaultSeverity: "error",
269
+ description: "The content contains prompt-injection-style instructions (e.g. 'ignore previous instructions', 'disregard your system prompt'). Skill content is untrusted input.",
270
+ fixable: false
271
+ },
272
+ {
273
+ ruleId: "sec/disable-safety",
274
+ title: "Instruction to disable safety/guardrails",
275
+ category: "security",
276
+ defaultSeverity: "error",
277
+ description: "The content instructs the agent to disable safety checks, hooks, guardrails, or approval gates.",
278
+ fixable: false
279
+ },
280
+ {
281
+ ruleId: "sec/data-exfiltration",
282
+ title: "Possible data-exfiltration pattern",
283
+ category: "security",
284
+ defaultSeverity: "error",
285
+ description: "The content combines an outbound network call (curl/POST/fetch to an external URL) with secrets/credentials/environment variables \u2014 a classic exfiltration shape.",
286
+ fixable: false
287
+ },
288
+ {
289
+ ruleId: "sec/env-base64",
290
+ title: "Encoding of secrets/env",
291
+ category: "security",
292
+ defaultSeverity: "warning",
293
+ description: "The content base64-encodes (or otherwise obfuscates) environment variables or secrets, often a precursor to covert exfiltration.",
294
+ fixable: false
295
+ },
296
+ {
297
+ ruleId: "sec/secret-access",
298
+ title: "Reads sensitive files/secrets",
299
+ category: "security",
300
+ defaultSeverity: "warning",
301
+ description: "The content references reading sensitive locations (~/.ssh, .env, credentials, cloud token files). Flagged for review; may be legitimate.",
302
+ fixable: false
303
+ },
304
+ {
305
+ ruleId: "sec/suspicious-tool-combo",
306
+ title: "Suspicious tool + content combination",
307
+ category: "security",
308
+ defaultSeverity: "warning",
309
+ description: "A skill described as read-only/docs grants Bash plus a network/exec capability \u2014 a combination that enables exfiltration from otherwise-innocent content.",
310
+ fixable: false
311
+ },
312
+ {
313
+ ruleId: "sec/destructive-command",
314
+ title: "Destructive shell command in content",
315
+ category: "security",
316
+ defaultSeverity: "warning",
317
+ description: "The content embeds a destructive command (e.g. `rm -rf /`, `curl | sh`, `git push --force`). Review before installing.",
318
+ fixable: false
319
+ },
320
+ {
321
+ ruleId: "sec/hidden-unicode",
322
+ title: "Hidden / bidi / zero-width Unicode",
323
+ category: "security",
324
+ defaultSeverity: "warning",
325
+ description: "The content contains zero-width or bidirectional control characters that can hide instructions from a human reviewer (Trojan-Source style).",
326
+ fixable: false
327
+ }
328
+ ];
329
+ var RULE_MAP = new Map(
330
+ RULES.map((r) => [r.ruleId, r])
331
+ );
332
+ function getRule(ruleId) {
333
+ const r = RULE_MAP.get(ruleId);
334
+ if (!r) {
335
+ throw new Error(
336
+ `Internal error: rule '${ruleId}' is not registered in registry.ts`
337
+ );
338
+ }
339
+ return r;
340
+ }
341
+ function allRuleIds() {
342
+ return RULES.map((r) => r.ruleId);
343
+ }
344
+
345
+ // src/locate.ts
346
+ function offsetToLineCol(text, offset) {
347
+ const clamped = Math.max(0, Math.min(offset, text.length));
348
+ let line = 1;
349
+ let lastNewline = -1;
350
+ for (let i = 0; i < clamped; i++) {
351
+ if (text.charCodeAt(i) === 10) {
352
+ line++;
353
+ lastNewline = i;
354
+ }
355
+ }
356
+ const column = clamped - lastNewline;
357
+ return { line, column };
358
+ }
359
+ function findKeyLine(frontmatterRaw, key, frontmatterStartLine) {
360
+ const lines = frontmatterRaw.split(/\r?\n/);
361
+ const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:`);
362
+ for (let i = 0; i < lines.length; i++) {
363
+ if (re.test(lines[i] ?? "")) {
364
+ return frontmatterStartLine + i;
365
+ }
366
+ }
367
+ return frontmatterStartLine;
368
+ }
369
+ function escapeRegExp(s) {
370
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
371
+ }
372
+ function makeEvidence(snippet, max = 120) {
373
+ const oneLine = snippet.replace(/\s+/g, " ").trim();
374
+ if (oneLine.length <= max) return oneLine;
375
+ return oneLine.slice(0, max - 1) + "\u2026";
376
+ }
377
+
378
+ // src/spec.ts
379
+ var SKILL_NAME_MAX = 64;
380
+ var SKILL_DESCRIPTION_MAX = 1024;
381
+ var SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
382
+ var SPEC_SKILL_FIELDS = /* @__PURE__ */ new Set([
383
+ "name",
384
+ "description",
385
+ "license",
386
+ "compatibility",
387
+ "metadata",
388
+ "allowed-tools"
389
+ ]);
390
+ var CLAUDE_CODE_SKILL_FIELDS = /* @__PURE__ */ new Set([
391
+ "when_to_use",
392
+ "argument-hint",
393
+ "arguments",
394
+ "disable-model-invocation",
395
+ "user-invocable",
396
+ "disallowed-tools",
397
+ "model",
398
+ "effort",
399
+ "context",
400
+ "agent",
401
+ "hooks",
402
+ "paths",
403
+ "shell"
404
+ ]);
405
+ var SUBAGENT_FIELDS = /* @__PURE__ */ new Set([
406
+ "name",
407
+ "description",
408
+ "tools",
409
+ "disallowedTools",
410
+ "model",
411
+ "permissionMode",
412
+ "mcpServers",
413
+ "hooks",
414
+ "maxTurns",
415
+ "skills",
416
+ "initialPrompt",
417
+ "memory",
418
+ "effort",
419
+ "background",
420
+ "isolation",
421
+ "color"
422
+ ]);
423
+ var SENSITIVE_TOOLS = /* @__PURE__ */ new Set([
424
+ "bash",
425
+ "shell",
426
+ "execute",
427
+ "exec",
428
+ "write",
429
+ "edit",
430
+ "multiedit",
431
+ "notebookedit",
432
+ "webfetch",
433
+ "websearch",
434
+ "fetch",
435
+ "applypatch",
436
+ "patch"
437
+ ]);
438
+ var NETWORK_TOOLS = /* @__PURE__ */ new Set([
439
+ "webfetch",
440
+ "websearch",
441
+ "fetch"
442
+ ]);
443
+ var EXEC_TOOLS = /* @__PURE__ */ new Set(["bash", "shell", "execute", "exec"]);
444
+ var READONLY_HINT_RE = /\b(read[- ]?only|look[- ]?up|lookup|summari[sz]e|explains?|describes?)\b/i;
445
+ function normalizeToolName(token) {
446
+ return token.trim().replace(/\(.*\)\s*$/, "").toLowerCase();
447
+ }
448
+ function parseToolList(value) {
449
+ if (Array.isArray(value)) {
450
+ return value.filter((v) => typeof v === "string");
451
+ }
452
+ if (typeof value === "string") {
453
+ return value.split(/[,\s]+/).map((t) => t.trim()).filter((t) => t.length > 0);
454
+ }
455
+ return [];
456
+ }
457
+ function isWildcardTool(token) {
458
+ const t = token.trim().toLowerCase();
459
+ return t === "*" || t === "all" || t === "any" || t === "everything";
460
+ }
461
+
462
+ // src/rules/lint.ts
463
+ function finding(ruleId, message, line, opts = {}) {
464
+ const rule = getRule(ruleId);
465
+ const f = {
466
+ ruleId: rule.ruleId,
467
+ title: rule.title,
468
+ category: rule.category,
469
+ severity: rule.defaultSeverity,
470
+ message,
471
+ line: Math.max(1, line),
472
+ column: Math.max(1, opts.column ?? 1),
473
+ fixable: rule.fixable
474
+ };
475
+ if (opts.evidence !== void 0) f.evidence = opts.evidence;
476
+ return f;
477
+ }
478
+ var VAGUE_RE = /^(helps?( you)?( with)?|does (stuff|things)|a skill( for)?|utility|tool|assistant|various|misc(ellaneous)?)\b/i;
479
+ function lintFile(file) {
480
+ const findings = [];
481
+ const { frontmatter, kind } = file;
482
+ if (kind === "agents-md") {
483
+ if (file.raw.trim().length === 0) {
484
+ findings.push(
485
+ finding(
486
+ "skill/empty-body",
487
+ "AGENTS.md is empty. It should contain project instructions for agents.",
488
+ 1
489
+ )
490
+ );
491
+ }
492
+ return findings;
493
+ }
494
+ if (kind === "unknown") {
495
+ return findings;
496
+ }
497
+ const fmStart = frontmatter.startLine || 1;
498
+ if (frontmatter.present && frontmatter.error) {
499
+ findings.push(
500
+ finding(
501
+ "skill/frontmatter-schema",
502
+ `Frontmatter could not be parsed: ${frontmatter.error}`,
503
+ fmStart
504
+ )
505
+ );
506
+ if (!frontmatter.data) return findings;
507
+ }
508
+ if (!frontmatter.present || !frontmatter.data) {
509
+ findings.push(
510
+ finding(
511
+ "skill/missing-name",
512
+ "No frontmatter found; a skill/subagent requires `name` and `description`.",
513
+ 1
514
+ )
515
+ );
516
+ findings.push(
517
+ finding(
518
+ "skill/missing-description",
519
+ "No frontmatter found; `description` is required.",
520
+ 1
521
+ )
522
+ );
523
+ return findings;
524
+ }
525
+ const data = frontmatter.data;
526
+ lintName(file, data, findings);
527
+ lintDescription(file, data, findings);
528
+ lintBody(file, findings);
529
+ lintTools(file, data, findings);
530
+ lintUnknownFields(file, data, findings);
531
+ lintTrailingWhitespace(file, findings);
532
+ lintDuplicateKeys(file, findings);
533
+ return findings;
534
+ }
535
+ function lintName(file, data, findings) {
536
+ const fmStart = file.frontmatter.startLine || 1;
537
+ const nameRaw = data["name"];
538
+ const nameLine = findKeyLine(file.frontmatter.raw, "name", fmStart);
539
+ if (nameRaw === void 0 || nameRaw === null) {
540
+ findings.push(
541
+ finding("skill/missing-name", "Missing required `name` field.", fmStart)
542
+ );
543
+ return;
544
+ }
545
+ if (typeof nameRaw !== "string") {
546
+ findings.push(
547
+ finding(
548
+ "skill/frontmatter-schema",
549
+ `\`name\` must be a string, got ${typeof nameRaw}.`,
550
+ nameLine
551
+ )
552
+ );
553
+ return;
554
+ }
555
+ const name = nameRaw.trim();
556
+ if (name.length === 0) {
557
+ findings.push(
558
+ finding("skill/missing-name", "`name` is empty.", nameLine)
559
+ );
560
+ return;
561
+ }
562
+ if (name.length > SKILL_NAME_MAX || !SKILL_NAME_RE.test(name)) {
563
+ findings.push(
564
+ finding(
565
+ "skill/invalid-name",
566
+ `\`name\` "${name}" is invalid. Use 1-${SKILL_NAME_MAX} lowercase chars (a-z, 0-9, hyphens), no leading/trailing/consecutive hyphens.`,
567
+ nameLine,
568
+ { evidence: makeEvidence(name) }
569
+ )
570
+ );
571
+ return;
572
+ }
573
+ if (basename(file.filePath).toLowerCase() === "skill.md") {
574
+ const dir = basename(dirname(file.filePath));
575
+ if (dir && dir !== "." && dir !== "" && dir.toLowerCase() !== name) {
576
+ findings.push(
577
+ finding(
578
+ "skill/name-dir-mismatch",
579
+ `\`name\` "${name}" does not match the parent directory "${dir}". The Agent Skills spec requires them to match.`,
580
+ nameLine
581
+ )
582
+ );
583
+ }
584
+ }
585
+ }
586
+ function lintDescription(file, data, findings) {
587
+ const fmStart = file.frontmatter.startLine || 1;
588
+ const descRaw = data["description"];
589
+ const descLine = findKeyLine(file.frontmatter.raw, "description", fmStart);
590
+ if (descRaw === void 0 || descRaw === null) {
591
+ findings.push(
592
+ finding(
593
+ "skill/missing-description",
594
+ "Missing required `description` field. Agents use it to decide when to load the skill.",
595
+ fmStart
596
+ )
597
+ );
598
+ return;
599
+ }
600
+ if (typeof descRaw !== "string") {
601
+ findings.push(
602
+ finding(
603
+ "skill/frontmatter-schema",
604
+ `\`description\` must be a string, got ${typeof descRaw}.`,
605
+ descLine
606
+ )
607
+ );
608
+ return;
609
+ }
610
+ const desc = descRaw.trim();
611
+ if (desc.length === 0) {
612
+ findings.push(
613
+ finding("skill/empty-description", "`description` is empty.", descLine)
614
+ );
615
+ return;
616
+ }
617
+ if (desc.length < 20) {
618
+ findings.push(
619
+ finding(
620
+ "skill/description-too-short",
621
+ `\`description\` is only ${desc.length} characters. Describe what the skill does AND when to use it (include trigger keywords).`,
622
+ descLine,
623
+ { evidence: makeEvidence(desc) }
624
+ )
625
+ );
626
+ }
627
+ if (desc.length > SKILL_DESCRIPTION_MAX) {
628
+ findings.push(
629
+ finding(
630
+ "skill/description-too-long",
631
+ `\`description\` is ${desc.length} characters, over the ${SKILL_DESCRIPTION_MAX}-char spec limit.`,
632
+ descLine
633
+ )
634
+ );
635
+ }
636
+ if (VAGUE_RE.test(desc)) {
637
+ findings.push(
638
+ finding(
639
+ "skill/vague-description",
640
+ "`description` reads as generic. Add concrete capabilities and trigger phrases so agents match it to real tasks.",
641
+ descLine,
642
+ { evidence: makeEvidence(desc) }
643
+ )
644
+ );
645
+ }
646
+ }
647
+ function lintBody(file, findings) {
648
+ if (file.body.trim().length === 0) {
649
+ findings.push(
650
+ finding(
651
+ "skill/empty-body",
652
+ "The instruction body is empty. Add the steps/instructions the agent should follow.",
653
+ file.bodyStartLine
654
+ )
655
+ );
656
+ }
657
+ }
658
+ function lintTools(file, data, findings) {
659
+ const fmStart = file.frontmatter.startLine || 1;
660
+ const keys = ["allowed-tools", "tools"];
661
+ for (const key of keys) {
662
+ if (!(key in data)) continue;
663
+ const value = data[key];
664
+ const line = findKeyLine(file.frontmatter.raw, key, fmStart);
665
+ if (value !== void 0 && value !== null && typeof value !== "string" && !Array.isArray(value)) {
666
+ findings.push(
667
+ finding(
668
+ "skill/frontmatter-schema",
669
+ `\`${key}\` must be a space/comma-separated string or a YAML list.`,
670
+ line
671
+ )
672
+ );
673
+ continue;
674
+ }
675
+ const tokens = parseToolList(value);
676
+ if (tokens.length === 0) continue;
677
+ const wild = tokens.find((t) => isWildcardTool(t));
678
+ if (wild) {
679
+ findings.push(
680
+ finding(
681
+ "tools/wildcard-grant",
682
+ `\`${key}\` grants a wildcard ("${wild}"). Least-privilege: list only the specific tools this skill needs.`,
683
+ line,
684
+ { evidence: makeEvidence(tokens.join(", ")) }
685
+ )
686
+ );
687
+ }
688
+ const seen = /* @__PURE__ */ new Set();
689
+ const dups = /* @__PURE__ */ new Set();
690
+ for (const t of tokens) {
691
+ const key2 = t.trim();
692
+ if (seen.has(key2)) dups.add(key2);
693
+ seen.add(key2);
694
+ }
695
+ if (dups.size > 0) {
696
+ findings.push(
697
+ finding(
698
+ "tools/duplicate-tool",
699
+ `\`${key}\` lists duplicate tool(s): ${[...dups].join(", ")}.`,
700
+ line
701
+ )
702
+ );
703
+ }
704
+ const desc = typeof data["description"] === "string" ? data["description"] : "";
705
+ const sensitiveGranted = tokens.map((t) => normalizeToolName(t)).filter((n) => SENSITIVE_TOOLS.has(n));
706
+ if (!wild && sensitiveGranted.length > 0 && READONLY_HINT_RE.test(desc)) {
707
+ findings.push(
708
+ finding(
709
+ "tools/over-broad-for-readonly",
710
+ `Description implies a read-only task but \`${key}\` grants write/exec/network tool(s): ${[
711
+ ...new Set(sensitiveGranted)
712
+ ].join(", ")}. Drop them or adjust the description.`,
713
+ line,
714
+ { evidence: makeEvidence(desc) }
715
+ )
716
+ );
717
+ }
718
+ }
719
+ }
720
+ function lintUnknownFields(file, data, findings) {
721
+ const fmStart = file.frontmatter.startLine || 1;
722
+ const allowed = file.kind === "subagent" ? SUBAGENT_FIELDS : unionFields();
723
+ for (const key of Object.keys(data)) {
724
+ if (!allowed.has(key)) {
725
+ findings.push(
726
+ finding(
727
+ "skill/unknown-field",
728
+ `Unknown frontmatter field "${key}" \u2014 not part of the Agent Skills spec or known Claude Code extensions. (Lenient: clients may add custom fields.)`,
729
+ findKeyLine(file.frontmatter.raw, key, fmStart)
730
+ )
731
+ );
732
+ }
733
+ }
734
+ }
735
+ function unionFields() {
736
+ const s = new Set(SPEC_SKILL_FIELDS);
737
+ for (const f of CLAUDE_CODE_SKILL_FIELDS) s.add(f);
738
+ return s;
739
+ }
740
+ function lintTrailingWhitespace(file, findings) {
741
+ if (!file.frontmatter.present) return;
742
+ const lines = file.frontmatter.raw.split(/\r?\n/);
743
+ const fmStart = file.frontmatter.startLine || 1;
744
+ for (let i = 0; i < lines.length; i++) {
745
+ const l = lines[i] ?? "";
746
+ if (/[ \t]+$/.test(l) && l.trim().length > 0) {
747
+ findings.push(
748
+ finding(
749
+ "skill/trailing-whitespace",
750
+ "Frontmatter line has trailing whitespace.",
751
+ fmStart + i
752
+ )
753
+ );
754
+ }
755
+ }
756
+ }
757
+ function lintDuplicateKeys(file, findings) {
758
+ if (!file.frontmatter.present) return;
759
+ const lines = file.frontmatter.raw.split(/\r?\n/);
760
+ const fmStart = file.frontmatter.startLine || 1;
761
+ const seen = /* @__PURE__ */ new Map();
762
+ for (let i = 0; i < lines.length; i++) {
763
+ const l = lines[i] ?? "";
764
+ const m = /^([A-Za-z0-9_-]+)\s*:/.exec(l);
765
+ if (!m) continue;
766
+ const key = m[1];
767
+ if (seen.has(key)) {
768
+ findings.push(
769
+ finding(
770
+ "skill/duplicate-key",
771
+ `Frontmatter key "${key}" appears more than once; YAML keeps only the last value.`,
772
+ fmStart + i
773
+ )
774
+ );
775
+ } else {
776
+ seen.set(key, i);
777
+ }
778
+ }
779
+ }
780
+
781
+ // src/security/scan.ts
782
+ var INJECTION_PATTERNS = [
783
+ {
784
+ ruleId: "sec/prompt-injection",
785
+ re: /ignore (?:all |any |the )?(?:previous|prior|above|earlier|preceding)[^\n]{0,30}\b(?:instruction|prompt|message|context|rule)s?/gi,
786
+ message: () => 'Contains "ignore previous instructions"-style injection.'
787
+ },
788
+ {
789
+ ruleId: "sec/prompt-injection",
790
+ re: /disregard[^\n]{0,30}\b(?:previous|prior|system|above|all)[^\n]{0,20}\b(?:instruction|prompt|rule|message)s?/gi,
791
+ message: () => 'Contains "disregard ... instructions/system prompt" injection.'
792
+ },
793
+ {
794
+ ruleId: "sec/prompt-injection",
795
+ re: /(?:forget|override|bypass)[^\n]{0,30}\b(?:your |the )?(?:system )?(?:prompt|instruction|rule|guideline)s?/gi,
796
+ message: () => "Instructs the agent to forget/override/bypass its prompt or rules."
797
+ },
798
+ {
799
+ ruleId: "sec/prompt-injection",
800
+ re: /\b(?:you are now|from now on,? you are|act as)[^\n]{0,40}\b(?:DAN|jailbroken|unrestricted|developer mode|no(?:t bound by| longer bound by| restrictions))/gi,
801
+ message: () => "Contains a role-override / jailbreak persona instruction."
802
+ },
803
+ {
804
+ ruleId: "sec/prompt-injection",
805
+ re: /\bnew (?:system )?(?:instructions?|prompt|directive)s?\s*:/gi,
806
+ message: () => 'Injects a "new instructions:" block (prompt-override pattern).'
807
+ }
808
+ ];
809
+ var SAFETY_PATTERNS = [
810
+ {
811
+ ruleId: "sec/disable-safety",
812
+ // Negative lookbehind: don't flag safety *documentation* like
813
+ // "never disable the content filter" / "do not bypass guardrails".
814
+ re: /(?<!\b(?:never|not|do not|don'?t|cannot|must not|should not)\s{1,6})\b(?:disable|turn off|bypass|skip|remove|ignore)[^\n]{0,40}\b(?:safety|guardrail|guard|content[- ]?filter|moderation|approval|confirmation|hook)s?\b/gi,
815
+ message: () => "Instructs the agent to disable safety/guardrails/hooks."
816
+ },
817
+ {
818
+ ruleId: "sec/disable-safety",
819
+ re: /(?<!\b(?:never|not|do not|don'?t|cannot|must not|should not)\s{1,6})\b(?:without|skip|no need for|don'?t (?:ask|request|require))[^\n]{0,30}\b(?:permission|approval|confirmation|consent)\b/gi,
820
+ message: () => "Tells the agent to act without permission/approval/confirmation."
821
+ },
822
+ {
823
+ ruleId: "sec/disable-safety",
824
+ re: /--dangerously-skip-permissions|CLAUDE_SKIP_[A-Z_]+\s*=\s*1|--yolo\b/g,
825
+ message: () => "References a flag/env that disables the harness safety prompts."
826
+ }
827
+ ];
828
+ var ENCODE_PATTERNS = [
829
+ {
830
+ ruleId: "sec/env-base64",
831
+ re: /\b(?:base64|btoa|b64encode|xxd|openssl enc)\b[^\n]{0,60}\b(?:env|environ|secret|token|key|password|credential)/gi,
832
+ message: () => "Encodes environment/secret values (possible covert exfil)."
833
+ },
834
+ {
835
+ ruleId: "sec/env-base64",
836
+ re: /\b(?:env|printenv|cat[^\n]{0,20}\.env)\b[^\n]{0,30}\|\s*(?:base64|xxd|openssl)/gi,
837
+ message: () => "Pipes environment/.env contents into an encoder."
838
+ }
839
+ ];
840
+ var SECRET_ACCESS_PATTERNS = [
841
+ {
842
+ ruleId: "sec/secret-access",
843
+ re: /(?:~\/?\.ssh\/|\.ssh\/id_(?:rsa|ed25519|ecdsa)|authorized_keys|known_hosts)/g,
844
+ message: () => "References SSH key material (~/.ssh, id_rsa, \u2026)."
845
+ },
846
+ {
847
+ ruleId: "sec/secret-access",
848
+ re: /(?:\.aws\/credentials|\.config\/gcloud|\.kube\/config|\.docker\/config\.json|\.netrc|\.npmrc)\b/g,
849
+ message: () => "References a cloud/credential config file."
850
+ },
851
+ {
852
+ ruleId: "sec/secret-access",
853
+ re: /\b(?:cat|read|open|less|head|tail)\b[^\n]{0,20}\b[^\n]{0,40}\.env(?:\.[a-z]+)?\b/gi,
854
+ message: () => "Reads a .env file (may contain secrets)."
855
+ },
856
+ {
857
+ ruleId: "sec/secret-access",
858
+ re: /\b(?:AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|GITHUB_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|GH_TOKEN|NPM_TOKEN|STRIPE_[A-Z_]*KEY|DATABASE_URL)\b/g,
859
+ message: (m) => `References a known secret environment variable (${m[0]}).`
860
+ }
861
+ ];
862
+ var DESTRUCTIVE_PATTERNS = [
863
+ {
864
+ ruleId: "sec/destructive-command",
865
+ re: /\brm\s+-[a-z]*r[a-z]*f?\s+(?:\/|~|\$HOME|\*)/gi,
866
+ message: () => "Contains a recursive force-delete (rm -rf) of a broad path."
867
+ },
868
+ {
869
+ ruleId: "sec/destructive-command",
870
+ re: /\b(?:curl|wget)\b[^\n]{0,80}\|\s*(?:sudo\s+)?(?:bash|sh|zsh|python3?|node)\b/gi,
871
+ message: () => "Pipes a downloaded script straight into a shell (curl | sh)."
872
+ },
873
+ {
874
+ ruleId: "sec/destructive-command",
875
+ re: /\bgit\s+push[^\n]{0,40}(?:--force\b|-f\b|\+[A-Za-z])/gi,
876
+ message: () => "Contains a force-push (git push --force)."
877
+ },
878
+ {
879
+ ruleId: "sec/destructive-command",
880
+ re: /\b(?:chmod|chown)\s+-R[^\n]{0,20}(?:777|a\+rwx)/gi,
881
+ message: () => "Recursively grants world-writable permissions."
882
+ }
883
+ ];
884
+ var OUTBOUND_RE = /\b(?:curl|wget|fetch|axios|http(?:s)?\.request|requests\.(?:post|get)|invoke-webrequest|Invoke-RestMethod)\b[^\n]{0,200}https?:\/\/[^\s"'`]+/gi;
885
+ var SECRET_NEAR_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|credential|\$[A-Z_]{3,})\b/i;
886
+ var SINGLE_PATTERN_GROUPS = [
887
+ INJECTION_PATTERNS,
888
+ SAFETY_PATTERNS,
889
+ ENCODE_PATTERNS,
890
+ SECRET_ACCESS_PATTERNS,
891
+ DESTRUCTIVE_PATTERNS
892
+ ];
893
+ var HIDDEN_UNICODE_RE = new RegExp(
894
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060\\u2066-\\u2069\\uFEFF]",
895
+ "g"
896
+ );
897
+ function finding2(ruleId, message, line, column, evidence) {
898
+ const rule = getRule(ruleId);
899
+ const f = {
900
+ ruleId: rule.ruleId,
901
+ title: rule.title,
902
+ category: rule.category,
903
+ severity: rule.defaultSeverity,
904
+ message,
905
+ line: Math.max(1, line),
906
+ column: Math.max(1, column),
907
+ fixable: rule.fixable
908
+ };
909
+ if (evidence !== void 0) f.evidence = evidence;
910
+ return f;
911
+ }
912
+ function scanFile(file) {
913
+ const findings = [];
914
+ const segments = [];
915
+ if (file.body.length > 0) {
916
+ segments.push({ text: file.body, baseLine: file.bodyStartLine });
917
+ }
918
+ const desc = file.frontmatter.data?.["description"];
919
+ if (typeof desc === "string" && desc.length > 0) {
920
+ segments.push({ text: desc, baseLine: file.frontmatter.startLine || 1 });
921
+ }
922
+ if (file.kind === "agents-md" || file.kind === "unknown") {
923
+ segments.length = 0;
924
+ segments.push({ text: file.raw, baseLine: 1 });
925
+ }
926
+ for (const seg of segments) {
927
+ runSinglePatterns(seg.text, seg.baseLine, findings);
928
+ runExfilCheck(seg.text, seg.baseLine, findings);
929
+ runHiddenUnicode(seg.text, seg.baseLine, findings);
930
+ }
931
+ runSuspiciousToolCombo(file, findings);
932
+ return findings;
933
+ }
934
+ function locInSegment(text, index, baseLine) {
935
+ const { line, column } = offsetToLineCol(text, index);
936
+ return { line: baseLine + (line - 1), column };
937
+ }
938
+ function runSinglePatterns(text, baseLine, findings) {
939
+ for (const group of SINGLE_PATTERN_GROUPS) {
940
+ for (const pat of group) {
941
+ pat.re.lastIndex = 0;
942
+ let m;
943
+ let guard = 0;
944
+ while ((m = pat.re.exec(text)) !== null) {
945
+ const { line, column } = locInSegment(text, m.index, baseLine);
946
+ findings.push(
947
+ finding2(pat.ruleId, pat.message(m), line, column, makeEvidence(m[0]))
948
+ );
949
+ if (m.index === pat.re.lastIndex) pat.re.lastIndex++;
950
+ if (++guard > 1e3) break;
951
+ }
952
+ }
953
+ }
954
+ }
955
+ function runExfilCheck(text, baseLine, findings) {
956
+ OUTBOUND_RE.lastIndex = 0;
957
+ let m;
958
+ let guard = 0;
959
+ while ((m = OUTBOUND_RE.exec(text)) !== null) {
960
+ const start = Math.max(0, m.index - 160);
961
+ const end = Math.min(text.length, m.index + m[0].length + 160);
962
+ const window = text.slice(start, end);
963
+ if (SECRET_NEAR_RE.test(window)) {
964
+ const { line, column } = locInSegment(text, m.index, baseLine);
965
+ findings.push(
966
+ finding2(
967
+ "sec/data-exfiltration",
968
+ "Outbound network call near secret/env values \u2014 possible data exfiltration.",
969
+ line,
970
+ column,
971
+ makeEvidence(m[0])
972
+ )
973
+ );
974
+ }
975
+ if (m.index === OUTBOUND_RE.lastIndex) OUTBOUND_RE.lastIndex++;
976
+ if (++guard > 1e3) break;
977
+ }
978
+ }
979
+ function runHiddenUnicode(text, baseLine, findings) {
980
+ HIDDEN_UNICODE_RE.lastIndex = 0;
981
+ const m = HIDDEN_UNICODE_RE.exec(text);
982
+ if (m) {
983
+ const { line, column } = locInSegment(text, m.index, baseLine);
984
+ findings.push(
985
+ finding2(
986
+ "sec/hidden-unicode",
987
+ "Contains zero-width or bidirectional Unicode control characters that can hide text from a human reviewer.",
988
+ line,
989
+ column
990
+ )
991
+ );
992
+ }
993
+ }
994
+ function runSuspiciousToolCombo(file, findings) {
995
+ const data = file.frontmatter.data;
996
+ if (!data) return;
997
+ const toolValue = data["allowed-tools"] ?? data["tools"];
998
+ const tokens = parseToolList(toolValue).map((t) => normalizeToolName(t));
999
+ if (tokens.length === 0) return;
1000
+ const hasExec = tokens.some((t) => EXEC_TOOLS.has(t));
1001
+ const hasNet = tokens.some((t) => NETWORK_TOOLS.has(t));
1002
+ const desc = typeof data["description"] === "string" ? data["description"] : "";
1003
+ const readonly = READONLY_HINT_RE.test(desc);
1004
+ if (readonly && hasExec && hasNet) {
1005
+ findings.push(
1006
+ finding2(
1007
+ "sec/suspicious-tool-combo",
1008
+ "A read-only/docs skill grants both shell execution and network access \u2014 this combination enables data exfiltration. Remove one or revise the description.",
1009
+ file.frontmatter.startLine || 1,
1010
+ 1,
1011
+ makeEvidence(tokens.join(", "))
1012
+ )
1013
+ );
1014
+ }
1015
+ }
1016
+
1017
+ // src/grade.ts
1018
+ var PENALTY = {
1019
+ lint: { error: 12, warning: 5, info: 1 },
1020
+ // Security errors are deliberately severe: a single hard hit should never
1021
+ // leave a file with a passing grade.
1022
+ security: { error: 60, warning: 20, info: 4 }
1023
+ };
1024
+ function scoreFindings(findings) {
1025
+ let penalty = 0;
1026
+ for (const f of findings) {
1027
+ penalty += PENALTY[f.category][f.severity];
1028
+ }
1029
+ return Math.max(0, Math.min(100, 100 - penalty));
1030
+ }
1031
+ function scoreToGrade(score) {
1032
+ if (score >= 90) return "A";
1033
+ if (score >= 80) return "B";
1034
+ if (score >= 70) return "C";
1035
+ if (score >= 60) return "D";
1036
+ return "F";
1037
+ }
1038
+ function tally(findings) {
1039
+ const totals = { error: 0, warning: 0, info: 0 };
1040
+ for (const f of findings) totals[f.severity]++;
1041
+ return totals;
1042
+ }
1043
+ function aggregateScore(fileScores) {
1044
+ if (fileScores.length === 0) return 100;
1045
+ const mean = fileScores.reduce((a, b) => a + b, 0) / fileScores.length;
1046
+ const worst = Math.min(...fileScores);
1047
+ return Math.round(mean * 0.6 + worst * 0.4);
1048
+ }
1049
+
1050
+ // src/analyze.ts
1051
+ function sortFindings(findings) {
1052
+ return [...findings].sort(
1053
+ (a, b) => a.line - b.line || a.column - b.column || a.ruleId.localeCompare(b.ruleId) || a.message.localeCompare(b.message)
1054
+ );
1055
+ }
1056
+ function analyzeParsed(file) {
1057
+ const findings = [];
1058
+ findings.push(...lintFile(file));
1059
+ findings.push(...scanFile(file));
1060
+ return findings;
1061
+ }
1062
+ function toFileReport(file, findings) {
1063
+ const sorted = sortFindings(findings);
1064
+ const score = scoreFindings(sorted);
1065
+ return {
1066
+ filePath: file.filePath,
1067
+ kind: file.kind,
1068
+ findings: sorted,
1069
+ score,
1070
+ grade: scoreToGrade(score)
1071
+ };
1072
+ }
1073
+ function applyDuplicateNameRule(files, perFileFindings) {
1074
+ const rule = getRule("skill/duplicate-name");
1075
+ const byName = /* @__PURE__ */ new Map();
1076
+ files.forEach((f, i) => {
1077
+ const name = f.frontmatter.data?.["name"];
1078
+ if (typeof name === "string" && name.trim().length > 0) {
1079
+ const key = name.trim();
1080
+ const arr = byName.get(key) ?? [];
1081
+ arr.push(i);
1082
+ byName.set(key, arr);
1083
+ }
1084
+ });
1085
+ for (const [name, indices] of byName) {
1086
+ if (indices.length < 2) continue;
1087
+ for (const i of indices) {
1088
+ const others = indices.filter((j) => j !== i).map((j) => files[j]?.filePath ?? "?");
1089
+ perFileFindings[i]?.push({
1090
+ ruleId: rule.ruleId,
1091
+ title: rule.title,
1092
+ category: rule.category,
1093
+ severity: rule.defaultSeverity,
1094
+ message: `Duplicate skill name "${name}" \u2014 also declared in: ${others.join(
1095
+ ", "
1096
+ )}. Clients keep only one.`,
1097
+ line: files[i]?.frontmatter.startLine || 1,
1098
+ column: 1,
1099
+ fixable: rule.fixable
1100
+ });
1101
+ }
1102
+ }
1103
+ }
1104
+ function analyzeContent(filePath, content, options = {}) {
1105
+ const parsed = parseFile(filePath, content, options.forceKind);
1106
+ const findings = analyzeParsed(parsed);
1107
+ return toFileReport(parsed, findings);
1108
+ }
1109
+ function analyzeFiles(inputs, options = {}) {
1110
+ const parsed = inputs.map(
1111
+ (i) => parseFile(i.filePath, i.content, options.forceKind)
1112
+ );
1113
+ const perFileFindings = parsed.map((p) => analyzeParsed(p));
1114
+ applyDuplicateNameRule(parsed, perFileFindings);
1115
+ const reports = parsed.map(
1116
+ (p, i) => toFileReport(p, perFileFindings[i] ?? [])
1117
+ );
1118
+ const all = reports.flatMap((r) => r.findings);
1119
+ const aggregate = aggregateScore(reports.map((r) => r.score));
1120
+ return {
1121
+ files: reports,
1122
+ score: aggregate,
1123
+ grade: scoreToGrade(aggregate),
1124
+ totals: tally(all)
1125
+ };
1126
+ }
1127
+ function analyzePaths(paths, options = {}) {
1128
+ const inputs = paths.map((p) => ({
1129
+ filePath: p,
1130
+ content: readFileSync(p, "utf-8")
1131
+ }));
1132
+ return analyzeFiles(inputs, options);
1133
+ }
1134
+ function parseForFix(filePath, content) {
1135
+ return parseFile(filePath, content);
1136
+ }
1137
+
1138
+ // src/fix.ts
1139
+ var DESCRIPTION_STUB = "TODO describe what this skill does and when to use it.";
1140
+ function fixFile(file) {
1141
+ const applied = [];
1142
+ if (file.kind === "agents-md" || file.kind === "unknown") {
1143
+ return { output: file.raw, changed: false, applied };
1144
+ }
1145
+ if (!file.frontmatter.present) {
1146
+ return { output: file.raw, changed: false, applied };
1147
+ }
1148
+ if (file.frontmatter.error) {
1149
+ return { output: file.raw, changed: false, applied };
1150
+ }
1151
+ const fmLines = file.frontmatter.raw.split("\n");
1152
+ let trimmedAny = false;
1153
+ for (let i = 0; i < fmLines.length; i++) {
1154
+ const line = fmLines[i] ?? "";
1155
+ const trimmed = line.replace(/[ \t]+$/, "");
1156
+ if (trimmed !== line) {
1157
+ fmLines[i] = trimmed;
1158
+ trimmedAny = true;
1159
+ }
1160
+ }
1161
+ if (trimmedAny) applied.push("trailing-whitespace");
1162
+ for (const key of ["allowed-tools", "tools"]) {
1163
+ const idx = fmLines.findIndex(
1164
+ (l) => new RegExp(`^${key}\\s*:`).test(l ?? "")
1165
+ );
1166
+ if (idx === -1) continue;
1167
+ const line = fmLines[idx] ?? "";
1168
+ const m = new RegExp(`^(${key}\\s*:\\s*)(.*)$`).exec(line);
1169
+ if (!m) continue;
1170
+ const prefix = m[1];
1171
+ const rest = (m[2] ?? "").trim();
1172
+ if (rest.length === 0 || rest.startsWith("[")) continue;
1173
+ const tokens = parseToolList(rest);
1174
+ if (tokens.length === 0) continue;
1175
+ const seen = /* @__PURE__ */ new Set();
1176
+ const deduped = [];
1177
+ for (const t of tokens) {
1178
+ const key2 = t.trim();
1179
+ if (!seen.has(key2)) {
1180
+ deduped.push(t);
1181
+ seen.add(key2);
1182
+ }
1183
+ }
1184
+ if (deduped.length !== tokens.length) {
1185
+ const sep2 = rest.includes(",") ? ", " : " ";
1186
+ fmLines[idx] = prefix + deduped.join(sep2);
1187
+ applied.push(`dedupe-${key}`);
1188
+ }
1189
+ }
1190
+ const hasDescriptionKey = fmLines.some((l) => /^description\s*:/.test(l ?? ""));
1191
+ const data = file.frontmatter.data;
1192
+ const descMissing = !data || data["description"] === void 0 || data["description"] === null;
1193
+ if (!hasDescriptionKey && descMissing) {
1194
+ const nameIdx = fmLines.findIndex((l) => /^name\s*:/.test(l ?? ""));
1195
+ let insertAt = 0;
1196
+ if (nameIdx !== -1) {
1197
+ const nameHasInlineValue = /^name\s*:\s*\S/.test(fmLines[nameIdx] ?? "");
1198
+ const nextIsContinuation = /^\s/.test(fmLines[nameIdx + 1] ?? "");
1199
+ insertAt = nameHasInlineValue && !nextIsContinuation ? nameIdx + 1 : 0;
1200
+ }
1201
+ fmLines.splice(insertAt, 0, `description: "${DESCRIPTION_STUB}"`);
1202
+ applied.push("add-description-stub");
1203
+ }
1204
+ if (applied.length === 0) {
1205
+ return { output: file.raw, changed: false, applied };
1206
+ }
1207
+ const newFrontmatter = fmLines.join("\n");
1208
+ const eol = file.raw.includes("\r\n") ? "\r\n" : "\n";
1209
+ const rebuilt = "---" + eol + newFrontmatter.split("\n").join(eol) + eol + "---" + eol + file.body.split("\n").join(eol);
1210
+ return { output: rebuilt, changed: true, applied };
1211
+ }
1212
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
1213
+ "node_modules",
1214
+ ".git",
1215
+ "dist",
1216
+ "build",
1217
+ "coverage",
1218
+ ".next",
1219
+ ".turbo",
1220
+ ".cache"
1221
+ ]);
1222
+ function isCandidateMarkdown(name) {
1223
+ const lower = name.toLowerCase();
1224
+ if (!lower.endsWith(".md")) return false;
1225
+ return true;
1226
+ }
1227
+ function isGlob(p) {
1228
+ return /[*?[\]{}]/.test(p);
1229
+ }
1230
+ function globToRegExp(glob) {
1231
+ const g = glob.replace(/\\/g, "/");
1232
+ let re = "";
1233
+ for (let i = 0; i < g.length; i++) {
1234
+ const c = g[i];
1235
+ if (c === "*") {
1236
+ if (g[i + 1] === "*") {
1237
+ i++;
1238
+ if (g[i + 1] === "/") i++;
1239
+ while (g[i + 1] === "*" && g[i + 2] === "*") {
1240
+ i += 2;
1241
+ if (g[i + 1] === "/") i++;
1242
+ }
1243
+ re += "(?:.*/)?";
1244
+ } else {
1245
+ re += "[^/]*";
1246
+ }
1247
+ } else if (c === "?") {
1248
+ re += "[^/]";
1249
+ } else if ("\\^$.|+()[]{}".includes(c)) {
1250
+ re += "\\" + c;
1251
+ } else {
1252
+ re += c;
1253
+ }
1254
+ }
1255
+ return new RegExp("^" + re + "$");
1256
+ }
1257
+ function walk(dir, out) {
1258
+ let entries;
1259
+ try {
1260
+ entries = readdirSync(dir);
1261
+ } catch {
1262
+ return;
1263
+ }
1264
+ for (const entry of entries) {
1265
+ const full = join(dir, entry);
1266
+ let st;
1267
+ try {
1268
+ st = statSync(full);
1269
+ } catch {
1270
+ continue;
1271
+ }
1272
+ if (st.isDirectory()) {
1273
+ if (IGNORED_DIRS.has(entry)) continue;
1274
+ walk(full, out);
1275
+ } else if (st.isFile() && isCandidateMarkdown(entry)) {
1276
+ out.push(full);
1277
+ }
1278
+ }
1279
+ }
1280
+ function globBaseDir(glob) {
1281
+ const norm = glob.replace(/\\/g, "/");
1282
+ const firstMeta = norm.search(/[*?[\]{}]/);
1283
+ const head = firstMeta === -1 ? norm : norm.slice(0, firstMeta);
1284
+ const lastSlash = head.lastIndexOf("/");
1285
+ const base = lastSlash === -1 ? "." : head.slice(0, lastSlash) || "/";
1286
+ return base;
1287
+ }
1288
+ function toPosix(p) {
1289
+ return p.split(sep).join(posix.sep);
1290
+ }
1291
+ function discoverFiles(specs) {
1292
+ const found = /* @__PURE__ */ new Set();
1293
+ for (const spec of specs) {
1294
+ if (isGlob(spec)) {
1295
+ const base = resolve(globBaseDir(spec));
1296
+ const collected = [];
1297
+ walk(base, collected);
1298
+ const re = globToRegExp(toPosix(resolve(spec)));
1299
+ for (const f of collected) {
1300
+ if (re.test(toPosix(f))) found.add(f);
1301
+ }
1302
+ continue;
1303
+ }
1304
+ const abs = resolve(spec);
1305
+ if (!existsSync(abs)) {
1306
+ continue;
1307
+ }
1308
+ const st = statSync(abs);
1309
+ if (st.isDirectory()) {
1310
+ const collected = [];
1311
+ walk(abs, collected);
1312
+ for (const f of collected) found.add(f);
1313
+ } else if (st.isFile()) {
1314
+ found.add(abs);
1315
+ }
1316
+ }
1317
+ return [...found].sort();
1318
+ }
1319
+
1320
+ // src/output/terminal.ts
1321
+ var ANSI = {
1322
+ reset: "\x1B[0m",
1323
+ bold: "\x1B[1m",
1324
+ dim: "\x1B[2m",
1325
+ red: "\x1B[31m",
1326
+ green: "\x1B[32m",
1327
+ yellow: "\x1B[33m",
1328
+ blue: "\x1B[34m",
1329
+ cyan: "\x1B[36m",
1330
+ gray: "\x1B[90m"
1331
+ };
1332
+ function colorEnabled(opt) {
1333
+ if (opt !== void 0) return opt;
1334
+ if (process.env["NO_COLOR"] !== void 0) return false;
1335
+ if (process.env["FORCE_COLOR"] !== void 0) return true;
1336
+ return Boolean(process.stdout.isTTY);
1337
+ }
1338
+ function paint(s, code, on) {
1339
+ return on ? `${code}${s}${ANSI.reset}` : s;
1340
+ }
1341
+ var SEVERITY_ICON = {
1342
+ error: "\u2716",
1343
+ warning: "\u26A0",
1344
+ info: "\u2139"
1345
+ };
1346
+ var SEVERITY_COLOR = {
1347
+ error: ANSI.red,
1348
+ warning: ANSI.yellow,
1349
+ info: ANSI.blue
1350
+ };
1351
+ var GRADE_COLOR = {
1352
+ A: ANSI.green,
1353
+ B: ANSI.green,
1354
+ C: ANSI.yellow,
1355
+ D: ANSI.yellow,
1356
+ F: ANSI.red
1357
+ };
1358
+ function severityLabel(sev, on) {
1359
+ return paint(`${SEVERITY_ICON[sev]} ${sev}`, SEVERITY_COLOR[sev], on);
1360
+ }
1361
+ function fileHeader(file, on) {
1362
+ const gradeBadge = paint(
1363
+ ` ${file.grade} `,
1364
+ `${ANSI.bold}${GRADE_COLOR[file.grade]}`,
1365
+ on
1366
+ );
1367
+ const kind = paint(`[${file.kind}]`, ANSI.gray, on);
1368
+ return `${paint(file.filePath, ANSI.bold, on)} ${kind} ${gradeBadge}${paint(
1369
+ `(${file.score}/100)`,
1370
+ ANSI.dim,
1371
+ on
1372
+ )}`;
1373
+ }
1374
+ function findingLine(f, on) {
1375
+ const loc = paint(`${f.line}:${f.column}`, ANSI.gray, on);
1376
+ const sev = severityLabel(f.severity, on);
1377
+ const rule = paint(f.ruleId, ANSI.cyan, on);
1378
+ let out = ` ${loc} ${sev} ${f.message} ${rule}`;
1379
+ if (f.fixable) out += paint(" (fixable)", ANSI.dim, on);
1380
+ if (f.evidence) {
1381
+ out += `
1382
+ ${paint("\u21B3 " + f.evidence, ANSI.gray, on)}`;
1383
+ }
1384
+ return out;
1385
+ }
1386
+ function renderTerminal(report, options = {}) {
1387
+ const on = colorEnabled(options.color);
1388
+ const lines = [];
1389
+ if (report.files.length === 0) {
1390
+ return paint("No skill / instruction files found.", ANSI.yellow, on);
1391
+ }
1392
+ for (const file of report.files) {
1393
+ lines.push(fileHeader(file, on));
1394
+ if (file.findings.length === 0) {
1395
+ lines.push(paint(" \u2713 no findings", ANSI.green, on));
1396
+ } else {
1397
+ for (const f of file.findings) {
1398
+ lines.push(findingLine(f, on));
1399
+ }
1400
+ }
1401
+ lines.push("");
1402
+ }
1403
+ const { error, warning, info } = report.totals;
1404
+ const summaryBadge = paint(
1405
+ ` Grade ${report.grade} `,
1406
+ `${ANSI.bold}${GRADE_COLOR[report.grade]}`,
1407
+ on
1408
+ );
1409
+ lines.push(
1410
+ paint("\u2500".repeat(48), ANSI.gray, on)
1411
+ );
1412
+ lines.push(
1413
+ `${summaryBadge} ${paint(`${report.score}/100`, ANSI.bold, on)} across ${report.files.length} file(s)`
1414
+ );
1415
+ lines.push(
1416
+ ` ${paint(`${error} error(s)`, error ? ANSI.red : ANSI.gray, on)} ${paint(`${warning} warning(s)`, warning ? ANSI.yellow : ANSI.gray, on)} ${paint(`${info} info`, info ? ANSI.blue : ANSI.gray, on)}`
1417
+ );
1418
+ return lines.join("\n");
1419
+ }
1420
+
1421
+ // src/output/json.ts
1422
+ var JSON_REPORT_VERSION = 1;
1423
+ function toJsonReport(report, toolVersion) {
1424
+ return {
1425
+ schemaVersion: JSON_REPORT_VERSION,
1426
+ tool: { name: "skilldoctor", version: toolVersion },
1427
+ summary: {
1428
+ grade: report.grade,
1429
+ score: report.score,
1430
+ fileCount: report.files.length,
1431
+ errors: report.totals.error,
1432
+ warnings: report.totals.warning,
1433
+ infos: report.totals.info
1434
+ },
1435
+ files: report.files
1436
+ };
1437
+ }
1438
+ function jsonString(report, toolVersion) {
1439
+ return JSON.stringify(toJsonReport(report, toolVersion), null, 2);
1440
+ }
1441
+ var SARIF_VERSION = "2.1.0";
1442
+ var SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
1443
+ var TOOL_NAME = "skilldoctor";
1444
+ var TOOL_INFO_URI = "https://github.com/studiomeyer-io/skilldoctor";
1445
+ function toSarifLevel(sev) {
1446
+ switch (sev) {
1447
+ case "error":
1448
+ return "error";
1449
+ case "warning":
1450
+ return "warning";
1451
+ case "info":
1452
+ return "note";
1453
+ }
1454
+ }
1455
+ function toUri(filePath, baseDir) {
1456
+ const rel = relative(baseDir, filePath);
1457
+ const norm = (rel === "" ? filePath : rel).split("\\").join("/");
1458
+ return norm.replace(/^\.\//, "");
1459
+ }
1460
+ function fingerprint(uri, f) {
1461
+ const norm = (f.evidence ?? f.message).replace(/\s+/g, " ").trim().toLowerCase();
1462
+ return createHash("sha256").update(f.ruleId).update("\0").update(uri).update("\0").update(norm).digest("hex").slice(0, 16);
1463
+ }
1464
+ function toSarif(report, options = {}) {
1465
+ const baseDir = options.baseDir ?? process.cwd();
1466
+ const toolVersion = options.version ?? "0.0.0";
1467
+ const ruleIndex = /* @__PURE__ */ new Map();
1468
+ const rules = RULES.map((r, i) => {
1469
+ ruleIndex.set(r.ruleId, i);
1470
+ return {
1471
+ id: r.ruleId,
1472
+ name: toPascalName(r.ruleId),
1473
+ shortDescription: { text: r.title },
1474
+ fullDescription: { text: r.description },
1475
+ defaultConfiguration: { level: toSarifLevel(r.defaultSeverity) },
1476
+ properties: {
1477
+ category: r.category,
1478
+ fixable: r.fixable
1479
+ }
1480
+ };
1481
+ });
1482
+ const results = [];
1483
+ for (const file of report.files) {
1484
+ const uri = toUri(file.filePath, baseDir);
1485
+ for (const f of file.findings) {
1486
+ const idx = ruleIndex.get(f.ruleId);
1487
+ if (idx === void 0) continue;
1488
+ results.push({
1489
+ ruleId: f.ruleId,
1490
+ ruleIndex: idx,
1491
+ level: toSarifLevel(f.severity),
1492
+ message: { text: f.message },
1493
+ locations: [
1494
+ {
1495
+ physicalLocation: {
1496
+ artifactLocation: { uri },
1497
+ region: {
1498
+ startLine: f.line,
1499
+ startColumn: f.column,
1500
+ ...f.evidence ? { snippet: { text: f.evidence } } : {}
1501
+ }
1502
+ }
1503
+ }
1504
+ ],
1505
+ partialFingerprints: {
1506
+ skilldoctor: fingerprint(uri, f)
1507
+ },
1508
+ properties: {
1509
+ category: f.category,
1510
+ fixable: f.fixable
1511
+ }
1512
+ });
1513
+ }
1514
+ }
1515
+ return {
1516
+ $schema: SARIF_SCHEMA,
1517
+ version: SARIF_VERSION,
1518
+ runs: [
1519
+ {
1520
+ tool: {
1521
+ driver: {
1522
+ name: TOOL_NAME,
1523
+ informationUri: TOOL_INFO_URI,
1524
+ version: toolVersion,
1525
+ rules
1526
+ }
1527
+ },
1528
+ results
1529
+ }
1530
+ ]
1531
+ };
1532
+ }
1533
+ function sarifString(report, options) {
1534
+ return JSON.stringify(toSarif(report, options), null, 2);
1535
+ }
1536
+ function toPascalName(ruleId) {
1537
+ return ruleId.split(/[/\-_]/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
1538
+ }
1539
+
1540
+ export { DESCRIPTION_STUB, JSON_REPORT_VERSION, RULES, SARIF_SCHEMA, SARIF_VERSION, SEVERITY_RANK, aggregateScore, allRuleIds, analyzeContent, analyzeFiles, analyzePaths, detectKind, discoverFiles, extractFrontmatter, fixFile, getRule, globToRegExp, isGlob, jsonString, parseFile, parseForFix, renderTerminal, sarifString, scoreFindings, scoreToGrade, tally, toJsonReport, toSarif };