skill-check 0.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +360 -0
  3. package/bin/skill-check.js +11 -0
  4. package/dist/cli/main.d.ts +5 -0
  5. package/dist/cli/main.js +724 -0
  6. package/dist/core/agent-scan.d.ts +19 -0
  7. package/dist/core/agent-scan.js +88 -0
  8. package/dist/core/allowlist.d.ts +1 -0
  9. package/dist/core/allowlist.js +8 -0
  10. package/dist/core/analyze.d.ts +6 -0
  11. package/dist/core/analyze.js +72 -0
  12. package/dist/core/artifact.d.ts +2 -0
  13. package/dist/core/artifact.js +33 -0
  14. package/dist/core/baseline.d.ts +8 -0
  15. package/dist/core/baseline.js +17 -0
  16. package/dist/core/config.d.ts +2 -0
  17. package/dist/core/config.js +215 -0
  18. package/dist/core/defaults.d.ts +6 -0
  19. package/dist/core/defaults.js +34 -0
  20. package/dist/core/discovery.d.ts +2 -0
  21. package/dist/core/discovery.js +46 -0
  22. package/dist/core/duplicates.d.ts +2 -0
  23. package/dist/core/duplicates.js +60 -0
  24. package/dist/core/errors.d.ts +4 -0
  25. package/dist/core/errors.js +8 -0
  26. package/dist/core/fix.d.ts +11 -0
  27. package/dist/core/fix.js +172 -0
  28. package/dist/core/formatters.d.ts +4 -0
  29. package/dist/core/formatters.js +182 -0
  30. package/dist/core/frontmatter.d.ts +7 -0
  31. package/dist/core/frontmatter.js +39 -0
  32. package/dist/core/github-formatter.d.ts +2 -0
  33. package/dist/core/github-formatter.js +10 -0
  34. package/dist/core/html-report.d.ts +3 -0
  35. package/dist/core/html-report.js +320 -0
  36. package/dist/core/interactive-fix.d.ts +9 -0
  37. package/dist/core/interactive-fix.js +22 -0
  38. package/dist/core/links.d.ts +17 -0
  39. package/dist/core/links.js +94 -0
  40. package/dist/core/open-browser.d.ts +6 -0
  41. package/dist/core/open-browser.js +20 -0
  42. package/dist/core/plugins.d.ts +2 -0
  43. package/dist/core/plugins.js +41 -0
  44. package/dist/core/quality-score.d.ts +14 -0
  45. package/dist/core/quality-score.js +55 -0
  46. package/dist/core/report.d.ts +2 -0
  47. package/dist/core/report.js +26 -0
  48. package/dist/core/rule-engine.d.ts +2 -0
  49. package/dist/core/rule-engine.js +52 -0
  50. package/dist/core/sarif.d.ts +2 -0
  51. package/dist/core/sarif.js +48 -0
  52. package/dist/index.d.ts +11 -0
  53. package/dist/index.js +8 -0
  54. package/dist/rules/core/body.d.ts +2 -0
  55. package/dist/rules/core/body.js +39 -0
  56. package/dist/rules/core/description.d.ts +2 -0
  57. package/dist/rules/core/description.js +66 -0
  58. package/dist/rules/core/file.d.ts +2 -0
  59. package/dist/rules/core/file.js +26 -0
  60. package/dist/rules/core/frontmatter.d.ts +2 -0
  61. package/dist/rules/core/frontmatter.js +124 -0
  62. package/dist/rules/core/index.d.ts +2 -0
  63. package/dist/rules/core/index.js +12 -0
  64. package/dist/rules/core/links.d.ts +2 -0
  65. package/dist/rules/core/links.js +54 -0
  66. package/dist/types.d.ts +92 -0
  67. package/dist/types.js +1 -0
  68. package/package.json +82 -0
  69. package/schemas/config.schema.json +53 -0
  70. package/skills/skill-check/SKILL.md +76 -0
