@orchid-labs/pluxx 0.1.1 → 0.1.4

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