···11+---
22+"@bomb.sh/args": minor
33+---
44+55+`parse()` now automatically resolves camelCase ↔ kebab-case flag variants when using an `object()` schema. No aliases required — `--moduleTypes` and `--module-types` both map to a `moduleTypes` field.
66+77+```ts
88+const args = await parse(["--module-types", "esm"], {
99+ schema: object({ moduleTypes: string() }),
1010+});
1111+// args.moduleTypes → "esm"
1212+1313+const args2 = await parse(["--moduleTypes", "esm"], {
1414+ schema: object({ moduleTypes: string() }),
1515+});
1616+// args2.moduleTypes → "esm"
1717+```
1818+1919+This eliminates the need for manual workarounds like `--moduleTypes` → `--module-types` normalization.
+11
.changeset/parse-sync-env-terminator.md
···11+---
22+"@bomb.sh/args": minor
33+---
44+55+New features alongside the Standard Schema rewrite:
66+77+- **`parseSync(argv)`** — synchronous parsing with no schema validation, returns `RawArgs`.
88+- **`ParseError`** — thrown by `parse()` on schema validation failure. Carries a typed `issues: ReadonlyArray<StandardSchemaV1.Issue>` array.
99+- **`env` option** — `env: { prefix: 'APP' }` auto-injects `APP_*` environment variables as flag defaults before schema validation. Argv takes precedence.
1010+- **`--` terminator** — all arguments after `--` are collected into `_` verbatim, not parsed as flags.
1111+- **Typed positionals** — define `_` in your schema for full type safety (`z.tuple([z.string()])` for fixed-length, `z.array(z.string())` for variadic).
+5
.changeset/remove-json-file-schemas.md
···11+---
22+"@bomb.sh/args": major
33+---
44+55+Removed `json()` and `file()` from `@bomb.sh/args/schema`.
+26
.changeset/schema-metadata-methods.md
···11+---
22+"@bomb.sh/args": minor
33+---
44+55+Add `.alias()` and `.docs()` chainable methods to all built-in schema primitives.
66+77+`.alias(...names)` registers per-field aliases that are automatically extracted by `parse()` when using an `object()` schema — no manual `alias` map required.
88+99+`.docs(description)` attaches a description string to the schema for help text generation and tooling.
1010+1111+```ts
1212+import { object, number, boolean } from "@bomb.sh/args/schema";
1313+1414+const args = await parse(process.argv.slice(2), {
1515+ schema: object({
1616+ port: number().alias("p").docs("Port to listen on"),
1717+ verbose: boolean().alias("v").docs("Enable verbose output"),
1818+ }),
1919+});
2020+// parse(["-p", "3000"]) → { port: 3000 }
2121+// parse(["-v"]) → { verbose: true }
2222+```
2323+2424+Manual `opts.alias` entries take precedence over per-field aliases on conflict.
2525+2626+Also exports `ArgsSchema`, `SchemaMeta`, and `ObjectSchema` types for consumers building on top of the built-in primitives. `object()` now exposes a `shape` property for introspection.
+20
.changeset/schema-subpath.md
···11+---
22+"@bomb.sh/args": minor
33+---
44+55+Add `@bomb.sh/args/schema` subpath with built-in Standard Schema primitives requiring no external dependencies: `string`, `number`, `boolean`, `array`, `object`.
66+77+These work with `parse()` directly or compose with Zod, Valibot, Arktype, and any other `@standard-schema/spec`-compliant library.
88+99+```ts
1010+import { parse } from "@bomb.sh/args";
1111+import { object, number, boolean, string, array } from "@bomb.sh/args/schema";
1212+1313+const args = await parse(process.argv.slice(2), {
1414+ schema: object({
1515+ port: number(),
1616+ verbose: boolean(),
1717+ tags: array(string()),
1818+ }),
1919+});
2020+```
···11+# Arg Parser — Design Specification
22+33+## Overview
44+55+A minimal, standalone TypeScript library for parsing CLI arguments with full type safety. Decoupled from any routing or framework layer. Accepts a spec string, a StandardSchema V1 flag schema, and an optional alias map. Returns a fully typed, validated context object.
66+77+---
88+99+## Package API
1010+1111+### `parse(argv, options)`
1212+1313+```ts
1414+function parse<Spec extends string, Schema extends StandardSchemaV1>(
1515+ argv: string[],
1616+ options: {
1717+ spec: Spec;
1818+ schema: Schema;
1919+ alias?: Record<
2020+ string,
2121+ keyof Schema["~types"]["input"] | ExtractSpecNames<Spec>
2222+ >;
2323+ },
2424+): Promise<ParseResult<Spec, Schema>>;
2525+```
2626+2727+The return type is a `Promise` because schema validation (via StandardSchema V1) may be async.
2828+2929+---
3030+3131+## Spec String
3232+3333+The `spec` string describes the full command signature using standard CLI notation, inspired by docopt and POSIX conventions. It is intended to be human-readable and suitable for direct use in help text.
3434+3535+### Notation
3636+3737+| Syntax | Meaning | Output type (no schema override) |
3838+| ----------- | ----------------------------- | -------------------------------- |
3939+| `bare` | Subcommand or literal segment | Not included in output |
4040+| `<name>` | Required positional | `string` |
4141+| `[name]` | Optional positional | `string \| undefined` |
4242+| `[...name]` | Variadic positional | `string[]` |
4343+4444+### Examples
4545+4646+```
4747+'deploy <env> [region] [...targets]'
4848+'git commit <message>'
4949+'build'
5050+```
5151+5252+### Rules
5353+5454+- Bare words are treated as subcommand names and are not included in the parsed output.
5555+- Required positionals (`<name>`) must precede optional positionals (`[name]`).
5656+- The variadic positional (`[...name]`) must be the terminal segment if present. Only one variadic positional is allowed per spec.
5757+- Spec names are extracted at the type level to produce `ExtractSpecNames<Spec>`.
5858+5959+---
6060+6161+## Schema
6262+6363+The `schema` option accepts any StandardSchema V1-compatible schema (e.g. Zod, Valibot, ArkType). It serves two purposes:
6464+6565+1. **Flag definitions** — any key in the schema that does not appear in the spec is treated as a named flag.
6666+2. **Positional type overrides** — any key in the schema that matches a positional name in the spec overrides the default `string` type for that positional.
6767+6868+### Positional Type Resolution
6969+7070+For each name extracted from the spec, the output type is resolved as follows:
7171+7272+| Spec form | No schema entry | Schema entry `T` | Schema entry `T[]` |
7373+| ----------- | --------------------- | ---------------- | ------------------ |
7474+| `<name>` | `string` | `T` | `T[]` |
7575+| `[name]` | `string \| undefined` | `T \| undefined` | `T[] \| undefined` |
7676+| `[...name]` | `string[]` | `T[]` | `T[]` ← unwrapped |
7777+7878+**Variadicity is always declared in the spec, never in the schema.** If a variadic spec entry (`[...name]`) maps to a schema entry of type `T[]`, the array is silently unwrapped to avoid `T[][]`. There is no CLI use case for a nested array output type.
7979+8080+A schema entry typed as `T[]` on a non-variadic positional is left as-is — the user is expected to handle their own parsing of the raw string value (e.g. comma-separated input).
8181+8282+---
8383+8484+## Aliases
8585+8686+The `alias` option maps short or alternate names to their canonical targets. Alias targets must be either a key of the schema's input type or a positional name extracted from the spec. This constraint is enforced at the type level.
8787+8888+```ts
8989+alias: {
9090+ e: 'env', // positional alias
9191+ f: 'force', // flag alias
9292+ r: 'region' // optional positional alias
9393+}
9494+```
9595+9696+**Alias resolution happens before validation.** By the time the schema is called, all aliases have been resolved to their canonical names. The output object is always keyed by canonical names only — aliases are invisible in the output type.
9797+9898+---
9999+100100+## Argument Parsing Rules
101101+102102+### Positional filling
103103+104104+Positionals are filled left-to-right from non-flag tokens in argv. A positional may also be passed by name (e.g. `--env prod`), in which case the named form takes precedence over positional filling. If both a named and positional form are provided for the same argument, the named form wins.
105105+106106+### Variadic collection
107107+108108+A variadic positional (`[...name]`) collects all remaining non-flag tokens after prior positionals are filled. When passed by name, it uses space-separated values (`--targets a b c`), stopping at the next flag token.
109109+110110+### Flag parsing
111111+112112+Flags are parsed from tokens starting with `--` (long form) or `-` (short form, alias only). Boolean flags are `true` when present, `false` when absent. Value flags consume the next token or use `=` syntax (`--tag=v1.0`).
113113+114114+### Precedence summary
115115+116116+1. Named form (`--name value`) wins over positional form for the same argument.
117117+2. Aliases resolve to canonical names before any other processing.
118118+3. Subcommand bare words are matched and consumed before positional filling begins.
119119+120120+---
121121+122122+## Output Type
123123+124124+The resolved output type is the intersection of spec-inferred positionals and the schema output type:
125125+126126+```ts
127127+type ParseResult<
128128+ Spec extends string,
129129+ Schema extends StandardSchemaV1,
130130+> = InferSpecOutput<Spec, Schema> & Schema["~types"]["output"];
131131+```
132132+133133+Where `InferSpecOutput` maps each spec positional to its resolved type (per the resolution table above), and `Schema['~types']['output']` contributes all flag types. Since the schema may also contain keys that match positional names (for type overrides), positional types in the intersection take their resolved form — the schema's contribution for that key is subsumed.
134134+135135+---
136136+137137+## Validation
138138+139139+Parsing and validation are two distinct phases:
140140+141141+1. **Parse** — tokenise argv, resolve aliases, fill positionals, collect flags. Produces a raw unvalidated record.
142142+2. **Validate** — pass the raw record through the StandardSchema. Returns `Promise<output>` to accommodate async validators.
143143+144144+If validation fails, `parse()` rejects with the schema's validation error. The library does not catch or transform schema errors — they propagate as-is.
145145+146146+---
147147+148148+## Constraints and Invariants
149149+150150+- A positional name and a flag schema key must not collide. If `spec` contains `<env>` and `schema` also defines `env` as a flag (rather than a type override), this is ambiguous. The spec takes ownership of the name; the schema entry is treated as a type override, not a flag.
151151+- Variadic positionals must be terminal in the spec. A non-terminal variadic is a runtime error thrown before parsing begins.
152152+- Optional positionals must all be terminal and contiguous. A required positional may not follow an optional one.
153153+- Only one variadic positional is permitted per spec.
154154+- Alias targets that do not resolve to a known spec name or schema key produce a type error.
155155+156156+---
157157+158158+## Subpath: `cli-parser/schema`
159159+160160+A companion subpath exposes helpers for CLI-specific schema constructs:
161161+162162+- `path()` — a schema for filesystem paths, with coercion and existence validation hooks.
163163+164164+These helpers are thin wrappers over StandardSchema-compatible schemas and are designed to be passed directly as values in the `schema` option.
+136-85
src/index.ts
···11import type {
22- Aliases,
33- Args,
44- BooleanType,
55- Collectable,
22+ ArgsSchema,
33+ EnvOptions,
64 NestedMapping,
75 ParseOptions,
88- StringType,
99- Values,
66+ ParseResult,
77+ RawArgs,
88+ RawValue,
99+ SchemaMeta,
1010+ StandardSchemaV1,
1011} from "./types.js";
1111-export { ParseOptions, Args } from "./types.js";
1212+export { ParseError, ParseOptions, RawArgs, StandardSchemaV1, ArgsSchema, SchemaMeta } from "./types.js";
1313+import { ParseError } from "./types.js";
1414+import type { ObjectSchema } from "./schema.js";
12151316const BOOL_RE = /^(true|false)$/;
1417const QUOTED_RE = /^('|").*\1$/;
15181616-const set = (obj: NestedMapping, key: string, value: any, type?: string) => {
1919+const set = (obj: NestedMapping, key: string, value: unknown, collect?: boolean) => {
1720 if (key.includes(".")) {
1821 const parts = key.split(".");
1922 for (let i = 0; i < parts.length - 1; i++) {
2023 const k = parts[i];
2121- const tmp = {};
2424+ const tmp: NestedMapping = {};
2225 set(obj, k, tmp);
2326 obj = tmp;
2427 }
2528 key = parts[parts.length - 1];
2629 }
2727- if (type === "array" && obj[key] !== undefined) {
3030+ if (collect && obj[key] !== undefined) {
2831 if (Array.isArray(obj[key])) {
2929- (obj[key] as any[]).push(value);
3232+ (obj[key] as unknown[]).push(value);
3033 } else {
3134 obj[key] = [obj[key], value];
3235 }
3336 } else {
3434- obj[key] = type === "array" ? [value] : value;
3737+ obj[key] = collect ? [value] : value;
3538 }
3639};
37403838-const type = (
3939- key: string,
4040- opts: Record<"boolean" | "string" | "array", string[]>,
4141-): "boolean" | "string" | "array" | undefined => {
4242- if (opts.array && opts.array.length > 0 && opts.array.includes(key))
4343- return "array";
4444- if (opts.string && opts.string.length > 0 && opts.string.includes(key))
4545- return "string";
4646- if (opts.boolean && opts.boolean.length > 0 && opts.boolean.includes(key))
4747- return "boolean";
4848- return;
4949-};
5050-5151-const defaultValue = (type?: "boolean" | "string" | "array") => {
5252- if (type === "string") return "";
5353- if (type === "array") return [];
5454- return true;
5555-};
5656-5757-const coerce = (value?: string, type?: "string" | "boolean" | "array") => {
5858- if (type === "string") return value;
5959- if (type === "boolean") return value === undefined ? true : value === "true";
6060-6161- if (!value) return value;
4141+const coerce = (value?: string): RawValue | undefined => {
4242+ if (value === undefined) return undefined;
6243 if (value.length > 3 && BOOL_RE.test(value)) return value === "true";
6344 if (value.length > 2 && QUOTED_RE.test(value)) return value.slice(1, -1);
6445 if ((value[0] === "." && /\d/.test(value[1])) || /\d/.test(value[0]))
···6647 return value;
6748};
68496969-export function parse<
7070- TArgs extends Values<
7171- TBooleans,
7272- TStrings,
7373- TCollectable,
7474- undefined,
7575- TDefaults,
7676- TAliases
7777- >,
7878- TBooleans extends BooleanType = undefined,
7979- TStrings extends StringType = undefined,
8080- TCollectable extends Collectable = undefined,
8181- TDefaults extends Record<string, unknown> | undefined = undefined,
8282- TAliases extends Aliases<TAliasArgNames, TAliasNames> | undefined = undefined,
8383- TAliasArgNames extends string = string,
8484- TAliasNames extends string = string,
8585->(
5050+function applyEnv(raw: RawArgs, { prefix }: EnvOptions): void {
5151+ const p = prefix.toUpperCase() + "_";
5252+ for (const [envKey, envVal] of Object.entries(process.env)) {
5353+ if (!envKey.startsWith(p) || envVal === undefined) continue;
5454+ const flag = envKey.slice(p.length).toLowerCase().replace(/_/g, "-");
5555+ if (!(flag in raw)) {
5656+ const coerced = coerce(envVal);
5757+ if (coerced !== undefined) raw[flag] = coerced;
5858+ }
5959+ }
6060+}
6161+6262+/** Convert camelCase to kebab-case: "moduleTypes" → "module-types" */
6363+function toKebab(str: string): string {
6464+ return str.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
6565+}
6666+6767+/** Convert kebab-case to camelCase: "module-types" → "moduleTypes" */
6868+function toCamel(str: string): string {
6969+ return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
7070+}
7171+7272+/**
7373+ * Extract aliases from a schema's shape (if it's an ObjectSchema).
7474+ * For each field:
7575+ * - Per-field `.alias()` names → { aliasName: fieldName }
7676+ * - camelCase field names → auto-add kebab-case alias: { "module-types": "moduleTypes" }
7777+ * - kebab-case field names → auto-add camelCase alias: { "moduleTypes": "module-types" }
7878+ */
7979+function extractAliases(schema: StandardSchemaV1): Record<string, string> {
8080+ const result: Record<string, string> = {};
8181+ if (!("shape" in schema) || typeof (schema as any).shape !== "object" || (schema as any).shape === null) {
8282+ return result;
8383+ }
8484+ const shape = (schema as ObjectSchema<Record<string, StandardSchemaV1>>).shape;
8585+ for (const [fieldName, fieldSchema] of Object.entries(shape)) {
8686+ // Per-field aliases from .alias()
8787+ if ("~meta" in fieldSchema) {
8888+ const meta = (fieldSchema as ArgsSchema)["~meta"] as SchemaMeta;
8989+ if (meta.aliases) {
9090+ for (const a of meta.aliases) {
9191+ result[a] = fieldName;
9292+ }
9393+ }
9494+ }
9595+ // Auto camelCase↔kebab-case
9696+ if (/[A-Z]/.test(fieldName)) {
9797+ // camelCase field → add kebab alias
9898+ const kebab = toKebab(fieldName);
9999+ if (!(kebab in result)) result[kebab] = fieldName;
100100+ } else if (fieldName.includes("-")) {
101101+ // kebab-case field → add camelCase alias
102102+ const camel = toCamel(fieldName);
103103+ if (!(camel in result)) result[camel] = fieldName;
104104+ }
105105+ }
106106+ return result;
107107+}
108108+109109+function parseRaw(
86110 argv: string[],
8787- {
8888- default: defaults,
8989- alias: aliases,
9090- ...types
9191- }: ParseOptions<TBooleans, TStrings, TCollectable, TDefaults, TAliases> = {},
9292-): Args<TArgs> {
9393- const obj = { ...defaults, _: [] } as unknown as Args<TArgs>;
111111+ aliases?: Record<string, string>,
112112+): RawArgs {
113113+ const obj: RawArgs = { _: [] };
94114 if (argv.length === 0) return obj;
9511596116 for (let i = 0; i < argv.length; i++) {
97117 const curr = argv[i];
98118 const next = argv[i + 1];
99119100100- let t: "string" | "boolean" | "array" | undefined;
120120+ // -- terminator: everything after is a raw positional
121121+ if (curr === "--") {
122122+ for (let j = i + 1; j < argv.length; j++) {
123123+ const v = coerce(argv[j]);
124124+ obj._.push(v !== undefined ? v : argv[j] as RawValue);
125125+ }
126126+ break;
127127+ }
128128+101129 let key = "";
102130 let value: string | undefined;
103131104132 if (curr.length > 1 && curr[0] === "-") {
105133 if (curr[1] !== "-" && curr.length > 2 && !curr.includes("=")) {
134134+ // Short combined flags: -abc or -a.b (dotted short)
106135 if (curr.includes(".")) {
107136 key = curr.slice(1, 2);
108137 value = curr.slice(2);
109138 } else {
139139+ // Expand all but last as boolean flags
110140 const keys = curr.slice(1, -1);
111111- for (let key of keys) {
112112- if (
113113- aliases &&
114114- (aliases as Record<string, any>)[key] !== undefined
115115- ) {
116116- key = aliases[key as keyof typeof aliases] as string;
117117- }
118118- set(obj, key, defaultValue(t), t);
141141+ for (let k of keys) {
142142+ if (aliases?.[k] !== undefined) k = aliases[k];
143143+ set(obj, k, true);
119144 }
120145 key = curr.slice(-1);
121146 if (next && next[0] !== "-") {
···124149 }
125150 }
126151 } else if (!curr.includes("=") && next && next[0] !== "-") {
152152+ // --flag value
127153 key = curr.replace(/^-{1,2}/, "");
128128- t = type(key, types as any);
129129- // treat boolean as flag without parameter
130130- if (t === "boolean") {
131131- value = "true";
132132- } else {
133133- value = next;
134134- i++;
135135- }
154154+ value = next;
155155+ i++;
136156 } else {
157157+ // --flag or --flag=value
137158 const eq = curr.indexOf("=");
138159 if (eq === -1) {
139160 key = curr.replace(/^-{1,2}/, "");
···141162 key = curr.slice(0, eq).replace(/^-{1,2}/, "");
142163 value = curr.slice(eq + 1);
143164 }
144144- t = type(key, types as any);
145165 }
146166147147- if ((!t || t === "boolean") && key.length > 3 && key.startsWith("no-")) {
167167+ if (key.length > 3 && key.startsWith("no-")) {
148168 set(obj, key.slice(3), false);
149169 } else {
150150- if (aliases && (aliases as Record<string, any>)[key] !== undefined) {
151151- key = aliases[key as keyof typeof aliases] as string;
152152- }
153153- set(obj, key, coerce(value, t) ?? defaultValue(t), t);
170170+ if (aliases?.[key] !== undefined) key = aliases[key];
171171+ set(obj, key, coerce(value) ?? true);
154172 }
155173 } else if (curr) {
156156- (obj as any)._.push(coerce(curr));
157157- continue;
174174+ const v = coerce(curr);
175175+ obj._.push(v !== undefined ? v : curr as RawValue);
158176 }
159177 }
160178161179 return obj;
162180}
181181+182182+export async function parse<
183183+ TSchema extends StandardSchemaV1 | undefined = undefined,
184184+ TAliases extends Record<string, string> | undefined = undefined,
185185+>(
186186+ argv: string[],
187187+ opts?: ParseOptions<TSchema, TAliases>,
188188+): Promise<ParseResult<TSchema>> {
189189+ const schemaAliases = opts?.schema ? extractAliases(opts.schema) : {};
190190+ const mergedAliases = { ...schemaAliases, ...opts?.alias };
191191+ const hasAliases = Object.keys(mergedAliases).length > 0;
192192+193193+ const raw = parseRaw(argv, hasAliases ? mergedAliases : undefined);
194194+ if (opts?.env) applyEnv(raw, opts.env);
195195+196196+ if (!opts?.schema) return raw as ParseResult<TSchema>;
197197+198198+ let result = opts.schema["~standard"].validate(raw);
199199+ if (result instanceof Promise) result = await result;
200200+ if (result.issues) {
201201+ throw new ParseError(result.issues);
202202+ }
203203+ return result.value as ParseResult<TSchema>;
204204+}
205205+206206+export function parseSync(
207207+ argv: string[],
208208+ opts?: Omit<ParseOptions<undefined, Record<string, string>>, "schema">,
209209+): RawArgs {
210210+ const raw = parseRaw(argv, opts?.alias);
211211+ if (opts?.env) applyEnv(raw, opts.env);
212212+ return raw;
213213+}
+149
src/schema.ts
···11+import type { StandardSchemaV1 } from "@standard-schema/spec";
22+import type { ArgsSchema, SchemaMeta } from "./types.js";
33+44+type Issue = StandardSchemaV1.Issue;
55+type Result<T> = StandardSchemaV1.Result<T>;
66+77+function ok<T>(value: T): Result<T> {
88+ return { value };
99+}
1010+1111+function fail(message: string, path?: PropertyKey[]): Result<never> {
1212+ const issue: Issue = path ? { message, path } : { message };
1313+ return { issues: [issue] };
1414+}
1515+1616+function makeSchema<I, O>(
1717+ validate: (value: I) => Result<O> | Promise<Result<O>>,
1818+): ArgsSchema<I, O> {
1919+ const schema = {
2020+ "~standard": {
2121+ version: 1,
2222+ vendor: "@bomb.sh/args",
2323+ validate: validate as (value: unknown) => Result<O> | Promise<Result<O>>,
2424+ },
2525+ "~meta": {} as SchemaMeta,
2626+ docs(description: string) {
2727+ this["~meta"].docs = description;
2828+ return this;
2929+ },
3030+ alias(...names: string[]) {
3131+ this["~meta"].aliases = [...(this["~meta"].aliases ?? []), ...names];
3232+ return this;
3333+ },
3434+ } as ArgsSchema<I, O>;
3535+ return schema;
3636+}
3737+3838+export function string(): ArgsSchema<unknown, string> {
3939+ return makeSchema((value) => {
4040+ if (typeof value === "string") return ok(value);
4141+ if (typeof value === "number" || typeof value === "boolean")
4242+ return ok(String(value));
4343+ return fail(`Expected string, received ${typeof value}`);
4444+ });
4545+}
4646+4747+export function number(): ArgsSchema<unknown, number> {
4848+ return makeSchema((value) => {
4949+ if (typeof value === "number") return ok(value);
5050+ if (typeof value === "string") {
5151+ const n = Number(value);
5252+ if (!Number.isNaN(n)) return ok(n);
5353+ }
5454+ return fail(`Expected number, received ${typeof value}`);
5555+ });
5656+}
5757+5858+export function boolean(): ArgsSchema<unknown, boolean> {
5959+ return makeSchema((value) => {
6060+ if (typeof value === "boolean") return ok(value);
6161+ if (value === "true" || value === "1" || value === 1) return ok(true);
6262+ if (value === "false" || value === "0" || value === 0) return ok(false);
6363+ return fail(`Expected boolean, received ${typeof value}`);
6464+ });
6565+}
6666+6767+export function array<T = unknown>(
6868+ item?: StandardSchemaV1<unknown, T>,
6969+): ArgsSchema<unknown, T[]> {
7070+ return makeSchema(async (value) => {
7171+ if (!Array.isArray(value))
7272+ return fail(`Expected array, received ${typeof value}`);
7373+ if (!item) return ok(value as T[]);
7474+7575+ const out: T[] = [];
7676+ const issues: Issue[] = [];
7777+ for (let i = 0; i < value.length; i++) {
7878+ let r = item["~standard"].validate(value[i]);
7979+ if (r instanceof Promise) r = await r;
8080+ if (r.issues) {
8181+ for (const issue of r.issues) {
8282+ issues.push({
8383+ message: issue.message,
8484+ path: [i, ...(issue.path ?? [])],
8585+ });
8686+ }
8787+ } else {
8888+ out.push(r.value);
8989+ }
9090+ }
9191+ return issues.length > 0 ? { issues } : ok(out);
9292+ });
9393+}
9494+9595+type ShapeOutput<T extends Record<string, StandardSchemaV1>> = {
9696+ [K in keyof T]: StandardSchemaV1.InferOutput<T[K]>;
9797+};
9898+9999+export interface ObjectSchema<T extends Record<string, StandardSchemaV1>>
100100+ extends ArgsSchema<unknown, ShapeOutput<T>> {
101101+ readonly shape: T;
102102+}
103103+104104+export function object<T extends Record<string, StandardSchemaV1>>(
105105+ shape: T,
106106+): ObjectSchema<T> {
107107+ const validate = async (value: unknown): Promise<Result<ShapeOutput<T>>> => {
108108+ if (typeof value !== "object" || value === null || Array.isArray(value))
109109+ return fail(`Expected object, received ${typeof value}`);
110110+111111+ const input = value as Record<string, unknown>;
112112+ const out: Record<string, unknown> = {};
113113+ const issues: Issue[] = [];
114114+115115+ for (const [key, schema] of Object.entries(shape)) {
116116+ let r = schema["~standard"].validate(input[key]);
117117+ if (r instanceof Promise) r = await r;
118118+ if (r.issues) {
119119+ for (const issue of r.issues) {
120120+ issues.push({
121121+ message: issue.message,
122122+ path: [key, ...(issue.path ?? [])],
123123+ });
124124+ }
125125+ } else {
126126+ out[key] = r.value;
127127+ }
128128+ }
129129+ return issues.length > 0 ? { issues } : ok(out as ShapeOutput<T>);
130130+ };
131131+132132+ return {
133133+ "~standard": {
134134+ version: 1,
135135+ vendor: "@bomb.sh/args",
136136+ validate,
137137+ },
138138+ "~meta": {} as SchemaMeta,
139139+ shape,
140140+ docs(description: string) {
141141+ this["~meta"].docs = description;
142142+ return this;
143143+ },
144144+ alias(...names: string[]) {
145145+ this["~meta"].aliases = [...(this["~meta"].aliases ?? []), ...names];
146146+ return this;
147147+ },
148148+ } as ObjectSchema<T>;
149149+}
+42-269
src/types.ts
···11-/** Combines recursively all intersection types and returns a new single type. */
22-type Id<TRecord> = TRecord extends Record<string, unknown>
33- ? TRecord extends infer InferredRecord
44- ? { [Key in keyof InferredRecord]: Id<InferredRecord[Key]> }
55- : never
66- : TRecord;
77-88-/** Converts a union type `A | B | C` into an intersection type `A & B & C`. */
99-type UnionToIntersection<TValue> = (
1010- TValue extends unknown
1111- ? (args: TValue) => unknown
1212- : never
1313-) extends (args: infer R) => unknown
1414- ? R extends Record<string, unknown>
1515- ? R
1616- : never
1717- : never;
1818-1919-export type BooleanType = boolean | string | undefined;
2020-export type StringType = string | undefined;
2121-export type ArgType = StringType | BooleanType;
2222-2323-export type Collectable = string | undefined;
2424-export type Negatable = string | undefined;
2525-2626-type UseTypes<
2727- TBooleans extends BooleanType,
2828- TStrings extends StringType,
2929- TCollectable extends Collectable,
3030-> = undefined extends (false extends TBooleans ? undefined : TBooleans) &
3131- TCollectable &
3232- TStrings
3333- ? false
3434- : true;
3535-3636-/**
3737- * Creates a record with all available flags with the corresponding type and
3838- * default type.
3939- */
4040-export type Values<
4141- TBooleans extends BooleanType,
4242- TStrings extends StringType,
4343- TCollectable extends Collectable,
4444- TNegatable extends Negatable,
4545- TDefault extends Record<string, unknown> | undefined,
4646- TAliases extends Aliases | undefined,
4747-> = UseTypes<TBooleans, TStrings, TCollectable> extends true
4848- ? Record<string, unknown> &
4949- AddAliases<
5050- SpreadDefaults<
5151- CollectValues<TStrings, string, TCollectable, TNegatable> &
5252- RecursiveRequired<CollectValues<TBooleans, boolean, TCollectable>> &
5353- CollectUnknownValues<TBooleans, TStrings, TCollectable, TNegatable>,
5454- DedotRecord<TDefault>
5555- >,
5656- TAliases
5757- >
5858- : // deno-lint-ignore no-explicit-any
5959- Record<string, any>;
11+import type { StandardSchemaV1 } from "@standard-schema/spec";
22+export type { StandardSchemaV1 };
6036161-export type Aliases<
6262- TArgNames = string,
6363- TAliasNames extends string = string,
6464-> = Partial<
6565- Record<Extract<TArgNames, string>, TAliasNames | ReadonlyArray<TAliasNames>>
6666->;
6767-6868-type AddAliases<TArgs, TAliases extends Aliases | undefined> = {
6969- [TArgName in keyof TArgs as AliasNames<TArgName, TAliases>]: TArgs[TArgName];
7070-};
7171-7272-type AliasNames<
7373- TArgName,
7474- TAliases extends Aliases | undefined,
7575-> = TArgName extends keyof TAliases
7676- ? string extends TAliases[TArgName]
7777- ? TArgName
7878- : TAliases[TArgName] extends string
7979- ? TArgName | TAliases[TArgName]
8080- : TAliases[TArgName] extends Array<string>
8181- ? TArgName | TAliases[TArgName][number]
8282- : TArgName
8383- : TArgName;
44+export interface SchemaMeta {
55+ docs?: string;
66+ aliases?: string[];
77+}
8488585-/**
8686- * Spreads all default values of Record `TDefaults` into Record `TArgs`
8787- * and makes default values required.
8888- *
8989- * **Example:**
9090- * `SpreadValues<{ foo?: boolean, bar?: number }, { foo: number }>`
9191- *
9292- * **Result:** `{ foo: boolean | number, bar?: number }`
9393- */
9494-type SpreadDefaults<TArgs, TDefaults> = TDefaults extends undefined
9595- ? TArgs
9696- : TArgs extends Record<string, unknown>
9797- ? Omit<TArgs, keyof TDefaults> & {
9898- [Default in keyof TDefaults]: Default extends keyof TArgs
9999- ?
100100- | (TArgs[Default] & TDefaults[Default])
101101- | TDefaults[Default] extends Record<string, unknown>
102102- ? NonNullable<SpreadDefaults<TArgs[Default], TDefaults[Default]>>
103103- : TDefaults[Default] | NonNullable<TArgs[Default]>
104104- : unknown;
105105- }
106106- : never;
99+export interface ArgsSchema<I = unknown, O = I> extends StandardSchemaV1<I, O> {
1010+ readonly "~meta": SchemaMeta;
1111+ docs(description: string): this;
1212+ alias(...names: string[]): this;
1313+}
10714108108-/**
109109- * Defines the Record for the `default` option to add
110110- * auto-suggestion support for IDE's.
111111- */
112112-type Defaults<TBooleans extends BooleanType, TStrings extends StringType> = Id<
113113- UnionToIntersection<
114114- Record<string, unknown> &
115115- // Dedotted auto suggestions: { foo: { bar: unknown } }
116116- MapTypes<TStrings, unknown> &
117117- MapTypes<TBooleans, unknown> &
118118- // Flat auto suggestions: { "foo.bar": unknown }
119119- MapDefaults<TBooleans> &
120120- MapDefaults<TStrings>
121121- >
122122->;
1515+export type RawValue = string | number | boolean;
12316124124-type MapDefaults<TArgNames extends ArgType> = Partial<
125125- Record<TArgNames extends string ? TArgNames : string, unknown>
126126->;
1717+export interface RawArgs {
1818+ _: RawValue[];
1919+ [key: string]: RawValue | RawValue[] | RawArgs;
2020+}
12721128128-type RecursiveRequired<TRecord> = TRecord extends Record<string, unknown>
129129- ? {
130130- [Key in keyof TRecord]-?: RecursiveRequired<TRecord[Key]>;
131131- }
132132- : TRecord;
2222+export interface EnvOptions {
2323+ prefix: string;
2424+}
13325134134-/** Same as `MapTypes` but also supports collectable options. */
135135-type CollectValues<
136136- TArgNames extends ArgType,
137137- TType,
138138- TCollectable extends Collectable,
139139- TNegatable extends Negatable = undefined,
140140-> = UnionToIntersection<
141141- Extract<TArgNames, TCollectable> extends string
142142- ? (Exclude<TArgNames, TCollectable> extends never
143143- ? Record<never, never>
144144- : MapTypes<Exclude<TArgNames, TCollectable>, TType, TNegatable>) &
145145- (Extract<TArgNames, TCollectable> extends never
146146- ? Record<never, never>
147147- : RecursiveRequired<
148148- MapTypes<
149149- Extract<TArgNames, TCollectable>,
150150- Array<TType>,
151151- TNegatable
152152- >
153153- >)
154154- : MapTypes<TArgNames, TType, TNegatable>
155155->;
156156-157157-/** Same as `Record` but also supports dotted and negatable options. */
158158-type MapTypes<
159159- TArgNames extends ArgType,
160160- TType,
161161- TNegatable extends Negatable = undefined,
162162-> = undefined extends TArgNames
163163- ? Record<never, never>
164164- : TArgNames extends `${infer Name}.${infer Rest}`
165165- ? {
166166- [Key in Name]?: MapTypes<
167167- Rest,
168168- TType,
169169- TNegatable extends `${Name}.${infer Negate}` ? Negate : undefined
170170- >;
171171- }
172172- : TArgNames extends string
173173- ? Partial<
174174- Record<
175175- TArgNames,
176176- TNegatable extends TArgNames ? TType | false : TType
177177- >
178178- >
179179- : Record<never, never>;
180180-181181-type CollectUnknownValues<
182182- TBooleans extends BooleanType,
183183- TStrings extends StringType,
184184- TCollectable extends Collectable,
185185- TNegatable extends Negatable,
186186-> = UnionToIntersection<
187187- TCollectable extends TBooleans & TStrings
188188- ? Record<never, never>
189189- : DedotRecord<
190190- // Unknown collectable & non-negatable args.
191191- Record<
192192- Exclude<
193193- Extract<Exclude<TCollectable, TNegatable>, string>,
194194- Extract<TStrings | TBooleans, string>
195195- >,
196196- Array<unknown>
197197- > &
198198- // Unknown collectable & negatable args.
199199- Record<
200200- Exclude<
201201- Extract<Extract<TCollectable, TNegatable>, string>,
202202- Extract<TStrings | TBooleans, string>
203203- >,
204204- Array<unknown> | false
205205- >
206206- >
207207->;
208208-209209-/** Converts `{ "foo.bar.baz": unknown }` into `{ foo: { bar: { baz: unknown } } }`. */
210210-type DedotRecord<TRecord> = Record<string, unknown> extends TRecord
211211- ? TRecord
212212- : TRecord extends Record<string, unknown>
213213- ? UnionToIntersection<
214214- ValueOf<{
215215- [Key in keyof TRecord]: Key extends string
216216- ? Dedot<Key, TRecord[Key]>
217217- : never;
218218- }>
219219- >
220220- : TRecord;
221221-222222-type Dedot<
223223- TKey extends string,
224224- TValue,
225225-> = TKey extends `${infer Name}.${infer Rest}`
226226- ? { [Key in Name]: Dedot<Rest, TValue> }
227227- : { [Key in TKey]: TValue };
228228-229229-type ValueOf<TValue> = TValue[keyof TValue];
230230-231231-/** The value returned from `parse`. */
232232-export type Args<
233233- // deno-lint-ignore no-explicit-any
234234- TArgs extends Record<string, unknown> = Record<string, any>,
235235-> = Id<
236236- TArgs & {
237237- /** Contains all the arguments that didn't have an option associated with
238238- * them. */
239239- _: Array<string | number | boolean>;
240240- }
241241->;
242242-243243-/** The options for the `parse` call. */
24426export interface ParseOptions<
245245- TBooleans extends BooleanType = BooleanType,
246246- TStrings extends StringType = StringType,
247247- TCollectable extends Collectable = Collectable,
248248- TDefault extends Record<string, unknown> | undefined =
249249- | Record<string, unknown>
250250- | undefined,
251251- TAliases extends Aliases | undefined = Aliases | undefined,
2727+ TSchema extends StandardSchemaV1 | undefined = undefined,
2828+ TAliases extends Record<string, string> | undefined = undefined,
25229> {
253253- /**
254254- * An object mapping string names to strings or arrays of string argument
255255- * names to use as aliases.
256256- */
3030+ /** Standard Schema-compliant schema. Output type drives `parse()` return type. */
3131+ schema?: TSchema;
3232+ /** Map short flags to long names. */
25733 alias?: TAliases;
258258-25934 /**
260260- * A boolean, string or array of strings to always treat as booleans. If
261261- * `true` will treat all double hyphenated arguments without equal signs as
262262- * `boolean` (e.g. affects `--foo`, not `-f` or `--foo=bar`).
263263- * All `boolean` arguments will be set to `false` by default.
264264- */
265265- boolean?: TBooleans | ReadonlyArray<Extract<TBooleans, string>>;
266266-267267- /** An object mapping string argument names to default values. */
268268- default?: TDefault & Defaults<TBooleans, TStrings>;
269269-270270- /** A string or array of strings argument names to always treat as strings. */
271271- string?: TStrings | ReadonlyArray<Extract<TStrings, string>>;
272272-273273- /**
274274- * A string or array of strings argument names to always treat as arrays.
275275- * Array options can be used multiple times. All values will be
276276- * collected into one array. If a non-array option is used multiple
277277- * times, the last value is used.
278278- * All Collectable arguments will be set to `[]` by default.
3535+ * When set, auto-defaults flags from environment variables before schema
3636+ * validation. Flag `--foo-bar` maps to `PREFIX_FOO_BAR` (uppercased,
3737+ * hyphens replaced with underscores).
27938 */
280280- array?: TCollectable | ReadonlyArray<Extract<TCollectable, string>>;
3939+ env?: false | EnvOptions;
28140}
4141+4242+export type ParseResult<TSchema extends StandardSchemaV1 | undefined> =
4343+ TSchema extends StandardSchemaV1
4444+ ? StandardSchemaV1.InferOutput<TSchema>
4545+ : RawArgs;
2824628347export interface NestedMapping {
28448 [key: string]: NestedMapping | unknown;
28549}
5050+5151+export class ParseError extends Error {
5252+ readonly issues: ReadonlyArray<StandardSchemaV1.Issue>;
5353+ constructor(issues: ReadonlyArray<StandardSchemaV1.Issue>) {
5454+ super(issues.map((i) => i.message).join("\n"));
5555+ this.name = "ParseError";
5656+ this.issues = issues;
5757+ }
5858+}