Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/variadic-positional-args.md
Original file line number Diff line number Diff line change
@@ -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 }),
})
```
19 changes: 19 additions & 0 deletions src/Help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,25 @@ describe('formatCommand', () => {
expect(result).toContain('Usage: tool run <port> [verbose] <fast|slow>')
})

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 <target> <paths...>')
})

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({
Expand Down
4 changes: 2 additions & 2 deletions src/Help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,13 +244,13 @@ export function formatCommand(name: string, options: formatCommand.Options = {})
return lines.join('\n')
}

/** Builds the synopsis string with `<required>` and `[optional]` placeholders. */
/** Builds the synopsis string with `<required>`, `[optional]`, and `<variadic...>` placeholders. */
function buildSynopsis(name: string, args?: z.ZodObject<any>): 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(' ')
Expand Down
22 changes: 22 additions & 0 deletions src/Mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>()
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 })
Expand Down
14 changes: 14 additions & 0 deletions src/Parser.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand Down
61 changes: 61 additions & 0 deletions src/Parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() }),
Expand Down
17 changes: 11 additions & 6 deletions src/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,18 @@ export function parse<
}
}

// Assign positionals to args schema keys in order
const rawArgs: Record<string, string> = {}
// Assign positionals to args schema keys in order; a final array key collects the rest
const rawArgs: Record<string, unknown> = {}
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]!
}
}
Expand Down Expand Up @@ -229,8 +234,8 @@ function isCountOption(name: string, schema: z.ZodObject<any> | 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<any> | undefined): boolean {
/** Checks if a field's inner type is an array. */
function isArrayField(name: string, schema: z.ZodObject<any> | undefined): boolean {
if (!schema) return false
const field = schema.shape[name]
if (!field) return false
Expand All @@ -244,7 +249,7 @@ function setOption(
value: string,
schema: z.ZodObject<any> | undefined,
) {
if (isArrayOption(name, schema)) {
if (isArrayField(name, schema)) {
const existing = raw[name]
if (Array.isArray(existing)) {
existing.push(value)
Expand Down
11 changes: 11 additions & 0 deletions src/Skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paths...>`')
})

test('handles commands without descriptions', () => {
const result = Skill.index('test', [{ name: 'ping' }])
expect(result).toContain('| `test ping` | |')
Expand Down
6 changes: 5 additions & 1 deletion src/Skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ function buildSignature(cli: string, cmd: CommandInfo): string {
const shape = cmd.args.shape as Record<string, z.ZodType>
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<string, Record<string, unknown>> | 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(' ')}`
}

Expand Down
Loading