fix: coerce array option items

This commit is contained in:
Peter Steinberger 2025-12-29 20:47:04 +01:00
parent d2f0de130b
commit ecfbaee7f9
4 changed files with 137 additions and 5 deletions

View File

@ -2,7 +2,11 @@
## [Unreleased]
No unreleased changes yet.
### CLI
- Coerce generated CLI array arguments based on JSON Schema item types (including integer arrays).
### Tests
- Added regression coverage for typed array parsing in generated CLIs.
## [0.7.2] - 2025-12-29
### CLI

View File

@ -224,8 +224,27 @@ export function inferType(descriptor: unknown): GeneratedOption['type'] {
return 'unknown';
}
const type = (descriptor as Record<string, unknown>).type;
if (type === 'string' || type === 'number' || type === 'boolean' || type === 'array') {
return type;
const resolveType = (value: unknown): GeneratedOption['type'] | undefined => {
if (value === 'integer') {
return 'number';
}
if (value === 'string' || value === 'number' || value === 'boolean' || value === 'array') {
return value;
}
return undefined;
};
if (Array.isArray(type)) {
for (const entry of type) {
const resolved = resolveType(entry);
if (resolved) {
return resolved;
}
}
return 'unknown';
}
const resolved = resolveType(type);
if (resolved) {
return resolved;
}
return 'unknown';
}
@ -240,8 +259,27 @@ export function inferArrayItemType(descriptor: unknown): GeneratedOption['arrayI
}
const items = record.items as Record<string, unknown>;
const itemType = items.type;
if (itemType === 'string' || itemType === 'number' || itemType === 'boolean') {
return itemType;
const resolveItemType = (value: unknown): GeneratedOption['arrayItemType'] | undefined => {
if (value === 'integer') {
return 'number';
}
if (value === 'string' || value === 'number' || value === 'boolean') {
return value;
}
return undefined;
};
if (Array.isArray(itemType)) {
for (const entry of itemType) {
const resolved = resolveItemType(entry);
if (resolved) {
return resolved;
}
}
return 'unknown';
}
const resolved = resolveItemType(itemType);
if (resolved) {
return resolved;
}
return 'unknown';
}

View File

@ -9,6 +9,7 @@ import {
getDescriptorDescription,
getDescriptorFormatHint,
getEnumValues,
inferArrayItemType,
inferType,
pickExampleLiteral,
toCliOption,
@ -89,8 +90,15 @@ describe('generate helpers', () => {
expect(buildExampleValue('mode', 'string', ['fast'], undefined)).toBe('fast');
expect(inferType({ type: 'boolean' })).toBe('boolean');
expect(inferType({ type: 'integer' })).toBe('number');
expect(inferType({ type: ['null', 'integer'] })).toBe('number');
expect(inferType({ type: ['null', 'array'] })).toBe('array');
expect(inferType({})).toBe('unknown');
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(getDescriptorDescription({ description: 'hi' })).toBe('hi');
expect(getDescriptorDescription({})).toBeUndefined();
expect(getDescriptorFormatHint({ format: 'uuid' })).toEqual({ display: 'UUID', slug: 'uuid' });

View File

@ -72,6 +72,29 @@ if (process.platform !== 'win32') {
structuredContent: { ok: true },
})
);
server.registerTool(
'array_tool',
{
title: 'Array Tool',
description: 'Tool with typed array inputs',
inputSchema: {
coords: z.array(z.number()),
flags: z.array(z.boolean()),
names: z.array(z.string()),
ids: z.array(z.number().int()),
},
outputSchema: {
coords: z.array(z.number()),
flags: z.array(z.boolean()),
names: z.array(z.string()),
ids: z.array(z.number().int()),
},
},
async ({ coords, flags, names, ids }) => ({
content: [{ type: 'text', text: JSON.stringify({ coords, flags, names, ids }) }],
structuredContent: { coords, flags, names, ids },
})
);
server.registerResource(
'greeting',
new ResourceTemplate('greeting://{name}', { list: undefined }),
@ -365,6 +388,65 @@ describeGenerateCli('generateCli', () => {
expect(content).not.toContain('cmdOpts.tls_1_3');
});
it('coerces array option values using schema item types', async () => {
const inline = JSON.stringify({
name: 'array-test',
description: 'Array coercion test',
command: baseUrl.toString(),
});
const outputPath = path.join(tmpDir, 'array-test.ts');
await fs.rm(outputPath, { force: true });
const { outputPath: renderedPath } = await generateCli({
serverRef: inline,
outputPath,
runtime: 'node',
timeoutMs: 5_000,
});
expect(renderedPath).toBe(outputPath);
const { execFile } = await import('node:child_process');
const { stdout } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
execFile(
'pnpm',
[
'exec',
'tsx',
renderedPath,
'array-tool',
'--coords',
'1, 2.5',
'--flags',
'true, false',
'--names',
'alpha, beta',
'--ids',
'1,2,3',
'--output',
'json',
],
execOptions(),
(error: import('node:child_process').ExecFileException | null, out: string, err: string) => {
if (error) {
reject(error);
return;
}
resolve({ stdout: out, stderr: err });
}
);
});
const parsed = JSON.parse(stdout) as {
coords: number[];
flags: boolean[];
names: string[];
ids: number[];
};
expect(parsed.coords).toEqual([1, 2.5]);
expect(parsed.flags).toEqual([true, false]);
expect(parsed.names).toEqual(['alpha', 'beta']);
expect(parsed.ids).toEqual([1, 2, 3]);
});
it('accepts both kebab-case and underscore tool names for generated CLIs', async () => {
const deepwikiRef = JSON.stringify({
name: 'deepwiki',