skill-check 0.1.0 → 1.0.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/README.md +69 -14
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +645 -175
- package/dist/core/agent-scan.d.ts +17 -1
- package/dist/core/agent-scan.js +53 -4
- package/dist/core/body-split.d.ts +31 -0
- package/dist/core/body-split.js +178 -0
- package/dist/core/conclusion-card.d.ts +21 -0
- package/dist/core/conclusion-card.js +152 -0
- package/dist/core/defaults.js +1 -0
- package/dist/core/formatters.d.ts +10 -1
- package/dist/core/formatters.js +34 -38
- package/dist/core/remote-target.d.ts +51 -0
- package/dist/core/remote-target.js +191 -0
- package/dist/core/share-image.d.ts +2 -0
- package/dist/core/share-image.js +144 -0
- package/dist/rules/core/body.js +3 -1
- package/package.json +7 -1
package/dist/cli/main.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
3
4
|
import { pathToFileURL } from 'node:url';
|
|
4
5
|
import { cancel as clackCancel, confirm, intro, isCancel, outro, select, text, } from '@clack/prompts';
|
|
5
6
|
import { Command, CommanderError } from 'commander';
|
|
6
7
|
import { Listr } from 'listr2';
|
|
7
8
|
import ora from 'ora';
|
|
8
9
|
import pc from 'picocolors';
|
|
9
|
-
import { isCommandAvailable, isValidAgentScanRunner, resolveAgentScanInvocation, runAgentScan, } from '../core/agent-scan.js';
|
|
10
|
+
import { deriveAgentScanSkillRoots, isCommandAvailable, isValidAgentScanRunner, resolveAgentScanInvocation, runAgentScan, summarizeAgentScanOutput, } from '../core/agent-scan.js';
|
|
10
11
|
import { analyze, analyzeWithConfig, ensureInitConfig, resolveExitCode, writeIfRequested, } from '../core/analyze.js';
|
|
12
|
+
import { buildSkillArtifact } from '../core/artifact.js';
|
|
11
13
|
import { diffBaseline, loadBaseline, } from '../core/baseline.js';
|
|
14
|
+
import { applyBodySplitPlan, planBodySplit, } from '../core/body-split.js';
|
|
15
|
+
import { renderConclusionCard, } from '../core/conclusion-card.js';
|
|
12
16
|
import { resolveConfig } from '../core/config.js';
|
|
17
|
+
import { discoverSkillFiles } from '../core/discovery.js';
|
|
13
18
|
import { detectDuplicates } from '../core/duplicates.js';
|
|
14
19
|
import { CliError } from '../core/errors.js';
|
|
15
20
|
import { applyAutoFixes, isFixableRuleId, } from '../core/fix.js';
|
|
@@ -19,13 +24,28 @@ import { renderHtml } from '../core/html-report.js';
|
|
|
19
24
|
import { selectFixableDiagnostics } from '../core/interactive-fix.js';
|
|
20
25
|
import { openInBrowser } from '../core/open-browser.js';
|
|
21
26
|
import { computeSkillScores } from '../core/quality-score.js';
|
|
27
|
+
import { isGitHubRepoUrl, materializeRemoteTarget, } from '../core/remote-target.js';
|
|
22
28
|
import { renderMarkdownReport } from '../core/report.js';
|
|
23
29
|
import { toSarif } from '../core/sarif.js';
|
|
30
|
+
import { writeShareImage } from '../core/share-image.js';
|
|
24
31
|
import { coreRules } from '../rules/core/index.js';
|
|
25
32
|
const defaultIO = {
|
|
26
33
|
stdout: (text) => process.stdout.write(text),
|
|
27
34
|
stderr: (text) => process.stderr.write(text),
|
|
28
35
|
};
|
|
36
|
+
const ROOT_COMMANDS = new Set([
|
|
37
|
+
'check',
|
|
38
|
+
'split-body',
|
|
39
|
+
'report',
|
|
40
|
+
'security-scan',
|
|
41
|
+
'rules',
|
|
42
|
+
'new',
|
|
43
|
+
'diff',
|
|
44
|
+
'watch',
|
|
45
|
+
'init',
|
|
46
|
+
'help',
|
|
47
|
+
'version',
|
|
48
|
+
]);
|
|
29
49
|
function collectList(value, previous) {
|
|
30
50
|
const parsed = value
|
|
31
51
|
.split(',')
|
|
@@ -60,6 +80,7 @@ function addAgentScanOptions(command) {
|
|
|
60
80
|
.option('--security-scan-mode <mode>', 'Security scan mode (default: llmsecurity)', 'llmsecurity')
|
|
61
81
|
.option('--security-scan-paths <path>', 'Comma-separated paths to scan (repeatable)', collectList, [])
|
|
62
82
|
.option('--security-scan-skills <path>', 'Comma-separated skills paths (repeatable)', collectList, [])
|
|
83
|
+
.option('--security-scan-verbose', 'Show full raw security scanner output')
|
|
63
84
|
.option('--allow-installs', 'Allow automatic dependency installs for security scan runners')
|
|
64
85
|
.option('--no-installs', 'Disallow dependency installs for security scan runners');
|
|
65
86
|
}
|
|
@@ -83,18 +104,226 @@ function normalizeCliOptions(raw) {
|
|
|
83
104
|
failOnWarning: Boolean(raw.failOnWarning),
|
|
84
105
|
};
|
|
85
106
|
}
|
|
107
|
+
function withInferredSecurityScanSkills(scanOptions, skillFilePaths) {
|
|
108
|
+
if (scanOptions.skills && scanOptions.skills.length > 0) {
|
|
109
|
+
return scanOptions;
|
|
110
|
+
}
|
|
111
|
+
const inferredSkills = deriveAgentScanSkillRoots(skillFilePaths);
|
|
112
|
+
if (inferredSkills.length === 0) {
|
|
113
|
+
return scanOptions;
|
|
114
|
+
}
|
|
115
|
+
const [selectedSkillRoot] = inferredSkills;
|
|
116
|
+
if (!selectedSkillRoot) {
|
|
117
|
+
return scanOptions;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
...scanOptions,
|
|
121
|
+
skills: [selectedSkillRoot],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
86
124
|
function parseCommaSeparated(value) {
|
|
87
125
|
return value
|
|
88
126
|
.split(',')
|
|
89
127
|
.map((entry) => entry.trim())
|
|
90
128
|
.filter(Boolean);
|
|
91
129
|
}
|
|
130
|
+
export function normalizeRootCommandArgs(argv) {
|
|
131
|
+
if (argv.length === 0) {
|
|
132
|
+
return ['check', '.'];
|
|
133
|
+
}
|
|
134
|
+
const [first] = argv;
|
|
135
|
+
if (!first || ROOT_COMMANDS.has(first)) {
|
|
136
|
+
return argv;
|
|
137
|
+
}
|
|
138
|
+
if (first === '-h' || first === '--help') {
|
|
139
|
+
return argv;
|
|
140
|
+
}
|
|
141
|
+
if (first.startsWith('-')) {
|
|
142
|
+
return ['check', '.', ...argv];
|
|
143
|
+
}
|
|
144
|
+
return ['check', ...argv];
|
|
145
|
+
}
|
|
146
|
+
function shellQuoteArg(value) {
|
|
147
|
+
if (value.length === 0) {
|
|
148
|
+
return "''";
|
|
149
|
+
}
|
|
150
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) {
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
154
|
+
}
|
|
155
|
+
function buildShareableRunCommand(args) {
|
|
156
|
+
if (args.length === 0) {
|
|
157
|
+
return 'npx skill-check';
|
|
158
|
+
}
|
|
159
|
+
return `npx skill-check ${args.map(shellQuoteArg).join(' ')}`;
|
|
160
|
+
}
|
|
92
161
|
function normalizeCheckCommandOptions(raw) {
|
|
93
162
|
return {
|
|
94
163
|
fix: raw.fix === true,
|
|
95
164
|
interactive: raw.interactive === true,
|
|
165
|
+
share: raw.share === true,
|
|
166
|
+
shareOut: typeof raw.shareOut === 'string' ? raw.shareOut : undefined,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function normalizeSplitBodyCommandOptions(raw) {
|
|
170
|
+
return {
|
|
171
|
+
write: raw.write === true,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function resolveCommandTarget(target, options = {}) {
|
|
175
|
+
if (!target || !isGitHubRepoUrl(target)) {
|
|
176
|
+
return {
|
|
177
|
+
target,
|
|
178
|
+
isRemote: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const materialized = materializeRemoteTarget(target, {
|
|
182
|
+
onProgress: options.onProgress,
|
|
183
|
+
});
|
|
184
|
+
return {
|
|
185
|
+
target: materialized.path,
|
|
186
|
+
isRemote: true,
|
|
187
|
+
cleanup: materialized.cleanup,
|
|
96
188
|
};
|
|
97
189
|
}
|
|
190
|
+
async function withResolvedTarget(target, run) {
|
|
191
|
+
const resolved = resolveCommandTarget(target);
|
|
192
|
+
try {
|
|
193
|
+
return await run(resolved);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
resolved.cleanup?.();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function toErrorMessage(error) {
|
|
200
|
+
return error instanceof Error ? error.message : String(error);
|
|
201
|
+
}
|
|
202
|
+
function createRemoteTargetLoader(io) {
|
|
203
|
+
const useSpinner = shouldUseInteractiveUi(io);
|
|
204
|
+
const spinner = useSpinner ? ora() : null;
|
|
205
|
+
let finished = false;
|
|
206
|
+
let failedViaEvent = false;
|
|
207
|
+
const write = (message) => {
|
|
208
|
+
io.stderr(`${message}\n`);
|
|
209
|
+
};
|
|
210
|
+
const update = (message) => {
|
|
211
|
+
if (finished)
|
|
212
|
+
return;
|
|
213
|
+
if (spinner) {
|
|
214
|
+
if (spinner.isSpinning) {
|
|
215
|
+
spinner.text = message;
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
spinner.start(message);
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
write(`[remote] ${message}`);
|
|
223
|
+
};
|
|
224
|
+
const writeSpinnerPersistentLine = (message) => {
|
|
225
|
+
if (!spinner)
|
|
226
|
+
return;
|
|
227
|
+
write(`[remote] ${message}`);
|
|
228
|
+
};
|
|
229
|
+
const succeed = (message) => {
|
|
230
|
+
if (finished)
|
|
231
|
+
return;
|
|
232
|
+
if (spinner) {
|
|
233
|
+
if (spinner.isSpinning) {
|
|
234
|
+
spinner.succeed(message);
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
write(`[remote] ${message}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
write(`[remote] ${message}`);
|
|
242
|
+
}
|
|
243
|
+
finished = true;
|
|
244
|
+
};
|
|
245
|
+
const failInternal = (message) => {
|
|
246
|
+
if (finished)
|
|
247
|
+
return;
|
|
248
|
+
if (spinner) {
|
|
249
|
+
if (spinner.isSpinning) {
|
|
250
|
+
spinner.fail(message);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
write(`[remote] ${message}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
write(`[remote] ${message}`);
|
|
258
|
+
}
|
|
259
|
+
finished = true;
|
|
260
|
+
};
|
|
261
|
+
return {
|
|
262
|
+
start: (url) => {
|
|
263
|
+
const message = `Preparing remote target: ${url}`;
|
|
264
|
+
writeSpinnerPersistentLine(message);
|
|
265
|
+
update(message);
|
|
266
|
+
},
|
|
267
|
+
onProgress: (event) => {
|
|
268
|
+
if (event.type === 'clone_start') {
|
|
269
|
+
const refLabel = event.ref ? ` (ref: ${event.ref})` : '';
|
|
270
|
+
const message = `Cloning ${event.cloneUrl}${refLabel}`;
|
|
271
|
+
writeSpinnerPersistentLine(message);
|
|
272
|
+
update(message);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (event.type === 'clone_done') {
|
|
276
|
+
const message = `Clone complete: ${event.checkoutPath}`;
|
|
277
|
+
writeSpinnerPersistentLine(message);
|
|
278
|
+
update(message);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (event.type === 'subpath_start') {
|
|
282
|
+
const message = `Resolving subpath: ${event.subpath}`;
|
|
283
|
+
writeSpinnerPersistentLine(message);
|
|
284
|
+
update(message);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (event.type === 'ready') {
|
|
288
|
+
succeed(`Remote target ready: ${event.targetPath}`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
failedViaEvent = true;
|
|
292
|
+
failInternal(`Remote target failed: ${event.message}`);
|
|
293
|
+
},
|
|
294
|
+
fail: (error) => {
|
|
295
|
+
if (failedViaEvent)
|
|
296
|
+
return;
|
|
297
|
+
failInternal(`Remote target failed: ${toErrorMessage(error)}`);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
async function withResolvedTargetFeedback(target, io, run) {
|
|
302
|
+
if (!target || !isGitHubRepoUrl(target)) {
|
|
303
|
+
return withResolvedTarget(target, run);
|
|
304
|
+
}
|
|
305
|
+
const loader = createRemoteTargetLoader(io);
|
|
306
|
+
loader.start(target);
|
|
307
|
+
let resolved;
|
|
308
|
+
try {
|
|
309
|
+
resolved = resolveCommandTarget(target, {
|
|
310
|
+
onProgress: (event) => loader.onProgress(event),
|
|
311
|
+
});
|
|
312
|
+
return await run(resolved);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
loader.fail(error);
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
resolved?.cleanup?.();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function assertLocalOnlyTarget(target, commandName) {
|
|
323
|
+
if (!target || !isGitHubRepoUrl(target))
|
|
324
|
+
return;
|
|
325
|
+
throw new CliError(`${commandName} does not support GitHub URL targets yet. Clone locally and re-run ${commandName}.`, 2);
|
|
326
|
+
}
|
|
98
327
|
async function runInteractiveInit(cwd, target, options) {
|
|
99
328
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
100
329
|
throw new CliError('Interactive init requires a TTY. Run without --interactive in non-interactive environments.', 2);
|
|
@@ -190,7 +419,8 @@ function normalizeAgentScanOptions(raw) {
|
|
|
190
419
|
mode,
|
|
191
420
|
paths: selectedPaths,
|
|
192
421
|
skills: selectedSkills,
|
|
193
|
-
|
|
422
|
+
verbose: raw.securityScanVerbose === true,
|
|
423
|
+
installPolicy: denyInstalls ? 'deny' : 'allow',
|
|
194
424
|
};
|
|
195
425
|
}
|
|
196
426
|
function shouldUseInteractiveUi(io) {
|
|
@@ -210,6 +440,37 @@ function shouldRenderBanner(io, format) {
|
|
|
210
440
|
!isTruthyFlag(process.env.CI) &&
|
|
211
441
|
!isTruthyFlag(process.env.SKILL_CHECK_NO_BANNER));
|
|
212
442
|
}
|
|
443
|
+
function emitShareStatus(io, message) {
|
|
444
|
+
io.stderr(`${pc.dim(`share: ${message}`)}\n`);
|
|
445
|
+
}
|
|
446
|
+
function renderAgentScanCompactSummary(summary, verboseHint = true) {
|
|
447
|
+
const lines = [];
|
|
448
|
+
if (summary.noSkillsOrServers) {
|
|
449
|
+
lines.push(`${pc.bold('Security scan summary:')} ${pc.yellow('no servers or skills found')}`);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
const skillsValue = typeof summary.skillsFound === 'number'
|
|
453
|
+
? String(summary.skillsFound)
|
|
454
|
+
: 'unknown';
|
|
455
|
+
const targetText = summary.scannedTarget
|
|
456
|
+
? pc.dim(summary.scannedTarget)
|
|
457
|
+
: pc.dim('(unknown target)');
|
|
458
|
+
lines.push(`${pc.bold('Security scan summary:')} skills=${pc.cyan(skillsValue)} target=${targetText}`);
|
|
459
|
+
}
|
|
460
|
+
const codes = Object.entries(summary.findingsByCode)
|
|
461
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
462
|
+
.map(([code, count]) => `${code}:${count}`);
|
|
463
|
+
if (summary.findingsTotal > 0) {
|
|
464
|
+
lines.push(`${pc.bold('Findings:')} ${pc.yellow(String(summary.findingsTotal))}${codes.length > 0 ? ` ${pc.dim(`(${codes.join(', ')})`)}` : ''}`);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
lines.push(`${pc.bold('Findings:')} ${pc.green('0')}`);
|
|
468
|
+
}
|
|
469
|
+
if (verboseHint) {
|
|
470
|
+
lines.push(pc.dim('Use --security-scan-verbose to view full scanner output.'));
|
|
471
|
+
}
|
|
472
|
+
return `${lines.join('\n')}\n`;
|
|
473
|
+
}
|
|
213
474
|
function renderAsciiBanner() {
|
|
214
475
|
const art = [
|
|
215
476
|
' ____ _ _____ _ _ ____ _ _ _____ ____ _ __',
|
|
@@ -236,34 +497,13 @@ function renderAutoFixSummary(summary) {
|
|
|
236
497
|
}
|
|
237
498
|
return `${lines.join('\n')}\n\n`;
|
|
238
499
|
}
|
|
239
|
-
function
|
|
240
|
-
return [invocation.command, ...invocation.args]
|
|
241
|
-
.map((part) => (part.includes(' ') ? `"${part}"` : part))
|
|
242
|
-
.join(' ');
|
|
243
|
-
}
|
|
244
|
-
async function ensureSecurityScanInstallConsent(scanOptions, invocation, io, format) {
|
|
500
|
+
async function ensureSecurityScanInstallConsent(scanOptions, invocation) {
|
|
245
501
|
const mayInstallDependency = invocation.command !== 'mcp-scan' && !isCommandAvailable('mcp-scan');
|
|
246
502
|
if (!mayInstallDependency)
|
|
247
503
|
return;
|
|
248
504
|
if (scanOptions.installPolicy === 'allow')
|
|
249
505
|
return;
|
|
250
|
-
|
|
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
|
-
}
|
|
506
|
+
throw new CliError(`Security scan may install dependencies via ${invocation.command}. Re-run without --no-installs, install mcp-scan manually, or skip scan with --no-security-scan.`, 2);
|
|
267
507
|
}
|
|
268
508
|
async function runValidationPipeline(cwd, config, interactiveUi) {
|
|
269
509
|
const useTaskUi = interactiveUi && config.output.format === 'text';
|
|
@@ -286,6 +526,25 @@ async function runValidationPipeline(cwd, config, interactiveUi) {
|
|
|
286
526
|
}
|
|
287
527
|
return context.result;
|
|
288
528
|
}
|
|
529
|
+
async function runValidationForShare(cwd, config, io) {
|
|
530
|
+
const interactiveUi = shouldUseInteractiveUi(io);
|
|
531
|
+
if (interactiveUi) {
|
|
532
|
+
const spinner = ora('Preparing share output: validating skills...').start();
|
|
533
|
+
try {
|
|
534
|
+
const result = await analyzeWithConfig(cwd, config);
|
|
535
|
+
spinner.succeed(`Validated ${result.summary.skillCount} skill(s); diagnostics ${result.diagnostics.length}.`);
|
|
536
|
+
return result;
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
spinner.fail('Validation failed.');
|
|
540
|
+
throw error;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
emitShareStatus(io, 'validating skills...');
|
|
544
|
+
const result = await analyzeWithConfig(cwd, config);
|
|
545
|
+
emitShareStatus(io, `validated ${result.summary.skillCount} skill(s); diagnostics ${result.diagnostics.length}.`);
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
289
548
|
async function runAgentScanWithFeedback(scanOptions, target, io, format) {
|
|
290
549
|
const useInteractiveFeedback = shouldUseInteractiveUi(io) && format === 'text';
|
|
291
550
|
const invocation = resolveAgentScanInvocation({
|
|
@@ -299,19 +558,21 @@ async function runAgentScanWithFeedback(scanOptions, target, io, format) {
|
|
|
299
558
|
if (format === 'text') {
|
|
300
559
|
io.stdout(`${pc.dim('Security scan engine:')} ${pc.bold('agent-scan (mcp-scan)')} ${pc.dim('via')} ${pc.cyan(invocation.command)}\n`);
|
|
301
560
|
}
|
|
302
|
-
await ensureSecurityScanInstallConsent(scanOptions, invocation
|
|
561
|
+
await ensureSecurityScanInstallConsent(scanOptions, invocation);
|
|
303
562
|
if (useInteractiveFeedback) {
|
|
304
563
|
ora().info('Running security scan...');
|
|
305
564
|
}
|
|
306
565
|
try {
|
|
307
|
-
const
|
|
566
|
+
const scanResult = runAgentScan({
|
|
308
567
|
cwd: process.cwd(),
|
|
309
568
|
targetPath: target,
|
|
310
569
|
mode: scanOptions.mode,
|
|
311
570
|
runner: scanOptions.runner,
|
|
312
571
|
paths: scanOptions.paths,
|
|
313
572
|
skills: scanOptions.skills,
|
|
573
|
+
verbose: scanOptions.verbose,
|
|
314
574
|
});
|
|
575
|
+
const exitCode = scanResult.status;
|
|
315
576
|
if (useInteractiveFeedback) {
|
|
316
577
|
if (exitCode === 0) {
|
|
317
578
|
ora().succeed('Security scan completed without blocking findings.');
|
|
@@ -320,6 +581,20 @@ async function runAgentScanWithFeedback(scanOptions, target, io, format) {
|
|
|
320
581
|
ora().warn('Security scan reported findings.');
|
|
321
582
|
}
|
|
322
583
|
}
|
|
584
|
+
if (!scanOptions.verbose && format === 'text') {
|
|
585
|
+
const combinedOutput = `${scanResult.stdout}\n${scanResult.stderr}`.trim();
|
|
586
|
+
const summary = summarizeAgentScanOutput(combinedOutput);
|
|
587
|
+
io.stdout(renderAgentScanCompactSummary(summary));
|
|
588
|
+
}
|
|
589
|
+
if (!scanOptions.verbose && format !== 'text' && exitCode !== 0) {
|
|
590
|
+
const combinedOutput = `${scanResult.stdout}\n${scanResult.stderr}`.trim();
|
|
591
|
+
if (combinedOutput) {
|
|
592
|
+
const tail = combinedOutput.split(/\r?\n/).slice(-8).join('\n').trim();
|
|
593
|
+
if (tail) {
|
|
594
|
+
io.stderr(`${tail}\n`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
323
598
|
return exitCode;
|
|
324
599
|
}
|
|
325
600
|
catch (error) {
|
|
@@ -329,7 +604,44 @@ async function runAgentScanWithFeedback(scanOptions, target, io, format) {
|
|
|
329
604
|
throw error;
|
|
330
605
|
}
|
|
331
606
|
}
|
|
607
|
+
function resolveValidationStatus(result) {
|
|
608
|
+
if (result.summary.errorCount > 0)
|
|
609
|
+
return 'FAIL';
|
|
610
|
+
if (result.summary.warningCount > 0)
|
|
611
|
+
return 'WARN';
|
|
612
|
+
return 'PASS';
|
|
613
|
+
}
|
|
614
|
+
function resolveSecurityStatus(enabled, scanExitCode) {
|
|
615
|
+
if (!enabled)
|
|
616
|
+
return 'SKIPPED';
|
|
617
|
+
return scanExitCode === 0 ? 'PASS' : 'FAIL';
|
|
618
|
+
}
|
|
619
|
+
function computeOverallScore(scores) {
|
|
620
|
+
if (scores.length === 0)
|
|
621
|
+
return null;
|
|
622
|
+
const total = scores.reduce((sum, score) => sum + score.score, 0);
|
|
623
|
+
return Math.round(total / scores.length);
|
|
624
|
+
}
|
|
625
|
+
function countAffectedFiles(diagnostics) {
|
|
626
|
+
return new Set(diagnostics.map((diagnostic) => diagnostic.file)).size;
|
|
627
|
+
}
|
|
628
|
+
function formatSourceRange(range) {
|
|
629
|
+
return `${range.startLine}-${range.endLine}`;
|
|
630
|
+
}
|
|
631
|
+
function toErrorText(error) {
|
|
632
|
+
return error instanceof Error ? error.message : String(error);
|
|
633
|
+
}
|
|
634
|
+
function renderSplitPlanLine(plan, maxBodyLines) {
|
|
635
|
+
if (plan.status === 'noop') {
|
|
636
|
+
return `${pc.cyan('[NOOP]')} ${plan.skillRelativePath} body lines ${plan.beforeLineCount} <= max ${maxBodyLines}\n`;
|
|
637
|
+
}
|
|
638
|
+
if (plan.status === 'blocked') {
|
|
639
|
+
return `${pc.red('[BLOCKED]')} ${plan.skillRelativePath} ${plan.reason ?? 'Cannot split body automatically.'}\n`;
|
|
640
|
+
}
|
|
641
|
+
return `${pc.green('[PLAN]')} ${plan.skillRelativePath} lines ${plan.beforeLineCount} -> ${plan.afterLineCount}\n`;
|
|
642
|
+
}
|
|
332
643
|
export async function runCli(argv, io = defaultIO) {
|
|
644
|
+
const originalArgv = [...argv];
|
|
333
645
|
const program = new Command();
|
|
334
646
|
let finalExitCode = 0;
|
|
335
647
|
let bannerRendered = false;
|
|
@@ -351,182 +663,325 @@ export async function runCli(argv, io = defaultIO) {
|
|
|
351
663
|
.description('Run validation checks')
|
|
352
664
|
.option('--fix', 'Apply safe automatic fixes for supported validation findings')
|
|
353
665
|
.option('--interactive', 'Prompt before applying each fix (use with --fix)')
|
|
666
|
+
.option('--share', 'Render a share card (text format only)')
|
|
667
|
+
.option('--share-out <path>', 'Write share card image file (default: ./skill-check-share.png)')
|
|
354
668
|
.option('--no-open', 'Do not open HTML report in browser')
|
|
355
669
|
.option('--baseline <path>', 'Compare against a previous JSON run')
|
|
356
670
|
.action(async (target, rawOptions) => {
|
|
671
|
+
const checkStartedAt = performance.now();
|
|
672
|
+
const runCommand = buildShareableRunCommand(originalArgv);
|
|
357
673
|
const options = normalizeCliOptions(rawOptions);
|
|
358
674
|
const checkOptions = normalizeCheckCommandOptions(rawOptions);
|
|
359
675
|
const scanOptions = normalizeAgentScanOptions(rawOptions);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
676
|
+
if (checkOptions.fix && target && isGitHubRepoUrl(target)) {
|
|
677
|
+
throw new CliError('Cannot use --fix with a GitHub URL target. Clone locally to apply persistent fixes.', 2);
|
|
678
|
+
}
|
|
679
|
+
await withResolvedTargetFeedback(target, io, async (resolvedTarget) => {
|
|
680
|
+
const cwd = resolvedTarget.isRemote && resolvedTarget.target
|
|
681
|
+
? resolvedTarget.target
|
|
682
|
+
: process.cwd();
|
|
683
|
+
const config = await resolveConfig(cwd, resolvedTarget.target, options);
|
|
684
|
+
if (!checkOptions.share) {
|
|
685
|
+
maybeRenderBanner(config.output.format);
|
|
686
|
+
}
|
|
687
|
+
if (checkOptions.share && config.output.format !== 'text') {
|
|
688
|
+
throw new CliError('--share requires text output format.', 2);
|
|
689
|
+
}
|
|
690
|
+
if (checkOptions.shareOut && !checkOptions.share) {
|
|
691
|
+
throw new CliError('--share-out requires --share.', 2);
|
|
692
|
+
}
|
|
693
|
+
const runValidation = async () => {
|
|
694
|
+
if (checkOptions.share) {
|
|
695
|
+
return runValidationForShare(cwd, config, io);
|
|
696
|
+
}
|
|
697
|
+
return runValidationPipeline(cwd, config, shouldUseInteractiveUi(io));
|
|
698
|
+
};
|
|
699
|
+
let result = await runValidation();
|
|
700
|
+
let fixSummary;
|
|
701
|
+
if (checkOptions.fix) {
|
|
702
|
+
if (checkOptions.interactive && shouldUseInteractiveUi(io)) {
|
|
703
|
+
const { accepted, skipped } = await selectFixableDiagnostics(result);
|
|
704
|
+
if (accepted.length > 0) {
|
|
705
|
+
const filteredResult = {
|
|
706
|
+
...result,
|
|
707
|
+
diagnostics: accepted,
|
|
708
|
+
};
|
|
709
|
+
fixSummary = applyAutoFixes(filteredResult);
|
|
710
|
+
fixSummary.unsupportedDiagnostics +=
|
|
711
|
+
result.diagnostics.length - accepted.length - skipped;
|
|
712
|
+
}
|
|
713
|
+
else {
|
|
714
|
+
fixSummary = {
|
|
715
|
+
requestedDiagnostics: result.diagnostics.length,
|
|
716
|
+
supportedDiagnostics: 0,
|
|
717
|
+
unsupportedDiagnostics: result.diagnostics.length,
|
|
718
|
+
appliedFixes: 0,
|
|
719
|
+
filesUpdated: 0,
|
|
720
|
+
updatedFiles: [],
|
|
721
|
+
};
|
|
722
|
+
}
|
|
375
723
|
}
|
|
376
724
|
else {
|
|
377
|
-
fixSummary =
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
725
|
+
fixSummary = applyAutoFixes(result);
|
|
726
|
+
}
|
|
727
|
+
if (fixSummary.appliedFixes > 0) {
|
|
728
|
+
result = await runValidation();
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const duplicateDiags = detectDuplicates(result.skills);
|
|
732
|
+
if (duplicateDiags.length > 0) {
|
|
733
|
+
result = {
|
|
734
|
+
...result,
|
|
735
|
+
diagnostics: [...result.diagnostics, ...duplicateDiags],
|
|
736
|
+
summary: {
|
|
737
|
+
...result.summary,
|
|
738
|
+
warningCount: result.summary.warningCount +
|
|
739
|
+
duplicateDiags.filter((d) => d.severity === 'warn')
|
|
740
|
+
.length,
|
|
741
|
+
errorCount: result.summary.errorCount +
|
|
742
|
+
duplicateDiags.filter((d) => d.severity === 'error')
|
|
743
|
+
.length,
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
const scores = computeSkillScores(result.skills, result.diagnostics);
|
|
748
|
+
const format = result.config.output.format;
|
|
749
|
+
let baselineDiff;
|
|
750
|
+
const baselinePath = typeof rawOptions.baseline === 'string'
|
|
751
|
+
? rawOptions.baseline
|
|
752
|
+
: undefined;
|
|
753
|
+
if (baselinePath) {
|
|
754
|
+
const baselineDiags = loadBaseline(path.resolve(process.cwd(), baselinePath));
|
|
755
|
+
baselineDiff = diffBaseline(result.diagnostics, baselineDiags);
|
|
756
|
+
}
|
|
757
|
+
if (format === 'json') {
|
|
758
|
+
const jsonData = toJson(result);
|
|
759
|
+
jsonData.scores = scores;
|
|
760
|
+
if (baselineDiff) {
|
|
761
|
+
jsonData.baseline = {
|
|
762
|
+
new: baselineDiff.newDiagnostics.length,
|
|
763
|
+
fixed: baselineDiff.fixedDiagnostics.length,
|
|
764
|
+
unchanged: baselineDiff.unchanged.length,
|
|
384
765
|
};
|
|
385
766
|
}
|
|
767
|
+
const output = `${JSON.stringify(jsonData, null, 2)}\n`;
|
|
768
|
+
io.stdout(output);
|
|
769
|
+
const written = writeIfRequested(result.config, output);
|
|
770
|
+
if (written)
|
|
771
|
+
io.stdout(`Wrote ${written}\n`);
|
|
772
|
+
}
|
|
773
|
+
else if (format === 'sarif') {
|
|
774
|
+
const output = `${JSON.stringify(toSarif(result), null, 2)}\n`;
|
|
775
|
+
io.stdout(output);
|
|
776
|
+
const written = writeIfRequested(result.config, output);
|
|
777
|
+
if (written)
|
|
778
|
+
io.stdout(`Wrote ${written}\n`);
|
|
779
|
+
}
|
|
780
|
+
else if (format === 'github') {
|
|
781
|
+
const output = toGitHubAnnotations(result);
|
|
782
|
+
if (output)
|
|
783
|
+
io.stdout(output);
|
|
784
|
+
}
|
|
785
|
+
else if (format === 'html') {
|
|
786
|
+
const html = renderHtml(result, scores);
|
|
787
|
+
const reportPath = result.config.output.reportPath ??
|
|
788
|
+
path.join(result.config.cwd, 'skill-check-report.html');
|
|
789
|
+
const parent = path.dirname(reportPath);
|
|
790
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
791
|
+
fs.writeFileSync(reportPath, html);
|
|
792
|
+
const shouldOpen = Boolean(process.stdout.isTTY) &&
|
|
793
|
+
!process.env.CI &&
|
|
794
|
+
rawOptions.noOpen !== true;
|
|
795
|
+
if (shouldOpen) {
|
|
796
|
+
try {
|
|
797
|
+
openInBrowser(reportPath);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
// ignore open failures
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
io.stdout(`Wrote ${reportPath}\n`);
|
|
386
804
|
}
|
|
387
805
|
else {
|
|
388
|
-
|
|
806
|
+
if (!checkOptions.share) {
|
|
807
|
+
if (fixSummary) {
|
|
808
|
+
io.stdout(renderAutoFixSummary(fixSummary));
|
|
809
|
+
}
|
|
810
|
+
io.stdout(renderText(result, scores, {
|
|
811
|
+
includeConclusion: false,
|
|
812
|
+
}));
|
|
813
|
+
}
|
|
389
814
|
}
|
|
390
|
-
if (
|
|
391
|
-
|
|
815
|
+
if (baselineDiff && (format === 'text' || format === 'html')) {
|
|
816
|
+
io.stdout(`${pc.bold('Baseline:')} ${pc.green(`${baselineDiff.fixedDiagnostics.length} fixed`)} ${pc.red(`${baselineDiff.newDiagnostics.length} new`)} ${pc.dim(`${baselineDiff.unchanged.length} unchanged`)}\n`);
|
|
392
817
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
};
|
|
818
|
+
const validationExitCode = resolveExitCode(result);
|
|
819
|
+
let exitCode = validationExitCode;
|
|
820
|
+
if (format === 'html') {
|
|
821
|
+
io.stdout(`${pc.bold('Validation:')} ${validationExitCode === 0 ? pc.green('PASS') : pc.red('FAIL')}\n`);
|
|
427
822
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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);
|
|
823
|
+
let scanExitCode;
|
|
824
|
+
if (scanOptions.enabled) {
|
|
825
|
+
if (checkOptions.share) {
|
|
826
|
+
emitShareStatus(io, 'running security scan...');
|
|
459
827
|
}
|
|
460
|
-
|
|
461
|
-
|
|
828
|
+
const effectiveScanOptions = withInferredSecurityScanSkills(scanOptions, result.skills.map((skill) => skill.filePath));
|
|
829
|
+
scanExitCode = await runAgentScanWithFeedback(effectiveScanOptions, resolvedTarget.target, io, format);
|
|
830
|
+
if (format === 'html') {
|
|
831
|
+
io.stdout(`${pc.bold('Security scan:')} ${scanExitCode === 0 ? pc.green('PASS') : pc.red('FAIL')}\n`);
|
|
832
|
+
}
|
|
833
|
+
if (scanExitCode !== 0) {
|
|
834
|
+
exitCode = 1;
|
|
462
835
|
}
|
|
463
836
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
else {
|
|
467
|
-
if (fixSummary) {
|
|
468
|
-
io.stdout(renderAutoFixSummary(fixSummary));
|
|
837
|
+
else if (format === 'html') {
|
|
838
|
+
io.stdout(`${pc.bold('Security scan:')} ${pc.yellow('SKIPPED')}\n`);
|
|
469
839
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
840
|
+
else if (checkOptions.share) {
|
|
841
|
+
emitShareStatus(io, 'security scan skipped.');
|
|
842
|
+
}
|
|
843
|
+
if (format === 'text') {
|
|
844
|
+
const conclusion = renderConclusionCard({
|
|
845
|
+
skillCount: result.summary.skillCount,
|
|
846
|
+
errorCount: result.summary.errorCount,
|
|
847
|
+
warningCount: result.summary.warningCount,
|
|
848
|
+
affectedFileCount: countAffectedFiles(result.diagnostics),
|
|
849
|
+
overallScore: computeOverallScore(scores),
|
|
850
|
+
validationStatus: resolveValidationStatus(result),
|
|
851
|
+
securityStatus: resolveSecurityStatus(scanOptions.enabled, scanExitCode),
|
|
852
|
+
elapsedMs: performance.now() - checkStartedAt,
|
|
853
|
+
runCommand,
|
|
854
|
+
mode: checkOptions.share ? 'share' : 'default',
|
|
855
|
+
});
|
|
856
|
+
io.stdout(`${conclusion.card}\n`);
|
|
857
|
+
if (conclusion.fullCommandPlain) {
|
|
858
|
+
io.stdout(`${conclusion.fullCommandPlain}\n`);
|
|
859
|
+
}
|
|
860
|
+
if (checkOptions.share) {
|
|
861
|
+
emitShareStatus(io, 'rendering share image...');
|
|
862
|
+
const shareOutputPath = path.resolve(process.cwd(), checkOptions.shareOut ?? 'skill-check-share.png');
|
|
863
|
+
const shareText = conclusion.fullCommandPlain
|
|
864
|
+
? `${conclusion.card}\n${conclusion.fullCommandPlain}`
|
|
865
|
+
: conclusion.card;
|
|
866
|
+
const writtenPath = writeShareImage(shareText, shareOutputPath);
|
|
867
|
+
io.stdout(`${pc.bold('Share image:')} ${writtenPath}\n`);
|
|
868
|
+
emitShareStatus(io, `share image written: ${writtenPath}`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
finalExitCode = exitCode;
|
|
872
|
+
});
|
|
873
|
+
})));
|
|
874
|
+
program
|
|
875
|
+
.command('split-body [target]')
|
|
876
|
+
.description('Preview or apply section-based body split into references/*.md files')
|
|
877
|
+
.option('--write', 'Apply split changes to files')
|
|
878
|
+
.option('--config <path>', 'Path to skill-check config file')
|
|
879
|
+
.option('--max-body-lines <n>', 'Override max body lines', parseNumber)
|
|
880
|
+
.option('--include <glob>', 'Additional include glob(s)', collectList, [])
|
|
881
|
+
.option('--exclude <glob>', 'Additional exclude glob(s)', collectList, [])
|
|
882
|
+
.action(async (target, rawOptions) => {
|
|
883
|
+
assertLocalOnlyTarget(target, 'split-body');
|
|
884
|
+
const splitOptions = normalizeSplitBodyCommandOptions(rawOptions);
|
|
885
|
+
const options = normalizeCliOptions(rawOptions);
|
|
886
|
+
if (options.format) {
|
|
887
|
+
throw new CliError('split-body does not support --format. It always prints text output.', 2);
|
|
479
888
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
889
|
+
const config = await resolveConfig(process.cwd(), target, options);
|
|
890
|
+
const skillFiles = await discoverSkillFiles(config);
|
|
891
|
+
const skills = skillFiles.map((filePath) => buildSkillArtifact(filePath, config.cwd));
|
|
892
|
+
const plans = skills.map((skill) => planBodySplit(skill, config.limits.maxBodyLines));
|
|
893
|
+
io.stdout(`${pc.bold('split-body')} ${pc.dim(splitOptions.write ? 'apply' : 'preview')}\n`);
|
|
894
|
+
io.stdout(`${pc.dim('max-body-lines:')} ${config.limits.maxBodyLines}\n`);
|
|
895
|
+
io.stdout(`${pc.dim('skills evaluated:')} ${skills.length}\n\n`);
|
|
896
|
+
let plannedCount = 0;
|
|
897
|
+
let blockedCount = 0;
|
|
898
|
+
let noopCount = 0;
|
|
899
|
+
let writtenSkillCount = 0;
|
|
900
|
+
let writtenReferenceCount = 0;
|
|
901
|
+
for (const plan of plans) {
|
|
902
|
+
io.stdout(renderSplitPlanLine(plan, config.limits.maxBodyLines));
|
|
903
|
+
if (plan.status === 'blocked') {
|
|
904
|
+
blockedCount += 1;
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
if (plan.status === 'noop') {
|
|
908
|
+
noopCount += 1;
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
plannedCount += 1;
|
|
912
|
+
for (const reference of plan.referencesToCreate) {
|
|
913
|
+
const details = `(${reference.relativePath}, source lines ${formatSourceRange(reference.sourceLineRange)})`;
|
|
914
|
+
if (splitOptions.write) {
|
|
915
|
+
io.stdout(` ${pc.green('create')} ${details}\n`);
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
io.stdout(` ${pc.dim('would create')} ${details}\n`);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
if (splitOptions.write) {
|
|
922
|
+
try {
|
|
923
|
+
const applied = applyBodySplitPlan(plan);
|
|
924
|
+
if (applied.updatedSkill) {
|
|
925
|
+
writtenSkillCount += 1;
|
|
926
|
+
}
|
|
927
|
+
writtenReferenceCount += applied.writtenReferenceFiles.length;
|
|
928
|
+
}
|
|
929
|
+
catch (error) {
|
|
930
|
+
throw new CliError(`Failed to write split-body output for ${plan.skillRelativePath}: ${toErrorText(error)}`, 1);
|
|
931
|
+
}
|
|
484
932
|
}
|
|
485
|
-
if (
|
|
486
|
-
|
|
933
|
+
if (plan.afterLineCount > config.limits.maxBodyLines) {
|
|
934
|
+
io.stdout(`${pc.yellow('[WARN]')} ${plan.skillRelativePath} still exceeds limit after split. Refine with docs/skills/split-into-references/SKILL.md.\n`);
|
|
487
935
|
}
|
|
488
936
|
}
|
|
489
|
-
|
|
490
|
-
|
|
937
|
+
io.stdout('\n');
|
|
938
|
+
io.stdout(`${pc.bold('Summary:')} planned=${plannedCount} blocked=${blockedCount} noop=${noopCount}\n`);
|
|
939
|
+
if (splitOptions.write) {
|
|
940
|
+
io.stdout(`${pc.bold('Writes:')} updated_skills=${writtenSkillCount} created_references=${writtenReferenceCount}\n`);
|
|
491
941
|
}
|
|
492
|
-
finalExitCode =
|
|
493
|
-
})
|
|
942
|
+
finalExitCode = blockedCount > 0 ? 2 : 0;
|
|
943
|
+
});
|
|
494
944
|
addSharedOptions(program
|
|
495
945
|
.command('report [target]')
|
|
496
946
|
.description('Generate markdown health report')
|
|
497
947
|
.option('--no-open', 'Do not open HTML report in browser')
|
|
498
948
|
.action(async (target, rawOptions) => {
|
|
499
949
|
const options = normalizeCliOptions(rawOptions);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
950
|
+
await withResolvedTargetFeedback(target, io, async (resolvedTarget) => {
|
|
951
|
+
const cwd = resolvedTarget.isRemote && resolvedTarget.target
|
|
952
|
+
? resolvedTarget.target
|
|
953
|
+
: process.cwd();
|
|
954
|
+
const result = await analyze(cwd, resolvedTarget.target, options);
|
|
955
|
+
const format = result.config.output.format;
|
|
956
|
+
if (format === 'html') {
|
|
957
|
+
const html = renderHtml(result);
|
|
958
|
+
const reportPath = result.config.output.reportPath ??
|
|
959
|
+
path.join(result.config.cwd, 'skill-check-report.html');
|
|
960
|
+
const parent = path.dirname(reportPath);
|
|
961
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
962
|
+
fs.writeFileSync(reportPath, html);
|
|
963
|
+
const shouldOpen = Boolean(process.stdout.isTTY) &&
|
|
964
|
+
!process.env.CI &&
|
|
965
|
+
rawOptions.noOpen !== true;
|
|
966
|
+
if (shouldOpen) {
|
|
967
|
+
try {
|
|
968
|
+
openInBrowser(reportPath);
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
// ignore open failures
|
|
972
|
+
}
|
|
518
973
|
}
|
|
974
|
+
io.stdout(`Wrote ${reportPath}\n`);
|
|
519
975
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
finalExitCode = resolveExitCode(result);
|
|
976
|
+
else {
|
|
977
|
+
const markdown = renderMarkdownReport(result);
|
|
978
|
+
const written = writeIfRequested(result.config, markdown);
|
|
979
|
+
io.stdout(markdown);
|
|
980
|
+
if (written)
|
|
981
|
+
io.stdout(`Wrote ${written}\n`);
|
|
982
|
+
}
|
|
983
|
+
finalExitCode = resolveExitCode(result);
|
|
984
|
+
});
|
|
530
985
|
}));
|
|
531
986
|
addAgentScanOptions(program
|
|
532
987
|
.command('security-scan [target]')
|
|
@@ -537,8 +992,16 @@ export async function runCli(argv, io = defaultIO) {
|
|
|
537
992
|
securityScan: true,
|
|
538
993
|
});
|
|
539
994
|
maybeRenderBanner('text');
|
|
540
|
-
|
|
541
|
-
|
|
995
|
+
await withResolvedTargetFeedback(target, io, async (resolvedTarget) => {
|
|
996
|
+
const scanCwd = resolvedTarget.isRemote && resolvedTarget.target
|
|
997
|
+
? resolvedTarget.target
|
|
998
|
+
: process.cwd();
|
|
999
|
+
const discoveryConfig = await resolveConfig(scanCwd, resolvedTarget.target, {});
|
|
1000
|
+
const discoveredSkillFiles = await discoverSkillFiles(discoveryConfig);
|
|
1001
|
+
const effectiveScanOptions = withInferredSecurityScanSkills(scanOptions, discoveredSkillFiles);
|
|
1002
|
+
const scanExitCode = await runAgentScanWithFeedback(effectiveScanOptions, resolvedTarget.target, io, 'text');
|
|
1003
|
+
finalExitCode = scanExitCode === 0 ? 0 : 1;
|
|
1004
|
+
});
|
|
542
1005
|
}));
|
|
543
1006
|
program
|
|
544
1007
|
.command('rules [ruleId]')
|
|
@@ -610,6 +1073,8 @@ export async function runCli(argv, io = defaultIO) {
|
|
|
610
1073
|
.command('diff <pathA> <pathB>')
|
|
611
1074
|
.description('Compare diagnostics between two skill directories')
|
|
612
1075
|
.action(async (pathA, pathB) => {
|
|
1076
|
+
assertLocalOnlyTarget(pathA, 'diff');
|
|
1077
|
+
assertLocalOnlyTarget(pathB, 'diff');
|
|
613
1078
|
const resultA = await analyze(process.cwd(), pathA, {});
|
|
614
1079
|
const resultB = await analyze(process.cwd(), pathB, {});
|
|
615
1080
|
const keyFn = (d) => `${d.ruleId}|${d.message}`;
|
|
@@ -640,15 +1105,19 @@ export async function runCli(argv, io = defaultIO) {
|
|
|
640
1105
|
.command('watch [target]')
|
|
641
1106
|
.description('Watch for changes and re-run validation')
|
|
642
1107
|
.action(async (target, rawOptions) => {
|
|
1108
|
+
assertLocalOnlyTarget(target, 'watch');
|
|
643
1109
|
const options = normalizeCliOptions(rawOptions);
|
|
644
1110
|
const config = await resolveConfig(process.cwd(), target, options);
|
|
645
1111
|
const watchDirs = config.rootsAbs.length > 0 ? config.rootsAbs : [config.cwd];
|
|
646
1112
|
io.stdout(`${pc.cyan('Watching')} ${watchDirs.join(', ')} for changes...\n`);
|
|
647
1113
|
const runCheck = async () => {
|
|
648
1114
|
try {
|
|
1115
|
+
const startedAt = performance.now();
|
|
649
1116
|
const result = await analyzeWithConfig(process.cwd(), config);
|
|
650
1117
|
const scores = computeSkillScores(result.skills, result.diagnostics);
|
|
651
|
-
io.stdout(`\n${renderText(result, scores
|
|
1118
|
+
io.stdout(`\n${renderText(result, scores, {
|
|
1119
|
+
elapsedMs: performance.now() - startedAt,
|
|
1120
|
+
})}`);
|
|
652
1121
|
}
|
|
653
1122
|
catch (err) {
|
|
654
1123
|
io.stderr(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -696,7 +1165,8 @@ export async function runCli(argv, io = defaultIO) {
|
|
|
696
1165
|
finalExitCode = 0;
|
|
697
1166
|
});
|
|
698
1167
|
try {
|
|
699
|
-
|
|
1168
|
+
const normalizedArgv = normalizeRootCommandArgs(argv);
|
|
1169
|
+
await program.parseAsync(normalizedArgv, { from: 'user' });
|
|
700
1170
|
return finalExitCode;
|
|
701
1171
|
}
|
|
702
1172
|
catch (error) {
|