diff --git a/src/cli/strictPositionals.ts b/src/cli/strictPositionals.ts index 6f5cc0a544..dba17a478c 100644 --- a/src/cli/strictPositionals.ts +++ b/src/cli/strictPositionals.ts @@ -62,20 +62,25 @@ export const rejectSurplusCliArguments = ( if (final !== undefined && tokenType(final) instanceof z.ZodArray) return; let tail = "__rea_unbound_positionals"; while (Object.hasOwn(fields, tail)) tail += "_"; - const args = z.object({ - ...Object.fromEntries( - entries.map(([name]) => [name, z.unknown().optional()]), - ), - [tail]: z.array(z.string()).optional(), - }); - const options = - command.options instanceof z.ZodObject - ? optionTokenShape(command.options) - : undefined; + const optionsSchema = + command.options instanceof z.ZodObject ? command.options : undefined; command.middleware = [ async (context, next) => { const tokens = invocation.getStore(); if (tokens !== undefined) { + // Only the resolved command needs an inert token-binding schema. + // Keep the original schema references, but do not prepare every + // registered command for each one-shot invocation. + const args = z.object({ + ...Object.fromEntries( + entries.map(([name]) => [name, z.unknown().optional()]), + ), + [tail]: z.array(z.string()).optional(), + }); + const options = + optionsSchema === undefined + ? undefined + : optionTokenShape(optionsSchema); // Incur supplies the invoked path, including aliases, after resolving // groups. No second router decides which command receives the tokens. const parsed = Parser.parse( diff --git a/tests/boundary/cli/strictPositionals.test.ts b/tests/boundary/cli/strictPositionals.test.ts index 44c931ae5a..48d4e0608e 100644 --- a/tests/boundary/cli/strictPositionals.test.ts +++ b/tests/boundary/cli/strictPositionals.test.ts @@ -175,3 +175,37 @@ it("uses resolved group and alias paths and preserves declared variadic inputs", values: ["one", "two", "three"], }); }); + +it("preserves counted aliases and negated booleans while checking the positional tail", async () => { + let runs = 0; + const cli = Cli.create("strict-counted-flags", { sync: false }); + cli.command("capture", { + args: z.object({ path: z.string() }), + options: z.object({ + enabled: z.boolean().default(true), + verbosity: z.number().default(0).meta({ count: true }), + }), + alias: { verbosity: "v" }, + run: ({ args, options }) => { + runs++; + return { args, options }; + }, + }); + installAdmission(cli); + const valid = await serve(cli, ["capture", "one", "--no-enabled", "-vvv"]); + expect(valid.exitCode).toBe(0); + expect(JSON.parse(valid.stdout)).toMatchObject({ + args: { path: "one" }, + options: { enabled: false, verbosity: 3 }, + }); + const surplus = await serve(cli, [ + "capture", + "one", + "--no-enabled", + "-vvv", + "extra", + ]); + expect(surplus.exitCode).toBe(1); + expect(surplus.stdout).toContain("Unexpected positional arguments"); + expect(runs).toBe(1); +});