@orchid-labs/pluxx 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +25 -8
  2. package/bin/pluxx.js +19 -28
  3. package/dist/agents.d.ts +16 -0
  4. package/dist/agents.d.ts.map +1 -0
  5. package/dist/cli/agent.d.ts +62 -0
  6. package/dist/cli/agent.d.ts.map +1 -1
  7. package/dist/cli/doctor.d.ts +2 -0
  8. package/dist/cli/doctor.d.ts.map +1 -1
  9. package/dist/cli/entry.d.ts +2 -0
  10. package/dist/cli/entry.d.ts.map +1 -0
  11. package/dist/cli/index.d.ts +7 -1
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +21810 -0
  14. package/dist/cli/init-from-mcp.d.ts +17 -1
  15. package/dist/cli/init-from-mcp.d.ts.map +1 -1
  16. package/dist/cli/install.d.ts +1 -0
  17. package/dist/cli/install.d.ts.map +1 -1
  18. package/dist/cli/lint.d.ts +3 -1
  19. package/dist/cli/lint.d.ts.map +1 -1
  20. package/dist/cli/mcp-proxy.d.ts.map +1 -1
  21. package/dist/cli/migrate.d.ts.map +1 -1
  22. package/dist/cli/primitive-summary.d.ts +14 -0
  23. package/dist/cli/primitive-summary.d.ts.map +1 -0
  24. package/dist/cli/prompt.d.ts +1 -1
  25. package/dist/cli/publish.d.ts +6 -1
  26. package/dist/cli/publish.d.ts.map +1 -1
  27. package/dist/cli/sync-from-mcp.d.ts.map +1 -1
  28. package/dist/cli/verify-install.d.ts +25 -0
  29. package/dist/cli/verify-install.d.ts.map +1 -0
  30. package/dist/commands.d.ts +10 -0
  31. package/dist/commands.d.ts.map +1 -0
  32. package/dist/compiler-intent.d.ts +165 -0
  33. package/dist/compiler-intent.d.ts.map +1 -0
  34. package/dist/config/load.d.ts.map +1 -1
  35. package/dist/delegation.d.ts +11 -0
  36. package/dist/delegation.d.ts.map +1 -0
  37. package/dist/generators/amp/index.d.ts.map +1 -1
  38. package/dist/generators/base.d.ts +5 -0
  39. package/dist/generators/base.d.ts.map +1 -1
  40. package/dist/generators/claude-code/index.d.ts.map +1 -1
  41. package/dist/generators/cline/index.d.ts.map +1 -1
  42. package/dist/generators/codex/index.d.ts +4 -0
  43. package/dist/generators/codex/index.d.ts.map +1 -1
  44. package/dist/generators/cursor/index.d.ts +1 -0
  45. package/dist/generators/cursor/index.d.ts.map +1 -1
  46. package/dist/generators/gemini-cli/index.d.ts.map +1 -1
  47. package/dist/generators/github-copilot/index.d.ts.map +1 -1
  48. package/dist/generators/opencode/index.d.ts +1 -0
  49. package/dist/generators/opencode/index.d.ts.map +1 -1
  50. package/dist/generators/openhands/index.d.ts.map +1 -1
  51. package/dist/generators/roo-code/index.d.ts.map +1 -1
  52. package/dist/generators/shared/claude-family.d.ts.map +1 -1
  53. package/dist/generators/warp/index.d.ts.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +5371 -553
  57. package/dist/schema.d.ts +91 -42
  58. package/dist/schema.d.ts.map +1 -1
  59. package/dist/text-files.d.ts +5 -0
  60. package/dist/text-files.d.ts.map +1 -0
  61. package/dist/validation/platform-rules.d.ts +15 -1
  62. package/dist/validation/platform-rules.d.ts.map +1 -1
  63. package/package.json +15 -13
  64. package/src/cli/agent.ts +0 -1455
  65. package/src/cli/dev.ts +0 -112
  66. package/src/cli/doctor.ts +0 -987
  67. package/src/cli/eval.ts +0 -470
  68. package/src/cli/index.ts +0 -2933
  69. package/src/cli/init-from-mcp.ts +0 -2115
  70. package/src/cli/install.ts +0 -860
  71. package/src/cli/lint.ts +0 -1249
  72. package/src/cli/mcp-proxy.ts +0 -322
  73. package/src/cli/migrate.ts +0 -867
  74. package/src/cli/prompt.ts +0 -82
  75. package/src/cli/publish.ts +0 -401
  76. package/src/cli/runtime.ts +0 -86
  77. package/src/cli/sync-from-mcp.ts +0 -586
  78. package/src/cli/test.ts +0 -142
  79. package/src/compatibility/matrix.ts +0 -149
  80. package/src/config/define.ts +0 -20
  81. package/src/config/load.ts +0 -74
  82. package/src/generators/amp/index.ts +0 -63
  83. package/src/generators/base.ts +0 -188
  84. package/src/generators/claude-code/index.ts +0 -172
  85. package/src/generators/cline/index.ts +0 -35
  86. package/src/generators/codex/index.ts +0 -143
  87. package/src/generators/cursor/index.ts +0 -158
  88. package/src/generators/gemini-cli/index.ts +0 -83
  89. package/src/generators/github-copilot/index.ts +0 -32
  90. package/src/generators/hooks-warning.ts +0 -51
  91. package/src/generators/index.ts +0 -71
  92. package/src/generators/opencode/index.ts +0 -526
  93. package/src/generators/openhands/index.ts +0 -32
  94. package/src/generators/roo-code/index.ts +0 -35
  95. package/src/generators/shared/claude-family.ts +0 -215
  96. package/src/generators/warp/index.ts +0 -32
  97. package/src/hook-events.ts +0 -33
  98. package/src/index.ts +0 -34
  99. package/src/mcp/introspect.ts +0 -1107
  100. package/src/permissions.ts +0 -260
  101. package/src/schema.ts +0 -312
  102. package/src/user-config.ts +0 -177
  103. package/src/validation/platform-rules.ts +0 -686