@@ -0,0 +1,19 @@
1
+ declare const VALID_RUNNERS: readonly ["auto", "local", "uvx", "pipx"];
2
+ export type AgentScanRunner = (typeof VALID_RUNNERS)[number];
3
+ export interface AgentScanOptions {
4
+ cwd: string;
5
+ targetPath?: string;
6
+ mode?: string;
7
+ paths?: string[];
8
+ skills?: string[];
9
+ runner?: AgentScanRunner;
10
+ }
11
+ export interface AgentScanInvocation {
12
+ command: string;
13
+ args: string[];
14
+ }
15
+ export declare function isValidAgentScanRunner(value: string): value is AgentScanRunner;
16
+ export declare function isCommandAvailable(commandName: string): boolean;
17
+ export declare function resolveAgentScanInvocation(options: AgentScanOptions, hasCommand?: (commandName: string) => boolean): AgentScanInvocation;
18
+ export declare function runAgentScan(options: AgentScanOptions): number;
19
+ export {};
@@ -0,0 +1,88 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CliError } from './errors.js';
5
+ const VALID_RUNNERS = ['auto', 'local', 'uvx', 'pipx'];
6
+ function normalizePaths(cwd, values, fallback) {
7
+ const list = values && values.length > 0 ? values : fallback;
8
+ return list.map((value) => path.resolve(cwd, value));
9
+ }
10
+ function buildMcpScanArgs(options) {
11
+ const targetPath = options.targetPath ?? '.';
12
+ const paths = normalizePaths(options.cwd, options.paths, []);
13
+ const skills = normalizePaths(options.cwd, options.skills, [targetPath]);
14
+ const args = [];
15
+ for (const scanPath of paths) {
16
+ args.push(scanPath);
17
+ }
18
+ for (const skillsPath of skills) {
19
+ args.push('--skills', skillsPath);
20
+ }
21
+ return args;
22
+ }
23
+ export function isValidAgentScanRunner(value) {
24
+ return VALID_RUNNERS.includes(value);
25
+ }
26
+ export function isCommandAvailable(commandName) {
27
+ const pathValue = process.env.PATH;
28
+ if (!pathValue)
29
+ return false;
30
+ const exts = process.platform === 'win32'
31
+ ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';')
32
+ : [''];
33
+ for (const base of pathValue.split(path.delimiter)) {
34
+ if (!base)
35
+ continue;
36
+ for (const ext of exts) {
37
+ const candidate = path.join(base, `${commandName}${ext}`);
38
+ try {
39
+ fs.accessSync(candidate, fs.constants.X_OK);
40
+ return true;
41
+ }
42
+ catch {
43
+ // Try next candidate.
44
+ }
45
+ }
46
+ }
47
+ return false;
48
+ }
49
+ export function resolveAgentScanInvocation(options, hasCommand = isCommandAvailable) {
50
+ const runner = options.runner ?? 'auto';
51
+ const scanArgs = buildMcpScanArgs(options);
52
+ if (runner === 'local') {
53
+ return { command: 'mcp-scan', args: scanArgs };
54
+ }
55
+ if (runner === 'uvx') {
56
+ return { command: 'uvx', args: ['mcp-scan', ...scanArgs] };
57
+ }
58
+ if (runner === 'pipx') {
59
+ return { command: 'pipx', args: ['run', 'mcp-scan', ...scanArgs] };
60
+ }
61
+ if (hasCommand('mcp-scan')) {
62
+ return { command: 'mcp-scan', args: scanArgs };
63
+ }
64
+ if (hasCommand('uvx')) {
65
+ return { command: 'uvx', args: ['mcp-scan', ...scanArgs] };
66
+ }
67
+ if (hasCommand('pipx')) {
68
+ return { command: 'pipx', args: ['run', 'mcp-scan', ...scanArgs] };
69
+ }
70
+ throw new CliError('No security scan runner found. Install mcp-scan, uv, or pipx.', 2);
71
+ }
72
+ export function runAgentScan(options) {
73
+ const invocation = resolveAgentScanInvocation(options);
74
+ const result = spawnSync(invocation.command, invocation.args, {
75
+ stdio: 'inherit',
76
+ });
77
+ const error = result.error;
78
+ if (error?.code === 'ENOENT') {
79
+ throw new CliError(`Runner not found: ${invocation.command}`, 2);
80
+ }
81
+ if (error) {
82
+ throw new CliError(`Failed to run security scan (${invocation.command}): ${error.message}`, 2);
83
+ }
84
+ if (typeof result.status !== 'number') {
85
+ throw new CliError('Security scan process did not return an exit code.', 2);
86
+ }
87
+ return result.status;
88
+ }
@@ -0,0 +1 @@
1
+ export declare function isAllowlisted(skillId: string, allowlist: string[]): boolean;
@@ -0,0 +1,8 @@
1
+ import { minimatch } from 'minimatch';
2
+ export function isAllowlisted(skillId, allowlist) {
3
+ return allowlist.some((pattern) => {
4
+ if (pattern === skillId)
5
+ return true;
6
+ return minimatch(skillId, pattern, { dot: true });
7
+ });
8
+ }
@@ -0,0 +1,6 @@
1
+ import type { AnalysisResult, CliOptions, ResolvedConfig } from '../types.js';
2
+ export declare function analyze(cwd: string, targetPath: string | undefined, cliOptions: CliOptions): Promise<AnalysisResult>;
3
+ export declare function analyzeWithConfig(cwd: string, config: ResolvedConfig): Promise<AnalysisResult>;
4
+ export declare function resolveExitCode(result: AnalysisResult): number;
5
+ export declare function writeIfRequested(config: ResolvedConfig, text: string): string | undefined;
6
+ export declare function ensureInitConfig(targetPath: string, force?: boolean): void;
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { coreRules } from '../rules/core/index.js';
4
+ import { buildSkillArtifact } from './artifact.js';
5
+ import { resolveConfig } from './config.js';
6
+ import { discoverSkillFiles } from './discovery.js';
7
+ import { CliError } from './errors.js';
8
+ import { loadPluginRules } from './plugins.js';
9
+ import { runRuleEngine } from './rule-engine.js';
10
+ function summarize(skillCount, diagnostics) {
11
+ const errorCount = diagnostics.filter((d) => d.severity === 'error').length;
12
+ const warningCount = diagnostics.filter((d) => d.severity === 'warn').length;
13
+ return { skillCount, errorCount, warningCount };
14
+ }
15
+ export async function analyze(cwd, targetPath, cliOptions) {
16
+ const config = await resolveConfig(cwd, targetPath, cliOptions);
17
+ return analyzeWithConfig(cwd, config);
18
+ }
19
+ export async function analyzeWithConfig(cwd, config) {
20
+ const files = await discoverSkillFiles(config);
21
+ const skills = files.map((file) => buildSkillArtifact(file, cwd));
22
+ const pluginRules = await loadPluginRules(config);
23
+ const rules = [...coreRules, ...pluginRules];
24
+ const diagnostics = await runRuleEngine(skills, rules, config);
25
+ return {
26
+ config,
27
+ skills,
28
+ diagnostics,
29
+ summary: summarize(skills.length, diagnostics),
30
+ };
31
+ }
32
+ export function resolveExitCode(result) {
33
+ if (result.summary.errorCount > 0)
34
+ return 1;
35
+ if (result.config.failOnWarning && result.summary.warningCount > 0)
36
+ return 1;
37
+ return 0;
38
+ }
39
+ export function writeIfRequested(config, text) {
40
+ const reportPath = config.output.reportPath;
41
+ if (!reportPath)
42
+ return undefined;
43
+ const parent = path.dirname(reportPath);
44
+ fs.mkdirSync(parent, { recursive: true });
45
+ fs.writeFileSync(reportPath, text);
46
+ return reportPath;
47
+ }
48
+ export function ensureInitConfig(targetPath, force = false) {
49
+ if (fs.existsSync(targetPath) && !force) {
50
+ throw new CliError(`Config file already exists: ${targetPath}. Use --force to overwrite.`, 2);
51
+ }
52
+ const template = {
53
+ roots: ['.'],
54
+ include: ['**/skills/*/SKILL.md'],
55
+ exclude: ['**/node_modules/**', '**/.git/**'],
56
+ limits: {
57
+ maxDescriptionChars: 1024,
58
+ maxBodyLines: 500,
59
+ minDescriptionChars: 50,
60
+ maxBodyTokens: 5000,
61
+ },
62
+ rules: {
63
+ 'description.use_when_phrase': 'warn',
64
+ },
65
+ allowlist: [],
66
+ plugins: [],
67
+ output: {
68
+ format: 'text',
69
+ },
70
+ };
71
+ fs.writeFileSync(targetPath, `${JSON.stringify(template, null, 2)}\n`);
72
+ }
@@ -0,0 +1,2 @@
1
+ import type { SkillArtifact } from '../types.js';
2
+ export declare function buildSkillArtifact(filePath: string, cwd: string): SkillArtifact;
@@ -0,0 +1,33 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseFrontmatter } from './frontmatter.js';
4
+ function deriveCategoryAndSlug(filePath) {
5
+ const parts = path.resolve(filePath).split(path.sep).filter(Boolean);
6
+ const skillsIndex = parts.lastIndexOf('skills');
7
+ if (skillsIndex > 0 && skillsIndex + 1 < parts.length) {
8
+ const category = parts[skillsIndex - 1] ?? 'unknown';
9
+ const slug = parts[skillsIndex + 1] ?? 'unknown-skill';
10
+ return { category, slug };
11
+ }
12
+ const slug = path.basename(path.dirname(filePath));
13
+ const category = path.basename(path.dirname(path.dirname(filePath)));
14
+ return { category, slug };
15
+ }
16
+ export function buildSkillArtifact(filePath, cwd) {
17
+ const content = fs.readFileSync(filePath, 'utf8');
18
+ const parsed = parseFrontmatter(content);
19
+ const { category, slug } = deriveCategoryAndSlug(filePath);
20
+ const id = `${category}/${slug}`;
21
+ return {
22
+ id,
23
+ category,
24
+ slug,
25
+ filePath: path.resolve(filePath),
26
+ relativePath: path.relative(cwd, path.resolve(filePath)),
27
+ content,
28
+ body: parsed.body,
29
+ frontmatter: parsed.frontmatter,
30
+ frontmatterRaw: parsed.frontmatterRaw,
31
+ parseError: parsed.error,
32
+ };
33
+ }
@@ -0,0 +1,8 @@
1
+ import type { Diagnostic } from '../types.js';
2
+ export declare function loadBaseline(baselinePath: string): Diagnostic[];
3
+ export interface BaselineDiff {
4
+ newDiagnostics: Diagnostic[];
5
+ fixedDiagnostics: Diagnostic[];
6
+ unchanged: Diagnostic[];
7
+ }
8
+ export declare function diffBaseline(current: Diagnostic[], baseline: Diagnostic[]): BaselineDiff;
@@ -0,0 +1,17 @@
1
+ import fs from 'node:fs';
2
+ function diagnosticKey(d) {
3
+ return `${d.file}|${d.ruleId}|${d.line}|${d.column}|${d.message}`;
4
+ }
5
+ export function loadBaseline(baselinePath) {
6
+ const raw = fs.readFileSync(baselinePath, 'utf8');
7
+ const data = JSON.parse(raw);
8
+ return data.diagnostics ?? [];
9
+ }
10
+ export function diffBaseline(current, baseline) {
11
+ const baselineKeys = new Set(baseline.map(diagnosticKey));
12
+ const currentKeys = new Set(current.map(diagnosticKey));
13
+ const newDiagnostics = current.filter((d) => !baselineKeys.has(diagnosticKey(d)));
14
+ const fixedDiagnostics = baseline.filter((d) => !currentKeys.has(diagnosticKey(d)));
15
+ const unchanged = current.filter((d) => baselineKeys.has(diagnosticKey(d)));
16
+ return { newDiagnostics, fixedDiagnostics, unchanged };
17
+ }
@@ -0,0 +1,2 @@
1
+ import type { CliOptions, ResolvedConfig } from '../types.js';
2
+ export declare function resolveConfig(cwd: string, targetPath: string | undefined, cli: CliOptions): Promise<ResolvedConfig>;
@@ -0,0 +1,215 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DEFAULT_EXCLUDE, DEFAULT_INCLUDE, DEFAULT_LIMITS, DEFAULT_OUTPUT, } from './defaults.js';
4
+ import { CliError } from './errors.js';
5
+ function isStringArray(value) {
6
+ return (Array.isArray(value) && value.every((entry) => typeof entry === 'string'));
7
+ }
8
+ function isObjectRecord(value) {
9
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
10
+ }
11
+ function readJsonFile(filePath) {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
14
+ }
15
+ catch (error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ throw new CliError(`Invalid JSON in ${filePath}: ${message}`, 2);
18
+ }
19
+ }
20
+ function validateConfigShape(config, sourcePath) {
21
+ if (!isObjectRecord(config)) {
22
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: config must be an object`, 2);
23
+ }
24
+ const knownKeys = new Set([
25
+ 'roots',
26
+ 'include',
27
+ 'exclude',
28
+ 'limits',
29
+ 'rules',
30
+ 'allowlist',
31
+ 'plugins',
32
+ 'output',
33
+ ]);
34
+ for (const key of Object.keys(config)) {
35
+ if (!knownKeys.has(key)) {
36
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: unknown key "${key}"`, 2);
37
+ }
38
+ }
39
+ if (config.roots !== undefined && !isStringArray(config.roots)) {
40
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: roots must be string[]`, 2);
41
+ }
42
+ if (config.include !== undefined && !isStringArray(config.include)) {
43
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: include must be string[]`, 2);
44
+ }
45
+ if (config.exclude !== undefined && !isStringArray(config.exclude)) {
46
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: exclude must be string[]`, 2);
47
+ }
48
+ if (config.allowlist !== undefined && !isStringArray(config.allowlist)) {
49
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: allowlist must be string[]`, 2);
50
+ }
51
+ if (config.plugins !== undefined && !isStringArray(config.plugins)) {
52
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: plugins must be string[]`, 2);
53
+ }
54
+ if (config.limits !== undefined) {
55
+ if (!isObjectRecord(config.limits)) {
56
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: limits must be an object`, 2);
57
+ }
58
+ for (const [key, value] of Object.entries(config.limits)) {
59
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
60
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: limits.${key} must be a positive number`, 2);
61
+ }
62
+ }
63
+ }
64
+ if (config.rules !== undefined) {
65
+ if (!isObjectRecord(config.rules)) {
66
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: rules must be an object`, 2);
67
+ }
68
+ for (const value of Object.values(config.rules)) {
69
+ if (value !== 'off' && value !== 'warn' && value !== 'error') {
70
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: rules values must be off|warn|error`, 2);
71
+ }
72
+ }
73
+ }
74
+ if (config.output !== undefined) {
75
+ if (!isObjectRecord(config.output)) {
76
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: output must be an object`, 2);
77
+ }
78
+ if (config.output.format !== undefined &&
79
+ config.output.format !== 'text' &&
80
+ config.output.format !== 'json' &&
81
+ config.output.format !== 'sarif' &&
82
+ config.output.format !== 'html' &&
83
+ config.output.format !== 'github') {
84
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: output.format must be text|json|sarif|html|github`, 2);
85
+ }
86
+ if (config.output.reportPath !== undefined &&
87
+ typeof config.output.reportPath !== 'string') {
88
+ throw new CliError(`Invalid config${sourcePath ? ` (${sourcePath})` : ''}: output.reportPath must be string`, 2);
89
+ }
90
+ }
91
+ return config;
92
+ }
93
+ function mergeConfig(base, override) {
94
+ return {
95
+ ...base,
96
+ ...override,
97
+ limits: {
98
+ ...(base.limits ?? {}),
99
+ ...(override.limits ?? {}),
100
+ },
101
+ rules: {
102
+ ...(base.rules ?? {}),
103
+ ...(override.rules ?? {}),
104
+ },
105
+ output: {
106
+ ...(base.output ?? {}),
107
+ ...(override.output ?? {}),
108
+ },
109
+ };
110
+ }
111
+ function resolveRoots(cwd, roots) {
112
+ return roots.map((root) => path.resolve(cwd, root));
113
+ }
114
+ function normalizeList(values, fallback) {
115
+ if (!values || values.length === 0)
116
+ return fallback;
117
+ return values.map((value) => value.trim()).filter(Boolean);
118
+ }
119
+ function normalizeRules(values) {
120
+ if (!values)
121
+ return {};
122
+ const normalized = {};
123
+ for (const [ruleId, level] of Object.entries(values)) {
124
+ if (level === 'off' || level === 'warn' || level === 'error') {
125
+ normalized[ruleId] = level;
126
+ }
127
+ }
128
+ return normalized;
129
+ }
130
+ export async function resolveConfig(cwd, targetPath, cli) {
131
+ if (cli.strict && cli.lenient) {
132
+ throw new CliError('Cannot use --strict and --lenient together.', 2);
133
+ }
134
+ const explicitConfigPath = cli.configPath
135
+ ? path.resolve(cwd, cli.configPath)
136
+ : undefined;
137
+ const defaultConfigPath = path.resolve(cwd, 'skill-check.config.json');
138
+ let fileConfig = {};
139
+ let configPath;
140
+ if (explicitConfigPath) {
141
+ if (!fs.existsSync(explicitConfigPath)) {
142
+ throw new CliError(`Config file not found: ${explicitConfigPath}`, 2);
143
+ }
144
+ configPath = explicitConfigPath;
145
+ fileConfig = validateConfigShape(readJsonFile(explicitConfigPath), explicitConfigPath);
146
+ }
147
+ else if (fs.existsSync(defaultConfigPath)) {
148
+ configPath = defaultConfigPath;
149
+ fileConfig = validateConfigShape(readJsonFile(defaultConfigPath), defaultConfigPath);
150
+ }
151
+ const optionConfig = {};
152
+ if (cli.include && cli.include.length > 0) {
153
+ optionConfig.include = cli.include;
154
+ }
155
+ if (cli.exclude && cli.exclude.length > 0) {
156
+ optionConfig.exclude = cli.exclude;
157
+ }
158
+ if (typeof cli.maxBodyLines === 'number' ||
159
+ typeof cli.maxDescriptionChars === 'number') {
160
+ optionConfig.limits = {};
161
+ if (typeof cli.maxBodyLines === 'number') {
162
+ optionConfig.limits.maxBodyLines = cli.maxBodyLines;
163
+ }
164
+ if (typeof cli.maxDescriptionChars === 'number') {
165
+ optionConfig.limits.maxDescriptionChars = cli.maxDescriptionChars;
166
+ }
167
+ }
168
+ if (cli.format) {
169
+ optionConfig.output = {
170
+ format: cli.format,
171
+ };
172
+ }
173
+ const merged = mergeConfig(fileConfig, optionConfig);
174
+ const roots = targetPath ? [targetPath] : normalizeList(merged.roots, ['.']);
175
+ const include = normalizeList(merged.include, DEFAULT_INCLUDE);
176
+ const exclude = normalizeList(merged.exclude, DEFAULT_EXCLUDE);
177
+ const limits = {
178
+ ...DEFAULT_LIMITS,
179
+ ...(merged.limits ?? {}),
180
+ };
181
+ const output = {
182
+ ...DEFAULT_OUTPUT,
183
+ ...(merged.output ?? {}),
184
+ };
185
+ const rules = normalizeRules(merged.rules);
186
+ if (cli.lenient) {
187
+ rules['frontmatter.name_matches_directory'] = 'off';
188
+ }
189
+ const failOnWarning = Boolean(cli.failOnWarning || cli.strict);
190
+ const strictMode = Boolean(cli.strict);
191
+ const lenientMode = Boolean(cli.lenient);
192
+ const rootsAbs = resolveRoots(cwd, roots);
193
+ const reportPath = output.reportPath
194
+ ? path.resolve(cwd, output.reportPath)
195
+ : undefined;
196
+ return {
197
+ cwd,
198
+ configPath,
199
+ roots,
200
+ rootsAbs,
201
+ include,
202
+ exclude,
203
+ limits,
204
+ rules,
205
+ allowlist: merged.allowlist ?? [],
206
+ plugins: merged.plugins ?? [],
207
+ output: {
208
+ ...output,
209
+ reportPath,
210
+ },
211
+ failOnWarning,
212
+ strictMode,
213
+ lenientMode,
214
+ };
215
+ }
@@ -0,0 +1,6 @@
1
+ import type { LimitsConfig, OutputConfig, RuleLevel } from '../types.js';
2
+ export declare const DEFAULT_INCLUDE: string[];
3
+ export declare const DEFAULT_EXCLUDE: string[];
4
+ export declare const DEFAULT_LIMITS: LimitsConfig;
5
+ export declare const DEFAULT_OUTPUT: OutputConfig;
6
+ export declare const DEFAULT_RULE_LEVELS: Record<string, RuleLevel>;
@@ -0,0 +1,34 @@
1
+ export const DEFAULT_INCLUDE = ['**/skills/*/SKILL.md'];
2
+ export const DEFAULT_EXCLUDE = [
3
+ '**/node_modules/**',
4
+ '**/.git/**',
5
+ '**/dist/**',
6
+ '**/build/**',
7
+ '**/.next/**',
8
+ '**/coverage/**',
9
+ ];
10
+ export const DEFAULT_LIMITS = {
11
+ maxDescriptionChars: 1024,
12
+ maxBodyLines: 500,
13
+ minDescriptionChars: 50,
14
+ maxBodyTokens: 5000,
15
+ };
16
+ export const DEFAULT_OUTPUT = {
17
+ format: 'text',
18
+ };
19
+ export const DEFAULT_RULE_LEVELS = {
20
+ 'frontmatter.required': 'error',
21
+ 'frontmatter.name_required': 'error',
22
+ 'frontmatter.description_required': 'error',
23
+ 'frontmatter.name_matches_directory': 'error',
24
+ 'frontmatter.name_slug_format': 'error',
25
+ 'frontmatter.field_order': 'error',
26
+ 'description.max_length': 'error',
27
+ 'description.use_when_phrase': 'warn',
28
+ 'description.min_recommended_length': 'warn',
29
+ 'body.max_lines': 'error',
30
+ 'body.max_tokens': 'warn',
31
+ 'file.trailing_newline_single': 'warn',
32
+ 'links.local_markdown_resolves': 'warn',
33
+ 'links.references_resolve': 'warn',
34
+ };
@@ -0,0 +1,2 @@
1
+ import type { ResolvedConfig } from '../types.js';
2
+ export declare function discoverSkillFiles(config: ResolvedConfig): Promise<string[]>;
@@ -0,0 +1,46 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import fg from 'fast-glob';
4
+ import { DEFAULT_INCLUDE } from './defaults.js';
5
+ export async function discoverSkillFiles(config) {
6
+ const found = new Set();
7
+ const usesDefaultInclude = config.include.length === DEFAULT_INCLUDE.length &&
8
+ config.include.every((pattern, index) => pattern === DEFAULT_INCLUDE[index]);
9
+ for (const root of config.rootsAbs) {
10
+ if (fs.existsSync(root) && fs.statSync(root).isFile()) {
11
+ if (path.basename(root) === 'SKILL.md') {
12
+ found.add(path.resolve(root));
13
+ }
14
+ continue;
15
+ }
16
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
17
+ continue;
18
+ }
19
+ const matches = await fg(config.include, {
20
+ cwd: root,
21
+ ignore: config.exclude,
22
+ absolute: true,
23
+ onlyFiles: true,
24
+ dot: true,
25
+ });
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
+ for (const match of matches) {
42
+ found.add(path.resolve(match));
43
+ }
44
+ }
45
+ return Array.from(found).sort();
46
+ }
@@ -0,0 +1,2 @@
1
+ import type { Diagnostic, SkillArtifact } from '../types.js';
2
+ export declare function detectDuplicates(skills: SkillArtifact[]): Diagnostic[];
@@ -0,0 +1,60 @@
1
+ export function detectDuplicates(skills) {
2
+ const diagnostics = [];
3
+ const nameMap = new Map();
4
+ for (const skill of skills) {
5
+ if (!skill.frontmatter?.name)
6
+ continue;
7
+ const name = String(skill.frontmatter.name);
8
+ const list = nameMap.get(name) ?? [];
9
+ list.push(skill);
10
+ nameMap.set(name, list);
11
+ }
12
+ for (const [name, group] of nameMap) {
13
+ if (group.length <= 1)
14
+ continue;
15
+ for (const skill of group) {
16
+ diagnostics.push({
17
+ ruleId: 'duplicates.name',
18
+ severity: 'warn',
19
+ message: `duplicate skill name "${name}" shared with: ${group
20
+ .filter((s) => s !== skill)
21
+ .map((s) => s.relativePath)
22
+ .join(', ')}`,
23
+ suggestion: 'Ensure each skill has a unique name.',
24
+ file: skill.relativePath,
25
+ line: 1,
26
+ column: 1,
27
+ });
28
+ }
29
+ }
30
+ const descMap = new Map();
31
+ for (const skill of skills) {
32
+ if (!skill.frontmatter?.description)
33
+ continue;
34
+ const desc = String(skill.frontmatter.description).trim().toLowerCase();
35
+ if (desc.length < 20)
36
+ continue;
37
+ const list = descMap.get(desc) ?? [];
38
+ list.push(skill);
39
+ descMap.set(desc, list);
40
+ }
41
+ for (const [, group] of descMap) {
42
+ if (group.length <= 1)
43
+ continue;
44
+ for (const skill of group) {
45
+ diagnostics.push({
46
+ ruleId: 'duplicates.description',
47
+ severity: 'warn',
48
+ message: `identical description shared with: ${group
49
+ .filter((s) => s !== skill)
50
+ .map((s) => s.relativePath)
51
+ .join(', ')}`,
52
+ suggestion: 'Write distinct descriptions so agents can differentiate skills.',
53
+ file: skill.relativePath,
54
+ line: 1,
55
+ column: 1,
56
+ });
57
+ }
58
+ }
59
+ return diagnostics;
60
+ }
@@ -0,0 +1,4 @@
1
+ export declare class CliError extends Error {
2
+ readonly code: number;
3
+ constructor(message: string, code?: number);
4
+ }
@@ -0,0 +1,8 @@
1
+ export class CliError extends Error {
2
+ code;
3
+ constructor(message, code = 2) {
4
+ super(message);
5
+ this.name = 'CliError';
6
+ this.code = code;
7
+ }
8
+ }
@@ -0,0 +1,11 @@
1
+ import type { AnalysisResult } from '../types.js';
2
+ export interface AutoFixSummary {
3
+ requestedDiagnostics: number;
4
+ supportedDiagnostics: number;
5
+ unsupportedDiagnostics: number;
6
+ appliedFixes: number;
7
+ filesUpdated: number;
8
+ updatedFiles: string[];
9
+ }
10
+ export declare function isFixableRuleId(ruleId: string): boolean;
11
+ export declare function applyAutoFixes(result: AnalysisResult): AutoFixSummary;