@orchid-labs/pluxx 0.1.0 → 0.1.1

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 (51) hide show
  1. package/README.md +100 -522
  2. package/dist/cli/agent.d.ts +7 -0
  3. package/dist/cli/agent.d.ts.map +1 -1
  4. package/dist/cli/doctor.d.ts +1 -0
  5. package/dist/cli/doctor.d.ts.map +1 -1
  6. package/dist/cli/eval.d.ts +22 -0
  7. package/dist/cli/eval.d.ts.map +1 -0
  8. package/dist/cli/index.d.ts +19 -2
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/init-from-mcp.d.ts +17 -2
  11. package/dist/cli/init-from-mcp.d.ts.map +1 -1
  12. package/dist/cli/install.d.ts +2 -0
  13. package/dist/cli/install.d.ts.map +1 -1
  14. package/dist/cli/lint.d.ts +5 -1
  15. package/dist/cli/lint.d.ts.map +1 -1
  16. package/dist/cli/mcp-proxy.d.ts +10 -0
  17. package/dist/cli/mcp-proxy.d.ts.map +1 -0
  18. package/dist/cli/migrate.d.ts.map +1 -1
  19. package/dist/cli/sync-from-mcp.d.ts.map +1 -1
  20. package/dist/cli/test.d.ts +2 -0
  21. package/dist/cli/test.d.ts.map +1 -1
  22. package/dist/generators/claude-code/index.d.ts +2 -0
  23. package/dist/generators/claude-code/index.d.ts.map +1 -1
  24. package/dist/generators/codex/index.d.ts +1 -0
  25. package/dist/generators/codex/index.d.ts.map +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +99 -1
  29. package/dist/mcp/introspect.d.ts +43 -1
  30. package/dist/mcp/introspect.d.ts.map +1 -1
  31. package/dist/permissions.d.ts.map +1 -1
  32. package/dist/validation/platform-rules.d.ts +20 -0
  33. package/dist/validation/platform-rules.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/cli/agent.ts +459 -34
  36. package/src/cli/doctor.ts +400 -1
  37. package/src/cli/eval.ts +470 -0
  38. package/src/cli/index.ts +633 -114
  39. package/src/cli/init-from-mcp.ts +545 -41
  40. package/src/cli/install.ts +166 -4
  41. package/src/cli/lint.ts +56 -26
  42. package/src/cli/mcp-proxy.ts +322 -0
  43. package/src/cli/migrate.ts +256 -3
  44. package/src/cli/sync-from-mcp.ts +23 -0
  45. package/src/cli/test.ts +10 -2
  46. package/src/generators/claude-code/index.ts +143 -0
  47. package/src/generators/codex/index.ts +23 -0
  48. package/src/index.ts +12 -1
  49. package/src/mcp/introspect.ts +297 -24
  50. package/src/permissions.ts +3 -1
  51. package/src/validation/platform-rules.ts +121 -0
@@ -5,7 +5,7 @@ import type { McpAuth, McpServer } from '../schema'
5
5
  const MCP_PROTOCOL_VERSION = '2025-03-26'
6
6
  const CLIENT_INFO = {
7
7
  name: 'pluxx',
8
- version: '0.1.0',
8
+ version: '0.1.1',
9
9
  }
10
10
  const DEFAULT_TIMEOUT_MS = 10_000
11
11
 
@@ -16,6 +16,33 @@ export interface IntrospectedMcpTool {
16
16
  inputSchema?: Record<string, unknown>
17
17
  }
18
18
 
