fix: improve generated cli raw and array parsing
This commit is contained in:
parent
3f4f8dc317
commit
b09ce5b20d
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user