padrone 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +94 -0
- package/README.md +105 -284
- package/dist/{args-DFEI7_G_.mjs → args-D5PNDyNu.mjs} +46 -21
- package/dist/args-D5PNDyNu.mjs.map +1 -0
- package/dist/chunk-CjcI7cDX.mjs +15 -0
- package/dist/codegen/index.d.mts +28 -3
- package/dist/codegen/index.d.mts.map +1 -1
- package/dist/codegen/index.mjs +169 -19
- package/dist/codegen/index.mjs.map +1 -1
- package/dist/command-utils-B1D-HqCd.mjs +1117 -0
- package/dist/command-utils-B1D-HqCd.mjs.map +1 -0
- package/dist/completion.d.mts +1 -1
- package/dist/completion.d.mts.map +1 -1
- package/dist/completion.mjs +77 -29
- package/dist/completion.mjs.map +1 -1
- package/dist/docs/index.d.mts +22 -2
- package/dist/docs/index.d.mts.map +1 -1
- package/dist/docs/index.mjs +94 -7
- package/dist/docs/index.mjs.map +1 -1
- package/dist/errors-BiVrBgi6.mjs +114 -0
- package/dist/errors-BiVrBgi6.mjs.map +1 -0
- package/dist/{formatter-XroimS3Q.d.mts → formatter-DtHzbP22.d.mts} +35 -5
- package/dist/formatter-DtHzbP22.d.mts.map +1 -0
- package/dist/help-bbmu9-qd.mjs +735 -0
- package/dist/help-bbmu9-qd.mjs.map +1 -0
- package/dist/index.d.mts +32 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +495 -267
- package/dist/index.mjs.map +1 -1
- package/dist/mcp-mLWIdUIu.mjs +379 -0
- package/dist/mcp-mLWIdUIu.mjs.map +1 -0
- package/dist/serve-B0u43DK7.mjs +404 -0
- package/dist/serve-B0u43DK7.mjs.map +1 -0
- package/dist/stream-BcC146Ud.mjs +56 -0
- package/dist/stream-BcC146Ud.mjs.map +1 -0
- package/dist/test.d.mts +1 -1
- package/dist/test.mjs +4 -15
- package/dist/test.mjs.map +1 -1
- package/dist/{types-BS7RP5Ls.d.mts → types-Ch8Mk6Qb.d.mts} +311 -63
- package/dist/types-Ch8Mk6Qb.d.mts.map +1 -0
- package/dist/{update-check-EbNDkzyV.mjs → update-check-CFX1FV3v.mjs} +2 -2
- package/dist/{update-check-EbNDkzyV.mjs.map → update-check-CFX1FV3v.mjs.map} +1 -1
- package/dist/zod.d.mts +32 -0
- package/dist/zod.d.mts.map +1 -0
- package/dist/zod.mjs +50 -0
- package/dist/zod.mjs.map +1 -0
- package/package.json +10 -2
- package/src/args.ts +76 -44
- package/src/cli/docs.ts +1 -7
- package/src/cli/doctor.ts +195 -10
- package/src/cli/index.ts +1 -1
- package/src/cli/init.ts +2 -3
- package/src/cli/link.ts +2 -2
- package/src/codegen/discovery.ts +80 -28
- package/src/codegen/index.ts +2 -1
- package/src/codegen/parsers/bash.ts +179 -0
- package/src/codegen/schema-to-code.ts +2 -1
- package/src/colorizer.ts +126 -13
- package/src/command-utils.ts +401 -23
- package/src/completion.ts +120 -47
- package/src/create.ts +483 -130
- package/src/docs/index.ts +122 -8
- package/src/formatter.ts +173 -125
- package/src/help.ts +46 -12
- package/src/index.ts +29 -1
- package/src/interactive.ts +45 -4
- package/src/mcp.ts +390 -0
- package/src/repl-loop.ts +16 -3
- package/src/runtime.ts +195 -2
- package/src/serve.ts +442 -0
- package/src/stream.ts +75 -0
- package/src/test.ts +7 -16
- package/src/type-utils.ts +28 -4
- package/src/types.ts +212 -30
- package/src/wrap.ts +23 -25
- package/src/zod.ts +50 -0
- package/dist/args-DFEI7_G_.mjs.map +0 -1
- package/dist/chunk-y_GBKt04.mjs +0 -5
- package/dist/formatter-XroimS3Q.d.mts.map +0 -1
- package/dist/help-CgGP7hQU.mjs +0 -1229
- package/dist/help-CgGP7hQU.mjs.map +0 -1
- package/dist/types-BS7RP5Ls.d.mts.map +0 -1
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { buildInputSchema, collectEndpoints, serializeArgsToFlags } from './command-utils.ts';
|
|
2
|
+
import { generateHelp } from './help.ts';
|
|
3
|
+
import type { AnyPadroneCommand, AnyPadroneProgram } from './types.ts';
|
|
4
|
+
|
|
5
|
+
export type PadroneMcpPreferences = {
|
|
6
|
+
/** Server name. Defaults to the program name. */
|
|
7
|
+
name?: string;
|
|
8
|
+
/** Server version. Defaults to the program version. */
|
|
9
|
+
version?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Transport mode.
|
|
12
|
+
* - `'http'` — Start a Streamable HTTP server (default). Responds with `application/json` or `text/event-stream` based on the client's `Accept` header. Use `port` and `host` to configure.
|
|
13
|
+
* - `'stdio'` — Communicate over stdin/stdout with newline-delimited JSON.
|
|
14
|
+
*/
|
|
15
|
+
transport?: 'http' | 'stdio';
|
|
16
|
+
/** HTTP port. Defaults to `3000`. Only used with `transport: 'http'`. */
|
|
17
|
+
port?: number;
|
|
18
|
+
/** HTTP host. Defaults to `'127.0.0.1'`. Only used with `transport: 'http'`. */
|
|
19
|
+
host?: string;
|
|
20
|
+
/** Base path for the MCP endpoint. Defaults to `'/mcp'`. Only used with `transport: 'http'`. */
|
|
21
|
+
basePath?: string;
|
|
22
|
+
/** CORS allowed origin. Defaults to `'*'`. Set to a specific origin or `false` to disable CORS headers. Only used with HTTP transports. */
|
|
23
|
+
cors?: string | false;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const PROTOCOL_VERSION = '2025-11-25';
|
|
27
|
+
|
|
28
|
+
type JsonRpcRequest = {
|
|
29
|
+
jsonrpc: '2.0';
|
|
30
|
+
id?: string | number;
|
|
31
|
+
method: string;
|
|
32
|
+
params?: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type JsonRpcResponse = {
|
|
36
|
+
jsonrpc: '2.0';
|
|
37
|
+
id: string | number | null;
|
|
38
|
+
result?: unknown;
|
|
39
|
+
error?: { code: number; message: string; data?: unknown };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Convert an endpoint dot-path to a valid MCP tool name. Spec allows: [A-Za-z0-9_\-\.] */
|
|
43
|
+
function toToolName(path: string): string {
|
|
44
|
+
return path.replace(/\s+/g, '.');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Convert a tool name back to a command path (dot → space). */
|
|
48
|
+
function toCommandPath(toolName: string): string {
|
|
49
|
+
return toolName.replace(/\./g, ' ');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Build MCP tool annotations from a command's metadata. */
|
|
53
|
+
function buildAnnotations(cmd: AnyPadroneCommand) {
|
|
54
|
+
if (cmd.mutation == null) return undefined;
|
|
55
|
+
return {
|
|
56
|
+
destructiveHint: cmd.mutation || undefined,
|
|
57
|
+
readOnlyHint: cmd.mutation === false || undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build an MCP tool definition from a command. */
|
|
62
|
+
function buildToolDefinition(name: string, cmd: AnyPadroneCommand) {
|
|
63
|
+
return {
|
|
64
|
+
name: toToolName(name),
|
|
65
|
+
title: cmd.title ?? undefined,
|
|
66
|
+
description: cmd.description || cmd.title || `Run the "${name}" command`,
|
|
67
|
+
inputSchema: buildInputSchema(cmd),
|
|
68
|
+
annotations: buildAnnotations(cmd),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Create the MCP request handler. Returns an async function that processes a JSON-RPC request and returns a response (or undefined for notifications). */
|
|
73
|
+
export function createMcpHandler(
|
|
74
|
+
existingCommand: AnyPadroneCommand,
|
|
75
|
+
evalCommand: AnyPadroneProgram['eval'],
|
|
76
|
+
prefs?: PadroneMcpPreferences,
|
|
77
|
+
) {
|
|
78
|
+
const serverName = prefs?.name ?? existingCommand.name;
|
|
79
|
+
const serverVersion = prefs?.version ?? existingCommand.version ?? '0.0.0';
|
|
80
|
+
|
|
81
|
+
const rootTools = collectEndpoints(existingCommand.commands, '');
|
|
82
|
+
if (existingCommand.action || existingCommand.argsSchema) {
|
|
83
|
+
rootTools.unshift({ name: '', command: existingCommand });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const toolMap = new Map(rootTools.map((t) => [toToolName(t.name), t]));
|
|
87
|
+
|
|
88
|
+
const helpToolName = 'help';
|
|
89
|
+
const helpToolDef = {
|
|
90
|
+
name: helpToolName,
|
|
91
|
+
title: 'Help',
|
|
92
|
+
description: `Show help for the "${serverName}" program or a specific command`,
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object' as const,
|
|
95
|
+
properties: { command: { type: 'string', description: 'Command name to get help for (omit for program help)' } },
|
|
96
|
+
additionalProperties: false,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return async function handleRequest(req: JsonRpcRequest): Promise<JsonRpcResponse | undefined> {
|
|
101
|
+
const { id, method, params } = req;
|
|
102
|
+
|
|
103
|
+
switch (method) {
|
|
104
|
+
case 'initialize':
|
|
105
|
+
return {
|
|
106
|
+
jsonrpc: '2.0',
|
|
107
|
+
id: id ?? null,
|
|
108
|
+
result: {
|
|
109
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
110
|
+
capabilities: { tools: {} },
|
|
111
|
+
serverInfo: { name: serverName, version: serverVersion },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
case 'notifications/initialized':
|
|
116
|
+
case 'notifications/cancelled':
|
|
117
|
+
return undefined;
|
|
118
|
+
|
|
119
|
+
case 'ping':
|
|
120
|
+
return { jsonrpc: '2.0', id: id ?? null, result: {} };
|
|
121
|
+
|
|
122
|
+
case 'tools/list': {
|
|
123
|
+
const tools = [...rootTools.map((t) => buildToolDefinition(t.name, t.command)), helpToolDef];
|
|
124
|
+
return { jsonrpc: '2.0', id: id ?? null, result: { tools } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case 'tools/call': {
|
|
128
|
+
const toolName = params?.name as string;
|
|
129
|
+
const args = (params?.arguments ?? {}) as Record<string, unknown>;
|
|
130
|
+
|
|
131
|
+
// Built-in help tool
|
|
132
|
+
if (toolName === helpToolName) {
|
|
133
|
+
const cmdName = args.command as string | undefined;
|
|
134
|
+
const targetCmd = cmdName ? rootTools.find((t) => t.name === cmdName || toToolName(t.name) === cmdName)?.command : undefined;
|
|
135
|
+
const helpText = generateHelp(existingCommand, targetCmd ?? existingCommand, { format: 'text', detail: 'full' });
|
|
136
|
+
return {
|
|
137
|
+
jsonrpc: '2.0',
|
|
138
|
+
id: id ?? null,
|
|
139
|
+
result: { content: [{ type: 'text', text: helpText }], isError: false },
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const tool = toolMap.get(toolName);
|
|
144
|
+
if (!tool) {
|
|
145
|
+
return {
|
|
146
|
+
jsonrpc: '2.0',
|
|
147
|
+
id: id ?? null,
|
|
148
|
+
error: { code: -32602, message: `Unknown tool: ${toolName}` },
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Build command string: convert tool name back to command path + serialize args as flags
|
|
153
|
+
const commandPath = toCommandPath(tool.name);
|
|
154
|
+
const argParts = serializeArgsToFlags(args);
|
|
155
|
+
const input = [commandPath, ...argParts].filter(Boolean).join(' ') || undefined;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const output: string[] = [];
|
|
159
|
+
const errors: string[] = [];
|
|
160
|
+
const result = await evalCommand(input as any, {
|
|
161
|
+
autoOutput: false,
|
|
162
|
+
runtime: {
|
|
163
|
+
output: (...outArgs: unknown[]) => output.push(outArgs.map(String).join(' ')),
|
|
164
|
+
error: (text: string) => errors.push(text),
|
|
165
|
+
interactive: 'unsupported',
|
|
166
|
+
format: 'text',
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const content: { type: string; text: string }[] = [];
|
|
171
|
+
|
|
172
|
+
if (result.error) {
|
|
173
|
+
const errorMsg = result.error instanceof Error ? result.error.message : String(result.error);
|
|
174
|
+
if (errors.length) content.push({ type: 'text', text: errors.join('\n') });
|
|
175
|
+
content.push({ type: 'text', text: errorMsg });
|
|
176
|
+
return { jsonrpc: '2.0', id: id ?? null, result: { content, isError: true } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (result.argsResult?.issues) {
|
|
180
|
+
const issueMessages = result.argsResult.issues.map((i: any) => `${i.path?.join('.') || 'root'}: ${i.message}`).join('\n');
|
|
181
|
+
content.push({ type: 'text', text: `Validation error:\n${issueMessages}` });
|
|
182
|
+
return { jsonrpc: '2.0', id: id ?? null, result: { content, isError: true } };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (output.length) content.push({ type: 'text', text: output.join('\n') });
|
|
186
|
+
if (result.result !== undefined && result.result !== null) {
|
|
187
|
+
const resultText = typeof result.result === 'string' ? result.result : JSON.stringify(result.result, null, 2);
|
|
188
|
+
content.push({ type: 'text', text: resultText });
|
|
189
|
+
}
|
|
190
|
+
if (content.length === 0) content.push({ type: 'text', text: 'Done.' });
|
|
191
|
+
return { jsonrpc: '2.0', id: id ?? null, result: { content, isError: false } };
|
|
192
|
+
} catch (err) {
|
|
193
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
194
|
+
return {
|
|
195
|
+
jsonrpc: '2.0',
|
|
196
|
+
id: id ?? null,
|
|
197
|
+
result: { content: [{ type: 'text', text: errorMsg }], isError: true },
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
default: {
|
|
203
|
+
if (id !== undefined) {
|
|
204
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };
|
|
205
|
+
}
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** stdio transport: newline-delimited JSON per 2025-11-25 spec. */
|
|
213
|
+
async function startStdioTransport(handleRequest: (req: JsonRpcRequest) => Promise<JsonRpcResponse | undefined>): Promise<void> {
|
|
214
|
+
const { stdin, stdout } = await import('node:process');
|
|
215
|
+
const { createInterface } = await import('node:readline');
|
|
216
|
+
|
|
217
|
+
function send(msg: JsonRpcResponse) {
|
|
218
|
+
stdout.write(`${JSON.stringify(msg)}\n`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
|
|
222
|
+
|
|
223
|
+
for await (const line of rl) {
|
|
224
|
+
if (!line.trim()) continue;
|
|
225
|
+
try {
|
|
226
|
+
const req = JSON.parse(line) as JsonRpcRequest;
|
|
227
|
+
const res = await handleRequest(req);
|
|
228
|
+
if (res) send(res);
|
|
229
|
+
} catch {
|
|
230
|
+
// Ignore malformed JSON
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Streamable HTTP transport per 2025-11-25 spec. Responds with JSON or SSE based on client's Accept header. */
|
|
236
|
+
async function startHttpTransport(
|
|
237
|
+
handleRequest: (req: JsonRpcRequest) => Promise<JsonRpcResponse | undefined>,
|
|
238
|
+
prefs: PadroneMcpPreferences,
|
|
239
|
+
log: (msg: string) => void,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const http = await import('node:http');
|
|
242
|
+
const crypto = await import('node:crypto');
|
|
243
|
+
|
|
244
|
+
const port = prefs.port ?? 3000;
|
|
245
|
+
const host = prefs.host ?? '127.0.0.1';
|
|
246
|
+
const endpoint = prefs.basePath ?? '/mcp';
|
|
247
|
+
|
|
248
|
+
// Session management
|
|
249
|
+
let sessionId: string | undefined;
|
|
250
|
+
let negotiatedVersion: string | undefined;
|
|
251
|
+
|
|
252
|
+
const corsOrigin = prefs.cors !== false ? (prefs.cors ?? '*') : undefined;
|
|
253
|
+
|
|
254
|
+
const server = http.createServer(async (req, res) => {
|
|
255
|
+
// CORS headers
|
|
256
|
+
if (corsOrigin) {
|
|
257
|
+
res.setHeader('Access-Control-Allow-Origin', corsOrigin);
|
|
258
|
+
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, DELETE, OPTIONS');
|
|
259
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, MCP-Session-Id, MCP-Protocol-Version');
|
|
260
|
+
res.setHeader('Access-Control-Expose-Headers', 'MCP-Session-Id');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (req.method === 'OPTIONS') {
|
|
264
|
+
res.writeHead(corsOrigin ? 204 : 405);
|
|
265
|
+
res.end();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (req.url !== endpoint) {
|
|
270
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
271
|
+
res.end(JSON.stringify({ error: 'Not found' }));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// DELETE: terminate session
|
|
276
|
+
if (req.method === 'DELETE') {
|
|
277
|
+
const reqSessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
278
|
+
if (sessionId && reqSessionId === sessionId) {
|
|
279
|
+
sessionId = undefined;
|
|
280
|
+
negotiatedVersion = undefined;
|
|
281
|
+
res.writeHead(200);
|
|
282
|
+
res.end();
|
|
283
|
+
} else {
|
|
284
|
+
res.writeHead(404);
|
|
285
|
+
res.end();
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// GET: SSE stream (not implemented — return 405)
|
|
291
|
+
if (req.method === 'GET') {
|
|
292
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
293
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32601, message: 'SSE stream not supported' } }));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (req.method !== 'POST') {
|
|
298
|
+
res.writeHead(405);
|
|
299
|
+
res.end();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Validate session ID on non-initialize requests
|
|
304
|
+
const reqSessionId = req.headers['mcp-session-id'] as string | undefined;
|
|
305
|
+
if (sessionId && reqSessionId && reqSessionId !== sessionId) {
|
|
306
|
+
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
307
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Invalid session' } }));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Validate MCP-Protocol-Version header on post-init requests
|
|
312
|
+
const reqProtocolVersion = req.headers['mcp-protocol-version'] as string | undefined;
|
|
313
|
+
if (negotiatedVersion && reqProtocolVersion && reqProtocolVersion !== negotiatedVersion) {
|
|
314
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
315
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'Protocol version mismatch' } }));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Read request body
|
|
320
|
+
const chunks: Buffer[] = [];
|
|
321
|
+
for await (const chunk of req) {
|
|
322
|
+
chunks.push(chunk);
|
|
323
|
+
}
|
|
324
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
325
|
+
|
|
326
|
+
let rpcRequest: JsonRpcRequest;
|
|
327
|
+
try {
|
|
328
|
+
rpcRequest = JSON.parse(body);
|
|
329
|
+
} catch {
|
|
330
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
331
|
+
res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const response = await handleRequest(rpcRequest);
|
|
336
|
+
|
|
337
|
+
// On initialize response: create session and set header
|
|
338
|
+
if (rpcRequest.method === 'initialize' && response?.result) {
|
|
339
|
+
sessionId = crypto.randomUUID();
|
|
340
|
+
negotiatedVersion = PROTOCOL_VERSION;
|
|
341
|
+
res.setHeader('MCP-Session-Id', sessionId);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (response) {
|
|
345
|
+
const accept = req.headers.accept ?? '';
|
|
346
|
+
if (accept.includes('text/event-stream')) {
|
|
347
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
348
|
+
res.write(`event: message\ndata: ${JSON.stringify(response)}\n\n`);
|
|
349
|
+
res.end();
|
|
350
|
+
} else {
|
|
351
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
352
|
+
res.end(JSON.stringify(response));
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
// Notification or response from client — no body
|
|
356
|
+
res.writeHead(202);
|
|
357
|
+
res.end();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
return new Promise<void>((resolve, reject) => {
|
|
362
|
+
server.listen(port, host, () => {
|
|
363
|
+
log(`MCP server listening on http://${host}:${port}${endpoint}`);
|
|
364
|
+
});
|
|
365
|
+
server.on('error', reject);
|
|
366
|
+
const onSignal = () => {
|
|
367
|
+
server.close(() => resolve());
|
|
368
|
+
};
|
|
369
|
+
process.on('SIGINT', onSignal);
|
|
370
|
+
process.on('SIGTERM', onSignal);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export async function startMcpServer(
|
|
375
|
+
_program: AnyPadroneProgram,
|
|
376
|
+
existingCommand: AnyPadroneCommand,
|
|
377
|
+
evalCommand: AnyPadroneProgram['eval'],
|
|
378
|
+
prefs?: PadroneMcpPreferences,
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
const handleRequest = createMcpHandler(existingCommand, evalCommand, prefs);
|
|
381
|
+
const transport = prefs?.transport ?? 'http';
|
|
382
|
+
|
|
383
|
+
if (transport === 'stdio') {
|
|
384
|
+
return startStdioTransport(handleRequest);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const { getCommandRuntime } = await import('./command-utils.ts');
|
|
388
|
+
const runtime = getCommandRuntime(existingCommand);
|
|
389
|
+
return startHttpTransport(handleRequest, prefs ?? {}, (msg) => runtime.error(msg));
|
|
390
|
+
}
|
package/src/repl-loop.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type ReplDeps = {
|
|
|
13
13
|
/**
|
|
14
14
|
* Creates a REPL async iterable for running commands interactively.
|
|
15
15
|
*/
|
|
16
|
-
export function createReplIterator(deps: ReplDeps, options?: PadroneReplPreferences): AsyncIterable<any> {
|
|
16
|
+
export function createReplIterator(deps: ReplDeps, options?: PadroneReplPreferences): AsyncIterable<any> & { drain: () => Promise<any> } {
|
|
17
17
|
const { existingCommand, evalCommand, replActiveRef } = deps;
|
|
18
18
|
|
|
19
19
|
if (replActiveRef.value) {
|
|
@@ -291,7 +291,10 @@ export function createReplIterator(deps: ReplDeps, options?: PadroneReplPreferen
|
|
|
291
291
|
try {
|
|
292
292
|
const replEvalPrefs: PadroneEvalPreferences | undefined = options?.autoOutput === false ? { autoOutput: false } : undefined;
|
|
293
293
|
const result = await evalCommand(scopedInput, replEvalPrefs);
|
|
294
|
-
if (result.
|
|
294
|
+
if (result.error) {
|
|
295
|
+
const msg = result.error instanceof Error ? result.error.message : String(result.error);
|
|
296
|
+
runtime.error(prefixLines ? prefixLines(msg) : msg);
|
|
297
|
+
} else if (result.argsResult?.issues) {
|
|
295
298
|
const issueMessages = result.argsResult.issues
|
|
296
299
|
.map((i: StandardSchemaV1.Issue) => ` - ${i.path?.join('.') || 'root'}: ${i.message}`)
|
|
297
300
|
.join('\n');
|
|
@@ -313,5 +316,15 @@ export function createReplIterator(deps: ReplDeps, options?: PadroneReplPreferen
|
|
|
313
316
|
}
|
|
314
317
|
}
|
|
315
318
|
|
|
316
|
-
|
|
319
|
+
const iterable = replIterator();
|
|
320
|
+
(iterable as any).drain = async () => {
|
|
321
|
+
try {
|
|
322
|
+
const results: any[] = [];
|
|
323
|
+
for await (const result of iterable) results.push(result);
|
|
324
|
+
return { value: results };
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return { error: err };
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
return iterable as any;
|
|
317
330
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
|
+
import type { ColorConfig, ColorTheme } from './colorizer.ts';
|
|
1
2
|
import type { HelpFormat } from './formatter.ts';
|
|
2
3
|
import { findConfigFile, loadConfigFile } from './utils.ts';
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* A progress indicator instance (spinner, progress bar, etc).
|
|
7
|
+
* Created by the runtime's `progress` factory and used to show loading state during command execution.
|
|
8
|
+
*/
|
|
9
|
+
export type PadroneProgressIndicator = {
|
|
10
|
+
/** Update the displayed message. */
|
|
11
|
+
update: (message: string) => void;
|
|
12
|
+
/** Mark as succeeded and stop. Pass `null` to stop without rendering a final message. */
|
|
13
|
+
succeed: (message?: string | null, options?: { indicator?: string }) => void;
|
|
14
|
+
/** Mark as failed and stop. Pass `null` to stop without rendering a final message. */
|
|
15
|
+
fail: (message?: string | null, options?: { indicator?: string }) => void;
|
|
16
|
+
/** Stop without success/fail status. */
|
|
17
|
+
stop: () => void;
|
|
18
|
+
/** Temporarily hide the indicator so other output can be written cleanly. */
|
|
19
|
+
pause: () => void;
|
|
20
|
+
/** Redraw the indicator after a `pause()`. */
|
|
21
|
+
resume: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Built-in spinner presets. */
|
|
25
|
+
export type PadroneSpinnerPreset = 'dots' | 'line' | 'arc' | 'bounce';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Spinner configuration for progress indicators.
|
|
29
|
+
* - A preset name (e.g., `'dots'`) to use built-in frames.
|
|
30
|
+
* - An object with custom `frames` and/or `interval`.
|
|
31
|
+
* - `false` to disable the spinner animation (static text only).
|
|
32
|
+
*/
|
|
33
|
+
export type PadroneSpinnerConfig = PadroneSpinnerPreset | { frames?: string[]; interval?: number } | false;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Options passed to the runtime's `progress` factory.
|
|
37
|
+
*/
|
|
38
|
+
export type PadroneProgressOptions = {
|
|
39
|
+
spinner?: PadroneSpinnerConfig;
|
|
40
|
+
/** Character/string shown before the success message. Defaults to `'✔'`. */
|
|
41
|
+
successIndicator?: string;
|
|
42
|
+
/** Character/string shown before the error message. Defaults to `'✖'`. */
|
|
43
|
+
errorIndicator?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
4
46
|
/**
|
|
5
47
|
* Controls interactive prompting capability and default behavior at the runtime level.
|
|
6
48
|
* - `'supported'` — capable; caller decides.
|
|
@@ -45,6 +87,8 @@ export type PadroneRuntime = {
|
|
|
45
87
|
env?: () => Record<string, string | undefined>;
|
|
46
88
|
/** Default help output format. */
|
|
47
89
|
format?: HelpFormat | 'auto';
|
|
90
|
+
/** Color theme for ANSI/console help output. A theme name or partial color config. */
|
|
91
|
+
theme?: ColorTheme | ColorConfig;
|
|
48
92
|
/** Load and parse a config file by path. Return undefined if not found or unparsable. */
|
|
49
93
|
loadConfigFile?: (path: string) => Record<string, unknown> | undefined;
|
|
50
94
|
/** Find the first existing file from a list of candidate names. */
|
|
@@ -81,6 +125,12 @@ export type PadroneRuntime = {
|
|
|
81
125
|
* When `interactive` is `true` and this is not provided, defaults to an Enquirer-based terminal prompt.
|
|
82
126
|
*/
|
|
83
127
|
prompt?: (config: InteractivePromptConfig) => Promise<unknown>;
|
|
128
|
+
/**
|
|
129
|
+
* Create a progress indicator (spinner, progress bar, etc).
|
|
130
|
+
* Used by commands that set `progress` in their config, or manually via `ctx.progress()` in actions.
|
|
131
|
+
* When not provided, auto-progress is silently skipped and `ctx.progress()` returns a no-op indicator.
|
|
132
|
+
*/
|
|
133
|
+
progress?: (message: string, options?: PadroneProgressOptions) => PadroneProgressIndicator;
|
|
84
134
|
/**
|
|
85
135
|
* Read a line of input from the user. Used by `repl()` for custom runtimes
|
|
86
136
|
* (web UIs, chat interfaces, testing).
|
|
@@ -97,8 +147,10 @@ export type PadroneRuntime = {
|
|
|
97
147
|
* Internal resolved runtime where all fields are guaranteed to be present.
|
|
98
148
|
* The `prompt`, `interactive`, and `readLine` fields remain optional since not all runtimes provide them.
|
|
99
149
|
*/
|
|
100
|
-
export type ResolvedPadroneRuntime = Required<
|
|
101
|
-
|
|
150
|
+
export type ResolvedPadroneRuntime = Required<
|
|
151
|
+
Omit<PadroneRuntime, 'prompt' | 'interactive' | 'readLine' | 'stdin' | 'progress' | 'theme'>
|
|
152
|
+
> &
|
|
153
|
+
Pick<PadroneRuntime, 'prompt' | 'interactive' | 'readLine' | 'stdin' | 'progress' | 'theme'>;
|
|
102
154
|
|
|
103
155
|
/**
|
|
104
156
|
* Default terminal prompt implementation powered by Enquirer.
|
|
@@ -255,6 +307,135 @@ function createDefaultStdin(): NonNullable<PadroneRuntime['stdin']> {
|
|
|
255
307
|
};
|
|
256
308
|
}
|
|
257
309
|
|
|
310
|
+
const spinnerPresets: Record<PadroneSpinnerPreset, string[]> = {
|
|
311
|
+
dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
|
|
312
|
+
line: ['-', '\\', '|', '/'],
|
|
313
|
+
arc: ['◜', '◠', '◝', '◞', '◡', '◟'],
|
|
314
|
+
bounce: ['⠁', '⠂', '⠄', '⡀', '⢀', '⠠', '⠐', '⠈'],
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
function resolveSpinnerConfig(config?: PadroneSpinnerConfig): { frames: string[]; interval: number; disabled: boolean } {
|
|
318
|
+
if (config === false) return { frames: [], interval: 80, disabled: true };
|
|
319
|
+
if (typeof config === 'string') return { frames: spinnerPresets[config], interval: 80, disabled: false };
|
|
320
|
+
if (typeof config === 'object') {
|
|
321
|
+
return {
|
|
322
|
+
frames: config.frames ?? spinnerPresets.dots,
|
|
323
|
+
interval: config.interval ?? 80,
|
|
324
|
+
disabled: false,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
return { frames: spinnerPresets.dots, interval: 80, disabled: false };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Creates a built-in terminal spinner. Returns a no-op indicator in non-TTY/CI environments.
|
|
332
|
+
*/
|
|
333
|
+
function createTerminalSpinner(message: string, options?: PadroneProgressOptions): PadroneProgressIndicator {
|
|
334
|
+
const { frames, interval, disabled: spinnerDisabled } = resolveSpinnerConfig(options?.spinner);
|
|
335
|
+
const successIcon = options?.successIndicator ?? '✔';
|
|
336
|
+
const errorIcon = options?.errorIndicator ?? '✖';
|
|
337
|
+
|
|
338
|
+
const formatFinal = (icon: string, msg: string) => (icon ? `${icon} ${msg}\n` : `${msg}\n`);
|
|
339
|
+
|
|
340
|
+
if (typeof process === 'undefined' || !process.stderr?.isTTY) {
|
|
341
|
+
// Non-TTY: just log start/end, no animation
|
|
342
|
+
return {
|
|
343
|
+
update() {},
|
|
344
|
+
succeed(msg, opts) {
|
|
345
|
+
if (msg === null) return;
|
|
346
|
+
const icon = opts?.indicator ?? successIcon;
|
|
347
|
+
if (msg || message) process?.stderr?.write?.(formatFinal(icon, msg || message));
|
|
348
|
+
},
|
|
349
|
+
fail(msg, opts) {
|
|
350
|
+
if (msg === null) return;
|
|
351
|
+
const icon = opts?.indicator ?? errorIcon;
|
|
352
|
+
if (msg || message) process?.stderr?.write?.(formatFinal(icon, msg || message));
|
|
353
|
+
},
|
|
354
|
+
stop() {},
|
|
355
|
+
pause() {},
|
|
356
|
+
resume() {},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// If spinner is disabled and there's no message, nothing to render
|
|
361
|
+
if (spinnerDisabled && !message) {
|
|
362
|
+
return { update() {}, succeed() {}, fail() {}, stop() {}, pause() {}, resume() {} };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
let frame = 0;
|
|
366
|
+
let text = message;
|
|
367
|
+
let stopped = false;
|
|
368
|
+
let paused = false;
|
|
369
|
+
|
|
370
|
+
const writeStderr = process.stderr.write.bind(process.stderr);
|
|
371
|
+
const writeStdout = process.stdout.write.bind(process.stdout);
|
|
372
|
+
const clearLine = () => writeStderr('\x1b[2K\r');
|
|
373
|
+
|
|
374
|
+
const render = () => {
|
|
375
|
+
if (paused || stopped) return;
|
|
376
|
+
if (spinnerDisabled) {
|
|
377
|
+
// Static text only, no spinner frames
|
|
378
|
+
if (text) writeStderr(`\x1b[2K\r${text}`);
|
|
379
|
+
} else {
|
|
380
|
+
const prefix = frames[frame] ?? '';
|
|
381
|
+
writeStderr(`\x1b[2K\r${text ? `${prefix} ${text}` : prefix}`);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const timer = spinnerDisabled
|
|
386
|
+
? undefined
|
|
387
|
+
: setInterval(() => {
|
|
388
|
+
frame = (frame + 1) % frames.length;
|
|
389
|
+
render();
|
|
390
|
+
}, interval);
|
|
391
|
+
|
|
392
|
+
render();
|
|
393
|
+
|
|
394
|
+
const clear = () => {
|
|
395
|
+
if (stopped) return;
|
|
396
|
+
stopped = true;
|
|
397
|
+
paused = false;
|
|
398
|
+
if (timer) clearInterval(timer);
|
|
399
|
+
clearLine();
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
update(msg) {
|
|
404
|
+
if (stopped) return;
|
|
405
|
+
text = msg;
|
|
406
|
+
render();
|
|
407
|
+
},
|
|
408
|
+
succeed(msg, opts) {
|
|
409
|
+
clear();
|
|
410
|
+
if (msg === null) return;
|
|
411
|
+
const finalMsg = msg ?? text;
|
|
412
|
+
const icon = opts?.indicator ?? successIcon;
|
|
413
|
+
if (finalMsg) writeStderr(formatFinal(icon, finalMsg));
|
|
414
|
+
},
|
|
415
|
+
fail(msg, opts) {
|
|
416
|
+
clear();
|
|
417
|
+
if (msg === null) return;
|
|
418
|
+
const finalMsg = msg ?? text;
|
|
419
|
+
const icon = opts?.indicator ?? errorIcon;
|
|
420
|
+
if (finalMsg) writeStderr(formatFinal(icon, finalMsg));
|
|
421
|
+
},
|
|
422
|
+
stop() {
|
|
423
|
+
clear();
|
|
424
|
+
},
|
|
425
|
+
pause() {
|
|
426
|
+
if (stopped || paused) return;
|
|
427
|
+
paused = true;
|
|
428
|
+
clearLine();
|
|
429
|
+
writeStdout('\x1b[2K\r');
|
|
430
|
+
},
|
|
431
|
+
resume() {
|
|
432
|
+
if (stopped || !paused) return;
|
|
433
|
+
paused = false;
|
|
434
|
+
render();
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
258
439
|
export function createDefaultRuntime(): ResolvedPadroneRuntime {
|
|
259
440
|
return {
|
|
260
441
|
output: (...args) => console.log(...args),
|
|
@@ -266,6 +447,7 @@ export function createDefaultRuntime(): ResolvedPadroneRuntime {
|
|
|
266
447
|
findFile: findConfigFile,
|
|
267
448
|
prompt: defaultTerminalPrompt,
|
|
268
449
|
interactive: detectInteractiveMode(),
|
|
450
|
+
progress: createTerminalSpinner,
|
|
269
451
|
};
|
|
270
452
|
}
|
|
271
453
|
|
|
@@ -285,6 +467,15 @@ export function resolveStdin(partial?: PadroneRuntime): NonNullable<PadroneRunti
|
|
|
285
467
|
return defaultStdin;
|
|
286
468
|
}
|
|
287
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Like `resolveStdin`, but always returns a stdin source even when it's a TTY.
|
|
472
|
+
* Used for async streams which support interactive (non-piped) input.
|
|
473
|
+
*/
|
|
474
|
+
export function resolveStdinAlways(partial?: PadroneRuntime): NonNullable<PadroneRuntime['stdin']> {
|
|
475
|
+
if (partial?.stdin) return partial.stdin;
|
|
476
|
+
return createDefaultStdin();
|
|
477
|
+
}
|
|
478
|
+
|
|
288
479
|
export function resolveRuntime(partial?: PadroneRuntime): ResolvedPadroneRuntime {
|
|
289
480
|
const defaults = createDefaultRuntime();
|
|
290
481
|
if (!partial) return defaults;
|
|
@@ -299,6 +490,8 @@ export function resolveRuntime(partial?: PadroneRuntime): ResolvedPadroneRuntime
|
|
|
299
490
|
interactive: partial.interactive ?? defaults.interactive,
|
|
300
491
|
prompt: partial.prompt ?? defaults.prompt,
|
|
301
492
|
readLine: partial.readLine ?? defaults.readLine,
|
|
493
|
+
progress: partial.progress ?? defaults.progress,
|
|
302
494
|
stdin: partial.stdin,
|
|
495
|
+
theme: partial.theme,
|
|
303
496
|
};
|
|
304
497
|
}
|