19
+ export interface IntrospectedMcpResource {
20
+ uri: string
21
+ name?: string
22
+ title?: string
23
+ description?: string
24
+ mimeType?: string
25
+ }
26
+
27
+ export interface IntrospectedMcpResourceTemplate {
28
+ uriTemplate: string
29
+ name: string
30
+ title?: string
31
+ description?: string
32
+ mimeType?: string
33
+ }
34
+
35
+ export interface IntrospectedMcpPrompt {
36
+ name: string
37
+ title?: string
38
+ description?: string
39
+ arguments?: Array<{
40
+ name: string
41
+ description?: string
42
+ required?: boolean
43
+ }>
44
+ }
45
+
19
46
  export interface IntrospectedMcpServer {
20
47
  protocolVersion: string
21
48
  instructions?: string
@@ -27,6 +54,17 @@ export interface IntrospectedMcpServer {
27
54
  websiteUrl?: string
28
55
  }
29
56
  tools: IntrospectedMcpTool[]
57
+ resources?: IntrospectedMcpResource[]
58
+ resourceTemplates?: IntrospectedMcpResourceTemplate[]
59
+ prompts?: IntrospectedMcpPrompt[]
60
+ }
61
+
62
+ export interface McpAuthDiscovery {
63
+ kind: 'bearer' | 'header' | 'platform'
64
+ mode?: 'oauth'
65
+ headerName?: string
66
+ authorizationUrl?: string
67
+ resourceMetadataUrl?: string
30
68
  }
31
69
 
32
70
  export class McpIntrospectionError extends Error {
@@ -38,12 +76,52 @@ export class McpIntrospectionError extends Error {
38
76
  responseBodySnippet?: string
39
77
  responseUrl?: string
40
78
  },
79
+ readonly rpcCode?: number,
41
80
  ) {
42
81
  super(message)
43
82
  this.name = 'McpIntrospectionError'
44
83
  }
45
84
  }
46
85
 
