diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a575f..578381e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli/generate/tools.ts b/src/cli/generate/tools.ts index c65dd28..a62740b 100644 --- a/src/cli/generate/tools.ts +++ b/src/cli/generate/tools.ts @@ -224,8 +224,27 @@ export function inferType(descriptor: unknown): GeneratedOption['type'] { return 'unknown'; } const type = (descriptor as Record).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; 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'; } diff --git a/tests/generate-cli-helpers.test.ts b/tests/generate-cli-helpers.test.ts index fea2d83..fc003b7 100644 --- a/tests/generate-cli-helpers.test.ts +++ b/tests/generate-cli-helpers.test.ts @@ -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' }); diff --git a/tests/generate-cli.test.ts b/tests/generate-cli.test.ts index 7f376d1..1fbb30a 100644 --- a/tests/generate-cli.test.ts +++ b/tests/generate-cli.test.ts @@ -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',