@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.
Files changed (119) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +574 -0
  3. package/bin/pluxx.js +37 -0
  4. package/dist/cli/agent.d.ts +90 -0
  5. package/dist/cli/agent.d.ts.map +1 -0
  6. package/dist/cli/dev.d.ts +2 -0
  7. package/dist/cli/dev.d.ts.map +1 -0
  8. package/dist/cli/doctor.d.ts +19 -0
  9. package/dist/cli/doctor.d.ts.map +1 -0
  10. package/dist/cli/index.d.ts +24 -0
  11. package/dist/cli/index.d.ts.map +1 -0
  12. package/dist/cli/init-from-mcp.d.ts +145 -0
  13. package/dist/cli/init-from-mcp.d.ts.map +1 -0
  14. package/dist/cli/install.d.ts +56 -0
  15. package/dist/cli/install.d.ts.map +1 -0
  16. package/dist/cli/lint.d.ts +18 -0
  17. package/dist/cli/lint.d.ts.map +1 -0
  18. package/dist/cli/migrate.d.ts +2 -0
  19. package/dist/cli/migrate.d.ts.map +1 -0
  20. package/dist/cli/prompt.d.ts +20 -0
  21. package/dist/cli/prompt.d.ts.map +1 -0
  22. package/dist/cli/publish.d.ts +70 -0
  23. package/dist/cli/publish.d.ts.map +1 -0
  24. package/dist/cli/runtime.d.ts +20 -0
  25. package/dist/cli/runtime.d.ts.map +1 -0
  26. package/dist/cli/sync-from-mcp.d.ts +32 -0
  27. package/dist/cli/sync-from-mcp.d.ts.map +1 -0
  28. package/dist/cli/test.d.ts +33 -0
  29. package/dist/cli/test.d.ts.map +1 -0
  30. package/dist/compatibility/matrix.d.ts +14 -0
  31. package/dist/compatibility/matrix.d.ts.map +1 -0
  32. package/dist/config/define.d.ts +18 -0
  33. package/dist/config/define.d.ts.map +1 -0
  34. package/dist/config/load.d.ts +7 -0
  35. package/dist/config/load.d.ts.map +1 -0
  36. package/dist/generators/amp/index.d.ts +13 -0
  37. package/dist/generators/amp/index.d.ts.map +1 -0
  38. package/dist/generators/base.d.ts +49 -0
  39. package/dist/generators/base.d.ts.map +1 -0
  40. package/dist/generators/claude-code/index.d.ts +7 -0
  41. package/dist/generators/claude-code/index.d.ts.map +1 -0
  42. package/dist/generators/cline/index.d.ts +14 -0
  43. package/dist/generators/cline/index.d.ts.map +1 -0
  44. package/dist/generators/codex/index.d.ts +9 -0
  45. package/dist/generators/codex/index.d.ts.map +1 -0
  46. package/dist/generators/cursor/index.d.ts +11 -0
  47. package/dist/generators/cursor/index.d.ts.map +1 -0
  48. package/dist/generators/gemini-cli/index.d.ts +13 -0
  49. package/dist/generators/gemini-cli/index.d.ts.map +1 -0
  50. package/dist/generators/github-copilot/index.d.ts +11 -0
  51. package/dist/generators/github-copilot/index.d.ts.map +1 -0
  52. package/dist/generators/hooks-warning.d.ts +3 -0
  53. package/dist/generators/hooks-warning.d.ts.map +1 -0
  54. package/dist/generators/index.d.ts +11 -0
  55. package/dist/generators/index.d.ts.map +1 -0
  56. package/dist/generators/opencode/index.d.ts +15 -0
  57. package/dist/generators/opencode/index.d.ts.map +1 -0
  58. package/dist/generators/openhands/index.d.ts +11 -0
  59. package/dist/generators/openhands/index.d.ts.map +1 -0
  60. package/dist/generators/roo-code/index.d.ts +14 -0
  61. package/dist/generators/roo-code/index.d.ts.map +1 -0
  62. package/dist/generators/shared/claude-family.d.ts +18 -0
  63. package/dist/generators/shared/claude-family.d.ts.map +1 -0
  64. package/dist/generators/warp/index.d.ts +13 -0
  65. package/dist/generators/warp/index.d.ts.map +1 -0
  66. package/dist/hook-events.d.ts +4 -0
  67. package/dist/hook-events.d.ts.map +1 -0
  68. package/dist/index.d.ts +7 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.js +5302 -0
  71. package/dist/mcp/introspect.d.ts +34 -0
  72. package/dist/mcp/introspect.d.ts.map +1 -0
  73. package/dist/permissions.d.ts +18 -0
  74. package/dist/permissions.d.ts.map +1 -0
  75. package/dist/schema.d.ts +9457 -0
  76. package/dist/schema.d.ts.map +1 -0
  77. package/dist/user-config.d.ts +19 -0
  78. package/dist/user-config.d.ts.map +1 -0
  79. package/dist/validation/platform-rules.d.ts +64 -0
  80. package/dist/validation/platform-rules.d.ts.map +1 -0
  81. package/package.json +76 -0
  82. package/src/cli/agent.ts +1030 -0
  83. package/src/cli/dev.ts +112 -0
  84. package/src/cli/doctor.ts +588 -0
  85. package/src/cli/index.ts +2414 -0
  86. package/src/cli/init-from-mcp.ts +1611 -0
  87. package/src/cli/install.ts +698 -0
  88. package/src/cli/lint.ts +1219 -0
  89. package/src/cli/migrate.ts +614 -0
  90. package/src/cli/prompt.ts +82 -0
  91. package/src/cli/publish.ts +401 -0
  92. package/src/cli/runtime.ts +86 -0
  93. package/src/cli/sync-from-mcp.ts +563 -0
  94. package/src/cli/test.ts +134 -0
  95. package/src/compatibility/matrix.ts +149 -0
  96. package/src/config/define.ts +20 -0
  97. package/src/config/load.ts +74 -0
  98. package/src/generators/amp/index.ts +63 -0
  99. package/src/generators/base.ts +188 -0
  100. package/src/generators/claude-code/index.ts +29 -0
  101. package/src/generators/cline/index.ts +35 -0
  102. package/src/generators/codex/index.ts +120 -0
  103. package/src/generators/cursor/index.ts +158 -0
  104. package/src/generators/gemini-cli/index.ts +83 -0
  105. package/src/generators/github-copilot/index.ts +32 -0
  106. package/src/generators/hooks-warning.ts +51 -0
  107. package/src/generators/index.ts +71 -0
  108. package/src/generators/opencode/index.ts +526 -0
  109. package/src/generators/openhands/index.ts +32 -0
  110. package/src/generators/roo-code/index.ts +35 -0
  111. package/src/generators/shared/claude-family.ts +215 -0
  112. package/src/generators/warp/index.ts +32 -0
  113. package/src/hook-events.ts +33 -0
  114. package/src/index.ts +23 -0
  115. package/src/mcp/introspect.ts +834 -0
  116. package/src/permissions.ts +258 -0
  117. package/src/schema.ts +312 -0
  118. package/src/user-config.ts +177 -0
  119. package/src/validation/platform-rules.ts +565 -0
