@orchid-labs/pluxx 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -8
- package/bin/pluxx.js +19 -28
- package/dist/agents.d.ts +16 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/cli/agent.d.ts +62 -0
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/doctor.d.ts +2 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/entry.d.ts +2 -0
- package/dist/cli/entry.d.ts.map +1 -0
- package/dist/cli/index.d.ts +7 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +21810 -0
- package/dist/cli/init-from-mcp.d.ts +17 -1
- package/dist/cli/init-from-mcp.d.ts.map +1 -1
- package/dist/cli/install.d.ts +1 -0
- package/dist/cli/install.d.ts.map +1 -1
- package/dist/cli/lint.d.ts +3 -1
- package/dist/cli/lint.d.ts.map +1 -1
- package/dist/cli/mcp-proxy.d.ts.map +1 -1
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/primitive-summary.d.ts +14 -0
- package/dist/cli/primitive-summary.d.ts.map +1 -0
- package/dist/cli/prompt.d.ts +1 -1
- package/dist/cli/publish.d.ts +6 -1
- package/dist/cli/publish.d.ts.map +1 -1
- package/dist/cli/sync-from-mcp.d.ts.map +1 -1
- package/dist/cli/verify-install.d.ts +25 -0
- package/dist/cli/verify-install.d.ts.map +1 -0
- package/dist/commands.d.ts +10 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/compiler-intent.d.ts +165 -0
- package/dist/compiler-intent.d.ts.map +1 -0
- package/dist/config/load.d.ts.map +1 -1
- package/dist/delegation.d.ts +11 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/generators/amp/index.d.ts.map +1 -1
- package/dist/generators/base.d.ts +5 -0
- package/dist/generators/base.d.ts.map +1 -1
- package/dist/generators/claude-code/index.d.ts.map +1 -1
- package/dist/generators/cline/index.d.ts.map +1 -1
- package/dist/generators/codex/index.d.ts +4 -0
- package/dist/generators/codex/index.d.ts.map +1 -1
- package/dist/generators/cursor/index.d.ts +1 -0
- package/dist/generators/cursor/index.d.ts.map +1 -1
- package/dist/generators/gemini-cli/index.d.ts.map +1 -1
- package/dist/generators/github-copilot/index.d.ts.map +1 -1
- package/dist/generators/opencode/index.d.ts +1 -0
- package/dist/generators/opencode/index.d.ts.map +1 -1
- package/dist/generators/openhands/index.d.ts.map +1 -1
- package/dist/generators/roo-code/index.d.ts.map +1 -1
- package/dist/generators/shared/claude-family.d.ts.map +1 -1
- package/dist/generators/warp/index.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5371 -553
- package/dist/schema.d.ts +91 -42
- package/dist/schema.d.ts.map +1 -1
- package/dist/text-files.d.ts +5 -0
- package/dist/text-files.d.ts.map +1 -0
- package/dist/validation/platform-rules.d.ts +15 -1
- package/dist/validation/platform-rules.d.ts.map +1 -1
- package/package.json +15 -13
- package/src/cli/agent.ts +0 -1455
- package/src/cli/dev.ts +0 -112
- package/src/cli/doctor.ts +0 -987
- package/src/cli/eval.ts +0 -470
- package/src/cli/index.ts +0 -2933
- package/src/cli/init-from-mcp.ts +0 -2115
- package/src/cli/install.ts +0 -860
- package/src/cli/lint.ts +0 -1249
- package/src/cli/mcp-proxy.ts +0 -322
- package/src/cli/migrate.ts +0 -867
- package/src/cli/prompt.ts +0 -82
- package/src/cli/publish.ts +0 -401
- package/src/cli/runtime.ts +0 -86
- package/src/cli/sync-from-mcp.ts +0 -586
- package/src/cli/test.ts +0 -142
- package/src/compatibility/matrix.ts +0 -149
- package/src/config/define.ts +0 -20
- package/src/config/load.ts +0 -74
- package/src/generators/amp/index.ts +0 -63
- package/src/generators/base.ts +0 -188
- package/src/generators/claude-code/index.ts +0 -172
- package/src/generators/cline/index.ts +0 -35
- package/src/generators/codex/index.ts +0 -143
- package/src/generators/cursor/index.ts +0 -158
- package/src/generators/gemini-cli/index.ts +0 -83
- package/src/generators/github-copilot/index.ts +0 -32
- package/src/generators/hooks-warning.ts +0 -51
- package/src/generators/index.ts +0 -71
- package/src/generators/opencode/index.ts +0 -526
- package/src/generators/openhands/index.ts +0 -32
- package/src/generators/roo-code/index.ts +0 -35
- package/src/generators/shared/claude-family.ts +0 -215
- package/src/generators/warp/index.ts +0 -32
- package/src/hook-events.ts +0 -33
- package/src/index.ts +0 -34
- package/src/mcp/introspect.ts +0 -1107
- package/src/permissions.ts +0 -260
- package/src/schema.ts +0 -312
- package/src/user-config.ts +0 -177
- package/src/validation/platform-rules.ts +0 -686
package/src/cli/sync-from-mcp.ts
DELETED
|
@@ -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
|
-
}
|