skill-check 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/LICENSE +21 -0
- package/README.md +360 -0
- package/bin/skill-check.js +11 -0
- package/dist/cli/main.d.ts +5 -0
- package/dist/cli/main.js +724 -0
- package/dist/core/agent-scan.d.ts +19 -0
- package/dist/core/agent-scan.js +88 -0
- package/dist/core/allowlist.d.ts +1 -0
- package/dist/core/allowlist.js +8 -0
- package/dist/core/analyze.d.ts +6 -0
- package/dist/core/analyze.js +72 -0
- package/dist/core/artifact.d.ts +2 -0
- package/dist/core/artifact.js +33 -0
- package/dist/core/baseline.d.ts +8 -0
- package/dist/core/baseline.js +17 -0
- package/dist/core/config.d.ts +2 -0
- package/dist/core/config.js +215 -0
- package/dist/core/defaults.d.ts +6 -0
- package/dist/core/defaults.js +34 -0
- package/dist/core/discovery.d.ts +2 -0
- package/dist/core/discovery.js +46 -0
- package/dist/core/duplicates.d.ts +2 -0
- package/dist/core/duplicates.js +60 -0
- package/dist/core/errors.d.ts +4 -0
- package/dist/core/errors.js +8 -0
- package/dist/core/fix.d.ts +11 -0
- package/dist/core/fix.js +172 -0
- package/dist/core/formatters.d.ts +4 -0
- package/dist/core/formatters.js +182 -0
- package/dist/core/frontmatter.d.ts +7 -0
- package/dist/core/frontmatter.js +39 -0
- package/dist/core/github-formatter.d.ts +2 -0
- package/dist/core/github-formatter.js +10 -0
- package/dist/core/html-report.d.ts +3 -0
- package/dist/core/html-report.js +320 -0
- package/dist/core/interactive-fix.d.ts +9 -0
- package/dist/core/interactive-fix.js +22 -0
- package/dist/core/links.d.ts +17 -0
- package/dist/core/links.js +94 -0
- package/dist/core/open-browser.d.ts +6 -0
- package/dist/core/open-browser.js +20 -0
- package/dist/core/plugins.d.ts +2 -0
- package/dist/core/plugins.js +41 -0
- package/dist/core/quality-score.d.ts +14 -0
- package/dist/core/quality-score.js +55 -0
- package/dist/core/report.d.ts +2 -0
- package/dist/core/report.js +26 -0
- package/dist/core/rule-engine.d.ts +2 -0
- package/dist/core/rule-engine.js +52 -0
- package/dist/core/sarif.d.ts +2 -0
- package/dist/core/sarif.js +48 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/rules/core/body.d.ts +2 -0
- package/dist/rules/core/body.js +39 -0
- package/dist/rules/core/description.d.ts +2 -0
- package/dist/rules/core/description.js +66 -0
- package/dist/rules/core/file.d.ts +2 -0
- package/dist/rules/core/file.js +26 -0
- package/dist/rules/core/frontmatter.d.ts +2 -0
- package/dist/rules/core/frontmatter.js +124 -0
- package/dist/rules/core/index.d.ts +2 -0
- package/dist/rules/core/index.js +12 -0
- package/dist/rules/core/links.d.ts +2 -0
- package/dist/rules/core/links.js +54 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.js +1 -0
- package/package.json +82 -0
- package/schemas/config.schema.json +53 -0
- package/skills/skill-check/SKILL.md +76 -0
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { cancel as clackCancel, confirm, intro, isCancel, outro, select, text, } from '@clack/prompts';
|
|
5
|
+
import { Command, CommanderError } from 'commander';
|
|
6
|
+
import { Listr } from 'listr2';
|
|
7
|
+
import ora from 'ora';
|
|
8
|
+
import pc from 'picocolors';
|
|
9
|
+
import { isCommandAvailable, isValidAgentScanRunner, resolveAgentScanInvocation, runAgentScan, } from '../core/agent-scan.js';
|
|
10
|
+
import { analyze, analyzeWithConfig, ensureInitConfig, resolveExitCode, writeIfRequested, } from '../core/analyze.js';
|
|
11
|
+
import { diffBaseline, loadBaseline, } from '../core/baseline.js';
|
|
12
|
+
import { resolveConfig } from '../core/config.js';
|
|
13
|
+
import { detectDuplicates } from '../core/duplicates.js';
|
|
14
|
+
import { CliError } from '../core/errors.js';
|
|
15
|
+
import { applyAutoFixes, isFixableRuleId, } from '../core/fix.js';
|
|
16
|
+
import { renderText, toJson } from '../core/formatters.js';
|
|
17
|
+
import { toGitHubAnnotations } from '../core/github-formatter.js';
|
|
18
|
+
import { renderHtml } from '../core/html-report.js';
|
|
19
|
+
import { selectFixableDiagnostics } from '../core/interactive-fix.js';
|
|
20
|
+
import { openInBrowser } from '../core/open-browser.js';
|
|
21
|
+
import { computeSkillScores } from '../core/quality-score.js';
|
|
22
|
+
import { renderMarkdownReport } from '../core/report.js';
|
|
23
|
+
import { toSarif } from '../core/sarif.js';
|
|
24
|
+
import { coreRules } from '../rules/core/index.js';
|
|
25
|
+
const defaultIO = {
|
|
26
|
+
stdout: (text) => process.stdout.write(text),
|
|
27
|
+
stderr: (text) => process.stderr.write(text),
|
|
28
|
+
};
|
|
29
|
+
function collectList(value, previous) {
|
|
30
|
+
const parsed = value
|
|
31
|
+
.split(',')
|
|
32
|
+
.map((entry) => entry.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
return [...previous, ...parsed];
|
|
35
|
+
}
|
|
36
|
+
function parseNumber(value) {
|
|
37
|
+
const num = Number(value);
|
|
38
|
+
if (!Number.isFinite(num) || num <= 0) {
|
|
39
|
+
throw new CliError(`Expected a positive number, got "${value}".`, 2);
|
|
40
|
+
}
|
|
41
|
+
return num;
|
|
42
|
+
}
|
|
43
|
+
function addSharedOptions(command) {
|
|
44
|
+
return command
|
|
45
|
+
.option('--config <path>', 'Path to skill-check config file')
|
|
46
|
+
.option('--format <format>', 'Output format: text|json|sarif|html|github')
|
|
47
|
+
.option('--strict', 'Treat warnings as errors')
|
|
48
|
+
.option('--lenient', 'Disable selected strict rules')
|
|
49
|
+
.option('--max-body-lines <n>', 'Override max body lines', parseNumber)
|
|
50
|
+
.option('--max-description-chars <n>', 'Override max description chars', parseNumber)
|
|
51
|
+
.option('--include <glob>', 'Additional include glob(s)', collectList, [])
|
|
52
|
+
.option('--exclude <glob>', 'Additional exclude glob(s)', collectList, [])
|
|
53
|
+
.option('--fail-on-warning', 'Exit non-zero when warnings exist');
|
|
54
|
+
}
|
|
55
|
+
function addAgentScanOptions(command) {
|
|
56
|
+
return command
|
|
57
|
+
.option('--security-scan', 'Run security scan as part of this command (enabled by default for check)')
|
|
58
|
+
.option('--no-security-scan', 'Skip security scan for this command')
|
|
59
|
+
.option('--security-scan-runner <runner>', 'Security scan runner: auto|local|uvx|pipx', 'auto')
|
|
60
|
+
.option('--security-scan-mode <mode>', 'Security scan mode (default: llmsecurity)', 'llmsecurity')
|
|
61
|
+
.option('--security-scan-paths <path>', 'Comma-separated paths to scan (repeatable)', collectList, [])
|
|
62
|
+
.option('--security-scan-skills <path>', 'Comma-separated skills paths (repeatable)', collectList, [])
|
|
63
|
+
.option('--allow-installs', 'Allow automatic dependency installs for security scan runners')
|
|
64
|
+
.option('--no-installs', 'Disallow dependency installs for security scan runners');
|
|
65
|
+
}
|
|
66
|
+
function normalizeCliOptions(raw) {
|
|
67
|
+
const format = raw.format;
|
|
68
|
+
if (format &&
|
|
69
|
+
!['text', 'json', 'sarif', 'html', 'github'].includes(String(format))) {
|
|
70
|
+
throw new CliError(`Invalid format "${String(format)}". Use text|json|sarif|html|github.`, 2);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
configPath: typeof raw.config === 'string' ? raw.config : undefined,
|
|
74
|
+
format: format,
|
|
75
|
+
strict: Boolean(raw.strict),
|
|
76
|
+
lenient: Boolean(raw.lenient),
|
|
77
|
+
maxBodyLines: typeof raw.maxBodyLines === 'number' ? raw.maxBodyLines : undefined,
|
|
78
|
+
maxDescriptionChars: typeof raw.maxDescriptionChars === 'number'
|
|
79
|
+
? raw.maxDescriptionChars
|
|
80
|
+
: undefined,
|
|
81
|
+
include: Array.isArray(raw.include) ? raw.include : undefined,
|
|
82
|
+
exclude: Array.isArray(raw.exclude) ? raw.exclude : undefined,
|
|
83
|
+
failOnWarning: Boolean(raw.failOnWarning),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function parseCommaSeparated(value) {
|
|
87
|
+
return value
|
|
88
|
+
.split(',')
|
|
89
|
+
.map((entry) => entry.trim())
|
|
90
|
+
.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
function normalizeCheckCommandOptions(raw) {
|
|
93
|
+
return {
|
|
94
|
+
fix: raw.fix === true,
|
|
95
|
+
interactive: raw.interactive === true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function runInteractiveInit(cwd, target, options) {
|
|
99
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
100
|
+
throw new CliError('Interactive init requires a TTY. Run without --interactive in non-interactive environments.', 2);
|
|
101
|
+
}
|
|
102
|
+
intro('skill-check init wizard');
|
|
103
|
+
const defaultPath = path.resolve(cwd, target ?? 'skill-check.config.json');
|
|
104
|
+
const configPathAnswer = await text({
|
|
105
|
+
message: 'Config file path',
|
|
106
|
+
initialValue: defaultPath,
|
|
107
|
+
placeholder: 'skill-check.config.json',
|
|
108
|
+
});
|
|
109
|
+
if (isCancel(configPathAnswer)) {
|
|
110
|
+
clackCancel('Initialization cancelled.');
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
const rootsAnswer = await text({
|
|
114
|
+
message: 'Roots to scan (comma-separated)',
|
|
115
|
+
initialValue: '.',
|
|
116
|
+
placeholder: '.',
|
|
117
|
+
});
|
|
118
|
+
if (isCancel(rootsAnswer)) {
|
|
119
|
+
clackCancel('Initialization cancelled.');
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
const formatAnswer = await select({
|
|
123
|
+
message: 'Default output format',
|
|
124
|
+
initialValue: 'text',
|
|
125
|
+
options: [
|
|
126
|
+
{ value: 'text', label: 'text', hint: 'human-readable console output' },
|
|
127
|
+
{ value: 'json', label: 'json', hint: 'machine-readable output' },
|
|
128
|
+
{ value: 'sarif', label: 'sarif', hint: 'security tooling format' },
|
|
129
|
+
{ value: 'html', label: 'html', hint: 'self-contained HTML report' },
|
|
130
|
+
{ value: 'github', label: 'github', hint: 'GitHub Actions annotations' },
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
if (isCancel(formatAnswer)) {
|
|
134
|
+
clackCancel('Initialization cancelled.');
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
const parsedRoots = parseCommaSeparated(rootsAnswer.toString());
|
|
138
|
+
const roots = parsedRoots.length > 0 ? parsedRoots : ['.'];
|
|
139
|
+
const filePath = path.resolve(cwd, configPathAnswer.toString());
|
|
140
|
+
let force = Boolean(options.force);
|
|
141
|
+
if (fs.existsSync(filePath) && !force) {
|
|
142
|
+
const overwriteAnswer = await confirm({
|
|
143
|
+
message: `Config already exists at ${filePath}. Overwrite?`,
|
|
144
|
+
initialValue: false,
|
|
145
|
+
});
|
|
146
|
+
if (isCancel(overwriteAnswer) || !overwriteAnswer) {
|
|
147
|
+
clackCancel('Initialization cancelled.');
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
force = true;
|
|
151
|
+
}
|
|
152
|
+
ensureInitConfig(filePath, force);
|
|
153
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
154
|
+
parsed.roots = roots;
|
|
155
|
+
parsed.output = {
|
|
156
|
+
...(parsed.output && typeof parsed.output === 'object'
|
|
157
|
+
? parsed.output
|
|
158
|
+
: {}),
|
|
159
|
+
format: formatAnswer,
|
|
160
|
+
};
|
|
161
|
+
fs.writeFileSync(filePath, `${JSON.stringify(parsed, null, 2)}\n`);
|
|
162
|
+
outro(`Created ${filePath}`);
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
function normalizeAgentScanOptions(raw) {
|
|
166
|
+
const rawRunner = typeof raw.securityScanRunner === 'string'
|
|
167
|
+
? raw.securityScanRunner
|
|
168
|
+
: 'auto';
|
|
169
|
+
if (!isValidAgentScanRunner(rawRunner)) {
|
|
170
|
+
throw new CliError(`Invalid --security-scan-runner "${rawRunner}". Use auto|local|uvx|pipx.`, 2);
|
|
171
|
+
}
|
|
172
|
+
const mode = typeof raw.securityScanMode === 'string' && raw.securityScanMode.trim()
|
|
173
|
+
? raw.securityScanMode.trim()
|
|
174
|
+
: 'llmsecurity';
|
|
175
|
+
const selectedPaths = Array.isArray(raw.securityScanPaths) && raw.securityScanPaths.length > 0
|
|
176
|
+
? raw.securityScanPaths
|
|
177
|
+
: undefined;
|
|
178
|
+
const selectedSkills = Array.isArray(raw.securityScanSkills) && raw.securityScanSkills.length > 0
|
|
179
|
+
? raw.securityScanSkills
|
|
180
|
+
: undefined;
|
|
181
|
+
const allowInstalls = raw.allowInstalls === true ||
|
|
182
|
+
isTruthyFlag(process.env.SKILL_CHECK_ALLOW_INSTALLS);
|
|
183
|
+
const denyInstalls = raw.installs === false || isTruthyFlag(process.env.SKILL_CHECK_NO_INSTALLS);
|
|
184
|
+
if (allowInstalls && denyInstalls) {
|
|
185
|
+
throw new CliError('Cannot use --allow-installs and --no-installs together.', 2);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
enabled: raw.securityScan !== false,
|
|
189
|
+
runner: rawRunner,
|
|
190
|
+
mode,
|
|
191
|
+
paths: selectedPaths,
|
|
192
|
+
skills: selectedSkills,
|
|
193
|
+
installPolicy: denyInstalls ? 'deny' : allowInstalls ? 'allow' : 'ask',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function shouldUseInteractiveUi(io) {
|
|
197
|
+
return (io === defaultIO &&
|
|
198
|
+
Boolean(process.stdout.isTTY) &&
|
|
199
|
+
Boolean(process.stderr.isTTY));
|
|
200
|
+
}
|
|
201
|
+
function isTruthyFlag(value) {
|
|
202
|
+
if (!value)
|
|
203
|
+
return false;
|
|
204
|
+
const normalized = value.trim().toLowerCase();
|
|
205
|
+
return normalized !== '' && normalized !== '0' && normalized !== 'false';
|
|
206
|
+
}
|
|
207
|
+
function shouldRenderBanner(io, format) {
|
|
208
|
+
return (io === defaultIO &&
|
|
209
|
+
format === 'text' &&
|
|
210
|
+
!isTruthyFlag(process.env.CI) &&
|
|
211
|
+
!isTruthyFlag(process.env.SKILL_CHECK_NO_BANNER));
|
|
212
|
+
}
|
|
213
|
+
function renderAsciiBanner() {
|
|
214
|
+
const art = [
|
|
215
|
+
' ____ _ _____ _ _ ____ _ _ _____ ____ _ __',
|
|
216
|
+
' / ___|| |/ /_ _| | | | / ___| | | | ____/ ___| |/ /',
|
|
217
|
+
" \\___ \\| ' / | || | | | | | | |_| | _|| | | ' / ",
|
|
218
|
+
' ___) | . \\ | || |___| |___ | |___| _ | |__| |___| . \\ ',
|
|
219
|
+
' |____/|_|\\_\\___|_____|_____| \\____|_| |_|_____\\____|_|\\_\\',
|
|
220
|
+
].join('\n');
|
|
221
|
+
return `${pc.cyan(pc.bold(art))}\n${pc.dim('Validate skills and security checks in one pass.')}\n\n`;
|
|
222
|
+
}
|
|
223
|
+
function renderAutoFixSummary(summary) {
|
|
224
|
+
const lines = [];
|
|
225
|
+
lines.push(`${pc.bold('Auto-fix:')} ${pc.cyan('analysis complete')}`);
|
|
226
|
+
lines.push(` requested=${pc.cyan(String(summary.requestedDiagnostics))} supported=${pc.cyan(String(summary.supportedDiagnostics))} unsupported=${pc.yellow(String(summary.unsupportedDiagnostics))}`);
|
|
227
|
+
lines.push(` applied=${summary.appliedFixes > 0 ? pc.green(String(summary.appliedFixes)) : pc.yellow('0')} files=${pc.cyan(String(summary.filesUpdated))}`);
|
|
228
|
+
if (summary.updatedFiles.length > 0) {
|
|
229
|
+
lines.push(' updated files:');
|
|
230
|
+
for (const file of summary.updatedFiles) {
|
|
231
|
+
lines.push(` - ${file}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
lines.push(' updated files: none');
|
|
236
|
+
}
|
|
237
|
+
return `${lines.join('\n')}\n\n`;
|
|
238
|
+
}
|
|
239
|
+
function formatInvocation(invocation) {
|
|
240
|
+
return [invocation.command, ...invocation.args]
|
|
241
|
+
.map((part) => (part.includes(' ') ? `"${part}"` : part))
|
|
242
|
+
.join(' ');
|
|
243
|
+
}
|
|
244
|
+
async function ensureSecurityScanInstallConsent(scanOptions, invocation, io, format) {
|
|
245
|
+
const mayInstallDependency = invocation.command !== 'mcp-scan' && !isCommandAvailable('mcp-scan');
|
|
246
|
+
if (!mayInstallDependency)
|
|
247
|
+
return;
|
|
248
|
+
if (scanOptions.installPolicy === 'allow')
|
|
249
|
+
return;
|
|
250
|
+
const guidance = 'Re-run with --allow-installs, install mcp-scan manually, or skip scan with --no-security-scan.';
|
|
251
|
+
if (scanOptions.installPolicy === 'deny') {
|
|
252
|
+
throw new CliError(`Security scan may install dependencies via ${invocation.command}. ${guidance}`, 2);
|
|
253
|
+
}
|
|
254
|
+
const interactivePromptAllowed = shouldUseInteractiveUi(io) &&
|
|
255
|
+
format === 'text' &&
|
|
256
|
+
!isTruthyFlag(process.env.CI);
|
|
257
|
+
if (!interactivePromptAllowed) {
|
|
258
|
+
throw new CliError(`Security scan may install dependencies via ${invocation.command} but interactive approval is unavailable. ${guidance}`, 2);
|
|
259
|
+
}
|
|
260
|
+
const accepted = await confirm({
|
|
261
|
+
message: `Security scan may install "mcp-scan" with: ${formatInvocation(invocation)}. Continue?`,
|
|
262
|
+
initialValue: false,
|
|
263
|
+
});
|
|
264
|
+
if (isCancel(accepted) || !accepted) {
|
|
265
|
+
throw new CliError('Security scan install was declined. Re-run with --no-security-scan or --allow-installs.', 2);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async function runValidationPipeline(cwd, config, interactiveUi) {
|
|
269
|
+
const useTaskUi = interactiveUi && config.output.format === 'text';
|
|
270
|
+
if (!useTaskUi) {
|
|
271
|
+
return analyzeWithConfig(cwd, config);
|
|
272
|
+
}
|
|
273
|
+
const context = {};
|
|
274
|
+
const tasks = new Listr([
|
|
275
|
+
{
|
|
276
|
+
title: 'Discover and validate skills',
|
|
277
|
+
task: async (ctx, task) => {
|
|
278
|
+
ctx.result = await analyzeWithConfig(cwd, config);
|
|
279
|
+
task.output = `${ctx.result.summary.skillCount} skill(s), ${ctx.result.diagnostics.length} diagnostic(s)`;
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
]);
|
|
283
|
+
await tasks.run(context);
|
|
284
|
+
if (!context.result) {
|
|
285
|
+
throw new CliError('Validation pipeline did not produce a result.', 2);
|
|
286
|
+
}
|
|
287
|
+
return context.result;
|
|
288
|
+
}
|
|
289
|
+
async function runAgentScanWithFeedback(scanOptions, target, io, format) {
|
|
290
|
+
const useInteractiveFeedback = shouldUseInteractiveUi(io) && format === 'text';
|
|
291
|
+
const invocation = resolveAgentScanInvocation({
|
|
292
|
+
cwd: process.cwd(),
|
|
293
|
+
targetPath: target,
|
|
294
|
+
mode: scanOptions.mode,
|
|
295
|
+
runner: scanOptions.runner,
|
|
296
|
+
paths: scanOptions.paths,
|
|
297
|
+
skills: scanOptions.skills,
|
|
298
|
+
});
|
|
299
|
+
if (format === 'text') {
|
|
300
|
+
io.stdout(`${pc.dim('Security scan engine:')} ${pc.bold('agent-scan (mcp-scan)')} ${pc.dim('via')} ${pc.cyan(invocation.command)}\n`);
|
|
301
|
+
}
|
|
302
|
+
await ensureSecurityScanInstallConsent(scanOptions, invocation, io, format);
|
|
303
|
+
if (useInteractiveFeedback) {
|
|
304
|
+
ora().info('Running security scan...');
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
const exitCode = runAgentScan({
|
|
308
|
+
cwd: process.cwd(),
|
|
309
|
+
targetPath: target,
|
|
310
|
+
mode: scanOptions.mode,
|
|
311
|
+
runner: scanOptions.runner,
|
|
312
|
+
paths: scanOptions.paths,
|
|
313
|
+
skills: scanOptions.skills,
|
|
314
|
+
});
|
|
315
|
+
if (useInteractiveFeedback) {
|
|
316
|
+
if (exitCode === 0) {
|
|
317
|
+
ora().succeed('Security scan completed without blocking findings.');
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
ora().warn('Security scan reported findings.');
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return exitCode;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (useInteractiveFeedback) {
|
|
327
|
+
ora().fail('Security scan failed to execute.');
|
|
328
|
+
}
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
export async function runCli(argv, io = defaultIO) {
|
|
333
|
+
const program = new Command();
|
|
334
|
+
let finalExitCode = 0;
|
|
335
|
+
let bannerRendered = false;
|
|
336
|
+
const maybeRenderBanner = (format) => {
|
|
337
|
+
if (bannerRendered)
|
|
338
|
+
return;
|
|
339
|
+
if (!shouldRenderBanner(io, format))
|
|
340
|
+
return;
|
|
341
|
+
io.stdout(renderAsciiBanner());
|
|
342
|
+
bannerRendered = true;
|
|
343
|
+
};
|
|
344
|
+
program
|
|
345
|
+
.name('skill-check')
|
|
346
|
+
.description('Linter for agent skill files')
|
|
347
|
+
.showHelpAfterError(true)
|
|
348
|
+
.exitOverride();
|
|
349
|
+
addAgentScanOptions(addSharedOptions(program
|
|
350
|
+
.command('check [target]')
|
|
351
|
+
.description('Run validation checks')
|
|
352
|
+
.option('--fix', 'Apply safe automatic fixes for supported validation findings')
|
|
353
|
+
.option('--interactive', 'Prompt before applying each fix (use with --fix)')
|
|
354
|
+
.option('--no-open', 'Do not open HTML report in browser')
|
|
355
|
+
.option('--baseline <path>', 'Compare against a previous JSON run')
|
|
356
|
+
.action(async (target, rawOptions) => {
|
|
357
|
+
const options = normalizeCliOptions(rawOptions);
|
|
358
|
+
const checkOptions = normalizeCheckCommandOptions(rawOptions);
|
|
359
|
+
const scanOptions = normalizeAgentScanOptions(rawOptions);
|
|
360
|
+
const config = await resolveConfig(process.cwd(), target, options);
|
|
361
|
+
maybeRenderBanner(config.output.format);
|
|
362
|
+
let result = await runValidationPipeline(process.cwd(), config, shouldUseInteractiveUi(io));
|
|
363
|
+
let fixSummary;
|
|
364
|
+
if (checkOptions.fix) {
|
|
365
|
+
if (checkOptions.interactive && shouldUseInteractiveUi(io)) {
|
|
366
|
+
const { accepted, skipped } = await selectFixableDiagnostics(result);
|
|
367
|
+
if (accepted.length > 0) {
|
|
368
|
+
const filteredResult = {
|
|
369
|
+
...result,
|
|
370
|
+
diagnostics: accepted,
|
|
371
|
+
};
|
|
372
|
+
fixSummary = applyAutoFixes(filteredResult);
|
|
373
|
+
fixSummary.unsupportedDiagnostics +=
|
|
374
|
+
result.diagnostics.length - accepted.length - skipped;
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
fixSummary = {
|
|
378
|
+
requestedDiagnostics: result.diagnostics.length,
|
|
379
|
+
supportedDiagnostics: 0,
|
|
380
|
+
unsupportedDiagnostics: result.diagnostics.length,
|
|
381
|
+
appliedFixes: 0,
|
|
382
|
+
filesUpdated: 0,
|
|
383
|
+
updatedFiles: [],
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
else {
|
|
388
|
+
fixSummary = applyAutoFixes(result);
|
|
389
|
+
}
|
|
390
|
+
if (fixSummary.appliedFixes > 0) {
|
|
391
|
+
result = await runValidationPipeline(process.cwd(), config, shouldUseInteractiveUi(io));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const duplicateDiags = detectDuplicates(result.skills);
|
|
395
|
+
if (duplicateDiags.length > 0) {
|
|
396
|
+
result = {
|
|
397
|
+
...result,
|
|
398
|
+
diagnostics: [...result.diagnostics, ...duplicateDiags],
|
|
399
|
+
summary: {
|
|
400
|
+
...result.summary,
|
|
401
|
+
warningCount: result.summary.warningCount +
|
|
402
|
+
duplicateDiags.filter((d) => d.severity === 'warn').length,
|
|
403
|
+
errorCount: result.summary.errorCount +
|
|
404
|
+
duplicateDiags.filter((d) => d.severity === 'error').length,
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const scores = computeSkillScores(result.skills, result.diagnostics);
|
|
409
|
+
const format = result.config.output.format;
|
|
410
|
+
let baselineDiff;
|
|
411
|
+
const baselinePath = typeof rawOptions.baseline === 'string'
|
|
412
|
+
? rawOptions.baseline
|
|
413
|
+
: undefined;
|
|
414
|
+
if (baselinePath) {
|
|
415
|
+
const baselineDiags = loadBaseline(path.resolve(process.cwd(), baselinePath));
|
|
416
|
+
baselineDiff = diffBaseline(result.diagnostics, baselineDiags);
|
|
417
|
+
}
|
|
418
|
+
if (format === 'json') {
|
|
419
|
+
const jsonData = toJson(result);
|
|
420
|
+
jsonData.scores = scores;
|
|
421
|
+
if (baselineDiff) {
|
|
422
|
+
jsonData.baseline = {
|
|
423
|
+
new: baselineDiff.newDiagnostics.length,
|
|
424
|
+
fixed: baselineDiff.fixedDiagnostics.length,
|
|
425
|
+
unchanged: baselineDiff.unchanged.length,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const output = `${JSON.stringify(jsonData, null, 2)}\n`;
|
|
429
|
+
io.stdout(output);
|
|
430
|
+
const written = writeIfRequested(result.config, output);
|
|
431
|
+
if (written)
|
|
432
|
+
io.stdout(`Wrote ${written}\n`);
|
|
433
|
+
}
|
|
434
|
+
else if (format === 'sarif') {
|
|
435
|
+
const output = `${JSON.stringify(toSarif(result), null, 2)}\n`;
|
|
436
|
+
io.stdout(output);
|
|
437
|
+
const written = writeIfRequested(result.config, output);
|
|
438
|
+
if (written)
|
|
439
|
+
io.stdout(`Wrote ${written}\n`);
|
|
440
|
+
}
|
|
441
|
+
else if (format === 'github') {
|
|
442
|
+
const output = toGitHubAnnotations(result);
|
|
443
|
+
if (output)
|
|
444
|
+
io.stdout(output);
|
|
445
|
+
}
|
|
446
|
+
else if (format === 'html') {
|
|
447
|
+
const html = renderHtml(result, scores);
|
|
448
|
+
const reportPath = result.config.output.reportPath ??
|
|
449
|
+
path.join(result.config.cwd, 'skill-check-report.html');
|
|
450
|
+
const parent = path.dirname(reportPath);
|
|
451
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
452
|
+
fs.writeFileSync(reportPath, html);
|
|
453
|
+
const shouldOpen = Boolean(process.stdout.isTTY) &&
|
|
454
|
+
!process.env.CI &&
|
|
455
|
+
rawOptions.noOpen !== true;
|
|
456
|
+
if (shouldOpen) {
|
|
457
|
+
try {
|
|
458
|
+
openInBrowser(reportPath);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// ignore open failures
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
io.stdout(`Wrote ${reportPath}\n`);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
if (fixSummary) {
|
|
468
|
+
io.stdout(renderAutoFixSummary(fixSummary));
|
|
469
|
+
}
|
|
470
|
+
io.stdout(renderText(result, scores));
|
|
471
|
+
}
|
|
472
|
+
if (baselineDiff && (format === 'text' || format === 'html')) {
|
|
473
|
+
io.stdout(`${pc.bold('Baseline:')} ${pc.green(`${baselineDiff.fixedDiagnostics.length} fixed`)} ${pc.red(`${baselineDiff.newDiagnostics.length} new`)} ${pc.dim(`${baselineDiff.unchanged.length} unchanged`)}\n`);
|
|
474
|
+
}
|
|
475
|
+
const validationExitCode = resolveExitCode(result);
|
|
476
|
+
let exitCode = validationExitCode;
|
|
477
|
+
if (format === 'text' || format === 'html') {
|
|
478
|
+
io.stdout(`${pc.bold('Validation:')} ${validationExitCode === 0 ? pc.green('PASS') : pc.red('FAIL')}\n`);
|
|
479
|
+
}
|
|
480
|
+
if (scanOptions.enabled) {
|
|
481
|
+
const scanExitCode = await runAgentScanWithFeedback(scanOptions, target, io, format);
|
|
482
|
+
if (format === 'text' || format === 'html') {
|
|
483
|
+
io.stdout(`${pc.bold('Security scan:')} ${scanExitCode === 0 ? pc.green('PASS') : pc.red('FAIL')}\n`);
|
|
484
|
+
}
|
|
485
|
+
if (scanExitCode !== 0) {
|
|
486
|
+
exitCode = 1;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
else if (format === 'text' || format === 'html') {
|
|
490
|
+
io.stdout(`${pc.bold('Security scan:')} ${pc.yellow('SKIPPED')}\n`);
|
|
491
|
+
}
|
|
492
|
+
finalExitCode = exitCode;
|
|
493
|
+
})));
|
|
494
|
+
addSharedOptions(program
|
|
495
|
+
.command('report [target]')
|
|
496
|
+
.description('Generate markdown health report')
|
|
497
|
+
.option('--no-open', 'Do not open HTML report in browser')
|
|
498
|
+
.action(async (target, rawOptions) => {
|
|
499
|
+
const options = normalizeCliOptions(rawOptions);
|
|
500
|
+
const result = await analyze(process.cwd(), target, options);
|
|
501
|
+
const format = result.config.output.format;
|
|
502
|
+
if (format === 'html') {
|
|
503
|
+
const html = renderHtml(result);
|
|
504
|
+
const reportPath = result.config.output.reportPath ??
|
|
505
|
+
path.join(result.config.cwd, 'skill-check-report.html');
|
|
506
|
+
const parent = path.dirname(reportPath);
|
|
507
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
508
|
+
fs.writeFileSync(reportPath, html);
|
|
509
|
+
const shouldOpen = Boolean(process.stdout.isTTY) &&
|
|
510
|
+
!process.env.CI &&
|
|
511
|
+
rawOptions.noOpen !== true;
|
|
512
|
+
if (shouldOpen) {
|
|
513
|
+
try {
|
|
514
|
+
openInBrowser(reportPath);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// ignore open failures
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
io.stdout(`Wrote ${reportPath}\n`);
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
const markdown = renderMarkdownReport(result);
|
|
524
|
+
const written = writeIfRequested(result.config, markdown);
|
|
525
|
+
io.stdout(markdown);
|
|
526
|
+
if (written)
|
|
527
|
+
io.stdout(`Wrote ${written}\n`);
|
|
528
|
+
}
|
|
529
|
+
finalExitCode = resolveExitCode(result);
|
|
530
|
+
}));
|
|
531
|
+
addAgentScanOptions(program
|
|
532
|
+
.command('security-scan [target]')
|
|
533
|
+
.description('Run security scan using agent-scan (mcp-scan)')
|
|
534
|
+
.action(async (target, rawOptions) => {
|
|
535
|
+
const scanOptions = normalizeAgentScanOptions({
|
|
536
|
+
...rawOptions,
|
|
537
|
+
securityScan: true,
|
|
538
|
+
});
|
|
539
|
+
maybeRenderBanner('text');
|
|
540
|
+
const scanExitCode = await runAgentScanWithFeedback(scanOptions, target, io, 'text');
|
|
541
|
+
finalExitCode = scanExitCode === 0 ? 0 : 1;
|
|
542
|
+
}));
|
|
543
|
+
program
|
|
544
|
+
.command('rules [ruleId]')
|
|
545
|
+
.description('List built-in rules or show detail for a specific rule')
|
|
546
|
+
.action((ruleId) => {
|
|
547
|
+
if (!ruleId) {
|
|
548
|
+
const lines = ['Built-in rules:\n'];
|
|
549
|
+
for (const rule of coreRules) {
|
|
550
|
+
const fixable = isFixableRuleId(rule.id)
|
|
551
|
+
? pc.green(' [fixable]')
|
|
552
|
+
: '';
|
|
553
|
+
lines.push(` ${pc.cyan(rule.id)} ${pc.dim(`(${rule.defaultSeverity})`)}${fixable}`);
|
|
554
|
+
lines.push(` ${pc.dim(rule.description)}`);
|
|
555
|
+
}
|
|
556
|
+
io.stdout(`${lines.join('\n')}\n`);
|
|
557
|
+
finalExitCode = 0;
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const rule = coreRules.find((r) => r.id === ruleId);
|
|
561
|
+
if (!rule) {
|
|
562
|
+
io.stderr(`Unknown rule: ${ruleId}\n`);
|
|
563
|
+
finalExitCode = 2;
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
io.stdout(`${pc.bold(rule.id)}\n`);
|
|
567
|
+
io.stdout(` Severity : ${rule.defaultSeverity}\n`);
|
|
568
|
+
io.stdout(` Fixable : ${isFixableRuleId(rule.id) ? 'yes' : 'no'}\n`);
|
|
569
|
+
io.stdout(` ${rule.description}\n`);
|
|
570
|
+
finalExitCode = 0;
|
|
571
|
+
});
|
|
572
|
+
program
|
|
573
|
+
.command('new <name>')
|
|
574
|
+
.description('Scaffold a new skill directory with SKILL.md template')
|
|
575
|
+
.option('--dir <directory>', 'Parent directory for the skill', 'skills')
|
|
576
|
+
.action((name, options) => {
|
|
577
|
+
const slug = name
|
|
578
|
+
.toLowerCase()
|
|
579
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
580
|
+
.replace(/^-|-$/g, '');
|
|
581
|
+
const skillDir = path.resolve(process.cwd(), options.dir, slug);
|
|
582
|
+
if (fs.existsSync(skillDir)) {
|
|
583
|
+
io.stderr(`Directory already exists: ${skillDir}\n`);
|
|
584
|
+
finalExitCode = 1;
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
588
|
+
const template = [
|
|
589
|
+
'---',
|
|
590
|
+
`name: ${slug}`,
|
|
591
|
+
`description: Use when ${slug} functionality is needed. Describe triggers, constraints, and expected outcomes.`,
|
|
592
|
+
'---',
|
|
593
|
+
'',
|
|
594
|
+
`# ${slug}`,
|
|
595
|
+
'',
|
|
596
|
+
'## When to use',
|
|
597
|
+
'',
|
|
598
|
+
'Describe when an agent should select this skill.',
|
|
599
|
+
'',
|
|
600
|
+
'## Instructions',
|
|
601
|
+
'',
|
|
602
|
+
'Step-by-step instructions for the agent.',
|
|
603
|
+
'',
|
|
604
|
+
].join('\n');
|
|
605
|
+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), template);
|
|
606
|
+
io.stdout(`Created ${path.join(skillDir, 'SKILL.md')}\n`);
|
|
607
|
+
finalExitCode = 0;
|
|
608
|
+
});
|
|
609
|
+
program
|
|
610
|
+
.command('diff <pathA> <pathB>')
|
|
611
|
+
.description('Compare diagnostics between two skill directories')
|
|
612
|
+
.action(async (pathA, pathB) => {
|
|
613
|
+
const resultA = await analyze(process.cwd(), pathA, {});
|
|
614
|
+
const resultB = await analyze(process.cwd(), pathB, {});
|
|
615
|
+
const keyFn = (d) => `${d.ruleId}|${d.message}`;
|
|
616
|
+
const keysA = new Set(resultA.diagnostics.map(keyFn));
|
|
617
|
+
const keysB = new Set(resultB.diagnostics.map(keyFn));
|
|
618
|
+
const onlyA = resultA.diagnostics.filter((d) => !keysB.has(keyFn(d)));
|
|
619
|
+
const onlyB = resultB.diagnostics.filter((d) => !keysA.has(keyFn(d)));
|
|
620
|
+
const shared = resultA.diagnostics.filter((d) => keysB.has(keyFn(d)));
|
|
621
|
+
io.stdout(`${pc.bold('Diff:')} ${pathA} vs ${pathB}\n`);
|
|
622
|
+
io.stdout(` Only in ${pc.cyan(pathA)}: ${onlyA.length} diagnostic(s)\n`);
|
|
623
|
+
io.stdout(` Only in ${pc.cyan(pathB)}: ${onlyB.length} diagnostic(s)\n`);
|
|
624
|
+
io.stdout(` Shared: ${shared.length} diagnostic(s)\n`);
|
|
625
|
+
if (onlyA.length > 0) {
|
|
626
|
+
io.stdout(`\n${pc.bold(`Only in ${pathA}:`)}\n`);
|
|
627
|
+
for (const d of onlyA) {
|
|
628
|
+
io.stdout(` ${d.severity === 'error' ? pc.red('ERR') : pc.yellow('WRN')} ${pc.cyan(d.ruleId)} ${d.message}\n`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (onlyB.length > 0) {
|
|
632
|
+
io.stdout(`\n${pc.bold(`Only in ${pathB}:`)}\n`);
|
|
633
|
+
for (const d of onlyB) {
|
|
634
|
+
io.stdout(` ${d.severity === 'error' ? pc.red('ERR') : pc.yellow('WRN')} ${pc.cyan(d.ruleId)} ${d.message}\n`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
finalExitCode = 0;
|
|
638
|
+
});
|
|
639
|
+
addSharedOptions(program
|
|
640
|
+
.command('watch [target]')
|
|
641
|
+
.description('Watch for changes and re-run validation')
|
|
642
|
+
.action(async (target, rawOptions) => {
|
|
643
|
+
const options = normalizeCliOptions(rawOptions);
|
|
644
|
+
const config = await resolveConfig(process.cwd(), target, options);
|
|
645
|
+
const watchDirs = config.rootsAbs.length > 0 ? config.rootsAbs : [config.cwd];
|
|
646
|
+
io.stdout(`${pc.cyan('Watching')} ${watchDirs.join(', ')} for changes...\n`);
|
|
647
|
+
const runCheck = async () => {
|
|
648
|
+
try {
|
|
649
|
+
const result = await analyzeWithConfig(process.cwd(), config);
|
|
650
|
+
const scores = computeSkillScores(result.skills, result.diagnostics);
|
|
651
|
+
io.stdout(`\n${renderText(result, scores)}`);
|
|
652
|
+
}
|
|
653
|
+
catch (err) {
|
|
654
|
+
io.stderr(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
await runCheck();
|
|
658
|
+
const watchers = [];
|
|
659
|
+
let debounceTimer = null;
|
|
660
|
+
for (const dir of watchDirs) {
|
|
661
|
+
const watcher = fs.watch(dir, { recursive: true }, (_event, filename) => {
|
|
662
|
+
if (!filename || !filename.endsWith('.md'))
|
|
663
|
+
return;
|
|
664
|
+
if (debounceTimer)
|
|
665
|
+
clearTimeout(debounceTimer);
|
|
666
|
+
debounceTimer = setTimeout(() => {
|
|
667
|
+
io.stdout(`\n${pc.dim(`Changed: ${filename}`)}\n`);
|
|
668
|
+
runCheck();
|
|
669
|
+
}, 300);
|
|
670
|
+
});
|
|
671
|
+
watchers.push(watcher);
|
|
672
|
+
}
|
|
673
|
+
await new Promise((resolve) => {
|
|
674
|
+
process.on('SIGINT', () => {
|
|
675
|
+
for (const w of watchers)
|
|
676
|
+
w.close();
|
|
677
|
+
io.stdout('\nStopped watching.\n');
|
|
678
|
+
resolve();
|
|
679
|
+
});
|
|
680
|
+
});
|
|
681
|
+
finalExitCode = 0;
|
|
682
|
+
}));
|
|
683
|
+
program
|
|
684
|
+
.command('init [target]')
|
|
685
|
+
.description('Create skill-check.config.json template')
|
|
686
|
+
.option('--force', 'Overwrite existing config file')
|
|
687
|
+
.option('--interactive', 'Run interactive setup wizard')
|
|
688
|
+
.action(async (target, options) => {
|
|
689
|
+
if (options.interactive) {
|
|
690
|
+
finalExitCode = await runInteractiveInit(process.cwd(), target, options);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const filePath = path.resolve(process.cwd(), target ?? 'skill-check.config.json');
|
|
694
|
+
ensureInitConfig(filePath, Boolean(options.force));
|
|
695
|
+
io.stdout(`Created ${filePath}\n`);
|
|
696
|
+
finalExitCode = 0;
|
|
697
|
+
});
|
|
698
|
+
try {
|
|
699
|
+
await program.parseAsync(argv, { from: 'user' });
|
|
700
|
+
return finalExitCode;
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
if (error instanceof CommanderError) {
|
|
704
|
+
if (error.code === 'commander.helpDisplayed') {
|
|
705
|
+
return 0;
|
|
706
|
+
}
|
|
707
|
+
io.stderr(`${error.message}\n`);
|
|
708
|
+
return 2;
|
|
709
|
+
}
|
|
710
|
+
if (error instanceof CliError) {
|
|
711
|
+
io.stderr(`${error.message}\n`);
|
|
712
|
+
return error.code;
|
|
713
|
+
}
|
|
714
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
715
|
+
io.stderr(`${message}\n`);
|
|
716
|
+
return 2;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (process.argv[1] &&
|
|
720
|
+
import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
721
|
+
runCli(process.argv.slice(2)).then((code) => {
|
|
722
|
+
process.exit(code);
|
|
723
|
+
});
|
|
724
|
+
}
|