571 lines
19 KiB
TypeScript
571 lines
19 KiB
TypeScript
import { realpathSync } from 'node:fs';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import type { CliArtifactMetadata } from '../../cli-metadata.js';
|
|
import type { ServerDefinition } from '../../config.js';
|
|
import { MCPORTER_VERSION } from '../../version.js';
|
|
import { buildToolDoc, type ToolOptionDoc } from '../list-detail-helpers.js';
|
|
import { markExecutable } from './fs-helpers.js';
|
|
import { renderEmbeddedHelpSource } from './template-help.js';
|
|
import type { GeneratedOption, ToolMetadata } from './tools.js';
|
|
import { buildEmbeddedSchemaMap } from './tools.js';
|
|
|
|
export interface TemplateInput {
|
|
outputPath?: string;
|
|
runtimeScriptPath?: string;
|
|
runtimeKind: 'node' | 'bun';
|
|
timeoutMs: number;
|
|
definition: ServerDefinition;
|
|
serverName: string;
|
|
tools: ToolMetadata[];
|
|
generator: {
|
|
name: string;
|
|
version: string;
|
|
};
|
|
metadata: CliArtifactMetadata;
|
|
}
|
|
|
|
export async function writeTemplate(input: TemplateInput): Promise<string> {
|
|
const resolvedOutput = input.outputPath
|
|
? path.resolve(input.outputPath)
|
|
: path.resolve(process.cwd(), `${input.serverName}.ts`);
|
|
const runtimeScriptPath = input.runtimeScriptPath ? path.resolve(input.runtimeScriptPath) : resolvedOutput;
|
|
await fs.mkdir(path.dirname(resolvedOutput), { recursive: true });
|
|
await fs.writeFile(
|
|
resolvedOutput,
|
|
renderTemplate({ ...input, outputPath: resolvedOutput, runtimeScriptPath }),
|
|
'utf8'
|
|
);
|
|
await markExecutable(resolvedOutput);
|
|
return resolvedOutput;
|
|
}
|
|
|
|
export async function readPackageMetadata(): Promise<{ name: string; version: string }> {
|
|
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
|
|
try {
|
|
const buffer = await fs.readFile(packageJsonPath, 'utf8');
|
|
const pkg = JSON.parse(buffer) as { name?: string; version?: string };
|
|
return {
|
|
name: pkg.name ?? 'mcporter',
|
|
version: pkg.version ?? '0.0.0',
|
|
};
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
throw error;
|
|
}
|
|
return { name: 'mcporter', version: MCPORTER_VERSION };
|
|
}
|
|
}
|
|
|
|
export function renderTemplate({
|
|
runtimeKind,
|
|
timeoutMs,
|
|
definition,
|
|
serverName,
|
|
tools,
|
|
generator,
|
|
metadata,
|
|
outputPath,
|
|
runtimeScriptPath,
|
|
}: TemplateInput): string {
|
|
const imports = [
|
|
"import path from 'node:path';",
|
|
"import { fileURLToPath } from 'node:url';",
|
|
"import { Command } from 'commander';",
|
|
"import { createGeneratedKeepAliveRuntime, createRuntime, createServerProxy, handleDaemonCli } from 'mcporter';",
|
|
"import { createCallResult } from 'mcporter';",
|
|
].join('\n');
|
|
const embedded = JSON.stringify(definition, (_key, value) => (value instanceof URL ? value.toString() : value), 2);
|
|
const relativeStdioCwd = computeRelativeStdioCwd(definition, runtimeScriptPath ?? outputPath);
|
|
const generatorHeader = `Generated by ${generator.name}@${generator.version} — https://github.com/steipete/mcporter`;
|
|
const toolDocs = tools.map((tool) => ({
|
|
tool,
|
|
doc: buildToolDoc({
|
|
serverName,
|
|
toolName: tool.tool.name,
|
|
description: tool.tool.description,
|
|
outputSchema: tool.tool.outputSchema,
|
|
options: tool.options,
|
|
requiredOnly: true,
|
|
colorize: false,
|
|
flagExtras: [{ text: '--raw <json>' }],
|
|
}),
|
|
}));
|
|
const renderedTools = toolDocs.map((entry) =>
|
|
Object.assign(renderToolCommand(entry.tool, timeoutMs, serverName, entry.doc), {
|
|
doc: entry.doc,
|
|
tool: entry.tool,
|
|
})
|
|
);
|
|
const toolHelp = renderedTools.map((entry) => ({
|
|
name: entry.commandName,
|
|
description: entry.tool.tool.description ?? '',
|
|
usage: entry.doc.flagUsage ? `${entry.commandName} ${entry.doc.flagUsage}` : undefined,
|
|
flags: entry.doc.flagUsage ?? '',
|
|
}));
|
|
const generatorHeaderLiteral = JSON.stringify(generatorHeader);
|
|
const toolHelpLiteral = JSON.stringify(toolHelp, undefined, 2);
|
|
const embeddedSchemas = JSON.stringify(buildEmbeddedSchemaMap(tools), undefined, 2);
|
|
const embeddedMetadata = JSON.stringify(metadata, undefined, 2);
|
|
const toolBlocks = renderedTools.map((entry) => entry.block).join('\n\n');
|
|
const signatureMap = Object.fromEntries(renderedTools.map((entry) => [entry.commandName, entry.tsSignature]));
|
|
const signatureMapLiteral = JSON.stringify(signatureMap, undefined, 2);
|
|
const generatedHeaderComment = `// @generated by ${generator.name}@${generator.version} on ${
|
|
metadata.generatedAt
|
|
}. DO NOT EDIT.`;
|
|
return `#!/usr/bin/env ${runtimeKind === 'bun' ? 'bun' : 'node'}
|
|
${generatedHeaderComment}
|
|
${imports}
|
|
|
|
const __mcpScriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const __mcpRelativeStdioCwd: string | null = ${JSON.stringify(relativeStdioCwd)};
|
|
const embeddedServer = ${embedded} as const;
|
|
const embeddedSchemas = ${embeddedSchemas} as const;
|
|
const embeddedName = ${JSON.stringify(serverName)};
|
|
const embeddedDescription = ${JSON.stringify(
|
|
definition.description ?? `Standalone CLI for the ${serverName} MCP server.`
|
|
)};
|
|
const generatorInfo = ${generatorHeaderLiteral};
|
|
const generatorTools = ${toolHelpLiteral} as const;
|
|
const embeddedMetadata = ${embeddedMetadata} as const;
|
|
const artifactKind = determineArtifactKind();
|
|
const program = new Command();
|
|
program.name(embeddedName);
|
|
program.description(embeddedDescription);
|
|
program.option('-t, --timeout <ms>', 'Call timeout in milliseconds', (value) => parseInt(value, 10), ${timeoutMs});
|
|
program.option('-o, --output <format>', 'Output format: text|markdown|json|raw', 'text');
|
|
const commandSignatures: Record<string, string> = ${signatureMapLiteral};
|
|
program.configureHelp({
|
|
\tcommandTerm(cmd) {
|
|
\t\tconst term = cmd.name();
|
|
\t\treturn commandSignatures[term] ?? cmd.name();
|
|
\t},
|
|
});
|
|
program.showSuggestionAfterError(true);
|
|
|
|
${toolBlocks}
|
|
|
|
program
|
|
\t.command('__mcporter_inspect', { hidden: true })
|
|
\t.description('Internal metadata printer for mcporter inspect-cli.')
|
|
\t.action(() => {
|
|
\t\tconst payload = buildMetadataPayload();
|
|
\t\tconsole.log(JSON.stringify(payload, null, 2));
|
|
\t});
|
|
|
|
configureToolCommandHelps();
|
|
|
|
const FORCE_COLOR = process.env.FORCE_COLOR?.toLowerCase();
|
|
const forceDisableColor = FORCE_COLOR === '0' || FORCE_COLOR === 'false';
|
|
const forceEnableColor = FORCE_COLOR === '1' || FORCE_COLOR === 'true' || FORCE_COLOR === '2' || FORCE_COLOR === '3';
|
|
const hasNoColor = process.env.NO_COLOR !== undefined;
|
|
const stdoutStream = process.stdout as NodeJS.WriteStream | undefined;
|
|
const supportsAnsiColor = !hasNoColor && (forceEnableColor || (!forceDisableColor && Boolean(stdoutStream?.isTTY)));
|
|
|
|
const tint = {
|
|
bold(text: string): string {
|
|
return supportsAnsiColor ? '\u001B[1m' + text + '\u001B[0m' : text;
|
|
},
|
|
dim(text: string): string {
|
|
return supportsAnsiColor ? '\u001B[90m' + text + '\u001B[0m' : text;
|
|
},
|
|
extraDim(text: string): string {
|
|
return supportsAnsiColor ? '\u001B[38;5;244m' + text + '\u001B[0m' : text;
|
|
},
|
|
};
|
|
|
|
${renderEmbeddedHelpSource()}
|
|
|
|
function printResult(result: unknown, format: string) {
|
|
\tconst wrapped = createCallResult(result);
|
|
\tconst rawPayload = unwrapRawPayload(wrapped.raw);
|
|
\tswitch (format) {
|
|
\t\tcase 'json': {
|
|
\t\t\tconst json = wrapped.json();
|
|
\t\t\tif (json !== null) {
|
|
\t\t\t\tconsole.log(JSON.stringify(json, null, 2));
|
|
\t\t\t\treturn;
|
|
\t\t\t}
|
|
\t\t\tconsole.log(JSON.stringify(rawPayload, null, 2));
|
|
\t\t\treturn;
|
|
\t\t}
|
|
\t\tcase 'markdown': {
|
|
\t\t\tconst markdown = wrapped.markdown();
|
|
\t\t\tif (markdown) {
|
|
\t\t\t\tconsole.log(markdown);
|
|
\t\t\t\treturn;
|
|
\t\t\t}
|
|
\t\t\tbreak;
|
|
\t\t}
|
|
\t\tcase 'raw': {
|
|
\t\t\tconsole.log(JSON.stringify(rawPayload, null, 2));
|
|
\t\t\treturn;
|
|
\t\t}
|
|
\t}
|
|
\tconst text = wrapped.text();
|
|
\tif (text) {
|
|
\t\tconsole.log(text);
|
|
\t} else {
|
|
\t\tconsole.log(JSON.stringify(rawPayload, null, 2));
|
|
\t}
|
|
}
|
|
|
|
function unwrapRawPayload(value: unknown): unknown {
|
|
\tif (value && typeof value === 'object' && 'raw' in value) {
|
|
\t\treturn (value as { raw: unknown }).raw;
|
|
\t}
|
|
\treturn value;
|
|
}
|
|
|
|
function parseArrayOption(value: string, itemType: 'string' | 'number' | 'boolean' | 'json') {
|
|
\tconst trimmed = value.trim();
|
|
\tif (trimmed.startsWith('[')) {
|
|
\t\tconst parsed = JSON.parse(trimmed);
|
|
\t\tif (!Array.isArray(parsed)) {
|
|
\t\t\tthrow new Error('Expected a JSON array.');
|
|
\t\t}
|
|
\t\treturn parsed;
|
|
\t}
|
|
\tif (itemType === 'json') {
|
|
\t\tconst parsed = JSON.parse('[' + value + ']');
|
|
\t\tif (!Array.isArray(parsed)) {
|
|
\t\t\tthrow new Error('Expected JSON array items.');
|
|
\t\t}
|
|
\t\treturn parsed;
|
|
\t}
|
|
\tconst values = value.split(',').map((entry) => entry.trim());
|
|
\tif (itemType === 'number') {
|
|
\t\treturn values.map((entry) => parseFloat(entry));
|
|
\t}
|
|
\tif (itemType === 'boolean') {
|
|
\t\treturn values.map((entry) => entry !== 'false');
|
|
\t}
|
|
\treturn values;
|
|
}
|
|
|
|
function normalizeEmbeddedServer(server: typeof embeddedServer) {
|
|
\tconst base = { ...server } as Record<string, unknown>;
|
|
\tif ((server.command as any).kind === 'http') {
|
|
\t\tconst urlRaw = (server.command as any).url;
|
|
\t\tconst urlValue = typeof urlRaw === 'string' ? urlRaw : String(urlRaw);
|
|
\t\treturn {
|
|
\t\t\t...base,
|
|
\t\t\tcommand: {
|
|
\t\t\t\t...(server.command as Record<string, unknown>),
|
|
\t\t\t\turl: new URL(urlValue),
|
|
\t\t\t},
|
|
\t\t};
|
|
\t}
|
|
\tif ((server.command as any).kind === 'stdio') {
|
|
\t\tconst stdio = server.command as Record<string, unknown>;
|
|
\t\tconst resolvedCwd =
|
|
\t\t\t__mcpRelativeStdioCwd !== null
|
|
\t\t\t\t? path.resolve(__mcpScriptDir, __mcpRelativeStdioCwd)
|
|
\t\t\t\t: typeof stdio.cwd === 'string' && stdio.cwd.length > 0
|
|
\t\t\t\t\t? stdio.cwd
|
|
\t\t\t\t\t: undefined;
|
|
\t\treturn {
|
|
\t\t\t...base,
|
|
\t\t\tcommand: {
|
|
\t\t\t\t...stdio,
|
|
\t\t\t\targs: [ ...((server.command as any).args ?? []) ],
|
|
\t\t\t\t...(resolvedCwd !== undefined ? { cwd: resolvedCwd } : {}),
|
|
\t\t\t},
|
|
\t\t};
|
|
\t}
|
|
\treturn base;
|
|
}
|
|
|
|
function determineArtifactKind(): 'template' | 'bundle' | 'binary' {
|
|
\tconst scriptPath = typeof process !== 'undefined' && Array.isArray(process.argv) ? process.argv[1] ?? '' : '';
|
|
\tif (scriptPath.endsWith('.ts')) {
|
|
\t\treturn 'template';
|
|
\t}
|
|
\tif (scriptPath.endsWith('.js')) {
|
|
\t\treturn 'bundle';
|
|
\t}
|
|
\treturn 'binary';
|
|
}
|
|
|
|
function resolveArtifactPath(): string {
|
|
\tif (typeof process !== 'undefined' && Array.isArray(process.argv) && process.argv.length > 1) {
|
|
\t\tconst script = process.argv[1];
|
|
\t\tif (script) {
|
|
\t\t\treturn script;
|
|
\t\t}
|
|
\t}
|
|
\treturn embeddedMetadata.artifact.path;
|
|
}
|
|
|
|
function buildMetadataPayload() {
|
|
\tconst invocation = { ...embeddedMetadata.invocation };
|
|
\tconst path = resolveArtifactPath();
|
|
\tif (artifactKind === 'template' && path) {
|
|
\t\tinvocation.outputPath = invocation.outputPath ?? path;
|
|
\t} else if (artifactKind === 'bundle' && path) {
|
|
\t\tinvocation.bundle = invocation.bundle ?? path;
|
|
\t} else if (artifactKind === 'binary' && path) {
|
|
\t\tinvocation.compile = invocation.compile ?? path;
|
|
\t}
|
|
\treturn {
|
|
\t\t...embeddedMetadata,
|
|
\t\tartifact: {
|
|
\t\t\tpath,
|
|
\t\t\tkind: artifactKind,
|
|
\t\t},
|
|
\t\tinvocation,
|
|
\t};
|
|
}
|
|
|
|
async function ensureRuntime() {
|
|
const server = normalizeEmbeddedServer(embeddedServer);
|
|
const baseRuntime = await createRuntime({
|
|
servers: [server as any],
|
|
});
|
|
return await createGeneratedKeepAliveRuntime(baseRuntime, server as any);
|
|
}
|
|
|
|
async function invokeWithTimeout<T>(call: Promise<T>, timeout: number): Promise<T> {
|
|
\tif (!Number.isFinite(timeout) || timeout <= 0) {
|
|
\t\treturn await call;
|
|
\t}
|
|
\tlet timer: ReturnType<typeof setTimeout> | undefined;
|
|
\ttry {
|
|
\t\treturn await Promise.race([
|
|
\t\t\tcall,
|
|
\t\t\tnew Promise<never>((_, reject) => {
|
|
\t\t\t\ttimer = setTimeout(() => {
|
|
\t\t\t\t\treject(new Error('Call timed out after ' + timeout + 'ms.'));
|
|
\t\t\t\t}, timeout);
|
|
\t\t\t}),
|
|
\t\t]);
|
|
\t} finally {
|
|
\t\tif (timer) {
|
|
\t\t\tclearTimeout(timer);
|
|
\t\t}
|
|
\t}
|
|
}
|
|
|
|
function parseGeneratedDaemonInvocation(rawArgs: string[]): { args: string[]; configPath: string; rootDir?: string } | null {
|
|
\tconst args = [...rawArgs];
|
|
\tlet configPath: string | undefined;
|
|
\tlet rootDir: string | undefined;
|
|
\twhile (args.length > 0) {
|
|
\t\tconst token = args[0];
|
|
\t\tif (token === '--config') {
|
|
\t\t\targs.shift();
|
|
\t\t\tconfigPath = args.shift();
|
|
\t\t\tcontinue;
|
|
\t\t}
|
|
\t\tif (token?.startsWith('--config=')) {
|
|
\t\t\targs.shift();
|
|
\t\t\tconfigPath = token.slice('--config='.length);
|
|
\t\t\tcontinue;
|
|
\t\t}
|
|
\t\tif (token === '--root') {
|
|
\t\t\targs.shift();
|
|
\t\t\trootDir = args.shift();
|
|
\t\t\tcontinue;
|
|
\t\t}
|
|
\t\tif (token?.startsWith('--root=')) {
|
|
\t\t\targs.shift();
|
|
\t\t\trootDir = token.slice('--root='.length);
|
|
\t\t\tcontinue;
|
|
\t\t}
|
|
\t\tbreak;
|
|
\t}
|
|
\tif (args[0] !== 'daemon') {
|
|
\t\treturn null;
|
|
\t}
|
|
\tif (!configPath) {
|
|
\t\tthrow new Error('Generated daemon invocation is missing --config.');
|
|
\t}
|
|
\treturn { args: args.slice(1), configPath, rootDir };
|
|
}
|
|
|
|
async function runCli(): Promise<void> {
|
|
\tconst args = process.argv.slice(2);
|
|
\tconst daemonInvocation = parseGeneratedDaemonInvocation(args);
|
|
\tif (daemonInvocation) {
|
|
\t\tawait handleDaemonCli([...daemonInvocation.args], {
|
|
\t\t\tconfigPath: daemonInvocation.configPath,
|
|
\t\t\tconfigExplicit: true,
|
|
\t\t\trootDir: daemonInvocation.rootDir,
|
|
\t\t});
|
|
\t\treturn;
|
|
\t}
|
|
\tif (args.length === 0) {
|
|
\t\tprogram.outputHelp();
|
|
\t\treturn;
|
|
\t}
|
|
\tawait program.parseAsync(process.argv);
|
|
}
|
|
|
|
if (process.env.MCPORTER_DISABLE_AUTORUN !== '1') {
|
|
\trunCli().catch((error) => {
|
|
\t\tconst message = error instanceof Error ? error.message : String(error);
|
|
\t\tconsole.error(message);
|
|
\t\tprocess.exit(1);
|
|
\t});
|
|
}
|
|
`;
|
|
}
|
|
|
|
export function renderToolCommand(
|
|
tool: ToolMetadata,
|
|
defaultTimeout: number,
|
|
serverName: string,
|
|
existingDoc?: ReturnType<typeof buildToolDoc>
|
|
): { block: string; commandName: string; signature: string; tsSignature: string } {
|
|
const commandName = tool.tool.name.replace(/[^a-zA-Z0-9-]/g, '-');
|
|
const description = tool.tool.description ?? `Invoke the ${tool.tool.name} tool.`;
|
|
const doc =
|
|
existingDoc ??
|
|
buildToolDoc({
|
|
serverName,
|
|
toolName: tool.tool.name,
|
|
description: tool.tool.description,
|
|
outputSchema: tool.tool.outputSchema,
|
|
options: tool.options,
|
|
requiredOnly: true,
|
|
colorize: false,
|
|
flagExtras: [{ text: '--raw <json>' }],
|
|
});
|
|
const buildArgs = tool.options
|
|
.map((option) => {
|
|
// Commander.js camelcases flag names (e.g. --relative-path => relativePath).
|
|
const camelCaseProp = option.cliName
|
|
.split('-')
|
|
.filter(Boolean)
|
|
.map((segment, index) => (index === 0 ? segment : `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`))
|
|
.join('');
|
|
const source = `cmdOpts.${camelCaseProp}`;
|
|
return `if (${source} !== undefined) args.${option.property} = ${source};`;
|
|
})
|
|
.join('\n\t\t');
|
|
const requiredChecks = tool.options
|
|
.filter((option) => option.required)
|
|
.map((option) => {
|
|
const camelCaseProp = option.cliName
|
|
.split('-')
|
|
.filter(Boolean)
|
|
.map((segment, index) => (index === 0 ? segment : `${segment.charAt(0).toUpperCase()}${segment.slice(1)}`))
|
|
.join('');
|
|
return { option, camelCaseProp };
|
|
});
|
|
const requiredValidation =
|
|
requiredChecks.length > 0
|
|
? `const missingRequired = [${requiredChecks
|
|
.map(
|
|
({ option, camelCaseProp }) =>
|
|
`{ value: cmdOpts.${camelCaseProp}, flag: ${JSON.stringify(`--${option.cliName}`)} }`
|
|
)
|
|
.join(', ')}].filter((entry) => entry.value === undefined).map((entry) => entry.flag);
|
|
\t\t\tif (missingRequired.length > 0) {
|
|
\t\t\t\tthrow new Error('Missing required option' + (missingRequired.length === 1 ? '' : 's') + ': ' + missingRequired.join(', '));
|
|
\t\t\t}`
|
|
: '';
|
|
const flagUsage = doc.flagUsage;
|
|
const optionLines = doc.optionDocs.map((entry) => renderOption(entry)).join('\n');
|
|
const summary = flagUsage ? `${commandName} ${flagUsage}` : commandName;
|
|
const signature = summary;
|
|
const usageSnippet = flagUsage ? `.usage(${JSON.stringify(flagUsage)})\n` : '';
|
|
const tsSignature = doc.tsSignature;
|
|
const exampleText = doc.examples[0];
|
|
const exampleSnippet = exampleText
|
|
? `\n\t.addHelpText('after', () => '\\nExample:\\n ' + ${JSON.stringify(exampleText)})`
|
|
: '';
|
|
const optionalSnippet = doc.optionalSummary
|
|
? `\n\t.addHelpText('afterAll', () => '\\n' + ${JSON.stringify(doc.optionalSummary)} + '\\n')`
|
|
: '';
|
|
const aliasSnippet = tool.tool.name !== commandName ? `\n\t.alias(${JSON.stringify(tool.tool.name)})` : '';
|
|
const block = `program
|
|
\t.command(${JSON.stringify(commandName)})
|
|
\t.summary(${JSON.stringify(summary)})
|
|
\t.description(${JSON.stringify(description)})
|
|
${usageSnippet ? `\t${usageSnippet}` : ''}\t.option('--raw <json>', 'Provide raw JSON arguments to the tool, bypassing flag parsing.')
|
|
${optionLines ? `\n${optionLines}` : ''}
|
|
${aliasSnippet ? `\t${aliasSnippet}` : ''}\t.action(async (cmdOpts) => {
|
|
\t\tconst globalOptions = program.opts();
|
|
\t\tconst runtimeContext = await ensureRuntime();
|
|
\t\tconst runtime = runtimeContext.runtime;
|
|
\t\tconst serverName = embeddedName;
|
|
\t\tconst proxy = createServerProxy(runtime, serverName, {
|
|
\t\t\tinitialSchemas: embeddedSchemas,
|
|
\t\t});
|
|
\t\ttry {
|
|
\t\t\tconst args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
|
|
\t\t\tif (!cmdOpts.raw) {
|
|
\t\t\t\t${requiredValidation}
|
|
\t\t\t\t${buildArgs}
|
|
\t\t\t}
|
|
\t\t\tconst call = (proxy.${tool.methodName} as any)(args);
|
|
\t\t\tconst result = await invokeWithTimeout(call, globalOptions.timeout || ${defaultTimeout});
|
|
\t\t\tprintResult(result, globalOptions.output ?? 'text');
|
|
\t\t} finally {
|
|
\t\t\tawait runtimeContext.close(serverName).catch(() => {});
|
|
\t\t}
|
|
\t})${exampleSnippet}${optionalSnippet};`;
|
|
return { block, commandName, signature, tsSignature };
|
|
}
|
|
|
|
function renderOption(optionDoc: ToolOptionDoc): string {
|
|
const parser = optionParser(optionDoc.option);
|
|
return `\t.option(${JSON.stringify(optionDoc.flagLabel)}, ${JSON.stringify(optionDoc.description)}${
|
|
parser ? `, ${parser}` : ''
|
|
})`;
|
|
}
|
|
|
|
function computeRelativeStdioCwd(definition: ServerDefinition, outputPath?: string): string | null {
|
|
if (!outputPath || definition.command.kind !== 'stdio') {
|
|
return null;
|
|
}
|
|
const rawCwd = definition.command.cwd;
|
|
if (typeof rawCwd === 'string' && path.isAbsolute(rawCwd)) {
|
|
return null;
|
|
}
|
|
const baseCwd = typeof rawCwd === 'string' && rawCwd.length > 0 ? rawCwd : process.cwd();
|
|
const absoluteCwd = realpathIfExists(path.resolve(process.cwd(), baseCwd));
|
|
const outputDir = realpathIfExists(path.dirname(path.resolve(outputPath)));
|
|
const relative = path.relative(outputDir, absoluteCwd);
|
|
if (path.isAbsolute(relative)) {
|
|
return null;
|
|
}
|
|
return relative === '' ? '.' : relative;
|
|
}
|
|
|
|
function realpathIfExists(value: string): string {
|
|
try {
|
|
return realpathSync.native(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
export const templateTestHelpers = { computeRelativeStdioCwd };
|
|
|
|
function optionParser(option: GeneratedOption): string | undefined {
|
|
switch (option.type) {
|
|
case 'number':
|
|
return '(value) => parseFloat(value)';
|
|
case 'boolean':
|
|
return "(value) => value !== 'false'";
|
|
case 'object':
|
|
return '(value) => JSON.parse(value)';
|
|
case 'array':
|
|
// Coerce array elements to their proper types based on schema
|
|
switch (option.arrayItemType) {
|
|
case 'number':
|
|
return "(value) => parseArrayOption(value, 'number')";
|
|
case 'boolean':
|
|
return "(value) => parseArrayOption(value, 'boolean')";
|
|
case 'object':
|
|
return "(value) => parseArrayOption(value, 'json')";
|
|
default:
|
|
return "(value) => parseArrayOption(value, 'string')";
|
|
}
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|