@@ -0,0 +1,563 @@
1
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, readdirSync, rmdirSync, writeFileSync } from 'fs'
2
+ import { dirname, isAbsolute, relative, resolve } from 'path'
3
+ import { tmpdir } from 'os'
4
+ import { loadConfig } from '../config/load'
5
+ import { introspectMcpServer, type IntrospectedMcpTool } from '../mcp/introspect'
6
+ import type { McpServer } from '../schema'
7
+ import {
8
+ MCP_SCAFFOLD_METADATA_PATH,
9
+ MCP_TAXONOMY_PATH,
10
+ PLUXX_CUSTOM_START,
11
+ PLUXX_CUSTOM_END,
12
+ extractMixedMarkdownContent,
13
+ hasMeaningfulCustomContent,
14
+ type McpScaffoldMetadata,
15
+ type PersistedSkill,
16
+ writeMcpScaffold,
17
+ } from './init-from-mcp'
18
+
19
+ export interface SyncFromMcpOptions {
20
+ rootDir: string
21
+ source?: McpServer
22
+ }
23
+
24
+ export interface SyncFromMcpResult {
25
+ source: McpServer
26
+ toolCount: number
27
+ addedFiles: string[]
28
+ updatedFiles: string[]
29
+ removedFiles: string[]
30
+ preservedFiles: string[]
31
+ renamedFiles: Array<{ from: string; to: string }>
32
+ }
33
+
34
+ export async function readMcpScaffoldMetadata(rootDir: string): Promise<McpScaffoldMetadata> {
35
+ const filepath = resolveWithinRoot(rootDir, MCP_SCAFFOLD_METADATA_PATH)
36
+ if (!existsSync(filepath)) {
37
+ throw new Error(
38
+ `No MCP scaffold metadata found at ${MCP_SCAFFOLD_METADATA_PATH}. Run "pluxx init --from-mcp" first.`,
39
+ )
40
+ }
41
+
42
+ return JSON.parse(readFileSync(filepath, 'utf-8')) as McpScaffoldMetadata
43
+ }
44
+
45
+ export async function syncFromMcp(options: SyncFromMcpOptions): Promise<SyncFromMcpResult> {
46
+ const metadata = await readMcpScaffoldMetadata(options.rootDir)
47
+ const config = await loadConfig(options.rootDir)
48
+ const source = options.source ?? metadata.source
49
+ const introspection = await introspectMcpServer(source)
50
+ const beforeContents = snapshotManagedFiles(options.rootDir, metadata.managedFiles)
51
+
52
+ // Step 1: Detect renames between old and new tool lists
53
+ const toolRenames = detectToolRenames(metadata.tools, introspection.tools)
54
+ const persistedSkills = readPersistedSkills(options.rootDir, metadata)
55
+
56
+ // Step 3: Generate new scaffold
57
+ const result = await writeMcpScaffold({
58
+ rootDir: options.rootDir,
59
+ pluginName: config.name,
60
+ authorName: config.author.name,
61
+ targets: config.targets,
62
+ source,
63
+ introspection,
64
+ displayName: config.brand?.displayName ?? metadata.settings.displayName,
65
+ skillGrouping: metadata.settings.skillGrouping,
66
+ hookMode: metadata.settings.requestedHookMode,
67
+ runtimeAuthMode: metadata.settings.runtimeAuthMode ?? 'inline',
68
+ persistedSkills,
69
+ toolRenames,
70
+ })
71
+
72
+ const newMetadataPath = resolveWithinRoot(options.rootDir, MCP_SCAFFOLD_METADATA_PATH)
73
+ const newMetadata: McpScaffoldMetadata = JSON.parse(readFileSync(newMetadataPath, 'utf-8'))
74
+ const skillRenames = detectSkillRenames(metadata.skills, newMetadata.skills, toolRenames)
75
+
76
+ // Step 4: Inject preserved custom content into renamed skill files
77
+ for (const [oldSkillDir, newSkillDir] of skillRenames) {
78
+ const oldSkillPath = resolveWithinRoot(options.rootDir, `skills/${oldSkillDir}/SKILL.md`)
79
+ if (!existsSync(oldSkillPath)) continue
80
+ const oldContent = readFileSync(oldSkillPath, 'utf-8')
81
+ const extracted = extractMixedMarkdownContent(oldContent, '')
82
+ if (!hasMeaningfulCustomContent(oldContent)) continue
83
+
84
+ const newSkill = newMetadata.skills.find((s) => s.dirName === newSkillDir)
85
+ if (!newSkill) continue
86
+
87
+ const newSkillPath = resolveWithinRoot(options.rootDir, `skills/${newSkill.dirName}/SKILL.md`)
88
+ if (!existsSync(newSkillPath)) continue
89
+ const currentContent = readFileSync(newSkillPath, 'utf-8')
90
+ const updatedContent = injectCustomContent(currentContent, extracted.customContent)
91
+ writeFileSync(newSkillPath, updatedContent, 'utf-8')
92
+ }
93
+
94
+ // Step 5: Build rename mapping (old skill dir -> new skill dir)
95
+ const renamedFiles: Array<{ from: string; to: string }> = []
96
+ const renamedOldDirs = new Set<string>()
97
+ const renamedNewDirs = new Set<string>()
98
+ for (const [oldSkillDir, newSkillDir] of skillRenames) {
99
+ const fromDir = `skills/${oldSkillDir}/`
100
+ const toDir = `skills/${newSkillDir}/`
101
+ if (renamedOldDirs.has(fromDir) || renamedNewDirs.has(toDir)) continue
102
+ renamedFiles.push({ from: fromDir, to: toDir })
103
+ renamedOldDirs.add(fromDir)
104
+ renamedNewDirs.add(toDir)
105
+ }
106
+
107
+ const afterManaged = new Set(result.generatedFiles)
108
+ const beforeManaged = new Set(metadata.managedFiles)
109
+
110
+ // Step 6: Handle removed files, excluding those that are part of renames
111
+ const removedCandidates = [...beforeManaged].filter((file) => !afterManaged.has(file))
112
+ const removedFiles: string[] = []
113
+ const preservedFiles: string[] = []
114
+
115
+ for (const file of removedCandidates) {
116
+ const isPartOfRename = [...renamedOldDirs].some((dir) => file.startsWith(dir))
117
+ if (isPartOfRename) {
118
+ removeManagedFile(options.rootDir, file)
119
+ continue
120
+ }
121
+
122
+ if (shouldPreserveManagedFile(options.rootDir, file)) {
123
+ preservedFiles.push(file)
124
+ continue
125
+ }
126
+
127
+ removeManagedFile(options.rootDir, file)
128
+ removedFiles.push(file)
129
+ }
130
+
131
+ // Step 7: Compute added/updated, excluding files that are part of renames from "added"
132
+ const addedFiles = [...afterManaged].filter((file) => {
133
+ if (beforeManaged.has(file)) return false
134
+ const isPartOfRename = [...renamedNewDirs].some((dir) => file.startsWith(dir))
135
+ return !isPartOfRename
136
+ })
137
+ const updatedFiles = [...afterManaged].filter((file) => {
138
+ if (!beforeManaged.has(file)) return false
139
+ const before = beforeContents.get(file)
140
+ const currentPath = resolveWithinRoot(options.rootDir, file)
141
+ if (!existsSync(currentPath)) return false
142
+ const after = readFileSync(currentPath, 'utf-8')
143
+ return before !== after
144
+ })
145
+
146
+ return {
147
+ source,
148
+ toolCount: introspection.tools.length,
149
+ addedFiles: addedFiles.sort(),
150
+ updatedFiles: updatedFiles.sort(),
151
+ removedFiles: removedFiles.sort(),
152
+ preservedFiles: preservedFiles.sort(),
153
+ renamedFiles: renamedFiles.sort((a, b) => a.from.localeCompare(b.from)),
154
+ }
155
+ }
156
+
157
+ export async function applyPersistedTaxonomy(rootDir: string): Promise<void> {
158
+ const metadata = await readMcpScaffoldMetadata(rootDir)
159
+ const config = await loadConfig(rootDir)
160
+ const beforeContents = snapshotManagedFiles(rootDir, metadata.managedFiles)
161
+ const persistedSkills = readPersistedSkills(rootDir, metadata)
162
+
163
+ const result = await writeMcpScaffold({
164
+ rootDir,
165
+ pluginName: config.name,
166
+ authorName: config.author.name,
167
+ targets: config.targets,
168
+ source: metadata.source,
169
+ introspection: {
170
+ protocolVersion: '2025-03-26',
171
+ serverInfo: metadata.serverInfo,
172
+ tools: metadata.tools,
173
+ },
174
+ displayName: config.brand?.displayName ?? metadata.settings.displayName,
175
+ skillGrouping: metadata.settings.skillGrouping,
176
+ hookMode: metadata.settings.requestedHookMode,
177
+ runtimeAuthMode: metadata.settings.runtimeAuthMode ?? 'inline',
178
+ persistedSkills,
179
+ })
180
+
181
+ const instructionsPath = './INSTRUCTIONS.md'
182
+ const previousInstructions = beforeContents.get(instructionsPath)
183
+ if (previousInstructions !== undefined) {
184
+ writeFileSync(resolveWithinRoot(rootDir, instructionsPath), previousInstructions, 'utf-8')
185
+ }
186
+
187
+ const newMetadataPath = resolveWithinRoot(rootDir, MCP_SCAFFOLD_METADATA_PATH)
188
+ const newMetadata: McpScaffoldMetadata = JSON.parse(readFileSync(newMetadataPath, 'utf-8'))
189
+ const skillRenames = detectSkillRenames(metadata.skills, newMetadata.skills, new Map())
190
+
191
+ preserveCustomContentForRenames(rootDir, skillRenames, (dirName) => `skills/${dirName}/SKILL.md`)
192
+ preserveCustomContentForRenames(rootDir, skillRenames, (dirName) => `commands/${dirName}.md`)
193
+
194
+ const afterManaged = new Set(result.generatedFiles)
195
+ const beforeManaged = new Set(metadata.managedFiles)
196
+ const renamedOldDirs = new Set([...skillRenames.keys()].map((dirName) => `skills/${dirName}/`))
197
+ const renamedNewDirs = new Set([...skillRenames.values()].map((dirName) => `skills/${dirName}/`))
198
+ const renamedOldCommands = new Set([...skillRenames.keys()].map((dirName) => `commands/${dirName}.md`))
199
+
200
+ for (const file of beforeManaged) {
201
+ if (afterManaged.has(file) || file === instructionsPath) continue
202
+
203
+ const isRenamedSkill = [...renamedOldDirs].some((dir) => file.startsWith(dir))
204
+ const isRenamedCommand = renamedOldCommands.has(file)
205
+ if (isRenamedSkill || isRenamedCommand) {
206
+ removeManagedFile(rootDir, file)
207
+ continue
208
+ }
209
+
210
+ if (shouldPreserveManagedFile(rootDir, file)) continue
211
+ removeManagedFile(rootDir, file)
212
+ }
213
+
214
+ for (const file of afterManaged) {
215
+ if (file === instructionsPath && previousInstructions !== undefined) {
216
+ writeFileSync(resolveWithinRoot(rootDir, file), previousInstructions, 'utf-8')
217
+ }
218
+ }
219
+ }
220
+
221
+ export async function planSyncFromMcp(options: SyncFromMcpOptions): Promise<SyncFromMcpResult> {
222
+ const tempRoot = mkdtempSync(resolve(tmpdir(), 'pluxx-sync-dry-run-'))
223
+ const projectDir = resolve(tempRoot, 'project')
224
+
225
+ try {
226
+ cpSync(options.rootDir, projectDir, { recursive: true })
227
+ return await syncFromMcp({
228
+ rootDir: projectDir,
229
+ source: options.source,
230
+ })
231
+ } finally {
232
+ rmSync(tempRoot, { recursive: true, force: true })
233
+ }
234
+ }
235
+
236
+ function readPersistedSkills(rootDir: string, metadata: McpScaffoldMetadata): PersistedSkill[] {
237
+ const taxonomyPath = resolveWithinRoot(rootDir, MCP_TAXONOMY_PATH)
238
+ if (existsSync(taxonomyPath)) {
239
+ return JSON.parse(readFileSync(taxonomyPath, 'utf-8')) as PersistedSkill[]
240
+ }
241
+
242
+ return metadata.skills.map((skill) => ({
243
+ dirName: skill.dirName,
244
+ title: skill.title,
245
+ description: skill.description,
246
+ toolNames: skill.toolNames,
247
+ }))
248
+ }
249
+
250
+ function preserveCustomContentForRenames(
251
+ rootDir: string,
252
+ renames: Map<string, string>,
253
+ pathForName: (dirName: string) => string,
254
+ ): void {
255
+ for (const [oldName, newName] of renames) {
256
+ const oldPath = resolveWithinRoot(rootDir, pathForName(oldName))
257
+ if (!existsSync(oldPath)) continue
258
+
259
+ const oldContent = readFileSync(oldPath, 'utf-8')
260
+ const extracted = extractMixedMarkdownContent(oldContent, '')
261
+ if (!hasMeaningfulCustomContent(oldContent)) continue
262
+
263
+ const newPath = resolveWithinRoot(rootDir, pathForName(newName))
264
+ if (!existsSync(newPath)) continue
265
+
266
+ const currentContent = readFileSync(newPath, 'utf-8')
267
+ const updatedContent = injectCustomContent(currentContent, extracted.customContent)
268
+ writeFileSync(newPath, updatedContent, 'utf-8')
269
+ }
270
+ }
271
+
272
+ function snapshotManagedFiles(rootDir: string, files: string[]): Map<string, string> {
273
+ const contents = new Map<string, string>()
274
+ for (const file of files) {
275
+ const filepath = resolveWithinRoot(rootDir, file)
276
+ if (!existsSync(filepath)) continue
277
+ contents.set(file, readFileSync(filepath, 'utf-8'))
278
+ }
279
+ return contents
280
+ }
281
+
282
+ function removeManagedFile(rootDir: string, relativePath: string): void {
283
+ const filepath = resolveWithinRoot(rootDir, relativePath)
284
+ if (!existsSync(filepath)) return
285
+
286
+ rmSync(filepath, { force: true })
287
+ pruneEmptyDirectories(rootDir, dirname(filepath))
288
+ }
289
+
290
+ function shouldPreserveManagedFile(rootDir: string, relativePath: string): boolean {
291
+ if (!relativePath.endsWith('.md')) return false
292
+
293
+ const filepath = resolveWithinRoot(rootDir, relativePath)
294
+ if (!existsSync(filepath)) return false
295
+
296
+ return hasMeaningfulCustomContent(readFileSync(filepath, 'utf-8'))
297
+ }
298
+
299
+ function pruneEmptyDirectories(rootDir: string, startDir: string): void {
300
+ let current = startDir
301
+ const stopDir = resolve(rootDir)
302
+
303
+ while (current.startsWith(stopDir) && current !== stopDir) {
304
+ const entries = readdirSync(current)
305
+ if (entries.length > 0) return
306
+
307
+ rmdirSync(current)
308
+ current = dirname(current)
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Detect tool renames by comparing old and new tool lists.
314
+ * Returns a map of oldName -> newName for likely renames.
315
+ * Only matches 1:1 (each old tool maps to at most one new tool and vice versa).
316
+ */
317
+ export function detectToolRenames(
318
+ oldTools: IntrospectedMcpTool[],
319
+ newTools: IntrospectedMcpTool[],
320
+ ): Map<string, string> {
321
+ const oldNames = new Set(oldTools.map((t) => t.name))
322
+ const newNames = new Set(newTools.map((t) => t.name))
323
+
324
+ // Only consider tools that were removed or added (not tools that stayed the same)
325
+ const removedTools = oldTools.filter((t) => !newNames.has(t.name))
326
+ const addedTools = newTools.filter((t) => !oldNames.has(t.name))
327
+
328
+ if (removedTools.length === 0 || addedTools.length === 0) {
329
+ return new Map()
330
+ }
331
+
332
+ // Score all candidate pairs
333
+ const candidates: Array<{ oldName: string; newName: string; score: number }> = []
334
+
335
+ for (const oldTool of removedTools) {
336
+ for (const newTool of addedTools) {
337
+ const score = computeRenameScore(oldTool, newTool)
338
+ if (score >= RENAME_SCORE_THRESHOLD) {
339
+ candidates.push({ oldName: oldTool.name, newName: newTool.name, score })
340
+ }
341
+ }
342
+ }
343
+
344
+ // Greedy 1:1 matching: take best scores first
345
+ candidates.sort((a, b) => b.score - a.score)
346
+ const renames = new Map<string, string>()
347
+ const usedOld = new Set<string>()
348
+ const usedNew = new Set<string>()
349
+
350
+ for (const candidate of candidates) {
351
+ if (usedOld.has(candidate.oldName) || usedNew.has(candidate.newName)) continue
352
+ renames.set(candidate.oldName, candidate.newName)
353
+ usedOld.add(candidate.oldName)
354
+ usedNew.add(candidate.newName)
355
+ }
356
+
357
+ return renames
358
+ }
359
+
360
+ const RENAME_SCORE_THRESHOLD = 0.5
361
+ const SKILL_RENAME_SCORE_THRESHOLD = 0.6
362
+
363
+ function computeRenameScore(oldTool: IntrospectedMcpTool, newTool: IntrospectedMcpTool): number {
364
+ let score = 0
365
+ let hasCorroboratingSignal = false
366
+
367
+ if (oldTool.description && newTool.description) {
368
+ if (oldTool.description === newTool.description) {
369
+ score += 0.45
370
+ } else {
371
+ // Partial description similarity (Jaccard on words)
372
+ const oldWords = new Set(oldTool.description.toLowerCase().split(/\s+/))
373
+ const newWords = new Set(newTool.description.toLowerCase().split(/\s+/))
374
+ const intersection = [...oldWords].filter((w) => newWords.has(w)).length
375
+ const union = new Set([...oldWords, ...newWords]).size
376
+ const jaccard = union > 0 ? intersection / union : 0
377
+ if (jaccard >= 0.7) {
378
+ score += 0.35
379
+ }
380
+ }
381
+ }
382
+
383
+ const distance = levenshteinDistance(oldTool.name, newTool.name)
384
+ if (distance <= 3) {
385
+ score += 0.35
386
+ hasCorroboratingSignal = true
387
+ } else if (distance <= 6 && Math.max(oldTool.name.length, newTool.name.length) > 10) {
388
+ score += 0.2
389
+ hasCorroboratingSignal = true
390
+ }
391
+
392
+ const oldRequired = getRequiredFieldNames(oldTool.inputSchema)
393
+ const newRequired = getRequiredFieldNames(newTool.inputSchema)
394
+ if (oldRequired.length > 0 || newRequired.length > 0) {
395
+ if (oldRequired.length === newRequired.length && oldRequired.every((f) => newRequired.includes(f))) {
396
+ score += 0.35
397
+ hasCorroboratingSignal = true
398
+ } else {
399
+ // Partial overlap
400
+ const overlap = oldRequired.filter((f) => newRequired.includes(f)).length
401
+ const total = new Set([...oldRequired, ...newRequired]).size
402
+ if (total > 0 && overlap / total >= 0.7) {
403
+ score += 0.2
404
+ hasCorroboratingSignal = true
405
+ }
406
+ }
407
+ }
408
+
409
+ if (oldTool.description && newTool.description && oldTool.description === newTool.description) {
410
+ return hasCorroboratingSignal ? score : 0
411
+ }
412
+
413
+ return score
414
+ }
415
+
416
+ function getRequiredFieldNames(inputSchema?: Record<string, unknown>): string[] {
417
+ if (!inputSchema) return []
418
+ const required = inputSchema.required
419
+ if (!Array.isArray(required)) return []
420
+ return required
421
+ .filter((v): v is string => typeof v === 'string')
422
+ .sort()
423
+ }
424
+
425
+ function levenshteinDistance(a: string, b: string): number {
426
+ const m = a.length
427
+ const n = b.length
428
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
429
+
430
+ for (let i = 0; i <= m; i++) dp[i][0] = i
431
+ for (let j = 0; j <= n; j++) dp[0][j] = j
432
+
433
+ for (let i = 1; i <= m; i++) {
434
+ for (let j = 1; j <= n; j++) {
435
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
436
+ dp[i][j] = Math.min(
437
+ dp[i - 1][j] + 1,
438
+ dp[i][j - 1] + 1,
439
+ dp[i - 1][j - 1] + cost,
440
+ )
441
+ }
442
+ }
443
+
444
+ return dp[m][n]
445
+ }
446
+
447
+ /**
448
+ * Replace the custom content section in a managed markdown file.
449
+ */
450
+ function injectCustomContent(fileContent: string, customContent: string): string {
451
+ const customStart = fileContent.indexOf(PLUXX_CUSTOM_START)
452
+ const customEnd = fileContent.indexOf(PLUXX_CUSTOM_END)
453
+
454
+ if (customStart === -1 || customEnd === -1 || customEnd <= customStart) {
455
+ return fileContent
456
+ }
457
+
458
+ return (
459
+ fileContent.slice(0, customStart + PLUXX_CUSTOM_START.length) +
460
+ '\n' +
461
+ customContent.trim() +
462
+ '\n' +
463
+ fileContent.slice(customEnd)
464
+ )
465
+ }
466
+
467
+ interface SkillRenameCandidate {
468
+ oldSkill: McpScaffoldMetadata['skills'][number]
469
+ newSkill: McpScaffoldMetadata['skills'][number]
470
+ score: number
471
+ }
472
+
473
+ export function detectSkillRenames(
474
+ oldSkills: McpScaffoldMetadata['skills'],
475
+ newSkills: McpScaffoldMetadata['skills'],
476
+ toolRenames: Map<string, string>,
477
+ ): Map<string, string> {
478
+ const candidates: SkillRenameCandidate[] = []
479
+
480
+ for (const oldSkill of oldSkills) {
481
+ for (const newSkill of newSkills) {
482
+ if (oldSkill.dirName === newSkill.dirName) continue
483
+
484
+ const score = computeSkillRenameScore(oldSkill, newSkill, toolRenames)
485
+ if (score >= SKILL_RENAME_SCORE_THRESHOLD) {
486
+ candidates.push({ oldSkill, newSkill, score })
487
+ }
488
+ }
489
+ }
490
+
491
+ candidates.sort((a, b) => b.score - a.score)
492
+
493
+ const renames = new Map<string, string>()
494
+ const usedOld = new Set<string>()
495
+ const usedNew = new Set<string>()
496
+
497
+ for (const candidate of candidates) {
498
+ if (usedOld.has(candidate.oldSkill.dirName) || usedNew.has(candidate.newSkill.dirName)) continue
499
+ renames.set(candidate.oldSkill.dirName, candidate.newSkill.dirName)
500
+ usedOld.add(candidate.oldSkill.dirName)
501
+ usedNew.add(candidate.newSkill.dirName)
502
+ }
503
+
504
+ return renames
505
+ }
506
+
507
+ function computeSkillRenameScore(
508
+ oldSkill: McpScaffoldMetadata['skills'][number],
509
+ newSkill: McpScaffoldMetadata['skills'][number],
510
+ toolRenames: Map<string, string>,
511
+ ): number {
512
+ const mappedOldTools = new Set(oldSkill.toolNames.map((toolName) => toolRenames.get(toolName) ?? toolName))
513
+ const newTools = new Set(newSkill.toolNames)
514
+ const overlap = [...mappedOldTools].filter((toolName) => newTools.has(toolName)).length
515
+
516
+ if (overlap === 0) return 0
517
+
518
+ const precision = overlap / newTools.size
519
+ const recall = overlap / mappedOldTools.size
520
+ let score = (precision + recall) / 2
521
+
522
+ const normalizedOldTitle = oldSkill.title.toLowerCase().replace(/[^a-z0-9]+/g, '')
523
+ const normalizedNewTitle = newSkill.title.toLowerCase().replace(/[^a-z0-9]+/g, '')
524
+ if (normalizedOldTitle === normalizedNewTitle) {
525
+ score += 0.1
526
+ }
527
+
528
+ return score
529
+ }
530
+
531
+ function resolveWithinRoot(rootDir: string, relativePath: string): string {
532
+ const rootPath = resolve(rootDir)
533
+ const filepath = resolve(rootPath, relativePath)
534
+ const relativePathFromRoot = relative(rootPath, filepath)
535
+
536
+ if (relativePathFromRoot === '' || (!relativePathFromRoot.startsWith('..') && !isAbsolute(relativePathFromRoot))) {
537
+ return filepath
538
+ }
539
+
540
+ throw new Error(`Refusing to access path outside root: ${relativePath}`)
541
+ }
542
+
543
+ export function formatSyncSummary(result: SyncFromMcpResult, rootDir: string): string[] {
544
+ const lines = [
545
+ `Synced ${result.toolCount} MCP tool(s).`,
546
+ '',
547
+ ]
548
+
549
+ if (result.renamedFiles.length > 0) {
550
+ lines.push(`Renamed: ${result.renamedFiles.length}`)
551
+ result.renamedFiles.forEach((rename) => lines.push(` → ${rename.from} → ${rename.to}`))
552
+ }
553
+ lines.push(`Added: ${result.addedFiles.length}`)
554
+ result.addedFiles.forEach((file) => lines.push(` + ${relative(rootDir, resolve(rootDir, file))}`))
555
+ lines.push(`Updated: ${result.updatedFiles.length}`)
556
+ result.updatedFiles.forEach((file) => lines.push(` ~ ${relative(rootDir, resolve(rootDir, file))}`))
557
+ lines.push(`Removed: ${result.removedFiles.length}`)
558
+ result.removedFiles.forEach((file) => lines.push(` - ${relative(rootDir, resolve(rootDir, file))}`))
559
+ lines.push(`Preserved: ${result.preservedFiles.length}`)
560
+ result.preservedFiles.forEach((file) => lines.push(` ! ${relative(rootDir, resolve(rootDir, file))}`))
561
+
562
+ return lines
563
+ }
@@ -0,0 +1,134 @@
1
+ import { existsSync } from 'fs'
2
+ import { resolve } from 'path'
3
+ import { loadConfig } from '../config/load'
4
+ import { build } from '../generators'
5
+ import { lintProject, type LintResult } from './lint'
6
+ import type { TargetPlatform } from '../schema'
7
+
8
+ export interface TestRunOptions {
9
+ rootDir?: string
10
+ targets?: TargetPlatform[]
11
+ }
12
+
13
+ export interface TestSmokeCheck {
14
+ platform: TargetPlatform
15
+ requiredPath: string
16
+ ok: boolean
17
+ }
18
+
19
+ export interface TestRunResult {
20
+ ok: boolean
21
+ config: {
22
+ ok: boolean
23
+ name?: string
24
+ version?: string
25
+ error?: string
26
+ }
27
+ lint?: LintResult
28
+ build?: {
29
+ ok: boolean
30
+ outDir: string
31
+ targets: TargetPlatform[]
32
+ }
33
+ smoke?: {
34
+ ok: boolean
35
+ checks: TestSmokeCheck[]
36
+ }
37
+ }
38
+
39
+ const SMOKE_PATHS: Record<TargetPlatform, string> = {
40
+ 'claude-code': '.claude-plugin/plugin.json',
41
+ cursor: '.cursor-plugin/plugin.json',
42
+ codex: '.codex-plugin/plugin.json',
43
+ opencode: 'package.json',
44
+ 'github-copilot': '.claude-plugin/plugin.json',
45
+ openhands: '.plugin/plugin.json',
46
+ warp: 'AGENTS.md',
47
+ 'gemini-cli': 'gemini-extension.json',
48
+ 'roo-code': '.roo/mcp.json',
49
+ cline: '.cline/mcp.json',
50
+ amp: '.amp/settings.json',
51
+ }
52
+
53
+ export async function runTestSuite(options: TestRunOptions = {}): Promise<TestRunResult> {
54
+ const rootDir = options.rootDir ?? process.cwd()
55
+
56
+ try {
57
+ const config = await loadConfig(rootDir)
58
+ const targets = options.targets ?? config.targets
59
+ const lint = await lintProject(rootDir)
60
+
61
+ if (lint.errors > 0) {
62
+ return {
63
+ ok: false,
64
+ config: {
65
+ ok: true,
66
+ name: config.name,
67
+ version: config.version,
68
+ },
69
+ lint,
70
+ }
71
+ }
72
+
73
+ await build(config, rootDir, { targets })
74
+
75
+ const checks = targets.map((platform) => {
76
+ const requiredPath = SMOKE_PATHS[platform]
77
+ const ok = existsSync(resolve(rootDir, config.outDir, platform, requiredPath))
78
+ return { platform, requiredPath, ok }
79
+ })
80
+
81
+ return {
82
+ ok: checks.every((check) => check.ok),
83
+ config: {
84
+ ok: true,
85
+ name: config.name,
86
+ version: config.version,
87
+ },
88
+ lint,
89
+ build: {
90
+ ok: true,
91
+ outDir: config.outDir,
92
+ targets,
93
+ },
94
+ smoke: {
95
+ ok: checks.every((check) => check.ok),
96
+ checks,
97
+ },
98
+ }
99
+ } catch (error) {
100
+ return {
101
+ ok: false,
102
+ config: {
103
+ ok: false,
104
+ error: error instanceof Error ? error.message : String(error),
105
+ },
106
+ }
107
+ }
108
+ }
109
+
110
+ export function printTestResult(result: TestRunResult): void {
111
+ if (!result.config.ok) {
112
+ console.error(`Config: ${result.config.error}`)
113
+ return
114
+ }
115
+
116
+ console.log(`Config: ${result.config.name}@${result.config.version}`)
117
+
118
+ if (result.lint) {
119
+ console.log(`Lint: ${result.lint.errors} error(s), ${result.lint.warnings} warning(s)`)
120
+ }
121
+
122
+ if (result.build) {
123
+ console.log(`Build: ${result.build.targets.join(', ')} -> ${result.build.outDir}`)
124
+ }
125
+
126
+ if (result.smoke) {
127
+ for (const check of result.smoke.checks) {
128
+ const prefix = check.ok ? 'PASS' : 'FAIL'
129
+ console.log(`${prefix} ${check.platform}: ${check.requiredPath}`)
130
+ }
131
+ }
132
+
133
+ console.log(result.ok ? 'pluxx test passed.' : 'pluxx test failed.')
134
+ }