skill-check 0.1.1 → 1.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/README.md +62 -9
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +624 -152
- 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 +154 -0
- package/dist/core/defaults.js +1 -1
- package/dist/core/discovery.js +0 -18
- package/dist/core/formatters.d.ts +10 -1
- package/dist/core/formatters.js +36 -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,8 +24,10 @@ 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),
|
|
@@ -28,6 +35,7 @@ const defaultIO = {
|
|
|
28
35
|
};
|
|
29
36
|
const ROOT_COMMANDS = new Set([
|
|
30
37
|
'check',
|
|
38
|
+
'split-body',
|
|
31
39
|
'report',
|
|
32
40
|
'security-scan',
|
|
33
41
|
'rules',
|
|
@@ -72,6 +80,7 @@ function addAgentScanOptions(command) {
|
|
|
72
80
|
.option('--security-scan-mode <mode>', 'Security scan mode (default: llmsecurity)', 'llmsecurity')
|
|
73
81
|
.option('--security-scan-paths <path>', 'Comma-separated paths to scan (repeatable)', collectList, [])
|
|
74
82
|
.option('--security-scan-skills <path>', 'Comma-separated skills paths (repeatable)', collectList, [])
|
|
83
|
+
.option('--security-scan-verbose', 'Show full raw security scanner output')
|
|
75
84
|
.option('--allow-installs', 'Allow automatic dependency installs for security scan runners')
|
|
76
85
|
.option('--no-installs', 'Disallow dependency installs for security scan runners');
|
|
77
86
|
}
|
|
@@ -95,28 +104,226 @@ function normalizeCliOptions(raw) {
|
|
|
95
104
|
failOnWarning: Boolean(raw.failOnWarning),
|
|
96
105
|
};
|
|
97
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
|
+
}
|
|
98
124
|
function parseCommaSeparated(value) {
|
|
99
125
|
return value
|
|
100
126
|
.split(',')
|
|
101
127
|
.map((entry) => entry.trim())
|
|
102
128
|
.filter(Boolean);
|
|
103
129
|
}
|
|
104
|
-
function normalizeRootCommandArgs(argv) {
|
|
130
|
+
export function normalizeRootCommandArgs(argv) {
|
|
105
131
|
if (argv.length === 0) {
|
|
106
|
-
return
|
|
132
|
+
return ['check', '.'];
|
|
107
133
|
}
|
|
108
134
|
const [first] = argv;
|
|
109
|
-
if (!first ||
|
|
135
|
+
if (!first || ROOT_COMMANDS.has(first)) {
|
|
136
|
+
return argv;
|
|
137
|
+
}
|
|
138
|
+
if (first === '-h' || first === '--help') {
|
|
110
139
|
return argv;
|
|
111
140
|
}
|
|
141
|
+
if (first.startsWith('-')) {
|
|
142
|
+
return ['check', '.', ...argv];
|
|
143
|
+
}
|
|
112
144
|
return ['check', ...argv];
|
|
113
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
|
+
}
|
|
114
161
|
function normalizeCheckCommandOptions(raw) {
|
|
115
162
|
return {
|
|
116
163
|
fix: raw.fix === true,
|
|
117
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,
|
|
118
188
|
};
|
|
119
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
|
+
}
|
|
120
327
|
async function runInteractiveInit(cwd, target, options) {
|
|
121
328
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
122
329
|
throw new CliError('Interactive init requires a TTY. Run without --interactive in non-interactive environments.', 2);
|
|
@@ -212,6 +419,7 @@ function normalizeAgentScanOptions(raw) {
|
|
|
212
419
|
mode,
|
|
213
420
|
paths: selectedPaths,
|
|
214
421
|
skills: selectedSkills,
|
|
422
|
+
verbose: raw.securityScanVerbose === true,
|
|
215
423
|
installPolicy: denyInstalls ? 'deny' : 'allow',
|
|
216
424
|
};
|
|
217
425
|
}
|
|
@@ -232,6 +440,37 @@ function shouldRenderBanner(io, format) {
|
|
|
232
440
|
!isTruthyFlag(process.env.CI) &&
|
|
233
441
|
!isTruthyFlag(process.env.SKILL_CHECK_NO_BANNER));
|
|
234
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
|
+
}
|
|
235
474
|
function renderAsciiBanner() {
|
|
236
475
|
const art = [
|
|
237
476
|
' ____ _ _____ _ _ ____ _ _ _____ ____ _ __',
|
|
@@ -287,6 +526,25 @@ async function runValidationPipeline(cwd, config, interactiveUi) {
|
|
|
287
526
|
}
|
|
288
527
|
return context.result;
|
|
289
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
|
+
}
|
|
290
548
|
async function runAgentScanWithFeedback(scanOptions, target, io, format) {
|
|
291
549
|
const useInteractiveFeedback = shouldUseInteractiveUi(io) && format === 'text';
|
|
292
550
|
const invocation = resolveAgentScanInvocation({
|
|
@@ -305,14 +563,16 @@ async function runAgentScanWithFeedback(scanOptions, target, io, format) {
|
|
|
305
563
|
ora().info('Running security scan...');
|
|
306
564
|
}
|
|
307
565
|
try {
|
|
308
|
-
const
|
|
566
|
+
const scanResult = runAgentScan({
|
|
309
567
|
cwd: process.cwd(),
|
|
310
568
|
targetPath: target,
|
|
311
569
|
mode: scanOptions.mode,
|
|
312
570
|
runner: scanOptions.runner,
|
|
313
571
|
paths: scanOptions.paths,
|
|
314
572
|
skills: scanOptions.skills,
|
|
573
|
+
verbose: scanOptions.verbose,
|
|
315
574
|
});
|
|
575
|
+
const exitCode = scanResult.status;
|
|
316
576
|
if (useInteractiveFeedback) {
|
|
317
577
|
if (exitCode === 0) {
|
|
318
578
|
ora().succeed('Security scan completed without blocking findings.');
|
|
@@ -321,6 +581,20 @@ async function runAgentScanWithFeedback(scanOptions, target, io, format) {
|
|
|
321
581
|
ora().warn('Security scan reported findings.');
|
|
322
582
|
}
|
|
323
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
|
+
}
|
|
324
598
|
return exitCode;
|
|
325
599
|
}
|
|
326
600
|
catch (error) {
|
|
@@ -330,7 +604,48 @@ async function runAgentScanWithFeedback(scanOptions, target, io, format) {
|
|
|
330
604
|
throw error;
|
|
331
605
|
}
|
|
332
606
|
}
|
|
607
|
+
function resolveValidationStatus(result) {
|
|
608
|
+
if (result.summary.skillCount === 0)
|
|
609
|
+
return 'SKIPPED';
|
|
610
|
+
if (result.summary.errorCount > 0)
|
|
611
|
+
return 'FAIL';
|
|
612
|
+
if (result.summary.warningCount > 0)
|
|
613
|
+
return 'WARN';
|
|
614
|
+
return 'PASS';
|
|
615
|
+
}
|
|
616
|
+
function resolveSecurityStatus(enabled, scanExitCode, skillCount) {
|
|
617
|
+
if (!enabled)
|
|
618
|
+
return 'SKIPPED';
|
|
619
|
+
if (skillCount === 0)
|
|
620
|
+
return 'SKIPPED';
|
|
621
|
+
return scanExitCode === 0 ? 'PASS' : 'FAIL';
|
|
622
|
+
}
|
|
623
|
+
function computeOverallScore(scores) {
|
|
624
|
+
if (scores.length === 0)
|
|
625
|
+
return null;
|
|
626
|
+
const total = scores.reduce((sum, score) => sum + score.score, 0);
|
|
627
|
+
return Math.round(total / scores.length);
|
|
628
|
+
}
|
|
629
|
+
function countAffectedFiles(diagnostics) {
|
|
630
|
+
return new Set(diagnostics.map((diagnostic) => diagnostic.file)).size;
|
|
631
|
+
}
|
|
632
|
+
function formatSourceRange(range) {
|
|
633
|
+
return `${range.startLine}-${range.endLine}`;
|
|
634
|
+
}
|
|
635
|
+
function toErrorText(error) {
|
|
636
|
+
return error instanceof Error ? error.message : String(error);
|
|
637
|
+
}
|
|
638
|
+
function renderSplitPlanLine(plan, maxBodyLines) {
|
|
639
|
+
if (plan.status === 'noop') {
|
|
640
|
+
return `${pc.cyan('[NOOP]')} ${plan.skillRelativePath} body lines ${plan.beforeLineCount} <= max ${maxBodyLines}\n`;
|
|
641
|
+
}
|
|
642
|
+
if (plan.status === 'blocked') {
|
|
643
|
+
return `${pc.red('[BLOCKED]')} ${plan.skillRelativePath} ${plan.reason ?? 'Cannot split body automatically.'}\n`;
|
|
644
|
+
}
|
|
645
|
+
return `${pc.green('[PLAN]')} ${plan.skillRelativePath} lines ${plan.beforeLineCount} -> ${plan.afterLineCount}\n`;
|
|
646
|
+
}
|
|
333
647
|
export async function runCli(argv, io = defaultIO) {
|
|
648
|
+
const originalArgv = [...argv];
|
|
334
649
|
const program = new Command();
|
|
335
650
|
let finalExitCode = 0;
|
|
336
651
|
let bannerRendered = false;
|
|
@@ -352,182 +667,325 @@ export async function runCli(argv, io = defaultIO) {
|
|
|
352
667
|
.description('Run validation checks')
|
|
353
668
|
.option('--fix', 'Apply safe automatic fixes for supported validation findings')
|
|
354
669
|
.option('--interactive', 'Prompt before applying each fix (use with --fix)')
|
|
670
|
+
.option('--share', 'Render a share card (text format only)')
|
|
671
|
+
.option('--share-out <path>', 'Write share card image file (default: ./skill-check-share.png)')
|
|
355
672
|
.option('--no-open', 'Do not open HTML report in browser')
|
|
356
673
|
.option('--baseline <path>', 'Compare against a previous JSON run')
|
|
357
674
|
.action(async (target, rawOptions) => {
|
|
675
|
+
const checkStartedAt = performance.now();
|
|
676
|
+
const runCommand = buildShareableRunCommand(originalArgv);
|
|
358
677
|
const options = normalizeCliOptions(rawOptions);
|
|
359
678
|
const checkOptions = normalizeCheckCommandOptions(rawOptions);
|
|
360
679
|
const scanOptions = normalizeAgentScanOptions(rawOptions);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
680
|
+
if (checkOptions.fix && target && isGitHubRepoUrl(target)) {
|
|
681
|
+
throw new CliError('Cannot use --fix with a GitHub URL target. Clone locally to apply persistent fixes.', 2);
|
|
682
|
+
}
|
|
683
|
+
await withResolvedTargetFeedback(target, io, async (resolvedTarget) => {
|
|
684
|
+
const cwd = resolvedTarget.isRemote && resolvedTarget.target
|
|
685
|
+
? resolvedTarget.target
|
|
686
|
+
: process.cwd();
|
|
687
|
+
const config = await resolveConfig(cwd, resolvedTarget.target, options);
|
|
688
|
+
if (!checkOptions.share) {
|
|
689
|
+
maybeRenderBanner(config.output.format);
|
|
690
|
+
}
|
|
691
|
+
if (checkOptions.share && config.output.format !== 'text') {
|
|
692
|
+
throw new CliError('--share requires text output format.', 2);
|
|
693
|
+
}
|
|
694
|
+
if (checkOptions.shareOut && !checkOptions.share) {
|
|
695
|
+
throw new CliError('--share-out requires --share.', 2);
|
|
696
|
+
}
|
|
697
|
+
const runValidation = async () => {
|
|
698
|
+
if (checkOptions.share) {
|
|
699
|
+
return runValidationForShare(cwd, config, io);
|
|
700
|
+
}
|
|
701
|
+
return runValidationPipeline(cwd, config, shouldUseInteractiveUi(io));
|
|
702
|
+
};
|
|
703
|
+
let result = await runValidation();
|
|
704
|
+
let fixSummary;
|
|
705
|
+
if (checkOptions.fix) {
|
|
706
|
+
if (checkOptions.interactive && shouldUseInteractiveUi(io)) {
|
|
707
|
+
const { accepted, skipped } = await selectFixableDiagnostics(result);
|
|
708
|
+
if (accepted.length > 0) {
|
|
709
|
+
const filteredResult = {
|
|
710
|
+
...result,
|
|
711
|
+
diagnostics: accepted,
|
|
712
|
+
};
|
|
713
|
+
fixSummary = applyAutoFixes(filteredResult);
|
|
714
|
+
fixSummary.unsupportedDiagnostics +=
|
|
715
|
+
result.diagnostics.length - accepted.length - skipped;
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
fixSummary = {
|
|
719
|
+
requestedDiagnostics: result.diagnostics.length,
|
|
720
|
+
supportedDiagnostics: 0,
|
|
721
|
+
unsupportedDiagnostics: result.diagnostics.length,
|
|
722
|
+
appliedFixes: 0,
|
|
723
|
+
filesUpdated: 0,
|
|
724
|
+
updatedFiles: [],
|
|
725
|
+
};
|
|
726
|
+
}
|
|
376
727
|
}
|
|
377
728
|
else {
|
|
378
|
-
fixSummary =
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
729
|
+
fixSummary = applyAutoFixes(result);
|
|
730
|
+
}
|
|
731
|
+
if (fixSummary.appliedFixes > 0) {
|
|
732
|
+
result = await runValidation();
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
const duplicateDiags = detectDuplicates(result.skills);
|
|
736
|
+
if (duplicateDiags.length > 0) {
|
|
737
|
+
result = {
|
|
738
|
+
...result,
|
|
739
|
+
diagnostics: [...result.diagnostics, ...duplicateDiags],
|
|
740
|
+
summary: {
|
|
741
|
+
...result.summary,
|
|
742
|
+
warningCount: result.summary.warningCount +
|
|
743
|
+
duplicateDiags.filter((d) => d.severity === 'warn')
|
|
744
|
+
.length,
|
|
745
|
+
errorCount: result.summary.errorCount +
|
|
746
|
+
duplicateDiags.filter((d) => d.severity === 'error')
|
|
747
|
+
.length,
|
|
748
|
+
},
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
const scores = computeSkillScores(result.skills, result.diagnostics);
|
|
752
|
+
const format = result.config.output.format;
|
|
753
|
+
let baselineDiff;
|
|
754
|
+
const baselinePath = typeof rawOptions.baseline === 'string'
|
|
755
|
+
? rawOptions.baseline
|
|
756
|
+
: undefined;
|
|
757
|
+
if (baselinePath) {
|
|
758
|
+
const baselineDiags = loadBaseline(path.resolve(process.cwd(), baselinePath));
|
|
759
|
+
baselineDiff = diffBaseline(result.diagnostics, baselineDiags);
|
|
760
|
+
}
|
|
761
|
+
if (format === 'json') {
|
|
762
|
+
const jsonData = toJson(result);
|
|
763
|
+
jsonData.scores = scores;
|
|
764
|
+
if (baselineDiff) {
|
|
765
|
+
jsonData.baseline = {
|
|
766
|
+
new: baselineDiff.newDiagnostics.length,
|
|
767
|
+
fixed: baselineDiff.fixedDiagnostics.length,
|
|
768
|
+
unchanged: baselineDiff.unchanged.length,
|
|
385
769
|
};
|
|
386
770
|
}
|
|
771
|
+
const output = `${JSON.stringify(jsonData, null, 2)}\n`;
|
|
772
|
+
io.stdout(output);
|
|
773
|
+
const written = writeIfRequested(result.config, output);
|
|
774
|
+
if (written)
|
|
775
|
+
io.stdout(`Wrote ${written}\n`);
|
|
776
|
+
}
|
|
777
|
+
else if (format === 'sarif') {
|
|
778
|
+
const output = `${JSON.stringify(toSarif(result), null, 2)}\n`;
|
|
779
|
+
io.stdout(output);
|
|
780
|
+
const written = writeIfRequested(result.config, output);
|
|
781
|
+
if (written)
|
|
782
|
+
io.stdout(`Wrote ${written}\n`);
|
|
783
|
+
}
|
|
784
|
+
else if (format === 'github') {
|
|
785
|
+
const output = toGitHubAnnotations(result);
|
|
786
|
+
if (output)
|
|
787
|
+
io.stdout(output);
|
|
788
|
+
}
|
|
789
|
+
else if (format === 'html') {
|
|
790
|
+
const html = renderHtml(result, scores);
|
|
791
|
+
const reportPath = result.config.output.reportPath ??
|
|
792
|
+
path.join(result.config.cwd, 'skill-check-report.html');
|
|
793
|
+
const parent = path.dirname(reportPath);
|
|
794
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
795
|
+
fs.writeFileSync(reportPath, html);
|
|
796
|
+
const shouldOpen = Boolean(process.stdout.isTTY) &&
|
|
797
|
+
!process.env.CI &&
|
|
798
|
+
rawOptions.noOpen !== true;
|
|
799
|
+
if (shouldOpen) {
|
|
800
|
+
try {
|
|
801
|
+
openInBrowser(reportPath);
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
// ignore open failures
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
io.stdout(`Wrote ${reportPath}\n`);
|
|
387
808
|
}
|
|
388
809
|
else {
|
|
389
|
-
|
|
810
|
+
if (!checkOptions.share) {
|
|
811
|
+
if (fixSummary) {
|
|
812
|
+
io.stdout(renderAutoFixSummary(fixSummary));
|
|
813
|
+
}
|
|
814
|
+
io.stdout(renderText(result, scores, {
|
|
815
|
+
includeConclusion: false,
|
|
816
|
+
}));
|
|
817
|
+
}
|
|
390
818
|
}
|
|
391
|
-
if (
|
|
392
|
-
|
|
819
|
+
if (baselineDiff && (format === 'text' || format === 'html')) {
|
|
820
|
+
io.stdout(`${pc.bold('Baseline:')} ${pc.green(`${baselineDiff.fixedDiagnostics.length} fixed`)} ${pc.red(`${baselineDiff.newDiagnostics.length} new`)} ${pc.dim(`${baselineDiff.unchanged.length} unchanged`)}\n`);
|
|
393
821
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
...result,
|
|
399
|
-
diagnostics: [...result.diagnostics, ...duplicateDiags],
|
|
400
|
-
summary: {
|
|
401
|
-
...result.summary,
|
|
402
|
-
warningCount: result.summary.warningCount +
|
|
403
|
-
duplicateDiags.filter((d) => d.severity === 'warn').length,
|
|
404
|
-
errorCount: result.summary.errorCount +
|
|
405
|
-
duplicateDiags.filter((d) => d.severity === 'error').length,
|
|
406
|
-
},
|
|
407
|
-
};
|
|
408
|
-
}
|
|
409
|
-
const scores = computeSkillScores(result.skills, result.diagnostics);
|
|
410
|
-
const format = result.config.output.format;
|
|
411
|
-
let baselineDiff;
|
|
412
|
-
const baselinePath = typeof rawOptions.baseline === 'string'
|
|
413
|
-
? rawOptions.baseline
|
|
414
|
-
: undefined;
|
|
415
|
-
if (baselinePath) {
|
|
416
|
-
const baselineDiags = loadBaseline(path.resolve(process.cwd(), baselinePath));
|
|
417
|
-
baselineDiff = diffBaseline(result.diagnostics, baselineDiags);
|
|
418
|
-
}
|
|
419
|
-
if (format === 'json') {
|
|
420
|
-
const jsonData = toJson(result);
|
|
421
|
-
jsonData.scores = scores;
|
|
422
|
-
if (baselineDiff) {
|
|
423
|
-
jsonData.baseline = {
|
|
424
|
-
new: baselineDiff.newDiagnostics.length,
|
|
425
|
-
fixed: baselineDiff.fixedDiagnostics.length,
|
|
426
|
-
unchanged: baselineDiff.unchanged.length,
|
|
427
|
-
};
|
|
822
|
+
const validationExitCode = resolveExitCode(result);
|
|
823
|
+
let exitCode = validationExitCode;
|
|
824
|
+
if (format === 'html') {
|
|
825
|
+
io.stdout(`${pc.bold('Validation:')} ${validationExitCode === 0 ? pc.green('PASS') : pc.red('FAIL')}\n`);
|
|
428
826
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const written = writeIfRequested(result.config, output);
|
|
439
|
-
if (written)
|
|
440
|
-
io.stdout(`Wrote ${written}\n`);
|
|
441
|
-
}
|
|
442
|
-
else if (format === 'github') {
|
|
443
|
-
const output = toGitHubAnnotations(result);
|
|
444
|
-
if (output)
|
|
445
|
-
io.stdout(output);
|
|
446
|
-
}
|
|
447
|
-
else if (format === 'html') {
|
|
448
|
-
const html = renderHtml(result, scores);
|
|
449
|
-
const reportPath = result.config.output.reportPath ??
|
|
450
|
-
path.join(result.config.cwd, 'skill-check-report.html');
|
|
451
|
-
const parent = path.dirname(reportPath);
|
|
452
|
-
fs.mkdirSync(parent, { recursive: true });
|
|
453
|
-
fs.writeFileSync(reportPath, html);
|
|
454
|
-
const shouldOpen = Boolean(process.stdout.isTTY) &&
|
|
455
|
-
!process.env.CI &&
|
|
456
|
-
rawOptions.noOpen !== true;
|
|
457
|
-
if (shouldOpen) {
|
|
458
|
-
try {
|
|
459
|
-
openInBrowser(reportPath);
|
|
827
|
+
let scanExitCode;
|
|
828
|
+
if (scanOptions.enabled) {
|
|
829
|
+
if (checkOptions.share) {
|
|
830
|
+
emitShareStatus(io, 'running security scan...');
|
|
831
|
+
}
|
|
832
|
+
const effectiveScanOptions = withInferredSecurityScanSkills(scanOptions, result.skills.map((skill) => skill.filePath));
|
|
833
|
+
scanExitCode = await runAgentScanWithFeedback(effectiveScanOptions, resolvedTarget.target, io, format);
|
|
834
|
+
if (format === 'html') {
|
|
835
|
+
io.stdout(`${pc.bold('Security scan:')} ${scanExitCode === 0 ? pc.green('PASS') : pc.red('FAIL')}\n`);
|
|
460
836
|
}
|
|
461
|
-
|
|
462
|
-
|
|
837
|
+
if (scanExitCode !== 0) {
|
|
838
|
+
exitCode = 1;
|
|
463
839
|
}
|
|
464
840
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
else {
|
|
468
|
-
if (fixSummary) {
|
|
469
|
-
io.stdout(renderAutoFixSummary(fixSummary));
|
|
841
|
+
else if (format === 'html') {
|
|
842
|
+
io.stdout(`${pc.bold('Security scan:')} ${pc.yellow('SKIPPED')}\n`);
|
|
470
843
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
844
|
+
else if (checkOptions.share) {
|
|
845
|
+
emitShareStatus(io, 'security scan skipped.');
|
|
846
|
+
}
|
|
847
|
+
if (format === 'text') {
|
|
848
|
+
const conclusion = renderConclusionCard({
|
|
849
|
+
skillCount: result.summary.skillCount,
|
|
850
|
+
errorCount: result.summary.errorCount,
|
|
851
|
+
warningCount: result.summary.warningCount,
|
|
852
|
+
affectedFileCount: countAffectedFiles(result.diagnostics),
|
|
853
|
+
overallScore: computeOverallScore(scores),
|
|
854
|
+
validationStatus: resolveValidationStatus(result),
|
|
855
|
+
securityStatus: resolveSecurityStatus(scanOptions.enabled, scanExitCode, result.summary.skillCount),
|
|
856
|
+
elapsedMs: performance.now() - checkStartedAt,
|
|
857
|
+
runCommand,
|
|
858
|
+
mode: checkOptions.share ? 'share' : 'default',
|
|
859
|
+
});
|
|
860
|
+
io.stdout(`${conclusion.card}\n`);
|
|
861
|
+
if (conclusion.fullCommandPlain) {
|
|
862
|
+
io.stdout(`${conclusion.fullCommandPlain}\n`);
|
|
863
|
+
}
|
|
864
|
+
if (checkOptions.share && result.summary.skillCount > 0) {
|
|
865
|
+
emitShareStatus(io, 'rendering share image...');
|
|
866
|
+
const shareOutputPath = path.resolve(process.cwd(), checkOptions.shareOut ?? 'skill-check-share.png');
|
|
867
|
+
const shareText = conclusion.fullCommandPlain
|
|
868
|
+
? `${conclusion.card}\n${conclusion.fullCommandPlain}`
|
|
869
|
+
: conclusion.card;
|
|
870
|
+
const writtenPath = writeShareImage(shareText, shareOutputPath);
|
|
871
|
+
io.stdout(`${pc.bold('Share image:')} ${writtenPath}\n`);
|
|
872
|
+
emitShareStatus(io, `share image written: ${writtenPath}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
finalExitCode = exitCode;
|
|
876
|
+
});
|
|
877
|
+
})));
|
|
878
|
+
program
|
|
879
|
+
.command('split-body [target]')
|
|
880
|
+
.description('Preview or apply section-based body split into references/*.md files')
|
|
881
|
+
.option('--write', 'Apply split changes to files')
|
|
882
|
+
.option('--config <path>', 'Path to skill-check config file')
|
|
883
|
+
.option('--max-body-lines <n>', 'Override max body lines', parseNumber)
|
|
884
|
+
.option('--include <glob>', 'Additional include glob(s)', collectList, [])
|
|
885
|
+
.option('--exclude <glob>', 'Additional exclude glob(s)', collectList, [])
|
|
886
|
+
.action(async (target, rawOptions) => {
|
|
887
|
+
assertLocalOnlyTarget(target, 'split-body');
|
|
888
|
+
const splitOptions = normalizeSplitBodyCommandOptions(rawOptions);
|
|
889
|
+
const options = normalizeCliOptions(rawOptions);
|
|
890
|
+
if (options.format) {
|
|
891
|
+
throw new CliError('split-body does not support --format. It always prints text output.', 2);
|
|
480
892
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
893
|
+
const config = await resolveConfig(process.cwd(), target, options);
|
|
894
|
+
const skillFiles = await discoverSkillFiles(config);
|
|
895
|
+
const skills = skillFiles.map((filePath) => buildSkillArtifact(filePath, config.cwd));
|
|
896
|
+
const plans = skills.map((skill) => planBodySplit(skill, config.limits.maxBodyLines));
|
|
897
|
+
io.stdout(`${pc.bold('split-body')} ${pc.dim(splitOptions.write ? 'apply' : 'preview')}\n`);
|
|
898
|
+
io.stdout(`${pc.dim('max-body-lines:')} ${config.limits.maxBodyLines}\n`);
|
|
899
|
+
io.stdout(`${pc.dim('skills evaluated:')} ${skills.length}\n\n`);
|
|
900
|
+
let plannedCount = 0;
|
|
901
|
+
let blockedCount = 0;
|
|
902
|
+
let noopCount = 0;
|
|
903
|
+
let writtenSkillCount = 0;
|
|
904
|
+
let writtenReferenceCount = 0;
|
|
905
|
+
for (const plan of plans) {
|
|
906
|
+
io.stdout(renderSplitPlanLine(plan, config.limits.maxBodyLines));
|
|
907
|
+
if (plan.status === 'blocked') {
|
|
908
|
+
blockedCount += 1;
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
if (plan.status === 'noop') {
|
|
912
|
+
noopCount += 1;
|
|
913
|
+
continue;
|
|
485
914
|
}
|
|
486
|
-
|
|
487
|
-
|
|
915
|
+
plannedCount += 1;
|
|
916
|
+
for (const reference of plan.referencesToCreate) {
|
|
917
|
+
const details = `(${reference.relativePath}, source lines ${formatSourceRange(reference.sourceLineRange)})`;
|
|
918
|
+
if (splitOptions.write) {
|
|
919
|
+
io.stdout(` ${pc.green('create')} ${details}\n`);
|
|
920
|
+
}
|
|
921
|
+
else {
|
|
922
|
+
io.stdout(` ${pc.dim('would create')} ${details}\n`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (splitOptions.write) {
|
|
926
|
+
try {
|
|
927
|
+
const applied = applyBodySplitPlan(plan);
|
|
928
|
+
if (applied.updatedSkill) {
|
|
929
|
+
writtenSkillCount += 1;
|
|
930
|
+
}
|
|
931
|
+
writtenReferenceCount += applied.writtenReferenceFiles.length;
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
throw new CliError(`Failed to write split-body output for ${plan.skillRelativePath}: ${toErrorText(error)}`, 1);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (plan.afterLineCount > config.limits.maxBodyLines) {
|
|
938
|
+
io.stdout(`${pc.yellow('[WARN]')} ${plan.skillRelativePath} still exceeds limit after split. Refine with docs/skills/split-into-references/SKILL.md.\n`);
|
|
488
939
|
}
|
|
489
940
|
}
|
|
490
|
-
|
|
491
|
-
|
|
941
|
+
io.stdout('\n');
|
|
942
|
+
io.stdout(`${pc.bold('Summary:')} planned=${plannedCount} blocked=${blockedCount} noop=${noopCount}\n`);
|
|
943
|
+
if (splitOptions.write) {
|
|
944
|
+
io.stdout(`${pc.bold('Writes:')} updated_skills=${writtenSkillCount} created_references=${writtenReferenceCount}\n`);
|
|
492
945
|
}
|
|
493
|
-
finalExitCode =
|
|
494
|
-
})
|
|
946
|
+
finalExitCode = blockedCount > 0 ? 2 : 0;
|
|
947
|
+
});
|
|
495
948
|
addSharedOptions(program
|
|
496
949
|
.command('report [target]')
|
|
497
950
|
.description('Generate markdown health report')
|
|
498
951
|
.option('--no-open', 'Do not open HTML report in browser')
|
|
499
952
|
.action(async (target, rawOptions) => {
|
|
500
953
|
const options = normalizeCliOptions(rawOptions);
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
954
|
+
await withResolvedTargetFeedback(target, io, async (resolvedTarget) => {
|
|
955
|
+
const cwd = resolvedTarget.isRemote && resolvedTarget.target
|
|
956
|
+
? resolvedTarget.target
|
|
957
|
+
: process.cwd();
|
|
958
|
+
const result = await analyze(cwd, resolvedTarget.target, options);
|
|
959
|
+
const format = result.config.output.format;
|
|
960
|
+
if (format === 'html') {
|
|
961
|
+
const html = renderHtml(result);
|
|
962
|
+
const reportPath = result.config.output.reportPath ??
|
|
963
|
+
path.join(result.config.cwd, 'skill-check-report.html');
|
|
964
|
+
const parent = path.dirname(reportPath);
|
|
965
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
966
|
+
fs.writeFileSync(reportPath, html);
|
|
967
|
+
const shouldOpen = Boolean(process.stdout.isTTY) &&
|
|
968
|
+
!process.env.CI &&
|
|
969
|
+
rawOptions.noOpen !== true;
|
|
970
|
+
if (shouldOpen) {
|
|
971
|
+
try {
|
|
972
|
+
openInBrowser(reportPath);
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
// ignore open failures
|
|
976
|
+
}
|
|
519
977
|
}
|
|
978
|
+
io.stdout(`Wrote ${reportPath}\n`);
|
|
520
979
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
finalExitCode = resolveExitCode(result);
|
|
980
|
+
else {
|
|
981
|
+
const markdown = renderMarkdownReport(result);
|
|
982
|
+
const written = writeIfRequested(result.config, markdown);
|
|
983
|
+
io.stdout(markdown);
|
|
984
|
+
if (written)
|
|
985
|
+
io.stdout(`Wrote ${written}\n`);
|
|
986
|
+
}
|
|
987
|
+
finalExitCode = resolveExitCode(result);
|
|
988
|
+
});
|
|
531
989
|
}));
|
|
532
990
|
addAgentScanOptions(program
|
|
533
991
|
.command('security-scan [target]')
|
|
@@ -538,8 +996,16 @@ export async function runCli(argv, io = defaultIO) {
|
|
|
538
996
|
securityScan: true,
|
|
539
997
|
});
|
|
540
998
|
maybeRenderBanner('text');
|
|
541
|
-
|
|
542
|
-
|
|
999
|
+
await withResolvedTargetFeedback(target, io, async (resolvedTarget) => {
|
|
1000
|
+
const scanCwd = resolvedTarget.isRemote && resolvedTarget.target
|
|
1001
|
+
? resolvedTarget.target
|
|
1002
|
+
: process.cwd();
|
|
1003
|
+
const discoveryConfig = await resolveConfig(scanCwd, resolvedTarget.target, {});
|
|
1004
|
+
const discoveredSkillFiles = await discoverSkillFiles(discoveryConfig);
|
|
1005
|
+
const effectiveScanOptions = withInferredSecurityScanSkills(scanOptions, discoveredSkillFiles);
|
|
1006
|
+
const scanExitCode = await runAgentScanWithFeedback(effectiveScanOptions, resolvedTarget.target, io, 'text');
|
|
1007
|
+
finalExitCode = scanExitCode === 0 ? 0 : 1;
|
|
1008
|
+
});
|
|
543
1009
|
}));
|
|
544
1010
|
program
|
|
545
1011
|
.command('rules [ruleId]')
|
|
@@ -611,6 +1077,8 @@ export async function runCli(argv, io = defaultIO) {
|
|
|
611
1077
|
.command('diff <pathA> <pathB>')
|
|
612
1078
|
.description('Compare diagnostics between two skill directories')
|
|
613
1079
|
.action(async (pathA, pathB) => {
|
|
1080
|
+
assertLocalOnlyTarget(pathA, 'diff');
|
|
1081
|
+
assertLocalOnlyTarget(pathB, 'diff');
|
|
614
1082
|
const resultA = await analyze(process.cwd(), pathA, {});
|
|
615
1083
|
const resultB = await analyze(process.cwd(), pathB, {});
|
|
616
1084
|
const keyFn = (d) => `${d.ruleId}|${d.message}`;
|
|
@@ -641,15 +1109,19 @@ export async function runCli(argv, io = defaultIO) {
|
|
|
641
1109
|
.command('watch [target]')
|
|
642
1110
|
.description('Watch for changes and re-run validation')
|
|
643
1111
|
.action(async (target, rawOptions) => {
|
|
1112
|
+
assertLocalOnlyTarget(target, 'watch');
|
|
644
1113
|
const options = normalizeCliOptions(rawOptions);
|
|
645
1114
|
const config = await resolveConfig(process.cwd(), target, options);
|
|
646
1115
|
const watchDirs = config.rootsAbs.length > 0 ? config.rootsAbs : [config.cwd];
|
|
647
1116
|
io.stdout(`${pc.cyan('Watching')} ${watchDirs.join(', ')} for changes...\n`);
|
|
648
1117
|
const runCheck = async () => {
|
|
649
1118
|
try {
|
|
1119
|
+
const startedAt = performance.now();
|
|
650
1120
|
const result = await analyzeWithConfig(process.cwd(), config);
|
|
651
1121
|
const scores = computeSkillScores(result.skills, result.diagnostics);
|
|
652
|
-
io.stdout(`\n${renderText(result, scores
|
|
1122
|
+
io.stdout(`\n${renderText(result, scores, {
|
|
1123
|
+
elapsedMs: performance.now() - startedAt,
|
|
1124
|
+
})}`);
|
|
653
1125
|
}
|
|
654
1126
|
catch (err) {
|
|
655
1127
|
io.stderr(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|