@orchid-labs/pluxx 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +110 -515
  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 +69 -0
  6. package/dist/cli/agent.d.ts.map +1 -1
  7. package/dist/cli/doctor.d.ts +3 -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/eval.d.ts +22 -0
  12. package/dist/cli/eval.d.ts.map +1 -0
  13. package/dist/cli/index.d.ts +26 -3
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +21810 -0
  16. package/dist/cli/init-from-mcp.d.ts +34 -3
  17. package/dist/cli/init-from-mcp.d.ts.map +1 -1
  18. package/dist/cli/install.d.ts +3 -0
  19. package/dist/cli/install.d.ts.map +1 -1
  20. package/dist/cli/lint.d.ts +7 -1
  21. package/dist/cli/lint.d.ts.map +1 -1
  22. package/dist/cli/mcp-proxy.d.ts +10 -0
  23. package/dist/cli/mcp-proxy.d.ts.map +1 -0
  24. package/dist/cli/migrate.d.ts.map +1 -1
  25. package/dist/cli/primitive-summary.d.ts +14 -0
  26. package/dist/cli/primitive-summary.d.ts.map +1 -0
  27. package/dist/cli/prompt.d.ts +1 -1
  28. package/dist/cli/publish.d.ts +6 -1
  29. package/dist/cli/publish.d.ts.map +1 -1
  30. package/dist/cli/sync-from-mcp.d.ts.map +1 -1
  31. package/dist/cli/test.d.ts +2 -0
  32. package/dist/cli/test.d.ts.map +1 -1
  33. package/dist/cli/verify-install.d.ts +25 -0
  34. package/dist/cli/verify-install.d.ts.map +1 -0
  35. package/dist/commands.d.ts +10 -0
  36. package/dist/commands.d.ts.map +1 -0
  37. package/dist/compiler-intent.d.ts +165 -0
  38. package/dist/compiler-intent.d.ts.map +1 -0
  39. package/dist/config/load.d.ts.map +1 -1
  40. package/dist/delegation.d.ts +11 -0
  41. package/dist/delegation.d.ts.map +1 -0
  42. package/dist/generators/amp/index.d.ts.map +1 -1
  43. package/dist/generators/base.d.ts +5 -0
  44. package/dist/generators/base.d.ts.map +1 -1
  45. package/dist/generators/claude-code/index.d.ts +2 -0
  46. package/dist/generators/claude-code/index.d.ts.map +1 -1
  47. package/dist/generators/cline/index.d.ts.map +1 -1
  48. package/dist/generators/codex/index.d.ts +5 -0
  49. package/dist/generators/codex/index.d.ts.map +1 -1
  50. package/dist/generators/cursor/index.d.ts +1 -0
  51. package/dist/generators/cursor/index.d.ts.map +1 -1
  52. package/dist/generators/gemini-cli/index.d.ts.map +1 -1
  53. package/dist/generators/github-copilot/index.d.ts.map +1 -1
  54. package/dist/generators/opencode/index.d.ts +1 -0
  55. package/dist/generators/opencode/index.d.ts.map +1 -1
  56. package/dist/generators/openhands/index.d.ts.map +1 -1
  57. package/dist/generators/roo-code/index.d.ts.map +1 -1
  58. package/dist/generators/shared/claude-family.d.ts.map +1 -1
  59. package/dist/generators/warp/index.d.ts.map +1 -1
  60. package/dist/index.d.ts +4 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +5464 -548
  63. package/dist/mcp/introspect.d.ts +43 -1
  64. package/dist/mcp/introspect.d.ts.map +1 -1
  65. package/dist/permissions.d.ts.map +1 -1
  66. package/dist/schema.d.ts +91 -42
  67. package/dist/schema.d.ts.map +1 -1
  68. package/dist/text-files.d.ts +5 -0
  69. package/dist/text-files.d.ts.map +1 -0
  70. package/dist/validation/platform-rules.d.ts +35 -1
  71. package/dist/validation/platform-rules.d.ts.map +1 -1
  72. package/package.json +16 -14
  73. package/src/cli/agent.ts +0 -1030
  74. package/src/cli/dev.ts +0 -112
  75. package/src/cli/doctor.ts +0 -588
  76. package/src/cli/index.ts +0 -2414
  77. package/src/cli/init-from-mcp.ts +0 -1611
  78. package/src/cli/install.ts +0 -698
  79. package/src/cli/lint.ts +0 -1219
  80. package/src/cli/migrate.ts +0 -614
  81. package/src/cli/prompt.ts +0 -82
  82. package/src/cli/publish.ts +0 -401
  83. package/src/cli/runtime.ts +0 -86
  84. package/src/cli/sync-from-mcp.ts +0 -563
  85. package/src/cli/test.ts +0 -134
  86. package/src/compatibility/matrix.ts +0 -149
  87. package/src/config/define.ts +0 -20
  88. package/src/config/load.ts +0 -74
  89. package/src/generators/amp/index.ts +0 -63
  90. package/src/generators/base.ts +0 -188
  91. package/src/generators/claude-code/index.ts +0 -29
  92. package/src/generators/cline/index.ts +0 -35
  93. package/src/generators/codex/index.ts +0 -120
  94. package/src/generators/cursor/index.ts +0 -158
  95. package/src/generators/gemini-cli/index.ts +0 -83
  96. package/src/generators/github-copilot/index.ts +0 -32
  97. package/src/generators/hooks-warning.ts +0 -51
  98. package/src/generators/index.ts +0 -71
  99. package/src/generators/opencode/index.ts +0 -526
  100. package/src/generators/openhands/index.ts +0 -32
  101. package/src/generators/roo-code/index.ts +0 -35
  102. package/src/generators/shared/claude-family.ts +0 -215
  103. package/src/generators/warp/index.ts +0 -32
  104. package/src/hook-events.ts +0 -33
  105. package/src/index.ts +0 -23
  106. package/src/mcp/introspect.ts +0 -834
  107. package/src/permissions.ts +0 -258
  108. package/src/schema.ts +0 -312
  109. package/src/user-config.ts +0 -177
  110. package/src/validation/platform-rules.ts +0 -565
