Setting the file. One moment. Validate Skill · Skill Creator · google-gemini/gemini-cli · Skills Docsscripts/validate_skill.cjs
scripts/validate_skill.cjs
JavaScript·131 lines·3 KB
'node:path'
);
14
15function validateSkill(skillPath) {
16 if (!fs.existsSync(skillPath) || !fs.statSync(skillPath).isDirectory()) {
17 return { valid: false, message: `Path is not a directory: ${skillPath}` };
18 }
19
20 const skillMdPath = path.join(skillPath, 'SKILL.md');
21 if (!fs.existsSync(skillMdPath)) {
22 return { valid: false, message: 'SKILL.md not found' };
23 }
24
25 const content = fs.readFileSync(skillMdPath, 'utf8');
26 if (!content.startsWith('---')) {
27 return { valid: false, message: 'No YAML frontmatter found' };
28 }
29
30 const parts = content.split('---');
31 if (parts.length < 3) {
32 return { valid: false, message: 'Invalid frontmatter format' };
33 }
34
35 const frontmatterText = parts[1];
36
37 const nameMatch = frontmatterText.match(/^name:\s*(.+)$/m);
38 // Match description: "text" or description: 'text' or description: text
39 const descMatch = frontmatterText.match(
40 /^description:\s*(?:'([^']*)'|"([^"]*)"|(.+))$/m,
41 );
42
43 if (!nameMatch)
44 return { valid: false, message: 'Missing "name" in frontmatter' };
45 if (!descMatch)
46 return {
47 valid: false,
48 message: 'Description must be a single-line string: description: ...',
49 };
50
51 const name = nameMatch[1].trim();
52 const description = (
53 descMatch[1] !== undefined
54 ? descMatch[1]
55 : descMatch[2] !== undefined
56 ? descMatch[2]
57 : descMatch[3] || ''
58 ).trim();
59
60 if (description.includes('\n')) {
61 return {
62 valid: false,
63 message: 'Description must be a single line (no newlines)',
64 };
65 }
66
67 if (!/^[a-z0-9-]+$/.test(name)) {
68 return { valid: false, message: `Name "${name}" should be hyphen-case` };
69 }
70
71 if (description.length > 1024) {
72 return { valid: false, message: 'Description is too long (max 1024)' };
73 }
74
75 // Check for TODOs
76 const files = getAllFiles(skillPath);
77 for (const file of files) {
78 const fileContent = fs.readFileSync(file, 'utf8');
79 if (fileContent.includes('TODO:')) {
80 return {
81 valid: true,
82 message: 'Skill has unresolved TODOs',
83 warning: `Found unresolved TODO in ${path.relative(skillPath, file)}`,
84 };
85 }
86 }
87
88 return { valid: true, message: 'Skill is valid!' };
89}
90
91function getAllFiles(dir, fileList = []) {
92 const files = fs.readdirSync(dir);
93 files.forEach((file) => {
94 const name = path.join(dir, file);
95 if (fs.statSync(name).isDirectory()) {
96 if (!['node_modules', '.git', '__pycache__'].includes(file)) {
97 getAllFiles(name, fileList);
98 }
99 } else {
100 fileList.push(name);
101 }
102 });
103 return fileList;
104}
105
106if (require.main === module) {
107 const args = process.argv.slice(2);
108 if (args.length !== 1) {
109 console.log('Usage: node validate_skill.js <skill_directory>');
110 process.exit(1);
111 }
112
113 const skillDirArg = args[0];
114 if (skillDirArg.includes('..')) {
115 console.error('❌ Error: Path traversal detected in skill directory path.');
116 process.exit(1);
117 }
118
119 const result = validateSkill(path.resolve(skillDirArg));
120 if (result.warning) {
121 console.warn(`⚠️ ${result.warning}`);
122 }
123 if (result.valid) {
124 console.log(`✅ ${result.message}`);
125 } else {
126 console.error(`❌ ${result.message}`);
127 process.exit(1);
128 }
129}
130
131module.exports = { validateSkill };