86
+ export function discoverMcpAuthFromError(error: McpIntrospectionError): McpAuthDiscovery | null {
87
+ const wwwAuthenticate = error.context?.responseHeaders?.['www-authenticate']?.trim() ?? ''
88
+ const locationHeader = error.context?.responseHeaders?.location?.trim()
89
+ const responseUrl = error.context?.responseUrl?.trim()
90
+ const responseBody = error.context?.responseBodySnippet ?? ''
91
+ const message = error.message
92
+
93
+ const authorizationUri = extractAuthParam(wwwAuthenticate, 'authorization_uri')
94
+ const resourceMetadataUrl = extractAuthParam(wwwAuthenticate, 'resource_metadata')
95
+ const oauthUrl = authorizationUri
96
+ ?? pickOAuthUrl(locationHeader, responseUrl)
97
+
98
+ if (looksLikeOAuthSignal([wwwAuthenticate, locationHeader, responseUrl, responseBody, message])) {
99
+ return {
100
+ kind: 'platform',
101
+ mode: 'oauth',
102
+ ...(oauthUrl ? { authorizationUrl: oauthUrl } : {}),
103
+ ...(resourceMetadataUrl ? { resourceMetadataUrl } : {}),
104
+ }
105
+ }
106
+
107
+ const headerName = extractHeaderName([wwwAuthenticate, responseBody, message])
108
+ if (headerName && headerName.toLowerCase() !== 'authorization') {
109
+ return {
110
+ kind: 'header',
111
+ headerName,
112
+ }
113
+ }
114
+
115
+ if (wwwAuthenticate.toLowerCase().includes('bearer') || headerName === 'Authorization') {
116
+ return {
117
+ kind: 'bearer',
118
+ headerName: 'Authorization',
119
+ }
120
+ }
121
+
122
+ return null
123
+ }
124
+
47
125
  interface JsonRpcSuccess<T> {
48
126
  jsonrpc: '2.0'
49
127
  id: number | string
@@ -65,6 +143,11 @@ type JsonRpcEnvelope<T> = JsonRpcSuccess<T> | JsonRpcFailure
65
143
  interface InitializeResult {
66
144
  protocolVersion?: string
67
145
  instructions?: string
146
+ capabilities?: {
147
+ tools?: Record<string, unknown>
148
+ resources?: Record<string, unknown>
149
+ prompts?: Record<string, unknown>
150
+ }
68
151
  serverInfo?: {
69
152
  name?: string
70
153
  title?: string
@@ -84,39 +167,80 @@ interface ListToolsResult {
84
167
  nextCursor?: string
85
168
  }
86
169
 
87
- interface McpClient {
170
+ interface ListResourcesResult {
171
+ resources?: Array<{
172
+ uri: string
173
+ name?: string
174
+ title?: string
175
+ description?: string
176
+ mimeType?: string
177
+ }>
178
+ nextCursor?: string
179
+ }
180
+
181
+ interface ListResourceTemplatesResult {
182
+ resourceTemplates?: Array<{
183
+ uriTemplate: string
184
+ name: string
185
+ title?: string
186
+ description?: string
187
+ mimeType?: string
188
+ }>
189
+ nextCursor?: string
190
+ }
191
+
192
+ interface ListPromptsResult {
193
+ prompts?: Array<{
194
+ name: string
195
+ title?: string
196
+ description?: string
197
+ arguments?: Array<{
198
+ name: string
199
+ description?: string
200
+ required?: boolean
201
+ }>
202
+ }>
203
+ nextCursor?: string
204
+ }
205
+
206
+ export interface McpClient {
88
207
  request<T>(method: string, params?: Record<string, unknown>): Promise<T>
89
208
  notify(method: string, params?: Record<string, unknown>): Promise<void>
90
209
  close(): Promise<void>
91
210
  }
92
211
 
93
212
  export async function introspectMcpServer(server: McpServer): Promise<IntrospectedMcpServer> {
213
+ if (server.transport === 'http') {
214
+ try {
215
+ return await introspectWithClient(await createMcpClient(server))
216
+ } catch (error) {
217
+ if (
218
+ error instanceof McpIntrospectionError
219
+ && (error.status === 400 || error.status === 404 || error.status === 405)
220
+ ) {
221
+ return await introspectWithClient(await createSseClient({
222
+ ...server,
223
+ transport: 'sse',
224
+ }))
225
+ }
226
+
227
+ throw error
228
+ }
229
+ }
230
+
231
+ return await introspectWithClient(await createMcpClient(server))
232
+ }
233
+
234
+ export async function createMcpClient(server: McpServer): Promise<McpClient> {
94
235
  if (server.transport === 'stdio') {
95
- const client = await createStdioClient(server)
96
- return await introspectWithClient(client)
236
+ return await createStdioClient(server)
97
237
  }
98
238
 
99
239
  if (server.transport === 'sse') {
100
- const client = await createSseClient(server)
101
- return await introspectWithClient(client)
240
+ return await createSseClient(server)
102
241
  }
103
242
 
104
- try {
105
- return await introspectWithClient(createHttpClient(server))
106
- } catch (error) {
107
- if (
108
- error instanceof McpIntrospectionError
109
- && (error.status === 400 || error.status === 404 || error.status === 405)
110
- ) {
111
- const sseClient = await createSseClient({
112
- ...server,
113
- transport: 'sse',
114
- })
115
- return await introspectWithClient(sseClient)
116
- }
117
-
118
- throw error
119
- }
243
+ return createHttpClient(server)
120
244
  }
121
245
 
122
246
  async function introspectWithClient(client: McpClient): Promise<IntrospectedMcpServer> {
@@ -135,6 +259,15 @@ async function introspectWithClient(client: McpClient): Promise<IntrospectedMcpS
135
259
  'The MCP server initialized successfully but exposed no tools. pluxx init --from-mcp currently scaffolds from tool metadata only.',
136
260
  )
137
261
  }
262
+ const resources = hasInitializeCapability(initialize, 'resources')
263
+ ? await listAllResources(client)
264
+ : []
265
+ const resourceTemplates = hasInitializeCapability(initialize, 'resources')
266
+ ? await listAllResourceTemplates(client)
267
+ : []
268
+ const prompts = hasInitializeCapability(initialize, 'prompts')
269
+ ? await listAllPrompts(client)
270
+ : []
138
271
 
139
272
  return {
140
273
  protocolVersion: initialize.protocolVersion ?? MCP_PROTOCOL_VERSION,
@@ -147,12 +280,79 @@ async function introspectWithClient(client: McpClient): Promise<IntrospectedMcpS
147
280
  websiteUrl: initialize.serverInfo?.websiteUrl,
148
281
  },
149
282
  tools,
283
+ resources,
284
+ resourceTemplates,
285
+ prompts,
150
286
  }
151
287
  } finally {
152
288
  await client.close()
153
289
  }
154
290
  }
