@orchid-labs/pluxx 0.1.0 → 0.1.1
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 +100 -522
- package/dist/cli/agent.d.ts +7 -0
- package/dist/cli/agent.d.ts.map +1 -1
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/eval.d.ts +22 -0
- package/dist/cli/eval.d.ts.map +1 -0
- package/dist/cli/index.d.ts +19 -2
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/init-from-mcp.d.ts +17 -2
- package/dist/cli/init-from-mcp.d.ts.map +1 -1
- package/dist/cli/install.d.ts +2 -0
- package/dist/cli/install.d.ts.map +1 -1
- package/dist/cli/lint.d.ts +5 -1
- package/dist/cli/lint.d.ts.map +1 -1
- package/dist/cli/mcp-proxy.d.ts +10 -0
- package/dist/cli/mcp-proxy.d.ts.map +1 -0
- package/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/sync-from-mcp.d.ts.map +1 -1
- package/dist/cli/test.d.ts +2 -0
- package/dist/cli/test.d.ts.map +1 -1
- package/dist/generators/claude-code/index.d.ts +2 -0
- package/dist/generators/claude-code/index.d.ts.map +1 -1
- package/dist/generators/codex/index.d.ts +1 -0
- package/dist/generators/codex/index.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +99 -1
- package/dist/mcp/introspect.d.ts +43 -1
- package/dist/mcp/introspect.d.ts.map +1 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/validation/platform-rules.d.ts +20 -0
- package/dist/validation/platform-rules.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/cli/agent.ts +459 -34
- package/src/cli/doctor.ts +400 -1
- package/src/cli/eval.ts +470 -0
- package/src/cli/index.ts +633 -114
- package/src/cli/init-from-mcp.ts +545 -41
- package/src/cli/install.ts +166 -4
- package/src/cli/lint.ts +56 -26
- package/src/cli/mcp-proxy.ts +322 -0
- package/src/cli/migrate.ts +256 -3
- package/src/cli/sync-from-mcp.ts +23 -0
- package/src/cli/test.ts +10 -2
- package/src/generators/claude-code/index.ts +143 -0
- package/src/generators/codex/index.ts +23 -0
- package/src/index.ts +12 -1
- package/src/mcp/introspect.ts +297 -24
- package/src/permissions.ts +3 -1
- package/src/validation/platform-rules.ts +121 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync } from 'fs'
|
|
2
|
+
import { dirname, resolve } from 'path'
|
|
3
|
+
import * as readline from 'readline'
|
|
4
|
+
import type { Readable, Writable } from 'stream'
|
|
5
|
+
import { createMcpClient, McpIntrospectionError, type McpClient } from '../mcp/introspect'
|
|
6
|
+
import { parseMcpSourceInput } from './init-from-mcp'
|
|
7
|
+
|
|
8
|
+
interface ProxyTapeInteraction {
|
|
9
|
+
kind: 'request' | 'notify'
|
|
10
|
+
method: string
|
|
11
|
+
params?: Record<string, unknown>
|
|
12
|
+
result?: unknown
|
|
13
|
+
error?: {
|
|
14
|
+
code: number
|
|
15
|
+
message: string
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ProxyTape {
|
|
20
|
+
version: 1
|
|
21
|
+
source?: string
|
|
22
|
+
interactions: ProxyTapeInteraction[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface McpProxyOptions {
|
|
26
|
+
source?: string
|
|
27
|
+
recordPath?: string
|
|
28
|
+
replayPath?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ProxyIo {
|
|
32
|
+
input: Readable
|
|
33
|
+
output: Writable
|
|
34
|
+
error: Writable
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function usage(): string {
|
|
38
|
+
return [
|
|
39
|
+
'Usage: pluxx mcp proxy --from-mcp <source> [--record <tape.json>]',
|
|
40
|
+
' pluxx mcp proxy --replay <tape.json>',
|
|
41
|
+
'',
|
|
42
|
+
'Acts as a local stdio MCP proxy for development and CI.',
|
|
43
|
+
'- --record stores normalized request/response interactions as a replay tape.',
|
|
44
|
+
'- --replay serves a deterministic stdio MCP session from a recorded tape.',
|
|
45
|
+
].join('\n')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readOption(rawArgs: string[], flag: string): string | undefined {
|
|
49
|
+
const index = rawArgs.indexOf(flag)
|
|
50
|
+
if (index === -1) return undefined
|
|
51
|
+
|
|
52
|
+
const value = rawArgs[index + 1]
|
|
53
|
+
if (!value || value.startsWith('-')) {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseOptions(rawArgs: string[]): McpProxyOptions {
|
|
61
|
+
const source = readOption(rawArgs, '--from-mcp')
|
|
62
|
+
const recordPath = readOption(rawArgs, '--record')
|
|
63
|
+
const replayPath = readOption(rawArgs, '--replay')
|
|
64
|
+
|
|
65
|
+
if (recordPath && replayPath) {
|
|
66
|
+
throw new Error('Choose either --record or --replay, not both.')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!source && !replayPath) {
|
|
70
|
+
throw new Error('Expected --from-mcp <source> for live proxying, or --replay <tape.json>.')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if ((recordPath || source) && replayPath && source) {
|
|
74
|
+
throw new Error('Replay mode does not accept --from-mcp.')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
source,
|
|
79
|
+
recordPath,
|
|
80
|
+
replayPath,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stableStringify(value: unknown): string {
|
|
85
|
+
if (value === null || typeof value !== 'object') {
|
|
86
|
+
return JSON.stringify(value)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
return `[${value.map((entry) => stableStringify(entry)).join(',')}]`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
94
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
95
|
+
.map(([key, nested]) => `${JSON.stringify(key)}:${stableStringify(nested)}`)
|
|
96
|
+
return `{${entries.join(',')}}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sameParams(
|
|
100
|
+
left: Record<string, unknown> | undefined,
|
|
101
|
+
right: Record<string, unknown> | undefined,
|
|
102
|
+
): boolean {
|
|
103
|
+
return stableStringify(left ?? null) === stableStringify(right ?? null)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sendEnvelope(output: Writable, envelope: Record<string, unknown>): void {
|
|
107
|
+
output.write(`${JSON.stringify(envelope)}\n`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sendError(output: Writable, id: number | string | null, code: number, message: string): void {
|
|
111
|
+
sendEnvelope(output, {
|
|
112
|
+
jsonrpc: '2.0',
|
|
113
|
+
id,
|
|
114
|
+
error: {
|
|
115
|
+
code,
|
|
116
|
+
message,
|
|
117
|
+
},
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function loadReplayTape(filepath: string): Promise<ProxyTape> {
|
|
122
|
+
const absolutePath = resolve(process.cwd(), filepath)
|
|
123
|
+
const tape = JSON.parse(readFileSync(absolutePath, 'utf-8')) as ProxyTape
|
|
124
|
+
if (tape.version !== 1 || !Array.isArray(tape.interactions)) {
|
|
125
|
+
throw new Error(`Replay tape is not a valid pluxx MCP tape: ${filepath}`)
|
|
126
|
+
}
|
|
127
|
+
return tape
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function writeTape(filepath: string, tape: ProxyTape): Promise<void> {
|
|
131
|
+
const absolutePath = resolve(process.cwd(), filepath)
|
|
132
|
+
mkdirSync(dirname(absolutePath), { recursive: true })
|
|
133
|
+
await Bun.write(absolutePath, `${JSON.stringify(tape, null, 2)}\n`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function serializeError(error: unknown): { code: number; message: string } {
|
|
137
|
+
if (error instanceof McpIntrospectionError) {
|
|
138
|
+
return {
|
|
139
|
+
code: error.rpcCode ?? -32000,
|
|
140
|
+
message: error.message,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
code: -32000,
|
|
146
|
+
message: error instanceof Error ? error.message : String(error),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function proxyLiveSession(client: McpClient, options: McpProxyOptions, io: ProxyIo): Promise<void> {
|
|
151
|
+
const tape: ProxyTape | null = options.recordPath
|
|
152
|
+
? {
|
|
153
|
+
version: 1,
|
|
154
|
+
source: options.source,
|
|
155
|
+
interactions: [],
|
|
156
|
+
}
|
|
157
|
+
: null
|
|
158
|
+
|
|
159
|
+
const rl = readline.createInterface({
|
|
160
|
+
input: io.input,
|
|
161
|
+
crlfDelay: Infinity,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
for await (const line of rl) {
|
|
166
|
+
if (!line.trim()) continue
|
|
167
|
+
|
|
168
|
+
let message: Record<string, unknown>
|
|
169
|
+
try {
|
|
170
|
+
message = JSON.parse(line) as Record<string, unknown>
|
|
171
|
+
} catch {
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const method = typeof message.method === 'string' ? message.method : undefined
|
|
176
|
+
if (!method) continue
|
|
177
|
+
|
|
178
|
+
const params = (typeof message.params === 'object' && message.params !== null)
|
|
179
|
+
? message.params as Record<string, unknown>
|
|
180
|
+
: undefined
|
|
181
|
+
const id = typeof message.id === 'number' || typeof message.id === 'string'
|
|
182
|
+
? message.id
|
|
183
|
+
: null
|
|
184
|
+
|
|
185
|
+
if (id === null) {
|
|
186
|
+
await client.notify(method, params)
|
|
187
|
+
tape?.interactions.push({
|
|
188
|
+
kind: 'notify',
|
|
189
|
+
method,
|
|
190
|
+
...(params ? { params } : {}),
|
|
191
|
+
})
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const result = await client.request<unknown>(method, params)
|
|
197
|
+
sendEnvelope(io.output, {
|
|
198
|
+
jsonrpc: '2.0',
|
|
199
|
+
id,
|
|
200
|
+
result,
|
|
201
|
+
})
|
|
202
|
+
tape?.interactions.push({
|
|
203
|
+
kind: 'request',
|
|
204
|
+
method,
|
|
205
|
+
...(params ? { params } : {}),
|
|
206
|
+
result,
|
|
207
|
+
})
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const serialized = serializeError(error)
|
|
210
|
+
sendError(io.output, id, serialized.code, serialized.message)
|
|
211
|
+
tape?.interactions.push({
|
|
212
|
+
kind: 'request',
|
|
213
|
+
method,
|
|
214
|
+
...(params ? { params } : {}),
|
|
215
|
+
error: serialized,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
rl.close()
|
|
221
|
+
await client.close()
|
|
222
|
+
if (tape && options.recordPath) {
|
|
223
|
+
await writeTape(options.recordPath, tape)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function replaySession(filepath: string, io: ProxyIo): Promise<void> {
|
|
229
|
+
const tape = await loadReplayTape(filepath)
|
|
230
|
+
const interactions = [...tape.interactions]
|
|
231
|
+
const rl = readline.createInterface({
|
|
232
|
+
input: io.input,
|
|
233
|
+
crlfDelay: Infinity,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
for await (const line of rl) {
|
|
238
|
+
if (!line.trim()) continue
|
|
239
|
+
|
|
240
|
+
let message: Record<string, unknown>
|
|
241
|
+
try {
|
|
242
|
+
message = JSON.parse(line) as Record<string, unknown>
|
|
243
|
+
} catch {
|
|
244
|
+
continue
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const method = typeof message.method === 'string' ? message.method : undefined
|
|
248
|
+
if (!method) continue
|
|
249
|
+
|
|
250
|
+
const params = (typeof message.params === 'object' && message.params !== null)
|
|
251
|
+
? message.params as Record<string, unknown>
|
|
252
|
+
: undefined
|
|
253
|
+
const id = typeof message.id === 'number' || typeof message.id === 'string'
|
|
254
|
+
? message.id
|
|
255
|
+
: null
|
|
256
|
+
const expected = interactions.shift()
|
|
257
|
+
|
|
258
|
+
if (!expected) {
|
|
259
|
+
if (id !== null) {
|
|
260
|
+
sendError(io.output, id, -32001, `Replay tape exhausted before handling ${method}.`)
|
|
261
|
+
}
|
|
262
|
+
continue
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (expected.kind !== (id === null ? 'notify' : 'request') || expected.method !== method || !sameParams(expected.params, params)) {
|
|
266
|
+
if (id !== null) {
|
|
267
|
+
sendError(
|
|
268
|
+
io.output,
|
|
269
|
+
id,
|
|
270
|
+
-32002,
|
|
271
|
+
`Replay mismatch. Expected ${expected.kind} ${expected.method}, received ${id === null ? 'notify' : 'request'} ${method}.`,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (id === null) {
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (expected.error) {
|
|
282
|
+
sendError(io.output, id, expected.error.code, expected.error.message)
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
sendEnvelope(io.output, {
|
|
287
|
+
jsonrpc: '2.0',
|
|
288
|
+
id,
|
|
289
|
+
result: expected.result ?? null,
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
} finally {
|
|
293
|
+
rl.close()
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function runMcpProxy(rawArgs: string[]): Promise<void> {
|
|
298
|
+
return await runMcpProxyWithIo(rawArgs, {
|
|
299
|
+
input: process.stdin,
|
|
300
|
+
output: process.stdout,
|
|
301
|
+
error: process.stderr,
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function runMcpProxyWithIo(rawArgs: string[], io: ProxyIo): Promise<void> {
|
|
306
|
+
let options: McpProxyOptions
|
|
307
|
+
try {
|
|
308
|
+
options = parseOptions(rawArgs)
|
|
309
|
+
} catch (error) {
|
|
310
|
+
io.error.write(`${error instanceof Error ? error.message : String(error)}\n\n${usage()}\n`)
|
|
311
|
+
throw new Error('Invalid MCP proxy arguments.')
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (options.replayPath) {
|
|
315
|
+
await replaySession(options.replayPath, io)
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const source = parseMcpSourceInput(options.source!)
|
|
320
|
+
const client = await createMcpClient(source)
|
|
321
|
+
await proxyLiveSession(client, options, io)
|
|
322
|
+
}
|
package/src/cli/migrate.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import { resolve, basename
|
|
1
|
+
import { resolve, basename } from 'path'
|
|
2
2
|
import { existsSync, readdirSync, mkdirSync, cpSync, readFileSync } from 'fs'
|
|
3
|
+
import {
|
|
4
|
+
MCP_SCAFFOLD_METADATA_PATH,
|
|
5
|
+
MCP_TAXONOMY_PATH,
|
|
6
|
+
type McpScaffoldMetadata,
|
|
7
|
+
type PersistedSkill,
|
|
8
|
+
} from './init-from-mcp'
|
|
9
|
+
import type { McpServer } from '../schema'
|
|
3
10
|
|
|
4
11
|
type DetectedPlatform = 'claude-code' | 'cursor' | 'codex' | 'opencode'
|
|
5
12
|
|
|
@@ -55,6 +62,7 @@ interface MigrateResult {
|
|
|
55
62
|
scripts: boolean
|
|
56
63
|
assets: boolean
|
|
57
64
|
}
|
|
65
|
+
persistedSkills: PersistedSkill[]
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
// ── Platform Detection ──────────────────────────────────────────
|
|
@@ -347,6 +355,237 @@ function detectDirectories(pluginDir: string) {
|
|
|
347
355
|
}
|
|
348
356
|
}
|
|
349
357
|
|
|
358
|
+
function toKebabCase(value: string): string {
|
|
359
|
+
return value
|
|
360
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
361
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
362
|
+
.replace(/^-+|-+$/g, '')
|
|
363
|
+
.toLowerCase()
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function titleCaseFromDirName(value: string): string {
|
|
367
|
+
return value
|
|
368
|
+
.split(/[-_]+/)
|
|
369
|
+
.filter(Boolean)
|
|
370
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
371
|
+
.join(' ')
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function firstHeading(content: string): string | undefined {
|
|
375
|
+
for (const line of content.split(/\r?\n/)) {
|
|
376
|
+
const trimmed = line.trim()
|
|
377
|
+
const match = trimmed.match(/^#\s+(.+)$/)
|
|
378
|
+
if (match) {
|
|
379
|
+
return match[1].trim()
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return undefined
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function extractFrontmatterField(content: string, key: 'name' | 'description'): string | undefined {
|
|
386
|
+
const lines = content.split(/\r?\n/)
|
|
387
|
+
if (lines[0]?.trim() !== '---') return undefined
|
|
388
|
+
|
|
389
|
+
let endIndex = -1
|
|
390
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
391
|
+
if (lines[i].trim() === '---') {
|
|
392
|
+
endIndex = i
|
|
393
|
+
break
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (endIndex === -1) return undefined
|
|
398
|
+
|
|
399
|
+
for (const line of lines.slice(1, endIndex)) {
|
|
400
|
+
const match = line.match(new RegExp(`^${key}:\\s*(.*)$`))
|
|
401
|
+
if (!match) continue
|
|
402
|
+
return match[1].trim().replace(/^['"]|['"]$/g, '')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return undefined
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function readMigratedSkills(pluginDir: string, dirs: MigrateResult['directories']): PersistedSkill[] {
|
|
409
|
+
const skills: PersistedSkill[] = []
|
|
410
|
+
|
|
411
|
+
if (dirs.skills) {
|
|
412
|
+
const skillsDir = resolve(pluginDir, 'skills')
|
|
413
|
+
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
|
414
|
+
|
|
415
|
+
for (const entry of entries) {
|
|
416
|
+
if (!entry.isDirectory()) continue
|
|
417
|
+
const dirName = entry.name
|
|
418
|
+
const skillPath = resolve(skillsDir, dirName, 'SKILL.md')
|
|
419
|
+
let title = titleCaseFromDirName(dirName)
|
|
420
|
+
let description: string | undefined
|
|
421
|
+
|
|
422
|
+
if (existsSync(skillPath)) {
|
|
423
|
+
const content = readFileSync(skillPath, 'utf-8')
|
|
424
|
+
title = extractFrontmatterField(content, 'name')
|
|
425
|
+
?? firstHeading(content)
|
|
426
|
+
?? title
|
|
427
|
+
description = extractFrontmatterField(content, 'description')
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
skills.push({
|
|
431
|
+
dirName,
|
|
432
|
+
title,
|
|
433
|
+
description,
|
|
434
|
+
toolNames: [],
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (skills.length === 0 && dirs.commands) {
|
|
440
|
+
const commandsDir = resolve(pluginDir, 'commands')
|
|
441
|
+
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
|
442
|
+
|
|
443
|
+
for (const entry of entries) {
|
|
444
|
+
if (!entry.isFile() || !entry.name.endsWith('.md')) continue
|
|
445
|
+
const dirName = toKebabCase(entry.name.replace(/\.md$/, '')) || 'command'
|
|
446
|
+
const content = readFileSync(resolve(commandsDir, entry.name), 'utf-8')
|
|
447
|
+
skills.push({
|
|
448
|
+
dirName,
|
|
449
|
+
title: firstHeading(content) ?? titleCaseFromDirName(dirName),
|
|
450
|
+
description: extractFrontmatterField(content, 'description'),
|
|
451
|
+
toolNames: [],
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return skills.sort((a, b) => a.dirName.localeCompare(b.dirName))
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function primarySource(result: MigrateResult): McpServer {
|
|
460
|
+
const [serverName, server] = Object.entries(result.mcp)[0] ?? []
|
|
461
|
+
const auth = normalizeMigrateAuth(server?.auth)
|
|
462
|
+
if (server) {
|
|
463
|
+
if (server.transport === 'stdio') {
|
|
464
|
+
return {
|
|
465
|
+
transport: 'stdio',
|
|
466
|
+
command: server.command ?? 'TODO_MCP_COMMAND',
|
|
467
|
+
args: server.args ?? [],
|
|
468
|
+
...(server.env ? { env: server.env } : {}),
|
|
469
|
+
...(auth ? { auth } : {}),
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (server.transport === 'sse') {
|
|
474
|
+
return {
|
|
475
|
+
transport: 'sse',
|
|
476
|
+
url: server.url ?? `https://example.com/${serverName ?? 'mcp'}`,
|
|
477
|
+
...(auth ? { auth } : {}),
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
transport: 'http',
|
|
483
|
+
url: server.url ?? `https://example.com/${serverName ?? 'mcp'}`,
|
|
484
|
+
...(auth ? { auth } : {}),
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return {
|
|
489
|
+
transport: 'stdio',
|
|
490
|
+
command: 'TODO_MCP_COMMAND',
|
|
491
|
+
args: [],
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function normalizeMigrateAuth(auth: ParsedMcp[string]['auth']) {
|
|
496
|
+
if (!auth) return undefined
|
|
497
|
+
if (auth.type === 'none') {
|
|
498
|
+
return { type: 'none' as const }
|
|
499
|
+
}
|
|
500
|
+
if (auth.type === 'bearer' && auth.envVar) {
|
|
501
|
+
return {
|
|
502
|
+
type: 'bearer' as const,
|
|
503
|
+
envVar: auth.envVar,
|
|
504
|
+
headerName: auth.headerName ?? 'Authorization',
|
|
505
|
+
headerTemplate: auth.headerTemplate ?? 'Bearer ${value}',
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (auth.type === 'header' && auth.envVar && auth.headerName) {
|
|
509
|
+
return {
|
|
510
|
+
type: 'header' as const,
|
|
511
|
+
envVar: auth.envVar,
|
|
512
|
+
headerName: auth.headerName,
|
|
513
|
+
headerTemplate: auth.headerTemplate ?? '${value}',
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
return undefined
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function buildMigratedScaffoldMetadata(result: MigrateResult, outputDir: string): McpScaffoldMetadata {
|
|
520
|
+
const pluginName = result.manifest.name ?? 'my-plugin'
|
|
521
|
+
const displayName = result.manifest.name ? titleCaseFromDirName(result.manifest.name) : 'Migrated Plugin'
|
|
522
|
+
const description = result.manifest.description ?? 'Migrated plugin scaffold.'
|
|
523
|
+
const generatedHookEvents = Object.keys(result.hooks)
|
|
524
|
+
const managedFiles = [
|
|
525
|
+
...(result.instructions ? [result.instructions.replace(/^\.\//, '')] : []),
|
|
526
|
+
...(['skills', 'commands', 'agents', 'scripts', 'assets'] as const).flatMap((dir) => {
|
|
527
|
+
if (!result.directories[dir]) return []
|
|
528
|
+
const baseDir = dir
|
|
529
|
+
const dirPath = resolve(outputDir, baseDir)
|
|
530
|
+
if (!existsSync(dirPath)) return []
|
|
531
|
+
const entries = readdirSync(dirPath, { withFileTypes: true })
|
|
532
|
+
const files: string[] = []
|
|
533
|
+
for (const entry of entries) {
|
|
534
|
+
if (entry.isDirectory()) {
|
|
535
|
+
const nestedDir = resolve(dirPath, entry.name)
|
|
536
|
+
for (const nested of readdirSync(nestedDir, { withFileTypes: true })) {
|
|
537
|
+
if (nested.isFile()) {
|
|
538
|
+
files.push(`${baseDir}/${entry.name}/${nested.name}`)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
if (entry.isFile()) {
|
|
544
|
+
files.push(`${baseDir}/${entry.name}`)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return files
|
|
548
|
+
}),
|
|
549
|
+
'pluxx.config.ts',
|
|
550
|
+
MCP_TAXONOMY_PATH,
|
|
551
|
+
MCP_SCAFFOLD_METADATA_PATH,
|
|
552
|
+
]
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
version: 1,
|
|
556
|
+
source: primarySource(result),
|
|
557
|
+
serverInfo: {
|
|
558
|
+
name: pluginName,
|
|
559
|
+
title: displayName,
|
|
560
|
+
version: result.manifest.version ?? '0.1.0',
|
|
561
|
+
description,
|
|
562
|
+
...(result.manifest.repository ? { websiteUrl: result.manifest.repository } : {}),
|
|
563
|
+
},
|
|
564
|
+
settings: {
|
|
565
|
+
pluginName,
|
|
566
|
+
displayName,
|
|
567
|
+
description,
|
|
568
|
+
skillGrouping: 'workflow',
|
|
569
|
+
requestedHookMode: generatedHookEvents.length > 0 ? 'safe' : 'none',
|
|
570
|
+
generatedHookMode: generatedHookEvents.length > 0 ? 'safe' : 'none',
|
|
571
|
+
generatedHookEvents,
|
|
572
|
+
runtimeAuthMode: 'inline',
|
|
573
|
+
},
|
|
574
|
+
userConfig: [],
|
|
575
|
+
tools: [],
|
|
576
|
+
resources: [],
|
|
577
|
+
resourceTemplates: [],
|
|
578
|
+
prompts: [],
|
|
579
|
+
skills: result.persistedSkills.map((skill) => ({
|
|
580
|
+
dirName: skill.dirName,
|
|
581
|
+
title: skill.title,
|
|
582
|
+
description: skill.description,
|
|
583
|
+
toolNames: skill.toolNames,
|
|
584
|
+
})),
|
|
585
|
+
managedFiles: [...new Set(managedFiles)].sort(),
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
350
589
|
// ── Copy Directories ────────────────────────────────────────────
|
|
351
590
|
|
|
352
591
|
function copyDirectories(
|
|
@@ -559,12 +798,16 @@ export async function migrate(inputPath: string): Promise<void> {
|
|
|
559
798
|
|
|
560
799
|
// 6. Detect directories
|
|
561
800
|
const directories = detectDirectories(pluginDir)
|
|
801
|
+
const persistedSkills = readMigratedSkills(pluginDir, directories)
|
|
562
802
|
const dirNames = Object.entries(directories)
|
|
563
803
|
.filter(([_, exists]) => exists)
|
|
564
804
|
.map(([name]) => name)
|
|
565
805
|
if (dirNames.length > 0) {
|
|
566
806
|
console.log(` directories: ${dirNames.join(', ')}`)
|
|
567
807
|
}
|
|
808
|
+
if (persistedSkills.length > 0) {
|
|
809
|
+
console.log(` migrated skills: ${persistedSkills.map((skill) => skill.dirName).join(', ')}`)
|
|
810
|
+
}
|
|
568
811
|
|
|
569
812
|
// 7. Build result
|
|
570
813
|
const result: MigrateResult = {
|
|
@@ -574,6 +817,7 @@ export async function migrate(inputPath: string): Promise<void> {
|
|
|
574
817
|
hooks,
|
|
575
818
|
instructions,
|
|
576
819
|
directories,
|
|
820
|
+
persistedSkills,
|
|
577
821
|
}
|
|
578
822
|
|
|
579
823
|
// 8. Generate config
|
|
@@ -606,9 +850,18 @@ export async function migrate(inputPath: string): Promise<void> {
|
|
|
606
850
|
}
|
|
607
851
|
}
|
|
608
852
|
|
|
853
|
+
// 11. Create synthetic migration metadata/taxonomy so Agent Mode and evals work.
|
|
854
|
+
const taxonomyPath = resolve(outputDir, MCP_TAXONOMY_PATH)
|
|
855
|
+
const metadataPath = resolve(outputDir, MCP_SCAFFOLD_METADATA_PATH)
|
|
856
|
+
mkdirSync(resolve(outputDir, '.pluxx'), { recursive: true })
|
|
857
|
+
await Bun.write(taxonomyPath, `${JSON.stringify(result.persistedSkills, null, 2)}\n`)
|
|
858
|
+
await Bun.write(metadataPath, `${JSON.stringify(buildMigratedScaffoldMetadata(result, outputDir), null, 2)}\n`)
|
|
859
|
+
console.log(`Generated: ${MCP_TAXONOMY_PATH}, ${MCP_SCAFFOLD_METADATA_PATH}`)
|
|
860
|
+
|
|
609
861
|
console.log('')
|
|
610
862
|
console.log('Migration complete! Next steps:')
|
|
611
863
|
console.log(' 1. Review pluxx.config.ts and fill in any TODOs')
|
|
612
|
-
console.log(' 2. Run: pluxx
|
|
613
|
-
console.log(' 3. Run: pluxx
|
|
864
|
+
console.log(' 2. Run: pluxx doctor')
|
|
865
|
+
console.log(' 3. Run: pluxx eval')
|
|
866
|
+
console.log(' 4. Run: pluxx build')
|
|
614
867
|
}
|
package/src/cli/sync-from-mcp.ts
CHANGED
|
@@ -142,6 +142,13 @@ export async function syncFromMcp(options: SyncFromMcpOptions): Promise<SyncFrom
|
|
|
142
142
|
const after = readFileSync(currentPath, 'utf-8')
|
|
143
143
|
return before !== after
|
|
144
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
|
+
}
|
|
145
152
|
|
|
146
153
|
return {
|
|
147
154
|
source,
|
|
@@ -216,6 +223,8 @@ export async function applyPersistedTaxonomy(rootDir: string): Promise<void> {
|
|
|
216
223
|
writeFileSync(resolveWithinRoot(rootDir, file), previousInstructions, 'utf-8')
|
|
217
224
|
}
|
|
218
225
|
}
|
|
226
|
+
|
|
227
|
+
invalidateSavedAgentPack(rootDir)
|
|
219
228
|
}
|
|
220
229
|
|
|
221
230
|
export async function planSyncFromMcp(options: SyncFromMcpOptions): Promise<SyncFromMcpResult> {
|
|
@@ -309,6 +318,20 @@ function pruneEmptyDirectories(rootDir: string, startDir: string): void {
|
|
|
309
318
|
}
|
|
310
319
|
}
|
|
311
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
|
+
|
|
312
335
|
/**
|
|
313
336
|
* Detect tool renames by comparing old and new tool lists.
|
|
314
337
|
* Returns a map of oldName -> newName for likely renames.
|