@orchid-labs/pluxx 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -8
- package/bin/pluxx.js +19 -28
- package/dist/agents.d.ts +16 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/cli/agent.d.ts +62 -0
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/entry.d.ts +2 -0
- package/dist/cli/entry.d.ts.map +1 -0
- package/dist/cli/index.d.ts +7 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +21810 -0
- package/dist/cli/init-from-mcp.d.ts +17 -1
- package/dist/cli/init-from-mcp.d.ts.map +1 -1
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.d.ts.map +1 -1
- package/dist/cli/lint.d.ts +3 -1
- package/dist/cli/lint.d.ts.map +1 -1
- package/dist/cli/mcp-proxy.d.ts.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/primitive-summary.d.ts +14 -0
- package/dist/cli/primitive-summary.d.ts.map +1 -0
- package/dist/cli/prompt.d.ts +1 -1
- package/dist/cli/publish.d.ts +6 -1
- package/dist/cli/publish.d.ts.map +1 -1
- package/dist/cli/sync-from-mcp.d.ts.map +1 -1
- package/dist/cli/verify-install.d.ts +25 -0
- package/dist/cli/verify-install.d.ts.map +1 -0
- package/dist/commands.d.ts +10 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/compiler-intent.d.ts +165 -0
- package/dist/compiler-intent.d.ts.map +1 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/delegation.d.ts +11 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/generators/amp/index.d.ts.map +1 -1
- package/dist/generators/base.d.ts +5 -0
- package/dist/generators/base.d.ts.map +1 -1
- package/dist/generators/claude-code/index.d.ts.map +1 -1
- package/dist/generators/cline/index.d.ts.map +1 -1
- package/dist/generators/codex/index.d.ts +4 -0
- package/dist/generators/codex/index.d.ts.map +1 -1
- package/dist/generators/cursor/index.d.ts +1 -0
- package/dist/generators/cursor/index.d.ts.map +1 -1
- package/dist/generators/gemini-cli/index.d.ts.map +1 -1
- package/dist/generators/github-copilot/index.d.ts.map +1 -1
- package/dist/generators/opencode/index.d.ts +1 -0
- package/dist/generators/opencode/index.d.ts.map +1 -1
- package/dist/generators/openhands/index.d.ts.map +1 -1
- package/dist/generators/roo-code/index.d.ts.map +1 -1
- package/dist/generators/shared/claude-family.d.ts.map +1 -1
- package/dist/generators/warp/index.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5371 -553
- package/dist/schema.d.ts +91 -42
- package/dist/schema.d.ts.map +1 -1
- package/dist/text-files.d.ts +5 -0
- package/dist/text-files.d.ts.map +1 -0
- package/dist/validation/platform-rules.d.ts +15 -1
- package/dist/validation/platform-rules.d.ts.map +1 -1
- package/package.json +15 -13
- package/src/cli/agent.ts +0 -1455
- package/src/cli/dev.ts +0 -112
- package/src/cli/doctor.ts +0 -987
- package/src/cli/eval.ts +0 -470
- package/src/cli/index.ts +0 -2933
- package/src/cli/init-from-mcp.ts +0 -2115
- package/src/cli/install.ts +0 -860
- package/src/cli/lint.ts +0 -1249
- package/src/cli/mcp-proxy.ts +0 -322
- package/src/cli/migrate.ts +0 -867
- package/src/cli/prompt.ts +0 -82
- package/src/cli/publish.ts +0 -401
- package/src/cli/runtime.ts +0 -86
- package/src/cli/sync-from-mcp.ts +0 -586
- package/src/cli/test.ts +0 -142
- package/src/compatibility/matrix.ts +0 -149
- package/src/config/define.ts +0 -20
- package/src/config/load.ts +0 -74
- package/src/generators/amp/index.ts +0 -63
- package/src/generators/base.ts +0 -188
- package/src/generators/claude-code/index.ts +0 -172
- package/src/generators/cline/index.ts +0 -35
- package/src/generators/codex/index.ts +0 -143
- package/src/generators/cursor/index.ts +0 -158
- package/src/generators/gemini-cli/index.ts +0 -83
- package/src/generators/github-copilot/index.ts +0 -32
- package/src/generators/hooks-warning.ts +0 -51
- package/src/generators/index.ts +0 -71
- package/src/generators/opencode/index.ts +0 -526
- package/src/generators/openhands/index.ts +0 -32
- package/src/generators/roo-code/index.ts +0 -35
- package/src/generators/shared/claude-family.ts +0 -215
- package/src/generators/warp/index.ts +0 -32
- package/src/hook-events.ts +0 -33
- package/src/index.ts +0 -34
- package/src/mcp/introspect.ts +0 -1107
- package/src/permissions.ts +0 -260
- package/src/schema.ts +0 -312
- package/src/user-config.ts +0 -177
- 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
|
-
}
|