@@ -1,2115 +0,0 @@
1
- import { existsSync } from 'fs'
2
- import { mkdir } from 'fs/promises'
3
- import { basename, resolve } from 'path'
4
- import type { HookEntry, McpServer, PluginConfig, TargetPlatform, UserConfigEntry } from '../schema'
5
- import type {
6
- IntrospectedMcpPrompt,
7
- IntrospectedMcpResource,
8
- IntrospectedMcpResourceTemplate,
9
- IntrospectedMcpServer,
10
- IntrospectedMcpTool,
11
- } from '../mcp/introspect'
12
- import { collectUserConfigEntries } from '../user-config'
13
-
14
- export interface McpScaffoldOptions {
15
- rootDir: string
16
- pluginName: string
17
- authorName: string
18
- targets: TargetPlatform[]
19
- source: McpServer
20
- introspection: IntrospectedMcpServer
21
- serverName?: string
22
- displayName?: string
23
- description?: string
24
- skillGrouping?: McpSkillGrouping
25
- hookMode?: McpHookMode
26
- runtimeAuthMode?: McpRuntimeAuthMode
27
- persistedSkills?: PersistedSkill[]
28
- toolRenames?: Map<string, string>
29
- }
30
-
31
- export interface McpScaffoldResult {
32
- instructionsPath: string
33
- skillDirectories: string[]
34
- commandFiles: string[]
35
- generatedFiles: string[]
36
- generatedHookMode: McpHookMode
37
- generatedHookEvents: string[]
38
- metadataPath: string
39
- }
40
-
41
- export interface McpScaffoldPlannedFile {
42
- relativePath: string
43
- content: string
44
- action: 'create' | 'update' | 'unchanged'
45
- }
46
-
47
- export interface McpScaffoldPlan extends McpScaffoldResult {
48
- files: McpScaffoldPlannedFile[]
49
- }
50
-
51
- interface PlannedSkill {
52
- dirName: string
53
- title: string
54
- description: string
55
- tools: IntrospectedMcpTool[]
56
- resources: IntrospectedMcpResource[]
57
- resourceTemplates: IntrospectedMcpResourceTemplate[]
58
- prompts: IntrospectedMcpPrompt[]
59
- workflowKey?: string | null
60
- }
61
-
62
- export interface PersistedSkill {
63
- dirName: string
64
- title: string
65
- description?: string
66
- toolNames: string[]
67
- }
68
-
69
- interface SchemaField {
70
- name: string
71
- type: string
72
- required: boolean
73
- description?: string
74
- }
75
-
76
- export type McpQualityLevel = 'warning' | 'info'
77
-
78
- export interface McpQualityIssue {
79
- level: McpQualityLevel
80
- code: string
81
- title: string
82
- detail: string
83
- fix: string
84
- }
85
-
86
- export interface McpQualityReport {
87
- ok: boolean
88
- warnings: number
89
- infos: number
90
- issues: McpQualityIssue[]
91
- }
92
-
93
- export const MCP_SKILL_GROUPINGS = ['workflow', 'tool'] as const
94
- export type McpSkillGrouping = typeof MCP_SKILL_GROUPINGS[number]
95
-
96
- export const MCP_HOOK_MODES = ['none', 'safe'] as const
97
- export type McpHookMode = typeof MCP_HOOK_MODES[number]
98
- export const MCP_RUNTIME_AUTH_MODES = ['inline', 'platform'] as const
99
- export type McpRuntimeAuthMode = typeof MCP_RUNTIME_AUTH_MODES[number]
100
-
101
- interface GeneratedHookScaffold {
102
- mode: McpHookMode
103
- scriptsPath?: string
104
- hookEntries?: Record<string, HookEntry[]>
105
- files: Array<{ relativePath: string; content: string }>
106
- }
107
-
108
- export interface McpScaffoldMetadata {
109
- version: 1
110
- source: McpServer
111
- serverInfo: IntrospectedMcpServer['serverInfo']
112
- settings: {
113
- pluginName: string
114
- displayName: string
115
- description?: string
116
- skillGrouping: McpSkillGrouping
117
- requestedHookMode: McpHookMode
118
- generatedHookMode: McpHookMode
119
- generatedHookEvents: string[]
120
- runtimeAuthMode: McpRuntimeAuthMode
121
- }
122
- userConfig: UserConfigEntry[]
123
- tools: IntrospectedMcpTool[]
124
- resources?: IntrospectedMcpResource[]
125
- resourceTemplates?: IntrospectedMcpResourceTemplate[]
126
- prompts?: IntrospectedMcpPrompt[]
127
- skills: Array<{
128
- dirName: string
129
- title: string
130
- description?: string
131
- toolNames: string[]
132
- resourceUris?: string[]
133
- resourceTemplateUris?: string[]
134
- promptNames?: string[]
135
- }>
136
- managedFiles: string[]
137
- }
138
-
139
- export const MCP_SCAFFOLD_METADATA_PATH = '.pluxx/mcp.json'
140
- export const MCP_TAXONOMY_PATH = '.pluxx/taxonomy.json'
141
- export const PLUXX_GENERATED_START = '<!-- pluxx:generated:start -->'
142
- export const PLUXX_GENERATED_END = '<!-- pluxx:generated:end -->'
143
- export const PLUXX_CUSTOM_START = '<!-- pluxx:custom:start -->'
144
- export const PLUXX_CUSTOM_END = '<!-- pluxx:custom:end -->'
145
-
146
- const DEFAULT_INSTRUCTIONS_CUSTOM_CONTENT = 'Add custom plugin instructions here. This section is preserved across `pluxx sync --from-mcp`.'
147
- const DEFAULT_SKILL_CUSTOM_CONTENT = 'Add custom guidance, examples, or caveats here. This section is preserved across `pluxx sync --from-mcp`.'
148
- const LEGACY_MIXED_CONTENT_NOTE = 'Migrated from a previous unstructured scaffold. Review and trim this section as needed.'
149
-
150
- interface MixedMarkdownContent {
151
- hasMarkers: boolean
152
- customContent: string
153
- }
154
-
155
- const WORKFLOW_SKILL_DEFINITIONS = [
156
- {
157
- key: 'setup-and-auth',
158
- title: 'Setup and Auth',
159
- description: 'Confirm access, auth state, and session readiness before running operational workflows.',
160
- match: ['connect', 'connected', 'connection', 'status', 'auth', 'session', 'cookie', 'workspace'],
161
- },
162
- {
163
- key: 'workflow-design',
164
- title: 'Workflow Design',
165
- description: 'Define strategy, prompts, targeting, and workflow shape before building tables or running enrichments.',
166
- match: ['workflow', 'design', 'play', 'prompt', 'icp', 'persona', 'audience', 'segment', 'brainstorm', 'outreach'],
167
- },
168
- {
169
- key: 'account-research',
170
- title: 'Account Research',
171
- description: 'Research companies, organizations, and account context before taking action.',
172
- match: ['organization', 'organisation', 'company', 'account', 'firmographic'],
173
- },
174
- {
175
- key: 'contact-discovery',
176
- title: 'Contact Discovery',
177
- description: 'Find people, contacts, and buyer-side context at the right accounts.',
178
- match: ['people', 'person', 'contact', 'prospect', 'decision maker', 'org chart'],
179
- },
180
- {
181
- key: 'hiring-signals',
182
- title: 'Hiring Signals',
183
- description: 'Use hiring activity and open roles as timing signals for outreach and research.',
184
- match: ['job', 'jobs', 'hiring', 'hire', 'role', 'roles', 'recruit', 'career'],
185
- },
186
- {
187
- key: 'technographics',
188
- title: 'Technographics',
189
- description: 'Research technologies, tools, and stack adoption across target accounts.',
190
- match: ['technology', 'technologies', 'tech', 'stack', 'tooling', 'software'],
191
- },
192
- {
193
- key: 'table-operations',
194
- title: 'Table Operations',
195
- description: 'Build, inspect, run, document, and export tables, rows, and enrichment workflows.',
196
- match: ['table', 'tables', 'schema', 'webhook', 'row', 'rows', 'export', 'audit', 'document', 'enrich', 'view'],
197
- },
198
- {
199
- key: 'provider-research',
200
- title: 'Provider Research',
201
- description: 'Compare providers, integrations, and capability tradeoffs before choosing a workflow.',
202
- match: ['provider', 'providers', 'integration', 'integrations', 'byoa', 'waterfall', 'compare', 'comparison'],
203
- },
204
- {
205
- key: 'account-and-usage',
206
- title: 'Account and Usage',
207
- description: 'Check pricing, usage, limits, credits, and upgrade context for the current account.',
208
- match: ['usage', 'tier', 'plan', 'pricing', 'price', 'cost', 'credits', 'checkout', 'upgrade', 'billing'],
209
- },
210
- {
211
- key: 'general-research',
212
- title: 'General Research',
213
- description: 'Handle broad search and query workflows when there is not a more specific product surface match.',
214
- match: ['search', 'query', 'lookup', 'look up', 'discover', 'find'],
215
- },
216
- ] as const
217
-
218
- const GENERIC_TOOL_NAMES = new Set([
219
- 'run',
220
- 'execute',
221
- 'query',
222
- 'invoke',
223
- 'action',
224
- 'tool',
225
- 'command',
226
- 'workflow',
227
- 'task',
228
- ])
229
-
230
- export function parseMcpSourceInput(input: string, transportOverride?: string): McpServer {
231
- const value = input.trim()
232
- if (!value) {
233
- throw new Error('Expected an MCP server URL or a local command.')
234
- }
235
-
236
- if (transportOverride && transportOverride !== 'http' && transportOverride !== 'sse') {
237
- throw new Error('Transport must be one of: http, sse')
238
- }
239
-
240
- try {
241
- const url = new URL(value)
242
- if (url.protocol === 'http:' || url.protocol === 'https:') {
243
- const normalizedPath = url.pathname.replace(/\/+$/, '')
244
- const transport = transportOverride === 'sse' || (!transportOverride && normalizedPath.endsWith('/sse'))
245
- ? 'sse' as const
246
- : 'http' as const
247
- return {
248
- transport,
249
- url: url.toString(),
250
- }
251
- }
252
- } catch {
253
- // Not a URL, treat it as a stdio command.
254
- }
255
-
256
- const parts = splitCommandString(value)
257
- if (parts.length === 0) {
258
- throw new Error('Expected an MCP server URL or a local command.')
259
- }
260
-
261
- return {
262
- transport: 'stdio',
263
- command: parts[0],
264
- args: parts.slice(1),
265
- }
266
- }
267
-
268
- export async function writeMcpScaffold(options: McpScaffoldOptions): Promise<McpScaffoldResult> {
269
- const plan = await planMcpScaffold(options)
270
- await applyMcpScaffoldPlan(options.rootDir, plan)
271
-
272
- return {
273
- instructionsPath: plan.instructionsPath,
274
- skillDirectories: plan.skillDirectories,
275
- commandFiles: plan.commandFiles,
276
- generatedFiles: plan.generatedFiles,
277
- generatedHookMode: plan.generatedHookMode,
278
- generatedHookEvents: plan.generatedHookEvents,
279
- metadataPath: plan.metadataPath,
280
- }
281
- }
282
-
283
- export async function applyMcpScaffoldPlan(rootDir: string, plan: McpScaffoldPlan): Promise<void> {
284
- for (const file of plan.files) {
285
- const filePath = resolve(rootDir, file.relativePath)
286
- const parentDir = file.relativePath.split('/').slice(0, -1).join('/')
287
- if (parentDir) {
288
- await mkdir(resolve(rootDir, parentDir), { recursive: true })
289
- }
290
- await Bun.write(filePath, file.content)
291
- }
292
- }
293
-
294
- export async function planMcpScaffold(options: McpScaffoldOptions): Promise<McpScaffoldPlan> {
295
- const pluginName = toKebabCase(options.pluginName) || 'mcp-plugin'
296
- const displayName = options.displayName
297
- ?? deriveDisplayName(options.introspection, pluginName)
298
- const plannedSkills = planSkillScaffoldsFromPersisted(
299
- options.introspection.tools,
300
- options.skillGrouping,
301
- options.introspection.resources ?? [],
302
- options.introspection.resourceTemplates ?? [],
303
- options.introspection.prompts ?? [],
304
- options.persistedSkills,
305
- options.toolRenames,
306
- )
307
- const description = options.description
308
- ?? deriveScaffoldDescription({
309
- displayName,
310
- introspection: options.introspection,
311
- plannedSkills,
312
- })
313
- const serverName = options.serverName
314
- ?? toKebabCase(options.introspection.serverInfo.name)
315
- ?? pluginName
316
- const runtimeAuthMode = options.runtimeAuthMode
317
- ?? (
318
- options.source.transport !== 'stdio' && options.source.auth?.type === 'platform'
319
- ? 'platform'
320
- : 'inline'
321
- )
322
-
323
- const instructionsPath = resolve(options.rootDir, 'INSTRUCTIONS.md')
324
- const skillRoot = resolve(options.rootDir, 'skills')
325
- const commandsRoot = resolve(options.rootDir, 'commands')
326
- const userConfigSource = {
327
- targets: options.targets,
328
- mcp: {
329
- [serverName]: options.source,
330
- },
331
- platforms: runtimeAuthMode === 'platform'
332
- ? {
333
- 'claude-code': { mcpAuth: 'platform' as const },
334
- cursor: { mcpAuth: 'platform' as const },
335
- }
336
- : undefined,
337
- } as PluginConfig
338
- const userConfig = collectUserConfigEntries(userConfigSource)
339
- .map(({ source: _source, ...entry }) => entry)
340
- const skillDirectories: string[] = []
341
- const commandFiles: string[] = []
342
- const generatedFiles = ['pluxx.config.ts', './INSTRUCTIONS.md']
343
- const generatedHooks = planGeneratedHooks(options.source, options.introspection.tools, serverName, options.hookMode)
344
- const metadataPath = MCP_SCAFFOLD_METADATA_PATH
345
- const taxonomyPath = MCP_TAXONOMY_PATH
346
- const files: McpScaffoldPlannedFile[] = []
347
-
348
- const addPlannedFile = async (relativePath: string, content: string) => {
349
- const filePath = resolve(options.rootDir, relativePath)
350
- const action = existsSync(filePath)
351
- ? ((await Bun.file(filePath).text()) === content ? 'unchanged' : 'update')
352
- : 'create'
353
- files.push({ relativePath, content, action })
354
- }
355
-
356
- await addPlannedFile(
357
- 'pluxx.config.ts',
358
- buildConfigTemplate({
359
- pluginName,
360
- authorName: options.authorName,
361
- description,
362
- displayName,
363
- serverName,
364
- source: options.source,
365
- websiteUrl: options.introspection.serverInfo.websiteUrl,
366
- targets: options.targets,
367
- userConfig,
368
- hooks: generatedHooks.hookEntries,
369
- scriptsPath: generatedHooks.scriptsPath,
370
- runtimeAuthMode,
371
- commandsPath: './commands/',
372
- }),
373
- )
374
-
375
- await addPlannedFile(
376
- './INSTRUCTIONS.md',
377
- wrapManagedMarkdown(
378
- buildInstructionsContent({
379
- displayName,
380
- description,
381
- source: options.source,
382
- runtimeAuthMode,
383
- instructions: options.introspection.instructions,
384
- skills: plannedSkills,
385
- tools: options.introspection.tools,
386
- resources: options.introspection.resources ?? [],
387
- resourceTemplates: options.introspection.resourceTemplates ?? [],
388
- prompts: options.introspection.prompts ?? [],
389
- userConfig,
390
- }),
391
- existsSync(instructionsPath) ? await Bun.file(instructionsPath).text() : undefined,
392
- {
393
- customHeading: '## Custom Instructions',
394
- defaultCustomContent: DEFAULT_INSTRUCTIONS_CUSTOM_CONTENT,
395
- },
396
- ),
397
- )
398
-
399
- for (const skill of plannedSkills) {
400
- const relativeSkillPath = `skills/${skill.dirName}`
401
- const skillPath = resolve(skillRoot, skill.dirName, 'SKILL.md')
402
- const relativeCommandPath = `commands/${skill.dirName}.md`
403
- const commandPath = resolve(commandsRoot, `${skill.dirName}.md`)
404
- await addPlannedFile(
405
- `${relativeSkillPath}/SKILL.md`,
406
- wrapManagedMarkdown(
407
- buildSkillContent(skill),
408
- existsSync(skillPath) ? await Bun.file(skillPath).text() : undefined,
409
- {
410
- customHeading: '## Custom Notes',
411
- defaultCustomContent: DEFAULT_SKILL_CUSTOM_CONTENT,
412
- },
413
- ),
414
- )
415
- skillDirectories.push(relativeSkillPath)
416
- generatedFiles.push(`${relativeSkillPath}/SKILL.md`)
417
-
418
- await addPlannedFile(
419
- relativeCommandPath,
420
- buildCommandContent(
421
- skill,
422
- existsSync(commandPath) ? await Bun.file(commandPath).text() : undefined,
423
- ),
424
- )
425
- commandFiles.push(relativeCommandPath)
426
- generatedFiles.push(relativeCommandPath)
427
- }
428
-
429
- for (const file of generatedHooks.files) {
430
- await addPlannedFile(file.relativePath, file.content)
431
- generatedFiles.push(file.relativePath)
432
- }
433
-
434
- const metadata = buildMcpScaffoldMetadata({
435
- source: options.source,
436
- introspection: options.introspection,
437
- pluginName,
438
- displayName,
439
- skillGrouping: options.skillGrouping ?? 'workflow',
440
- requestedHookMode: options.hookMode ?? 'none',
441
- generatedHookMode: generatedHooks.mode,
442
- generatedHookEvents: Object.keys(generatedHooks.hookEntries ?? {}),
443
- runtimeAuthMode,
444
- userConfig,
445
- plannedSkills,
446
- managedFiles: [...generatedFiles, taxonomyPath, metadataPath],
447
- })
448
- await addPlannedFile(taxonomyPath, `${JSON.stringify(buildPersistedTaxonomy(plannedSkills), null, 2)}\n`)
449
- generatedFiles.push(taxonomyPath)
450
- await addPlannedFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`)
451
- generatedFiles.push(metadataPath)
452
-
453
- return {
454
- instructionsPath: './INSTRUCTIONS.md',
455
- skillDirectories,
456
- commandFiles,
457
- generatedFiles,
458
- generatedHookMode: generatedHooks.mode,
459
- generatedHookEvents: Object.keys(generatedHooks.hookEntries ?? {}),
460
- metadataPath,
461
- files,
462
- }
463
- }
464
-
465
- export function wrapManagedMarkdown(
466
- generatedContent: string,
467
- existingContent: string | undefined,
468
- options: {
469
- customHeading: string
470
- defaultCustomContent: string
471
- },
472
- ): string {
473
- const mixedContent = extractMixedMarkdownContent(existingContent, options.defaultCustomContent)
474
- const customContent = mixedContent.customContent.trim() || options.defaultCustomContent
475
- const { frontmatter, body } = splitMarkdownFrontmatter(generatedContent.trim())
476
-
477
- const lines = [
478
- ...(frontmatter ? [frontmatter, ''] : []),
479
- PLUXX_GENERATED_START,
480
- body.trim(),
481
- PLUXX_GENERATED_END,
482
- '',
483
- options.customHeading,
484
- '',
485
- PLUXX_CUSTOM_START,
486
- customContent,
487
- PLUXX_CUSTOM_END,
488
- '',
489
- ]
490
-
491
- return lines.join('\n')
492
- }
493
-
494
- function splitMarkdownFrontmatter(content: string): { frontmatter: string; body: string } {
495
- const lines = content.split(/\r?\n/)
496
- if (lines[0]?.trim() !== '---') {
497
- return {
498
- frontmatter: '',
499
- body: content,
500
- }
501
- }
502
-
503
- let endIndex = -1
504
- for (let index = 1; index < lines.length; index += 1) {
505
- if (lines[index].trim() === '---') {
506
- endIndex = index
507
- break
508
- }
509
- }
510
-
511
- if (endIndex === -1) {
512
- return {
513
- frontmatter: '',
514
- body: content,
515
- }
516
- }
517
-
518
- return {
519
- frontmatter: lines.slice(0, endIndex + 1).join('\n'),
520
- body: lines.slice(endIndex + 1).join('\n').replace(/^\n+/, ''),
521
- }
522
- }
523
-
524
- export function extractMixedMarkdownContent(
525
- content: string | undefined,
526
- defaultCustomContent: string,
527
- ): MixedMarkdownContent {
528
- if (!content) {
529
- return {
530
- hasMarkers: false,
531
- customContent: defaultCustomContent,
532
- }
533
- }
534
-
535
- const customStart = content.indexOf(PLUXX_CUSTOM_START)
536
- const customEnd = content.indexOf(PLUXX_CUSTOM_END)
537
-
538
- if (customStart !== -1 && customEnd !== -1 && customEnd > customStart) {
539
- const customContent = content
540
- .slice(customStart + PLUXX_CUSTOM_START.length, customEnd)
541
- .trim()
542
-
543
- return {
544
- hasMarkers: true,
545
- customContent: customContent || defaultCustomContent,
546
- }
547
- }
548
-
549
- const trimmed = content.trim()
550
- return {
551
- hasMarkers: false,
552
- customContent: trimmed
553
- ? `${LEGACY_MIXED_CONTENT_NOTE}\n\n${trimmed}`
554
- : defaultCustomContent,
555
- }
556
- }
557
-
558
- export function hasMeaningfulCustomContent(content: string | undefined): boolean {
559
- if (!content) return false
560
-
561
- const extracted = extractMixedMarkdownContent(content, DEFAULT_INSTRUCTIONS_CUSTOM_CONTENT)
562
- const normalized = extracted.customContent.trim()
563
-
564
- return normalized !== '' && normalized !== DEFAULT_INSTRUCTIONS_CUSTOM_CONTENT && normalized !== DEFAULT_SKILL_CUSTOM_CONTENT
565
- }
566
-
567
- export function buildSkillContent(skill: PlannedSkill): string {
568
- const description = buildSkillFrontmatterDescription(skill)
569
- const exampleRequests = skill.tools
570
- .map((tool) => buildToolExampleRequest(tool))
571
- .filter((example, index, values) => values.indexOf(example) === index)
572
-
573
- const lines = [
574
- '---',
575
- `name: ${JSON.stringify(skill.dirName)}`,
576
- `description: ${JSON.stringify(description)}`,
577
- '---',
578
- '',
579
- `# ${skill.title}`,
580
- '',
581
- skill.description,
582
- '',
583
- '## Tools In This Skill',
584
- '',
585
- ]
586
-
587
- for (const tool of skill.tools) {
588
- lines.push(`### \`${tool.name}\``)
589
- lines.push('')
590
- lines.push(tool.description ?? `Calls \`${tool.name}\` on the configured MCP server.`)
591
- lines.push('')
592
-
593
- const fields = getTopLevelSchemaFields(tool.inputSchema)
594
- if (fields.length > 0) {
595
- lines.push('Inputs:')
596
- for (const field of fields) {
597
- const required = field.required ? ', required' : ''
598
- const detail = field.description ? `: ${field.description}` : ''
599
- lines.push(`- \`${field.name}\` (${field.type}${required})${detail}`)
600
- }
601
- lines.push('')
602
- }
603
- }
604
-
605
- if (skill.resources.length > 0 || skill.resourceTemplates.length > 0) {
606
- lines.push('## Related Resources', '')
607
-
608
- for (const resource of skill.resources) {
609
- const label = resource.name ?? resource.title ?? resource.uri
610
- lines.push(`- \`${label}\`: ${summarizeResourceForInstructions(resource)}`)
611
- }
612
-
613
- for (const template of skill.resourceTemplates) {
614
- lines.push(`- \`${template.name}\`: ${summarizeResourceTemplateForInstructions(template)}`)
615
- }
616
-
617
- lines.push('')
618
- }
619
-
620
- if (skill.prompts.length > 0) {
621
- lines.push('## Related Prompt Templates', '')
622
-
623
- for (const prompt of skill.prompts) {
624
- lines.push(`- \`${prompt.name}\`: ${summarizePromptForInstructions(prompt)}`)
625
- }
626
-
627
- lines.push('')
628
- }
629
-
630
- lines.push(
631
- '## Example Requests',
632
- '',
633
- )
634
-
635
- for (const example of exampleRequests) {
636
- lines.push(`- "${example}"`)
637
- }
638
-
639
- lines.push(
640
- '',
641
- '## Usage',
642
- '',
643
- '- Pick the most specific tool in this skill for the user request.',
644
- '- Gather required inputs before calling a tool.',
645
- '- Summarize the returned data clearly instead of dumping raw JSON unless the user asks for it.',
646
- '',
647
- )
648
-
649
- return lines.join('\n')
650
- }
651
-
652
- export function buildCommandContent(skill: PlannedSkill, existingContent?: string): string {
653
- const description = truncate(cleanSingleLineText(skill.description), 140)
654
- const argumentHint = inferCommandArgumentHint(skill)
655
- const entryBlurb = buildCommandEntryBlurb(skill)
656
- const hasRelatedResources = skill.resources.length > 0 || skill.resourceTemplates.length > 0
657
- const hasRelatedPrompts = skill.prompts.length > 0
658
- const workflowSteps = [
659
- '1. Interpret `$ARGUMENTS` as the user request for this workflow.',
660
- '2. Choose the most specific tool in this surface.',
661
- ]
662
-
663
- if (hasRelatedResources) {
664
- workflowSteps.push(`${workflowSteps.length + 1}. Use related MCP resources or resource templates as canonical context when they fit the request.`)
665
- }
666
-
667
- if (hasRelatedPrompts) {
668
- workflowSteps.push(`${workflowSteps.length + 1}. Use related MCP prompt templates when they provide a canonical starting point for the task.`)
669
- }
670
-
671
- workflowSteps.push(`${workflowSteps.length + 1}. Ask for missing required inputs only if the request does not already provide them.`)
672
- workflowSteps.push(`${workflowSteps.length + 1}. Return a concise task-focused answer instead of raw JSON unless the user asks for it.`)
673
-
674
- const generatedContent = [
675
- '---',
676
- `description: ${JSON.stringify(description)}`,
677
- `argument-hint: ${JSON.stringify(argumentHint)}`,
678
- '---',
679
- '',
680
- entryBlurb,
681
- '',
682
- 'Arguments: $ARGUMENTS',
683
- '',
684
- 'Primary tools:',
685
- ...skill.tools.map((tool) => `- \`${tool.name}\``),
686
- ...(hasRelatedResources
687
- ? [
688
- '',
689
- 'Related resources:',
690
- ...skill.resources.map((resource) => {
691
- const label = resource.name ?? resource.title ?? resource.uri
692
- return `- \`${label}\``
693
- }),
694
- ...skill.resourceTemplates.map((template) => `- \`${template.name}\``),
695
- ]
696
- : []),
697
- ...(hasRelatedPrompts
698
- ? [
699
- '',
700
- 'Related prompt templates:',
701
- ...skill.prompts.map((prompt) => `- \`${prompt.name}\``),
702
- ]
703
- : []),
704
- '',
705
- 'Workflow:',
706
- '',
707
- ...workflowSteps,
708
- ].join('\n')
709
-
710
- return wrapManagedMarkdown(
711
- generatedContent,
712
- existingContent,
713
- {
714
- customHeading: '## Custom Notes',
715
- defaultCustomContent: DEFAULT_SKILL_CUSTOM_CONTENT,
716
- },
717
- )
718
- }
719
-
720
- export function buildInstructionsContent(input: {
721
- displayName: string
722
- description: string
723
- source: McpServer
724
- runtimeAuthMode?: McpRuntimeAuthMode
725
- instructions?: string
726
- skills: PlannedSkill[]
727
- tools: IntrospectedMcpTool[]
728
- resources: IntrospectedMcpResource[]
729
- resourceTemplates: IntrospectedMcpResourceTemplate[]
730
- prompts: IntrospectedMcpPrompt[]
731
- userConfig?: UserConfigEntry[]
732
- }): string {
733
- const accessLine = describePluginAccess(input.displayName, input.source, input.runtimeAuthMode ?? 'inline')
734
- const lines = [
735
- `# ${input.displayName}`,
736
- '',
737
- input.description,
738
- '',
739
- accessLine,
740
- '',
741
- '## Workflow Guidance',
742
- '',
743
- ]
744
-
745
- for (const skill of input.skills) {
746
- lines.push(`- ${buildInstructionSkillSummary(skill)}`)
747
- }
748
-
749
- lines.push(
750
- '',
751
- '## Tool Routing',
752
- '',
753
- )
754
-
755
- for (const tool of input.tools) {
756
- lines.push(`- \`${tool.name}\`: ${summarizeToolForInstructions(tool)}`)
757
- }
758
-
759
- if (input.resources.length > 0 || input.resourceTemplates.length > 0) {
760
- lines.push('', '## Resource Surfaces', '')
761
-
762
- for (const resource of input.resources.slice(0, 8)) {
763
- const label = resource.name ?? resource.title ?? resource.uri
764
- lines.push(`- \`${label}\`: ${summarizeResourceForInstructions(resource)}`)
765
- }
766
-
767
- for (const template of input.resourceTemplates.slice(0, 8)) {
768
- lines.push(`- \`${template.name}\`: ${summarizeResourceTemplateForInstructions(template)}`)
769
- }
770
- }
771
-
772
- if (input.prompts.length > 0) {
773
- lines.push('', '## Prompt Templates', '')
774
-
775
- for (const prompt of input.prompts.slice(0, 8)) {
776
- lines.push(`- \`${prompt.name}\`: ${summarizePromptForInstructions(prompt)}`)
777
- }
778
- }
779
-
780
- lines.push(
781
- '',
782
- '## Operating Notes',
783
- '',
784
- '- Prefer the most specific tool that matches the user request.',
785
- '- If the MCP exposes resources or prompt templates, use them as canonical context before improvising your own workflow.',
786
- '- Confirm required inputs before calling a tool.',
787
- '- Summarize returned data instead of dumping raw JSON unless the user asks for it.',
788
- )
789
-
790
- if (input.userConfig && input.userConfig.length > 0) {
791
- lines.push('', '## User Config', '')
792
- for (const item of input.userConfig) {
793
- const descriptor = [item.type ?? 'string', item.required === false ? 'optional' : 'required']
794
- .filter(Boolean)
795
- .join(', ')
796
- const envVar = item.envVar ? ` — env: \`${item.envVar}\`` : ''
797
- lines.push(`- \`${item.key}\` (${item.title}; ${descriptor})${envVar}: ${item.description}`)
798
- }
799
- }
800
-
801
- if (input.instructions) {
802
- const serverGuidance = summarizeServerGuidance(input.instructions)
803
- if (serverGuidance.length > 0) {
804
- lines.push('', '## Server Guidance', '')
805
- for (const item of serverGuidance) {
806
- lines.push(`- ${item}`)
807
- }
808
- }
809
- }
810
-
811
- lines.push('')
812
- return lines.join('\n')
813
- }
814
-
815
- export function buildConfigTemplate(input: {
816
- pluginName: string
817
- authorName: string
818
- description: string
819
- displayName: string
820
- serverName: string
821
- source: McpServer
822
- websiteUrl?: string
823
- targets: TargetPlatform[]
824
- userConfig?: UserConfigEntry[]
825
- hooks?: Record<string, HookEntry[]>
826
- scriptsPath?: string
827
- runtimeAuthMode?: McpRuntimeAuthMode
828
- commandsPath?: string
829
- }): string {
830
- const targets = input.targets.map((target) => JSON.stringify(target)).join(', ')
831
- const mcpBlock = buildMcpBlock(input.serverName, input.source)
832
- const shortDescription = truncate(firstSentenceOf(cleanSingleLineText(input.description)), 140)
833
- const brandFields = [
834
- `displayName: ${JSON.stringify(input.displayName)}`,
835
- shortDescription ? `shortDescription: ${JSON.stringify(shortDescription)}` : null,
836
- input.websiteUrl ? `websiteURL: ${JSON.stringify(input.websiteUrl)}` : null,
837
- ].filter(Boolean).join(',\n ')
838
- const userConfigBlock = input.userConfig && input.userConfig.length > 0
839
- ? `\n userConfig: ${serializeUserConfig(input.userConfig)},\n`
840
- : ''
841
- const scriptsBlock = input.scriptsPath ? ` scripts: ${JSON.stringify(input.scriptsPath)},\n` : ''
842
- const commandsBlock = input.commandsPath ? ` commands: ${JSON.stringify(input.commandsPath)},\n` : ''
843
- const hooksBlock = input.hooks ? `\n hooks: ${serializeHooks(input.hooks)},\n` : ''
844
- const platformsBlock = input.runtimeAuthMode === 'platform'
845
- ? `\n platforms: {\n 'claude-code': {\n mcpAuth: 'platform',\n },\n cursor: {\n mcpAuth: 'platform',\n },\n },\n`
846
- : ''
847
-
848
- return `import { definePlugin } from 'pluxx'
849
-
850
- export default definePlugin({
851
- name: ${JSON.stringify(input.pluginName)},
852
- version: '0.1.0',
853
- description: ${JSON.stringify(input.description)},
854
- author: {
855
- name: ${JSON.stringify(input.authorName)},
856
- },
857
- license: 'MIT',
858
-
859
- skills: './skills/',
860
- ${commandsBlock}
861
- instructions: './INSTRUCTIONS.md',
862
- ${userConfigBlock}
863
- ${scriptsBlock}
864
-
865
- mcp: {
866
- ${mcpBlock}
867
- },
868
- ${hooksBlock}
869
- ${platformsBlock}
870
-
871
- brand: {
872
- ${brandFields}
873
- },
874
-
875
- targets: [${targets}],
876
- })
877
- `
878
- }
879
-
880
- function buildMcpBlock(serverName: string, source: McpServer): string {
881
- if (source.transport === 'stdio') {
882
- const argsLine = source.args && source.args.length > 0
883
- ? `,\n args: ${JSON.stringify(source.args)}`
884
- : ''
885
- const envLine = source.env && Object.keys(source.env).length > 0
886
- ? `,\n env: ${JSON.stringify(source.env, null, 6).replace(/\n/g, '\n ')}`
887
- : ''
888
-
889
- return ` ${JSON.stringify(serverName)}: {
890
- transport: 'stdio',
891
- command: ${JSON.stringify(source.command)}${argsLine}${envLine}
892
- },`
893
- }
894
-
895
- const authLine = source.auth && source.auth.type !== 'none'
896
- ? source.auth.type === 'platform'
897
- ? `,\n auth: {\n type: 'platform',\n mode: ${JSON.stringify(source.auth.mode ?? 'oauth')}\n }`
898
- : `,\n auth: {\n type: ${JSON.stringify(source.auth.type)},\n envVar: ${JSON.stringify(source.auth.envVar)}${source.auth.type === 'header'
899
- ? `,\n headerName: ${JSON.stringify(source.auth.headerName)},\n headerTemplate: ${JSON.stringify(source.auth.headerTemplate)}`
900
- : ''}\n }`
901
- : ''
902
- const transportLine = source.transport === 'sse'
903
- ? `\n transport: 'sse',`
904
- : ''
905
-
906
- return ` ${JSON.stringify(serverName)}: {${transportLine}
907
- url: ${JSON.stringify(source.url)}${authLine}
908
- },`
909
- }
910
-
911
- function serializeHooks(hooks: Record<string, HookEntry[]>): string {
912
- const entries = Object.entries(hooks)
913
- .map(([event, hookEntries]) => {
914
- const serializedEntries = hookEntries
915
- .map((entry) => {
916
- const fields = [
917
- entry.type && entry.type !== 'command' ? `type: ${JSON.stringify(entry.type)}` : null,
918
- entry.command ? `command: ${JSON.stringify(entry.command)}` : null,
919
- entry.prompt ? `prompt: ${JSON.stringify(entry.prompt)}` : null,
920
- entry.model ? `model: ${JSON.stringify(entry.model)}` : null,
921
- entry.timeout !== undefined ? `timeout: ${entry.timeout}` : null,
922
- entry.matcher ? `matcher: ${JSON.stringify(entry.matcher)}` : null,
923
- entry.failClosed !== undefined ? `failClosed: ${entry.failClosed}` : null,
924
- entry.loop_limit !== undefined ? `loop_limit: ${entry.loop_limit}` : null,
925
- ].filter(Boolean)
926
-
927
- return ` {\n ${fields.join(',\n ')}\n }`
928
- })
929
- .join(',\n')
930
-
931
- return ` ${event}: [\n${serializedEntries}\n ]`
932
- })
933
- .join(',\n')
934
-
935
- return `{\n${entries}\n }`
936
- }
937
-
938
- function serializeUserConfig(userConfig: UserConfigEntry[]): string {
939
- const entries = userConfig
940
- .map((item) => {
941
- const fields = [
942
- `key: ${JSON.stringify(item.key)}`,
943
- `title: ${JSON.stringify(item.title)}`,
944
- `description: ${JSON.stringify(item.description)}`,
945
- `type: ${JSON.stringify(item.type ?? 'string')}`,
946
- `required: ${item.required ?? true}`,
947
- item.envVar ? `envVar: ${JSON.stringify(item.envVar)}` : null,
948
- item.defaultValue !== undefined ? `defaultValue: ${JSON.stringify(item.defaultValue)}` : null,
949
- item.placeholder ? `placeholder: ${JSON.stringify(item.placeholder)}` : null,
950
- item.targets ? `targets: ${JSON.stringify(item.targets)}` : null,
951
- ].filter(Boolean)
952
-
953
- return ` {\n ${fields.join(',\n ')}\n }`
954
- })
955
- .join(',\n')
956
-
957
- return `[\n${entries}\n ]`
958
- }
959
-
960
- const MUTATING_PREFIXES = [
961
- 'create', 'add', 'insert',
962
- 'update', 'edit', 'modify', 'patch',
963
- 'delete', 'remove', 'destroy', 'drop', 'purge',
964
- 'bulk', 'send', 'post', 'publish',
965
- ] as const
966
-
967
- const MUTATING_PREFIX_PATTERN = new RegExp(`^(${MUTATING_PREFIXES.join('|')})\\b`, 'i')
968
-
969
- export function detectMutatingTools(tools: IntrospectedMcpTool[]): string[] {
970
- return tools
971
- .filter((tool) => {
972
- const normalized = normalizeIdentifier(tool.name).trim().toLowerCase()
973
- return MUTATING_PREFIX_PATTERN.test(normalized)
974
- })
975
- .map((tool) => tool.name)
976
- }
977
-
978
- function planGeneratedHooks(source: McpServer, tools: IntrospectedMcpTool[], serverName: string, hookMode: McpHookMode = 'none'): GeneratedHookScaffold {
979
- if (hookMode !== 'safe') {
980
- return { mode: 'none', files: [] }
981
- }
982
-
983
- const envVars = collectRequiredEnvVars(source)
984
- const mutatingTools = detectMutatingTools(tools)
985
-
986
- if (envVars.length === 0 && mutatingTools.length === 0) {
987
- return { mode: 'none', files: [] }
988
- }
989
-
990
- const hookEntries: Record<string, HookEntry[]> = {}
991
- const files: Array<{ relativePath: string; content: string }> = []
992
-
993
- if (envVars.length > 0) {
994
- hookEntries.sessionStart = [{
995
- type: 'command',
996
- command: 'bash "${PLUGIN_ROOT}/scripts/check-env.sh"',
997
- }]
998
- files.push({
999
- relativePath: 'scripts/check-env.sh',
1000
- content: buildEnvValidationScript(envVars),
1001
- })
1002
- }
1003
-
1004
- if (mutatingTools.length > 0) {
1005
- hookEntries.preToolUse = mutatingTools.map((toolName) => ({
1006
- type: 'command',
1007
- command: 'bash "${PLUGIN_ROOT}/scripts/confirm-mutation.sh"',
1008
- matcher: buildMcpToolMatcher(serverName, toolName),
1009
- }))
1010
- files.push({
1011
- relativePath: 'scripts/confirm-mutation.sh',
1012
- content: buildMutationConfirmationScript(mutatingTools),
1013
- })
1014
- }
1015
-
1016
- return {
1017
- mode: 'safe',
1018
- scriptsPath: './scripts/',
1019
- hookEntries,
1020
- files,
1021
- }
1022
- }
1023
-
1024
- export function buildMutationConfirmationScript(mutatingTools: string[]): string {
1025
- const toolList = JSON.stringify(mutatingTools.map(sanitizeShellCommentText))
1026
- return `#!/usr/bin/env bash
1027
- set -euo pipefail
1028
- # This hook runs before mutating MCP tools.
1029
- # The platform will prompt the user for confirmation.
1030
- # Mutating tools: ${toolList}
1031
- echo "pluxx: This tool modifies data. The agent should confirm before proceeding." >&2
1032
- `
1033
- }
1034
-
1035
- function collectRequiredEnvVars(source: McpServer): string[] {
1036
- const envVars = new Set<string>()
1037
-
1038
- if (source.auth?.type && source.auth.type !== 'none' && source.auth.type !== 'platform') {
1039
- if (isValidShellEnvVarName(source.auth.envVar)) {
1040
- envVars.add(source.auth.envVar)
1041
- }
1042
- }
1043
-
1044
- if (source.transport === 'stdio') {
1045
- for (const key of Object.keys(source.env ?? {})) {
1046
- if (isValidShellEnvVarName(key)) {
1047
- envVars.add(key)
1048
- }
1049
- }
1050
- }
1051
-
1052
- return [...envVars]
1053
- }
1054
-
1055
- function buildEnvValidationScript(envVars: string[]): string {
1056
- const checks = envVars
1057
- .map((envVar) => `if [ -z "\${${envVar}:-}" ]; then
1058
- echo "pluxx: ${envVar} is not set. Export it before using this plugin." >&2
1059
- exit 1
1060
- fi`)
1061
- .join('\n\n')
1062
-
1063
- return `#!/usr/bin/env bash
1064
- set -euo pipefail
1065
-
1066
- ${checks}
1067
- `
1068
- }
1069
-
1070
- function buildMcpToolMatcher(serverName: string, toolName: string): string {
1071
- return `mcp__${serverName}__${toolName}`
1072
- }
1073
-
1074
- function sanitizeShellCommentText(value: string): string {
1075
- return value.replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, ' ').trim()
1076
- }
1077
-
1078
- function isValidShellEnvVarName(value: string): boolean {
1079
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value)
1080
- }
1081
-
1082
- function buildMcpScaffoldMetadata(input: {
1083
- source: McpServer
1084
- introspection: IntrospectedMcpServer
1085
- pluginName: string
1086
- displayName: string
1087
- skillGrouping: McpSkillGrouping
1088
- requestedHookMode: McpHookMode
1089
- generatedHookMode: McpHookMode
1090
- generatedHookEvents: string[]
1091
- runtimeAuthMode: McpRuntimeAuthMode
1092
- userConfig: UserConfigEntry[]
1093
- plannedSkills: PlannedSkill[]
1094
- managedFiles: string[]
1095
- }): McpScaffoldMetadata {
1096
- return {
1097
- version: 1,
1098
- source: input.source,
1099
- serverInfo: input.introspection.serverInfo,
1100
- settings: {
1101
- pluginName: input.pluginName,
1102
- displayName: input.displayName,
1103
- description: deriveScaffoldDescription({
1104
- displayName: input.displayName,
1105
- introspection: input.introspection,
1106
- plannedSkills: input.plannedSkills,
1107
- }),
1108
- skillGrouping: input.skillGrouping,
1109
- requestedHookMode: input.requestedHookMode,
1110
- generatedHookMode: input.generatedHookMode,
1111
- generatedHookEvents: input.generatedHookEvents,
1112
- runtimeAuthMode: input.runtimeAuthMode,
1113
- },
1114
- userConfig: input.userConfig,
1115
- tools: input.introspection.tools,
1116
- resources: input.introspection.resources ?? [],
1117
- resourceTemplates: input.introspection.resourceTemplates ?? [],
1118
- prompts: input.introspection.prompts ?? [],
1119
- skills: input.plannedSkills.map((skill) => ({
1120
- dirName: skill.dirName,
1121
- title: skill.title,
1122
- description: skill.description,
1123
- toolNames: skill.tools.map((tool) => tool.name),
1124
- resourceUris: skill.resources.map((resource) => resource.uri),
1125
- resourceTemplateUris: skill.resourceTemplates.map((resource) => resource.uriTemplate),
1126
- promptNames: skill.prompts.map((prompt) => prompt.name),
1127
- })),
1128
- managedFiles: input.managedFiles,
1129
- }
1130
- }
1131
-
1132
- export function planSkillScaffolds(
1133
- tools: IntrospectedMcpTool[],
1134
- grouping: McpSkillGrouping = 'workflow',
1135
- resources: IntrospectedMcpResource[] = [],
1136
- resourceTemplates: IntrospectedMcpResourceTemplate[] = [],
1137
- prompts: IntrospectedMcpPrompt[] = [],
1138
- ): PlannedSkill[] {
1139
- if (grouping === 'tool') {
1140
- return allocateSkillDirectoryNames(
1141
- attachRelatedSurfaceSignals(
1142
- tools
1143
- .sort((a, b) => a.name.localeCompare(b.name))
1144
- .map((tool) => ({
1145
- dirName: toKebabCase(tool.name) || 'tool',
1146
- title: tool.title ?? humanizeName(tool.name),
1147
- description: tool.description ?? `Use the \`${tool.name}\` MCP tool for this workflow.`,
1148
- tools: [tool],
1149
- resources: [],
1150
- resourceTemplates: [],
1151
- prompts: [],
1152
- workflowKey: classifyToolWorkflow(tool),
1153
- })),
1154
- resources,
1155
- resourceTemplates,
1156
- prompts,
1157
- ),
1158
- )
1159
- }
1160
-
1161
- const categoryBuckets = new Map<string, IntrospectedMcpTool[]>()
1162
- const standaloneTools: IntrospectedMcpTool[] = []
1163
-
1164
- for (const tool of tools) {
1165
- const category = classifyToolWorkflow(tool)
1166
- if (!category) {
1167
- standaloneTools.push(tool)
1168
- continue
1169
- }
1170
-
1171
- const bucket = categoryBuckets.get(category) ?? []
1172
- bucket.push(tool)
1173
- categoryBuckets.set(category, bucket)
1174
- }
1175
-
1176
- const plannedSkills: PlannedSkill[] = []
1177
-
1178
- for (const definition of WORKFLOW_SKILL_DEFINITIONS) {
1179
- const bucket = categoryBuckets.get(definition.key)
1180
- if (!bucket || bucket.length === 0) continue
1181
-
1182
- plannedSkills.push({
1183
- dirName: definition.key,
1184
- title: definition.title,
1185
- description: definition.description,
1186
- tools: bucket.sort((a, b) => a.name.localeCompare(b.name)),
1187
- resources: [],
1188
- resourceTemplates: [],
1189
- prompts: [],
1190
- workflowKey: definition.key,
1191
- })
1192
- }
1193
-
1194
- for (const tool of standaloneTools.sort((a, b) => a.name.localeCompare(b.name))) {
1195
- plannedSkills.push({
1196
- dirName: toKebabCase(tool.name) || 'tool',
1197
- title: tool.title ?? humanizeName(tool.name),
1198
- description: tool.description ?? `Use the \`${tool.name}\` MCP tool for this workflow.`,
1199
- tools: [tool],
1200
- resources: [],
1201
- resourceTemplates: [],
1202
- prompts: [],
1203
- workflowKey: classifyToolWorkflow(tool),
1204
- })
1205
- }
1206
-
1207
- return allocateSkillDirectoryNames(
1208
- attachRelatedSurfaceSignals(plannedSkills, resources, resourceTemplates, prompts),
1209
- )
1210
- }
1211
-
1212
- function planSkillScaffoldsFromPersisted(
1213
- tools: IntrospectedMcpTool[],
1214
- grouping: McpSkillGrouping = 'workflow',
1215
- resources: IntrospectedMcpResource[] = [],
1216
- resourceTemplates: IntrospectedMcpResourceTemplate[] = [],
1217
- prompts: IntrospectedMcpPrompt[] = [],
1218
- persistedSkills: PersistedSkill[] = [],
1219
- toolRenames: Map<string, string> = new Map(),
1220
- ): PlannedSkill[] {
1221
- if (persistedSkills.length === 0) {
1222
- return planSkillScaffolds(tools, grouping, resources, resourceTemplates, prompts)
1223
- }
1224
-
1225
- const toolByName = new Map(tools.map((tool) => [tool.name, tool]))
1226
- const assigned = new Set<string>()
1227
- const planned: PlannedSkill[] = []
1228
-
1229
- for (const skill of persistedSkills) {
1230
- const matchedTools: IntrospectedMcpTool[] = []
1231
-
1232
- for (const originalToolName of skill.toolNames) {
1233
- const resolvedToolName = toolRenames.get(originalToolName) ?? originalToolName
1234
- const tool = toolByName.get(resolvedToolName)
1235
- if (!tool || assigned.has(tool.name)) continue
1236
- matchedTools.push(tool)
1237
- assigned.add(tool.name)
1238
- }
1239
-
1240
- if (matchedTools.length === 0) continue
1241
-
1242
- planned.push({
1243
- dirName: skill.dirName,
1244
- title: skill.title,
1245
- description: skill.description ?? `Handle ${skill.title.toLowerCase()} workflows.`,
1246
- tools: matchedTools,
1247
- resources: [],
1248
- resourceTemplates: [],
1249
- prompts: [],
1250
- workflowKey: inferWorkflowKeyFromTools(matchedTools),
1251
- })
1252
- }
1253
-
1254
- const remainingTools = tools.filter((tool) => !assigned.has(tool.name))
1255
- if (remainingTools.length > 0) {
1256
- planned.push(...planSkillScaffolds(remainingTools, grouping, resources, resourceTemplates, prompts))
1257
- }
1258
-
1259
- return allocateSkillDirectoryNames(
1260
- attachRelatedSurfaceSignals(planned, resources, resourceTemplates, prompts),
1261
- )
1262
- }
1263
-
1264
- function buildPersistedTaxonomy(skills: PlannedSkill[]): PersistedSkill[] {
1265
- return skills.map((skill) => ({
1266
- dirName: skill.dirName,
1267
- title: skill.title,
1268
- description: skill.description,
1269
- toolNames: skill.tools.map((tool) => tool.name),
1270
- }))
1271
- }
1272
-
1273
- export function analyzeMcpQuality(
1274
- tools: IntrospectedMcpTool[],
1275
- plannedSkills: PlannedSkill[] = planSkillScaffolds(tools, 'workflow'),
1276
- ): McpQualityReport {
1277
- const issues: McpQualityIssue[] = []
1278
-
1279
- const genericNameTools = tools.filter((tool) => {
1280
- const normalized = toKebabCase(tool.name)
1281
- return GENERIC_TOOL_NAMES.has(normalized)
1282
- })
1283
-
1284
- if (genericNameTools.length > 0) {
1285
- issues.push({
1286
- level: 'warning',
1287
- code: 'generic-tool-names',
1288
- title: 'Generic MCP tool names',
1289
- detail: `${genericNameTools.length} tool(s) use generic names: ${genericNameTools.slice(0, 5).map((tool) => tool.name).join(', ')}`,
1290
- fix: 'Add sharper tool names or use Agent Mode with docs/website context to recover better taxonomy.',
1291
- })
1292
- }
1293
-
1294
- const missingDescriptionTools = tools.filter((tool) => cleanSingleLineText(tool.description).length < 12)
1295
- if (missingDescriptionTools.length > 0) {
1296
- issues.push({
1297
- level: 'warning',
1298
- code: 'missing-tool-descriptions',
1299
- title: 'Weak MCP tool descriptions',
1300
- detail: `${missingDescriptionTools.length} tool(s) have missing or too-short descriptions.`,
1301
- fix: 'Add clearer tool descriptions, or expect the scaffold to need more agent refinement.',
1302
- })
1303
- }
1304
-
1305
- const verboseDescriptionTools = tools.filter((tool) => {
1306
- const description = tool.description ?? ''
1307
- return description.includes('\n') || /returns:|args:|usage:|example:/i.test(description) || description.length > 260
1308
- })
1309
- if (verboseDescriptionTools.length > 0) {
1310
- issues.push({
1311
- level: 'info',
1312
- code: 'verbose-tool-descriptions',
1313
- title: 'Tool descriptions look documentation-shaped',
1314
- detail: `${verboseDescriptionTools.length} tool(s) include long or multiline help text that may need agent cleanup in skills and instructions.`,
1315
- fix: 'Use autopilot or rerun agent refinement with docs/website context so the output becomes more product-shaped.',
1316
- })
1317
- }
1318
-
1319
- const weakSchemaTools = tools.filter((tool) => {
1320
- const fields = getTopLevelSchemaFields(tool.inputSchema)
1321
- return fields.length >= 2 && fields.every((field) => !field.description)
1322
- })
1323
- if (weakSchemaTools.length > 0) {
1324
- issues.push({
1325
- level: 'info',
1326
- code: 'weak-input-schemas',
1327
- title: 'Input schemas lack field descriptions',
1328
- detail: `${weakSchemaTools.length} tool(s) define multi-field input schemas without per-field descriptions.`,
1329
- fix: 'Add input field descriptions or provide docs/context so agents can infer better examples and guidance.',
1330
- })
1331
- }
1332
-
1333
- const workflowFallbackSkills = plannedSkills.filter((skill) => skill.tools.length === 1 && toKebabCase(skill.tools[0]?.name ?? '') === skill.dirName)
1334
- if (workflowFallbackSkills.length >= 2) {
1335
- issues.push({
1336
- level: 'warning',
1337
- code: 'workflow-fallback-skills',
1338
- title: 'Workflow grouping fell back to tool-level buckets',
1339
- detail: `${workflowFallbackSkills.length} generated skill(s) are still direct tool wrappers, which usually means the MCP metadata is too weak for clean workflow grouping.`,
1340
- fix: 'Use docs/website context, add pluxx.agent.md hints, or improve the MCP tool metadata before publishing.',
1341
- })
1342
- }
1343
-
1344
- const warnings = issues.filter((issue) => issue.level === 'warning').length
1345
- const infos = issues.filter((issue) => issue.level === 'info').length
1346
-
1347
- return {
1348
- ok: warnings === 0,
1349
- warnings,
1350
- infos,
1351
- issues,
1352
- }
1353
- }
1354
-
1355
- function allocateSkillDirectoryNames(skills: PlannedSkill[]): PlannedSkill[] {
1356
- const used = new Set<string>()
1357
-
1358
- return skills.map((skill) => {
1359
- const base = toKebabCase(skill.dirName) || 'skill'
1360
- let candidate = base
1361
- let suffix = 2
1362
-
1363
- while (used.has(candidate)) {
1364
- candidate = `${base}-${suffix}`
1365
- suffix += 1
1366
- }
1367
-
1368
- used.add(candidate)
1369
- return {
1370
- ...skill,
1371
- dirName: candidate,
1372
- }
1373
- })
1374
- }
1375
-
1376
- function classifyToolWorkflow(tool: IntrospectedMcpTool): string | null {
1377
- return classifyWorkflowSurface(
1378
- [
1379
- normalizeIdentifier(tool.name).toLowerCase(),
1380
- normalizeIdentifier(tool.title ?? '').toLowerCase(),
1381
- ].join(' '),
1382
- (tool.description ?? '').toLowerCase(),
1383
- )
1384
- }
1385
-
1386
- function classifyResourceWorkflow(resource: IntrospectedMcpResource): string | null {
1387
- const primaryText = [
1388
- normalizeIdentifier(resource.name ?? '').toLowerCase(),
1389
- normalizeIdentifier(resource.title ?? '').toLowerCase(),
1390
- normalizeIdentifier(resource.uri).toLowerCase(),
1391
- ].join(' ')
1392
- const secondaryText = (resource.description ?? '').toLowerCase()
1393
- return classifyWorkflowSurface(primaryText, secondaryText)
1394
- }
1395
-
1396
- function classifyResourceTemplateWorkflow(template: IntrospectedMcpResourceTemplate): string | null {
1397
- const primaryText = [
1398
- normalizeIdentifier(template.name).toLowerCase(),
1399
- normalizeIdentifier(template.title ?? '').toLowerCase(),
1400
- normalizeIdentifier(template.uriTemplate).toLowerCase(),
1401
- ].join(' ')
1402
- const secondaryText = (template.description ?? '').toLowerCase()
1403
- return classifyWorkflowSurface(primaryText, secondaryText)
1404
- }
1405
-
1406
- function classifyPromptWorkflow(prompt: IntrospectedMcpPrompt): string | null {
1407
- const primaryText = [
1408
- normalizeIdentifier(prompt.name).toLowerCase(),
1409
- ...(prompt.arguments ?? []).map((argument) => normalizeIdentifier(argument.name).toLowerCase()),
1410
- ].join(' ')
1411
- const secondaryText = (prompt.description ?? '').toLowerCase()
1412
- return classifyWorkflowSurface(primaryText, secondaryText)
1413
- }
1414
-
1415
- function classifyWorkflowSurface(primaryText: string, secondaryText: string): string | null {
1416
- let bestMatch: string | null = null
1417
- let bestScore = 0
1418
-
1419
- for (const definition of WORKFLOW_SKILL_DEFINITIONS) {
1420
- let score = 0
1421
-
1422
- for (const needle of definition.match) {
1423
- if (primaryText.includes(needle)) {
1424
- score += 3
1425
- }
1426
- if (secondaryText.includes(needle)) {
1427
- score += 1
1428
- }
1429
- }
1430
-
1431
- if (score > bestScore) {
1432
- bestScore = score
1433
- bestMatch = definition.key
1434
- }
1435
- }
1436
-
1437
- return bestMatch
1438
- }
1439
-
1440
- function inferWorkflowKeyFromTools(tools: IntrospectedMcpTool[]): string | null {
1441
- for (const tool of tools) {
1442
- const workflowKey = classifyToolWorkflow(tool)
1443
- if (workflowKey) return workflowKey
1444
- }
1445
- return null
1446
- }
1447
-
1448
- function attachRelatedSurfaceSignals(
1449
- skills: PlannedSkill[],
1450
- resources: IntrospectedMcpResource[],
1451
- resourceTemplates: IntrospectedMcpResourceTemplate[],
1452
- prompts: IntrospectedMcpPrompt[],
1453
- ): PlannedSkill[] {
1454
- const nextSkills = skills.map((skill) => ({
1455
- ...skill,
1456
- resources: [...skill.resources],
1457
- resourceTemplates: [...skill.resourceTemplates],
1458
- prompts: [...skill.prompts],
1459
- }))
1460
-
1461
- const attachResource = (workflowKey: string | null, apply: (skill: PlannedSkill) => void) => {
1462
- if (!workflowKey) return
1463
- const target = nextSkills.find((skill) => skill.workflowKey === workflowKey)
1464
- if (!target) return
1465
- apply(target)
1466
- }
1467
-
1468
- for (const resource of resources) {
1469
- attachResource(classifyResourceWorkflow(resource), (skill) => {
1470
- if (!skill.resources.some((entry) => entry.uri === resource.uri)) {
1471
- skill.resources.push(resource)
1472
- }
1473
- })
1474
- }
1475
-
1476
- for (const template of resourceTemplates) {
1477
- attachResource(classifyResourceTemplateWorkflow(template), (skill) => {
1478
- if (!skill.resourceTemplates.some((entry) => entry.uriTemplate === template.uriTemplate)) {
1479
- skill.resourceTemplates.push(template)
1480
- }
1481
- })
1482
- }
1483
-
1484
- for (const prompt of prompts) {
1485
- attachResource(classifyPromptWorkflow(prompt), (skill) => {
1486
- if (!skill.prompts.some((entry) => entry.name === prompt.name)) {
1487
- skill.prompts.push(prompt)
1488
- }
1489
- })
1490
- }
1491
-
1492
- return nextSkills
1493
- }
1494
-
1495
- function buildSkillFrontmatterDescription(skill: PlannedSkill): string {
1496
- if (skill.tools.length === 1) {
1497
- const tool = skill.tools[0]
1498
- const cleanedDescription = cleanSingleLineText(tool.description)
1499
- const sentence = firstSentenceOf(cleanedDescription)
1500
-
1501
- if (sentence) {
1502
- return truncate(sentence, 220)
1503
- }
1504
- }
1505
-
1506
- return truncate(cleanSingleLineText(skill.description), 220)
1507
- }
1508
-
1509
- function buildInstructionSkillSummary(skill: PlannedSkill): string {
1510
- const toolNames = skill.tools.map((tool) => `\`${tool.name}\``).join(', ')
1511
- const description = truncate(cleanSingleLineText(skill.description), 180)
1512
- const resourceLabels = [
1513
- ...skill.resources.map((resource) => `\`${resource.name ?? resource.title ?? resource.uri}\``),
1514
- ...skill.resourceTemplates.map((template) => `\`${template.name}\``),
1515
- ]
1516
- const promptLabels = skill.prompts.map((prompt) => `\`${prompt.name}\``)
1517
- const resourceNote = resourceLabels.length > 0
1518
- ? ` Related resources: ${resourceLabels.join(', ')}.`
1519
- : ''
1520
- const promptNote = promptLabels.length > 0
1521
- ? ` Related prompt templates: ${promptLabels.join(', ')}.`
1522
- : ''
1523
- return `\`${skill.dirName}\`: ${description} Primary tools: ${toolNames}.${resourceNote}${promptNote}`
1524
- }
1525
-
1526
- function inferCommandArgumentHint(skill: PlannedSkill): string {
1527
- const fieldHints = new Set<string>()
1528
-
1529
- for (const tool of skill.tools) {
1530
- const fields = getTopLevelSchemaFields(tool.inputSchema)
1531
- const prioritizedFields = fields.filter((field) => field.required)
1532
- const candidates = (prioritizedFields.length > 0 ? prioritizedFields : fields).slice(0, 2)
1533
-
1534
- for (const field of candidates) {
1535
- fieldHints.add(mapSchemaFieldToArgumentHint(field.name))
1536
- if (fieldHints.size >= 2) break
1537
- }
1538
-
1539
- if (fieldHints.size >= 2) break
1540
- }
1541
-
1542
- if (fieldHints.size === 0) {
1543
- return '[request]'
1544
- }
1545
-
1546
- return [...fieldHints].slice(0, 2).map((hint) => `[${hint}]`).join(' ')
1547
- }
1548
-
1549
- function buildCommandEntryBlurb(skill: PlannedSkill): string {
1550
- const intent = inferSkillIntentPhrase(skill)
1551
- const resourceLabels = [
1552
- ...skill.resources.map((resource) => resource.name ?? resource.title ?? resource.uri),
1553
- ...skill.resourceTemplates.map((template) => template.name),
1554
- ]
1555
- const promptLabels = skill.prompts.map((prompt) => prompt.name)
1556
- const resourceNote = resourceLabels.length > 0
1557
- ? ` Check related MCP resources such as ${resourceLabels.slice(0, 2).map((label) => `\`${label}\``).join(' and ')} when they fit the request.`
1558
- : ''
1559
- const promptNote = promptLabels.length > 0
1560
- ? ` Prompt templates such as ${promptLabels.slice(0, 2).map((label) => `\`${label}\``).join(' and ')} can provide a canonical starting point.`
1561
- : ''
1562
- return `Use this command when the user asks to ${intent}.${resourceNote}${promptNote}`
1563
- }
1564
-
1565
- function inferSkillIntentPhrase(skill: PlannedSkill): string {
1566
- const toolDescription = skill.tools.length === 1
1567
- ? firstSentenceOf(cleanSingleLineText(skill.tools[0].description))
1568
- : ''
1569
- const fallback = firstSentenceOf(cleanSingleLineText(skill.description))
1570
- const cleaned = normalizeSkillIntentPhrase(toolDescription || fallback)
1571
-
1572
- if (!cleaned) {
1573
- return `work with ${skill.title.toLowerCase()} in this plugin`
1574
- }
1575
-
1576
- return startsWithActionVerb(cleaned) ? cleaned : `work on ${cleaned}`
1577
- }
1578
-
1579
- function normalizeSkillIntentPhrase(value: string): string {
1580
- if (!value) return ''
1581
-
1582
- const withoutGenericPrefixes = value
1583
- .replace(/^use (?:the |this )?(?:workflow|command|tool)\s+(?:for|to)\s+/i, '')
1584
- .replace(/^use the `[^`]+` mcp tool for this workflow\.?/i, '')
1585
- .replace(/^handle\s+/i, '')
1586
- .replace(/^this (?:workflow|command|tool)\s+/i, '')
1587
- .replace(/\s+for this plugin\.?$/i, '')
1588
- .replace(/\s+for this workflow\.?$/i, '')
1589
- .trim()
1590
-
1591
- if (!withoutGenericPrefixes) return ''
1592
-
1593
- const normalized = withoutGenericPrefixes.charAt(0).toLowerCase() + withoutGenericPrefixes.slice(1)
1594
- return normalized.replace(/[.?!]+$/, '').trim()
1595
- }
1596
-
1597
- function startsWithActionVerb(value: string): boolean {
1598
- return /^(search|find|look up|get|fetch|create|update|delete|list|query|send|check|compare|build|run|research)\b/i.test(value)
1599
- }
1600
-
1601
- function mapSchemaFieldToArgumentHint(fieldName: string): string {
1602
- const value = fieldName.toLowerCase()
1603
-
1604
- if (value.includes('query') || value.includes('search') || value.includes('keyword')) return 'query'
1605
- if (value.includes('company') || value.includes('organization') || value.includes('organisation') || value.includes('account')) return 'company'
1606
- if (value.includes('role') || value.includes('title')) return 'role'
1607
- if (value.includes('domain')) return 'domain'
1608
- if (value.includes('url')) return 'url'
1609
- if (value.includes('email')) return 'email'
1610
- if (value === 'id' || value.endsWith('id')) return 'id'
1611
- if (value.includes('name')) return 'name'
1612
-
1613
- return value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'request'
1614
- }
1615
-
1616
- function summarizeToolForInstructions(tool: IntrospectedMcpTool): string {
1617
- const fallback = 'Use this tool when its inputs match the user request.'
1618
- const description = tool.description ?? ''
1619
- const base = firstSentenceOf(cleanSingleLineText(description))
1620
- const bestFor = extractLabeledGuidance(description, 'Best for')
1621
- const useWhen = extractLabeledGuidance(description, 'Use when')
1622
-
1623
- const parts = [base].filter(Boolean)
1624
- const guidance = bestFor || useWhen
1625
-
1626
- if (guidance) {
1627
- parts.push(`Best for: ${guidance}.`)
1628
- }
1629
-
1630
- if (parts.length === 0) {
1631
- return fallback
1632
- }
1633
-
1634
- return truncate(parts.join(' '), 240)
1635
- }
1636
-
1637
- function summarizeResourceForInstructions(resource: IntrospectedMcpResource): string {
1638
- const descriptor = firstSentenceOf(cleanSingleLineText(resource.description ?? ''))
1639
- const location = resource.uri ? `URI: \`${resource.uri}\`.` : ''
1640
- const mimeType = resource.mimeType ? ` Format: ${resource.mimeType}.` : ''
1641
- const parts = [
1642
- descriptor || 'Reference resource exposed by the MCP.',
1643
- location,
1644
- mimeType,
1645
- ].filter(Boolean)
1646
-
1647
- return truncate(parts.join(' '), 240)
1648
- }
1649
-
1650
- function summarizeResourceTemplateForInstructions(template: IntrospectedMcpResourceTemplate): string {
1651
- const descriptor = firstSentenceOf(cleanSingleLineText(template.description ?? ''))
1652
- const uriTemplate = template.uriTemplate ? `URI template: \`${template.uriTemplate}\`.` : ''
1653
- const mimeType = template.mimeType ? ` Format: ${template.mimeType}.` : ''
1654
- const parts = [
1655
- descriptor || 'Parameterized MCP resource template.',
1656
- uriTemplate,
1657
- mimeType,
1658
- ].filter(Boolean)
1659
-
1660
- return truncate(parts.join(' '), 240)
1661
- }
1662
-
1663
- function summarizePromptForInstructions(prompt: IntrospectedMcpPrompt): string {
1664
- const descriptor = firstSentenceOf(cleanSingleLineText(prompt.description ?? ''))
1665
- const args = prompt.arguments?.slice(0, 4).map((argument) => {
1666
- const required = argument.required ? 'required' : 'optional'
1667
- return `\`${argument.name}\` (${required})`
1668
- }) ?? []
1669
- const parts = [
1670
- descriptor || 'Prompt template exposed by the MCP.',
1671
- args.length > 0 ? `Arguments: ${args.join(', ')}.` : '',
1672
- ].filter(Boolean)
1673
-
1674
- return truncate(parts.join(' '), 240)
1675
- }
1676
-
1677
- function summarizeServerGuidance(value: string): string[] {
1678
- const lines = value
1679
- .split('\n')
1680
- .map((line) => line.trim())
1681
- .filter(Boolean)
1682
- .filter((line) => !line.startsWith('```'))
1683
- .map((line) => line.replace(/^#+\s*/, ''))
1684
- .map((line) => line.replace(/^[-*]\s+/, ''))
1685
- .filter((line) => !/^example:?$/i.test(line))
1686
- .map((line) => truncate(cleanSingleLineText(line), 220))
1687
- .filter(Boolean)
1688
-
1689
- return [...new Set(lines)].slice(0, 4)
1690
- }
1691
-
1692
- function extractLabeledGuidance(value: string | undefined, label: string): string {
1693
- if (!value) return ''
1694
-
1695
- const normalizedLabel = `${label.toLowerCase()}:`
1696
- for (const rawLine of value.split('\n')) {
1697
- const line = rawLine
1698
- .trim()
1699
- .replace(/^\*+|\*+$/g, '')
1700
- .replace(/^[-*]\s*/, '')
1701
- .replace(/^\*+\s*/, '')
1702
- .replace(/\s+\*+$/, '')
1703
- if (!line) continue
1704
-
1705
- const plain = line.replace(/[*`_]/g, '')
1706
- if (plain.toLowerCase().startsWith(normalizedLabel)) {
1707
- return truncate(cleanSingleLineText(plain.slice(normalizedLabel.length).trim()), 160)
1708
- }
1709
- }
1710
-
1711
- return ''
1712
- }
1713
-
1714
- function getTopLevelSchemaFields(inputSchema?: Record<string, unknown>): SchemaField[] {
1715
- if (!inputSchema) return []
1716
-
1717
- const rawProperties = inputSchema.properties
1718
- if (!rawProperties || typeof rawProperties !== 'object') {
1719
- return []
1720
- }
1721
-
1722
- const required = new Set(
1723
- Array.isArray(inputSchema.required)
1724
- ? inputSchema.required.filter((value): value is string => typeof value === 'string')
1725
- : [],
1726
- )
1727
-
1728
- return Object.entries(rawProperties).map(([name, value]) => {
1729
- const schema = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
1730
- return {
1731
- name,
1732
- type: formatSchemaType(schema.type),
1733
- required: required.has(name),
1734
- description: typeof schema.description === 'string' ? schema.description : undefined,
1735
- }
1736
- })
1737
- }
1738
-
1739
- export function buildToolExampleRequest(tool: IntrospectedMcpTool): string {
1740
- const action = inferToolAction(tool)
1741
- const objectLabel = inferToolObject(tool)
1742
- const context = buildToolRequestContext(tool)
1743
- const sentence = buildExampleSentence(action, objectLabel, context)
1744
-
1745
- return sentence.charAt(0).toUpperCase() + sentence.slice(1)
1746
- }
1747
-
1748
- function inferToolAction(tool: IntrospectedMcpTool): string {
1749
- const identifier = normalizeIdentifier(tool.title ?? tool.name).toLowerCase()
1750
-
1751
- if (/^(get|fetch|lookup|look up)\b/.test(identifier)) return 'look up'
1752
- if (/^(create|add)\b/.test(identifier)) return 'create'
1753
- if (/^(update|edit)\b/.test(identifier)) return 'update'
1754
- if (/^(delete|remove)\b/.test(identifier)) return 'delete'
1755
- if (/^(list)\b/.test(identifier)) return 'list'
1756
- if (/^(query)\b/.test(identifier)) return 'query'
1757
- if (/^(search)\b/.test(identifier)) return 'search'
1758
- if (/^(send|post|publish)\b/.test(identifier)) return 'send'
1759
- return 'find'
1760
- }
1761
-
1762
- function inferToolObject(tool: IntrospectedMcpTool): string {
1763
- const raw = normalizeIdentifier(tool.title ?? tool.name).trim()
1764
- const stripped = raw.replace(/^(find|get|fetch|lookup|look up|search|list|create|add|update|edit|delete|remove|query)\s+/i, '')
1765
- const tokens = (stripped || raw).toLowerCase().split(/\s+/).filter(Boolean)
1766
-
1767
- while (tokens.length > 1 && SECONDARY_ACTION_TOKENS.has(tokens[0])) {
1768
- tokens.shift()
1769
- }
1770
-
1771
- while (tokens.length > 1 && NOISE_OBJECT_TOKENS.has(tokens[tokens.length - 1])) {
1772
- tokens.pop()
1773
- }
1774
-
1775
- const candidate = tokens.join(' ').trim()
1776
- return candidate || 'results'
1777
- }
1778
-
1779
- function buildToolRequestContext(tool: IntrospectedMcpTool): string {
1780
- const requiredFields = getTopLevelSchemaFields(tool.inputSchema).filter((field) => field.required)
1781
- const preferredField = requiredFields[0]
1782
-
1783
- if (!preferredField) return ''
1784
-
1785
- const placeholder = `<${preferredField.name}>`
1786
- const fieldName = preferredField.name.toLowerCase()
1787
-
1788
- if (fieldName.endsWith('id') || fieldName === 'id') {
1789
- return `using ${placeholder}`
1790
- }
1791
-
1792
- if (fieldName.includes('query') || fieldName.includes('keyword') || fieldName.includes('search')) {
1793
- return `matching ${placeholder}`
1794
- }
1795
-
1796
- if (fieldName.includes('role') || fieldName.includes('title')) {
1797
- return `for ${placeholder}`
1798
- }
1799
-
1800
- if (fieldName.includes('company') || fieldName.includes('organization') || fieldName.includes('organisation') || fieldName.includes('account')) {
1801
- return `for ${placeholder}`
1802
- }
1803
-
1804
- if (fieldName.includes('domain') || fieldName.includes('email') || fieldName.includes('url')) {
1805
- return `using ${placeholder}`
1806
- }
1807
-
1808
- return `with ${placeholder}`
1809
- }
1810
-
1811
- const SECONDARY_ACTION_TOKENS = new Set([
1812
- 'find',
1813
- 'get',
1814
- 'fetch',
1815
- 'lookup',
1816
- 'search',
1817
- 'list',
1818
- 'create',
1819
- 'add',
1820
- 'update',
1821
- 'edit',
1822
- 'delete',
1823
- 'remove',
1824
- 'query',
1825
- 'send',
1826
- 'post',
1827
- 'publish',
1828
- ])
1829
-
1830
- const NOISE_OBJECT_TOKENS = new Set(['tool', 'workflow', 'mcp'])
1831
-
1832
- function buildExampleSentence(action: string, objectLabel: string, context: string): string {
1833
- const objectPhrase = buildObjectPhrase(action, objectLabel)
1834
- const withContext = context ? `${objectPhrase} ${context}` : objectPhrase
1835
- return `${withContext}.`
1836
- }
1837
-
1838
- function buildObjectPhrase(action: string, objectLabel: string): string {
1839
- const trimmed = objectLabel.trim() || 'results'
1840
- const pluralized = maybePluralizePhrase(trimmed)
1841
-
1842
- if (action === 'create') return `create a new ${trimmed}`
1843
- if (action === 'update') return `update ${withArticle(trimmed)}`
1844
- if (action === 'delete') return `delete the ${trimmed}`
1845
- if (action === 'look up') return `look up ${withArticle(trimmed)}`
1846
- if (action === 'list') return `list ${pluralized}`
1847
- if (action === 'find' || action === 'search' || action === 'query') return `${action} ${pluralized}`
1848
- if (action === 'send') return `send ${withArticle(trimmed)}`
1849
- return `${action} ${trimmed}`
1850
- }
1851
-
1852
- function withArticle(value: string): string {
1853
- if (!value) return 'results'
1854
- if (/^(a|an|the)\b/i.test(value)) return value
1855
- if (/\b(and|or)\b/i.test(value) || value.endsWith('s')) return value
1856
- const firstWord = value.split(/\s+/)[0].toLowerCase()
1857
- const article = /^[aeiou]/.test(firstWord) ? 'an' : 'a'
1858
- return `${article} ${value}`
1859
- }
1860
-
1861
- function maybePluralizePhrase(value: string): string {
1862
- const words = value.trim().split(/\s+/).filter(Boolean)
1863
- if (words.length === 0) return 'results'
1864
-
1865
- const last = words[words.length - 1]
1866
- if (/s$/i.test(last) || /people$/i.test(last)) return value
1867
- if (/y$/i.test(last) && !/[aeiou]y$/i.test(last)) {
1868
- words[words.length - 1] = `${last.slice(0, -1)}ies`
1869
- return words.join(' ')
1870
- }
1871
- if (/(ch|sh|x|z)$/i.test(last)) {
1872
- words[words.length - 1] = `${last}es`
1873
- return words.join(' ')
1874
- }
1875
-
1876
- words[words.length - 1] = `${last}s`
1877
- return words.join(' ')
1878
- }
1879
-
1880
- function formatSchemaType(value: unknown): string {
1881
- if (typeof value === 'string') return value
1882
- if (Array.isArray(value)) {
1883
- const parts = value.filter((entry): entry is string => typeof entry === 'string')
1884
- if (parts.length > 0) return parts.join(' | ')
1885
- }
1886
- return 'unknown'
1887
- }
1888
-
1889
- function describePluginAccess(displayName: string, source: McpServer, runtimeAuthMode: McpRuntimeAuthMode): string {
1890
- if (source.transport === 'stdio') {
1891
- const command = [source.command, ...(source.args ?? [])].join(' ')
1892
- const authLine = describeAuthRequirement(source)
1893
- return `${displayName} connects through a local stdio MCP command (\`${command}\`).${authLine ? ` ${authLine}` : ''}`
1894
- }
1895
-
1896
- const transportLabel = source.transport === 'sse' ? 'legacy SSE' : 'HTTP'
1897
- const authLine = describeAuthRequirement(source, runtimeAuthMode)
1898
- return `${displayName} connects to its MCP over ${transportLabel}.${authLine ? ` ${authLine}` : ''}`
1899
- }
1900
-
1901
- function describeAuthRequirement(source: McpServer, runtimeAuthMode: McpRuntimeAuthMode = 'inline'): string {
1902
- if (runtimeAuthMode === 'platform' && source.transport !== 'stdio') {
1903
- if (source.auth?.type === 'platform') {
1904
- return 'This server relies on platform-managed OAuth at runtime. Pluxx does not complete interactive OAuth in the CLI; use the host-native auth flow before calling authenticated tools.'
1905
- }
1906
- return 'Claude Code and Cursor use platform-managed auth at runtime (for example native OAuth/custom connector flows). Exported env vars remain useful for scaffold refreshes and other non-platform-managed targets like Codex and OpenCode.'
1907
- }
1908
-
1909
- if (!source.auth || source.auth.type === 'none') {
1910
- return ''
1911
- }
1912
-
1913
- if (source.auth.type === 'platform') {
1914
- return 'This server relies on platform-managed OAuth at runtime. Pluxx does not complete interactive OAuth in the CLI; use the host-native auth flow before calling authenticated tools.'
1915
- }
1916
-
1917
- if (source.auth.type === 'header') {
1918
- return `Export \`${source.auth.envVar}\` so Pluxx can send ${source.auth.headerName ?? 'the required auth header'}.`
1919
- }
1920
-
1921
- return `Export \`${source.auth.envVar}\` before using authenticated tools.`
1922
- }
1923
-
1924
- function humanizeName(value: string): string {
1925
- return normalizeIdentifier(value)
1926
- .split(/\s+/)
1927
- .filter(Boolean)
1928
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1929
- .join(' ')
1930
- }
1931
-
1932
- function toKebabCase(value: string): string {
1933
- return normalizeIdentifier(value)
1934
- .toLowerCase()
1935
- .trim()
1936
- .replace(/[^a-z0-9]+/g, '-')
1937
- .replace(/^-+|-+$/g, '')
1938
- }
1939
-
1940
- function normalizeIdentifier(value: string): string {
1941
- return value
1942
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
1943
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
1944
- .replace(/[._/]+/g, ' ')
1945
- .replace(/-/g, ' ')
1946
- }
1947
-
1948
- function truncate(value: string, maxLength: number): string {
1949
- if (value.length <= maxLength) return value
1950
- return `${value.slice(0, maxLength - 1).trimEnd()}…`
1951
- }
1952
-
1953
- function cleanSingleLineText(value: string | undefined): string {
1954
- if (!value) return ''
1955
-
1956
- return value
1957
- .replace(/\s+/g, ' ')
1958
- .replace(/\s*[-*]\s+/g, ' ')
1959
- .trim()
1960
- }
1961
-
1962
- function firstSentenceOf(value: string): string {
1963
- if (!value) return ''
1964
-
1965
- const match = value.match(/^(.+?[.?!])(?:\s|$)/)
1966
- return (match?.[1] ?? value).trim()
1967
- }
1968
-
1969
- function splitCommandString(command: string): string[] {
1970
- const parts: string[] = []
1971
- let current = ''
1972
- let quote: '"' | "'" | null = null
1973
- let escaping = false
1974
-
1975
- for (const char of command) {
1976
- if (escaping) {
1977
- current += char
1978
- escaping = false
1979
- continue
1980
- }
1981
-
1982
- if (char === '\\') {
1983
- escaping = true
1984
- continue
1985
- }
1986
-
1987
- if (quote) {
1988
- if (char === quote) {
1989
- quote = null
1990
- } else {
1991
- current += char
1992
- }
1993
- continue
1994
- }
1995
-
1996
- if (char === '"' || char === "'") {
1997
- quote = char
1998
- continue
1999
- }
2000
-
2001
- if (/\s/.test(char)) {
2002
- if (current) {
2003
- parts.push(current)
2004
- current = ''
2005
- }
2006
- continue
2007
- }
2008
-
2009
- current += char
2010
- }
2011
-
2012
- if (quote) {
2013
- throw new Error('Unterminated quote in MCP command.')
2014
- }
2015
-
2016
- if (escaping) {
2017
- current += '\\'
2018
- }
2019
-
2020
- if (current) {
2021
- parts.push(current)
2022
- }
2023
-
2024
- return parts
2025
- }
2026
-
2027
- export function derivePluginName(introspection: IntrospectedMcpServer, source: McpServer): string {
2028
- const candidates = [
2029
- introspection.serverInfo.name,
2030
- introspection.serverInfo.title,
2031
- source.transport === 'stdio' ? basename(source.command) : new URL(source.url).hostname.split('.')[0],
2032
- ].filter((value): value is string => Boolean(value))
2033
-
2034
- for (const candidate of candidates) {
2035
- const normalized = toKebabCase(candidate)
2036
- if (normalized) return normalized
2037
- }
2038
-
2039
- return 'mcp-plugin'
2040
- }
2041
-
2042
- export function deriveDisplayName(introspection: IntrospectedMcpServer, pluginName: string): string {
2043
- const candidates = [
2044
- introspection.serverInfo.title,
2045
- introspection.serverInfo.name,
2046
- pluginName,
2047
- ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
2048
-
2049
- for (const candidate of candidates) {
2050
- const polished = polishDisplayName(candidate)
2051
- if (polished) return polished
2052
- }
2053
-
2054
- return 'MCP Plugin'
2055
- }
2056
-
2057
- function polishDisplayName(value: string): string {
2058
- const raw = value.trim()
2059
- if (!raw) return ''
2060
-
2061
- const looksMachineIdentifier = /^[a-z0-9._/-]+$/.test(raw) || /[-_/]/.test(raw) || raw === raw.toLowerCase()
2062
- let candidate = looksMachineIdentifier ? humanizeName(raw) : raw
2063
-
2064
- candidate = candidate
2065
- .replace(/\bmcp\b/gi, 'MCP')
2066
- .replace(/\bMCP\s+Server\b/gi, 'MCP')
2067
- .replace(/\s+/g, ' ')
2068
- .trim()
2069
-
2070
- return candidate
2071
- }
2072
-
2073
- function deriveScaffoldDescription(input: {
2074
- displayName: string
2075
- introspection: IntrospectedMcpServer
2076
- plannedSkills: PlannedSkill[]
2077
- }): string {
2078
- const serverDescription = truncate(cleanSingleLineText(input.introspection.serverInfo.description), 200)
2079
- if (serverDescription && !isGenericServerDescription(serverDescription)) {
2080
- return serverDescription
2081
- }
2082
-
2083
- const skillTitles = [...new Set(
2084
- input.plannedSkills
2085
- .map((skill) => cleanSingleLineText(skill.title).toLowerCase())
2086
- .filter(Boolean),
2087
- )].slice(0, 2)
2088
-
2089
- if (skillTitles.length === 1) {
2090
- return `${input.displayName} plugin scaffold for ${skillTitles[0]} workflows.`
2091
- }
2092
-
2093
- if (skillTitles.length === 2) {
2094
- return `${input.displayName} plugin scaffold for ${skillTitles[0]} and ${skillTitles[1]} workflows.`
2095
- }
2096
-
2097
- const toolCount = input.introspection.tools.length
2098
- if (toolCount > 0) {
2099
- return `${input.displayName} plugin scaffold for ${toolCount} MCP tool${toolCount === 1 ? '' : 's'}.`
2100
- }
2101
-
2102
- return `${input.displayName} plugin scaffold for MCP-driven workflows.`
2103
- }
2104
-
2105
- function isGenericServerDescription(value: string): boolean {
2106
- const normalized = value.toLowerCase().trim()
2107
- if (!normalized) return true
2108
-
2109
- if (normalized.startsWith('generated from ')) return true
2110
- if (normalized === 'mcp server') return true
2111
- if (normalized === 'an mcp server') return true
2112
- if (normalized === 'a mcp server') return true
2113
-
2114
- return false
2115
- }