skill-check 0.1.1 → 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/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 argv;
132
+ return ['check', '.'];
107
133
  }
108
134
  const [first] = argv;
109
- if (!first || first.startsWith('-') || ROOT_COMMANDS.has(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 exitCode = runAgentScan({
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,44 @@ async function runAgentScanWithFeedback(scanOptions, target, io, format) {
330
604
  throw error;
331
605
  }
332
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
+ }
333
643
  export async function runCli(argv, io = defaultIO) {
644
+ const originalArgv = [...argv];
334
645
  const program = new Command();
335
646
  let finalExitCode = 0;
336
647
  let bannerRendered = false;
@@ -352,182 +663,325 @@ export async function runCli(argv, io = defaultIO) {
352
663
  .description('Run validation checks')
353
664
  .option('--fix', 'Apply safe automatic fixes for supported validation findings')
354
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)')
355
668
  .option('--no-open', 'Do not open HTML report in browser')
356
669
  .option('--baseline <path>', 'Compare against a previous JSON run')
357
670
  .action(async (target, rawOptions) => {
671
+ const checkStartedAt = performance.now();
672
+ const runCommand = buildShareableRunCommand(originalArgv);
358
673
  const options = normalizeCliOptions(rawOptions);
359
674
  const checkOptions = normalizeCheckCommandOptions(rawOptions);
360
675
  const scanOptions = normalizeAgentScanOptions(rawOptions);
361
- const config = await resolveConfig(process.cwd(), target, options);
362
- maybeRenderBanner(config.output.format);
363
- let result = await runValidationPipeline(process.cwd(), config, shouldUseInteractiveUi(io));
364
- let fixSummary;
365
- if (checkOptions.fix) {
366
- if (checkOptions.interactive && shouldUseInteractiveUi(io)) {
367
- const { accepted, skipped } = await selectFixableDiagnostics(result);
368
- if (accepted.length > 0) {
369
- const filteredResult = {
370
- ...result,
371
- diagnostics: accepted,
372
- };
373
- fixSummary = applyAutoFixes(filteredResult);
374
- fixSummary.unsupportedDiagnostics +=
375
- result.diagnostics.length - accepted.length - skipped;
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
+ }
376
723
  }
377
724
  else {
378
- fixSummary = {
379
- requestedDiagnostics: result.diagnostics.length,
380
- supportedDiagnostics: 0,
381
- unsupportedDiagnostics: result.diagnostics.length,
382
- appliedFixes: 0,
383
- filesUpdated: 0,
384
- updatedFiles: [],
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,
385
765
  };
386
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`);
387
804
  }
388
805
  else {
389
- fixSummary = applyAutoFixes(result);
806
+ if (!checkOptions.share) {
807
+ if (fixSummary) {
808
+ io.stdout(renderAutoFixSummary(fixSummary));
809
+ }
810
+ io.stdout(renderText(result, scores, {
811
+ includeConclusion: false,
812
+ }));
813
+ }
390
814
  }
391
- if (fixSummary.appliedFixes > 0) {
392
- result = await runValidationPipeline(process.cwd(), config, shouldUseInteractiveUi(io));
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`);
393
817
  }
394
- }
395
- const duplicateDiags = detectDuplicates(result.skills);
396
- if (duplicateDiags.length > 0) {
397
- result = {
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
- };
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`);
428
822
  }
429
- const output = `${JSON.stringify(jsonData, null, 2)}\n`;
430
- io.stdout(output);
431
- const written = writeIfRequested(result.config, output);
432
- if (written)
433
- io.stdout(`Wrote ${written}\n`);
434
- }
435
- else if (format === 'sarif') {
436
- const output = `${JSON.stringify(toSarif(result), null, 2)}\n`;
437
- io.stdout(output);
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);
823
+ let scanExitCode;
824
+ if (scanOptions.enabled) {
825
+ if (checkOptions.share) {
826
+ emitShareStatus(io, 'running security scan...');
827
+ }
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`);
460
832
  }
461
- catch {
462
- // ignore open failures
833
+ if (scanExitCode !== 0) {
834
+ exitCode = 1;
463
835
  }
464
836
  }
465
- io.stdout(`Wrote ${reportPath}\n`);
466
- }
467
- else {
468
- if (fixSummary) {
469
- io.stdout(renderAutoFixSummary(fixSummary));
837
+ else if (format === 'html') {
838
+ io.stdout(`${pc.bold('Security scan:')} ${pc.yellow('SKIPPED')}\n`);
470
839
  }
471
- io.stdout(renderText(result, scores));
472
- }
473
- if (baselineDiff && (format === 'text' || format === 'html')) {
474
- io.stdout(`${pc.bold('Baseline:')} ${pc.green(`${baselineDiff.fixedDiagnostics.length} fixed`)} ${pc.red(`${baselineDiff.newDiagnostics.length} new`)} ${pc.dim(`${baselineDiff.unchanged.length} unchanged`)}\n`);
475
- }
476
- const validationExitCode = resolveExitCode(result);
477
- let exitCode = validationExitCode;
478
- if (format === 'text' || format === 'html') {
479
- io.stdout(`${pc.bold('Validation:')} ${validationExitCode === 0 ? pc.green('PASS') : pc.red('FAIL')}\n`);
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);
480
888
  }
481
- if (scanOptions.enabled) {
482
- const scanExitCode = await runAgentScanWithFeedback(scanOptions, target, io, format);
483
- if (format === 'text' || format === 'html') {
484
- io.stdout(`${pc.bold('Security scan:')} ${scanExitCode === 0 ? pc.green('PASS') : pc.red('FAIL')}\n`);
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;
485
910
  }
486
- if (scanExitCode !== 0) {
487
- exitCode = 1;
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
+ }
932
+ }
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`);
488
935
  }
489
936
  }
490
- else if (format === 'text' || format === 'html') {
491
- io.stdout(`${pc.bold('Security scan:')} ${pc.yellow('SKIPPED')}\n`);
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`);
492
941
  }
493
- finalExitCode = exitCode;
494
- })));
942
+ finalExitCode = blockedCount > 0 ? 2 : 0;
943
+ });
495
944
  addSharedOptions(program
496
945
  .command('report [target]')
497
946
  .description('Generate markdown health report')
498
947
  .option('--no-open', 'Do not open HTML report in browser')
499
948
  .action(async (target, rawOptions) => {
500
949
  const options = normalizeCliOptions(rawOptions);
501
- const result = await analyze(process.cwd(), target, options);
502
- const format = result.config.output.format;
503
- if (format === 'html') {
504
- const html = renderHtml(result);
505
- const reportPath = result.config.output.reportPath ??
506
- path.join(result.config.cwd, 'skill-check-report.html');
507
- const parent = path.dirname(reportPath);
508
- fs.mkdirSync(parent, { recursive: true });
509
- fs.writeFileSync(reportPath, html);
510
- const shouldOpen = Boolean(process.stdout.isTTY) &&
511
- !process.env.CI &&
512
- rawOptions.noOpen !== true;
513
- if (shouldOpen) {
514
- try {
515
- openInBrowser(reportPath);
516
- }
517
- catch {
518
- // ignore open failures
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
+ }
519
973
  }
974
+ io.stdout(`Wrote ${reportPath}\n`);
520
975
  }
521
- io.stdout(`Wrote ${reportPath}\n`);
522
- }
523
- else {
524
- const markdown = renderMarkdownReport(result);
525
- const written = writeIfRequested(result.config, markdown);
526
- io.stdout(markdown);
527
- if (written)
528
- io.stdout(`Wrote ${written}\n`);
529
- }
530
- 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
+ });
531
985
  }));
