Skip to content
Closed
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 src/Cli.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,13 +170,25 @@ test('OpenAPI-mounted operations are included in CLI command map type', () => {
responses: { '200': { description: 'ok' } },
},
},
'/widgets/{id}/actions': {
additionalOperations: {
Search: {
operationId: 'searchWidgetActions',
responses: { '200': { description: 'ok' } },
},
},
},
},
},
})

expectTypeOf<typeof cli>().toMatchTypeOf<
Cli.Cli<{
'api listUsers': { args: Record<string, unknown>; options: Record<string, unknown> }
'api searchWidgetActions': {
args: Record<string, unknown>
options: Record<string, unknown>
}
}>
>()
})
Expand Down
60 changes: 60 additions & 0 deletions src/Completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,66 @@ describe('complete', () => {
expect(candidates).toEqual([])
})

test('does not run transformed boolean schemas when completing flags', () => {
let calls = 0
const flag = z
.union([
z.boolean(),
z.enum(['true', 'false']).transform((value) => {
calls++
return value === 'true'
}),
])
.pipe(z.boolean())
const candidates = Completions.complete(
new Map([['status', { description: 'Status' }]]),
{ options: z.object({ flag: flag.optional() }) },
['mycli', '--flag', ''],
2,
)

expect(candidates.map((c) => c.value)).toEqual(['status'])
expect(calls).toBe(0)
})

test('does not treat arbitrary boolean preprocessors as boolean flags', () => {
const debug = z.preprocess((value) => {
if (value === 'yes') return true
if (value === 'no') return false
return 'invalid'
}, z.boolean())
const candidates = Completions.complete(
new Map([['status', { description: 'Status' }]]),
{ options: z.object({ debug }) },
['mycli', '--debug', ''],
2,
)

expect(candidates).toEqual([])
})

test('does not treat boolean literal options as boolean flags', () => {
const candidates = Completions.complete(
new Map([['status', { description: 'Status' }]]),
{ options: z.object({ exact: z.literal(false).optional() }) },
['mycli', '--exact', ''],
2,
)

expect(candidates).toEqual([])
})

test('does not treat string-parsed boolean options as boolean flags', () => {
const candidates = Completions.complete(
new Map([['status', { description: 'Status' }]]),
{ options: z.object({ debug: z.stringbool().optional() }) },
['mycli', '--debug', ''],
2,
)

expect(candidates).toEqual([])
})

test('includes descriptions', () => {
const cli = makeCli()
const commands = Cli.toCommands.get(cli)!
Expand Down
30 changes: 28 additions & 2 deletions src/Completions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { z } from 'zod'
import { z } from 'zod'

import type { Shell } from './internal/command.js'

Expand Down Expand Up @@ -196,7 +196,33 @@ function isBooleanOption(name: string, schema: z.ZodObject<any>): boolean {
const field = schema.shape[name]
if (!field) return false
if (typeof field.meta === 'function' && field.meta()?.count === true) return true
return unwrap(field).constructor.name === 'ZodBoolean'
const inner = unwrap(field)
if (inner.constructor.name === 'ZodBoolean') return true
// Generated OpenAPI booleans accept real booleans plus CLI "true"/"false" strings.
// Check that public schema shape instead of running user validation during introspection.
const input = z.toJSONSchema(inner, { unrepresentable: 'any', io: 'input' }) as {
anyOf?: { enum?: unknown[] | undefined; type?: unknown | undefined }[] | undefined
const?: unknown | undefined
type?: unknown | undefined
}
const output = z.toJSONSchema(inner, { unrepresentable: 'any', io: 'output' }) as {
const?: unknown | undefined
type?: unknown | undefined
}
if (
input.anyOf?.some((schema) => schema.type === 'boolean') &&
input.anyOf?.some(
(schema) =>
schema.type === 'string' &&
schema.enum?.includes('true') &&
schema.enum.includes('false') &&
schema.enum.length === 2,
) &&
output.type === 'boolean' &&
!('const' in output)
)
return true
return false
}

/** @internal Extracts possible values from enum schemas. */
Expand Down
62 changes: 62 additions & 0 deletions src/Help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,68 @@ describe('formatCommand', () => {
expect(line).toBe(' --dry-run Preview without submitting.')
})

test('does not run transformed boolean schemas when formatting help', () => {
let calls = 0
const dryRun = z
.union([
z.boolean(),
z.enum(['true', 'false']).transform((value) => {
calls++
return value === 'true'
}),
])
.pipe(z.boolean())

const result = Help.formatCommand('tool deploy', {
options: z.object({ dryRun: dryRun.optional().describe('Preview without submitting.') }),
})
const line = result.split('\n').find((line) => line.includes('--dry-run'))

expect(line).toBe(' --dry-run Preview without submitting.')
expect(calls).toBe(0)
})

test('keeps value placeholders for arbitrary boolean preprocessors', () => {
const debug = z
.preprocess((value) => {
if (value === 'yes') return true
if (value === 'no') return false
return 'invalid'
}, z.boolean())
.describe('Enable debug output.')

const result = Help.formatCommand('tool deploy', {
options: z.object({ debug }),
})
const line = result.split('\n').find((line) => line.includes('--debug'))

expect(line).toBe(' --debug <value> Enable debug output.')
})

test('keeps value placeholders for boolean literal options', () => {
const result = Help.formatCommand('tool deploy', {
options: z.object({
exact: z.literal(false).optional().describe('Must be false.'),
}),
})

const line = result.split('\n').find((line) => line.includes('--exact'))

expect(line).toBe(' --exact <value> Must be false.')
})

test('keeps value placeholders for string-parsed boolean options', () => {
const result = Help.formatCommand('tool deploy', {
options: z.object({
debug: z.stringbool().optional().describe('Enable debug output.'),
}),
})

const line = result.split('\n').find((line) => line.includes('--debug'))

expect(line).toBe(' --debug <value> Enable debug output.')
})

test('omits value placeholders for aliased boolean flag options', () => {
const result = Help.formatCommand('tool deploy', {
options: z.object({
Expand Down
30 changes: 30 additions & 0 deletions src/Help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,36 @@ function resolveTypeName(schema: unknown): string {
if (unwrapped instanceof z.ZodString) return 'string'
if (unwrapped instanceof z.ZodNumber) return 'number'
if (unwrapped instanceof z.ZodBoolean) return 'boolean'
// Generated OpenAPI booleans accept real booleans plus CLI "true"/"false" strings.
// Check that public schema shape instead of running user validation during introspection.
const input = z.toJSONSchema(unwrapped as z.ZodType, {
unrepresentable: 'any',
io: 'input',
}) as {
anyOf?: { enum?: unknown[] | undefined; type?: unknown | undefined }[] | undefined
const?: unknown | undefined
type?: unknown | undefined
}
const output = z.toJSONSchema(unwrapped as z.ZodType, {
unrepresentable: 'any',
io: 'output',
}) as {
const?: unknown | undefined
type?: unknown | undefined
}
if (
input.anyOf?.some((schema) => schema.type === 'boolean') &&
input.anyOf?.some(
(schema) =>
schema.type === 'string' &&
schema.enum?.includes('true') &&
schema.enum.includes('false') &&
schema.enum.length === 2,
) &&
output.type === 'boolean' &&
!('const' in output)
)
return 'boolean'
if (unwrapped instanceof z.ZodArray) return 'array'
if (unwrapped instanceof z.ZodEnum) {
const values = Object.values((unwrapped as any)._zod.def.entries) as string[]
Expand Down
Loading