cursor-lint 0.13.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 +251 -0
- package/src/diff.js +93 -0
- package/src/doctor.js +162 -0
- package/src/migrate.js +118 -0
- package/src/plugin.js +653 -0
- package/src/stats.js +183 -0
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 };
|