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.
@@ -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 runAgentScan(options: AgentScanOptions): number;
33
+ export declare function summarizeAgentScanOutput(rawOutput: string): AgentScanOutputSummary;
34
+ export declare function runAgentScan(options: AgentScanOptions): AgentScanRunResult;
19
35
  export {};
@@ -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 = spawnSync(invocation.command, invocation.args, {
75
- stdio: 'inherit',
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 result.status;
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
+ }
@@ -1,4 +1,4 @@
1
- export const DEFAULT_INCLUDE = ['**/skills/*/SKILL.md'];
1
+ export const DEFAULT_INCLUDE = ['**/SKILL.md'];
2
2
  export const DEFAULT_EXCLUDE = [
3
3
  '**/node_modules/**',
4
4
  '**/.git/**',
@@ -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 declare function renderText(result: AnalysisResult, scores?: SkillScore[]): string;
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>;
@@ -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
- export function renderText(result, scores) {
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
- lines.push(pc.dim(renderDivider('-')));
160
- lines.push(renderSummaryPanel(result));
161
- lines.push('');
162
- const statusWord = result.summary.errorCount > 0
163
- ? pc.red('FAIL')
164
- : result.summary.warningCount > 0
165
- ? pc.yellow('WARN')
166
- : pc.green('PASS');
167
- lines.push(`Summary: skills=${pc.cyan(String(result.summary.skillCount))} errors=${pc.red(String(result.summary.errorCount))} warnings=${pc.yellow(String(result.summary.warningCount))} status=${statusWord}`);
168
- lines.push(pc.dim(renderDivider('-')));
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) {