155
291
 
292
+ function hasInitializeCapability(
293
+ initialize: InitializeResult,
294
+ capability: 'resources' | 'prompts',
295
+ ): boolean {
296
+ const value = initialize.capabilities?.[capability]
297
+ return typeof value === 'object' && value !== null
298
+ }
299
+
300
+ function looksLikeOAuthSignal(values: Array<string | undefined>): boolean {
301
+ return values.some((value) => {
302
+ const normalized = value?.toLowerCase() ?? ''
303
+ return /\boauth\b/.test(normalized)
304
+ || normalized.includes('authorization_uri=')
305
+ || /\bauthorize\b/.test(normalized)
306
+ || /\blogin\b/.test(normalized)
307
+ || /\bsignin\b/.test(normalized)
308
+ })
309
+ }
310
+
311
+ function pickOAuthUrl(...candidates: Array<string | undefined>): string | undefined {
312
+ for (const value of candidates) {
313
+ if (!value) continue
314
+ const normalized = value.toLowerCase()
315
+ if (
316
+ /\boauth\b/.test(normalized)
317
+ || /\bauthorize\b/.test(normalized)
318
+ || /\blogin\b/.test(normalized)
319
+ || /\bsignin\b/.test(normalized)
320
+ ) {
321
+ return value
322
+ }
323
+ }
324
+ return undefined
325
+ }
326
+
327
+ function extractAuthParam(header: string, name: string): string | undefined {
328
+ if (!header) return undefined
329
+ const pattern = new RegExp(`${name}=\"([^\"]+)\"`, 'i')
330
+ return header.match(pattern)?.[1]
331
+ }
332
+
333
+ function extractHeaderName(values: string[]): string | undefined {
334
+ for (const value of values) {
335
+ const matches = value.match(/\b(?:x-[a-z0-9-]+|authorization)\b/gi)
336
+ if (!matches) continue
337
+ const candidate = matches[0]
338
+ if (!candidate) continue
339
+ if (candidate.toLowerCase() === 'authorization') {
340
+ return 'Authorization'
341
+ }
342
+ return candidate
343
+ .split('-')
344
+ .map((part) => {
345
+ const normalized = part.toLowerCase()
346
+ if (normalized === 'api' || normalized === 'id' || normalized === 'url') {
347
+ return normalized.toUpperCase()
348
+ }
349
+ return normalized.charAt(0).toUpperCase() + normalized.slice(1)
350
+ })
351
+ .join('-')
352
+ }
353
+ return undefined
354
+ }
355
+
156
356
  async function listAllTools(client: McpClient): Promise<IntrospectedMcpTool[]> {
157
357
  const tools: IntrospectedMcpTool[] = []
158
358
  let cursor: string | undefined
@@ -167,6 +367,61 @@ async function listAllTools(client: McpClient): Promise<IntrospectedMcpTool[]> {
167
367
  }
168
368
  }
169
369
 
