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
|
@@ -7,13 +7,29 @@ export interface AgentScanOptions {
|
|
|
7
7
|
paths?: string[];
|
|
8
8
|
skills?: string[];
|
|
9
9
|
runner?: AgentScanRunner;
|
|
10
|
+
verbose?: boolean;
|
|
10
11
|
}
|
|
11
12
|
export interface AgentScanInvocation {
|
|
12
13
|
command: string;
|
|
13
14
|
args: string[];
|
|
14
15
|
}
|
|
16
|
+
export interface AgentScanRunResult {
|
|
17
|
+
status: number;
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
invocation: AgentScanInvocation;
|
|
21
|
+
}
|
|
22
|
+
export interface AgentScanOutputSummary {
|
|
23
|
+
scannedTarget?: string;
|
|
24
|
+
skillsFound?: number;
|
|
25
|
+
findingsTotal: number;
|
|
26
|
+
findingsByCode: Record<string, number>;
|
|
27
|
+
noSkillsOrServers: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function deriveAgentScanSkillRoots(skillFilePaths: string[]): string[];
|
|
15
30
|
export declare function isValidAgentScanRunner(value: string): value is AgentScanRunner;
|
|
16
31
|
export declare function isCommandAvailable(commandName: string): boolean;
|
|
17
32
|
export declare function resolveAgentScanInvocation(options: AgentScanOptions, hasCommand?: (commandName: string) => boolean): AgentScanInvocation;
|
|
18
|
-
export declare function
|
|
33
|
+
export declare function summarizeAgentScanOutput(rawOutput: string): AgentScanOutputSummary;
|
|
34
|
+
export declare function runAgentScan(options: AgentScanOptions): AgentScanRunResult;
|
|
19
35
|
export {};
|
package/dist/core/agent-scan.js
CHANGED
|
@@ -3,6 +3,26 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { CliError } from './errors.js';
|
|
5
5
|
const VALID_RUNNERS = ['auto', 'local', 'uvx', 'pipx'];
|
|
6
|
+
export function deriveAgentScanSkillRoots(skillFilePaths) {
|
|
7
|
+
const rootCounts = new Map();
|
|
8
|
+
for (const skillFilePath of skillFilePaths) {
|
|
9
|
+
const absoluteSkillFilePath = path.resolve(skillFilePath);
|
|
10
|
+
const skillDir = path.dirname(absoluteSkillFilePath);
|
|
11
|
+
const maybeSkillsRoot = path.dirname(skillDir);
|
|
12
|
+
const selectedRoot = path.basename(maybeSkillsRoot).toLowerCase() === 'skills'
|
|
13
|
+
? maybeSkillsRoot
|
|
14
|
+
: skillDir;
|
|
15
|
+
rootCounts.set(selectedRoot, (rootCounts.get(selectedRoot) ?? 0) + 1);
|
|
16
|
+
}
|
|
17
|
+
return Array.from(rootCounts.entries())
|
|
18
|
+
.sort((a, b) => {
|
|
19
|
+
if (b[1] !== a[1]) {
|
|
20
|
+
return b[1] - a[1];
|
|
21
|
+
}
|
|
22
|
+
return a[0].localeCompare(b[0]);
|
|
23
|
+
})
|
|
24
|
+
.map(([root]) => root);
|
|
25
|
+
}
|
|
6
26
|
function normalizePaths(cwd, values, fallback) {
|
|
7
27
|
const list = values && values.length > 0 ? values : fallback;
|
|
8
28
|
return list.map((value) => path.resolve(cwd, value));
|
|
@@ -69,11 +89,35 @@ export function resolveAgentScanInvocation(options, hasCommand = isCommandAvaila
|
|
|
69
89
|
}
|
|
70
90
|
throw new CliError('No security scan runner found. Install mcp-scan, uv, or pipx.', 2);
|
|
71
91
|
}
|
|
92
|
+
export function summarizeAgentScanOutput(rawOutput) {
|
|
93
|
+
const output = rawOutput.replace(/\r/g, '');
|
|
94
|
+
const scanMatch = output.match(/●\s+Scanning\s+(.+?)\s+found\s+(\d+)\s+skills?/i);
|
|
95
|
+
const codeMatches = Array.from(output.matchAll(/\[([A-Z]\d{3})\]/g)).map((match) => match[1]);
|
|
96
|
+
const findingsByCode = {};
|
|
97
|
+
for (const code of codeMatches) {
|
|
98
|
+
if (!code)
|
|
99
|
+
continue;
|
|
100
|
+
findingsByCode[code] = (findingsByCode[code] ?? 0) + 1;
|
|
101
|
+
}
|
|
102
|
+
const findingsTotal = Object.values(findingsByCode).reduce((sum, count) => sum + count, 0);
|
|
103
|
+
return {
|
|
104
|
+
scannedTarget: scanMatch?.[1]?.trim(),
|
|
105
|
+
skillsFound: scanMatch?.[2] ? Number.parseInt(scanMatch[2], 10) : undefined,
|
|
106
|
+
findingsTotal,
|
|
107
|
+
findingsByCode,
|
|
108
|
+
noSkillsOrServers: /no servers or skills found/i.test(output),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
72
111
|
export function runAgentScan(options) {
|
|
73
112
|
const invocation = resolveAgentScanInvocation(options);
|
|
74
|
-
const result =
|
|
75
|
-
|
|
76
|
-
|
|
113
|
+
const result = options.verbose
|
|
114
|
+
? spawnSync(invocation.command, invocation.args, {
|
|
115
|
+
stdio: 'inherit',
|
|
116
|
+
})
|
|
117
|
+
: spawnSync(invocation.command, invocation.args, {
|
|
118
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
119
|
+
encoding: 'utf8',
|
|
120
|
+
});
|
|
77
121
|
const error = result.error;
|
|
78
122
|
if (error?.code === 'ENOENT') {
|
|
79
123
|
throw new CliError(`Runner not found: ${invocation.command}`, 2);
|
|
@@ -84,5 +128,10 @@ export function runAgentScan(options) {
|
|
|
84
128
|
if (typeof result.status !== 'number') {
|
|
85
129
|
throw new CliError('Security scan process did not return an exit code.', 2);
|
|
86
130
|
}
|
|
87
|
-
return
|
|
131
|
+
return {
|
|
132
|
+
status: result.status,
|
|
133
|
+
stdout: typeof result.stdout === 'string' ? result.stdout : '',
|
|
134
|
+
stderr: typeof result.stderr === 'string' ? result.stderr : '',
|
|
135
|
+
invocation,
|
|
136
|
+
};
|
|
88
137
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SkillArtifact } from '../types.js';
|
|
2
|
+
export interface SourceLineRange {
|
|
3
|
+
startLine: number;
|
|
4
|
+
endLine: number;
|
|
5
|
+
}
|
|
6
|
+
export interface SplitReferencePlan {
|
|
7
|
+
relativePath: string;
|
|
8
|
+
title: string;
|
|
9
|
+
content: string;
|
|
10
|
+
sourceLineRange: SourceLineRange;
|
|
11
|
+
}
|
|
12
|
+
export interface SplitPlan {
|
|
13
|
+
skillFilePath: string;
|
|
14
|
+
skillRelativePath: string;
|
|
15
|
+
beforeLineCount: number;
|
|
16
|
+
afterLineCount: number;
|
|
17
|
+
referencesToCreate: SplitReferencePlan[];
|
|
18
|
+
newSkillBody: string;
|
|
19
|
+
newSkillContent: string;
|
|
20
|
+
status: 'noop' | 'planned' | 'blocked';
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
interface PlanBodySplitDeps {
|
|
24
|
+
pathExists?: (targetPath: string) => boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function planBodySplit(skill: SkillArtifact, maxBodyLines: number, deps?: PlanBodySplitDeps): SplitPlan;
|
|
27
|
+
export declare function applyBodySplitPlan(plan: SplitPlan): {
|
|
28
|
+
updatedSkill: boolean;
|
|
29
|
+
writtenReferenceFiles: string[];
|
|
30
|
+
};
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
4
|
+
const H2_RE = /^##\s+(.+?)\s*$/;
|
|
5
|
+
function countLines(text) {
|
|
6
|
+
return text.split(/\r?\n/).length;
|
|
7
|
+
}
|
|
8
|
+
function trimEmptyEdges(lines) {
|
|
9
|
+
let start = 0;
|
|
10
|
+
while (start < lines.length && lines[start]?.trim() === '') {
|
|
11
|
+
start += 1;
|
|
12
|
+
}
|
|
13
|
+
let end = lines.length - 1;
|
|
14
|
+
while (end >= start && lines[end]?.trim() === '') {
|
|
15
|
+
end -= 1;
|
|
16
|
+
}
|
|
17
|
+
return lines.slice(start, end + 1);
|
|
18
|
+
}
|
|
19
|
+
function toSlug(input) {
|
|
20
|
+
const normalized = input
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[`*_~]/g, '')
|
|
23
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
24
|
+
.replace(/^-+|-+$/g, '');
|
|
25
|
+
return normalized || 'section';
|
|
26
|
+
}
|
|
27
|
+
function normalizeLineEnding(content) {
|
|
28
|
+
return content.includes('\r\n') ? '\r\n' : '\n';
|
|
29
|
+
}
|
|
30
|
+
function parseSections(body) {
|
|
31
|
+
const lines = body.split(/\r?\n/);
|
|
32
|
+
const indexes = [];
|
|
33
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
34
|
+
if (H2_RE.test(lines[i] ?? '')) {
|
|
35
|
+
indexes.push(i);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const sections = [];
|
|
39
|
+
for (let i = 0; i < indexes.length; i += 1) {
|
|
40
|
+
const startIndex = indexes[i] ?? 0;
|
|
41
|
+
const endIndex = (indexes[i + 1] ?? lines.length) - 1;
|
|
42
|
+
const heading = lines[startIndex] ?? '';
|
|
43
|
+
const title = heading.replace(H2_RE, '$1').trim();
|
|
44
|
+
sections.push({ title: title || 'Section', startIndex, endIndex });
|
|
45
|
+
}
|
|
46
|
+
return sections;
|
|
47
|
+
}
|
|
48
|
+
function buildReferenceContent(title, sectionLines, lineEnding) {
|
|
49
|
+
const trimmed = trimEmptyEdges(sectionLines);
|
|
50
|
+
if (trimmed.length === 0) {
|
|
51
|
+
return `# ${title}${lineEnding}`;
|
|
52
|
+
}
|
|
53
|
+
return `# ${title}${lineEnding}${lineEnding}${trimmed.join(lineEnding)}${lineEnding}`;
|
|
54
|
+
}
|
|
55
|
+
function buildNewBody(introLines, references, lineEnding) {
|
|
56
|
+
const blocks = [];
|
|
57
|
+
const intro = trimEmptyEdges(introLines).join(lineEnding);
|
|
58
|
+
if (intro) {
|
|
59
|
+
blocks.push(intro);
|
|
60
|
+
}
|
|
61
|
+
const referenceLines = [
|
|
62
|
+
'## References',
|
|
63
|
+
'',
|
|
64
|
+
'This skill content is modularized into reference docs for readability.',
|
|
65
|
+
'',
|
|
66
|
+
...references.map((entry) => `- [${entry.title}](${entry.relativePath}) - extracted from SKILL body`),
|
|
67
|
+
];
|
|
68
|
+
blocks.push(referenceLines.join(lineEnding));
|
|
69
|
+
return `${blocks.join(`${lineEnding}${lineEnding}`)}${lineEnding}`;
|
|
70
|
+
}
|
|
71
|
+
function replaceBody(content, newBody) {
|
|
72
|
+
const match = content.match(FRONTMATTER_RE);
|
|
73
|
+
if (!match) {
|
|
74
|
+
return newBody;
|
|
75
|
+
}
|
|
76
|
+
return `${match[0]}${newBody}`;
|
|
77
|
+
}
|
|
78
|
+
function resolveBodyStartLine(content) {
|
|
79
|
+
const match = content.match(FRONTMATTER_RE);
|
|
80
|
+
if (!match)
|
|
81
|
+
return 1;
|
|
82
|
+
return match[0].split(/\r?\n/).length;
|
|
83
|
+
}
|
|
84
|
+
function allocateReferencePath(skillDir, baseSlug, usedPaths, pathExists) {
|
|
85
|
+
let index = 1;
|
|
86
|
+
while (true) {
|
|
87
|
+
const suffix = index === 1 ? '' : `-${index}`;
|
|
88
|
+
const relativePath = path.posix.join('references', `${baseSlug}${suffix}.md`);
|
|
89
|
+
if (!usedPaths.has(relativePath)) {
|
|
90
|
+
const absolutePath = path.join(skillDir, ...relativePath.split('/'));
|
|
91
|
+
if (!pathExists(absolutePath)) {
|
|
92
|
+
usedPaths.add(relativePath);
|
|
93
|
+
return relativePath;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
index += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
export function planBodySplit(skill, maxBodyLines, deps = {}) {
|
|
100
|
+
const beforeLineCount = countLines(skill.body);
|
|
101
|
+
const lineEnding = normalizeLineEnding(skill.content);
|
|
102
|
+
const noopResult = {
|
|
103
|
+
skillFilePath: skill.filePath,
|
|
104
|
+
skillRelativePath: skill.relativePath,
|
|
105
|
+
beforeLineCount,
|
|
106
|
+
afterLineCount: beforeLineCount,
|
|
107
|
+
referencesToCreate: [],
|
|
108
|
+
newSkillBody: skill.body,
|
|
109
|
+
newSkillContent: skill.content,
|
|
110
|
+
status: 'noop',
|
|
111
|
+
reason: `Body is within limit (${beforeLineCount} <= ${maxBodyLines}).`,
|
|
112
|
+
};
|
|
113
|
+
if (beforeLineCount <= maxBodyLines) {
|
|
114
|
+
return noopResult;
|
|
115
|
+
}
|
|
116
|
+
const sections = parseSections(skill.body);
|
|
117
|
+
if (sections.length === 0) {
|
|
118
|
+
return {
|
|
119
|
+
...noopResult,
|
|
120
|
+
status: 'blocked',
|
|
121
|
+
reason: 'Add at least one ## section heading before automatic split.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const pathExists = deps.pathExists ?? ((targetPath) => fs.existsSync(targetPath));
|
|
125
|
+
const skillDir = path.dirname(skill.filePath);
|
|
126
|
+
const bodyLines = skill.body.split(/\r?\n/);
|
|
127
|
+
const bodyStartLine = resolveBodyStartLine(skill.content);
|
|
128
|
+
const introLines = bodyLines.slice(0, sections[0]?.startIndex ?? 0);
|
|
129
|
+
const usedReferencePaths = new Set();
|
|
130
|
+
const referencesToCreate = sections.map((section) => {
|
|
131
|
+
const baseSlug = toSlug(section.title);
|
|
132
|
+
const relativePath = allocateReferencePath(skillDir, baseSlug, usedReferencePaths, pathExists);
|
|
133
|
+
const sectionLines = bodyLines.slice(section.startIndex + 1, section.endIndex + 1);
|
|
134
|
+
return {
|
|
135
|
+
relativePath,
|
|
136
|
+
title: section.title,
|
|
137
|
+
content: buildReferenceContent(section.title, sectionLines, lineEnding),
|
|
138
|
+
sourceLineRange: {
|
|
139
|
+
startLine: bodyStartLine + section.startIndex,
|
|
140
|
+
endLine: bodyStartLine + section.endIndex,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
const newSkillBody = buildNewBody(introLines, referencesToCreate, lineEnding);
|
|
145
|
+
const newSkillContent = replaceBody(skill.content, newSkillBody);
|
|
146
|
+
const afterLineCount = countLines(newSkillBody);
|
|
147
|
+
return {
|
|
148
|
+
skillFilePath: skill.filePath,
|
|
149
|
+
skillRelativePath: skill.relativePath,
|
|
150
|
+
beforeLineCount,
|
|
151
|
+
afterLineCount,
|
|
152
|
+
referencesToCreate,
|
|
153
|
+
newSkillBody,
|
|
154
|
+
newSkillContent,
|
|
155
|
+
status: 'planned',
|
|
156
|
+
reason: afterLineCount > maxBodyLines
|
|
157
|
+
? `Split completed but body still exceeds limit (${afterLineCount} > ${maxBodyLines}).`
|
|
158
|
+
: undefined,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
export function applyBodySplitPlan(plan) {
|
|
162
|
+
if (plan.status !== 'planned') {
|
|
163
|
+
return { updatedSkill: false, writtenReferenceFiles: [] };
|
|
164
|
+
}
|
|
165
|
+
fs.writeFileSync(plan.skillFilePath, plan.newSkillContent, 'utf8');
|
|
166
|
+
const skillDir = path.dirname(plan.skillFilePath);
|
|
167
|
+
const writtenReferenceFiles = [];
|
|
168
|
+
for (const reference of plan.referencesToCreate) {
|
|
169
|
+
const absolutePath = path.join(skillDir, ...reference.relativePath.split('/'));
|
|
170
|
+
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
171
|
+
fs.writeFileSync(absolutePath, reference.content, 'utf8');
|
|
172
|
+
writtenReferenceFiles.push(absolutePath);
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
updatedSkill: true,
|
|
176
|
+
writtenReferenceFiles,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ValidationStatus = 'PASS' | 'WARN' | 'FAIL' | 'SKIPPED';
|
|
2
|
+
export type SecurityStatus = 'PASS' | 'FAIL' | 'SKIPPED';
|
|
3
|
+
export type ConclusionCardMode = 'default' | 'share';
|
|
4
|
+
export interface ConclusionCardInput {
|
|
5
|
+
skillCount: number;
|
|
6
|
+
errorCount: number;
|
|
7
|
+
warningCount: number;
|
|
8
|
+
affectedFileCount: number;
|
|
9
|
+
overallScore: number | null;
|
|
10
|
+
validationStatus: ValidationStatus;
|
|
11
|
+
securityStatus: SecurityStatus;
|
|
12
|
+
elapsedMs: number;
|
|
13
|
+
runCommand?: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
mode?: ConclusionCardMode;
|
|
16
|
+
}
|
|
17
|
+
export interface ConclusionCardRenderResult {
|
|
18
|
+
card: string;
|
|
19
|
+
fullCommandPlain?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function renderConclusionCard(input: ConclusionCardInput): ConclusionCardRenderResult;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
const pcForced = pc.createColors(true);
|
|
3
|
+
const SCORE_BAR_WIDTH = 30;
|
|
4
|
+
const RUN_COMMAND_PREVIEW_LIMIT = 56;
|
|
5
|
+
const SHARE_CARD_MIN_WIDTH = 74;
|
|
6
|
+
function createLine(plainText, renderedText = plainText) {
|
|
7
|
+
return { plainText, renderedText };
|
|
8
|
+
}
|
|
9
|
+
function padLine(line, width) {
|
|
10
|
+
const trailing = ' '.repeat(Math.max(0, width - line.plainText.length));
|
|
11
|
+
return `| ${line.renderedText}${trailing} |`;
|
|
12
|
+
}
|
|
13
|
+
function colorizeByScore(c, score, text) {
|
|
14
|
+
if (score === null)
|
|
15
|
+
return c.dim(text);
|
|
16
|
+
if (score >= 90)
|
|
17
|
+
return c.green(text);
|
|
18
|
+
if (score >= 75)
|
|
19
|
+
return c.cyan(text);
|
|
20
|
+
if (score >= 50)
|
|
21
|
+
return c.yellow(text);
|
|
22
|
+
return c.red(text);
|
|
23
|
+
}
|
|
24
|
+
function scoreLabel(score) {
|
|
25
|
+
if (score === null)
|
|
26
|
+
return 'No skills';
|
|
27
|
+
if (score >= 90)
|
|
28
|
+
return 'Excellent';
|
|
29
|
+
if (score >= 75)
|
|
30
|
+
return 'Solid';
|
|
31
|
+
if (score >= 50)
|
|
32
|
+
return 'Needs work';
|
|
33
|
+
return 'Critical';
|
|
34
|
+
}
|
|
35
|
+
function formatElapsed(elapsedMs) {
|
|
36
|
+
if (elapsedMs < 1000) {
|
|
37
|
+
return `${Math.max(0, Math.round(elapsedMs))}ms`;
|
|
38
|
+
}
|
|
39
|
+
return `${(elapsedMs / 1000).toFixed(1)}s`;
|
|
40
|
+
}
|
|
41
|
+
function renderScoreBar(c, score, mode = 'default') {
|
|
42
|
+
const emptyChar = mode === 'share' ? '·' : '░';
|
|
43
|
+
if (score === null) {
|
|
44
|
+
const empty = emptyChar.repeat(SCORE_BAR_WIDTH);
|
|
45
|
+
return createLine(empty, c.dim(empty));
|
|
46
|
+
}
|
|
47
|
+
const filledCount = Math.round((score / 100) * SCORE_BAR_WIDTH);
|
|
48
|
+
const emptyCount = SCORE_BAR_WIDTH - filledCount;
|
|
49
|
+
const filled = '█'.repeat(filledCount);
|
|
50
|
+
const empty = emptyChar.repeat(emptyCount);
|
|
51
|
+
return createLine(`${filled}${empty}`, `${colorizeByScore(c, score, filled)}${c.dim(empty)}`);
|
|
52
|
+
}
|
|
53
|
+
function renderValidationStatus(c, status) {
|
|
54
|
+
if (status === 'PASS')
|
|
55
|
+
return c.green(status);
|
|
56
|
+
if (status === 'WARN')
|
|
57
|
+
return c.yellow(status);
|
|
58
|
+
if (status === 'SKIPPED')
|
|
59
|
+
return c.dim(status);
|
|
60
|
+
return c.red(status);
|
|
61
|
+
}
|
|
62
|
+
function renderSecurityStatus(c, status) {
|
|
63
|
+
if (status === 'PASS')
|
|
64
|
+
return c.green(status);
|
|
65
|
+
if (status === 'FAIL')
|
|
66
|
+
return c.red(status);
|
|
67
|
+
return c.yellow(status);
|
|
68
|
+
}
|
|
69
|
+
function truncateRunCommand(value, maxLength) {
|
|
70
|
+
if (value.length <= maxLength) {
|
|
71
|
+
return { text: value, truncated: false };
|
|
72
|
+
}
|
|
73
|
+
if (maxLength <= 1) {
|
|
74
|
+
return { text: '…', truncated: true };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
text: `${value.slice(0, maxLength - 1)}…`,
|
|
78
|
+
truncated: true,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export function renderConclusionCard(input) {
|
|
82
|
+
const mode = input.mode ?? 'default';
|
|
83
|
+
const c = mode === 'share' ? pcForced : pc;
|
|
84
|
+
const scoreValue = input.overallScore === null
|
|
85
|
+
? '--'
|
|
86
|
+
: String(Math.max(0, input.overallScore));
|
|
87
|
+
const label = scoreLabel(input.overallScore);
|
|
88
|
+
const scoreLinePlain = `${scoreValue} / 100 ${label}`;
|
|
89
|
+
const scoreLineRendered = `${colorizeByScore(c, input.overallScore, scoreValue)} / 100 ${colorizeByScore(c, input.overallScore, label)}`;
|
|
90
|
+
const scoreBar = renderScoreBar(c, input.overallScore, mode);
|
|
91
|
+
const validationLinePlain = `validation ${input.validationStatus} | security ${input.securityStatus}`;
|
|
92
|
+
const validationLineRendered = `${c.bold('validation')} ${renderValidationStatus(c, input.validationStatus)} ${c.dim('|')} ${c.bold('security')} ${renderSecurityStatus(c, input.securityStatus)}`;
|
|
93
|
+
const errorsText = `✖ ${input.errorCount} error${input.errorCount === 1 ? '' : 's'}`;
|
|
94
|
+
const warningsText = `⚠ ${input.warningCount} warning${input.warningCount === 1 ? '' : 's'}`;
|
|
95
|
+
const skillCountText = `${input.skillCount} skill${input.skillCount === 1 ? '' : 's'}`;
|
|
96
|
+
const filesText = `across ${input.affectedFileCount} file${input.affectedFileCount === 1 ? '' : 's'}`;
|
|
97
|
+
const elapsedText = `in ${formatElapsed(input.elapsedMs)}`;
|
|
98
|
+
const countsLinePlain = `${errorsText} ${warningsText} ${skillCountText} ${filesText} ${elapsedText}`;
|
|
99
|
+
const countsLineRendered = `${input.errorCount > 0 ? c.red(errorsText) : c.dim(errorsText)} ` +
|
|
100
|
+
`${input.warningCount > 0 ? c.yellow(warningsText) : c.dim(warningsText)} ` +
|
|
101
|
+
`${c.dim(skillCountText)} ${c.dim(filesText)} ${c.dim(elapsedText)}`;
|
|
102
|
+
const title = input.title ?? 'skill-check cli';
|
|
103
|
+
let fullCommandPlain;
|
|
104
|
+
let runCommandPreview;
|
|
105
|
+
let runCommandWasTruncated = false;
|
|
106
|
+
if (input.runCommand) {
|
|
107
|
+
const preview = truncateRunCommand(input.runCommand, RUN_COMMAND_PREVIEW_LIMIT);
|
|
108
|
+
runCommandPreview = preview.text;
|
|
109
|
+
runCommandWasTruncated = preview.truncated;
|
|
110
|
+
if (runCommandWasTruncated) {
|
|
111
|
+
fullCommandPlain = input.runCommand;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const lines = [];
|
|
115
|
+
if (mode === 'share') {
|
|
116
|
+
lines.push(createLine(title, `${c.bold(c.cyan('skill-check'))} ${c.bold('cli')}`), createLine(''));
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
lines.push(createLine(' .--------.', c.cyan(' .--------.')), createLine(' | skill |', c.cyan(' | skill |')), createLine(' | check |', c.cyan(' | check |')), createLine(" '--------'", c.cyan(" '--------'")), createLine(''), createLine(title, `${c.bold('skill-check')} ${c.dim('cli')}`), createLine(''));
|
|
120
|
+
}
|
|
121
|
+
if (runCommandPreview) {
|
|
122
|
+
lines.push(createLine(`run: ${runCommandPreview}`, `${c.bold('run:')} ${c.cyan(runCommandPreview)}`));
|
|
123
|
+
if (runCommandWasTruncated) {
|
|
124
|
+
lines.push(createLine('full command below', c.dim('full command below')));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
lines.push(createLine(''), createLine(scoreLinePlain, scoreLineRendered), createLine(''), scoreBar, createLine(''), createLine(validationLinePlain, validationLineRendered), createLine(countsLinePlain, countsLineRendered));
|
|
128
|
+
if (mode === 'share') {
|
|
129
|
+
lines.push(createLine(''), createLine('try it: npx skill-check <path-or-github-url>', `${c.bold('try it:')} ${c.cyan('npx skill-check <path-or-github-url>')}`), createLine('npm: https://www.npmjs.com/package/skill-check', `${c.bold('npm:')} ${c.dim('https://www.npmjs.com/package/skill-check')}`));
|
|
130
|
+
}
|
|
131
|
+
const width = Math.max(mode === 'share' ? SHARE_CARD_MIN_WIDTH : 0, ...lines.map((line) => line.plainText.length));
|
|
132
|
+
let card;
|
|
133
|
+
if (mode === 'share') {
|
|
134
|
+
card = lines
|
|
135
|
+
.map((line) => {
|
|
136
|
+
const trailing = ' '.repeat(Math.max(0, width - line.plainText.length));
|
|
137
|
+
return `${line.renderedText}${trailing}`;
|
|
138
|
+
})
|
|
139
|
+
.join('\n');
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
const border = '-'.repeat(width + 2);
|
|
143
|
+
const output = [`+${border}+`];
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
output.push(padLine(line, width));
|
|
146
|
+
}
|
|
147
|
+
output.push(`+${border}+`);
|
|
148
|
+
card = output.join('\n');
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
card,
|
|
152
|
+
fullCommandPlain,
|
|
153
|
+
};
|
|
154
|
+
}
|
package/dist/core/defaults.js
CHANGED
package/dist/core/discovery.js
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import fg from 'fast-glob';
|
|
4
|
-
import { DEFAULT_INCLUDE } from './defaults.js';
|
|
5
4
|
export async function discoverSkillFiles(config) {
|
|
6
5
|
const found = new Set();
|
|
7
|
-
const usesDefaultInclude = config.include.length === DEFAULT_INCLUDE.length &&
|
|
8
|
-
config.include.every((pattern, index) => pattern === DEFAULT_INCLUDE[index]);
|
|
9
6
|
for (const root of config.rootsAbs) {
|
|
10
7
|
if (fs.existsSync(root) && fs.statSync(root).isFile()) {
|
|
11
8
|
if (path.basename(root) === 'SKILL.md') {
|
|
@@ -23,21 +20,6 @@ export async function discoverSkillFiles(config) {
|
|
|
23
20
|
onlyFiles: true,
|
|
24
21
|
dot: true,
|
|
25
22
|
});
|
|
26
|
-
if (matches.length === 0 &&
|
|
27
|
-
usesDefaultInclude &&
|
|
28
|
-
path.basename(path.resolve(root)) === 'skills') {
|
|
29
|
-
const fallbackMatches = await fg('**/SKILL.md', {
|
|
30
|
-
cwd: root,
|
|
31
|
-
ignore: config.exclude,
|
|
32
|
-
absolute: true,
|
|
33
|
-
onlyFiles: true,
|
|
34
|
-
dot: true,
|
|
35
|
-
});
|
|
36
|
-
for (const match of fallbackMatches) {
|
|
37
|
-
found.add(path.resolve(match));
|
|
38
|
-
}
|
|
39
|
-
continue;
|
|
40
|
-
}
|
|
41
23
|
for (const match of matches) {
|
|
42
24
|
found.add(path.resolve(match));
|
|
43
25
|
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import type { AnalysisResult } from '../types.js';
|
|
2
|
+
import { type SecurityStatus } from './conclusion-card.js';
|
|
2
3
|
import type { SkillScore } from './quality-score.js';
|
|
3
|
-
export
|
|
4
|
+
export interface RenderTextOptions {
|
|
5
|
+
includeConclusion?: boolean;
|
|
6
|
+
securityStatus?: SecurityStatus;
|
|
7
|
+
elapsedMs?: number;
|
|
8
|
+
affectedFileCount?: number;
|
|
9
|
+
runCommand?: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function renderText(result: AnalysisResult, scores?: SkillScore[], options?: RenderTextOptions): string;
|
|
4
13
|
export declare function toJson(result: AnalysisResult): Record<string, unknown>;
|
package/dist/core/formatters.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import boxen from 'boxen';
|
|
2
1
|
import Table from 'cli-table3';
|
|
3
2
|
import pc from 'picocolors';
|
|
3
|
+
import { renderConclusionCard, } from './conclusion-card.js';
|
|
4
4
|
function groupByFile(diagnostics) {
|
|
5
5
|
const grouped = new Map();
|
|
6
6
|
for (const diagnostic of diagnostics) {
|
|
@@ -77,39 +77,28 @@ function renderOverviewTable(result, fileCount) {
|
|
|
77
77
|
]);
|
|
78
78
|
return table.toString();
|
|
79
79
|
}
|
|
80
|
-
function renderSummaryPanel(result) {
|
|
81
|
-
const status = result.summary.errorCount > 0
|
|
82
|
-
? pc.red(pc.bold('FAIL'))
|
|
83
|
-
: result.summary.warningCount > 0
|
|
84
|
-
? pc.yellow(pc.bold('WARN'))
|
|
85
|
-
: pc.green(pc.bold('PASS'));
|
|
86
|
-
return boxen([
|
|
87
|
-
`${pc.bold('Status')} ${status}`,
|
|
88
|
-
`${pc.bold('Skills')} ${pc.cyan(String(result.summary.skillCount))}`,
|
|
89
|
-
`${pc.bold('Errors')} ${pc.red(String(result.summary.errorCount))}`,
|
|
90
|
-
`${pc.bold('Warnings')} ${pc.yellow(String(result.summary.warningCount))}`,
|
|
91
|
-
].join('\n'), {
|
|
92
|
-
borderColor: result.summary.errorCount > 0
|
|
93
|
-
? 'red'
|
|
94
|
-
: result.summary.warningCount > 0
|
|
95
|
-
? 'yellow'
|
|
96
|
-
: 'green',
|
|
97
|
-
borderStyle: 'classic',
|
|
98
|
-
padding: {
|
|
99
|
-
top: 0,
|
|
100
|
-
right: 1,
|
|
101
|
-
bottom: 0,
|
|
102
|
-
left: 1,
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
80
|
function renderScoreBar(score) {
|
|
107
81
|
const color = score >= 80 ? pc.green : score >= 50 ? pc.yellow : pc.red;
|
|
108
82
|
const filled = Math.round(score / 5);
|
|
109
83
|
const empty = 20 - filled;
|
|
110
84
|
return `${color('█'.repeat(filled))}${pc.dim('░'.repeat(empty))} ${color(String(score))}`;
|
|
111
85
|
}
|
|
112
|
-
|
|
86
|
+
function resolveValidationStatus(result) {
|
|
87
|
+
if (result.summary.skillCount === 0)
|
|
88
|
+
return 'SKIPPED';
|
|
89
|
+
if (result.summary.errorCount > 0)
|
|
90
|
+
return 'FAIL';
|
|
91
|
+
if (result.summary.warningCount > 0)
|
|
92
|
+
return 'WARN';
|
|
93
|
+
return 'PASS';
|
|
94
|
+
}
|
|
95
|
+
function computeOverallScore(scores) {
|
|
96
|
+
if (!scores || scores.length === 0)
|
|
97
|
+
return null;
|
|
98
|
+
const total = scores.reduce((sum, score) => sum + score.score, 0);
|
|
99
|
+
return Math.round(total / scores.length);
|
|
100
|
+
}
|
|
101
|
+
export function renderText(result, scores, options = {}) {
|
|
113
102
|
const lines = [];
|
|
114
103
|
const grouped = groupByFile(result.diagnostics);
|
|
115
104
|
const status = renderStatusTag(result.summary);
|
|
@@ -156,16 +145,25 @@ export function renderText(result, scores) {
|
|
|
156
145
|
}
|
|
157
146
|
lines.push('');
|
|
158
147
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
:
|
|
167
|
-
|
|
168
|
-
|
|
148
|
+
if (options.includeConclusion !== false) {
|
|
149
|
+
const conclusion = renderConclusionCard({
|
|
150
|
+
skillCount: result.summary.skillCount,
|
|
151
|
+
errorCount: result.summary.errorCount,
|
|
152
|
+
warningCount: result.summary.warningCount,
|
|
153
|
+
affectedFileCount: options.affectedFileCount ?? grouped.size,
|
|
154
|
+
overallScore: computeOverallScore(scores),
|
|
155
|
+
validationStatus: resolveValidationStatus(result),
|
|
156
|
+
securityStatus: options.securityStatus ?? 'SKIPPED',
|
|
157
|
+
elapsedMs: options.elapsedMs ?? 0,
|
|
158
|
+
runCommand: options.runCommand,
|
|
159
|
+
title: options.title,
|
|
160
|
+
});
|
|
161
|
+
lines.push(pc.dim(renderDivider('-')));
|
|
162
|
+
lines.push(conclusion.card);
|
|
163
|
+
if (conclusion.fullCommandPlain) {
|
|
164
|
+
lines.push(conclusion.fullCommandPlain);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
169
167
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
170
168
|
}
|
|
171
169
|
export function toJson(result) {
|