From 3873b8b76eb85bb0defa97ca2eb41d3857d1e0b9 Mon Sep 17 00:00:00 2001 From: figulusproject <269854178+figulusproject@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:33:40 -0400 Subject: [PATCH 1/2] let positionals carry an optional label, include it in auto-generated usage --- CHANGELOG.md | 6 +++++ README.md | 17 +++++++++++++ src/defineCli.test.ts | 31 ++++++++++++++++++++++++ src/defineCli.ts | 56 ++++++++++++++++++++++++++++++------------- src/index.ts | 1 + src/types.ts | 6 +++++ src/usage.ts | 8 +++++-- 7 files changed, 106 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 482a12b..b899f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added + +- `positionals` now also accepts `{ schema, label }`; when a `label` is given, `buildUsage()`/`cli.usage` prepends it before the flag list instead of silently omitting positionals. + ## [1.0.0] - 2026-08-08 ### Added diff --git a/README.md b/README.md index e2f0192..4b0262e 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,23 @@ const cli = defineCli({ Omit `positionals` and a stray bare argument is a parse error. +`positionals` also accepts `{ schema, label }` to include the positionals in the auto-generated `cli.usage`, prepended before the flags: + +```ts +const cli = defineCli({ + flags: { output: { schema: z.string() } }, + positionals: { + schema: z + .array(z.string()) + .length(2) + .transform(([source, destination]) => ({ source, destination })), + label: " ", + }, +}); + +cli.usage; // "Usage: --output " +``` + ## Copyright Copyright 2026, Figulus Project. diff --git a/src/defineCli.test.ts b/src/defineCli.test.ts index dac9397..dd2f935 100644 --- a/src/defineCli.test.ts +++ b/src/defineCli.test.ts @@ -258,6 +258,37 @@ describe("defineCli - positionals", () => { const result = cli.parse(["--output", "/tmp", "stray"]); expect(result.success).toBe(false); }); + + it("still accepts a bare schema (no label) and omits positionals from usage", () => { + const cli = defineCli({ + flags: { output: { schema: z.string() } }, + positionals: z.array(z.string()), + }); + expect(cli.usage).toBe("Usage: --output "); + + const result = cli.parse(["--output", "/tmp", "one", "two"]); + expect(result.success).toBe(true); + if (result.success) expect(result.positionals).toEqual(["one", "two"]); + }); + + it("prepends the label before the flags when positionals carry one", () => { + const cli = defineCli({ + flags: { output: { schema: z.string() } }, + positionals: { + schema: z + .array(z.string()) + .length(2) + .transform(([source, destination]) => ({ source, destination })), + label: " ", + }, + }); + expect(cli.usage).toBe("Usage: --output "); + + const result = cli.parse(["--output", "/tmp", "a", "b"]); + expect(result.success).toBe(true); + if (result.success) + expect(result.positionals).toEqual({ source: "a", destination: "b" }); + }); }); describe("defineCli - parseOrExit", () => { diff --git a/src/defineCli.ts b/src/defineCli.ts index 7f9c1ce..4a009d0 100644 --- a/src/defineCli.ts +++ b/src/defineCli.ts @@ -9,6 +9,7 @@ import type { FlagDescriptors, FlagRawValue, ParseResult, + PositionalsDescriptor, } from "./types.js"; import { buildUsage } from "./usage.js"; @@ -87,25 +88,44 @@ function toCliIssues(error: z.ZodError): CliIssue[] { })); } +type PositionalsInput = z.ZodType | PositionalsDescriptor; + +type SchemaOf = + TPositionalsInput extends PositionalsDescriptor + ? z.ZodType + : TPositionalsInput extends z.ZodType + ? TPositionalsInput + : undefined; + +function resolvePositionals(positionals: PositionalsInput | undefined): { + schema?: z.ZodType; + label?: string; +} { + if (positionals === undefined) return {}; + if (positionals instanceof z.ZodType) return { schema: positionals }; + return { schema: positionals.schema, label: positionals.label }; +} + export interface DefineCliOptions< TFlags extends FlagDescriptors, - TPositionalsSchema extends z.ZodType | undefined, + TPositionalsInput extends PositionalsInput | undefined, > { flags: TFlags; - positionals?: TPositionalsSchema; + positionals?: TPositionalsInput; usage?: string; } type InferFlags = { [K in keyof TFlags]: z.infer; }; -type InferPositionals = TPositionalsSchema extends z.ZodType - ? z.infer - : string[]; +type InferPositionals = + SchemaOf extends z.ZodType + ? z.infer> + : string[]; export interface CliDefinition< TFlags extends FlagDescriptors, - TPositionalsSchema extends z.ZodType | undefined, + TPositionalsInput extends PositionalsInput | undefined, > { flagsSchema: z.ZodObject<{ [K in keyof TFlags]: TFlags[K]["schema"] }>; parseArgsOptions: Record; @@ -113,21 +133,22 @@ export interface CliDefinition< parse>( argv: string[], overrideFlagsSchema?: z.ZodType, - ): ParseResult>; + ): ParseResult>; parseOrExit>( argv: string[], overrideFlagsSchema?: z.ZodType, - ): { data: TOut; positionals: InferPositionals }; + ): { data: TOut; positionals: InferPositionals }; } export function defineCli< TFlags extends FlagDescriptors, - TPositionalsSchema extends z.ZodType | undefined = undefined, + TPositionalsInput extends PositionalsInput | undefined = undefined, >( - def: DefineCliOptions, -): CliDefinition { + def: DefineCliOptions, +): CliDefinition { const resolved = resolveFlags(def.flags); const parseArgsOptions = buildParseArgsOptions(resolved); + const positionalsConfig = resolvePositionals(def.positionals); const shape = Object.fromEntries( resolved.map(({ key, descriptor }) => [key, descriptor.schema]), @@ -145,19 +166,20 @@ export function defineCli< isBoolean, isOptional: isOptionalFlag(descriptor.schema), })), + positionalsConfig.label, ); function parse>( argv: string[], overrideFlagsSchema?: z.ZodType, - ): ParseResult> { + ): ParseResult> { let values: Record; let positionalsRaw: string[]; try { const parsed = parseArgs({ args: normalizeArgv(argv), options: parseArgsOptions, - allowPositionals: def.positionals !== undefined, + allowPositionals: positionalsConfig.schema !== undefined, strict: true, }); values = parsed.values as Record; @@ -185,8 +207,8 @@ export function defineCli< any >; const flagsResult = schemaToUse.safeParse(raw); - const positionalsResult = def.positionals - ? def.positionals.safeParse(positionalsRaw) + const positionalsResult = positionalsConfig.schema + ? positionalsConfig.schema.safeParse(positionalsRaw) : { success: true as const, data: positionalsRaw }; if (!flagsResult.success || !positionalsResult.success) { @@ -209,14 +231,14 @@ export function defineCli< success: true, data: flagsResult.data, positionals: - positionalsResult.data as InferPositionals, + positionalsResult.data as InferPositionals, }; } function parseOrExit>( argv: string[], overrideFlagsSchema?: z.ZodType, - ): { data: TOut; positionals: InferPositionals } { + ): { data: TOut; positionals: InferPositionals } { const result = parse(argv, overrideFlagsSchema); if (!result.success) { console.error(result.error.message); diff --git a/src/index.ts b/src/index.ts index f72e572..a451860 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,4 +7,5 @@ export type { CliIssue, CliParseError, ParseResult, + PositionalsDescriptor, } from "./types.js"; diff --git a/src/types.ts b/src/types.ts index 1af76b5..9384704 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,12 @@ export interface FlagDescriptor { export type FlagDescriptors = Record>; +export interface PositionalsDescriptor { + schema: z.ZodType; + /** Shown in the auto-generated usage string, prepended before the flags. */ + label?: string; +} + export interface CliIssue { path: (string | number)[]; message: string; diff --git a/src/usage.ts b/src/usage.ts index c97ecff..b27d74e 100644 --- a/src/usage.ts +++ b/src/usage.ts @@ -11,7 +11,10 @@ interface UsageFlag { } // Auto-generated default for cli.usage; override via defineCli({ usage: "..." }). -export function buildUsage(resolved: UsageFlag[]): string { +export function buildUsage( + resolved: UsageFlag[], + positionalsLabel?: string, +): string { const parts = resolved.map(({ long, descriptor, isBoolean, isOptional }) => { const alias = descriptor.short ? `--${long}/-${descriptor.short}` @@ -29,5 +32,6 @@ export function buildUsage(resolved: UsageFlag[]): string { return isOptional || descriptor.negatable ? `[${core}]` : core; }); - return `Usage: ${parts.join(" ")}`; + const allParts = positionalsLabel ? [positionalsLabel, ...parts] : parts; + return `Usage: ${allParts.join(" ")}`; } From cd23c5adea6ea042fded5b6aef4a31fc7a996a5a Mon Sep 17 00:00:00 2001 From: figulusproject <269854178+figulusproject@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:39:51 -0400 Subject: [PATCH 2/2] version++ --- CHANGELOG.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b899f06..a89a1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [Unreleased] +## [1.0.1] - 2026-08-09 ### Added diff --git a/package-lock.json b/package-lock.json index 294b346..d275073 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "zod-cli-flags", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "zod-cli-flags", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "devDependencies": { "@types/node": "^22", diff --git a/package.json b/package.json index 681c798..c1ae2aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zod-cli-flags", - "version": "1.0.0", + "version": "1.0.1", "description": "Define a CLI's flags as a single Zod schema and parse argv into a typed result.", "type": "module", "main": "./dist/index.js",