370
+ async function listAllResources(client: McpClient): Promise<IntrospectedMcpResource[]> {
371
+ return await listOptionalPaged(client, 'resources/list', (result: ListResourcesResult) => result.resources ?? [])
372
+ }
373
+
374
+ async function listAllResourceTemplates(client: McpClient): Promise<IntrospectedMcpResourceTemplate[]> {
375
+ return await listOptionalPaged(
376
+ client,
377
+ 'resources/templates/list',
378
+ (result: ListResourceTemplatesResult) => result.resourceTemplates ?? [],
379
+ )
380
+ }
381
+
382
+ async function listAllPrompts(client: McpClient): Promise<IntrospectedMcpPrompt[]> {
383
+ return await listOptionalPaged(client, 'prompts/list', (result: ListPromptsResult) => result.prompts ?? [])
384
+ }
385
+
386
+ async function listOptionalPaged<TResult extends { nextCursor?: string }, TItem>(
387
+ client: McpClient,
388
+ method: string,
389
+ getItems: (result: TResult) => TItem[],
390
+ ): Promise<TItem[]> {
391
+ const items: TItem[] = []
392
+ let cursor: string | undefined
393
+
394
+ while (true) {
395
+ let result: TResult
396
+ try {
397
+ result = await client.request<TResult>(method, cursor ? { cursor } : undefined)
398
+ } catch (error) {
399
+ if (isOptionalDiscoveryUnavailable(error)) {
400
+ return items
401
+ }
402
+ throw error
403
+ }
404
+
405
+ items.push(...getItems(result))
406
+ cursor = result.nextCursor
407
+ if (!cursor) {
408
+ return items
409
+ }
410
+ }
411
+ }
412
+
413
+ function isOptionalDiscoveryUnavailable(error: unknown): boolean {
414
+ if (!(error instanceof McpIntrospectionError)) {
415
+ return false
416
+ }
417
+
418
+ const message = error.message.toLowerCase()
419
+ return error.rpcCode === -32601
420
+ || message.includes('method not found')
421
+ || message.includes('not supported')
422
+ || message.includes('unsupported')
423
+ }
424
+
170
425
  function createHttpClient(server: Exclude<McpServer, { transport: 'stdio' }>): McpClient {
171
426
  let sessionId: string | null = null
172
427
 
@@ -541,6 +796,7 @@ async function createStdioClient(server: Extract<McpServer, { transport: 'stdio'
541
796
  },
542
797
  stdio: ['pipe', 'pipe', 'pipe'],
543
798
  })
799
+ let isClosing = false
544
800
 
545
801
  const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
