cursor-lint 0.14.0 → 0.15.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/package.json +1 -1
- package/src/cli.js +58 -2
- package/src/doctor.js +28 -0
- package/src/plugin.js +653 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -11,6 +11,7 @@ const { showStats } = require('./stats');
|
|
|
11
11
|
const { migrate } = require('./migrate');
|
|
12
12
|
const { doctor } = require('./doctor');
|
|
13
13
|
const { saveSnapshot, diffSnapshot } = require('./diff');
|
|
14
|
+
const { lintPlugin } = require('./plugin');
|
|
14
15
|
|
|
15
16
|
const VERSION = '0.13.0';
|
|
16
17
|
|
|
@@ -49,6 +50,7 @@ ${YELLOW}Options:${RESET}
|
|
|
49
50
|
--doctor Full project health check with letter grade
|
|
50
51
|
--diff save Save current rules as snapshot
|
|
51
52
|
--diff Compare current rules to saved snapshot
|
|
53
|
+
--plugin Validate Cursor 2.5 plugin structure
|
|
52
54
|
|
|
53
55
|
${YELLOW}What it checks (default):${RESET}
|
|
54
56
|
• .cursorrules files (warns about agent mode compatibility)
|
|
@@ -120,6 +122,7 @@ async function main() {
|
|
|
120
122
|
const isMigrate = args.includes('--migrate');
|
|
121
123
|
const isDoctor = args.includes('--doctor');
|
|
122
124
|
const isDiff = args.includes('--diff');
|
|
125
|
+
const isPlugin = args.includes('--plugin');
|
|
123
126
|
|
|
124
127
|
if (isVersionCheck) {
|
|
125
128
|
console.log(`\n📦 cursor-lint v${VERSION} --version-check\n`);
|
|
@@ -234,9 +237,10 @@ async function main() {
|
|
|
234
237
|
process.exit(0);
|
|
235
238
|
|
|
236
239
|
} else if (isDoctor) {
|
|
240
|
+
const dir = args.find(a => !a.startsWith('-')) ? path.resolve(args.find(a => !a.startsWith('-'))) : cwd;
|
|
237
241
|
console.log(`\n🏥 cursor-lint v${VERSION} --doctor\n`);
|
|
238
|
-
console.log(`Running full health check on ${
|
|
239
|
-
const report = await doctor(
|
|
242
|
+
console.log(`Running full health check on ${dir}...\n`);
|
|
243
|
+
const report = await doctor(dir);
|
|
240
244
|
|
|
241
245
|
// Grade display
|
|
242
246
|
const gradeColors = { A: GREEN, B: GREEN, C: YELLOW, D: YELLOW, F: RED };
|
|
@@ -337,6 +341,58 @@ async function main() {
|
|
|
337
341
|
// Exit 1 if changes detected (useful for CI)
|
|
338
342
|
process.exit(1);
|
|
339
343
|
|
|
344
|
+
} else if (isPlugin) {
|
|
345
|
+
const dir = args.find(a => !a.startsWith('-')) ? path.resolve(args.find(a => !a.startsWith('-'))) : cwd;
|
|
346
|
+
|
|
347
|
+
console.log(`\n🔌 cursor-lint v${VERSION} --plugin\n`);
|
|
348
|
+
console.log(`Validating Cursor plugin in ${dir}...\n`);
|
|
349
|
+
|
|
350
|
+
const results = await lintPlugin(dir);
|
|
351
|
+
|
|
352
|
+
if (results.length === 0) {
|
|
353
|
+
console.log(`${GREEN}✓ Plugin validation passed${RESET}\n`);
|
|
354
|
+
process.exit(0);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let totalErrors = 0;
|
|
358
|
+
let totalWarnings = 0;
|
|
359
|
+
|
|
360
|
+
for (const result of results) {
|
|
361
|
+
console.log(result.file);
|
|
362
|
+
|
|
363
|
+
if (result.issues.length === 0) {
|
|
364
|
+
console.log(` ${GREEN}✓ All checks passed${RESET}`);
|
|
365
|
+
} else {
|
|
366
|
+
for (const issue of result.issues) {
|
|
367
|
+
let icon;
|
|
368
|
+
if (issue.severity === 'error') {
|
|
369
|
+
icon = `${RED}✗${RESET}`;
|
|
370
|
+
totalErrors++;
|
|
371
|
+
} else if (issue.severity === 'warning') {
|
|
372
|
+
icon = `${YELLOW}⚠${RESET}`;
|
|
373
|
+
totalWarnings++;
|
|
374
|
+
} else {
|
|
375
|
+
icon = `${BLUE}ℹ${RESET}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
console.log(` ${icon} ${issue.message}`);
|
|
379
|
+
if (issue.hint) {
|
|
380
|
+
console.log(` ${DIM}→ ${issue.hint}${RESET}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
console.log();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
console.log('─'.repeat(50));
|
|
388
|
+
const parts = [];
|
|
389
|
+
if (totalErrors > 0) parts.push(`${RED}${totalErrors} error${totalErrors !== 1 ? 's' : ''}${RESET}`);
|
|
390
|
+
if (totalWarnings > 0) parts.push(`${YELLOW}${totalWarnings} warning${totalWarnings !== 1 ? 's' : ''}${RESET}`);
|
|
391
|
+
if (parts.length === 0) parts.push(`${GREEN}All checks passed${RESET}`);
|
|
392
|
+
console.log(parts.join(', ') + '\n');
|
|
393
|
+
|
|
394
|
+
process.exit(totalErrors > 0 ? 1 : 0);
|
|
395
|
+
|
|
340
396
|
} else if (isOrder) {
|
|
341
397
|
const { showLoadOrder } = require('./order');
|
|
342
398
|
console.log(`\n📋 cursor-lint v${VERSION} --order\n`);
|
package/src/doctor.js
CHANGED
|
@@ -2,6 +2,7 @@ const fs = require('fs');
|
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { lintProject } = require('./index');
|
|
4
4
|
const { showStats } = require('./stats');
|
|
5
|
+
const { lintPlugin } = require('./plugin');
|
|
5
6
|
|
|
6
7
|
async function doctor(dir) {
|
|
7
8
|
const report = {
|
|
@@ -118,6 +119,33 @@ async function doctor(dir) {
|
|
|
118
119
|
report.checks.push({ name: 'Agent skills', status: 'info', detail: 'No agent skills found. Skills are optional but can improve agent behavior for complex workflows.' });
|
|
119
120
|
}
|
|
120
121
|
|
|
122
|
+
// 7. Plugin validation (if this is a plugin)
|
|
123
|
+
const pluginManifestPath = path.join(dir, '.cursor-plugin', 'plugin.json');
|
|
124
|
+
if (fs.existsSync(pluginManifestPath)) {
|
|
125
|
+
report.maxScore += 10;
|
|
126
|
+
const pluginResults = await lintPlugin(dir);
|
|
127
|
+
let pluginErrors = 0;
|
|
128
|
+
let pluginWarnings = 0;
|
|
129
|
+
|
|
130
|
+
for (const r of pluginResults) {
|
|
131
|
+
for (const i of r.issues) {
|
|
132
|
+
if (i.severity === 'error') pluginErrors++;
|
|
133
|
+
else if (i.severity === 'warning') pluginWarnings++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (pluginErrors === 0 && pluginWarnings === 0) {
|
|
138
|
+
report.score += 10;
|
|
139
|
+
report.checks.push({ name: 'Plugin validation', status: 'pass', detail: 'Plugin structure is valid' });
|
|
140
|
+
} else if (pluginErrors === 0) {
|
|
141
|
+
report.score += 7;
|
|
142
|
+
report.checks.push({ name: 'Plugin validation', status: 'warn', detail: `${pluginWarnings} warning${pluginWarnings !== 1 ? 's' : ''} in plugin structure. Run cursor-lint --plugin to see details.` });
|
|
143
|
+
} else {
|
|
144
|
+
report.score += 3;
|
|
145
|
+
report.checks.push({ name: 'Plugin validation', status: 'fail', detail: `${pluginErrors} error${pluginErrors !== 1 ? 's' : ''} in plugin structure. Run cursor-lint --plugin to fix.` });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
121
149
|
// Calculate grade
|
|
122
150
|
const pct = (report.score / report.maxScore) * 100;
|
|
123
151
|
if (pct >= 90) report.grade = 'A';
|
package/src/plugin.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
// Regex patterns from Cursor's official validator
|
|
5
|
+
const PLUGIN_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/;
|
|
6
|
+
const MARKETPLACE_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
7
|
+
const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
|
8
|
+
|
|
9
|
+
// Valid hook event names from Cursor 2.5 docs
|
|
10
|
+
const VALID_HOOK_EVENTS = new Set([
|
|
11
|
+
'sessionStart',
|
|
12
|
+
'sessionEnd',
|
|
13
|
+
'preToolUse',
|
|
14
|
+
'postToolUse',
|
|
15
|
+
'postToolUseFailure',
|
|
16
|
+
'subagentStart',
|
|
17
|
+
'subagentStop',
|
|
18
|
+
'beforeShellExecution',
|
|
19
|
+
'afterShellExecution',
|
|
20
|
+
'beforeMCPExecution',
|
|
21
|
+
'afterMCPExecution',
|
|
22
|
+
'beforeReadFile',
|
|
23
|
+
'afterFileEdit',
|
|
24
|
+
'beforeSubmitPrompt',
|
|
25
|
+
'preCompact',
|
|
26
|
+
'stop',
|
|
27
|
+
'afterAgentResponse',
|
|
28
|
+
'afterAgentThought',
|
|
29
|
+
'beforeTabFileRead',
|
|
30
|
+
'afterTabFileEdit',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// Helper: Check if a path is a safe relative path
|
|
34
|
+
function isSafeRelativePath(value) {
|
|
35
|
+
if (typeof value !== 'string' || value.length === 0) return false;
|
|
36
|
+
|
|
37
|
+
// Allow URLs for logo field
|
|
38
|
+
if (value.startsWith('http://') || value.startsWith('https://')) return true;
|
|
39
|
+
|
|
40
|
+
// Reject absolute paths
|
|
41
|
+
if (path.isAbsolute(value)) return false;
|
|
42
|
+
|
|
43
|
+
// Normalize and check for parent directory references
|
|
44
|
+
const normalized = path.posix.normalize(value.replace(/\\/g, '/'));
|
|
45
|
+
return !normalized.startsWith('../') && normalized !== '..';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Helper: Parse frontmatter from markdown content
|
|
49
|
+
function parseFrontmatter(content) {
|
|
50
|
+
const normalized = content.replace(/\r\n/g, '\n');
|
|
51
|
+
if (!normalized.startsWith('---\n')) return null;
|
|
52
|
+
|
|
53
|
+
const closingIndex = normalized.indexOf('\n---\n', 4);
|
|
54
|
+
if (closingIndex === -1) return null;
|
|
55
|
+
|
|
56
|
+
const frontmatterBlock = normalized.slice(4, closingIndex);
|
|
57
|
+
const fields = {};
|
|
58
|
+
|
|
59
|
+
for (const line of frontmatterBlock.split('\n')) {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
62
|
+
|
|
63
|
+
const separator = line.indexOf(':');
|
|
64
|
+
if (separator === -1) continue;
|
|
65
|
+
|
|
66
|
+
const key = line.slice(0, separator).trim();
|
|
67
|
+
const value = line.slice(separator + 1).trim();
|
|
68
|
+
fields[key] = value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return fields;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Helper: Extract path values from manifest fields
|
|
75
|
+
function extractPathValues(value) {
|
|
76
|
+
if (typeof value === 'string') return [value];
|
|
77
|
+
if (Array.isArray(value)) return value.flatMap(extractPathValues);
|
|
78
|
+
|
|
79
|
+
if (value && typeof value === 'object') {
|
|
80
|
+
const candidates = [];
|
|
81
|
+
if (typeof value.path === 'string') candidates.push(value.path);
|
|
82
|
+
if (typeof value.file === 'string') candidates.push(value.file);
|
|
83
|
+
return candidates;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Helper: Walk directory tree and collect files
|
|
90
|
+
async function walkFiles(dirPath) {
|
|
91
|
+
const files = [];
|
|
92
|
+
const stack = [dirPath];
|
|
93
|
+
|
|
94
|
+
while (stack.length > 0) {
|
|
95
|
+
const current = stack.pop();
|
|
96
|
+
try {
|
|
97
|
+
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
const entryPath = path.join(current, entry.name);
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
stack.push(entryPath);
|
|
102
|
+
} else if (entry.isFile()) {
|
|
103
|
+
files.push(entryPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (e) {
|
|
107
|
+
// Skip directories we can't read
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return files;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Validate manifest (plugin.json)
|
|
115
|
+
async function validateManifest(pluginDir) {
|
|
116
|
+
const issues = [];
|
|
117
|
+
const manifestPath = path.join(pluginDir, '.cursor-plugin', 'plugin.json');
|
|
118
|
+
|
|
119
|
+
if (!fs.existsSync(manifestPath)) {
|
|
120
|
+
issues.push({
|
|
121
|
+
severity: 'error',
|
|
122
|
+
code: 'MANIFEST_MISSING',
|
|
123
|
+
message: 'Missing .cursor-plugin/plugin.json manifest file',
|
|
124
|
+
});
|
|
125
|
+
return issues;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let manifest;
|
|
129
|
+
try {
|
|
130
|
+
const content = fs.readFileSync(manifestPath, 'utf8');
|
|
131
|
+
manifest = JSON.parse(content);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
issues.push({
|
|
134
|
+
severity: 'error',
|
|
135
|
+
code: 'MANIFEST_INVALID_JSON',
|
|
136
|
+
message: `Invalid JSON in plugin.json: ${e.message}`,
|
|
137
|
+
});
|
|
138
|
+
return issues;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Check required name field
|
|
142
|
+
if (!manifest.name) {
|
|
143
|
+
issues.push({
|
|
144
|
+
severity: 'error',
|
|
145
|
+
code: 'MANIFEST_NAME_REQUIRED',
|
|
146
|
+
message: 'plugin.json must have a "name" field',
|
|
147
|
+
});
|
|
148
|
+
} else if (!PLUGIN_NAME_PATTERN.test(manifest.name)) {
|
|
149
|
+
issues.push({
|
|
150
|
+
severity: 'error',
|
|
151
|
+
code: 'MANIFEST_NAME_INVALID',
|
|
152
|
+
message: `Plugin name "${manifest.name}" must be lowercase, use alphanumerics, hyphens, and periods, and start/end with alphanumeric`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check version if present
|
|
157
|
+
if (manifest.version && !SEMVER_PATTERN.test(manifest.version)) {
|
|
158
|
+
issues.push({
|
|
159
|
+
severity: 'error',
|
|
160
|
+
code: 'MANIFEST_VERSION_INVALID',
|
|
161
|
+
message: `Version "${manifest.version}" is not valid semver`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Check author if present
|
|
166
|
+
if (manifest.author) {
|
|
167
|
+
if (typeof manifest.author !== 'object' || !manifest.author.name) {
|
|
168
|
+
issues.push({
|
|
169
|
+
severity: 'error',
|
|
170
|
+
code: 'MANIFEST_AUTHOR_INVALID',
|
|
171
|
+
message: 'author field must be an object with a "name" property',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Check referenced paths exist and are safe
|
|
177
|
+
const pathFields = ['logo', 'rules', 'skills', 'agents', 'commands', 'hooks', 'mcpServers'];
|
|
178
|
+
for (const field of pathFields) {
|
|
179
|
+
if (!manifest[field]) continue;
|
|
180
|
+
|
|
181
|
+
const paths = extractPathValues(manifest[field]);
|
|
182
|
+
for (const pathValue of paths) {
|
|
183
|
+
// Skip URLs (allowed for logo)
|
|
184
|
+
if (pathValue.startsWith('http://') || pathValue.startsWith('https://')) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!isSafeRelativePath(pathValue)) {
|
|
189
|
+
issues.push({
|
|
190
|
+
severity: 'error',
|
|
191
|
+
code: 'MANIFEST_PATH_UNSAFE',
|
|
192
|
+
message: `Field "${field}" has unsafe path "${pathValue}" (must be relative, no "..")`,
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const resolved = path.resolve(pluginDir, pathValue);
|
|
198
|
+
if (!fs.existsSync(resolved)) {
|
|
199
|
+
issues.push({
|
|
200
|
+
severity: 'error',
|
|
201
|
+
code: 'MANIFEST_PATH_MISSING',
|
|
202
|
+
message: `Field "${field}" references missing path "${pathValue}"`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return issues;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Validate component frontmatter
|
|
212
|
+
async function validateComponentFrontmatter(pluginDir) {
|
|
213
|
+
const results = [];
|
|
214
|
+
|
|
215
|
+
// Rules: .mdc/.md files in rules/ must have description
|
|
216
|
+
const rulesDir = path.join(pluginDir, 'rules');
|
|
217
|
+
if (fs.existsSync(rulesDir) && fs.statSync(rulesDir).isDirectory()) {
|
|
218
|
+
const files = await walkFiles(rulesDir);
|
|
219
|
+
for (const file of files) {
|
|
220
|
+
const ext = path.extname(file).toLowerCase();
|
|
221
|
+
if (ext === '.md' || ext === '.mdc' || ext === '.markdown') {
|
|
222
|
+
const issues = [];
|
|
223
|
+
try {
|
|
224
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
225
|
+
const fm = parseFrontmatter(content);
|
|
226
|
+
|
|
227
|
+
if (!fm) {
|
|
228
|
+
issues.push({
|
|
229
|
+
severity: 'error',
|
|
230
|
+
code: 'RULE_MISSING_FRONTMATTER',
|
|
231
|
+
message: 'Rule file missing YAML frontmatter',
|
|
232
|
+
});
|
|
233
|
+
} else if (!fm.description) {
|
|
234
|
+
issues.push({
|
|
235
|
+
severity: 'error',
|
|
236
|
+
code: 'RULE_MISSING_DESCRIPTION',
|
|
237
|
+
message: 'Rule frontmatter missing "description" field',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
} catch (e) {
|
|
241
|
+
issues.push({
|
|
242
|
+
severity: 'error',
|
|
243
|
+
code: 'RULE_READ_ERROR',
|
|
244
|
+
message: `Failed to read rule file: ${e.message}`,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (issues.length > 0) {
|
|
249
|
+
results.push({ file: path.relative(pluginDir, file), issues });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Skills: SKILL.md files must have name and description
|
|
256
|
+
const skillsDir = path.join(pluginDir, 'skills');
|
|
257
|
+
if (fs.existsSync(skillsDir) && fs.statSync(skillsDir).isDirectory()) {
|
|
258
|
+
const files = await walkFiles(skillsDir);
|
|
259
|
+
for (const file of files) {
|
|
260
|
+
if (path.basename(file) === 'SKILL.md') {
|
|
261
|
+
const issues = [];
|
|
262
|
+
try {
|
|
263
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
264
|
+
const fm = parseFrontmatter(content);
|
|
265
|
+
|
|
266
|
+
if (!fm) {
|
|
267
|
+
issues.push({
|
|
268
|
+
severity: 'error',
|
|
269
|
+
code: 'SKILL_MISSING_FRONTMATTER',
|
|
270
|
+
message: 'Skill file missing YAML frontmatter',
|
|
271
|
+
});
|
|
272
|
+
} else {
|
|
273
|
+
if (!fm.name) {
|
|
274
|
+
issues.push({
|
|
275
|
+
severity: 'error',
|
|
276
|
+
code: 'SKILL_MISSING_NAME',
|
|
277
|
+
message: 'Skill frontmatter missing "name" field',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (!fm.description) {
|
|
281
|
+
issues.push({
|
|
282
|
+
severity: 'error',
|
|
283
|
+
code: 'SKILL_MISSING_DESCRIPTION',
|
|
284
|
+
message: 'Skill frontmatter missing "description" field',
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch (e) {
|
|
289
|
+
issues.push({
|
|
290
|
+
severity: 'error',
|
|
291
|
+
code: 'SKILL_READ_ERROR',
|
|
292
|
+
message: `Failed to read skill file: ${e.message}`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (issues.length > 0) {
|
|
297
|
+
results.push({ file: path.relative(pluginDir, file), issues });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Agents: .md files must have name and description
|
|
304
|
+
const agentsDir = path.join(pluginDir, 'agents');
|
|
305
|
+
if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory()) {
|
|
306
|
+
const files = await walkFiles(agentsDir);
|
|
307
|
+
for (const file of files) {
|
|
308
|
+
const ext = path.extname(file).toLowerCase();
|
|
309
|
+
if (ext === '.md' || ext === '.mdc' || ext === '.markdown') {
|
|
310
|
+
const issues = [];
|
|
311
|
+
try {
|
|
312
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
313
|
+
const fm = parseFrontmatter(content);
|
|
314
|
+
|
|
315
|
+
if (!fm) {
|
|
316
|
+
issues.push({
|
|
317
|
+
severity: 'error',
|
|
318
|
+
code: 'AGENT_MISSING_FRONTMATTER',
|
|
319
|
+
message: 'Agent file missing YAML frontmatter',
|
|
320
|
+
});
|
|
321
|
+
} else {
|
|
322
|
+
if (!fm.name) {
|
|
323
|
+
issues.push({
|
|
324
|
+
severity: 'error',
|
|
325
|
+
code: 'AGENT_MISSING_NAME',
|
|
326
|
+
message: 'Agent frontmatter missing "name" field',
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
if (!fm.description) {
|
|
330
|
+
issues.push({
|
|
331
|
+
severity: 'error',
|
|
332
|
+
code: 'AGENT_MISSING_DESCRIPTION',
|
|
333
|
+
message: 'Agent frontmatter missing "description" field',
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} catch (e) {
|
|
338
|
+
issues.push({
|
|
339
|
+
severity: 'error',
|
|
340
|
+
code: 'AGENT_READ_ERROR',
|
|
341
|
+
message: `Failed to read agent file: ${e.message}`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (issues.length > 0) {
|
|
346
|
+
results.push({ file: path.relative(pluginDir, file), issues });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Commands: .md/.txt files must have name and description
|
|
353
|
+
const commandsDir = path.join(pluginDir, 'commands');
|
|
354
|
+
if (fs.existsSync(commandsDir) && fs.statSync(commandsDir).isDirectory()) {
|
|
355
|
+
const files = await walkFiles(commandsDir);
|
|
356
|
+
for (const file of files) {
|
|
357
|
+
const ext = path.extname(file).toLowerCase();
|
|
358
|
+
if (ext === '.md' || ext === '.mdc' || ext === '.markdown' || ext === '.txt') {
|
|
359
|
+
const issues = [];
|
|
360
|
+
try {
|
|
361
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
362
|
+
const fm = parseFrontmatter(content);
|
|
363
|
+
|
|
364
|
+
if (!fm) {
|
|
365
|
+
issues.push({
|
|
366
|
+
severity: 'error',
|
|
367
|
+
code: 'COMMAND_MISSING_FRONTMATTER',
|
|
368
|
+
message: 'Command file missing YAML frontmatter',
|
|
369
|
+
});
|
|
370
|
+
} else {
|
|
371
|
+
if (!fm.name) {
|
|
372
|
+
issues.push({
|
|
373
|
+
severity: 'error',
|
|
374
|
+
code: 'COMMAND_MISSING_NAME',
|
|
375
|
+
message: 'Command frontmatter missing "name" field',
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (!fm.description) {
|
|
379
|
+
issues.push({
|
|
380
|
+
severity: 'error',
|
|
381
|
+
code: 'COMMAND_MISSING_DESCRIPTION',
|
|
382
|
+
message: 'Command frontmatter missing "description" field',
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} catch (e) {
|
|
387
|
+
issues.push({
|
|
388
|
+
severity: 'error',
|
|
389
|
+
code: 'COMMAND_READ_ERROR',
|
|
390
|
+
message: `Failed to read command file: ${e.message}`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (issues.length > 0) {
|
|
395
|
+
results.push({ file: path.relative(pluginDir, file), issues });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return results;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Validate hooks configuration
|
|
405
|
+
function validateHooks(pluginDir) {
|
|
406
|
+
const issues = [];
|
|
407
|
+
const hooksPath = path.join(pluginDir, 'hooks', 'hooks.json');
|
|
408
|
+
|
|
409
|
+
if (!fs.existsSync(hooksPath)) {
|
|
410
|
+
// Hooks are optional, so just return empty
|
|
411
|
+
return issues;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let hooks;
|
|
415
|
+
try {
|
|
416
|
+
const content = fs.readFileSync(hooksPath, 'utf8');
|
|
417
|
+
hooks = JSON.parse(content);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
issues.push({
|
|
420
|
+
severity: 'error',
|
|
421
|
+
code: 'HOOKS_INVALID_JSON',
|
|
422
|
+
message: `Invalid JSON in hooks/hooks.json: ${e.message}`,
|
|
423
|
+
});
|
|
424
|
+
return issues;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Check that all event names are valid
|
|
428
|
+
if (hooks && typeof hooks === 'object') {
|
|
429
|
+
for (const eventName of Object.keys(hooks)) {
|
|
430
|
+
if (!VALID_HOOK_EVENTS.has(eventName)) {
|
|
431
|
+
issues.push({
|
|
432
|
+
severity: 'error',
|
|
433
|
+
code: 'HOOKS_INVALID_EVENT',
|
|
434
|
+
message: `Invalid hook event name: "${eventName}"`,
|
|
435
|
+
hint: `Valid events: ${Array.from(VALID_HOOK_EVENTS).join(', ')}`,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return issues;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Validate MCP configuration
|
|
445
|
+
function validateMCP(pluginDir) {
|
|
446
|
+
const issues = [];
|
|
447
|
+
const mcpPath = path.join(pluginDir, '.mcp.json');
|
|
448
|
+
|
|
449
|
+
if (!fs.existsSync(mcpPath)) {
|
|
450
|
+
// MCP config is optional
|
|
451
|
+
return issues;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
let mcp;
|
|
455
|
+
try {
|
|
456
|
+
const content = fs.readFileSync(mcpPath, 'utf8');
|
|
457
|
+
mcp = JSON.parse(content);
|
|
458
|
+
} catch (e) {
|
|
459
|
+
issues.push({
|
|
460
|
+
severity: 'error',
|
|
461
|
+
code: 'MCP_INVALID_JSON',
|
|
462
|
+
message: `Invalid JSON in .mcp.json: ${e.message}`,
|
|
463
|
+
});
|
|
464
|
+
return issues;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Check that each server entry has command or url
|
|
468
|
+
if (mcp && typeof mcp === 'object') {
|
|
469
|
+
for (const [serverName, config] of Object.entries(mcp)) {
|
|
470
|
+
if (!config || typeof config !== 'object') continue;
|
|
471
|
+
|
|
472
|
+
if (!config.command && !config.url) {
|
|
473
|
+
issues.push({
|
|
474
|
+
severity: 'warning',
|
|
475
|
+
code: 'MCP_SERVER_NO_ENDPOINT',
|
|
476
|
+
message: `MCP server "${serverName}" has neither "command" nor "url" field`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return issues;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Validate marketplace.json
|
|
486
|
+
function validateMarketplace(pluginDir) {
|
|
487
|
+
const issues = [];
|
|
488
|
+
const marketplacePath = path.join(pluginDir, '.cursor-plugin', 'marketplace.json');
|
|
489
|
+
|
|
490
|
+
if (!fs.existsSync(marketplacePath)) {
|
|
491
|
+
// Marketplace file is optional
|
|
492
|
+
return issues;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let marketplace;
|
|
496
|
+
try {
|
|
497
|
+
const content = fs.readFileSync(marketplacePath, 'utf8');
|
|
498
|
+
marketplace = JSON.parse(content);
|
|
499
|
+
} catch (e) {
|
|
500
|
+
issues.push({
|
|
501
|
+
severity: 'error',
|
|
502
|
+
code: 'MARKETPLACE_INVALID_JSON',
|
|
503
|
+
message: `Invalid JSON in marketplace.json: ${e.message}`,
|
|
504
|
+
});
|
|
505
|
+
return issues;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Check required name field (stricter pattern - no periods)
|
|
509
|
+
if (!marketplace.name) {
|
|
510
|
+
issues.push({
|
|
511
|
+
severity: 'error',
|
|
512
|
+
code: 'MARKETPLACE_NAME_REQUIRED',
|
|
513
|
+
message: 'marketplace.json must have a "name" field',
|
|
514
|
+
});
|
|
515
|
+
} else if (!MARKETPLACE_NAME_PATTERN.test(marketplace.name)) {
|
|
516
|
+
issues.push({
|
|
517
|
+
severity: 'error',
|
|
518
|
+
code: 'MARKETPLACE_NAME_INVALID',
|
|
519
|
+
message: `Marketplace name "${marketplace.name}" must be lowercase kebab-case (no periods)`,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Check owner.name
|
|
524
|
+
if (!marketplace.owner || !marketplace.owner.name) {
|
|
525
|
+
issues.push({
|
|
526
|
+
severity: 'error',
|
|
527
|
+
code: 'MARKETPLACE_OWNER_REQUIRED',
|
|
528
|
+
message: 'marketplace.json must have "owner.name" field',
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Check plugins array
|
|
533
|
+
if (!Array.isArray(marketplace.plugins) || marketplace.plugins.length === 0) {
|
|
534
|
+
issues.push({
|
|
535
|
+
severity: 'error',
|
|
536
|
+
code: 'MARKETPLACE_PLUGINS_REQUIRED',
|
|
537
|
+
message: 'marketplace.json "plugins" must be a non-empty array',
|
|
538
|
+
});
|
|
539
|
+
return issues;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// Check each plugin entry
|
|
543
|
+
const seenNames = new Set();
|
|
544
|
+
for (const [index, plugin] of marketplace.plugins.entries()) {
|
|
545
|
+
if (!plugin || typeof plugin !== 'object') {
|
|
546
|
+
issues.push({
|
|
547
|
+
severity: 'error',
|
|
548
|
+
code: 'MARKETPLACE_PLUGIN_INVALID',
|
|
549
|
+
message: `Plugin entry ${index} must be an object`,
|
|
550
|
+
});
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (!plugin.name) {
|
|
555
|
+
issues.push({
|
|
556
|
+
severity: 'error',
|
|
557
|
+
code: 'MARKETPLACE_PLUGIN_NAME_REQUIRED',
|
|
558
|
+
message: `Plugin entry ${index} missing "name" field`,
|
|
559
|
+
});
|
|
560
|
+
} else {
|
|
561
|
+
if (seenNames.has(plugin.name)) {
|
|
562
|
+
issues.push({
|
|
563
|
+
severity: 'error',
|
|
564
|
+
code: 'MARKETPLACE_PLUGIN_DUPLICATE',
|
|
565
|
+
message: `Duplicate plugin name in marketplace: "${plugin.name}"`,
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
seenNames.add(plugin.name);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (!plugin.source) {
|
|
572
|
+
issues.push({
|
|
573
|
+
severity: 'error',
|
|
574
|
+
code: 'MARKETPLACE_PLUGIN_SOURCE_REQUIRED',
|
|
575
|
+
message: `Plugin "${plugin.name || index}" missing "source" field`,
|
|
576
|
+
});
|
|
577
|
+
} else if (!isSafeRelativePath(plugin.source)) {
|
|
578
|
+
issues.push({
|
|
579
|
+
severity: 'error',
|
|
580
|
+
code: 'MARKETPLACE_PLUGIN_SOURCE_UNSAFE',
|
|
581
|
+
message: `Plugin "${plugin.name || index}" source path is unsafe (must be relative, no "..")`,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return issues;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Main lint function
|
|
590
|
+
async function lintPlugin(dir) {
|
|
591
|
+
const results = [];
|
|
592
|
+
|
|
593
|
+
// Check if this looks like a plugin directory
|
|
594
|
+
const pluginManifestPath = path.join(dir, '.cursor-plugin', 'plugin.json');
|
|
595
|
+
const marketplaceManifestPath = path.join(dir, '.cursor-plugin', 'marketplace.json');
|
|
596
|
+
|
|
597
|
+
if (!fs.existsSync(pluginManifestPath) && !fs.existsSync(marketplaceManifestPath)) {
|
|
598
|
+
results.push({
|
|
599
|
+
file: dir,
|
|
600
|
+
issues: [{
|
|
601
|
+
severity: 'error',
|
|
602
|
+
code: 'NOT_A_PLUGIN',
|
|
603
|
+
message: 'No .cursor-plugin/plugin.json or .cursor-plugin/marketplace.json found',
|
|
604
|
+
hint: 'This does not appear to be a Cursor plugin directory',
|
|
605
|
+
}],
|
|
606
|
+
});
|
|
607
|
+
return results;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Validate manifest
|
|
611
|
+
const manifestIssues = await validateManifest(dir);
|
|
612
|
+
if (manifestIssues.length > 0) {
|
|
613
|
+
results.push({
|
|
614
|
+
file: '.cursor-plugin/plugin.json',
|
|
615
|
+
issues: manifestIssues,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Validate component frontmatter
|
|
620
|
+
const componentResults = await validateComponentFrontmatter(dir);
|
|
621
|
+
results.push(...componentResults);
|
|
622
|
+
|
|
623
|
+
// Validate hooks
|
|
624
|
+
const hooksIssues = validateHooks(dir);
|
|
625
|
+
if (hooksIssues.length > 0) {
|
|
626
|
+
results.push({
|
|
627
|
+
file: 'hooks/hooks.json',
|
|
628
|
+
issues: hooksIssues,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Validate MCP config
|
|
633
|
+
const mcpIssues = validateMCP(dir);
|
|
634
|
+
if (mcpIssues.length > 0) {
|
|
635
|
+
results.push({
|
|
636
|
+
file: '.mcp.json',
|
|
637
|
+
issues: mcpIssues,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Validate marketplace manifest if present
|
|
642
|
+
const marketplaceIssues = validateMarketplace(dir);
|
|
643
|
+
if (marketplaceIssues.length > 0) {
|
|
644
|
+
results.push({
|
|
645
|
+
file: '.cursor-plugin/marketplace.json',
|
|
646
|
+
issues: marketplaceIssues,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
return results;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
module.exports = { lintPlugin };
|