@orchid-labs/pluxx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +574 -0
- package/bin/pluxx.js +37 -0
- package/dist/cli/agent.d.ts +90 -0
- package/dist/cli/agent.d.ts.map +1 -0
- package/dist/cli/dev.d.ts +2 -0
- package/dist/cli/dev.d.ts.map +1 -0
- package/dist/cli/doctor.d.ts +19 -0
- package/dist/cli/doctor.d.ts.map +1 -0
- package/dist/cli/index.d.ts +24 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/init-from-mcp.d.ts +145 -0
- package/dist/cli/init-from-mcp.d.ts.map +1 -0
- package/dist/cli/install.d.ts +56 -0
- package/dist/cli/install.d.ts.map +1 -0
- package/dist/cli/lint.d.ts +18 -0
- package/dist/cli/lint.d.ts.map +1 -0
- package/dist/cli/migrate.d.ts +2 -0
- package/dist/cli/migrate.d.ts.map +1 -0
- package/dist/cli/prompt.d.ts +20 -0
- package/dist/cli/prompt.d.ts.map +1 -0
- package/dist/cli/publish.d.ts +70 -0
- package/dist/cli/publish.d.ts.map +1 -0
- package/dist/cli/runtime.d.ts +20 -0
- package/dist/cli/runtime.d.ts.map +1 -0
- package/dist/cli/sync-from-mcp.d.ts +32 -0
- package/dist/cli/sync-from-mcp.d.ts.map +1 -0
- package/dist/cli/test.d.ts +33 -0
- package/dist/cli/test.d.ts.map +1 -0
- package/dist/compatibility/matrix.d.ts +14 -0
- package/dist/compatibility/matrix.d.ts.map +1 -0
- package/dist/config/define.d.ts +18 -0
- package/dist/config/define.d.ts.map +1 -0
- package/dist/config/load.d.ts +7 -0
- package/dist/config/load.d.ts.map +1 -0
- package/dist/generators/amp/index.d.ts +13 -0
- package/dist/generators/amp/index.d.ts.map +1 -0
- package/dist/generators/base.d.ts +49 -0
- package/dist/generators/base.d.ts.map +1 -0
- package/dist/generators/claude-code/index.d.ts +7 -0
- package/dist/generators/claude-code/index.d.ts.map +1 -0
- package/dist/generators/cline/index.d.ts +14 -0
- package/dist/generators/cline/index.d.ts.map +1 -0
- package/dist/generators/codex/index.d.ts +9 -0
- package/dist/generators/codex/index.d.ts.map +1 -0
- package/dist/generators/cursor/index.d.ts +11 -0
- package/dist/generators/cursor/index.d.ts.map +1 -0
- package/dist/generators/gemini-cli/index.d.ts +13 -0
- package/dist/generators/gemini-cli/index.d.ts.map +1 -0
- package/dist/generators/github-copilot/index.d.ts +11 -0
- package/dist/generators/github-copilot/index.d.ts.map +1 -0
- package/dist/generators/hooks-warning.d.ts +3 -0
- package/dist/generators/hooks-warning.d.ts.map +1 -0
- package/dist/generators/index.d.ts +11 -0
- package/dist/generators/index.d.ts.map +1 -0
- package/dist/generators/opencode/index.d.ts +15 -0
- package/dist/generators/opencode/index.d.ts.map +1 -0
- package/dist/generators/openhands/index.d.ts +11 -0
- package/dist/generators/openhands/index.d.ts.map +1 -0
- package/dist/generators/roo-code/index.d.ts +14 -0
- package/dist/generators/roo-code/index.d.ts.map +1 -0
- package/dist/generators/shared/claude-family.d.ts +18 -0
- package/dist/generators/shared/claude-family.d.ts.map +1 -0
- package/dist/generators/warp/index.d.ts +13 -0
- package/dist/generators/warp/index.d.ts.map +1 -0
- package/dist/hook-events.d.ts +4 -0
- package/dist/hook-events.d.ts.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5302 -0
- package/dist/mcp/introspect.d.ts +34 -0
- package/dist/mcp/introspect.d.ts.map +1 -0
- package/dist/permissions.d.ts +18 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/schema.d.ts +9457 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/user-config.d.ts +19 -0
- package/dist/user-config.d.ts.map +1 -0
- package/dist/validation/platform-rules.d.ts +64 -0
- package/dist/validation/platform-rules.d.ts.map +1 -0
- package/package.json +76 -0
- package/src/cli/agent.ts +1030 -0
- package/src/cli/dev.ts +112 -0
- package/src/cli/doctor.ts +588 -0
- package/src/cli/index.ts +2414 -0
- package/src/cli/init-from-mcp.ts +1611 -0
- package/src/cli/install.ts +698 -0
- package/src/cli/lint.ts +1219 -0
- package/src/cli/migrate.ts +614 -0
- package/src/cli/prompt.ts +82 -0
- package/src/cli/publish.ts +401 -0
- package/src/cli/runtime.ts +86 -0
- package/src/cli/sync-from-mcp.ts +563 -0
- package/src/cli/test.ts +134 -0
- package/src/compatibility/matrix.ts +149 -0
- package/src/config/define.ts +20 -0
- package/src/config/load.ts +74 -0
- package/src/generators/amp/index.ts +63 -0
- package/src/generators/base.ts +188 -0
- package/src/generators/claude-code/index.ts +29 -0
- package/src/generators/cline/index.ts +35 -0
- package/src/generators/codex/index.ts +120 -0
- package/src/generators/cursor/index.ts +158 -0
- package/src/generators/gemini-cli/index.ts +83 -0
- package/src/generators/github-copilot/index.ts +32 -0
- package/src/generators/hooks-warning.ts +51 -0
- package/src/generators/index.ts +71 -0
- package/src/generators/opencode/index.ts +526 -0
- package/src/generators/openhands/index.ts +32 -0
- package/src/generators/roo-code/index.ts +35 -0
- package/src/generators/shared/claude-family.ts +215 -0
- package/src/generators/warp/index.ts +32 -0
- package/src/hook-events.ts +33 -0
- package/src/index.ts +23 -0
- package/src/mcp/introspect.ts +834 -0
- package/src/permissions.ts +258 -0
- package/src/schema.ts +312 -0
- package/src/user-config.ts +177 -0
- package/src/validation/platform-rules.ts +565 -0
|
@@ -0,0 +1,1611 @@
|
|
|
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
|
+
}
|