546
802
  const stdout = readline.createInterface({
@@ -578,6 +834,10 @@ async function createStdioClient(server: Extract<McpServer, { transport: 'stdio'
578
834
  })
579
835
 
580
836
  child.once('exit', (code, signal) => {
837
+ if (isClosing) {
838
+ pending.clear()
839
+ return
840
+ }
581
841
  const error = new McpIntrospectionError(
582
842
  `MCP stdio process exited before pluxx finished introspecting it (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`,
583
843
  )
@@ -637,8 +897,16 @@ async function createStdioClient(server: Extract<McpServer, { transport: 'stdio'
637
897
  },
638
898
 
639
899
  async close(): Promise<void> {
900
+ isClosing = true
640
901
  stdout.close()
641
- child.kill()
902
+ child.stdin.end()
903
+ child.stdout.destroy()
904
+ child.stderr?.destroy()
905
+
906
+ if (child.exitCode === null && child.signalCode === null) {
907
+ child.kill('SIGKILL')
908
+ child.unref()
909
+ }
642
910
  },
643
911
  }
644
912
  }
@@ -821,7 +1089,12 @@ function parseSsePayload(payload: string): Array<JsonRpcEnvelope<unknown>> {
821
1089
 
822
1090
  function unwrapEnvelope<T>(envelope: JsonRpcEnvelope<T>): T {
823
1091
  if ('error' in envelope) {
824
- throw new McpIntrospectionError(`MCP request failed: ${envelope.error.message}`)
1092
+ throw new McpIntrospectionError(
1093
+ `MCP request failed: ${envelope.error.message}`,
1094
+ undefined,
1095
+ undefined,
1096
+ envelope.error.code,
1097
+ )
825
1098
  }
826
1099
 
827
1100
  return envelope.result
@@ -84,7 +84,9 @@ export function buildOpenCodePermissionMap(
84
84
  Edit: ['edit', 'write'],
85
85
  Read: ['read'],
86
86
  MCP: ['mcp'],
87
- Skill: ['skill'],
87
+ // OpenCode's native permission surface is tool-level and does not expose
88
+ // a dedicated skill permission key.
89
+ Skill: [],
88
90
  }
89
91
 
90
92
  for (const rule of rules) {
@@ -7,6 +7,13 @@ export interface PlatformRuleSource {
7
7
  url: string
8
8
  }
9
9
 
10
+ export type PlatformLimitKind = 'hard' | 'advisory' | 'display'
11
+
12
+ export interface PlatformLimitPolicy {
13
+ kind: PlatformLimitKind
14
+ notes?: string
15
+ }
16
+
10
17
  export interface PlatformLimits {
11
18
  skillDescriptionMax: number | null
12
19
  skillDescriptionDisplayMax: number | null
@@ -21,10 +28,25 @@ export interface PlatformLimits {
21
28
  rulesMaxLines: number | null
22
29
  }
23
30
 
31
+ export interface PlatformLimitPolicies {
32
+ skillDescriptionMax: PlatformLimitPolicy | null
33
+ skillDescriptionDisplayMax: PlatformLimitPolicy | null
34
+ skillListingBudgetMax: PlatformLimitPolicy | null
35
+ skillNameMax: PlatformLimitPolicy
36
+ skillNameMustMatchDir: PlatformLimitPolicy
37
+ manifestPromptMax: PlatformLimitPolicy | null
38
+ manifestPromptCountMax: PlatformLimitPolicy | null
39
+ manifestPathPrefix: PlatformLimitPolicy | null
40
+ instructionsMaxBytes: PlatformLimitPolicy | null
41
+ hooksFeatureFlag: PlatformLimitPolicy | null
42
+ rulesMaxLines: PlatformLimitPolicy | null
43
+ }
44
+
24
45
  export interface PlatformRules {
25
46
  platform: TargetPlatform
26
47
  summary: string
27
48
  limits: PlatformLimits
49
+ limitPolicies: PlatformLimitPolicies
28
50
  skillDiscoveryDirs: {
29
51
  path: string
30
52
  level: RuleLevel
@@ -84,6 +106,20 @@ const NULL_LIMITS: PlatformLimits = {
84
106
  rulesMaxLines: null,
85
107
  }
86
108
 
109
+ const NULL_LIMIT_POLICIES: PlatformLimitPolicies = {
110
+ skillDescriptionMax: null,
111
+ skillDescriptionDisplayMax: null,
112
+ skillListingBudgetMax: null,
113
+ skillNameMax: { kind: 'hard' },
114
+ skillNameMustMatchDir: { kind: 'hard' },
115
+ manifestPromptMax: null,
116
+ manifestPromptCountMax: null,
117
+ manifestPathPrefix: null,
118
+ instructionsMaxBytes: null,
119
+ hooksFeatureFlag: null,
120
+ rulesMaxLines: null,
121
+ }
122
+
87
123
  export const PLATFORM_LIMITS: Record<TargetPlatform, PlatformLimits> = {
88
124
  'claude-code': {
89
125
  ...NULL_LIMITS,
@@ -138,6 +174,78 @@ export const PLATFORM_LIMITS: Record<TargetPlatform, PlatformLimits> = {
138
174
  },
139
175
  }
140
176
 
177
+ export const PLATFORM_LIMIT_POLICIES: Record<TargetPlatform, PlatformLimitPolicies> = {
178
+ 'claude-code': {
179
+ ...NULL_LIMIT_POLICIES,
180
+ skillDescriptionMax: {
181
+ kind: 'hard',
182
+ notes: 'Claude skills listing caps description + when_to_use combined at 1,536 characters.',
183
+ },
184
+ skillDescriptionDisplayMax: {
185
+ kind: 'display',
186
+ notes: 'Claude surfaces commonly truncate long listing text around 250 characters.',
187
+ },
188
+ skillListingBudgetMax: {
189
+ kind: 'advisory',
190
+ notes: 'Pluxx warns at 8,000 aggregate characters to keep Claude listings readable and avoid crowded discovery surfaces.',
191
+ },
192
+ },
193
+ 'codex': {
194
+ ...NULL_LIMIT_POLICIES,
195
+ skillDescriptionMax: { kind: 'hard' },
196
+ skillNameMustMatchDir: { kind: 'hard' },
197
+ manifestPromptMax: { kind: 'hard' },
198
+ manifestPromptCountMax: { kind: 'hard' },
199
+ manifestPathPrefix: { kind: 'hard' },
200
+ instructionsMaxBytes: {
201
+ kind: 'hard',
202
+ notes: 'Codex AGENTS.md/project docs truncate at 32 KiB.',
203
+ },
204
+ hooksFeatureFlag: {
205
+ kind: 'hard',
206
+ notes: 'Hook support depends on the Codex hooks feature flag/runtime support.',
207
+ },
208
+ },
209
+ 'cursor': {
210
+ ...NULL_LIMIT_POLICIES,
211
+ skillNameMustMatchDir: { kind: 'hard' },
212
+ rulesMaxLines: {
213
+ kind: 'advisory',
214
+ notes: 'Cursor docs treat 500 lines as practical guidance rather than a documented hard cap.',
215
+ },
216
+ },
217
+ 'opencode': {
218
+ ...NULL_LIMIT_POLICIES,
219
+ skillDescriptionMax: { kind: 'hard' },
220
+ skillNameMustMatchDir: { kind: 'hard' },
221
+ },
222
+ 'github-copilot': {
223
+ ...NULL_LIMIT_POLICIES,
224
+ skillDescriptionDisplayMax: { kind: 'display' },
225
+ },
226
+ 'openhands': {
227
+ ...NULL_LIMIT_POLICIES,
228
+ },
229
+ 'warp': {
230
+ ...NULL_LIMIT_POLICIES,
231
+ },
232
+ 'gemini-cli': {
233
+ ...NULL_LIMIT_POLICIES,
234
+ skillNameMustMatchDir: { kind: 'hard' },
235
+ },
236
+ 'roo-code': {
237
+ ...NULL_LIMIT_POLICIES,
238
+ },
239
+ 'cline': {
240
+ ...NULL_LIMIT_POLICIES,
241
+ skillDescriptionMax: { kind: 'hard' },
242
+ skillNameMustMatchDir: { kind: 'hard' },
243
+ },
244
+ 'amp': {
245
+ ...NULL_LIMIT_POLICIES,
246
+ },
247
+ }
248
+
141
249
  type ResearchTarget = Extract<
142
250
  TargetPlatform,
143
251
  | 'claude-code'
@@ -157,6 +265,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
157
265
  platform: 'claude-code',
158
266
  summary: 'Claude Code plugins use an optional manifest at .claude-plugin/plugin.json with auto-discovery for skills, commands, agents, hooks, MCP, and output styles.',
159
267
  limits: PLATFORM_LIMITS['claude-code'],
268
+ limitPolicies: PLATFORM_LIMIT_POLICIES['claude-code'],
160
269
  skillDiscoveryDirs: [
161
270
  { path: 'skills/', level: 'supported' },
162
271
  ],
@@ -187,6 +296,9 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
187
296
  format: 'markdown',
188
297
  },
189
298
  sources: [
299
+ { label: 'Claude Code headless docs', url: 'https://code.claude.com/docs/en/headless' },
300
+ { label: 'Claude Code CLI reference', url: 'https://code.claude.com/docs/en/cli-reference' },
301
+ { label: 'Claude Code discover plugins docs', url: 'https://code.claude.com/docs/en/discover-plugins' },
190
302
  { label: 'Claude Code plugins reference', url: 'https://code.claude.com/docs/en/plugins-reference' },
191
303
  { label: 'Claude Code hooks docs', url: 'https://code.claude.com/docs/en/hooks' },
192
304
  { label: 'Claude Code skills docs', url: 'https://code.claude.com/docs/en/skills' },
@@ -196,6 +308,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
196
308
  platform: 'cursor',
197
309
  summary: 'Cursor plugins use .cursor-plugin/plugin.json plus auto-discovered rules, skills, agents, commands, hooks, and mcp.json at the plugin root; Cursor subagents are a related but separate surface under .cursor/agents and ~/.cursor/agents.',
198
310
  limits: PLATFORM_LIMITS['cursor'],
311
+ limitPolicies: PLATFORM_LIMIT_POLICIES['cursor'],
199
312
  skillDiscoveryDirs: [
200
313
  { path: 'skills/', level: 'supported' },
201
314
  { path: 'SKILL.md', level: 'fallback', notes: 'Used when no skills directory or manifest skill path is present.' },
@@ -243,6 +356,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
243
356
  platform: 'codex',
244
357
  summary: 'Codex plugins use .codex-plugin/plugin.json with skills, .mcp.json, optional app mappings, and AGENTS.md; current docs separate plugin packaging from hooks configuration and do not document plugin-provided slash commands.',
245
358
  limits: PLATFORM_LIMITS['codex'],
359
+ limitPolicies: PLATFORM_LIMIT_POLICIES['codex'],
246
360
  skillDiscoveryDirs: [
247
361
  { path: 'skills/', level: 'supported' },
248
362
  ],
@@ -284,6 +398,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
284
398
  platform: 'opencode',
285
399
  summary: 'OpenCode plugins are code-first TypeScript or JavaScript modules that register skills, commands, MCP servers, and hook handlers programmatically.',
286
400
  limits: PLATFORM_LIMITS['opencode'],
401
+ limitPolicies: PLATFORM_LIMIT_POLICIES['opencode'],
287
402
  skillDiscoveryDirs: [
288
403
  { path: 'skills/', level: 'supported' },
289
404
  ],
@@ -323,6 +438,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
323
438
  platform: 'openhands',
324
439
  summary: 'OpenHands plugins use a Claude-style manifest at .plugin/plugin.json and support skills, hooks, and MCP.',
325
440
  limits: PLATFORM_LIMITS['openhands'],
441
+ limitPolicies: PLATFORM_LIMIT_POLICIES['openhands'],
326
442
  skillDiscoveryDirs: [
327
443
  { path: '.openhands/skills/', level: 'supported' },
328
444
  { path: '.claude/skills/', level: 'supported' },
@@ -363,6 +479,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
363
479
  platform: 'warp',
364
480
  summary: 'Warp supports skills, rules, and MCP with AGENTS.md as the current rules anchor.',
365
481
  limits: PLATFORM_LIMITS['warp'],
482
+ limitPolicies: PLATFORM_LIMIT_POLICIES['warp'],
366
483
  skillDiscoveryDirs: [
367
484
  { path: '.agents/skills/', level: 'supported' },
368
485
  { path: '.warp/skills/', level: 'supported' },
@@ -405,6 +522,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
405
522
  platform: 'gemini-cli',
406
523
  summary: 'Gemini CLI uses gemini-extension.json, GEMINI.md instructions, and hook definitions in hooks/hooks.json.',
407
524
  limits: PLATFORM_LIMITS['gemini-cli'],
525
+ limitPolicies: PLATFORM_LIMIT_POLICIES['gemini-cli'],
408
526
  skillDiscoveryDirs: [
409
527
  { path: 'skills/', level: 'supported' },
410
528
  ],
@@ -443,6 +561,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
443
561
  platform: 'roo-code',
444
562
  summary: 'Roo Code supports project and mode-specific rules, project-level MCP config, and custom modes metadata.',
445
563
  limits: PLATFORM_LIMITS['roo-code'],
564
+ limitPolicies: PLATFORM_LIMIT_POLICIES['roo-code'],
446
565
  skillDiscoveryDirs: [
447
566
  { path: '.roo/skills/', level: 'supported' },
448
567
  ],
@@ -484,6 +603,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
484
603
  platform: 'cline',
485
604
  summary: 'Cline supports layered rules, .cline/mcp.json, and conditional rules via frontmatter path globs.',
486
605
  limits: PLATFORM_LIMITS['cline'],
606
+ limitPolicies: PLATFORM_LIMIT_POLICIES['cline'],
487
607
  skillDiscoveryDirs: [
488
608
  { path: '.cline/skills/', level: 'supported' },
489
609
  { path: '.agents/skills/', level: 'supported' },
@@ -524,6 +644,7 @@ export const PLATFORM_VALIDATION_RULES: Record<ResearchTarget, PlatformRules> =
524
644
  platform: 'amp',
525
645
  summary: 'AMP uses AGENTS.md/AGENT.md for instruction hierarchy and .amp/settings.json for settings, hooks, and MCP.',
526
646
  limits: PLATFORM_LIMITS['amp'],
647
+ limitPolicies: PLATFORM_LIMIT_POLICIES['amp'],
527
648
  skillDiscoveryDirs: [
528
649
  { path: '.agents/skills/', level: 'supported' },
529
650
  { path: '~/.config/amp/skills/', level: 'supported' },