From b09ce5b20df77fa9aa2c7025e968b2389a163f56 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 05:55:18 +0100 Subject: [PATCH] fix: improve generated cli raw and array parsing --- CHANGELOG.md | 1 + src/cli/generate/template.ts | 64 ++++++++++++-- src/cli/generate/tools.ts | 4 +- tests/generate-cli-helpers.test.ts | 2 +- tests/generate-cli.test.ts | 133 +++++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1137e26..44d159c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/cli/generate/template.ts b/src/cli/generate/template.ts index 90483a6..c132b43 100644 --- a/src/cli/generate/template.ts +++ b/src/cli/generate/template.ts @@ -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; \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); -\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; diff --git a/src/cli/generate/tools.ts b/src/cli/generate/tools.ts index 57dd590..06f47f4 100644 --- a/src/cli/generate/tools.ts +++ b/src/cli/generate/tools.ts @@ -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; diff --git a/tests/generate-cli-helpers.test.ts b/tests/generate-cli-helpers.test.ts index d9d8bb4..76ddbcc 100644 --- a/tests/generate-cli-helpers.test.ts +++ b/tests/generate-cli-helpers.test.ts @@ -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(); diff --git a/tests/generate-cli.test.ts b/tests/generate-cli.test.ts index 4cb7632..f016070 100644 --- a/tests/generate-cli.test.ts +++ b/tests/generate-cli.test.ts @@ -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 "'); + 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',