Skip to content

Commit 9258fd9

Browse files
priyanshu92Copilot
andauthored
Add skill description metadata validation (#184)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 08287fe commit 9258fd9

4 files changed

Lines changed: 289 additions & 56 deletions

File tree

.github/workflows/validate-plugin-names.yml

Lines changed: 0 additions & 56 deletions
This file was deleted.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: validate-repository-metadata
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
workflow_dispatch:
8+
9+
jobs:
10+
validate-repository-metadata:
11+
name: validate-repository-metadata
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: checkout
15+
uses: actions/checkout@v4
16+
17+
- name: setup-node
18+
uses: actions/setup-node@v4
19+
with:
20+
node-version: 20
21+
22+
- name: validate-kebab-case-plugin-names
23+
run: node scripts/validate-plugin-names.js
24+
25+
- name: validate-skill-descriptions
26+
run: node scripts/validate-skill-descriptions.js

scripts/validate-plugin-names.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Validates that plugin names use kebab-case in all plugin metadata files.
5+
*/
6+
7+
const fs = require('fs');
8+
const path = require('path');
9+
10+
const ROOT = path.resolve(__dirname, '..');
11+
const KEBAB_CASE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
12+
13+
function validateName(name, source) {
14+
if (KEBAB_CASE_PATTERN.test(name)) return null;
15+
return `${source}: '${name}' is not kebab-case`;
16+
}
17+
18+
function readJson(filePath) {
19+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
20+
}
21+
22+
function getPluginManifestPaths() {
23+
const pluginsDirectory = path.join(ROOT, 'plugins');
24+
if (!fs.existsSync(pluginsDirectory)) return [];
25+
26+
return fs
27+
.readdirSync(pluginsDirectory, { withFileTypes: true })
28+
.filter((entry) => entry.isDirectory())
29+
.map((entry) => path.join(pluginsDirectory, entry.name, '.claude-plugin', 'plugin.json'))
30+
.filter((filePath) => fs.existsSync(filePath))
31+
.sort();
32+
}
33+
34+
const errors = [];
35+
36+
const marketplacePath = path.join(ROOT, '.claude-plugin', 'marketplace.json');
37+
const marketplace = readJson(marketplacePath);
38+
for (const [index, plugin] of (marketplace.plugins || []).entries()) {
39+
const error = validateName(
40+
plugin.name,
41+
`${path.relative(ROOT, marketplacePath)} plugins[${index}].name`
42+
);
43+
if (error) errors.push(error);
44+
}
45+
46+
for (const pluginManifestPath of getPluginManifestPaths()) {
47+
const pluginManifest = readJson(pluginManifestPath);
48+
const error = validateName(
49+
pluginManifest.name,
50+
`${path.relative(ROOT, pluginManifestPath)} name`
51+
);
52+
if (error) errors.push(error);
53+
}
54+
55+
if (errors.length > 0) {
56+
console.log('Found plugin names that are not kebab-case:');
57+
for (const error of errors) {
58+
console.log(`- ${error}`);
59+
}
60+
process.exit(1);
61+
}
62+
63+
console.log('All plugin names are kebab-case.');
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Validates that skill metadata descriptions are present and under the
5+
* GitHub Copilot supported limit.
6+
*/
7+
8+
const fs = require('fs');
9+
const path = require('path');
10+
11+
const ROOT = path.resolve(__dirname, '..');
12+
const MAX_DESCRIPTION_LENGTH = 1024;
13+
14+
function walkFiles(directory) {
15+
if (!fs.existsSync(directory)) return [];
16+
17+
const entries = fs.readdirSync(directory, { withFileTypes: true });
18+
const files = [];
19+
20+
for (const entry of entries) {
21+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
22+
23+
const entryPath = path.join(directory, entry.name);
24+
if (entry.isDirectory()) {
25+
files.push(...walkFiles(entryPath));
26+
} else {
27+
files.push(entryPath);
28+
}
29+
}
30+
31+
return files;
32+
}
33+
34+
function isSkillMetadataFile(filePath) {
35+
const normalized = path.relative(ROOT, filePath).split(path.sep).join('/');
36+
return (
37+
normalized.includes('/skills/') &&
38+
(normalized.endsWith('/SKILL.md') || normalized.endsWith('/SKILL.template.md'))
39+
);
40+
}
41+
42+
function getFrontmatter(content) {
43+
const match = content.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/);
44+
if (!match) return null;
45+
46+
const startLine = content.slice(0, match.index).split(/\r?\n/).length;
47+
return {
48+
body: match[1],
49+
startLine,
50+
};
51+
}
52+
53+
function countIndent(line) {
54+
const match = line.match(/^[ \t]*/);
55+
return match ? match[0].length : 0;
56+
}
57+
58+
function stripBlockIndent(lines) {
59+
const indents = lines
60+
.filter((line) => line.trim() !== '')
61+
.map((line) => countIndent(line));
62+
const indent = indents.length > 0 ? Math.min(...indents) : 0;
63+
64+
return lines.map((line) => {
65+
if (line.trim() === '') return '';
66+
return line.slice(Math.min(indent, countIndent(line)));
67+
});
68+
}
69+
70+
function foldBlockLines(lines) {
71+
let value = '';
72+
73+
for (let index = 0; index < lines.length; index += 1) {
74+
const line = lines[index];
75+
if (index > 0) {
76+
const previousLine = lines[index - 1];
77+
value += previousLine === '' || line === '' ? '\n' : ' ';
78+
}
79+
value += line;
80+
}
81+
82+
return value;
83+
}
84+
85+
function unquoteInlineValue(value) {
86+
const trimmed = value.trim();
87+
if (trimmed.length < 2) return trimmed;
88+
89+
const quote = trimmed[0];
90+
if ((quote !== '"' && quote !== "'") || trimmed[trimmed.length - 1] !== quote) {
91+
return trimmed;
92+
}
93+
94+
const unquoted = trimmed.slice(1, -1);
95+
if (quote === "'") {
96+
return unquoted.replace(/''/g, "'");
97+
}
98+
99+
return unquoted
100+
.replace(/\\"/g, '"')
101+
.replace(/\\n/g, '\n')
102+
.replace(/\\t/g, '\t')
103+
.replace(/\\\\/g, '\\');
104+
}
105+
106+
function parseDescription(frontmatter) {
107+
const lines = frontmatter.body.split(/\r?\n/);
108+
109+
for (let index = 0; index < lines.length; index += 1) {
110+
const line = lines[index];
111+
const match = line.match(/^description\s*:\s*(.*)$/);
112+
if (!match) continue;
113+
114+
const rawValue = match[1].trim();
115+
const lineNumber = frontmatter.startLine + index + 1;
116+
const blockMatch = rawValue.match(/^([>|])/);
117+
118+
if (!blockMatch) {
119+
return {
120+
value: unquoteInlineValue(rawValue),
121+
lineNumber,
122+
};
123+
}
124+
125+
const blockLines = [];
126+
const parentIndent = countIndent(line);
127+
const explicitIndentMatch = rawValue.match(/^[>|]([1-9])/);
128+
let blockIndent = explicitIndentMatch
129+
? parentIndent + Number(explicitIndentMatch[1])
130+
: null;
131+
132+
for (let blockIndex = index + 1; blockIndex < lines.length; blockIndex += 1) {
133+
const blockLine = lines[blockIndex];
134+
if (blockLine.trim() !== '') {
135+
const lineIndent = countIndent(blockLine);
136+
if (blockIndent === null) {
137+
if (lineIndent <= parentIndent) break;
138+
blockIndent = lineIndent;
139+
} else if (lineIndent < blockIndent) {
140+
break;
141+
}
142+
}
143+
blockLines.push(blockLine);
144+
}
145+
146+
const strippedLines = stripBlockIndent(blockLines);
147+
const value =
148+
blockMatch[1] === '|'
149+
? strippedLines.join('\n').trimEnd()
150+
: foldBlockLines(strippedLines).trimEnd();
151+
152+
return {
153+
value,
154+
lineNumber,
155+
};
156+
}
157+
158+
return null;
159+
}
160+
161+
const skillFiles = walkFiles(ROOT).filter(isSkillMetadataFile).sort();
162+
const errors = [];
163+
164+
for (const filePath of skillFiles) {
165+
const relativePath = path.relative(ROOT, filePath);
166+
const content = fs.readFileSync(filePath, 'utf8');
167+
const frontmatter = getFrontmatter(content);
168+
169+
if (!frontmatter) {
170+
errors.push(`${relativePath}: missing YAML frontmatter`);
171+
continue;
172+
}
173+
174+
const description = parseDescription(frontmatter);
175+
if (!description) {
176+
errors.push(`${relativePath}: missing description in YAML frontmatter`);
177+
continue;
178+
}
179+
180+
const descriptionLength = Array.from(description.value).length;
181+
if (descriptionLength >= MAX_DESCRIPTION_LENGTH) {
182+
errors.push(
183+
`${relativePath}:${description.lineNumber}: description is ${descriptionLength} characters; ` +
184+
`must be fewer than ${MAX_DESCRIPTION_LENGTH} characters because GitHub Copilot ` +
185+
`does not support longer skill descriptions`
186+
);
187+
}
188+
}
189+
190+
if (errors.length > 0) {
191+
console.log('Found invalid skill descriptions:');
192+
for (const error of errors) {
193+
console.log(`- ${error}`);
194+
}
195+
process.exit(1);
196+
}
197+
198+
console.log(
199+
`Validated ${skillFiles.length} skill metadata file(s); all descriptions are under ${MAX_DESCRIPTION_LENGTH} characters.`
200+
);

0 commit comments

Comments
 (0)