fix: improve generated cli raw and array parsing

This commit is contained in:
Peter Steinberger 2026-05-04 05:55:18 +01:00
parent 3f4f8dc317
commit b09ce5b20d
No known key found for this signature in database
5 changed files with 195 additions and 9 deletions

View File

@ -12,6 +12,7 @@
- Resolve relative stdio args in generated CLI bundles against the generated script location instead of the caller's current directory. (PR #148 / issue #56, thanks @solomonneas)
- Print OAuth manual-completion URLs at the default warning log level so headless users can copy them. (PR #143 / issue #139, thanks @stainlu)
- Support repeatable `--header KEY=value` flags for ad-hoc HTTP servers and persisted ad-hoc entries. (Issue #117)
- Let generated CLIs use `--raw` without also passing required flags, and parse array flags containing JSON object items. (Issues #102 and #103)
### Config

View File

@ -208,6 +208,32 @@ function printResult(result: unknown, format: string) {
\t}
}
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') {
@ -360,6 +386,28 @@ export function renderToolCommand(
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;
@ -389,7 +437,10 @@ ${aliasSnippet ? `\t${aliasSnippet}` : ''}\t.action(async (cmdOpts) => {
\t\t});
\t\ttry {
\t\t\tconst args = cmdOpts.raw ? JSON.parse(cmdOpts.raw) : ({} as Record<string, unknown>);
\t\t\t${buildArgs}
\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');
@ -402,8 +453,7 @@ ${aliasSnippet ? `\t${aliasSnippet}` : ''}\t.action(async (cmdOpts) => {
function renderOption(optionDoc: ToolOptionDoc): string {
const parser = optionParser(optionDoc.option);
const method = optionDoc.option.required ? '.requiredOption' : '.option';
return `\t${method}(${JSON.stringify(optionDoc.flagLabel)}, ${JSON.stringify(optionDoc.description)}${
return `\t.option(${JSON.stringify(optionDoc.flagLabel)}, ${JSON.stringify(optionDoc.description)}${
parser ? `, ${parser}` : ''
})`;
}
@ -448,11 +498,13 @@ function optionParser(option: GeneratedOption): string | undefined {
// Coerce array elements to their proper types based on schema
switch (option.arrayItemType) {
case 'number':
return "(value) => value.split(',').map((v) => parseFloat(v.trim()))";
return "(value) => parseArrayOption(value, 'number')";
case 'boolean':
return "(value) => value.split(',').map((v) => v.trim() !== 'false')";
return "(value) => parseArrayOption(value, 'boolean')";
case 'object':
return "(value) => parseArrayOption(value, 'json')";
default:
return "(value) => value.split(',').map((v) => v.trim())";
return "(value) => parseArrayOption(value, 'string')";
}
default:
return undefined;

View File

@ -12,7 +12,7 @@ export interface GeneratedOption {
description?: string;
required: boolean;
type: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'unknown';
arrayItemType?: 'string' | 'number' | 'boolean' | 'unknown';
arrayItemType?: 'string' | 'number' | 'boolean' | 'object' | 'unknown';
placeholder: string;
exampleValue?: string;
enumValues?: string[];
@ -34,7 +34,7 @@ function resolveArrayItemType(value: unknown): GeneratedOption['arrayItemType']
if (value === 'integer') {
return 'number';
}
if (value === 'string' || value === 'number' || value === 'boolean') {
if (value === 'string' || value === 'number' || value === 'boolean' || value === 'object') {
return value;
}
return undefined;

View File

@ -100,7 +100,7 @@ describe('generate helpers', () => {
expect(inferArrayItemType({ type: 'array', items: { type: 'integer' } })).toBe('number');
expect(inferArrayItemType({ type: 'array', items: { type: ['null', 'boolean'] } })).toBe('boolean');
expect(inferArrayItemType({ type: 'array', items: { type: 'object' } })).toBe('unknown');
expect(inferArrayItemType({ type: 'array', items: { type: 'object' } })).toBe('object');
expect(getDescriptorDescription({ description: 'hi' })).toBe('hi');
expect(getDescriptorDescription({})).toBeUndefined();

View File

@ -113,6 +113,37 @@ if (process.platform !== 'win32') {
structuredContent: { fields },
})
);
server.registerTool(
'set_cells_batch',
{
title: 'Set Cells Batch',
description: 'Set multiple cells in a single operation',
inputSchema: {
cells: z.array(
z.object({
x: z.number().int(),
y: z.number().int(),
char: z.string().min(1).max(1).optional(),
color: z.string().optional(),
bgColor: z.string().optional(),
})
),
},
outputSchema: {
cells: z.array(
z.object({
x: z.number(),
y: z.number(),
char: z.string().optional(),
})
),
},
},
async ({ cells }) => ({
content: [{ type: 'text', text: JSON.stringify({ cells }) }],
structuredContent: { cells },
})
);
server.registerResource(
'greeting',
new ResourceTemplate('greeting://{name}', { list: undefined }),
@ -519,6 +550,108 @@ describeGenerateCli('generateCli', () => {
});
}, 30_000);
it('lets --raw bypass required generated flags', async () => {
const inline = JSON.stringify({
name: 'raw-required-test',
description: 'Raw required test',
command: baseUrl.toString(),
});
const outputPath = path.join(tmpDir, 'raw-required-test.ts');
await fs.rm(outputPath, { force: true });
const { outputPath: renderedPath } = await generateCli({
serverRef: inline,
outputPath,
runtime: 'node',
timeoutMs: 5_000,
includeTools: ['set_cells_batch'],
});
const content = await fs.readFile(renderedPath, 'utf8');
expect(content).toContain('.option("--cells <cells:value1,value2>"');
expect(content).not.toContain('.requiredOption("--cells');
const { execFile } = await import('node:child_process');
const { stdout } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(
'pnpm',
[
'exec',
'tsx',
renderedPath,
'set-cells-batch',
'--raw',
'{"cells":[{"x":50,"y":15,"char":"A"}]}',
'--output',
'json',
],
execOptions(),
(error: import('node:child_process').ExecFileException | null, out: string, err: string) => {
if (error) {
reject(error);
return;
}
resolve({ stdout: out, stderr: err });
}
);
});
expect(JSON.parse(stdout)).toEqual({
cells: [{ char: 'A', x: 50, y: 15 }],
});
}, 30_000);
it('parses generated array flags with JSON object items', async () => {
const inline = JSON.stringify({
name: 'array-object-test',
description: 'Array object test',
command: baseUrl.toString(),
});
const outputPath = path.join(tmpDir, 'array-object-test.ts');
await fs.rm(outputPath, { force: true });
const { outputPath: renderedPath } = await generateCli({
serverRef: inline,
outputPath,
runtime: 'node',
timeoutMs: 5_000,
includeTools: ['set_cells_batch'],
});
const content = await fs.readFile(renderedPath, 'utf8');
expect(content).toContain("parseArrayOption(value, 'json')");
const { execFile } = await import('node:child_process');
const { stdout } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(
'pnpm',
[
'exec',
'tsx',
renderedPath,
'set-cells-batch',
'--cells',
'{"x":50,"y":15,"char":"A"},{"x":51,"y":15,"char":"B"}',
'--output',
'json',
],
execOptions(),
(error: import('node:child_process').ExecFileException | null, out: string, err: string) => {
if (error) {
reject(error);
return;
}
resolve({ stdout: out, stderr: err });
}
);
});
expect(JSON.parse(stdout)).toEqual({
cells: [
{ char: 'A', x: 50, y: 15 },
{ char: 'B', x: 51, y: 15 },
],
});
}, 30_000);
it('accepts both kebab-case and underscore tool names for generated CLIs', async () => {
const deepwikiRef = JSON.stringify({
name: 'deepwiki',