532
986
  addAgentScanOptions(program
533
987
  .command('security-scan [target]')
@@ -538,8 +992,16 @@ export async function runCli(argv, io = defaultIO) {
538
992
  securityScan: true,
539
993
  });
540
994
  maybeRenderBanner('text');
541
- const scanExitCode = await runAgentScanWithFeedback(scanOptions, target, io, 'text');
542
- finalExitCode = scanExitCode === 0 ? 0 : 1;
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
+ });
543
1005
  }));
544
1006
  program
545
1007
  .command('rules [ruleId]')
@@ -611,6 +1073,8 @@ export async function runCli(argv, io = defaultIO) {
611
1073
  .command('diff <pathA> <pathB>')
612
1074
  .description('Compare diagnostics between two skill directories')
613
1075
  .action(async (pathA, pathB) => {
1076
+ assertLocalOnlyTarget(pathA, 'diff');
1077
+ assertLocalOnlyTarget(pathB, 'diff');
614
1078
  const resultA = await analyze(process.cwd(), pathA, {});
615
1079
  const resultB = await analyze(process.cwd(), pathB, {});
616
1080
  const keyFn = (d) => `${d.ruleId}|${d.message}`;
@@ -641,15 +1105,19 @@ export async function runCli(argv, io = defaultIO) {
641
1105
  .command('watch [target]')
642
1106
  .description('Watch for changes and re-run validation')
643
1107
  .action(async (target, rawOptions) => {
1108
+ assertLocalOnlyTarget(target, 'watch');
644
1109
  const options = normalizeCliOptions(rawOptions);
645
1110
  const config = await resolveConfig(process.cwd(), target, options);
646
1111
  const watchDirs = config.rootsAbs.length > 0 ? config.rootsAbs : [config.cwd];
647
1112
  io.stdout(`${pc.cyan('Watching')} ${watchDirs.join(', ')} for changes...\n`);
648
1113
  const runCheck = async () => {
649
1114
  try {
1115
+ const startedAt = performance.now();
650
1116
  const result = await analyzeWithConfig(process.cwd(), config);
651
1117
  const scores = computeSkillScores(result.skills, result.diagnostics);
652
- io.stdout(`\n${renderText(result, scores)}`);
1118
+ io.stdout(`\n${renderText(result, scores, {
1119
+ elapsedMs: performance.now() - startedAt,
1120
+ })}`);
653
1121
  }
654
1122
  catch (err) {
655
1123
  io.stderr(`Error: ${err instanceof Error ? err.message : String(err)}\n`);