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