skill-tools 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/LICENSE +190 -0
- package/README.md +62 -0
- package/dist/cli.js +979 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +716 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +111 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.js +709 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolveSkillFiles, parseSkill } from '@skill-tools/core';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { readdirSync, existsSync, statSync } from 'fs';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
|
|
7
|
+
// src/formatters/json.ts
|
|
8
|
+
function formatValidationJson(results) {
|
|
9
|
+
return JSON.stringify(
|
|
10
|
+
results.map((r) => ({
|
|
11
|
+
filePath: r.filePath,
|
|
12
|
+
name: r.name,
|
|
13
|
+
valid: r.valid,
|
|
14
|
+
diagnostics: r.diagnostics
|
|
15
|
+
})),
|
|
16
|
+
null,
|
|
17
|
+
2
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
function formatLintJson(results) {
|
|
21
|
+
return JSON.stringify(
|
|
22
|
+
results.map((r) => ({
|
|
23
|
+
filePath: r.filePath,
|
|
24
|
+
name: r.name,
|
|
25
|
+
errorCount: r.errorCount,
|
|
26
|
+
warningCount: r.warningCount,
|
|
27
|
+
infoCount: r.infoCount,
|
|
28
|
+
diagnostics: r.diagnostics
|
|
29
|
+
})),
|
|
30
|
+
null,
|
|
31
|
+
2
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
function formatScoreJson(name, qualityScore) {
|
|
35
|
+
return JSON.stringify(
|
|
36
|
+
{
|
|
37
|
+
name,
|
|
38
|
+
score: qualityScore.score,
|
|
39
|
+
dimensions: qualityScore.dimensions,
|
|
40
|
+
suggestions: qualityScore.suggestions
|
|
41
|
+
},
|
|
42
|
+
null,
|
|
43
|
+
2
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/formatters/text.ts
|
|
48
|
+
var RESET = "\x1B[0m";
|
|
49
|
+
var RED = "\x1B[31m";
|
|
50
|
+
var YELLOW = "\x1B[33m";
|
|
51
|
+
var GREEN = "\x1B[32m";
|
|
52
|
+
var CYAN = "\x1B[36m";
|
|
53
|
+
var DIM = "\x1B[2m";
|
|
54
|
+
var BOLD = "\x1B[1m";
|
|
55
|
+
function severityIcon(severity) {
|
|
56
|
+
switch (severity) {
|
|
57
|
+
case "error":
|
|
58
|
+
return `${RED}\u2717${RESET}`;
|
|
59
|
+
case "warning":
|
|
60
|
+
return `${YELLOW}\u26A0${RESET}`;
|
|
61
|
+
case "info":
|
|
62
|
+
return `${CYAN}\u2139${RESET}`;
|
|
63
|
+
default:
|
|
64
|
+
return " ";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function formatDiagnostic(diag) {
|
|
68
|
+
const icon = severityIcon(diag.severity);
|
|
69
|
+
const location = diag.line ? `${DIM}line ${diag.line}${RESET} ` : "";
|
|
70
|
+
const ruleId = `${DIM}(${diag.ruleId})${RESET}`;
|
|
71
|
+
let line = ` ${icon} ${location}${diag.message} ${ruleId}`;
|
|
72
|
+
if (diag.fix) {
|
|
73
|
+
line += `
|
|
74
|
+
${DIM}\u2192 ${diag.fix}${RESET}`;
|
|
75
|
+
}
|
|
76
|
+
return line;
|
|
77
|
+
}
|
|
78
|
+
function formatValidation(results) {
|
|
79
|
+
const lines = [];
|
|
80
|
+
for (const result of results) {
|
|
81
|
+
const status = result.valid ? `${GREEN}\u2713 PASS${RESET}` : `${RED}\u2717 FAIL${RESET}`;
|
|
82
|
+
lines.push(`${BOLD}${result.name}${RESET} ${status}`);
|
|
83
|
+
lines.push(` ${DIM}${result.filePath}${RESET}`);
|
|
84
|
+
if (result.diagnostics.length > 0) {
|
|
85
|
+
lines.push("");
|
|
86
|
+
for (const diag of result.diagnostics) {
|
|
87
|
+
lines.push(formatDiagnostic(diag));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
lines.push("");
|
|
91
|
+
}
|
|
92
|
+
const total = results.length;
|
|
93
|
+
const passed = results.filter((r) => r.valid).length;
|
|
94
|
+
const failed = total - passed;
|
|
95
|
+
if (failed > 0) {
|
|
96
|
+
lines.push(`${RED}${failed} failed${RESET}, ${GREEN}${passed} passed${RESET} (${total} total)`);
|
|
97
|
+
} else {
|
|
98
|
+
lines.push(`${GREEN}All ${total} skill(s) passed validation${RESET}`);
|
|
99
|
+
}
|
|
100
|
+
return lines.join("\n");
|
|
101
|
+
}
|
|
102
|
+
function formatLint(results) {
|
|
103
|
+
const lines = [];
|
|
104
|
+
for (const result of results) {
|
|
105
|
+
lines.push(`${BOLD}${result.name}${RESET}`);
|
|
106
|
+
lines.push(` ${DIM}${result.filePath}${RESET}`);
|
|
107
|
+
if (result.diagnostics.length > 0) {
|
|
108
|
+
lines.push("");
|
|
109
|
+
for (const diag of result.diagnostics) {
|
|
110
|
+
lines.push(formatDiagnostic(diag));
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
lines.push(` ${GREEN}No lint issues${RESET}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push("");
|
|
116
|
+
}
|
|
117
|
+
const totalErrors = results.reduce((sum, r) => sum + r.errorCount, 0);
|
|
118
|
+
const totalWarnings = results.reduce((sum, r) => sum + r.warningCount, 0);
|
|
119
|
+
const totalInfo = results.reduce((sum, r) => sum + r.infoCount, 0);
|
|
120
|
+
const parts = [];
|
|
121
|
+
if (totalErrors > 0) parts.push(`${RED}${totalErrors} error(s)${RESET}`);
|
|
122
|
+
if (totalWarnings > 0) parts.push(`${YELLOW}${totalWarnings} warning(s)${RESET}`);
|
|
123
|
+
if (totalInfo > 0) parts.push(`${CYAN}${totalInfo} info${RESET}`);
|
|
124
|
+
if (parts.length > 0) {
|
|
125
|
+
lines.push(parts.join(", "));
|
|
126
|
+
} else {
|
|
127
|
+
lines.push(`${GREEN}No lint issues found${RESET}`);
|
|
128
|
+
}
|
|
129
|
+
return lines.join("\n");
|
|
130
|
+
}
|
|
131
|
+
function formatScore(_name, qualityScore) {
|
|
132
|
+
const lines = [];
|
|
133
|
+
const stars = scoreToStars(qualityScore.score);
|
|
134
|
+
lines.push("");
|
|
135
|
+
lines.push(` ${BOLD}Quality Score: ${qualityScore.score}/100${RESET} ${stars}`);
|
|
136
|
+
lines.push("");
|
|
137
|
+
for (const [_key, dim] of Object.entries(qualityScore.dimensions)) {
|
|
138
|
+
const barWidth = 10;
|
|
139
|
+
const filled = Math.round(dim.score / dim.max * barWidth);
|
|
140
|
+
const empty = barWidth - filled;
|
|
141
|
+
const bar = `${GREEN}${"\u2588".repeat(filled)}${DIM}${"\u2591".repeat(empty)}${RESET}`;
|
|
142
|
+
const scoreStr = `${dim.score}/${dim.max}`.padStart(6);
|
|
143
|
+
lines.push(` ${dim.label.padEnd(24)} ${bar} ${scoreStr}`);
|
|
144
|
+
}
|
|
145
|
+
if (qualityScore.suggestions.length > 0) {
|
|
146
|
+
lines.push("");
|
|
147
|
+
lines.push(` ${BOLD}Top suggestions:${RESET}`);
|
|
148
|
+
for (let i = 0; i < Math.min(5, qualityScore.suggestions.length); i++) {
|
|
149
|
+
const s = qualityScore.suggestions[i];
|
|
150
|
+
lines.push(` ${i + 1}. ${s.message} ${DIM}(+${s.pointsGain} points)${RESET}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
lines.push("");
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
function scoreToStars(score2) {
|
|
157
|
+
if (score2 >= 90) return "\u2605\u2605\u2605\u2605\u2605";
|
|
158
|
+
if (score2 >= 75) return "\u2605\u2605\u2605\u2605";
|
|
159
|
+
if (score2 >= 60) return "\u2605\u2605\u2605";
|
|
160
|
+
if (score2 >= 40) return "\u2605\u2605";
|
|
161
|
+
if (score2 >= 20) return "\u2605";
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/rules/consistent-headings.ts
|
|
166
|
+
var consistentHeadings = {
|
|
167
|
+
id: "consistent-headings",
|
|
168
|
+
description: "Headings should follow a consistent hierarchy without skipping levels",
|
|
169
|
+
defaultSeverity: "info",
|
|
170
|
+
check(skill) {
|
|
171
|
+
const diagnostics = [];
|
|
172
|
+
const sections = skill.sections;
|
|
173
|
+
for (let i = 1; i < sections.length; i++) {
|
|
174
|
+
const prev = sections[i - 1];
|
|
175
|
+
const curr = sections[i];
|
|
176
|
+
if (curr.depth > prev.depth + 1) {
|
|
177
|
+
diagnostics.push({
|
|
178
|
+
ruleId: "consistent-headings",
|
|
179
|
+
severity: "info",
|
|
180
|
+
message: `Heading "${curr.heading}" (H${curr.depth}) skips levels from "${prev.heading}" (H${prev.depth})`,
|
|
181
|
+
file: skill.filePath,
|
|
182
|
+
line: curr.line,
|
|
183
|
+
fix: `Change to H${prev.depth + 1} for consistent hierarchy`
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return diagnostics;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// src/rules/description-specificity.ts
|
|
192
|
+
var GENERIC_VERBS = [
|
|
193
|
+
"manage",
|
|
194
|
+
"handle",
|
|
195
|
+
"process",
|
|
196
|
+
"deal with",
|
|
197
|
+
"work with",
|
|
198
|
+
"do stuff",
|
|
199
|
+
"help with",
|
|
200
|
+
"assist with",
|
|
201
|
+
"take care of"
|
|
202
|
+
];
|
|
203
|
+
var descriptionSpecificity = {
|
|
204
|
+
id: "description-specificity",
|
|
205
|
+
description: 'Description should contain specific nouns or action verbs, not generic phrases like "manages" or "handles"',
|
|
206
|
+
defaultSeverity: "warning",
|
|
207
|
+
check(skill) {
|
|
208
|
+
const desc = skill.metadata.description;
|
|
209
|
+
if (!desc) return [];
|
|
210
|
+
const diagnostics = [];
|
|
211
|
+
const lowerDesc = desc.toLowerCase();
|
|
212
|
+
const foundGeneric = GENERIC_VERBS.filter((verb) => lowerDesc.includes(verb));
|
|
213
|
+
if (foundGeneric.length > 0) {
|
|
214
|
+
diagnostics.push({
|
|
215
|
+
ruleId: "description-specificity",
|
|
216
|
+
severity: "warning",
|
|
217
|
+
message: `Description uses generic verbs: ${foundGeneric.map((v) => `"${v}"`).join(", ")}. Use specific action verbs instead`,
|
|
218
|
+
file: skill.filePath,
|
|
219
|
+
line: 1,
|
|
220
|
+
fix: 'Replace generic verbs with specific ones. E.g., "Deploy apps to Vercel" instead of "Handle Vercel deployments"'
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return diagnostics;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// src/rules/description-trigger-keywords.ts
|
|
228
|
+
var descriptionTriggerKeywords = {
|
|
229
|
+
id: "description-trigger-keywords",
|
|
230
|
+
description: "Description should contain words that a user would naturally type to trigger this skill",
|
|
231
|
+
defaultSeverity: "warning",
|
|
232
|
+
check(skill) {
|
|
233
|
+
const desc = skill.metadata.description;
|
|
234
|
+
if (!desc) return [];
|
|
235
|
+
const diagnostics = [];
|
|
236
|
+
const hasTriggerPhrase = /\buse when\b|\buse for\b|\buse this\b|\binvoke when\b|\btrigger when\b/i.test(desc);
|
|
237
|
+
const hasActionVerb = /\b(deploy|test|build|run|create|generate|analyze|review|format|lint|fix|check|commit|push|pull|fetch|install|configure|setup|search|find|list|delete|update|migrate|convert|export|import)\b/i.test(
|
|
238
|
+
desc
|
|
239
|
+
);
|
|
240
|
+
if (!hasTriggerPhrase && !hasActionVerb) {
|
|
241
|
+
diagnostics.push({
|
|
242
|
+
ruleId: "description-trigger-keywords",
|
|
243
|
+
severity: "warning",
|
|
244
|
+
message: 'Description lacks trigger keywords. Include "Use when..." or specific action verbs (deploy, test, build, etc.)',
|
|
245
|
+
file: skill.filePath,
|
|
246
|
+
line: 1,
|
|
247
|
+
fix: 'Add trigger context: "Deploy apps to Vercel. Use when the user wants to publish or ship a web app."'
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return diagnostics;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// src/rules/instructions-has-error-handling.ts
|
|
255
|
+
var instructionsHasErrorHandling = {
|
|
256
|
+
id: "instructions-has-error-handling",
|
|
257
|
+
description: "Instructions should mention what to do when things fail",
|
|
258
|
+
defaultSeverity: "info",
|
|
259
|
+
check(skill) {
|
|
260
|
+
const diagnostics = [];
|
|
261
|
+
const body = skill.body.toLowerCase();
|
|
262
|
+
const hasErrorSection = /#{1,3}\s+(?:error|troubleshoot|fail|issue|problem|debug)/i.test(
|
|
263
|
+
skill.body
|
|
264
|
+
);
|
|
265
|
+
const hasErrorKeywords = /\b(?:error|fail|troubleshoot|if .+ fails|when .+ fails|common issues|known issues)\b/.test(
|
|
266
|
+
body
|
|
267
|
+
);
|
|
268
|
+
if (!hasErrorSection && !hasErrorKeywords) {
|
|
269
|
+
diagnostics.push({
|
|
270
|
+
ruleId: "instructions-has-error-handling",
|
|
271
|
+
severity: "info",
|
|
272
|
+
message: "Instructions have no error handling guidance. Add a section about common failures",
|
|
273
|
+
file: skill.filePath,
|
|
274
|
+
fix: 'Add an "## Error Handling" section describing common failures and how to resolve them'
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return diagnostics;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// src/rules/instructions-has-examples.ts
|
|
282
|
+
var instructionsHasExamples = {
|
|
283
|
+
id: "instructions-has-examples",
|
|
284
|
+
description: "Skill instructions should include at least one concrete example",
|
|
285
|
+
defaultSeverity: "info",
|
|
286
|
+
check(skill) {
|
|
287
|
+
const diagnostics = [];
|
|
288
|
+
const body = skill.body;
|
|
289
|
+
const hasCodeBlock = /```[\s\S]*?```/.test(body);
|
|
290
|
+
const hasExampleSection = /^#{1,3}\s+(?:example|usage|demo)/im.test(body);
|
|
291
|
+
const hasInlineCode = /`[^`]+`/.test(body);
|
|
292
|
+
const hasNumberedSteps = /^\d+\.\s+/m.test(body);
|
|
293
|
+
if (!hasCodeBlock && !hasExampleSection && !hasInlineCode && !hasNumberedSteps) {
|
|
294
|
+
diagnostics.push({
|
|
295
|
+
ruleId: "instructions-has-examples",
|
|
296
|
+
severity: "info",
|
|
297
|
+
message: "Instructions have no examples. Add code blocks, numbered steps, or an Examples section",
|
|
298
|
+
file: skill.filePath,
|
|
299
|
+
fix: "Add a code block showing expected usage:\n```bash\nskill-tools check ./my-skill/\n```"
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return diagnostics;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// src/rules/no-hardcoded-paths.ts
|
|
307
|
+
var HARDCODED_PATH_PATTERN = /(?:\/Users\/\w+|\/home\/\w+|[A-Z]:\\\\?Users\\\\?\w+|\/var\/|\/tmp\/\w+)/;
|
|
308
|
+
var noHardcodedPaths = {
|
|
309
|
+
id: "no-hardcoded-paths",
|
|
310
|
+
description: "Flag absolute filesystem paths that are machine-specific",
|
|
311
|
+
defaultSeverity: "error",
|
|
312
|
+
check(skill) {
|
|
313
|
+
const diagnostics = [];
|
|
314
|
+
const lines = skill.body.split("\n");
|
|
315
|
+
for (let i = 0; i < lines.length; i++) {
|
|
316
|
+
const line = lines[i];
|
|
317
|
+
if (HARDCODED_PATH_PATTERN.test(line)) {
|
|
318
|
+
const match = HARDCODED_PATH_PATTERN.exec(line);
|
|
319
|
+
diagnostics.push({
|
|
320
|
+
ruleId: "no-hardcoded-paths",
|
|
321
|
+
severity: "error",
|
|
322
|
+
message: `Hardcoded path found: "${match?.[0]}". Use environment variables or relative paths`,
|
|
323
|
+
file: skill.filePath,
|
|
324
|
+
line: i + 1,
|
|
325
|
+
fix: "Replace with $HOME, relative paths, or environment variables"
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return diagnostics;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// src/rules/no-secrets.ts
|
|
334
|
+
var SECRET_PATTERNS = [
|
|
335
|
+
{ pattern: /sk-[a-zA-Z0-9]{20,}/, label: "OpenAI API key" },
|
|
336
|
+
{ pattern: /sk_live_[a-zA-Z0-9]{20,}/, label: "Stripe live key" },
|
|
337
|
+
{ pattern: /sk_test_[a-zA-Z0-9]{20,}/, label: "Stripe test key" },
|
|
338
|
+
{ pattern: /ghp_[a-zA-Z0-9]{36,}/, label: "GitHub personal access token" },
|
|
339
|
+
{ pattern: /gho_[a-zA-Z0-9]{36,}/, label: "GitHub OAuth token" },
|
|
340
|
+
{ pattern: /github_pat_[a-zA-Z0-9_]{20,}/, label: "GitHub fine-grained token" },
|
|
341
|
+
{ pattern: /xoxb-[a-zA-Z0-9-]+/, label: "Slack bot token" },
|
|
342
|
+
{ pattern: /xoxp-[a-zA-Z0-9-]+/, label: "Slack user token" },
|
|
343
|
+
{ pattern: /AKIA[0-9A-Z]{16}/, label: "AWS access key ID" },
|
|
344
|
+
{ pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, label: "Private key" },
|
|
345
|
+
{ pattern: /eyJ[a-zA-Z0-9_-]{20,500}\.[a-zA-Z0-9_-]{20,500}\./, label: "JWT token" }
|
|
346
|
+
];
|
|
347
|
+
var noSecrets = {
|
|
348
|
+
id: "no-secrets",
|
|
349
|
+
description: "Flag patterns that look like API keys, tokens, or passwords",
|
|
350
|
+
defaultSeverity: "error",
|
|
351
|
+
check(skill) {
|
|
352
|
+
const diagnostics = [];
|
|
353
|
+
const lines = skill.rawContent.split("\n");
|
|
354
|
+
for (let i = 0; i < lines.length; i++) {
|
|
355
|
+
const line = lines[i];
|
|
356
|
+
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
357
|
+
if (pattern.test(line)) {
|
|
358
|
+
diagnostics.push({
|
|
359
|
+
ruleId: "no-secrets",
|
|
360
|
+
severity: "error",
|
|
361
|
+
message: `Possible ${label} detected. Never embed secrets in SKILL.md files`,
|
|
362
|
+
file: skill.filePath,
|
|
363
|
+
line: i + 1,
|
|
364
|
+
fix: "Use environment variable references (e.g., $API_KEY) instead of actual secrets"
|
|
365
|
+
});
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return diagnostics;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// src/rules/progressive-disclosure.ts
|
|
375
|
+
var DEFAULT_MAX_LINES = 500;
|
|
376
|
+
var progressiveDisclosure = {
|
|
377
|
+
id: "progressive-disclosure",
|
|
378
|
+
description: "Large SKILL.md files should use references/ or scripts/ to keep the main file lean",
|
|
379
|
+
defaultSeverity: "warning",
|
|
380
|
+
check(skill) {
|
|
381
|
+
const diagnostics = [];
|
|
382
|
+
if (skill.lineCount > DEFAULT_MAX_LINES) {
|
|
383
|
+
const hasReferences = skill.fileReferences.some((r) => r.path.startsWith("references/"));
|
|
384
|
+
const hasScripts = skill.fileReferences.some((r) => r.path.startsWith("scripts/"));
|
|
385
|
+
if (!hasReferences && !hasScripts) {
|
|
386
|
+
diagnostics.push({
|
|
387
|
+
ruleId: "progressive-disclosure",
|
|
388
|
+
severity: "warning",
|
|
389
|
+
message: `SKILL.md is ${skill.lineCount} lines (recommended: <${DEFAULT_MAX_LINES}). Move detailed content to references/ or scripts/`,
|
|
390
|
+
file: skill.filePath,
|
|
391
|
+
fix: "Create a references/ directory and move detailed API docs, examples, or reference tables there. Link from SKILL.md: [See API reference](references/api.md)"
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return diagnostics;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// src/rules/index.ts
|
|
400
|
+
var builtinRules = /* @__PURE__ */ new Map([
|
|
401
|
+
[descriptionSpecificity.id, descriptionSpecificity],
|
|
402
|
+
[descriptionTriggerKeywords.id, descriptionTriggerKeywords],
|
|
403
|
+
[progressiveDisclosure.id, progressiveDisclosure],
|
|
404
|
+
[noHardcodedPaths.id, noHardcodedPaths],
|
|
405
|
+
[noSecrets.id, noSecrets],
|
|
406
|
+
[instructionsHasExamples.id, instructionsHasExamples],
|
|
407
|
+
[instructionsHasErrorHandling.id, instructionsHasErrorHandling],
|
|
408
|
+
[consistentHeadings.id, consistentHeadings]
|
|
409
|
+
]);
|
|
410
|
+
Object.fromEntries(
|
|
411
|
+
Array.from(builtinRules.values()).map((rule) => [rule.id, rule.defaultSeverity])
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// src/linter.ts
|
|
415
|
+
function lint(skill, rulesConfig) {
|
|
416
|
+
const diagnostics = [];
|
|
417
|
+
for (const [ruleId, rule] of builtinRules) {
|
|
418
|
+
const ruleDiagnostics = rule.check(skill);
|
|
419
|
+
const severity = rule.defaultSeverity;
|
|
420
|
+
for (const diag of ruleDiagnostics) {
|
|
421
|
+
diagnostics.push({
|
|
422
|
+
...diag,
|
|
423
|
+
severity
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
filePath: skill.filePath,
|
|
429
|
+
name: skill.metadata.name ?? "unknown",
|
|
430
|
+
diagnostics,
|
|
431
|
+
errorCount: diagnostics.filter((d) => d.severity === "error").length,
|
|
432
|
+
warningCount: diagnostics.filter((d) => d.severity === "warning").length,
|
|
433
|
+
infoCount: diagnostics.filter((d) => d.severity === "info").length
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/scorer/description-quality.ts
|
|
438
|
+
var MAX_POINTS = 30;
|
|
439
|
+
function scoreDescriptionQuality(skill) {
|
|
440
|
+
const desc = skill.metadata.description;
|
|
441
|
+
if (!desc) {
|
|
442
|
+
return {
|
|
443
|
+
score: 0,
|
|
444
|
+
max: MAX_POINTS,
|
|
445
|
+
label: "Description Quality",
|
|
446
|
+
details: "No description provided"
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
let points = 0;
|
|
450
|
+
const details = [];
|
|
451
|
+
const len = desc.length;
|
|
452
|
+
if (len >= 50 && len <= 300) {
|
|
453
|
+
points += 8;
|
|
454
|
+
} else if (len >= 30 && len <= 400) {
|
|
455
|
+
points += 5;
|
|
456
|
+
} else if (len >= 10) {
|
|
457
|
+
points += 2;
|
|
458
|
+
}
|
|
459
|
+
details.push(`Length: ${len} chars`);
|
|
460
|
+
const actionVerbs = /\b(deploy|test|build|run|create|generate|analyze|review|format|lint|fix|check|commit|push|pull|fetch|install|configure|search|find|list|delete|update|migrate|convert|export|import)\b/gi;
|
|
461
|
+
const verbCount = (desc.match(actionVerbs) ?? []).length;
|
|
462
|
+
if (verbCount >= 2) {
|
|
463
|
+
points += 8;
|
|
464
|
+
} else if (verbCount === 1) {
|
|
465
|
+
points += 5;
|
|
466
|
+
}
|
|
467
|
+
details.push(`Action verbs: ${verbCount}`);
|
|
468
|
+
const hasTriggerContext = /\buse when\b|\buse for\b|\buse this\b|\binvoke when\b|\bwhen the user\b/i.test(desc);
|
|
469
|
+
if (hasTriggerContext) {
|
|
470
|
+
points += 8;
|
|
471
|
+
details.push("Has trigger context");
|
|
472
|
+
}
|
|
473
|
+
const name = skill.metadata.name ?? "";
|
|
474
|
+
const nameWords = name.split("-").filter((w) => w.length > 2);
|
|
475
|
+
const descLower = desc.toLowerCase();
|
|
476
|
+
const nameRepetitions = nameWords.filter((w) => descLower.includes(w)).length;
|
|
477
|
+
const ratio = nameWords.length > 0 ? nameRepetitions / nameWords.length : 0;
|
|
478
|
+
if (ratio < 0.5) {
|
|
479
|
+
points += 6;
|
|
480
|
+
} else if (ratio < 0.8) {
|
|
481
|
+
points += 3;
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
score: Math.min(points, MAX_POINTS),
|
|
485
|
+
max: MAX_POINTS,
|
|
486
|
+
label: "Description Quality",
|
|
487
|
+
details: details.join(", ")
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// src/scorer/instruction-clarity.ts
|
|
492
|
+
var MAX_POINTS2 = 25;
|
|
493
|
+
function scoreInstructionClarity(skill) {
|
|
494
|
+
const body = skill.body;
|
|
495
|
+
if (!body || body.trim().length === 0) {
|
|
496
|
+
return { score: 0, max: MAX_POINTS2, label: "Instruction Clarity", details: "No instructions" };
|
|
497
|
+
}
|
|
498
|
+
let points = 0;
|
|
499
|
+
const details = [];
|
|
500
|
+
const codeBlockCount = (body.match(/```[\s\S]*?```/g) ?? []).length;
|
|
501
|
+
if (codeBlockCount >= 2) {
|
|
502
|
+
points += 7;
|
|
503
|
+
details.push(`${codeBlockCount} code blocks`);
|
|
504
|
+
} else if (codeBlockCount === 1) {
|
|
505
|
+
points += 4;
|
|
506
|
+
details.push("1 code block");
|
|
507
|
+
}
|
|
508
|
+
const numberedSteps = (body.match(/^\d+\.\s+/gm) ?? []).length;
|
|
509
|
+
if (numberedSteps >= 3) {
|
|
510
|
+
points += 6;
|
|
511
|
+
details.push(`${numberedSteps} numbered steps`);
|
|
512
|
+
} else if (numberedSteps >= 1) {
|
|
513
|
+
points += 3;
|
|
514
|
+
details.push(`${numberedSteps} numbered steps`);
|
|
515
|
+
}
|
|
516
|
+
const hasErrorSection = /#{1,3}\s+(?:error|troubleshoot|fail|issue|problem|debug)/i.test(body);
|
|
517
|
+
const hasErrorKeywords = /\b(?:error|fail|troubleshoot|if .+ fails|when .+ fails|common issues)\b/i.test(body);
|
|
518
|
+
if (hasErrorSection) {
|
|
519
|
+
points += 6;
|
|
520
|
+
details.push("Error handling section");
|
|
521
|
+
} else if (hasErrorKeywords) {
|
|
522
|
+
points += 3;
|
|
523
|
+
details.push("Error mentions");
|
|
524
|
+
}
|
|
525
|
+
const wordCount = body.split(/\s+/).length;
|
|
526
|
+
if (wordCount >= 100) {
|
|
527
|
+
points += 6;
|
|
528
|
+
} else if (wordCount >= 50) {
|
|
529
|
+
points += 4;
|
|
530
|
+
} else if (wordCount >= 20) {
|
|
531
|
+
points += 2;
|
|
532
|
+
}
|
|
533
|
+
details.push(`${wordCount} words`);
|
|
534
|
+
return {
|
|
535
|
+
score: Math.min(points, MAX_POINTS2),
|
|
536
|
+
max: MAX_POINTS2,
|
|
537
|
+
label: "Instruction Clarity",
|
|
538
|
+
details: details.join(", ")
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// src/scorer/progressive-disclosure-score.ts
|
|
543
|
+
var MAX_POINTS3 = 15;
|
|
544
|
+
function scoreProgressiveDisclosure(skill) {
|
|
545
|
+
let points = 0;
|
|
546
|
+
const details = [];
|
|
547
|
+
const isSmall = skill.lineCount <= 100;
|
|
548
|
+
const isMedium = skill.lineCount <= 500;
|
|
549
|
+
if (isSmall) {
|
|
550
|
+
points += 15;
|
|
551
|
+
details.push("Skill is compact");
|
|
552
|
+
} else if (isMedium) {
|
|
553
|
+
points += 8;
|
|
554
|
+
const hasRefs = skill.fileReferences.some(
|
|
555
|
+
(r) => r.path.startsWith("references/") || r.path.startsWith("scripts/")
|
|
556
|
+
);
|
|
557
|
+
if (hasRefs) {
|
|
558
|
+
points += 7;
|
|
559
|
+
details.push("Uses supporting files");
|
|
560
|
+
} else {
|
|
561
|
+
details.push("Could benefit from references/");
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
const hasRefs = skill.fileReferences.some(
|
|
565
|
+
(r) => r.path.startsWith("references/") || r.path.startsWith("scripts/")
|
|
566
|
+
);
|
|
567
|
+
if (hasRefs) {
|
|
568
|
+
points += 10;
|
|
569
|
+
details.push("Uses supporting files (recommended: further reduce main file)");
|
|
570
|
+
} else {
|
|
571
|
+
points += 2;
|
|
572
|
+
details.push("Large file without progressive disclosure");
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return {
|
|
576
|
+
score: Math.min(points, MAX_POINTS3),
|
|
577
|
+
max: MAX_POINTS3,
|
|
578
|
+
label: "Progressive Disclosure",
|
|
579
|
+
details: details.join(", ")
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// src/scorer/security-score.ts
|
|
584
|
+
var MAX_POINTS4 = 10;
|
|
585
|
+
function scoreSecurityScore(skill) {
|
|
586
|
+
let points = MAX_POINTS4;
|
|
587
|
+
const details = [];
|
|
588
|
+
const secretDiags = noSecrets.check(skill);
|
|
589
|
+
if (secretDiags.length > 0) {
|
|
590
|
+
points -= 10;
|
|
591
|
+
details.push(`${secretDiags.length} possible secret(s) found`);
|
|
592
|
+
}
|
|
593
|
+
const pathDiags = noHardcodedPaths.check(skill);
|
|
594
|
+
if (pathDiags.length > 0) {
|
|
595
|
+
points -= 3;
|
|
596
|
+
details.push(`${pathDiags.length} hardcoded path(s)`);
|
|
597
|
+
}
|
|
598
|
+
const body = skill.body;
|
|
599
|
+
const suspiciousPatterns = [
|
|
600
|
+
/\brm\s+-rf\s+\/(?!\s)/,
|
|
601
|
+
// rm -rf / (not followed by space)
|
|
602
|
+
/\bcurl\s+.*\|\s*(?:bash|sh)\b/,
|
|
603
|
+
// curl | bash
|
|
604
|
+
/\beval\s+\$/
|
|
605
|
+
// eval $
|
|
606
|
+
];
|
|
607
|
+
for (const pattern of suspiciousPatterns) {
|
|
608
|
+
if (pattern.test(body)) {
|
|
609
|
+
points -= 2;
|
|
610
|
+
details.push("Suspicious shell pattern");
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (details.length === 0) {
|
|
615
|
+
details.push("No security issues");
|
|
616
|
+
}
|
|
617
|
+
return {
|
|
618
|
+
score: Math.max(0, points),
|
|
619
|
+
max: MAX_POINTS4,
|
|
620
|
+
label: "Security",
|
|
621
|
+
details: details.join(", ")
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// src/scorer/spec-compliance.ts
|
|
626
|
+
var MAX_POINTS5 = 20;
|
|
627
|
+
function scoreSpecCompliance(skill) {
|
|
628
|
+
let points = 0;
|
|
629
|
+
const details = [];
|
|
630
|
+
if (skill.metadata.name) {
|
|
631
|
+
points += 5;
|
|
632
|
+
details.push("Has name");
|
|
633
|
+
}
|
|
634
|
+
if (skill.metadata.description) {
|
|
635
|
+
points += 5;
|
|
636
|
+
details.push("Has description");
|
|
637
|
+
}
|
|
638
|
+
if (skill.tokenCount <= 5e3) {
|
|
639
|
+
points += 5;
|
|
640
|
+
details.push(`${skill.tokenCount} tokens (within budget)`);
|
|
641
|
+
} else if (skill.tokenCount <= 7500) {
|
|
642
|
+
points += 2;
|
|
643
|
+
details.push(`${skill.tokenCount} tokens (over budget)`);
|
|
644
|
+
} else {
|
|
645
|
+
details.push(`${skill.tokenCount} tokens (far over budget)`);
|
|
646
|
+
}
|
|
647
|
+
if (skill.lineCount <= 500) {
|
|
648
|
+
points += 5;
|
|
649
|
+
details.push(`${skill.lineCount} lines`);
|
|
650
|
+
} else if (skill.lineCount <= 750) {
|
|
651
|
+
points += 2;
|
|
652
|
+
details.push(`${skill.lineCount} lines (over recommendation)`);
|
|
653
|
+
} else {
|
|
654
|
+
details.push(`${skill.lineCount} lines (far over recommendation)`);
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
score: Math.min(points, MAX_POINTS5),
|
|
658
|
+
max: MAX_POINTS5,
|
|
659
|
+
label: "Spec Compliance",
|
|
660
|
+
details: details.join(", ")
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// src/scorer/index.ts
|
|
665
|
+
function score(skill) {
|
|
666
|
+
const descriptionQuality = scoreDescriptionQuality(skill);
|
|
667
|
+
const instructionClarity = scoreInstructionClarity(skill);
|
|
668
|
+
const specCompliance = scoreSpecCompliance(skill);
|
|
669
|
+
const progressiveDisclosure2 = scoreProgressiveDisclosure(skill);
|
|
670
|
+
const security = scoreSecurityScore(skill);
|
|
671
|
+
const totalScore = descriptionQuality.score + instructionClarity.score + specCompliance.score + progressiveDisclosure2.score + security.score;
|
|
672
|
+
const dimensions = {
|
|
673
|
+
description_quality: descriptionQuality,
|
|
674
|
+
instruction_clarity: instructionClarity,
|
|
675
|
+
spec_compliance: specCompliance,
|
|
676
|
+
progressive_disclosure: progressiveDisclosure2,
|
|
677
|
+
security
|
|
678
|
+
};
|
|
679
|
+
const suggestions = generateSuggestions(skill, dimensions);
|
|
680
|
+
return {
|
|
681
|
+
score: totalScore,
|
|
682
|
+
dimensions,
|
|
683
|
+
suggestions
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
function generateSuggestions(skill, dimensions) {
|
|
687
|
+
const suggestions = [];
|
|
688
|
+
const descDim = dimensions.description_quality;
|
|
689
|
+
const instrDim = dimensions.instruction_clarity;
|
|
690
|
+
const progDim = dimensions.progressive_disclosure;
|
|
691
|
+
const secDim = dimensions.security;
|
|
692
|
+
if (!skill.metadata.description) {
|
|
693
|
+
suggestions.push({
|
|
694
|
+
message: "Add a description field to the frontmatter",
|
|
695
|
+
pointsGain: 15,
|
|
696
|
+
dimension: "description_quality"
|
|
697
|
+
});
|
|
698
|
+
} else {
|
|
699
|
+
if (descDim.score < descDim.max * 0.6) {
|
|
700
|
+
if (!/\buse when\b/i.test(skill.metadata.description)) {
|
|
701
|
+
suggestions.push({
|
|
702
|
+
message: 'Add "Use when..." to the description to clarify trigger conditions',
|
|
703
|
+
pointsGain: 8,
|
|
704
|
+
dimension: "description_quality"
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (instrDim.score < instrDim.max * 0.5) {
|
|
710
|
+
const hasCodeBlock = /```[\s\S]*?```/.test(skill.body);
|
|
711
|
+
if (!hasCodeBlock) {
|
|
712
|
+
suggestions.push({
|
|
713
|
+
message: "Add a concrete usage example with a code block",
|
|
714
|
+
pointsGain: 5,
|
|
715
|
+
dimension: "instruction_clarity"
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
const hasNumberedSteps = /^\d+\.\s+/m.test(skill.body);
|
|
719
|
+
if (!hasNumberedSteps) {
|
|
720
|
+
suggestions.push({
|
|
721
|
+
message: "Add numbered steps for the main workflow",
|
|
722
|
+
pointsGain: 4,
|
|
723
|
+
dimension: "instruction_clarity"
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
const hasErrorHandling = /#{1,3}\s+(?:error|troubleshoot)/i.test(skill.body);
|
|
727
|
+
if (!hasErrorHandling) {
|
|
728
|
+
suggestions.push({
|
|
729
|
+
message: 'Add an "## Error Handling" section',
|
|
730
|
+
pointsGain: 3,
|
|
731
|
+
dimension: "instruction_clarity"
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (!skill.metadata.name) {
|
|
736
|
+
suggestions.push({
|
|
737
|
+
message: "Add a name field to the frontmatter",
|
|
738
|
+
pointsGain: 5,
|
|
739
|
+
dimension: "spec_compliance"
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
if (skill.tokenCount > 5e3) {
|
|
743
|
+
suggestions.push({
|
|
744
|
+
message: `Reduce token count from ${skill.tokenCount} to under 5,000`,
|
|
745
|
+
pointsGain: 3,
|
|
746
|
+
dimension: "spec_compliance"
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
if (progDim.score < progDim.max * 0.5 && skill.lineCount > 200) {
|
|
750
|
+
suggestions.push({
|
|
751
|
+
message: "Move detailed reference content to a references/ directory",
|
|
752
|
+
pointsGain: 5,
|
|
753
|
+
dimension: "progressive_disclosure"
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
if (secDim.score < secDim.max) {
|
|
757
|
+
suggestions.push({
|
|
758
|
+
message: "Fix security issues (secrets, hardcoded paths, or suspicious patterns)",
|
|
759
|
+
pointsGain: secDim.max - secDim.score,
|
|
760
|
+
dimension: "security"
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
suggestions.sort((a, b) => b.pointsGain - a.pointsGain);
|
|
764
|
+
return suggestions;
|
|
765
|
+
}
|
|
766
|
+
async function validate(path) {
|
|
767
|
+
const locations = await resolveSkillFiles(path);
|
|
768
|
+
if (locations.length === 0) {
|
|
769
|
+
return [
|
|
770
|
+
{
|
|
771
|
+
filePath: path,
|
|
772
|
+
name: "unknown",
|
|
773
|
+
valid: false,
|
|
774
|
+
skill: null,
|
|
775
|
+
diagnostics: [
|
|
776
|
+
{
|
|
777
|
+
ruleId: "skill-not-found",
|
|
778
|
+
severity: "error",
|
|
779
|
+
message: `No SKILL.md file found at path: ${path}`,
|
|
780
|
+
file: path
|
|
781
|
+
}
|
|
782
|
+
]
|
|
783
|
+
}
|
|
784
|
+
];
|
|
785
|
+
}
|
|
786
|
+
const results = [];
|
|
787
|
+
for (const location of locations) {
|
|
788
|
+
const parseResult = await parseSkill(location.skillFile);
|
|
789
|
+
const extraDiagnostics = parseResult.ok ? validateStructure(parseResult.skill) : [];
|
|
790
|
+
const allDiagnostics = [...parseResult.diagnostics, ...extraDiagnostics];
|
|
791
|
+
const hasErrors = allDiagnostics.some((d) => d.severity === "error");
|
|
792
|
+
results.push({
|
|
793
|
+
filePath: location.skillFile,
|
|
794
|
+
name: parseResult.ok ? parseResult.skill.metadata.name ?? location.dirName : location.dirName,
|
|
795
|
+
valid: !hasErrors,
|
|
796
|
+
skill: parseResult.ok ? parseResult.skill : null,
|
|
797
|
+
diagnostics: allDiagnostics
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
return results;
|
|
801
|
+
}
|
|
802
|
+
function validateStructure(skill) {
|
|
803
|
+
const diagnostics = [];
|
|
804
|
+
const dirPath = skill.dirPath;
|
|
805
|
+
const expectedDirs = ["scripts", "references", "assets", "examples", "agents"];
|
|
806
|
+
const entries = safeReaddir(dirPath);
|
|
807
|
+
for (const entry of entries) {
|
|
808
|
+
if (entry === "SKILL.md" || entry.startsWith(".")) continue;
|
|
809
|
+
const entryPath = join(dirPath, entry);
|
|
810
|
+
const isDir = safeIsDirectory(entryPath);
|
|
811
|
+
if (isDir && !expectedDirs.includes(entry)) {
|
|
812
|
+
diagnostics.push({
|
|
813
|
+
ruleId: "unexpected-directory",
|
|
814
|
+
severity: "info",
|
|
815
|
+
message: `Unexpected directory "${entry}" in skill directory. Expected: ${expectedDirs.join(", ")}`,
|
|
816
|
+
file: skill.filePath
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if (skill.rawContent.charCodeAt(0) === 65279) {
|
|
821
|
+
diagnostics.push({
|
|
822
|
+
ruleId: "no-bom",
|
|
823
|
+
severity: "warning",
|
|
824
|
+
message: "SKILL.md contains a UTF-8 BOM (byte order mark). Remove it for compatibility",
|
|
825
|
+
file: skill.filePath,
|
|
826
|
+
line: 1,
|
|
827
|
+
fix: "Save the file without BOM"
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
if (skill.rawContent.includes("\0")) {
|
|
831
|
+
diagnostics.push({
|
|
832
|
+
ruleId: "no-binary",
|
|
833
|
+
severity: "error",
|
|
834
|
+
message: "SKILL.md contains binary content (null bytes). It must be a text file",
|
|
835
|
+
file: skill.filePath
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
return diagnostics;
|
|
839
|
+
}
|
|
840
|
+
function safeReaddir(dirPath) {
|
|
841
|
+
try {
|
|
842
|
+
return readdirSync(dirPath).map(String);
|
|
843
|
+
} catch {
|
|
844
|
+
return [];
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
function safeIsDirectory(path) {
|
|
848
|
+
try {
|
|
849
|
+
return existsSync(path) && statSync(path).isDirectory();
|
|
850
|
+
} catch {
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// src/cli.ts
|
|
856
|
+
var program = new Command();
|
|
857
|
+
program.name("skill-tools").description("Validate, lint, and score Agent Skills (SKILL.md) files").version("0.1.0");
|
|
858
|
+
program.command("validate").alias("v").description("Validate SKILL.md files against the Agent Skills specification").argument("<path>", "Path to SKILL.md file, skill directory, or directory of skills").option("-f, --format <format>", "Output format: text or json", "text").action(async (path, opts) => {
|
|
859
|
+
const results = await validate(path);
|
|
860
|
+
const output = opts.format === "json" ? formatValidationJson(results) : formatValidation(results);
|
|
861
|
+
console.log(output);
|
|
862
|
+
const hasErrors = results.some((r) => !r.valid);
|
|
863
|
+
process.exitCode = hasErrors ? 1 : 0;
|
|
864
|
+
});
|
|
865
|
+
program.command("lint").alias("l").description("Lint SKILL.md files for quality issues beyond spec compliance").argument("<path>", "Path to SKILL.md file, skill directory, or directory of skills").option("-f, --format <format>", "Output format: text or json", "text").option(
|
|
866
|
+
"--fail-on <severity>",
|
|
867
|
+
"Fail if any diagnostic has this severity or higher: error, warning, info",
|
|
868
|
+
"error"
|
|
869
|
+
).action(async (path, opts) => {
|
|
870
|
+
const locations = await resolveSkillFiles(path);
|
|
871
|
+
if (locations.length === 0) {
|
|
872
|
+
console.error(`No SKILL.md files found at: ${path}`);
|
|
873
|
+
process.exitCode = 1;
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const results = [];
|
|
877
|
+
for (const location of locations) {
|
|
878
|
+
const parseResult = await parseSkill(location.skillFile);
|
|
879
|
+
if (!parseResult.ok) {
|
|
880
|
+
console.error(
|
|
881
|
+
`Failed to parse ${location.skillFile}: ${parseResult.diagnostics.map((d) => d.message).join(", ")}`
|
|
882
|
+
);
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
results.push(lint(parseResult.skill));
|
|
886
|
+
}
|
|
887
|
+
const output = opts.format === "json" ? formatLintJson(results) : formatLint(results);
|
|
888
|
+
console.log(output);
|
|
889
|
+
const failSeverities = getFailSeverities(opts.failOn);
|
|
890
|
+
const hasFails = results.some((r) => r.diagnostics.some((d) => failSeverities.has(d.severity)));
|
|
891
|
+
process.exitCode = hasFails ? 1 : 0;
|
|
892
|
+
});
|
|
893
|
+
program.command("score").alias("s").description("Compute a quality score (0-100) for SKILL.md files").argument("<path>", "Path to SKILL.md file, skill directory, or directory of skills").option("-f, --format <format>", "Output format: text or json", "text").option("--min-score <score>", "Fail if any skill scores below this threshold", "0").action(async (path, opts) => {
|
|
894
|
+
const locations = await resolveSkillFiles(path);
|
|
895
|
+
const minScore = Number.parseInt(opts.minScore, 10);
|
|
896
|
+
if (Number.isNaN(minScore) || minScore < 0 || minScore > 100) {
|
|
897
|
+
console.error("--min-score must be a number between 0 and 100");
|
|
898
|
+
process.exitCode = 1;
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (locations.length === 0) {
|
|
902
|
+
console.error(`No SKILL.md files found at: ${path}`);
|
|
903
|
+
process.exitCode = 1;
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
let anyBelowMin = false;
|
|
907
|
+
for (const location of locations) {
|
|
908
|
+
const parseResult = await parseSkill(location.skillFile);
|
|
909
|
+
if (!parseResult.ok) {
|
|
910
|
+
console.error(
|
|
911
|
+
`Failed to parse ${location.skillFile}: ${parseResult.diagnostics.map((d) => d.message).join(", ")}`
|
|
912
|
+
);
|
|
913
|
+
anyBelowMin = true;
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
const qualityScore = score(parseResult.skill);
|
|
917
|
+
const name = parseResult.skill.metadata.name ?? location.dirName;
|
|
918
|
+
const output = opts.format === "json" ? formatScoreJson(name, qualityScore) : formatScore(name, qualityScore);
|
|
919
|
+
console.log(output);
|
|
920
|
+
if (qualityScore.score < minScore) {
|
|
921
|
+
anyBelowMin = true;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
process.exitCode = anyBelowMin ? 1 : 0;
|
|
925
|
+
});
|
|
926
|
+
program.command("check").alias("c").description("Run validate + lint + score in a single pass").argument("<path>", "Path to SKILL.md file, skill directory, or directory of skills").option("-f, --format <format>", "Output format: text or json", "text").option(
|
|
927
|
+
"--fail-on <severity>",
|
|
928
|
+
"Fail if any diagnostic has this severity or higher: error, warning, info",
|
|
929
|
+
"error"
|
|
930
|
+
).option("--min-score <score>", "Fail if any skill scores below this threshold", "0").action(async (path, opts) => {
|
|
931
|
+
const minScore = Number.parseInt(opts.minScore, 10);
|
|
932
|
+
const validationResults = await validate(path);
|
|
933
|
+
if (opts.format === "json") {
|
|
934
|
+
console.log(formatValidationJson(validationResults));
|
|
935
|
+
} else {
|
|
936
|
+
console.log(formatValidation(validationResults));
|
|
937
|
+
}
|
|
938
|
+
const validSkills = validationResults.filter((r) => r.valid && r.skill);
|
|
939
|
+
let anyBelowMin = false;
|
|
940
|
+
const failSeverities = getFailSeverities(opts.failOn);
|
|
941
|
+
let hasLintFails = false;
|
|
942
|
+
for (const result of validSkills) {
|
|
943
|
+
const skill = result.skill;
|
|
944
|
+
const lintResult = lint(skill);
|
|
945
|
+
if (opts.format === "json") {
|
|
946
|
+
console.log(formatLintJson([lintResult]));
|
|
947
|
+
} else {
|
|
948
|
+
console.log(formatLint([lintResult]));
|
|
949
|
+
}
|
|
950
|
+
if (lintResult.diagnostics.some((d) => failSeverities.has(d.severity))) {
|
|
951
|
+
hasLintFails = true;
|
|
952
|
+
}
|
|
953
|
+
const qualityScore = score(skill);
|
|
954
|
+
const name = skill.metadata.name ?? result.name;
|
|
955
|
+
if (opts.format === "json") {
|
|
956
|
+
console.log(formatScoreJson(name, qualityScore));
|
|
957
|
+
} else {
|
|
958
|
+
console.log(formatScore(name, qualityScore));
|
|
959
|
+
}
|
|
960
|
+
if (qualityScore.score < minScore) {
|
|
961
|
+
anyBelowMin = true;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
const hasValidationErrors = validationResults.some((r) => !r.valid);
|
|
965
|
+
process.exitCode = hasValidationErrors || hasLintFails || anyBelowMin ? 1 : 0;
|
|
966
|
+
});
|
|
967
|
+
function getFailSeverities(failOn) {
|
|
968
|
+
switch (failOn) {
|
|
969
|
+
case "info":
|
|
970
|
+
return /* @__PURE__ */ new Set(["error", "warning", "info"]);
|
|
971
|
+
case "warning":
|
|
972
|
+
return /* @__PURE__ */ new Set(["error", "warning"]);
|
|
973
|
+
default:
|
|
974
|
+
return /* @__PURE__ */ new Set(["error"]);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
program.parse();
|
|
978
|
+
//# sourceMappingURL=cli.js.map
|
|
979
|
+
//# sourceMappingURL=cli.js.map
|