skill-check 1.0.0 → 1.2.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 CHANGED
@@ -382,7 +382,13 @@ Run `skill-check rules <id>` for detail on a specific rule.
382
382
  | `frontmatter.description_required` | error | yes |
383
383
  | `frontmatter.name_matches_directory` | error | yes |
384
384
  | `frontmatter.name_slug_format` | error | yes |
385
+ | `frontmatter.name_max_length` | error | no |
385
386
  | `frontmatter.field_order` | error | yes |
387
+ | `frontmatter.unknown_fields` | warn | no |
388
+ | `frontmatter.compatibility_max_length` | warn | no |
389
+ | `frontmatter.metadata_string_values` | warn | no |
390
+ | `frontmatter.allowed_tools_format` | warn | no |
391
+ | `description.non_empty` | error | no |
386
392
  | `description.max_length` | error | no |
387
393
  | `description.use_when_phrase` | warn | yes |
388
394
  | `description.min_recommended_length` | warn | yes |
package/dist/cli/main.js CHANGED
@@ -605,15 +605,19 @@ async function runAgentScanWithFeedback(scanOptions, target, io, format) {
605
605
  }
606
606
  }
607
607
  function resolveValidationStatus(result) {
608
+ if (result.summary.skillCount === 0)
609
+ return 'SKIPPED';
608
610
  if (result.summary.errorCount > 0)
609
611
  return 'FAIL';
610
612
  if (result.summary.warningCount > 0)
611
613
  return 'WARN';
612
614
  return 'PASS';
613
615
  }