@@ -1,1611 +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 { IntrospectedMcpServer, IntrospectedMcpTool } from '../mcp/introspect'
6
- import { collectUserConfigEntries } from '../user-config'
7
-
8
- export interface McpScaffoldOptions {
9
- rootDir: string
10
- pluginName: string
11
- authorName: string
12
- targets: TargetPlatform[]
13
- source: McpServer
14
- introspection: IntrospectedMcpServer
15
- serverName?: string
16
- displayName?: string
17
- description?: string
18
- skillGrouping?: McpSkillGrouping
19
- hookMode?: McpHookMode
20
- runtimeAuthMode?: McpRuntimeAuthMode
21
- persistedSkills?: PersistedSkill[]
22
- toolRenames?: Map<string, string>
23
- }
24
-
25
- export interface McpScaffoldResult {
26
- instructionsPath: string
27
- skillDirectories: string[]
28
- commandFiles: string[]
29
- generatedFiles: string[]
30
- generatedHookMode: McpHookMode
31
- generatedHookEvents: string[]
32
- metadataPath: string
33
- }
34
-
35
- export interface McpScaffoldPlannedFile {
36
- relativePath: string
37
- content: string
38
- action: 'create' | 'update' | 'unchanged'
39
- }
40
-
41
- export interface McpScaffoldPlan extends McpScaffoldResult {
42
- files: McpScaffoldPlannedFile[]
43
- }
44
-
45
- interface PlannedSkill {
46
- dirName: string
47
- title: string
48
- description: string
49
- tools: IntrospectedMcpTool[]
50
- }
51
-
52
- export interface PersistedSkill {
53
- dirName: string
54
- title: string
55
- description?: string
56
- toolNames: string[]
57
- }
58
-
59
- interface SchemaField {
60
- name: string
61
- type: string
62
- required: boolean
63
- description?: string
64
- }
65
-
66
- export type McpQualityLevel = 'warning' | 'info'
67
-
68
- export interface McpQualityIssue {
69
- level: McpQualityLevel
70
- code: string
71
- title: string
72
- detail: string
73
- fix: string
74
- }
75
-
76
- export interface McpQualityReport {
77
- ok: boolean
78
- warnings: number
79
- infos: number
80
- issues: McpQualityIssue[]
81
- }
82
-
83
- export const MCP_SKILL_GROUPINGS = ['workflow', 'tool'] as const
84
- export type McpSkillGrouping = typeof MCP_SKILL_GROUPINGS[number]
85
-
86
- export const MCP_HOOK_MODES = ['none', 'safe'] as const
87
- export type McpHookMode = typeof MCP_HOOK_MODES[number]
88
- export const MCP_RUNTIME_AUTH_MODES = ['inline', 'platform'] as const
89
- export type McpRuntimeAuthMode = typeof MCP_RUNTIME_AUTH_MODES[number]
90
-
91
- interface GeneratedHookScaffold {
92
- mode: McpHookMode
93
- scriptsPath?: string
94
- hookEntries?: Record<string, HookEntry[]>
95
- files: Array<{ relativePath: string; content: string }>
96
- }
97
-
98
- export interface McpScaffoldMetadata {
99
- version: 1
100
- source: McpServer
101
- serverInfo: IntrospectedMcpServer['serverInfo']
102
- settings: {
103
- pluginName: string
104
- displayName: string
105
- skillGrouping: McpSkillGrouping
106
- requestedHookMode: McpHookMode
107
- generatedHookMode: McpHookMode
108
- generatedHookEvents: string[]
109
- runtimeAuthMode: McpRuntimeAuthMode
110
- }
111
- userConfig: UserConfigEntry[]
112
- tools: IntrospectedMcpTool[]
113
- skills: Array<{
114
- dirName: string
115
- title: string
116
- description?: string
117
- toolNames: string[]
118
- }>
119
- managedFiles: string[]
120
- }
121
-
122
- export const MCP_SCAFFOLD_METADATA_PATH = '.pluxx/mcp.json'
123
- export const MCP_TAXONOMY_PATH = '.pluxx/taxonomy.json'
124
- export const PLUXX_GENERATED_START = '<!-- pluxx:generated:start -->'
125
- export const PLUXX_GENERATED_END = '<!-- pluxx:generated:end -->'
126
- export const PLUXX_CUSTOM_START = '<!-- pluxx:custom:start -->'
127
- export const PLUXX_CUSTOM_END = '<!-- pluxx:custom:end -->'
128
-
129
- const DEFAULT_INSTRUCTIONS_CUSTOM_CONTENT = 'Add custom plugin instructions here. This section is preserved across `pluxx sync --from-mcp`.'
130
- const DEFAULT_SKILL_CUSTOM_CONTENT = 'Add custom guidance, examples, or caveats here. This section is preserved across `pluxx sync --from-mcp`.'
131
- const LEGACY_MIXED_CONTENT_NOTE = 'Migrated from a previous unstructured scaffold. Review and trim this section as needed.'
132
-
133
- interface MixedMarkdownContent {
134
- hasMarkers: boolean
135
- customContent: string
136
- }
137
-
138
- const WORKFLOW_SKILL_DEFINITIONS = [
139
- {
140
- key: 'setup-and-auth',
141
- title: 'Setup and Auth',
142
- description: 'Confirm access, auth state, and session readiness before running operational workflows.',
143
- match: ['connect', 'connected', 'connection', 'status', 'auth', 'session', 'cookie', 'workspace'],
144
- },
145
- {
146
- key: 'workflow-design',
147
- title: 'Workflow Design',
148
- description: 'Define strategy, prompts, targeting, and workflow shape before building tables or running enrichments.',
149
- match: ['workflow', 'design', 'play', 'prompt', 'icp', 'persona', 'audience', 'segment', 'brainstorm', 'outreach'],
150
- },
151
- {
152
- key: 'account-research',
153
- title: 'Account Research',
154
- description: 'Research companies, organizations, and account context before taking action.',
155
- match: ['organization', 'organisation', 'company', 'account', 'firmographic'],
156
- },
157
- {
158
- key: 'contact-discovery',
159
- title: 'Contact Discovery',
160
- description: 'Find people, contacts, and buyer-side context at the right accounts.',
161
- match: ['people', 'person', 'contact', 'prospect', 'decision maker', 'org chart'],
162
- },
163
- {
164
- key: 'hiring-signals',
165
- title: 'Hiring Signals',
166
- description: 'Use hiring activity and open roles as timing signals for outreach and research.',
167
- match: ['job', 'jobs', 'hiring', 'hire', 'role', 'roles', 'recruit', 'career'],
168
- },
169
- {
170
- key: 'technographics',
171
- title: 'Technographics',
172
- description: 'Research technologies, tools, and stack adoption across target accounts.',
173
- match: ['technology', 'technologies', 'tech', 'stack', 'tooling', 'software'],
174
- },
175
- {
176
- key: 'table-operations',
177
- title: 'Table Operations',
178
- description: 'Build, inspect, run, document, and export tables, rows, and enrichment workflows.',
179
- match: ['table', 'tables', 'schema', 'webhook', 'row', 'rows', 'export', 'audit', 'document', 'enrich', 'view'],
180
- },
181
- {
182
- key: 'provider-research',
183
- title: 'Provider Research',
184
- description: 'Compare providers, integrations, and capability tradeoffs before choosing a workflow.',
185
- match: ['provider', 'providers', 'integration', 'integrations', 'byoa', 'waterfall', 'compare', 'comparison'],
186
- },
187
- {
188
- key: 'account-and-usage',
189
- title: 'Account and Usage',
190
- description: 'Check pricing, usage, limits, credits, and upgrade context for the current account.',
191
- match: ['usage', 'tier', 'plan', 'pricing', 'price', 'cost', 'credits', 'checkout', 'upgrade', 'billing'],
192
- },
193
- {
194
- key: 'general-research',
195
- title: 'General Research',
196
- description: 'Handle broad search and query workflows when there is not a more specific product surface match.',
197
- match: ['search', 'query', 'lookup', 'look up', 'discover', 'find'],
198
- },
199
- ] as const
200
-
201
- const GENERIC_TOOL_NAMES = new Set([
202
- 'run',
203
- 'execute',
204
- 'query',
205
- 'invoke',
206
- 'action',
207
- 'tool',
208
- 'command',
209
- 'workflow',
210
- 'task',
211
- ])
212
-
213
- export function parseMcpSourceInput(input: string, transportOverride?: string): McpServer {
214
- const value = input.trim()
215
- if (!value) {
216
- throw new Error('Expected an MCP server URL or a local command.')
217
- }
218
-
219
- if (transportOverride && transportOverride !== 'http' && transportOverride !== 'sse') {
220
- throw new Error('Transport must be one of: http, sse')
221
- }
222
-
223
- try {
224
- const url = new URL(value)
225
- if (url.protocol === 'http:' || url.protocol === 'https:') {
226
- const normalizedPath = url.pathname.replace(/\/+$/, '')
227
- const transport = transportOverride === 'sse' || (!transportOverride && normalizedPath.endsWith('/sse'))
228
- ? 'sse' as const
229
- : 'http' as const
230
- return {
231
- transport,
232
- url: url.toString(),
233
- }
234
- }
235
- } catch {
236
- // Not a URL, treat it as a stdio command.
237
- }
238
-
239
- const parts = splitCommandString(value)
240
- if (parts.length === 0) {
241
- throw new Error('Expected an MCP server URL or a local command.')
242
- }
243
-
244
- return {
245
- transport: 'stdio',
246
- command: parts[0],
247
- args: parts.slice(1),
248
- }
249
- }
250
-
251
- export async function writeMcpScaffold(options: McpScaffoldOptions): Promise<McpScaffoldResult> {
252
- const plan = await planMcpScaffold(options)
253
- await applyMcpScaffoldPlan(options.rootDir, plan)
254
-
255
- return {
256
- instructionsPath: plan.instructionsPath,
257
- skillDirectories: plan.skillDirectories,
258
- commandFiles: plan.commandFiles,
259
- generatedFiles: plan.generatedFiles,
260
- generatedHookMode: plan.generatedHookMode,
261
- generatedHookEvents: plan.generatedHookEvents,
262
- metadataPath: plan.metadataPath,
263
- }
264
- }
265
-
266
- export async function applyMcpScaffoldPlan(rootDir: string, plan: McpScaffoldPlan): Promise<void> {
267
- for (const file of plan.files) {
268
- const filePath = resolve(rootDir, file.relativePath)
269
- const parentDir = file.relativePath.split('/').slice(0, -1).join('/')
270
- if (parentDir) {
271
- await mkdir(resolve(rootDir, parentDir), { recursive: true })
272
- }
273
- await Bun.write(filePath, file.content)
274
- }
275
- }
276
-
277
- export async function planMcpScaffold(options: McpScaffoldOptions): Promise<McpScaffoldPlan> {
278
- const pluginName = toKebabCase(options.pluginName) || 'mcp-plugin'
279
- const displayName = options.displayName
280
- ?? options.introspection.serverInfo.title
281
- ?? humanizeName(pluginName)
282
- const description = options.description
283
- ?? options.introspection.serverInfo.description
284
- ?? `Generated from the ${displayName} MCP server.`
285
- const serverName = options.serverName
286
- ?? toKebabCase(options.introspection.serverInfo.name)
287
- ?? pluginName
288
- const runtimeAuthMode = options.runtimeAuthMode ?? 'inline'
289
-
290
- const instructionsPath = resolve(options.rootDir, 'INSTRUCTIONS.md')
291
- const skillRoot = resolve(options.rootDir, 'skills')
292
- const commandsRoot = resolve(options.rootDir, 'commands')
293
- const plannedSkills = planSkillScaffoldsFromPersisted(
294
- options.introspection.tools,
295
- options.skillGrouping,
296
- options.persistedSkills,
297
- options.toolRenames,
298
- )
299
- const userConfigSource = {
300
- targets: options.targets,
301
- mcp: {
302
- [serverName]: options.source,
303
- },
304
- platforms: runtimeAuthMode === 'platform'
305
- ? {
306
- 'claude-code': { mcpAuth: 'platform' as const },
307
- cursor: { mcpAuth: 'platform' as const },
308
- }
309
- : undefined,
310
- } as PluginConfig
311
- const userConfig = collectUserConfigEntries(userConfigSource)
312
- .map(({ source: _source, ...entry }) => entry)
313
- const skillDirectories: string[] = []
314
- const commandFiles: string[] = []
315
- const generatedFiles = ['pluxx.config.ts', './INSTRUCTIONS.md']
316
- const generatedHooks = planGeneratedHooks(options.source, options.introspection.tools, serverName, options.hookMode)
317
- const metadataPath = MCP_SCAFFOLD_METADATA_PATH
318
- const taxonomyPath = MCP_TAXONOMY_PATH
319
- const files: McpScaffoldPlannedFile[] = []
320
-
321
- const addPlannedFile = async (relativePath: string, content: string) => {
322
- const filePath = resolve(options.rootDir, relativePath)
323
- const action = existsSync(filePath)
324
- ? ((await Bun.file(filePath).text()) === content ? 'unchanged' : 'update')
325
- : 'create'
326
- files.push({ relativePath, content, action })
327
- }
328
-
329
- await addPlannedFile(
330
- 'pluxx.config.ts',
331
- buildConfigTemplate({
332
- pluginName,
333
- authorName: options.authorName,
334
- description,
335
- displayName,
336
- serverName,
337
- source: options.source,
338
- websiteUrl: options.introspection.serverInfo.websiteUrl,
339
- targets: options.targets,
340
- userConfig,
341
- hooks: generatedHooks.hookEntries,
342
- scriptsPath: generatedHooks.scriptsPath,
343
- runtimeAuthMode,
344
- commandsPath: './commands/',
345
- }),
346
- )
347
-
348
- await addPlannedFile(
349
- './INSTRUCTIONS.md',
350
- wrapManagedMarkdown(
351
- buildInstructionsContent({
352
- displayName,
353
- description,
354
- source: options.source,
355
- runtimeAuthMode,
356
- instructions: options.introspection.instructions,
357
- skills: plannedSkills,
358
- tools: options.introspection.tools,
359
- userConfig,
360
- }),
361
- existsSync(instructionsPath) ? await Bun.file(instructionsPath).text() : undefined,
362
- {
363
- customHeading: '## Custom Instructions',
364
- defaultCustomContent: DEFAULT_INSTRUCTIONS_CUSTOM_CONTENT,
365
- },
366
- ),
367
- )
368
-
369
- for (const skill of plannedSkills) {
370
- const relativeSkillPath = `skills/${skill.dirName}`
371
- const skillPath = resolve(skillRoot, skill.dirName, 'SKILL.md')
372
- const relativeCommandPath = `commands/${skill.dirName}.md`
373
- const commandPath = resolve(commandsRoot, `${skill.dirName}.md`)
374
- await addPlannedFile(
375
- `${relativeSkillPath}/SKILL.md`,
376
- wrapManagedMarkdown(
377
- buildSkillContent(skill),
378
- existsSync(skillPath) ? await Bun.file(skillPath).text() : undefined,
379
- {
380
- customHeading: '## Custom Notes',
381
- defaultCustomContent: DEFAULT_SKILL_CUSTOM_CONTENT,
382
- },
383
- ),
384
- )
385
- skillDirectories.push(relativeSkillPath)
386
- generatedFiles.push(`${relativeSkillPath}/SKILL.md`)
387
-
388
- await addPlannedFile(
389
- relativeCommandPath,
390
- buildCommandContent(
391
- skill,
392
- existsSync(commandPath) ? await Bun.file(commandPath).text() : undefined,
393
- ),
394
- )
395
- commandFiles.push(relativeCommandPath)
396
- generatedFiles.push(relativeCommandPath)
397
- }
398
-
399
- for (const file of generatedHooks.files) {
400
- await addPlannedFile(file.relativePath, file.content)
401
- generatedFiles.push(file.relativePath)
402
- }
403
-
404
- const metadata = buildMcpScaffoldMetadata({
405
- source: options.source,
406
- introspection: options.introspection,
407
- pluginName,
408
- displayName,
409
- skillGrouping: options.skillGrouping ?? 'workflow',
410
- requestedHookMode: options.hookMode ?? 'none',
411
- generatedHookMode: generatedHooks.mode,
412
- generatedHookEvents: Object.keys(generatedHooks.hookEntries ?? {}),
413
- runtimeAuthMode,
414
- userConfig,
415
- plannedSkills,
416
- managedFiles: [...generatedFiles, taxonomyPath, metadataPath],
417
- })
418
- await addPlannedFile(taxonomyPath, `${JSON.stringify(buildPersistedTaxonomy(plannedSkills), null, 2)}\n`)
419
- generatedFiles.push(taxonomyPath)
420
- await addPlannedFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`)
421
- generatedFiles.push(metadataPath)
422
-
423
- return {
424
- instructionsPath: './INSTRUCTIONS.md',
425
- skillDirectories,
426
- commandFiles,
427
- generatedFiles,
428
- generatedHookMode: generatedHooks.mode,
429
- generatedHookEvents: Object.keys(generatedHooks.hookEntries ?? {}),
430
- metadataPath,
431
- files,
432
- }
433
- }
434
-
435
- export function wrapManagedMarkdown(
436
- generatedContent: string,
437
- existingContent: string | undefined,
438
- options: {
439
- customHeading: string
440
- defaultCustomContent: string
441
- },
442
- ): string {
443
- const mixedContent = extractMixedMarkdownContent(existingContent, options.defaultCustomContent)
444
- const customContent = mixedContent.customContent.trim() || options.defaultCustomContent
445
- const { frontmatter, body } = splitMarkdownFrontmatter(generatedContent.trim())
446
-
447
- const lines = [
448
- ...(frontmatter ? [frontmatter, ''] : []),
449
- PLUXX_GENERATED_START,
450
- body.trim(),
451
- PLUXX_GENERATED_END,
452
- '',
453
- options.customHeading,
454
- '',
455
- PLUXX_CUSTOM_START,
456
- customContent,
457
- PLUXX_CUSTOM_END,
458
- '',
459
- ]
460
-
461
- return lines.join('\n')
462
- }
463
-
464
- function splitMarkdownFrontmatter(content: string): { frontmatter: string; body: string } {
465
- const lines = content.split(/\r?\n/)
466
- if (lines[0]?.trim() !== '---') {
467
- return {
468
- frontmatter: '',
469
- body: content,
470
- }
471
- }
472
-
473
- let endIndex = -1
474
- for (let index = 1; index < lines.length; index += 1) {
475
- if (lines[index].trim() === '---') {
476
- endIndex = index
477
- break
478
- }
479
- }
480
-
481
- if (endIndex === -1) {
482
- return {
483
- frontmatter: '',
484
- body: content,
485
- }
486
- }
487
-
488
- return {
489
- frontmatter: lines.slice(0, endIndex + 1).join('\n'),
490
- body: lines.slice(endIndex + 1).join('\n').replace(/^\n+/, ''),
491
- }
492
- }
493
-
494
- export function extractMixedMarkdownContent(
495
- content: string | undefined,
496
- defaultCustomContent: string,
497
- ): MixedMarkdownContent {
498
- if (!content) {
499
- return {
500
- hasMarkers: false,
501
- customContent: defaultCustomContent,
502
- }
503
- }
504
-
505
- const customStart = content.indexOf(PLUXX_CUSTOM_START)
506
- const customEnd = content.indexOf(PLUXX_CUSTOM_END)
507
-
508
- if (customStart !== -1 && customEnd !== -1 && customEnd > customStart) {
509
- const customContent = content
510
- .slice(customStart + PLUXX_CUSTOM_START.length, customEnd)
511
- .trim()
512
-
513
- return {
514
- hasMarkers: true,
515
- customContent: customContent || defaultCustomContent,
516
- }
517
- }
518
-
519
- const trimmed = content.trim()
520
- return {
521
- hasMarkers: false,
522
- customContent: trimmed
523
- ? `${LEGACY_MIXED_CONTENT_NOTE}\n\n${trimmed}`
524
- : defaultCustomContent,
525
- }
526
- }
527
-
528
- export function hasMeaningfulCustomContent(content: string | undefined): boolean {
529
- if (!content) return false
530
-
531
- const extracted = extractMixedMarkdownContent(content, DEFAULT_INSTRUCTIONS_CUSTOM_CONTENT)
532
- const normalized = extracted.customContent.trim()
533
-
534
- return normalized !== '' && normalized !== DEFAULT_INSTRUCTIONS_CUSTOM_CONTENT && normalized !== DEFAULT_SKILL_CUSTOM_CONTENT
535
- }
536
-
537
- export function buildSkillContent(skill: PlannedSkill): string {
538
- const description = buildSkillFrontmatterDescription(skill)
539
- const exampleRequests = skill.tools
540
- .map((tool) => buildToolExampleRequest(tool))
541
- .filter((example, index, values) => values.indexOf(example) === index)
542
-
543
- const lines = [
544
- '---',
545
- `name: ${JSON.stringify(skill.dirName)}`,
546
- `description: ${JSON.stringify(description)}`,
547
- '---',
548
- '',
549
- `# ${skill.title}`,
550
- '',
551
- skill.description,
552
- '',
553
- '## Tools In This Skill',
554
- '',
555
- ]
556
-
557
- for (const tool of skill.tools) {
558
- lines.push(`### \`${tool.name}\``)
559
- lines.push('')
560
- lines.push(tool.description ?? `Calls \`${tool.name}\` on the configured MCP server.`)
561
- lines.push('')
562
-
563
- const fields = getTopLevelSchemaFields(tool.inputSchema)
564
- if (fields.length > 0) {
565
- lines.push('Inputs:')
566
- for (const field of fields) {
567
- const required = field.required ? ', required' : ''
568
- const detail = field.description ? `: ${field.description}` : ''
569
- lines.push(`- \`${field.name}\` (${field.type}${required})${detail}`)
570
- }
571
- lines.push('')
572
- }
573
- }
574
-
575
- lines.push(
576
- '## Example Requests',
577
- '',
578
- )
579
-
580
- for (const example of exampleRequests) {
581
- lines.push(`- "${example}"`)
582
- }
583
-
584
- lines.push(
585
- '',
586
- '## Usage',
587
- '',
588
- '- Pick the most specific tool in this skill for the user request.',
589
- '- Gather required inputs before calling a tool.',
590
- '- Summarize the returned data clearly instead of dumping raw JSON unless the user asks for it.',
591
- '',
592
- )
593
-
594
- return lines.join('\n')
595
- }
596
-
597
- export function buildCommandContent(skill: PlannedSkill, existingContent?: string): string {
598
- const description = truncate(cleanSingleLineText(skill.description), 140)
599
- const argumentHint = inferCommandArgumentHint(skill)
600
- const generatedContent = [
601
- '---',
602
- `description: ${JSON.stringify(description)}`,
603
- `argument-hint: ${JSON.stringify(argumentHint)}`,
604
- '---',
605
- '',
606
- `Use the ${skill.title.toLowerCase()} workflow for this plugin.`,
607
- '',
608
- 'Arguments: $ARGUMENTS',
609
- '',
610
- 'Primary tools:',
611
- ...skill.tools.map((tool) => `- \`${tool.name}\``),
612
- '',
613
- 'Workflow:',
614
- '',
615
- '1. Interpret `$ARGUMENTS` as the user request for this workflow.',
616
- '2. Choose the most specific tool in this surface.',
617
- '3. Ask for missing required inputs only if the request does not already provide them.',
618
- '4. Return a concise task-focused answer instead of raw JSON unless the user asks for it.',
619
- ].join('\n')
620
-
621
- return wrapManagedMarkdown(
622
- generatedContent,
623
- existingContent,
624
- {
625
- customHeading: '## Custom Notes',
626
- defaultCustomContent: DEFAULT_SKILL_CUSTOM_CONTENT,
627
- },
628
- )
629
- }
630
-
631
- export function buildInstructionsContent(input: {
632
- displayName: string
633
- description: string
634
- source: McpServer
635
- runtimeAuthMode?: McpRuntimeAuthMode
636
- instructions?: string
637
- skills: PlannedSkill[]
638
- tools: IntrospectedMcpTool[]
639
- userConfig?: UserConfigEntry[]
640
- }): string {
641
- const accessLine = describePluginAccess(input.displayName, input.source, input.runtimeAuthMode ?? 'inline')
642
- const lines = [
643
- `# ${input.displayName}`,
644
- '',
645
- input.description,
646
- '',
647
- accessLine,
648
- '',
649
- '## Workflow Guidance',
650
- '',
651
- ]
652
-
653
- for (const skill of input.skills) {
654
- lines.push(`- ${buildInstructionSkillSummary(skill)}`)
655
- }
656
-
657
- lines.push(
658
- '',
659
- '## Tool Routing',
660
- '',
661
- )
662
-
663
- for (const tool of input.tools) {
664
- lines.push(`- \`${tool.name}\`: ${summarizeToolForInstructions(tool)}`)
665
- }
666
-
667
- lines.push(
668
- '',
669
- '## Operating Notes',
670
- '',
671
- '- Prefer the most specific tool that matches the user request.',
672
- '- Confirm required inputs before calling a tool.',
673
- '- Summarize returned data instead of dumping raw JSON unless the user asks for it.',
674
- )
675
-
676
- if (input.userConfig && input.userConfig.length > 0) {
677
- lines.push('', '## User Config', '')
678
- for (const item of input.userConfig) {
679
- const descriptor = [item.type ?? 'string', item.required === false ? 'optional' : 'required']
680
- .filter(Boolean)
681
- .join(', ')
682
- const envVar = item.envVar ? ` — env: \`${item.envVar}\`` : ''
683
- lines.push(`- \`${item.key}\` (${item.title}; ${descriptor})${envVar}: ${item.description}`)
684
- }
685
- }
686
-
687
- if (input.instructions) {
688
- const serverGuidance = summarizeServerGuidance(input.instructions)
689
- if (serverGuidance.length > 0) {
690
- lines.push('', '## Server Guidance', '')
691
- for (const item of serverGuidance) {
692
- lines.push(`- ${item}`)
693
- }
694
- }
695
- }
696
-
697
- lines.push('')
698
- return lines.join('\n')
699
- }
700
-
701
- export function buildConfigTemplate(input: {
702
- pluginName: string
703
- authorName: string
704
- description: string
705
- displayName: string
706
- serverName: string
707
- source: McpServer
708
- websiteUrl?: string
709
- targets: TargetPlatform[]
710
- userConfig?: UserConfigEntry[]
711
- hooks?: Record<string, HookEntry[]>
712
- scriptsPath?: string
713
- runtimeAuthMode?: McpRuntimeAuthMode
714
- commandsPath?: string
715
- }): string {
716
- const targets = input.targets.map((target) => JSON.stringify(target)).join(', ')
717
- const mcpBlock = buildMcpBlock(input.serverName, input.source)
718
- const brandFields = [
719
- `displayName: ${JSON.stringify(input.displayName)}`,
720
- input.websiteUrl ? `websiteURL: ${JSON.stringify(input.websiteUrl)}` : null,
721
- ].filter(Boolean).join(',\n ')
722
- const userConfigBlock = input.userConfig && input.userConfig.length > 0
723
- ? `\n userConfig: ${serializeUserConfig(input.userConfig)},\n`
724
- : ''
725
- const scriptsBlock = input.scriptsPath ? ` scripts: ${JSON.stringify(input.scriptsPath)},\n` : ''
726
- const commandsBlock = input.commandsPath ? ` commands: ${JSON.stringify(input.commandsPath)},\n` : ''
727
- const hooksBlock = input.hooks ? `\n hooks: ${serializeHooks(input.hooks)},\n` : ''
728
- const platformsBlock = input.runtimeAuthMode === 'platform'
729
- ? `\n platforms: {\n 'claude-code': {\n mcpAuth: 'platform',\n },\n cursor: {\n mcpAuth: 'platform',\n },\n },\n`
730
- : ''
731
-
732
- return `import { definePlugin } from 'pluxx'
733
-
734
- export default definePlugin({
735
- name: ${JSON.stringify(input.pluginName)},
736
- version: '0.1.0',
737
- description: ${JSON.stringify(input.description)},
738
- author: {
739
- name: ${JSON.stringify(input.authorName)},
740
- },
741
- license: 'MIT',
742
-
743
- skills: './skills/',
744
- ${commandsBlock}
745
- instructions: './INSTRUCTIONS.md',
746
- ${userConfigBlock}
747
- ${scriptsBlock}
748
-
749
- mcp: {
750
- ${mcpBlock}
751
- },
752
- ${hooksBlock}
753
- ${platformsBlock}
754
-
755
- brand: {
756
- ${brandFields}
757
- },
758
-
759
- targets: [${targets}],
760
- })
761
- `
762
- }
763
-
764
- function buildMcpBlock(serverName: string, source: McpServer): string {
765
- if (source.transport === 'stdio') {
766
- const argsLine = source.args && source.args.length > 0
767
- ? `,\n args: ${JSON.stringify(source.args)}`
768
- : ''
769
- const envLine = source.env && Object.keys(source.env).length > 0
770
- ? `,\n env: ${JSON.stringify(source.env, null, 6).replace(/\n/g, '\n ')}`
771
- : ''
772
-
773
- return ` ${JSON.stringify(serverName)}: {
774
- transport: 'stdio',
775
- command: ${JSON.stringify(source.command)}${argsLine}${envLine}
776
- },`
777
- }
778
-
779
- const authLine = source.auth && source.auth.type !== 'none'
780
- ? source.auth.type === 'platform'
781
- ? `,\n auth: {\n type: 'platform',\n mode: ${JSON.stringify(source.auth.mode ?? 'oauth')}\n }`
782
- : `,\n auth: {\n type: ${JSON.stringify(source.auth.type)},\n envVar: ${JSON.stringify(source.auth.envVar)}${source.auth.type === 'header'
783
- ? `,\n headerName: ${JSON.stringify(source.auth.headerName)},\n headerTemplate: ${JSON.stringify(source.auth.headerTemplate)}`
784
- : ''}\n }`
785
- : ''
786
- const transportLine = source.transport === 'sse'
787
- ? `\n transport: 'sse',`
788
- : ''
789
-
790
- return ` ${JSON.stringify(serverName)}: {${transportLine}
791
- url: ${JSON.stringify(source.url)}${authLine}
792
- },`
793
- }
794
-
795
- function serializeHooks(hooks: Record<string, HookEntry[]>): string {
796
- const entries = Object.entries(hooks)
797
- .map(([event, hookEntries]) => {
798
- const serializedEntries = hookEntries
799
- .map((entry) => {
800
- const fields = [
801
- entry.type && entry.type !== 'command' ? `type: ${JSON.stringify(entry.type)}` : null,
802
- entry.command ? `command: ${JSON.stringify(entry.command)}` : null,
803
- entry.prompt ? `prompt: ${JSON.stringify(entry.prompt)}` : null,
804
- entry.model ? `model: ${JSON.stringify(entry.model)}` : null,
805
- entry.timeout !== undefined ? `timeout: ${entry.timeout}` : null,
806
- entry.matcher ? `matcher: ${JSON.stringify(entry.matcher)}` : null,
807
- entry.failClosed !== undefined ? `failClosed: ${entry.failClosed}` : null,
808
- entry.loop_limit !== undefined ? `loop_limit: ${entry.loop_limit}` : null,
809
- ].filter(Boolean)
810
-
811
- return ` {\n ${fields.join(',\n ')}\n }`
812
- })
813
- .join(',\n')
814
-
815
- return ` ${event}: [\n${serializedEntries}\n ]`
816
- })
817
- .join(',\n')
818
-
819
- return `{\n${entries}\n }`
820
- }
821
-
822
- function serializeUserConfig(userConfig: UserConfigEntry[]): string {
823
- const entries = userConfig
824
- .map((item) => {
825
- const fields = [
826
- `key: ${JSON.stringify(item.key)}`,
827
- `title: ${JSON.stringify(item.title)}`,
828
- `description: ${JSON.stringify(item.description)}`,
829
- `type: ${JSON.stringify(item.type ?? 'string')}`,
830
- `required: ${item.required ?? true}`,
831
- item.envVar ? `envVar: ${JSON.stringify(item.envVar)}` : null,
832
- item.defaultValue !== undefined ? `defaultValue: ${JSON.stringify(item.defaultValue)}` : null,
833
- item.placeholder ? `placeholder: ${JSON.stringify(item.placeholder)}` : null,
834
- item.targets ? `targets: ${JSON.stringify(item.targets)}` : null,
835
- ].filter(Boolean)
836
-
837
- return ` {\n ${fields.join(',\n ')}\n }`
838
- })
839
- .join(',\n')
840
-
841
- return `[\n${entries}\n ]`
842
- }
843
-
844
- const MUTATING_PREFIXES = [
845
- 'create', 'add', 'insert',
846
- 'update', 'edit', 'modify', 'patch',
847
- 'delete', 'remove', 'destroy', 'drop', 'purge',
848
- 'bulk', 'send', 'post', 'publish',
849
- ] as const
850
-
851
- const MUTATING_PREFIX_PATTERN = new RegExp(`^(${MUTATING_PREFIXES.join('|')})\\b`, 'i')
852
-
853
- export function detectMutatingTools(tools: IntrospectedMcpTool[]): string[] {
854
- return tools
855
- .filter((tool) => {
856
- const normalized = normalizeIdentifier(tool.name).trim().toLowerCase()
857
- return MUTATING_PREFIX_PATTERN.test(normalized)
858
- })
859
- .map((tool) => tool.name)
860
- }
861
-
862
- function planGeneratedHooks(source: McpServer, tools: IntrospectedMcpTool[], serverName: string, hookMode: McpHookMode = 'none'): GeneratedHookScaffold {
863
- if (hookMode !== 'safe') {
864
- return { mode: 'none', files: [] }
865
- }
866
-
867
- const envVars = collectRequiredEnvVars(source)
868
- const mutatingTools = detectMutatingTools(tools)
869
-
870
- if (envVars.length === 0 && mutatingTools.length === 0) {
871
- return { mode: 'none', files: [] }
872
- }
873
-
874
- const hookEntries: Record<string, HookEntry[]> = {}
875
- const files: Array<{ relativePath: string; content: string }> = []
876
-
877
- if (envVars.length > 0) {
878
- hookEntries.sessionStart = [{
879
- type: 'command',
880
- command: 'bash "${PLUGIN_ROOT}/scripts/check-env.sh"',
881
- }]
882
- files.push({
883
- relativePath: 'scripts/check-env.sh',
884
- content: buildEnvValidationScript(envVars),
885
- })
886
- }
887
-
888
- if (mutatingTools.length > 0) {
889
- hookEntries.preToolUse = mutatingTools.map((toolName) => ({
890
- type: 'command',
891
- command: 'bash "${PLUGIN_ROOT}/scripts/confirm-mutation.sh"',
892
- matcher: buildMcpToolMatcher(serverName, toolName),
893
- }))
894
- files.push({
895
- relativePath: 'scripts/confirm-mutation.sh',
896
- content: buildMutationConfirmationScript(mutatingTools),
897
- })
898
- }
899
-
900
- return {
901
- mode: 'safe',
902
- scriptsPath: './scripts/',
903
- hookEntries,
904
- files,
905
- }
906
- }
907
-
908
- export function buildMutationConfirmationScript(mutatingTools: string[]): string {
909
- const toolList = JSON.stringify(mutatingTools.map(sanitizeShellCommentText))
910
- return `#!/usr/bin/env bash
911
- set -euo pipefail
912
- # This hook runs before mutating MCP tools.
913
- # The platform will prompt the user for confirmation.
914
- # Mutating tools: ${toolList}
915
- echo "pluxx: This tool modifies data. The agent should confirm before proceeding." >&2
916
- `
917
- }
918
-
919
- function collectRequiredEnvVars(source: McpServer): string[] {
920
- const envVars = new Set<string>()
921
-
922
- if (source.auth?.type && source.auth.type !== 'none' && source.auth.type !== 'platform') {
923
- if (isValidShellEnvVarName(source.auth.envVar)) {
924
- envVars.add(source.auth.envVar)
925
- }
926
- }
927
-
928
- if (source.transport === 'stdio') {
929
- for (const key of Object.keys(source.env ?? {})) {
930
- if (isValidShellEnvVarName(key)) {
931
- envVars.add(key)
932
- }
933
- }
934
- }
935
-
936
- return [...envVars]
937
- }
938
-
939
- function buildEnvValidationScript(envVars: string[]): string {
940
- const checks = envVars
941
- .map((envVar) => `if [ -z "\${${envVar}:-}" ]; then
942
- echo "pluxx: ${envVar} is not set. Export it before using this plugin." >&2
943
- exit 1
944
- fi`)
945
- .join('\n\n')
946
-
947
- return `#!/usr/bin/env bash
948
- set -euo pipefail
949
-
950
- ${checks}
951
- `
952
- }
953
-
954
- function buildMcpToolMatcher(serverName: string, toolName: string): string {
955
- return `mcp__${serverName}__${toolName}`
956
- }
957
-
958
- function sanitizeShellCommentText(value: string): string {
959
- return value.replace(/[\u0000-\u001f\u007f\u2028\u2029]/g, ' ').trim()
960
- }
961
-
962
- function isValidShellEnvVarName(value: string): boolean {
963
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value)
964
- }
965
-
966
- function buildMcpScaffoldMetadata(input: {
967
- source: McpServer
968
- introspection: IntrospectedMcpServer
969
- pluginName: string
970
- displayName: string
971
- skillGrouping: McpSkillGrouping
972
- requestedHookMode: McpHookMode
973
- generatedHookMode: McpHookMode
974
- generatedHookEvents: string[]
975
- runtimeAuthMode: McpRuntimeAuthMode
976
- userConfig: UserConfigEntry[]
977
- plannedSkills: PlannedSkill[]
978
- managedFiles: string[]
979
- }): McpScaffoldMetadata {
980
- return {
981
- version: 1,
982
- source: input.source,
983
- serverInfo: input.introspection.serverInfo,
984
- settings: {
985
- pluginName: input.pluginName,
986
- displayName: input.displayName,
987
- skillGrouping: input.skillGrouping,
988
- requestedHookMode: input.requestedHookMode,
989
- generatedHookMode: input.generatedHookMode,
990
- generatedHookEvents: input.generatedHookEvents,
991
- runtimeAuthMode: input.runtimeAuthMode,
992
- },
993
- userConfig: input.userConfig,
994
- tools: input.introspection.tools,
995
- skills: input.plannedSkills.map((skill) => ({
996
- dirName: skill.dirName,
997
- title: skill.title,
998
- description: skill.description,
999
- toolNames: skill.tools.map((tool) => tool.name),
1000
- })),
1001
- managedFiles: input.managedFiles,
1002
- }
1003
- }
1004
-
1005
- export function planSkillScaffolds(
1006
- tools: IntrospectedMcpTool[],
1007
- grouping: McpSkillGrouping = 'workflow',
1008
- ): PlannedSkill[] {
1009
- if (grouping === 'tool') {
1010
- return allocateSkillDirectoryNames(
1011
- tools
1012
- .sort((a, b) => a.name.localeCompare(b.name))
1013
- .map((tool) => ({
1014
- dirName: toKebabCase(tool.name) || 'tool',
1015
- title: tool.title ?? humanizeName(tool.name),
1016
- description: tool.description ?? `Use the \`${tool.name}\` MCP tool for this workflow.`,
1017
- tools: [tool],
1018
- })),
1019
- )
1020
- }
1021
-
1022
- const categoryBuckets = new Map<string, IntrospectedMcpTool[]>()
1023
- const standaloneTools: IntrospectedMcpTool[] = []
1024
-
1025
- for (const tool of tools) {
1026
- const category = classifyToolWorkflow(tool)
1027
- if (!category) {
1028
- standaloneTools.push(tool)
1029
- continue
1030
- }
1031
-
1032
- const bucket = categoryBuckets.get(category) ?? []
1033
- bucket.push(tool)
1034
- categoryBuckets.set(category, bucket)
1035
- }
1036
-
1037
- const plannedSkills: PlannedSkill[] = []
1038
-
1039
- for (const definition of WORKFLOW_SKILL_DEFINITIONS) {
1040
- const bucket = categoryBuckets.get(definition.key)
1041
- if (!bucket || bucket.length === 0) continue
1042
-
1043
- plannedSkills.push({
1044
- dirName: definition.key,
1045
- title: definition.title,
1046
- description: definition.description,
1047
- tools: bucket.sort((a, b) => a.name.localeCompare(b.name)),
1048
- })
1049
- }
1050
-
1051
- for (const tool of standaloneTools.sort((a, b) => a.name.localeCompare(b.name))) {
1052
- plannedSkills.push({
1053
- dirName: toKebabCase(tool.name) || 'tool',
1054
- title: tool.title ?? humanizeName(tool.name),
1055
- description: tool.description ?? `Use the \`${tool.name}\` MCP tool for this workflow.`,
1056
- tools: [tool],
1057
- })
1058
- }
1059
-
1060
- return allocateSkillDirectoryNames(plannedSkills)
1061
- }
1062
-
1063
- function planSkillScaffoldsFromPersisted(
1064
- tools: IntrospectedMcpTool[],
1065
- grouping: McpSkillGrouping = 'workflow',
1066
- persistedSkills: PersistedSkill[] = [],
1067
- toolRenames: Map<string, string> = new Map(),
1068
- ): PlannedSkill[] {
1069
- if (persistedSkills.length === 0) {
1070
- return planSkillScaffolds(tools, grouping)
1071
- }
1072
-
1073
- const toolByName = new Map(tools.map((tool) => [tool.name, tool]))
1074
- const assigned = new Set<string>()
1075
- const planned: PlannedSkill[] = []
1076
-
1077
- for (const skill of persistedSkills) {
1078
- const matchedTools: IntrospectedMcpTool[] = []
1079
-
1080
- for (const originalToolName of skill.toolNames) {
1081
- const resolvedToolName = toolRenames.get(originalToolName) ?? originalToolName
1082
- const tool = toolByName.get(resolvedToolName)
1083
- if (!tool || assigned.has(tool.name)) continue
1084
- matchedTools.push(tool)
1085
- assigned.add(tool.name)
1086
- }
1087
-
1088
- if (matchedTools.length === 0) continue
1089
-
1090
- planned.push({
1091
- dirName: skill.dirName,
1092
- title: skill.title,
1093
- description: skill.description ?? `Handle ${skill.title.toLowerCase()} workflows.`,
1094
- tools: matchedTools,
1095
- })
1096
- }
1097
-
1098
- const remainingTools = tools.filter((tool) => !assigned.has(tool.name))
1099
- if (remainingTools.length > 0) {
1100
- planned.push(...planSkillScaffolds(remainingTools, grouping))
1101
- }
1102
-
1103
- return allocateSkillDirectoryNames(planned)
1104
- }
1105
-
1106
- function buildPersistedTaxonomy(skills: PlannedSkill[]): PersistedSkill[] {
1107
- return skills.map((skill) => ({
1108
- dirName: skill.dirName,
1109
- title: skill.title,
1110
- description: skill.description,
1111
- toolNames: skill.tools.map((tool) => tool.name),
1112
- }))
1113
- }
1114
-
1115
- export function analyzeMcpQuality(
1116
- tools: IntrospectedMcpTool[],
1117
- plannedSkills: PlannedSkill[] = planSkillScaffolds(tools, 'workflow'),
1118
- ): McpQualityReport {
1119
- const issues: McpQualityIssue[] = []
1120
-
1121
- const genericNameTools = tools.filter((tool) => {
1122
- const normalized = toKebabCase(tool.name)
1123
- return GENERIC_TOOL_NAMES.has(normalized)
1124
- })
1125
-
1126
- if (genericNameTools.length > 0) {
1127
- issues.push({
1128
- level: 'warning',
1129
- code: 'generic-tool-names',
1130
- title: 'Generic MCP tool names',
1131
- detail: `${genericNameTools.length} tool(s) use generic names: ${genericNameTools.slice(0, 5).map((tool) => tool.name).join(', ')}`,
1132
- fix: 'Add sharper tool names or use Agent Mode with docs/website context to recover better taxonomy.',
1133
- })
1134
- }
1135
-
1136
- const missingDescriptionTools = tools.filter((tool) => cleanSingleLineText(tool.description).length < 12)
1137
- if (missingDescriptionTools.length > 0) {
1138
- issues.push({
1139
- level: 'warning',
1140
- code: 'missing-tool-descriptions',
1141
- title: 'Weak MCP tool descriptions',
1142
- detail: `${missingDescriptionTools.length} tool(s) have missing or too-short descriptions.`,
1143
- fix: 'Add clearer tool descriptions, or expect the scaffold to need more agent refinement.',
1144
- })
1145
- }
1146
-
1147
- const verboseDescriptionTools = tools.filter((tool) => {
1148
- const description = tool.description ?? ''
1149
- return description.includes('\n') || /returns:|args:|usage:|example:/i.test(description) || description.length > 260
1150
- })
1151
- if (verboseDescriptionTools.length > 0) {
1152
- issues.push({
1153
- level: 'info',
1154
- code: 'verbose-tool-descriptions',
1155
- title: 'Tool descriptions look documentation-shaped',
1156
- detail: `${verboseDescriptionTools.length} tool(s) include long or multiline help text that may need agent cleanup in skills and instructions.`,
1157
- fix: 'Use autopilot or rerun agent refinement with docs/website context so the output becomes more product-shaped.',
1158
- })
1159
- }
1160
-
1161
- const weakSchemaTools = tools.filter((tool) => {
1162
- const fields = getTopLevelSchemaFields(tool.inputSchema)
1163
- return fields.length >= 2 && fields.every((field) => !field.description)
1164
- })
1165
- if (weakSchemaTools.length > 0) {
1166
- issues.push({
1167
- level: 'info',
1168
- code: 'weak-input-schemas',
1169
- title: 'Input schemas lack field descriptions',
1170
- detail: `${weakSchemaTools.length} tool(s) define multi-field input schemas without per-field descriptions.`,
1171
- fix: 'Add input field descriptions or provide docs/context so agents can infer better examples and guidance.',
1172
- })
1173
- }
1174
-
1175
- const workflowFallbackSkills = plannedSkills.filter((skill) => skill.tools.length === 1 && toKebabCase(skill.tools[0]?.name ?? '') === skill.dirName)
1176
- if (workflowFallbackSkills.length >= 2) {
1177
- issues.push({
1178
- level: 'warning',
1179
- code: 'workflow-fallback-skills',
1180
- title: 'Workflow grouping fell back to tool-level buckets',
1181
- detail: `${workflowFallbackSkills.length} generated skill(s) are still direct tool wrappers, which usually means the MCP metadata is too weak for clean workflow grouping.`,
1182
- fix: 'Use docs/website context, add pluxx.agent.md hints, or improve the MCP tool metadata before publishing.',
1183
- })
1184
- }
1185
-
1186
- const warnings = issues.filter((issue) => issue.level === 'warning').length
1187
- const infos = issues.filter((issue) => issue.level === 'info').length
1188
-
1189
- return {
1190
- ok: warnings === 0,
1191
- warnings,
1192
- infos,
1193
- issues,
1194
- }
1195
- }
1196
-
1197
- function allocateSkillDirectoryNames(skills: PlannedSkill[]): PlannedSkill[] {
1198
- const used = new Set<string>()
1199
-
1200
- return skills.map((skill) => {
1201
- const base = toKebabCase(skill.dirName) || 'skill'
1202
- let candidate = base
1203
- let suffix = 2
1204
-
1205
- while (used.has(candidate)) {
1206
- candidate = `${base}-${suffix}`
1207
- suffix += 1
1208
- }
1209
-
1210
- used.add(candidate)
1211
- return {
1212
- ...skill,
1213
- dirName: candidate,
1214
- }
1215
- })
1216
- }
1217
-
1218
- function classifyToolWorkflow(tool: IntrospectedMcpTool): string | null {
1219
- const primaryText = [
1220
- normalizeIdentifier(tool.name).toLowerCase(),
1221
- normalizeIdentifier(tool.title ?? '').toLowerCase(),
1222
- ].join(' ')
1223
- const secondaryText = (tool.description ?? '').toLowerCase()
1224
-
1225
- let bestMatch: string | null = null
1226
- let bestScore = 0
1227
-
1228
- for (const definition of WORKFLOW_SKILL_DEFINITIONS) {
1229
- let score = 0
1230
-
1231
- for (const needle of definition.match) {
1232
- if (primaryText.includes(needle)) {
1233
- score += 3
1234
- }
1235
- if (secondaryText.includes(needle)) {
1236
- score += 1
1237
- }
1238
- }
1239
-
1240
- if (score > bestScore) {
1241
- bestScore = score
1242
- bestMatch = definition.key
1243
- }
1244
- }
1245
-
1246
- return bestMatch
1247
- }
1248
-
1249
- function buildSkillFrontmatterDescription(skill: PlannedSkill): string {
1250
- if (skill.tools.length === 1) {
1251
- const tool = skill.tools[0]
1252
- const cleanedDescription = cleanSingleLineText(tool.description)
1253
- const sentence = firstSentenceOf(cleanedDescription)
1254
-
1255
- if (sentence) {
1256
- return truncate(sentence, 220)
1257
- }
1258
- }
1259
-
1260
- return truncate(cleanSingleLineText(skill.description), 220)
1261
- }
1262
-
1263
- function buildInstructionSkillSummary(skill: PlannedSkill): string {
1264
- const toolNames = skill.tools.map((tool) => `\`${tool.name}\``).join(', ')
1265
- const description = truncate(cleanSingleLineText(skill.description), 180)
1266
- return `\`${skill.dirName}\`: ${description} Primary tools: ${toolNames}.`
1267
- }
1268
-
1269
- function inferCommandArgumentHint(skill: PlannedSkill): string {
1270
- const fieldHints = new Set<string>()
1271
-
1272
- for (const tool of skill.tools) {
1273
- const fields = getTopLevelSchemaFields(tool.inputSchema)
1274
- const prioritizedFields = fields.filter((field) => field.required)
1275
- const candidates = (prioritizedFields.length > 0 ? prioritizedFields : fields).slice(0, 2)
1276
-
1277
- for (const field of candidates) {
1278
- fieldHints.add(mapSchemaFieldToArgumentHint(field.name))
1279
- if (fieldHints.size >= 2) break
1280
- }
1281
-
1282
- if (fieldHints.size >= 2) break
1283
- }
1284
-
1285
- if (fieldHints.size === 0) {
1286
- return '[request]'
1287
- }
1288
-
1289
- return [...fieldHints].slice(0, 2).map((hint) => `[${hint}]`).join(' ')
1290
- }
1291
-
1292
- function mapSchemaFieldToArgumentHint(fieldName: string): string {
1293
- const value = fieldName.toLowerCase()
1294
-
1295
- if (value.includes('query') || value.includes('search') || value.includes('keyword')) return 'query'
1296
- if (value.includes('company') || value.includes('organization') || value.includes('organisation') || value.includes('account')) return 'company'
1297
- if (value.includes('role') || value.includes('title')) return 'role'
1298
- if (value.includes('domain')) return 'domain'
1299
- if (value.includes('url')) return 'url'
1300
- if (value.includes('email')) return 'email'
1301
- if (value === 'id' || value.endsWith('id')) return 'id'
1302
- if (value.includes('name')) return 'name'
1303
-
1304
- return value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'request'
1305
- }
1306
-
1307
- function summarizeToolForInstructions(tool: IntrospectedMcpTool): string {
1308
- const fallback = 'Use this tool when its inputs match the user request.'
1309
- const description = tool.description ?? ''
1310
- const base = firstSentenceOf(cleanSingleLineText(description))
1311
- const bestFor = extractLabeledGuidance(description, 'Best for')
1312
- const useWhen = extractLabeledGuidance(description, 'Use when')
1313
-
1314
- const parts = [base].filter(Boolean)
1315
- const guidance = bestFor || useWhen
1316
-
1317
- if (guidance) {
1318
- parts.push(`Best for: ${guidance}.`)
1319
- }
1320
-
1321
- if (parts.length === 0) {
1322
- return fallback
1323
- }
1324
-
1325
- return truncate(parts.join(' '), 240)
1326
- }
1327
-
1328
- function summarizeServerGuidance(value: string): string[] {
1329
- const lines = value
1330
- .split('\n')
1331
- .map((line) => line.trim())
1332
- .filter(Boolean)
1333
- .filter((line) => !line.startsWith('```'))
1334
- .map((line) => line.replace(/^#+\s*/, ''))
1335
- .map((line) => line.replace(/^[-*]\s+/, ''))
1336
- .filter((line) => !/^example:?$/i.test(line))
1337
- .map((line) => truncate(cleanSingleLineText(line), 220))
1338
- .filter(Boolean)
1339
-
1340
- return [...new Set(lines)].slice(0, 4)
1341
- }
1342
-
1343
- function extractLabeledGuidance(value: string | undefined, label: string): string {
1344
- if (!value) return ''
1345
-
1346
- const normalizedLabel = `${label.toLowerCase()}:`
1347
- for (const rawLine of value.split('\n')) {
1348
- const line = rawLine
1349
- .trim()
1350
- .replace(/^\*+|\*+$/g, '')
1351
- .replace(/^[-*]\s*/, '')
1352
- .replace(/^\*+\s*/, '')
1353
- .replace(/\s+\*+$/, '')
1354
- if (!line) continue
1355
-
1356
- const plain = line.replace(/[*`_]/g, '')
1357
- if (plain.toLowerCase().startsWith(normalizedLabel)) {
1358
- return truncate(cleanSingleLineText(plain.slice(normalizedLabel.length).trim()), 160)
1359
- }
1360
- }
1361
-
1362
- return ''
1363
- }
1364
-
1365
- function getTopLevelSchemaFields(inputSchema?: Record<string, unknown>): SchemaField[] {
1366
- if (!inputSchema) return []
1367
-
1368
- const rawProperties = inputSchema.properties
1369
- if (!rawProperties || typeof rawProperties !== 'object') {
1370
- return []
1371
- }
1372
-
1373
- const required = new Set(
1374
- Array.isArray(inputSchema.required)
1375
- ? inputSchema.required.filter((value): value is string => typeof value === 'string')
1376
- : [],
1377
- )
1378
-
1379
- return Object.entries(rawProperties).map(([name, value]) => {
1380
- const schema = typeof value === 'object' && value !== null ? value as Record<string, unknown> : {}
1381
- return {
1382
- name,
1383
- type: formatSchemaType(schema.type),
1384
- required: required.has(name),
1385
- description: typeof schema.description === 'string' ? schema.description : undefined,
1386
- }
1387
- })
1388
- }
1389
-
1390
- export function buildToolExampleRequest(tool: IntrospectedMcpTool): string {
1391
- const action = inferToolAction(tool)
1392
- const objectLabel = inferToolObject(tool)
1393
- const context = buildToolRequestContext(tool)
1394
-
1395
- const sentence = context
1396
- ? `${action} ${objectLabel} ${context}.`
1397
- : `${action} ${objectLabel}.`
1398
-
1399
- return sentence.charAt(0).toUpperCase() + sentence.slice(1)
1400
- }
1401
-
1402
- function inferToolAction(tool: IntrospectedMcpTool): string {
1403
- const identifier = normalizeIdentifier(tool.title ?? tool.name).toLowerCase()
1404
-
1405
- if (/^(get|fetch|lookup|look up)\b/.test(identifier)) return 'look up'
1406
- if (/^(create|add)\b/.test(identifier)) return 'create'
1407
- if (/^(update|edit)\b/.test(identifier)) return 'update'
1408
- if (/^(delete|remove)\b/.test(identifier)) return 'delete'
1409
- if (/^(list)\b/.test(identifier)) return 'list'
1410
- if (/^(query)\b/.test(identifier)) return 'query'
1411
- if (/^(search)\b/.test(identifier)) return 'search'
1412
- return 'find'
1413
- }
1414
-
1415
- function inferToolObject(tool: IntrospectedMcpTool): string {
1416
- const raw = normalizeIdentifier(tool.title ?? tool.name).trim()
1417
- const stripped = raw.replace(/^(find|get|fetch|lookup|look up|search|list|create|add|update|edit|delete|remove|query)\s+/i, '')
1418
- const candidate = stripped || raw
1419
- return candidate ? candidate.toLowerCase() : 'results'
1420
- }
1421
-
1422
- function buildToolRequestContext(tool: IntrospectedMcpTool): string {
1423
- const requiredFields = getTopLevelSchemaFields(tool.inputSchema).filter((field) => field.required)
1424
- const preferredField = requiredFields[0]
1425
-
1426
- if (!preferredField) return ''
1427
-
1428
- const placeholder = `<${preferredField.name}>`
1429
- const fieldName = preferredField.name.toLowerCase()
1430
-
1431
- if (fieldName.endsWith('id') || fieldName === 'id') {
1432
- return `using ${placeholder}`
1433
- }
1434
-
1435
- if (fieldName.includes('query') || fieldName.includes('keyword') || fieldName.includes('search')) {
1436
- return `matching ${placeholder}`
1437
- }
1438
-
1439
- if (fieldName.includes('role') || fieldName.includes('title')) {
1440
- return `for ${placeholder}`
1441
- }
1442
-
1443
- if (fieldName.includes('company') || fieldName.includes('organization') || fieldName.includes('organisation') || fieldName.includes('account')) {
1444
- return `for ${placeholder}`
1445
- }
1446
-
1447
- if (fieldName.includes('domain') || fieldName.includes('email') || fieldName.includes('url')) {
1448
- return `using ${placeholder}`
1449
- }
1450
-
1451
- return `with ${placeholder}`
1452
- }
1453
-
1454
- function formatSchemaType(value: unknown): string {
1455
- if (typeof value === 'string') return value
1456
- if (Array.isArray(value)) {
1457
- const parts = value.filter((entry): entry is string => typeof entry === 'string')
1458
- if (parts.length > 0) return parts.join(' | ')
1459
- }
1460
- return 'unknown'
1461
- }
1462
-
1463
- function describePluginAccess(displayName: string, source: McpServer, runtimeAuthMode: McpRuntimeAuthMode): string {
1464
- if (source.transport === 'stdio') {
1465
- const command = [source.command, ...(source.args ?? [])].join(' ')
1466
- const authLine = describeAuthRequirement(source)
1467
- return `${displayName} connects through a local stdio MCP command (\`${command}\`).${authLine ? ` ${authLine}` : ''}`
1468
- }
1469
-
1470
- const transportLabel = source.transport === 'sse' ? 'legacy SSE' : 'HTTP'
1471
- const authLine = describeAuthRequirement(source, runtimeAuthMode)
1472
- return `${displayName} connects to its MCP over ${transportLabel}.${authLine ? ` ${authLine}` : ''}`
1473
- }
1474
-
1475
- function describeAuthRequirement(source: McpServer, runtimeAuthMode: McpRuntimeAuthMode = 'inline'): string {
1476
- if (runtimeAuthMode === 'platform' && source.transport !== 'stdio') {
1477
- 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.'
1478
- }
1479
-
1480
- if (!source.auth || source.auth.type === 'none') {
1481
- return ''
1482
- }
1483
-
1484
- if (source.auth.type === 'platform') {
1485
- return 'Use the platform-managed auth flow before calling authenticated tools.'
1486
- }
1487
-
1488
- if (source.auth.type === 'header') {
1489
- return `Export \`${source.auth.envVar}\` so Pluxx can send ${source.auth.headerName ?? 'the required auth header'}.`
1490
- }
1491
-
1492
- return `Export \`${source.auth.envVar}\` before using authenticated tools.`
1493
- }
1494
-
1495
- function humanizeName(value: string): string {
1496
- return normalizeIdentifier(value)
1497
- .split(/\s+/)
1498
- .filter(Boolean)
1499
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1500
- .join(' ')
1501
- }
1502
-
1503
- function toKebabCase(value: string): string {
1504
- return normalizeIdentifier(value)
1505
- .toLowerCase()
1506
- .trim()
1507
- .replace(/[^a-z0-9]+/g, '-')
1508
- .replace(/^-+|-+$/g, '')
1509
- }
1510
-
1511
- function normalizeIdentifier(value: string): string {
1512
- return value
1513
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
1514
- .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
1515
- .replace(/[._/]+/g, ' ')
1516
- .replace(/-/g, ' ')
1517
- }
1518
-
1519
- function truncate(value: string, maxLength: number): string {
1520
- if (value.length <= maxLength) return value
1521
- return `${value.slice(0, maxLength - 1).trimEnd()}…`
1522
- }
1523
-
1524
- function cleanSingleLineText(value: string | undefined): string {
1525
- if (!value) return ''
1526
-
1527
- return value
1528
- .replace(/\s+/g, ' ')
1529
- .replace(/\s*[-*]\s+/g, ' ')
1530
- .trim()
1531
- }
1532
-
1533
- function firstSentenceOf(value: string): string {
1534
- if (!value) return ''
1535
-
1536
- const match = value.match(/^(.+?[.?!])(?:\s|$)/)
1537
- return (match?.[1] ?? value).trim()
1538
- }
1539
-
1540
- function splitCommandString(command: string): string[] {
1541
- const parts: string[] = []
1542
- let current = ''
1543
- let quote: '"' | "'" | null = null
1544
- let escaping = false
1545
-
1546
- for (const char of command) {
1547
- if (escaping) {
1548
- current += char
1549
- escaping = false
1550
- continue
1551
- }
1552
-
1553
- if (char === '\\') {
1554
- escaping = true
1555
- continue
1556
- }
1557
-
1558
- if (quote) {
1559
- if (char === quote) {
1560
- quote = null
1561
- } else {
1562
- current += char
1563
- }
1564
- continue
1565
- }
1566
-
1567
- if (char === '"' || char === "'") {
1568
- quote = char
1569
- continue
1570
- }
1571
-
1572
- if (/\s/.test(char)) {
1573
- if (current) {
1574
- parts.push(current)
1575
- current = ''
1576
- }
1577
- continue
1578
- }
1579
-
1580
- current += char
1581
- }
1582
-
1583
- if (quote) {
1584
- throw new Error('Unterminated quote in MCP command.')
1585
- }
1586
-
1587
- if (escaping) {
1588
- current += '\\'
1589
- }
1590
-
1591
- if (current) {
1592
- parts.push(current)
1593
- }
1594
-
1595
- return parts
1596
- }
1597
-
1598
- export function derivePluginName(introspection: IntrospectedMcpServer, source: McpServer): string {
1599
- const candidates = [
1600
- introspection.serverInfo.name,
1601
- introspection.serverInfo.title,
1602
- source.transport === 'stdio' ? basename(source.command) : new URL(source.url).hostname.split('.')[0],
1603
- ].filter((value): value is string => Boolean(value))
1604
-
1605
- for (const candidate of candidates) {
1606
- const normalized = toKebabCase(candidate)
1607
- if (normalized) return normalized
1608
- }
1609
-
1610
- return 'mcp-plugin'
1611
- }