From 2eba080b9c095d95e2d4c9af6fe14db3124ce296 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:51:35 +1000 Subject: [PATCH] feat(parser): support variadic positional args via final array key --- .changeset/variadic-positional-args.md | 12 +++++ src/Help.test.ts | 19 ++++++++ src/Help.ts | 4 +- src/Mcp.test.ts | 22 ++++++++++ src/Parser.test-d.ts | 14 ++++++ src/Parser.test.ts | 61 ++++++++++++++++++++++++++ src/Parser.ts | 17 ++++--- src/Skill.test.ts | 11 +++++ src/Skill.ts | 6 ++- 9 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 .changeset/variadic-positional-args.md diff --git a/.changeset/variadic-positional-args.md b/.changeset/variadic-positional-args.md new file mode 100644 index 00000000..f92f9fef --- /dev/null +++ b/.changeset/variadic-positional-args.md @@ -0,0 +1,12 @@ +--- +'incur': minor +--- + +Added variadic positional arguments: a final `z.array(...)` args key collects all remaining positionals. + +```ts +Cli.create('my-cli').command('lint', { + args: z.object({ paths: z.array(z.string()).describe('Files to lint') }), + run: (c) => ({ count: c.args.paths.length }), +}) +``` diff --git a/src/Help.test.ts b/src/Help.test.ts index b2b1f1cb..0b9daa47 100644 --- a/src/Help.test.ts +++ b/src/Help.test.ts @@ -137,6 +137,25 @@ describe('formatCommand', () => { expect(result).toContain('Usage: tool run [verbose] ') }) + test('synopsis renders variadic args with ellipsis', () => { + const result = Help.formatCommand('tool copy', { + args: z.object({ + target: z.string().describe('Destination'), + paths: z.array(z.string()).describe('Files to copy'), + }), + }) + expect(result).toContain('Usage: tool copy ') + }) + + test('synopsis renders optional variadic args in brackets', () => { + const result = Help.formatCommand('tool lint', { + args: z.object({ + paths: z.array(z.string()).optional().describe('Files to lint'), + }), + }) + expect(result).toContain('Usage: tool lint [paths...]') + }) + test('shows count type in help for meta count', () => { const result = Help.formatCommand('tool run', { options: z.object({ diff --git a/src/Help.ts b/src/Help.ts index 3c6e0d3f..9b919015 100644 --- a/src/Help.ts +++ b/src/Help.ts @@ -244,13 +244,13 @@ export function formatCommand(name: string, options: formatCommand.Options = {}) return lines.join('\n') } -/** Builds the synopsis string with `` and `[optional]` placeholders. */ +/** Builds the synopsis string with ``, `[optional]`, and `` placeholders. */ function buildSynopsis(name: string, args?: z.ZodObject): string { if (!args) return name const parts = [name] for (const [key, schema] of Object.entries(args.shape)) { const type = resolveTypeName(schema) - const label = type.includes('|') ? type : key + const label = (type.includes('|') ? type : key) + (type === 'array' ? '...' : '') parts.push((schema as z.ZodType)._zod.optout === 'optional' ? `[${label}]` : `<${label}>`) } return parts.join(' ') diff --git a/src/Mcp.test.ts b/src/Mcp.test.ts index d81d6b91..8d3269e1 100644 --- a/src/Mcp.test.ts +++ b/src/Mcp.test.ts @@ -419,6 +419,28 @@ describe('Mcp', () => { expect(res.result.content).toEqual([{ type: 'text', text: '{"result":"HELLO"}' }]) }) + test('tools/list and tools/call handle variadic array args', async () => { + const commands = new Map() + commands.set('lint', { + description: 'Lint files', + args: z.object({ paths: z.array(z.string()).describe('Files to lint') }), + run: (c: any) => ({ count: c.args.paths.length }), + }) + + const [, listRes, callRes] = await mcpSession(commands, [ + { id: 1, method: 'initialize', params: initParams }, + { id: 2, method: 'tools/list', params: {} }, + { + id: 3, + method: 'tools/call', + params: { name: 'lint', arguments: { paths: ['a.ts', 'b.ts'] } }, + }, + ]) + + expect(listRes.result.tools[0].inputSchema.properties.paths).toMatchObject({ type: 'array' }) + expect(callRes.result.content).toEqual([{ type: 'text', text: '{"count":2}' }]) + }) + test('tools/call validation error includes fieldErrors', async () => { const tool = Mcp.collectTools(createTestCommands(), []).find((tool) => tool.name === 'echo')! const result = await Mcp.callTool(tool, { message: 123 }) diff --git a/src/Parser.test-d.ts b/src/Parser.test-d.ts index 89444783..6afe3db6 100644 --- a/src/Parser.test-d.ts +++ b/src/Parser.test-d.ts @@ -8,6 +8,20 @@ test('narrows args from schema', () => { expectTypeOf(result.args).toEqualTypeOf<{ name: string }>() }) +test('variadic array arg infers as array', () => { + const result = Parser.parse(['a.ts', 'b.ts'], { + args: z.object({ paths: z.array(z.string()) }), + }) + expectTypeOf(result.args).toEqualTypeOf<{ paths: string[] }>() +}) + +test('scalar and variadic args infer together', () => { + const result = Parser.parse(['dest', 'a.ts'], { + args: z.object({ target: z.string(), paths: z.array(z.string()).default([]) }), + }) + expectTypeOf(result.args).toEqualTypeOf<{ target: string; paths: string[] }>() +}) + test('narrows options from schema', () => { const result = Parser.parse(['--state', 'open'], { options: z.object({ state: z.string() }), diff --git a/src/Parser.test.ts b/src/Parser.test.ts index fd47b545..1d213851 100644 --- a/src/Parser.test.ts +++ b/src/Parser.test.ts @@ -12,6 +12,67 @@ describe('parse', () => { expect(result.args).toEqual({ greeting: 'hello', name: 'world' }) }) + test('collects remaining positionals into a final array arg', () => { + const result = Parser.parse(['a.ts', 'b.ts', 'c.ts'], { + args: z.object({ paths: z.array(z.string()) }), + }) + expect(result.args).toEqual({ paths: ['a.ts', 'b.ts', 'c.ts'] }) + }) + + test('assigns scalar args before a final array arg', () => { + const result = Parser.parse(['dest', 'a.ts', 'b.ts'], { + args: z.object({ target: z.string(), paths: z.array(z.string()) }), + }) + expect(result.args).toEqual({ target: 'dest', paths: ['a.ts', 'b.ts'] }) + }) + + test('collects variadic positionals interleaved with options', () => { + const result = Parser.parse(['a.ts', '--verbose', 'b.ts'], { + args: z.object({ paths: z.array(z.string()) }), + options: z.object({ verbose: z.boolean().default(false) }), + }) + expect(result.args).toEqual({ paths: ['a.ts', 'b.ts'] }) + expect(result.options).toEqual({ verbose: true }) + }) + + test('validates each element of a variadic arg', () => { + expect(() => + Parser.parse(['open', 'invalid'], { + args: z.object({ states: z.array(z.enum(['open', 'closed'])) }), + }), + ).toThrow(expect.objectContaining({ name: 'Incur.ValidationError' })) + }) + + test('throws ValidationError on missing required variadic arg', () => { + expect(() => + Parser.parse([], { + args: z.object({ paths: z.array(z.string()) }), + }), + ).toThrow(expect.objectContaining({ name: 'Incur.ValidationError' })) + }) + + test('optional variadic arg may be omitted', () => { + const result = Parser.parse([], { + args: z.object({ paths: z.array(z.string()).optional() }), + }) + expect(result.args).toEqual({}) + }) + + test('variadic arg with default applies when omitted', () => { + const result = Parser.parse([], { + args: z.object({ paths: z.array(z.string()).default(['.']) }), + }) + expect(result.args).toEqual({ paths: ['.'] }) + }) + + test('throws on non-final array arg', () => { + expect(() => + Parser.parse(['a', 'b'], { + args: z.object({ paths: z.array(z.string()), target: z.string() }), + }), + ).toThrow('Variadic arg "paths" must be the last key in the args schema') + }) + test('parses --flag value options', () => { const result = Parser.parse(['--state', 'open'], { options: z.object({ state: z.string() }), diff --git a/src/Parser.ts b/src/Parser.ts index 71582444..7f0966b7 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -91,13 +91,18 @@ export function parse< } } - // Assign positionals to args schema keys in order - const rawArgs: Record = {} + // Assign positionals to args schema keys in order; a final array key collects the rest + const rawArgs: Record = {} if (argsSchema) { const keys = Object.keys(argsSchema.shape) for (let j = 0; j < keys.length; j++) { const key = keys[j]! - if (positionals[j] !== undefined) { + if (isArrayField(key, argsSchema)) { + if (j !== keys.length - 1) + throw new Error(`Variadic arg "${key}" must be the last key in the args schema`) + const rest = positionals.slice(j) + if (rest.length > 0) rawArgs[key] = rest + } else if (positionals[j] !== undefined) { rawArgs[key] = positionals[j]! } } @@ -229,8 +234,8 @@ function isCountOption(name: string, schema: z.ZodObject | undefined): bool return typeof field.meta === 'function' && field.meta()?.count === true } -/** Checks if an option's inner type is an array. */ -function isArrayOption(name: string, schema: z.ZodObject | undefined): boolean { +/** Checks if a field's inner type is an array. */ +function isArrayField(name: string, schema: z.ZodObject | undefined): boolean { if (!schema) return false const field = schema.shape[name] if (!field) return false @@ -244,7 +249,7 @@ function setOption( value: string, schema: z.ZodObject | undefined, ) { - if (isArrayOption(name, schema)) { + if (isArrayField(name, schema)) { const existing = raw[name] if (Array.isArray(existing)) { existing.push(value) diff --git a/src/Skill.test.ts b/src/Skill.test.ts index c2482ee9..0a793ad4 100644 --- a/src/Skill.test.ts +++ b/src/Skill.test.ts @@ -174,6 +174,17 @@ describe('index', () => { expect(result).toContain('`test install [package]`') }) + test('renders variadic args with ellipsis', () => { + const result = Skill.index('test', [ + { + name: 'lint', + description: 'Lint files', + args: z.object({ paths: z.array(z.string()) }), + }, + ]) + expect(result).toContain('`test lint `') + }) + test('handles commands without descriptions', () => { const result = Skill.index('test', [{ name: 'ping' }]) expect(result).toContain('| `test ping` | |') diff --git a/src/Skill.ts b/src/Skill.ts index 6de746f1..b58731dc 100644 --- a/src/Skill.ts +++ b/src/Skill.ts @@ -55,7 +55,11 @@ function buildSignature(cli: string, cmd: CommandInfo): string { const shape = cmd.args.shape as Record const json = Schema.toJsonSchema(cmd.args) const required = new Set((json.required as string[] | undefined) ?? []) - const argNames = Object.keys(shape).map((k) => (required.has(k) ? `<${k}>` : `[${k}]`)) + const properties = json.properties as Record> | undefined + const argNames = Object.keys(shape).map((k) => { + const label = properties?.[k]?.type === 'array' ? `${k}...` : k + return required.has(k) ? `<${label}>` : `[${label}]` + }) return `${base} ${argNames.join(' ')}` }