614
- function resolveSecurityStatus(enabled, scanExitCode) {
616
+ function resolveSecurityStatus(enabled, scanExitCode, skillCount) {
615
617
  if (!enabled)
616
618
  return 'SKIPPED';
619
+ if (skillCount === 0)
620
+ return 'SKIPPED';
617
621
  return scanExitCode === 0 ? 'PASS' : 'FAIL';
618
622
  }
619
623
  function computeOverallScore(scores) {
@@ -848,7 +852,7 @@ export async function runCli(argv, io = defaultIO) {
848
852
  affectedFileCount: countAffectedFiles(result.diagnostics),
849
853
  overallScore: computeOverallScore(scores),
850
854
  validationStatus: resolveValidationStatus(result),
851
- securityStatus: resolveSecurityStatus(scanOptions.enabled, scanExitCode),
855
+ securityStatus: resolveSecurityStatus(scanOptions.enabled, scanExitCode, result.summary.skillCount),
852
856
  elapsedMs: performance.now() - checkStartedAt,
853
857
  runCommand,
854
858
  mode: checkOptions.share ? 'share' : 'default',
@@ -857,7 +861,7 @@ export async function runCli(argv, io = defaultIO) {
857
861
  if (conclusion.fullCommandPlain) {
858
862
  io.stdout(`${conclusion.fullCommandPlain}\n`);
859
863
  }
860
- if (checkOptions.share) {
864
+ if (checkOptions.share && result.summary.skillCount > 0) {
861
865
  emitShareStatus(io, 'rendering share image...');
862
866
  const shareOutputPath = path.resolve(process.cwd(), checkOptions.shareOut ?? 'skill-check-share.png');
863
867
  const shareText = conclusion.fullCommandPlain
@@ -1052,6 +1056,12 @@ export async function runCli(argv, io = defaultIO) {
1052
1056
  '---',
1053
1057
  `name: ${slug}`,
1054
1058
  `description: Use when ${slug} functionality is needed. Describe triggers, constraints, and expected outcomes.`,
1059
+ '# license: MIT',
1060
+ '# compatibility: Designed for Claude Code (or similar products)',
1061
+ '# metadata:',
1062
+ '# author: your-org',
1063
+ '# version: "1.0"',
1064
+ '# allowed-tools: Bash(git:*) Read',
1055
1065
  '---',
1056
1066
  '',
1057
1067
  `# ${slug}`,
@@ -58,6 +58,8 @@ export function ensureInitConfig(targetPath, force = false) {
58
58
  maxBodyLines: 500,
59
59
  minDescriptionChars: 50,
60
60
  maxBodyTokens: 5000,
61
+ maxNameChars: 64,
62
+ maxCompatibilityChars: 500,
61
63
  },
62
64
  rules: {
63
65
  'description.use_when_phrase': 'warn',
@@ -1,4 +1,4 @@
1
- export type ValidationStatus = 'PASS' | 'WARN' | 'FAIL';
1
+ export type ValidationStatus = 'PASS' | 'WARN' | 'FAIL' | 'SKIPPED';
2
2
  export type SecurityStatus = 'PASS' | 'FAIL' | 'SKIPPED';
3
3
  export type ConclusionCardMode = 'default' | 'share';
4
4
  export interface ConclusionCardInput {
@@ -55,6 +55,8 @@ function renderValidationStatus(c, status) {
55
55
  return c.green(status);
56
56
  if (status === 'WARN')
57
57
  return c.yellow(status);
58
+ if (status === 'SKIPPED')
59
+ return c.dim(status);
58
60
  return c.red(status);
59
61
  }
60
62
  function renderSecurityStatus(c, status) {
@@ -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/**',
@@ -13,6 +13,8 @@ export const DEFAULT_LIMITS = {
13
13
  maxBodyLines: 500,
14
14
  minDescriptionChars: 50,
15
15
  maxBodyTokens: 5000,
16
+ maxNameChars: 64,
17
+ maxCompatibilityChars: 500,
16
18
  };
17
19
  export const DEFAULT_OUTPUT = {
18
20
  format: 'text',
@@ -23,8 +25,14 @@ export const DEFAULT_RULE_LEVELS = {
23
25
  'frontmatter.description_required': 'error',
24
26
  'frontmatter.name_matches_directory': 'error',
25
27
  'frontmatter.name_slug_format': 'error',
28
+ 'frontmatter.name_max_length': 'error',
26
29
  'frontmatter.field_order': 'error',
30
+ 'frontmatter.unknown_fields': 'warn',
31
+ 'frontmatter.compatibility_max_length': 'warn',
32
+ 'frontmatter.metadata_string_values': 'warn',
33
+ 'frontmatter.allowed_tools_format': 'warn',
27
34
  'description.max_length': 'error',
35
+ 'description.non_empty': 'error',
28
36
  'description.use_when_phrase': 'warn',
29
37
  'description.min_recommended_length': 'warn',
30
38
  'body.max_lines': 'error',
@@ -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
  }
@@ -84,6 +84,8 @@ function renderScoreBar(score) {
84
84
  return `${color('█'.repeat(filled))}${pc.dim('░'.repeat(empty))} ${color(String(score))}`;
85
85
  }
86
86
  function resolveValidationStatus(result) {
87
+ if (result.summary.skillCount === 0)
88
+ return 'SKIPPED';
87
89
  if (result.summary.errorCount > 0)
88
90
  return 'FAIL';
89
91
  if (result.summary.warningCount > 0)
@@ -7,6 +7,27 @@ function getDescription(skill) {
7
7
  return value.trim();
8
8
  }
9
9
  export const descriptionRules = [
10
+ {
11
+ id: 'description.non_empty',
12
+ description: 'Description must not be empty or whitespace-only.',
13
+ defaultSeverity: 'error',
14
+ evaluate(skill) {
15
+ if (!skill.frontmatter)
16
+ return [];
17
+ const raw = skill.frontmatter.description;
18
+ if (raw === undefined || raw === null)
19
+ return [];
20
+ const trimmed = typeof raw === 'string' ? raw.trim() : String(raw).trim();
21
+ if (trimmed.length > 0)
22
+ return [];
23
+ return [
24
+ {
25
+ message: 'description is empty or whitespace-only',
26
+ suggestion: 'Provide a meaningful description. Start with "Use when" to help agents match this skill.',
27
+ },
28
+ ];
29
+ },
30
+ },
10
31
  {
11
32
  id: 'description.max_length',
12
33
  description: 'Description must be within configured max length.',
@@ -3,6 +3,14 @@ function normalizeName(name) {
3
3
  return '';
4
4
  return name.replace(/^['"]|['"]$/g, '').trim();
5
5
  }
6
+ const KNOWN_FRONTMATTER_FIELDS = new Set([
7
+ 'name',
8
+ 'description',
9
+ 'license',
10
+ 'compatibility',
11
+ 'metadata',
12
+ 'allowed-tools',
13
+ ]);
6
14
  export const frontmatterRules = [
7
15
  {
8
16
  id: 'frontmatter.required',
@@ -100,6 +108,27 @@ export const frontmatterRules = [
100
108
  ];
101
109
  },
102
110
  },
111
+ {
112
+ id: 'frontmatter.name_max_length',
113
+ description: 'Frontmatter name must not exceed 64 characters.',
114
+ defaultSeverity: 'error',
115
+ evaluate(skill, context) {
116
+ if (!skill.frontmatter)
117
+ return [];
118
+ const name = normalizeName(skill.frontmatter.name);
119
+ if (!name)
120
+ return [];
121
+ const max = context.config.limits.maxNameChars;
122
+ if (name.length <= max)
123
+ return [];
124
+ return [
125
+ {
126
+ message: `name length ${name.length} exceeds max ${max}`,
127
+ suggestion: `Shorten the name to ${max} characters or fewer.`,
128
+ },
129
+ ];
130
+ },
131
+ },
103
132
  {
104
133
  id: 'frontmatter.field_order',
105
134
  description: 'Frontmatter should list name before description.',
@@ -121,4 +150,99 @@ export const frontmatterRules = [
121
150
  ];
122
151
  },
123
152
  },
153
+ {
154
+ id: 'frontmatter.unknown_fields',
155
+ description: 'Frontmatter should only contain spec-defined fields (name, description, license, compatibility, metadata, allowed-tools).',
156
+ defaultSeverity: 'warn',
157
+ evaluate(skill) {
158
+ if (!skill.frontmatter)
159
+ return [];
160
+ const unknown = Object.keys(skill.frontmatter).filter((key) => !KNOWN_FRONTMATTER_FIELDS.has(key));
161
+ if (unknown.length === 0)
162
+ return [];
163
+ const plural = unknown.length === 1 ? 'field' : 'fields';
164
+ return [
165
+ {
166
+ message: `unknown frontmatter ${plural}: ${unknown.join(', ')}`,
167
+ suggestion: `The spec defines: ${[...KNOWN_FRONTMATTER_FIELDS].join(', ')}. Remove or rename unrecognized fields.`,
168
+ },
169
+ ];
170
+ },
171
+ },
172
+ {
173
+ id: 'frontmatter.compatibility_max_length',
174
+ description: 'Compatibility field must not exceed 500 characters when provided.',
175
+ defaultSeverity: 'warn',
176
+ evaluate(skill, context) {
177
+ if (!skill.frontmatter)
178
+ return [];
179
+ const value = skill.frontmatter.compatibility;
180
+ if (value === undefined || value === null)
181
+ return [];
182
+ if (typeof value !== 'string')
183
+ return [];
184
+ const max = context.config.limits.maxCompatibilityChars;
185
+ if (value.trim().length <= max)
186
+ return [];
187
+ return [
188
+ {
189
+ message: `compatibility length ${value.trim().length} exceeds max ${max}`,
190
+ suggestion: `Shorten the compatibility field to ${max} characters or fewer.`,
191
+ },
192
+ ];
193
+ },
194
+ },
195
+ {
196
+ id: 'frontmatter.metadata_string_values',
197
+ description: 'Metadata field must be a map of string keys to string values.',
198
+ defaultSeverity: 'warn',
199
+ evaluate(skill) {
200
+ if (!skill.frontmatter)
201
+ return [];
202
+ const metadata = skill.frontmatter.metadata;
203
+ if (metadata === undefined || metadata === null)
204
+ return [];
205
+ if (typeof metadata !== 'object' || Array.isArray(metadata)) {
206
+ return [
207
+ {
208
+ message: 'metadata must be a key-value object',
209
+ suggestion: 'Use metadata as a YAML mapping, e.g. metadata:\\n author: example-org',
210
+ },
211
+ ];
212
+ }
213
+ const nonStringKeys = [];
214
+ for (const [key, val] of Object.entries(metadata)) {
215
+ if (typeof val !== 'string')
216
+ nonStringKeys.push(key);
217
+ }
218
+ if (nonStringKeys.length === 0)
219
+ return [];
220
+ return [
221
+ {
222
+ message: `metadata values must be strings; non-string keys: ${nonStringKeys.join(', ')}`,
223
+ suggestion: 'Convert metadata values to strings, e.g. version: "1.0" instead of version: 1.0',
224
+ },
225
+ ];
226
+ },
227
+ },
228
+ {
229
+ id: 'frontmatter.allowed_tools_format',
230
+ description: 'allowed-tools field must be a space-delimited string.',
231
+ defaultSeverity: 'warn',
232
+ evaluate(skill) {
233
+ if (!skill.frontmatter)
234
+ return [];
235
+ const value = skill.frontmatter['allowed-tools'];
236
+ if (value === undefined || value === null)
237
+ return [];
238
+ if (typeof value === 'string')
239
+ return [];
240
+ return [
241
+ {
242
+ message: 'allowed-tools must be a string (space-delimited tool list)',
243
+ suggestion: 'Use a space-delimited string, e.g. allowed-tools: Bash(git:*) Read',
244
+ },
245
+ ];
246
+ },
247
+ },
124
248
  ];
package/dist/types.d.ts CHANGED
@@ -22,6 +22,8 @@ export interface LimitsConfig {
22
22
  maxBodyLines: number;
23
23
  minDescriptionChars: number;
24
24
  maxBodyTokens: number;
25
+ maxNameChars: number;
26
+ maxCompatibilityChars: number;
25
27
  }
26
28
  export interface OutputConfig {
27
29
  format: OutputFormat;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-check",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Linter for agent skill files",
5
5
  "type": "module",
6
6
  "private": false,
@@ -22,7 +22,9 @@
22
22
  "maxDescriptionChars": { "type": "integer", "minimum": 1 },
23
23
  "maxBodyLines": { "type": "integer", "minimum": 1 },
24
24
  "minDescriptionChars": { "type": "integer", "minimum": 0 },
25
- "maxBodyTokens": { "type": "integer", "minimum": 1 }
25
+ "maxBodyTokens": { "type": "integer", "minimum": 1 },
26
+ "maxNameChars": { "type": "integer", "minimum": 1 },
27
+ "maxCompatibilityChars": { "type": "integer", "minimum": 1 }
26
28
  },
27
29
  "additionalProperties": false
28
30
  },