@orchid-labs/pluxx 0.1.1 → 0.1.4

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.
Files changed (103) hide show
  1. package/README.md +25 -8
  2. package/bin/pluxx.js +19 -28
  3. package/dist/agents.d.ts +16 -0
  4. package/dist/agents.d.ts.map +1 -0
  5. package/dist/cli/agent.d.ts +62 -0
  6. package/dist/cli/agent.d.ts.map +1 -1
  7. package/dist/cli/doctor.d.ts +2 -0
  8. package/dist/cli/doctor.d.ts.map +1 -1
  9. package/dist/cli/entry.d.ts +2 -0
  10. package/dist/cli/entry.d.ts.map +1 -0
  11. package/dist/cli/index.d.ts +7 -1
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +21810 -0
  14. package/dist/cli/init-from-mcp.d.ts +17 -1
  15. package/dist/cli/init-from-mcp.d.ts.map +1 -1
  16. package/dist/cli/install.d.ts +1 -0
  17. package/dist/cli/install.d.ts.map +1 -1
  18. package/dist/cli/lint.d.ts +3 -1
  19. package/dist/cli/lint.d.ts.map +1 -1
  20. package/dist/cli/mcp-proxy.d.ts.map +1 -1
  21. package/dist/cli/migrate.d.ts.map +1 -1
  22. package/dist/cli/primitive-summary.d.ts +14 -0
  23. package/dist/cli/primitive-summary.d.ts.map +1 -0
  24. package/dist/cli/prompt.d.ts +1 -1
  25. package/dist/cli/publish.d.ts +6 -1
  26. package/dist/cli/publish.d.ts.map +1 -1
  27. package/dist/cli/sync-from-mcp.d.ts.map +1 -1
  28. package/dist/cli/verify-install.d.ts +25 -0
  29. package/dist/cli/verify-install.d.ts.map +1 -0
  30. package/dist/commands.d.ts +10 -0
  31. package/dist/commands.d.ts.map +1 -0
  32. package/dist/compiler-intent.d.ts +165 -0
  33. package/dist/compiler-intent.d.ts.map +1 -0
  34. package/dist/config/load.d.ts.map +1 -1
  35. package/dist/delegation.d.ts +11 -0
  36. package/dist/delegation.d.ts.map +1 -0
  37. package/dist/generators/amp/index.d.ts.map +1 -1
  38. package/dist/generators/base.d.ts +5 -0
  39. package/dist/generators/base.d.ts.map +1 -1
  40. package/dist/generators/claude-code/index.d.ts.map +1 -1
  41. package/dist/generators/cline/index.d.ts.map +1 -1
  42. package/dist/generators/codex/index.d.ts +4 -0
  43. package/dist/generators/codex/index.d.ts.map +1 -1
  44. package/dist/generators/cursor/index.d.ts +1 -0
  45. package/dist/generators/cursor/index.d.ts.map +1 -1
  46. package/dist/generators/gemini-cli/index.d.ts.map +1 -1
  47. package/dist/generators/github-copilot/index.d.ts.map +1 -1
  48. package/dist/generators/opencode/index.d.ts +1 -0
  49. package/dist/generators/opencode/index.d.ts.map +1 -1
  50. package/dist/generators/openhands/index.d.ts.map +1 -1
  51. package/dist/generators/roo-code/index.d.ts.map +1 -1
  52. package/dist/generators/shared/claude-family.d.ts.map +1 -1
  53. package/dist/generators/warp/index.d.ts.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +5371 -553
  57. package/dist/schema.d.ts +91 -42
  58. package/dist/schema.d.ts.map +1 -1
  59. package/dist/text-files.d.ts +5 -0
  60. package/dist/text-files.d.ts.map +1 -0
  61. package/dist/validation/platform-rules.d.ts +15 -1
  62. package/dist/validation/platform-rules.d.ts.map +1 -1
  63. package/package.json +15 -13
  64. package/src/cli/agent.ts +0 -1455
  65. package/src/cli/dev.ts +0 -112
  66. package/src/cli/doctor.ts +0 -987
  67. package/src/cli/eval.ts +0 -470
  68. package/src/cli/index.ts +0 -2933
  69. package/src/cli/init-from-mcp.ts +0 -2115
  70. package/src/cli/install.ts +0 -860
  71. package/src/cli/lint.ts +0 -1249
  72. package/src/cli/mcp-proxy.ts +0 -322
  73. package/src/cli/migrate.ts +0 -867
  74. package/src/cli/prompt.ts +0 -82
  75. package/src/cli/publish.ts +0 -401
  76. package/src/cli/runtime.ts +0 -86
  77. package/src/cli/sync-from-mcp.ts +0 -586
  78. package/src/cli/test.ts +0 -142
  79. package/src/compatibility/matrix.ts +0 -149
  80. package/src/config/define.ts +0 -20
  81. package/src/config/load.ts +0 -74
  82. package/src/generators/amp/index.ts +0 -63
  83. package/src/generators/base.ts +0 -188
  84. package/src/generators/claude-code/index.ts +0 -172
  85. package/src/generators/cline/index.ts +0 -35
  86. package/src/generators/codex/index.ts +0 -143
  87. package/src/generators/cursor/index.ts +0 -158
  88. package/src/generators/gemini-cli/index.ts +0 -83
  89. package/src/generators/github-copilot/index.ts +0 -32
  90. package/src/generators/hooks-warning.ts +0 -51
  91. package/src/generators/index.ts +0 -71
  92. package/src/generators/opencode/index.ts +0 -526
  93. package/src/generators/openhands/index.ts +0 -32
  94. package/src/generators/roo-code/index.ts +0 -35
  95. package/src/generators/shared/claude-family.ts +0 -215
  96. package/src/generators/warp/index.ts +0 -32
  97. package/src/hook-events.ts +0 -33
  98. package/src/index.ts +0 -34
  99. package/src/mcp/introspect.ts +0 -1107
  100. package/src/permissions.ts +0 -260
  101. package/src/schema.ts +0 -312
  102. package/src/user-config.ts +0 -177
  103. package/src/validation/platform-rules.ts +0 -686
