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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursor-lint",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Lint your Cursor rules — catch common mistakes before they break your workflow",
5
5
  "main": "src/index.js",
6
6
  "bin": {
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 ${cwd}...\n`);
239
- const report = await doctor(cwd);
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 };