package/src/cli/lint.ts DELETED
@@ -1,1249 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync } from 'fs'
2
- import { resolve, relative, basename, dirname } from 'path'
3
- import { loadConfig } from '../config/load'
4
- import type { PluginConfig, TargetPlatform } from '../schema'
5
- import { PLATFORM_LIMITS } from '../validation/platform-rules'
6
- import { collectPermissionRules, permissionRulesNeedToolLevelDowngrade } from '../permissions'
7
- import {
8
- CURSOR_LOOP_LIMIT_HOOK_EVENTS,
9
- CURSOR_SUPPORTED_HOOK_EVENTS,
10
- mapHookEventToPascalCase,
11
- } from '../hook-events'
12
-
13
- const AGENT_SKILLS_RULES = { name: { pattern: /^[a-z0-9-]+$/, maxLength: 64 }, description: { maxLength: 1024 } }
14
- const CLAUDE_CODE_RULES = { description: { maxDisplayLength: 250 } }
15
- const CODEX_RULES = {
16
- interface: { maxDefaultPrompts: 3, maxDefaultPromptLength: 128, brandColorPattern: /^#[0-9a-fA-F]{6}$/, knownCapabilities: ['Interactive', 'Write', 'Read'] as const },
17
- manifestPaths: { requiredPrefix: './' },
18
- mcp: { serverNamePattern: /^[a-z0-9_-]+$/ },
19
- hooks: { supportedEvents: ['SessionStart', 'PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop'] as const },
20
- agents: { maxThreadsMin: 1, maxDepthMin: 1 },
21
- settings: { validKeys: ['agent'] as const },
22
- }
23
-
24
- const CLAUDE_CODE_HOOK_EVENTS = [
25
- 'SessionStart', 'PreToolUse', 'PostToolUse', 'UserPromptSubmit',
26
- 'PermissionRequest', 'PermissionDenied', 'PostToolUseFailure',
27
- 'Notification', 'SubagentStart', 'SubagentStop',
28
- 'TaskCreated', 'TaskCompleted', 'Stop', 'StopFailure',
29
- 'TeammateIdle', 'InstructionsLoaded', 'ConfigChange', 'CwdChanged',
30
- 'FileChanged', 'WorktreeCreate', 'WorktreeRemove',
31
- 'PreCompact', 'PostCompact', 'Elicitation', 'ElicitationResult', 'SessionEnd',
32
- ] as const
33
-
34
- const CLAUDE_CODE_HOOK_TYPES = ['command', 'http', 'prompt', 'agent'] as const
35
-
36
- const AGENT_FORBIDDEN_FRONTMATTER = ['hooks', 'mcpServers', 'permissionMode'] as const
37
-
38
- const SEMVER_REGEX = /^\d+\.\d+\.\d+$/
39
-
40
- const PLUGIN_NAME_KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/
41
-
42
- type LintLevel = 'error' | 'warning'
43
-
44
- interface LintIssue {
45
- level: LintLevel
46
- code: string
47
- message: string
48
- file?: string
49
- platform?: string
50
- }
51
-
52
- interface FrontmatterField {
53
- key: string
54
- value: string
55
- rawValue: string
56
- quoted: boolean
57
- }
58
-
59
- interface ParsedFrontmatterFile {
60
- parsed: { fields: Map<string, FrontmatterField>; valid: boolean }
61
- }
62
-
63
- export interface LintResult {
64
- errors: number
65
- warnings: number
66
- issues: LintIssue[]
67
- }
68
-
69
- export interface LintProjectOptions {
70
- targets?: TargetPlatform[]
71
- }
72
-
73
- const SKILL_NAME_REGEX = AGENT_SKILLS_RULES.name.pattern
74
- const MAX_AGENT_SKILLS_DESCRIPTION = AGENT_SKILLS_RULES.description.maxLength
75
- const MAX_CLAUDE_DESCRIPTION = CLAUDE_CODE_RULES.description.maxDisplayLength
76
- const MAX_SKILL_NAME = AGENT_SKILLS_RULES.name.maxLength
77
- const MAX_CODEX_DEFAULT_PROMPTS = CODEX_RULES.interface.maxDefaultPrompts
78
- const MAX_CODEX_PROMPT_LENGTH = CODEX_RULES.interface.maxDefaultPromptLength
79
- const HEX_COLOR_REGEX = CODEX_RULES.interface.brandColorPattern
80
-
81
- function pushIssue(issues: LintIssue[], issue: LintIssue): void {
82
- issues.push(issue)
83
- }
84
-
85
- function collectSkillFiles(dir: string): string[] {
86
- if (!existsSync(dir)) return []
87
-
88
- const files: string[] = []
89
- const entries = readdirSync(dir, { withFileTypes: true })
90
-
91
- for (const entry of entries) {
92
- const fullPath = resolve(dir, entry.name)
93
- if (entry.isDirectory()) {
94
- files.push(...collectSkillFiles(fullPath))
95
- continue
96
- }
97
- if (entry.isFile() && entry.name === 'SKILL.md') {
98
- files.push(fullPath)
99
- }
100
- }
101
-
102
- return files
103
- }
104
-
105
- function unquote(value: string): { value: string; quoted: boolean } {
106
- const trimmed = value.trim()
107
- if (trimmed.length >= 2) {
108
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
109
- return { value: trimmed.slice(1, -1), quoted: true }
110
- }
111
- }
112
- return { value: trimmed, quoted: false }
113
- }
114
-
115
- function parseFrontmatter(content: string): { fields: Map<string, FrontmatterField>; valid: boolean } {
116
- const lines = content.split(/\r?\n/)
117
- if (lines[0]?.trim() !== '---') {
118
- return { fields: new Map(), valid: false }
119
- }
120
-
121
- let endIndex = -1
122
- for (let i = 1; i < lines.length; i += 1) {
123
- if (lines[i].trim() === '---') {
124
- endIndex = i
125
- break
126
- }
127
- }
128
-
129
- if (endIndex === -1) {
130
- return { fields: new Map(), valid: false }
131
- }
132
-
133
- const fields = new Map<string, FrontmatterField>()
134
- for (const line of lines.slice(1, endIndex)) {
135
- const trimmed = line.trim()
136
- if (!trimmed || trimmed.startsWith('#')) continue
137
-
138
- const match = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/)
139
- if (!match) continue
140
-
141
- const key = match[1]
142
- const rawValue = match[2]
143
- const parsed = unquote(rawValue)
144
- fields.set(key, {
145
- key,
146
- value: parsed.value,
147
- rawValue: rawValue.trim(),
148
- quoted: parsed.quoted,
149
- })
150
- }
151
-
152
- return { fields, valid: true }
153
- }
154
-
155
- function getParsedFrontmatterFile(filePath: string, cache: Map<string, ParsedFrontmatterFile>): ParsedFrontmatterFile {
156
- const cached = cache.get(filePath)
157
- if (cached) return cached
158
-
159
- const entry = {
160
- parsed: parseFrontmatter(readFileSync(filePath, 'utf-8')),
161
- }
162
- cache.set(filePath, entry)
163
- return entry
164
- }
165
-
166
- function needsQuotes(value: string): boolean {
167
- const startsWithSpecial = /^[\[\]{},&*!|>@`]/.test(value)
168
- const containsCommentChar = /\s#/.test(value)
169
- const containsYamlColon = /:\s/.test(value)
170
- const hasLeadingOrTrailingSpace = value !== value.trim()
171
- return startsWithSpecial || containsCommentChar || containsYamlColon || hasLeadingOrTrailingSpace
172
- }
173
-
174
- function normalizeWhitespace(value: string): string {
175
- return value.split(/\s+/).filter(Boolean).join(' ')
176
- }
177
-
178
- function isCodexTargetEnabled(config: PluginConfig): boolean {
179
- return config.targets.includes('codex')
180
- }
181
-
182
- function isCodexManifestRelativePath(path: string): boolean {
183
- if (!path.startsWith(CODEX_RULES.manifestPaths.requiredPrefix)) return false
184
- if (path === CODEX_RULES.manifestPaths.requiredPrefix) return false
185
- const relativePath = path.slice(CODEX_RULES.manifestPaths.requiredPrefix.length)
186
- if (relativePath.includes('..')) return false
187
- return !relativePath.startsWith('/') && !relativePath.startsWith('\\')
188
- }
189
-
190
- function asRecord(value: unknown): Record<string, unknown> | null {
191
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
192
- return value as Record<string, unknown>
193
- }
194
-
195
- function lintSkillFile(
196
- skillFile: string,
197
- targets: TargetPlatform[],
198
- issues: LintIssue[],
199
- frontmatterCache: Map<string, ParsedFrontmatterFile>,
200
- ): void {
201
- const { parsed } = getParsedFrontmatterFile(skillFile, frontmatterCache)
202
-
203
- if (!parsed.valid) {
204
- pushIssue(issues, {
205
- level: 'error',
206
- code: 'skill-frontmatter',
207
- message: 'SKILL.md must start with valid YAML frontmatter delimited by --- lines.',
208
- file: skillFile,
209
- platform: 'Agent Skills',
210
- })
211
- return
212
- }
213
-
214
- const nameField = parsed.fields.get('name')
215
- const descriptionField = parsed.fields.get('description')
216
-
217
- if (!nameField?.value) {
218
- pushIssue(issues, {
219
- level: 'error',
220
- code: 'skill-name-missing',
221
- message: 'Frontmatter must include a non-empty `name` field.',
222
- file: skillFile,
223
- platform: 'Agent Skills',
224
- })
225
- } else {
226
- if (!SKILL_NAME_REGEX.test(nameField.value)) {
227
- pushIssue(issues, {
228
- level: 'error',
229
- code: 'skill-name-format',
230
- message: 'Skill name must be lowercase with hyphens only.',
231
- file: skillFile,
232
- platform: 'Agent Skills',
233
- })
234
- }
235
-
236
- if (nameField.value.length > MAX_SKILL_NAME) {
237
- pushIssue(issues, {
238
- level: 'error',
239
- code: 'skill-name-length',
240
- message: `Skill name exceeds ${MAX_SKILL_NAME} characters.`,
241
- file: skillFile,
242
- platform: 'Agent Skills',
243
- })
244
- }
245
-
246
- // Check skill name must match directory for platforms that require it
247
- const expectedDirName = basename(dirname(skillFile))
248
- const platformsRequiringDirMatch = targets.filter(t => PLATFORM_LIMITS[t].skillNameMustMatchDir)
249
- if (platformsRequiringDirMatch.length > 0 && nameField.value !== expectedDirName) {
250
- const platformNames = platformsRequiringDirMatch.join(', ')
251
- pushIssue(issues, {
252
- level: 'error',
253
- code: 'skill-name-dir-mismatch',
254
- message: `Skill name "${nameField.value}" must match directory name "${expectedDirName}" (required by ${platformNames}).`,
255
- file: skillFile,
256
- platform: platformsRequiringDirMatch[0],
257
- })
258
- }
259
-
260
- if (!nameField.quoted && needsQuotes(nameField.rawValue)) {
261
- pushIssue(issues, {
262
- level: 'warning',
263
- code: 'yaml-quote-special-chars',
264
- message: 'Frontmatter name contains YAML-sensitive characters and should be quoted.',
265
- file: skillFile,
266
- platform: 'YAML',
267
- })
268
- }
269
- }
270
-
271
- if (!descriptionField?.value) {
272
- pushIssue(issues, {
273
- level: 'error',
274
- code: 'skill-description-missing',
275
- message: 'Frontmatter must include a non-empty `description` field.',
276
- file: skillFile,
277
- platform: 'Agent Skills',
278
- })
279
- } else {
280
- // Check hard description max for each platform
281
- for (const target of targets) {
282
- const limits = PLATFORM_LIMITS[target]
283
- if (limits.skillDescriptionMax !== null && descriptionField.value.length > limits.skillDescriptionMax) {
284
- pushIssue(issues, {
285
- level: 'error',
286
- code: 'skill-description-length',
287
- message: `Description exceeds ${target} max of ${limits.skillDescriptionMax} characters.`,
288
- file: skillFile,
289
- platform: target,
290
- })
291
- }
292
- }
293
-
294
- // Check display truncation thresholds for each platform
295
- for (const target of targets) {
296
- const limits = PLATFORM_LIMITS[target]
297
- if (limits.skillDescriptionDisplayMax !== null && descriptionField.value.length > limits.skillDescriptionDisplayMax) {
298
- pushIssue(issues, {
299
- level: 'warning',
300
- code: 'skill-description-truncation',
301
- message: `Description will be truncated in ${target} (display limit: ${limits.skillDescriptionDisplayMax}).`,
302
- file: skillFile,
303
- platform: target,
304
- })
305
- }
306
- }
307
-
308
- if (!descriptionField.quoted && needsQuotes(descriptionField.rawValue)) {
309
- pushIssue(issues, {
310
- level: 'warning',
311
- code: 'yaml-quote-special-chars',
312
- message: 'Frontmatter description contains YAML-sensitive characters and should be quoted.',
313
- file: skillFile,
314
- platform: 'YAML',
315
- })
316
- }
317
- }
318
- }
319
-
320
- function lintBrandMetadata(config: PluginConfig, issues: LintIssue[]): void {
321
- if (!config.brand) return
322
-
323
- if (config.brand.color && !HEX_COLOR_REGEX.test(config.brand.color)) {
324
- pushIssue(issues, {
325
- level: 'error',
326
- code: 'brand-color-hex',
327
- message: 'Brand color must be a valid Codex hex color (#RRGGBB).',
328
- file: 'pluxx.config.ts',
329
- platform: 'Codex',
330
- })
331
- }
332
-
333
- if (config.brand.defaultPrompts) {
334
- if (config.brand.defaultPrompts.length > MAX_CODEX_DEFAULT_PROMPTS) {
335
- pushIssue(issues, {
336
- level: 'error',
337
- code: 'codex-default-prompts-count',
338
- message: `Codex supports at most ${MAX_CODEX_DEFAULT_PROMPTS} default prompts.`,
339
- file: 'pluxx.config.ts',
340
- platform: 'Codex',
341
- })
342
- }
343
-
344
- for (const prompt of config.brand.defaultPrompts) {
345
- if (prompt.length > MAX_CODEX_PROMPT_LENGTH) {
346
- pushIssue(issues, {
347
- level: 'error',
348
- code: 'codex-default-prompt-length',
349
- message: `A default prompt exceeds ${MAX_CODEX_PROMPT_LENGTH} characters.`,
350
- file: 'pluxx.config.ts',
351
- platform: 'Codex',
352
- })
353
- }
354
- }
355
- }
356
- }
357
-
358
- function lintCodexOverrides(config: PluginConfig, issues: LintIssue[]): void {
359
- if (!isCodexTargetEnabled(config)) return
360
-
361
- const iface = asRecord(config.platforms?.codex?.interface)
362
- if (!iface) return
363
-
364
- if (typeof iface.brandColor === 'string' && !HEX_COLOR_REGEX.test(iface.brandColor)) {
365
- pushIssue(issues, {
366
- level: 'error',
367
- code: 'codex-interface-brand-color-hex',
368
- message: 'Codex interface.brandColor must be a valid hex color (#RRGGBB).',
369
- file: 'pluxx.config.ts',
370
- platform: 'Codex',
371
- })
372
- }
373
-
374
- if (typeof iface.composerIcon === 'string' && !isCodexManifestRelativePath(iface.composerIcon)) {
375
- pushIssue(issues, {
376
- level: 'error',
377
- code: 'codex-interface-composer-icon-path',
378
- message: 'Codex interface.composerIcon must be a plugin-root-relative path starting with `./`.',
379
- file: 'pluxx.config.ts',
380
- platform: 'Codex',
381
- })
382
- }
383
-
384
- if (typeof iface.logo === 'string' && !isCodexManifestRelativePath(iface.logo)) {
385
- pushIssue(issues, {
386
- level: 'error',
387
- code: 'codex-interface-logo-path',
388
- message: 'Codex interface.logo must be a plugin-root-relative path starting with `./`.',
389
- file: 'pluxx.config.ts',
390
- platform: 'Codex',
391
- })
392
- }
393
-
394
- if (Array.isArray(iface.screenshots)) {
395
- for (const screenshot of iface.screenshots) {
396
- if (typeof screenshot !== 'string' || !isCodexManifestRelativePath(screenshot)) {
397
- pushIssue(issues, {
398
- level: 'error',
399
- code: 'codex-interface-screenshot-path',
400
- message: 'Codex interface.screenshots entries must be plugin-root-relative paths starting with `./`.',
401
- file: 'pluxx.config.ts',
402
- platform: 'Codex',
403
- })
404
- break
405
- }
406
- }
407
- }
408
-
409
- const defaultPrompt = iface.defaultPrompt
410
- const prompts = typeof defaultPrompt === 'string'
411
- ? [defaultPrompt]
412
- : Array.isArray(defaultPrompt) ? defaultPrompt : null
413
-
414
- if (prompts) {
415
- if (prompts.length > MAX_CODEX_DEFAULT_PROMPTS) {
416
- pushIssue(issues, {
417
- level: 'error',
418
- code: 'codex-default-prompts-count',
419
- message: `Codex supports at most ${MAX_CODEX_DEFAULT_PROMPTS} default prompts.`,
420
- file: 'pluxx.config.ts',
421
- platform: 'Codex',
422
- })
423
- }
424
- for (const prompt of prompts) {
425
- if (typeof prompt !== 'string') {
426
- pushIssue(issues, {
427
- level: 'error',
428
- code: 'codex-default-prompt-type',
429
- message: 'Codex interface.defaultPrompt must be a string or an array of strings.',
430
- file: 'pluxx.config.ts',
431
- platform: 'Codex',
432
- })
433
- break
434
- }
435
- const normalized = normalizeWhitespace(prompt)
436
- if (!normalized) {
437
- pushIssue(issues, {
438
- level: 'error',
439
- code: 'codex-default-prompt-empty',
440
- message: 'Codex default prompts must not be empty after whitespace normalization.',
441
- file: 'pluxx.config.ts',
442
- platform: 'Codex',
443
- })
444
- } else if (normalized.length > MAX_CODEX_PROMPT_LENGTH) {
445
- pushIssue(issues, {
446
- level: 'error',
447
- code: 'codex-default-prompt-length',
448
- message: `A default prompt exceeds ${MAX_CODEX_PROMPT_LENGTH} characters.`,
449
- file: 'pluxx.config.ts',
450
- platform: 'Codex',
451
- })
452
- }
453
- }
454
- }
455
-
456
- if (Array.isArray(iface.capabilities)) {
457
- for (const capability of iface.capabilities) {
458
- if (typeof capability !== 'string') {
459
- pushIssue(issues, {
460
- level: 'error',
461
- code: 'codex-interface-capability-type',
462
- message: 'Codex interface.capabilities must contain only strings.',
463
- file: 'pluxx.config.ts',
464
- platform: 'Codex',
465
- })
466
- break
467
- }
468
- if (!CODEX_RULES.interface.knownCapabilities.includes(capability as typeof CODEX_RULES.interface.knownCapabilities[number])) {
469
- pushIssue(issues, {
470
- level: 'warning',
471
- code: 'codex-interface-capability-unknown',
472
- message: `Capability "${capability}" is not in Codex's documented capability set (${CODEX_RULES.interface.knownCapabilities.join(', ')}).`,
473
- file: 'pluxx.config.ts',
474
- platform: 'Codex',
475
- })
476
- }
477
- }
478
- }
479
- }
480
-
481
- function lintMcpUrls(config: PluginConfig, issues: LintIssue[]): void {
482
- if (!config.mcp) return
483
-
484
- for (const [serverName, server] of Object.entries(config.mcp)) {
485
- if (!CODEX_RULES.mcp.serverNamePattern.test(serverName)) {
486
- pushIssue(issues, {
487
- level: 'error',
488
- code: 'mcp-server-name-format',
489
- message: `MCP server name "${serverName}" must match ${CODEX_RULES.mcp.serverNamePattern}.`,
490
- file: 'pluxx.config.ts',
491
- platform: 'MCP',
492
- })
493
- }
494
-
495
- if (!('url' in server) || !server.url) continue
496
- try {
497
- const parsed = new URL(server.url)
498
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
499
- pushIssue(issues, {
500
- level: 'error',
501
- code: 'mcp-url-protocol',
502
- message: `MCP server "${serverName}" must use http:// or https:// URL.`,
503
- file: 'pluxx.config.ts',
504
- platform: 'MCP',
505
- })
506
- }
507
- } catch {
508
- pushIssue(issues, {
509
- level: 'error',
510
- code: 'mcp-url-invalid',
511
- message: `MCP server "${serverName}" has an invalid URL.`,
512
- file: 'pluxx.config.ts',
513
- platform: 'MCP',
514
- })
515
- }
516
- }
517
- }
518
-
519
- function lintCodexHookCompatibility(config: PluginConfig, issues: LintIssue[]): void {
520
- if (!isCodexTargetEnabled(config) || !config.hooks) return
521
-
522
- for (const hookEvent of Object.keys(config.hooks)) {
523
- const mappedEvent = mapHookEventToPascalCase(hookEvent)
524
- if (!CODEX_RULES.hooks.supportedEvents.includes(mappedEvent as typeof CODEX_RULES.hooks.supportedEvents[number])) {
525
- pushIssue(issues, {
526
- level: 'warning',
527
- code: 'codex-hook-event-unsupported',
528
- message: `Codex hooks only support ${CODEX_RULES.hooks.supportedEvents.join(', ')}; "${hookEvent}" maps to "${mappedEvent}", which is not supported.`,
529
- file: 'pluxx.config.ts',
530
- platform: 'Codex',
531
- })
532
- }
533
- }
534
- }
535
-
536
- function lintManifestPromptLimits(config: PluginConfig, issues: LintIssue[]): void {
537
- for (const target of config.targets) {
538
- const limits = PLATFORM_LIMITS[target]
539
- if (limits.manifestPromptCountMax === null && limits.manifestPromptMax === null) continue
540
-
541
- const prompts = config.brand?.defaultPrompts
542
- if (!prompts) continue
543
-
544
- if (limits.manifestPromptCountMax !== null && prompts.length > limits.manifestPromptCountMax) {
545
- pushIssue(issues, {
546
- level: 'error',
547
- code: 'platform-prompt-count',
548
- message: `${target} supports at most ${limits.manifestPromptCountMax} default prompts (found ${prompts.length}).`,
549
- file: 'pluxx.config.ts',
550
- platform: target,
551
- })
552
- }
553
-
554
- if (limits.manifestPromptMax !== null) {
555
- for (const prompt of prompts) {
556
- if (prompt.length > limits.manifestPromptMax) {
557
- pushIssue(issues, {
558
- level: 'error',
559
- code: 'platform-prompt-length',
560
- message: `A default prompt exceeds ${target} max of ${limits.manifestPromptMax} characters.`,
561
- file: 'pluxx.config.ts',
562
- platform: target,
563
- })
564
- }
565
- }
566
- }
567
- }
568
- }
569
-
570
- function lintInstructionsFileLimits(config: PluginConfig, dir: string, issues: LintIssue[]): void {
571
- for (const target of config.targets) {
572
- const limits = PLATFORM_LIMITS[target]
573
- if (limits.instructionsMaxBytes === null) continue
574
-
575
- // Check common instructions files
576
- const instructionsFiles = ['AGENTS.md', 'CLAUDE.md', 'INSTRUCTIONS.md']
577
- for (const file of instructionsFiles) {
578
- const filePath = resolve(dir, file)
579
- if (!existsSync(filePath)) continue
580
-
581
- const content = readFileSync(filePath, 'utf-8')
582
- const byteSize = Buffer.byteLength(content, 'utf-8')
583
- if (byteSize > limits.instructionsMaxBytes) {
584
- pushIssue(issues, {
585
- level: 'warning',
586
- code: 'platform-instructions-size',
587
- message: `${file} is ${byteSize} bytes, exceeding ${target} max of ${limits.instructionsMaxBytes} bytes.`,
588
- file,
589
- platform: target,
590
- })
591
- }
592
- }
593
- }
594
- }
595
-
596
- function lintRulesFileLimits(config: PluginConfig, dir: string, issues: LintIssue[]): void {
597
- for (const target of config.targets) {
598
- const limits = PLATFORM_LIMITS[target]
599
- if (limits.rulesMaxLines === null) continue
600
-
601
- // Check common rules files
602
- const rulesFiles = ['.cursorrules', '.clinerules']
603
- for (const file of rulesFiles) {
604
- const filePath = resolve(dir, file)
605
- if (!existsSync(filePath)) continue
606
-
607
- const content = readFileSync(filePath, 'utf-8')
608
- const lineCount = content.split(/\r?\n/).length
609
- if (lineCount > limits.rulesMaxLines) {
610
- pushIssue(issues, {
611
- level: 'warning',
612
- code: 'platform-rules-lines',
613
- message: `${file} has ${lineCount} lines, exceeding ${target} recommended max of ${limits.rulesMaxLines} lines.`,
614
- file,
615
- platform: target,
616
- })
617
- }
618
- }
619
- }
620
- }
621
-
622
- // ── Gotcha #1: Plugin directories must be at plugin root, not inside .claude-plugin/ ──
623
- function lintPluginDirectoryPlacement(dir: string, issues: LintIssue[]): void {
624
- const pluginSubDirs = ['commands', 'agents', 'skills', 'hooks']
625
- const nestedParents = ['.claude-plugin', '.plugin']
626
-
627
- for (const parent of nestedParents) {
628
- for (const subDir of pluginSubDirs) {
629
- const nestedPath = resolve(dir, parent, subDir)
630
- if (existsSync(nestedPath)) {
631
- pushIssue(issues, {
632
- level: 'error',
633
- code: 'plugin-dir-nested',
634
- message: `"${subDir}/" must be at the plugin root, not inside ${parent}/. Move ${parent}/${subDir}/ to ./${subDir}/`,
635
- file: `${parent}/${subDir}/`,
636
- platform: 'Claude Code',
637
- })
638
- }
639
- }
640
- }
641
- }
642
-
643
- // ── Gotcha #2 & #3: Manifest paths must be relative with ./ prefix, no ../ traversal ──
644
- function lintManifestPaths(config: PluginConfig, issues: LintIssue[]): void {
645
- const pathFields: { name: string; value: string | undefined }[] = [
646
- { name: 'skills', value: config.skills },
647
- { name: 'commands', value: config.commands },
648
- { name: 'agents', value: config.agents },
649
- { name: 'instructions', value: config.instructions },
650
- { name: 'scripts', value: config.scripts },
651
- { name: 'assets', value: config.assets },
652
- { name: 'outDir', value: config.outDir },
653
- ]
654
-
655
- for (const field of pathFields) {
656
- if (!field.value) continue
657
-
658
- if (!field.value.startsWith('./')) {
659
- pushIssue(issues, {
660
- level: 'error',
661
- code: 'manifest-path-prefix',
662
- message: `Config path "${field.name}" must start with "./" (got "${field.value}").`,
663
- file: 'pluxx.config.ts',
664
- platform: 'Plugin Structure',
665
- })
666
- }
667
-
668
- if (field.value.includes('..')) {
669
- pushIssue(issues, {
670
- level: 'error',
671
- code: 'manifest-path-traversal',
672
- message: `Config path "${field.name}" must not traverse outside plugin root (contains "..").`,
673
- file: 'pluxx.config.ts',
674
- platform: 'Plugin Structure',
675
- })
676
- }
677
- }
678
- }
679
-
680
- // ── Gotcha #4: Plugin name must be kebab-case ──
681
- function lintPluginName(config: PluginConfig, issues: LintIssue[]): void {
682
- if (!PLUGIN_NAME_KEBAB.test(config.name)) {
683
- pushIssue(issues, {
684
- level: 'error',
685
- code: 'plugin-name-kebab',
686
- message: `Plugin name "${config.name}" must be kebab-case (lowercase letters, numbers, hyphens, no spaces).`,
687
- file: 'pluxx.config.ts',
688
- platform: 'Plugin Structure',
689
- })
690
- }
691
- }
692
-
693
- // ── Gotcha #5 & #6: Validate hook event names and hook types ──
694
- function lintHookEvents(config: PluginConfig, issues: LintIssue[]): void {
695
- if (!config.hooks) return
696
-
697
- for (const [hookEvent, hookEntries] of Object.entries(config.hooks)) {
698
- const pascalEvent = mapHookEventToPascalCase(hookEvent)
699
- if (!(CLAUDE_CODE_HOOK_EVENTS as readonly string[]).includes(pascalEvent)) {
700
- pushIssue(issues, {
701
- level: 'warning',
702
- code: 'hook-event-unknown',
703
- message: `Hook event "${hookEvent}" (as "${pascalEvent}") is not a recognized Claude Code hook event. Valid events: ${CLAUDE_CODE_HOOK_EVENTS.join(', ')}`,
704
- file: 'pluxx.config.ts',
705
- platform: 'Claude Code',
706
- })
707
- }
708
-
709
- if (!Array.isArray(hookEntries)) continue
710
- for (const entry of hookEntries) {
711
- if (!entry || typeof entry !== 'object') continue
712
- const hookType = (entry as Record<string, unknown>).type as string | undefined
713
- if (hookType && !(CLAUDE_CODE_HOOK_TYPES as readonly string[]).includes(hookType)) {
714
- pushIssue(issues, {
715
- level: 'error',
716
- code: 'hook-type-invalid',
717
- message: `Hook type "${hookType}" in "${hookEvent}" is not valid. Must be one of: ${CLAUDE_CODE_HOOK_TYPES.join(', ')}`,
718
- file: 'pluxx.config.ts',
719
- platform: 'Claude Code',
720
- })
721
- }
722
- }
723
- }
724
- }
725
-
726
- // ── Gotcha #7: Agent frontmatter must not include hooks, mcpServers, permissionMode ──
727
- function lintAgentFrontmatter(
728
- agentFiles: string[],
729
- issues: LintIssue[],
730
- frontmatterCache: Map<string, ParsedFrontmatterFile>,
731
- ): void {
732
- for (const file of agentFiles) {
733
- const { parsed } = getParsedFrontmatterFile(file, frontmatterCache)
734
- if (!parsed.valid) continue
735
-
736
- for (const forbidden of AGENT_FORBIDDEN_FRONTMATTER) {
737
- if (parsed.fields.has(forbidden)) {
738
- pushIssue(issues, {
739
- level: 'warning',
740
- code: 'agent-forbidden-frontmatter',
741
- message: `Agent file uses "${forbidden}" in frontmatter. Plugin agents do not support hooks, mcpServers, or permissionMode.`,
742
- file,
743
- platform: 'Claude Code',
744
- })
745
- }
746
- }
747
- }
748
- }
749
-
750
- function collectMarkdownFiles(dir: string): string[] {
751
- if (!existsSync(dir)) return []
752
- const files: string[] = []
753
- const entries = readdirSync(dir, { withFileTypes: true })
754
- for (const entry of entries) {
755
- const fullPath = resolve(dir, entry.name)
756
- if (entry.isDirectory()) {
757
- files.push(...collectMarkdownFiles(fullPath))
758
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
759
- files.push(fullPath)
760
- }
761
- }
762
- return files
763
- }
764
-
765
- // ── Gotcha #8: Agent isolation field only accepts "worktree" ──
766
- function lintAgentIsolation(
767
- agentFiles: string[],
768
- issues: LintIssue[],
769
- frontmatterCache: Map<string, ParsedFrontmatterFile>,
770
- ): void {
771
- for (const file of agentFiles) {
772
- const { parsed } = getParsedFrontmatterFile(file, frontmatterCache)
773
- if (!parsed.valid) continue
774
-
775
- const isolation = parsed.fields.get('isolation')
776
- if (isolation && isolation.value !== 'worktree') {
777
- pushIssue(issues, {
778
- level: 'error',
779
- code: 'agent-isolation-invalid',
780
- message: `Agent isolation field must be "worktree" (got "${isolation.value}").`,
781
- file,
782
- platform: 'Claude Code',
783
- })
784
- }
785
- }
786
- }
787
-
788
- // ── Gotcha #9: Warn if absolute paths used in hooks/MCP instead of ${CLAUDE_PLUGIN_ROOT} ──
789
- function lintAbsolutePaths(config: PluginConfig, issues: LintIssue[]): void {
790
- const absolutePathPattern = /^\/[a-zA-Z]|^[A-Z]:\\/
791
-
792
- // Check hooks commands
793
- if (config.hooks) {
794
- for (const [eventName, hookEntries] of Object.entries(config.hooks)) {
795
- if (!Array.isArray(hookEntries)) continue
796
- for (const entry of hookEntries) {
797
- if (!entry || typeof entry !== 'object') continue
798
- const cmd = (entry as Record<string, unknown>).command
799
- if (typeof cmd === 'string' && absolutePathPattern.test(cmd)) {
800
- pushIssue(issues, {
801
- level: 'warning',
802
- code: 'hook-absolute-path',
803
- message: `Hook "${eventName}" uses an absolute path in command. Use \${CLAUDE_PLUGIN_ROOT} for portability.`,
804
- file: 'pluxx.config.ts',
805
- platform: 'Claude Code',
806
- })
807
- }
808
- }
809
- }
810
- }
811
-
812
- // Check MCP server commands
813
- if (config.mcp) {
814
- for (const [serverName, server] of Object.entries(config.mcp)) {
815
- if ('command' in server && typeof server.command === 'string' && absolutePathPattern.test(server.command)) {
816
- pushIssue(issues, {
817
- level: 'warning',
818
- code: 'mcp-absolute-path',
819
- message: `MCP server "${serverName}" uses an absolute path in command. Use \${CLAUDE_PLUGIN_ROOT} for portability.`,
820
- file: 'pluxx.config.ts',
821
- platform: 'Claude Code',
822
- })
823
- }
824
- }
825
- }
826
- }
827
-
828
- // ── Gotcha #11: settings.json only supports "agent" key ──
829
- function lintSettingsJson(dir: string, issues: LintIssue[]): void {
830
- const settingsPath = resolve(dir, 'settings.json')
831
- if (!existsSync(settingsPath)) return
832
-
833
- try {
834
- const content = JSON.parse(readFileSync(settingsPath, 'utf-8'))
835
- if (typeof content === 'object' && content !== null) {
836
- const keys = Object.keys(content)
837
- for (const key of keys) {
838
- if (!(CODEX_RULES.settings.validKeys as readonly string[]).includes(key)) {
839
- pushIssue(issues, {
840
- level: 'warning',
841
- code: 'settings-unknown-key',
842
- message: `settings.json key "${key}" is not recognized. Currently only "${CODEX_RULES.settings.validKeys.join(', ')}" is supported.`,
843
- file: 'settings.json',
844
- platform: 'Claude Code',
845
- })
846
- }
847
- }
848
- }
849
- } catch {
850
- // If settings.json can't be parsed, skip
851
- }
852
- }
853
-
854
- // ── Gotcha #12: Version must follow semver ──
855
- function lintVersionFormat(config: PluginConfig, issues: LintIssue[]): void {
856
- if (!SEMVER_REGEX.test(config.version)) {
857
- pushIssue(issues, {
858
- level: 'error',
859
- code: 'version-semver',
860
- message: `Version "${config.version}" must follow semantic versioning (MAJOR.MINOR.PATCH).`,
861
- file: 'pluxx.config.ts',
862
- platform: 'Plugin Structure',
863
- })
864
- }
865
- }
866
-
867
- // ── Commands are a first-class optional surface alongside skills ──
868
- function lintLegacyCommandsDir(dir: string, config: PluginConfig, issues: LintIssue[]): void {
869
- void dir
870
- void config
871
- void issues
872
- }
873
-
874
- // ── Gotcha #15: Codex agents.max_threads minimum is 1 ──
875
- // ── Gotcha #16: Codex agents.max_depth minimum is 1 ──
876
- function lintCodexAgentsConfig(config: PluginConfig, issues: LintIssue[]): void {
877
- if (!isCodexTargetEnabled(config)) return
878
-
879
- const codexOverrides = asRecord(config.platforms?.codex)
880
- if (!codexOverrides) return
881
-
882
- const agents = asRecord(codexOverrides.agents)
883
- if (!agents) return
884
-
885
- if (typeof agents.max_threads === 'number' && agents.max_threads < CODEX_RULES.agents.maxThreadsMin) {
886
- pushIssue(issues, {
887
- level: 'error',
888
- code: 'codex-agents-max-threads',
889
- message: `Codex agents.max_threads must be at least ${CODEX_RULES.agents.maxThreadsMin} (got ${agents.max_threads}).`,
890
- file: 'pluxx.config.ts',
891
- platform: 'Codex',
892
- })
893
- }
894
-
895
- if (typeof agents.max_depth === 'number' && agents.max_depth < CODEX_RULES.agents.maxDepthMin) {
896
- pushIssue(issues, {
897
- level: 'error',
898
- code: 'codex-agents-max-depth',
899
- message: `Codex agents.max_depth must be at least ${CODEX_RULES.agents.maxDepthMin} (got ${agents.max_depth}).`,
900
- file: 'pluxx.config.ts',
901
- platform: 'Codex',
902
- })
903
- }
904
- }
905
-
906
- // ── Gotcha #18: Codex hooks live in Codex config, not plugin bundles ──
907
- function lintCodexHooksExternalConfig(config: PluginConfig, issues: LintIssue[]): void {
908
- if (!isCodexTargetEnabled(config) || !config.hooks) return
909
- if (Object.keys(config.hooks).length === 0) return
910
-
911
- const codexOverrides = asRecord(config.platforms?.codex)
912
- const features = codexOverrides ? asRecord(codexOverrides.features) : null
913
- const hasPluxxCodexHooksFlag = features && features.codex_hooks === true
914
-
915
- const featureNote = hasPluxxCodexHooksFlag
916
- ? 'Note: `platforms.codex.features.codex_hooks` in pluxx.config.ts is not emitted into Codex plugin output today.'
917
- : 'If you want Codex to run these hooks, configure them in `~/.codex/hooks.json` or `<repo>/.codex/hooks.json` and enable `codex_hooks = true` in Codex itself.'
918
-
919
- pushIssue(issues, {
920
- level: 'warning',
921
- code: 'codex-hooks-external-config',
922
- message: `Codex plugin docs currently separate hook configuration from plugin packaging, so Pluxx does not bundle Codex hooks into generated plugin output. ${featureNote}`,
923
- file: 'pluxx.config.ts',
924
- platform: 'Codex',
925
- })
926
- }
927
-
928
- // ── Cursor-specific hook + frontmatter checks (fixes failing test) ──
929
- function lintCursorHooks(config: PluginConfig, issues: LintIssue[]): void {
930
- if (!config.targets.includes('cursor') || !config.hooks) return
931
-
932
- for (const [hookEvent, hookEntries] of Object.entries(config.hooks)) {
933
- if (!(CURSOR_SUPPORTED_HOOK_EVENTS as readonly string[]).includes(hookEvent)) {
934
- pushIssue(issues, {
935
- level: 'warning',
936
- code: 'cursor-hook-event-unknown',
937
- message: `Cursor does not support hook event "${hookEvent}". Supported: ${CURSOR_SUPPORTED_HOOK_EVENTS.join(', ')}`,
938
- file: 'pluxx.config.ts',
939
- platform: 'Cursor',
940
- })
941
- }
942
-
943
- if (!Array.isArray(hookEntries)) continue
944
- for (const entry of hookEntries) {
945
- if (!entry || typeof entry !== 'object') continue
946
- const rec = entry as Record<string, unknown>
947
-
948
- if (rec.loop_limit !== undefined && !(CURSOR_LOOP_LIMIT_HOOK_EVENTS as readonly string[]).includes(hookEvent)) {
949
- pushIssue(issues, {
950
- level: 'warning',
951
- code: 'cursor-hook-loop-limit-unsupported-event',
952
- message: `Hook "${hookEvent}" has loop_limit but Cursor only supports loop_limit on ${CURSOR_LOOP_LIMIT_HOOK_EVENTS.join(', ')}.`,
953
- file: 'pluxx.config.ts',
954
- platform: 'Cursor',
955
- })
956
- }
957
- }
958
- }
959
- }
960
-
961
- function lintCursorSkillFrontmatter(
962
- config: PluginConfig,
963
- skillFiles: string[],
964
- issues: LintIssue[],
965
- frontmatterCache: Map<string, ParsedFrontmatterFile>,
966
- ): void {
967
- if (!config.targets.includes('cursor')) return
968
-
969
- const cursorSupportedFrontmatter = ['name', 'description', 'license', 'compatibility', 'metadata', 'disable-model-invocation']
970
-
971
- for (const skillFile of skillFiles) {
972
- const { parsed } = getParsedFrontmatterFile(skillFile, frontmatterCache)
973
- if (!parsed.valid) continue
974
-
975
- for (const [key] of parsed.fields) {
976
- if (!cursorSupportedFrontmatter.includes(key)) {
977
- pushIssue(issues, {
978
- level: 'warning',
979
- code: 'cursor-skill-frontmatter-unsupported',
980
- message: `Skill frontmatter field "${key}" is not supported by Cursor. Supported: ${cursorSupportedFrontmatter.join(', ')}`,
981
- file: skillFile,
982
- platform: 'Cursor',
983
- })
984
- }
985
- }
986
- }
987
- }
988
-
989
- function lintSkillListingBudgets(
990
- skillFiles: string[],
991
- targets: TargetPlatform[],
992
- issues: LintIssue[],
993
- frontmatterCache: Map<string, ParsedFrontmatterFile>,
994
- ): void {
995
- for (const target of targets) {
996
- const budget = PLATFORM_LIMITS[target].skillListingBudgetMax
997
- if (budget === null) continue
998
-
999
- let total = 0
1000
- for (const skillFile of skillFiles) {
1001
- const { parsed } = getParsedFrontmatterFile(skillFile, frontmatterCache)
1002
- if (!parsed.valid) continue
1003
- const description = parsed.fields.get('description')?.value
1004
- if (description) total += description.length
1005
- }
1006
-
1007
- if (total > budget) {
1008
- pushIssue(issues, {
1009
- level: 'warning',
1010
- code: 'platform-skill-listing-budget',
1011
- message: `Combined skill descriptions total ${total} characters, exceeding ${target} listing budget of ${budget} characters.`,
1012
- file: 'skills/',
1013
- platform: target,
1014
- })
1015
- }
1016
- }
1017
- }
1018
-
1019
- function lintCursorRuleContentLimits(config: PluginConfig, issues: LintIssue[]): void {
1020
- if (!config.targets.includes('cursor')) return
1021
-
1022
- const maxLines = PLATFORM_LIMITS.cursor.rulesMaxLines
1023
- if (maxLines === null) return
1024
-
1025
- for (const rule of config.platforms?.cursor?.rules ?? []) {
1026
- const lineCount = (rule.content ?? '').split(/\r?\n/).length
1027
- if (lineCount > maxLines) {
1028
- pushIssue(issues, {
1029
- level: 'warning',
1030
- code: 'platform-rules-lines',
1031
- message: `Cursor rule "${rule.description}" has ${lineCount} lines, exceeding the recommended max of ${maxLines} lines.`,
1032
- file: 'pluxx.config.ts',
1033
- platform: 'cursor',
1034
- })
1035
- }
1036
- }
1037
- }
1038
-
1039
- function lintPermissions(config: PluginConfig, issues: LintIssue[]): void {
1040
- if (!config.permissions) return
1041
-
1042
- for (const action of ['allow', 'ask', 'deny'] as const) {
1043
- const seen = new Set<string>()
1044
- for (const raw of config.permissions[action] ?? []) {
1045
- const trimmed = raw.trim()
1046
-
1047
- if (seen.has(trimmed)) {
1048
- pushIssue(issues, {
1049
- level: 'warning',
1050
- code: 'permissions-duplicate',
1051
- message: `Permission rule "${trimmed}" is duplicated in "${action}".`,
1052
- file: 'pluxx.config.ts',
1053
- platform: 'Permissions',
1054
- })
1055
- }
1056
-
1057
- seen.add(trimmed)
1058
- }
1059
- }
1060
-
1061
- const rules = collectPermissionRules(config.permissions)
1062
- const seen = new Map<string, Set<string>>()
1063
-
1064
- for (const rule of rules) {
1065
- const key = `${rule.kind}:${rule.pattern}`
1066
- const actions = seen.get(key) ?? new Set<string>()
1067
- actions.add(rule.action)
1068
- seen.set(key, actions)
1069
- }
1070
-
1071
- for (const [key, actions] of seen) {
1072
- if (actions.size > 1) {
1073
- pushIssue(issues, {
1074
- level: 'warning',
1075
- code: 'permissions-conflict',
1076
- message: `Permission rule "${key}" is declared in multiple actions (${Array.from(actions).join(', ')}). Deny should win, but this intent should be made explicit.`,
1077
- file: 'pluxx.config.ts',
1078
- platform: 'Permissions',
1079
- })
1080
- }
1081
- }
1082
-
1083
- if (config.targets.includes('codex')) {
1084
- pushIssue(issues, {
1085
- level: 'warning',
1086
- code: 'codex-permissions-external-config',
1087
- message: 'Codex does not currently support plugin-packaged permission enforcement. Mirror canonical permissions into Codex user/admin config or external hooks for real enforcement (Pluxx emits .codex/permissions.generated.json as a starter mirror).',
1088
- file: 'pluxx.config.ts',
1089
- platform: 'Codex',
1090
- })
1091
- }
1092
-
1093
- if (rules.some(rule => rule.kind === 'Skill')) {
1094
- const nonClaudeTargets = config.targets.filter(target => target !== 'claude-code')
1095
- if (nonClaudeTargets.length > 0) {
1096
- pushIssue(issues, {
1097
- level: 'warning',
1098
- code: 'permissions-skill-selector-limited',
1099
- message: `Skill(...) permission rules are Claude-style and will require downgrade or docs-only handling on ${nonClaudeTargets.join(', ')}.`,
1100
- file: 'pluxx.config.ts',
1101
- platform: 'Permissions',
1102
- })
1103
- }
1104
- }
1105
-
1106
- if (config.targets.includes('opencode') && permissionRulesNeedToolLevelDowngrade(config.permissions)) {
1107
- pushIssue(issues, {
1108
- level: 'warning',
1109
- code: 'permissions-opencode-downgrade',
1110
- message: 'OpenCode permission output is currently tool-level. Selector patterns like file globs and specific MCP tool names are downgraded to coarse tool permissions there.',
1111
- file: 'pluxx.config.ts',
1112
- platform: 'OpenCode',
1113
- })
1114
- }
1115
- }
1116
-
1117
- function sortIssues(issues: LintIssue[]): LintIssue[] {
1118
- return [...issues].sort((a, b) => {
1119
- if (a.level === b.level) {
1120
- return a.code.localeCompare(b.code)
1121
- }
1122
- return a.level === 'error' ? -1 : 1
1123
- })
1124
- }
1125
-
1126
- export async function lintProject(
1127
- dir: string = process.cwd(),
1128
- options: LintProjectOptions = {},
1129
- ): Promise<LintResult> {
1130
- const issues: LintIssue[] = []
1131
- const frontmatterCache = new Map<string, ParsedFrontmatterFile>()
1132
-
1133
- let config: PluginConfig
1134
- try {
1135
- config = await loadConfig(dir)
1136
- } catch (err) {
1137
- pushIssue(issues, {
1138
- level: 'error',
1139
- code: 'config-invalid',
1140
- message: err instanceof Error ? err.message : String(err),
1141
- file: 'pluxx.config.ts',
1142
- platform: 'Config',
1143
- })
1144
- return { errors: 1, warnings: 0, issues }
1145
- }
1146
-
1147
- const lintConfig: PluginConfig = options.targets
1148
- ? { ...config, targets: options.targets }
1149
- : config
1150
-
1151
- // Plugin structure checks
1152
- lintPluginName(lintConfig, issues)
1153
- lintVersionFormat(lintConfig, issues)
1154
- lintManifestPaths(lintConfig, issues)
1155
- lintPluginDirectoryPlacement(dir, issues)
1156
- lintAbsolutePaths(lintConfig, issues)
1157
- lintSettingsJson(dir, issues)
1158
- lintLegacyCommandsDir(dir, lintConfig, issues)
1159
-
1160
- // Hook and event validation
1161
- lintHookEvents(lintConfig, issues)
1162
-
1163
- // Agent file checks
1164
- const agentsDir = resolve(dir, 'agents')
1165
- const agentFiles = existsSync(agentsDir) ? collectMarkdownFiles(agentsDir) : []
1166
- lintAgentFrontmatter(agentFiles, issues, frontmatterCache)
1167
- lintAgentIsolation(agentFiles, issues, frontmatterCache)
1168
-
1169
- // MCP and brand
1170
- lintMcpUrls(lintConfig, issues)
1171
- lintBrandMetadata(lintConfig, issues)
1172
- lintCodexOverrides(lintConfig, issues)
1173
- lintCodexHookCompatibility(lintConfig, issues)
1174
- lintCodexAgentsConfig(lintConfig, issues)
1175
- lintCodexHooksExternalConfig(lintConfig, issues)
1176
- lintPermissions(lintConfig, issues)
1177
-
1178
- // Cursor-specific checks
1179
- lintCursorHooks(lintConfig, issues)
1180
- lintCursorRuleContentLimits(lintConfig, issues)
1181
-
1182
- const skillsDir = resolve(dir, lintConfig.skills)
1183
- let skillFiles: string[] = []
1184
- if (!existsSync(skillsDir)) {
1185
- pushIssue(issues, {
1186
- level: 'error',
1187
- code: 'skills-dir-missing',
1188
- message: `Skills directory not found: ${lintConfig.skills}`,
1189
- file: 'pluxx.config.ts',
1190
- platform: 'Agent Skills',
1191
- })
1192
- } else {
1193
- skillFiles = collectSkillFiles(skillsDir)
1194
- if (skillFiles.length === 0) {
1195
- pushIssue(issues, {
1196
- level: 'warning',
1197
- code: 'skills-none-found',
1198
- message: `No SKILL.md files found in ${lintConfig.skills}`,
1199
- file: 'pluxx.config.ts',
1200
- platform: 'Agent Skills',
1201
- })
1202
- }
1203
-
1204
- for (const skillFile of skillFiles) {
1205
- lintSkillFile(skillFile, lintConfig.targets, issues, frontmatterCache)
1206
- }
1207
- }
1208
-
1209
- // Cursor skill frontmatter checks
1210
- lintCursorSkillFrontmatter(lintConfig, skillFiles, issues, frontmatterCache)
1211
- lintSkillListingBudgets(skillFiles, lintConfig.targets, issues, frontmatterCache)
1212
-
1213
- // Platform limit checks for manifest prompts (Codex)
1214
- lintManifestPromptLimits(lintConfig, issues)
1215
-
1216
- // Platform limit checks for instructions file size
1217
- lintInstructionsFileLimits(lintConfig, dir, issues)
1218
-
1219
- // Platform limit checks for rules file line count
1220
- lintRulesFileLimits(lintConfig, dir, issues)
1221
-
1222
- const sorted = sortIssues(issues)
1223
- const errors = sorted.filter(i => i.level === 'error').length
1224
- const warnings = sorted.filter(i => i.level === 'warning').length
1225
-
1226
- return { errors, warnings, issues: sorted }
1227
- }
1228
-
1229
- export function printLintResult(result: LintResult, dir: string = process.cwd()): void {
1230
- for (const issue of result.issues) {
1231
- const levelLabel = issue.level === 'error' ? 'ERROR' : 'WARN '
1232
- const platformLabel = issue.platform ? `[${issue.platform}] ` : ''
1233
- const loc = issue.file ? `${relative(dir, resolve(dir, issue.file))}: ` : ''
1234
- console.log(`${levelLabel} ${issue.code} ${platformLabel}${loc}${issue.message}`)
1235
- }
1236
-
1237
- if (result.errors === 0 && result.warnings === 0) {
1238
- console.log('No lint issues found.')
1239
- } else {
1240
- console.log('')
1241
- console.log(`Lint summary: ${result.errors} error(s), ${result.warnings} warning(s)`)
1242
- }
1243
- }
1244
-
1245
- export async function runLint(dir: string = process.cwd()): Promise<number> {
1246
- const result = await lintProject(dir)
1247
- printLintResult(result, dir)
1248
- return result.errors > 0 ? 1 : 0
1249
- }