Optique 1.1.0: Command discovery, value parsers, and ordered grammars · dahlia optique · Discussion #834

9 min read Original article ↗

Optique 1.1.0 is the first feature release after the stable 1.0.0 baseline. The largest addition is @optique/discover, a package for organizing larger CLIs as file-based command modules with typed handlers.

The release also adds value parsers for file sizes, colors, semantic versions, JSON, and key–value pairs; seq() for ordered positional grammars; negatableFlag() for --foo/--no-foo options; command aliases; async Zod and Valibot helpers; and .env file loading in @optique/env.

Optique is a TypeScript CLI parser built from composable parser functions. Parser definitions drive runtime parsing, help and completion output, and the inferred value types that handlers receive.

Command discovery with @optique/discover

Optique's core combinators work well when the entire command tree fits in one module. Larger applications often want a file-per-command layout: each command owns its parser, metadata, and handler, and the application builds the command tree from whatever modules exist in a directory. The new @optique/discover package provides that layer.

Command modules default-export a defineCommand() definition. runProgram() scans the command directory, maps file paths to command paths, builds the parser tree, and dispatches to the matched handler:

import { runProgram } from "@optique/discover";
import { message } from "@optique/core/message";

await runProgram({
  dir: new URL("./commands/", import.meta.url),
  metadata: {
    name: "admin",
    version: "1.0.0",
    brief: message`Administrative tools.`,
  },
});

A command module looks like this:

import { defineCommand } from "@optique/discover/command";
import { object } from "@optique/core/constructs";
import { message } from "@optique/core/message";
import { option } from "@optique/core/primitives";
import { string } from "@optique/core/valueparser";

export default defineCommand({
  parser: object({
    name: option("--name", string()),
  }),
  metadata: {
    brief: message`Add a user.`,
  },
  handler(value) {
    console.log(`Adding ${value.name}.`);
  },
});

With a file layout like:

commands/
  build.ts
  user/
    add.ts
    remove.ts

the discovered paths are admin build, admin user add, and admin user remove. Help, shell completion, and version output are enabled automatically through @optique/run. TypeScript checks the handler signature against the parser's inferred value type, so changing the parser immediately surfaces handler mismatches at compile time.

When command modules need to be visible to a bundler or single-file packager, import them manually and pass them as a commands array instead of using dir. Both modes are mutually exclusive.

The original proposal and implementation are in #812 and #818.

See the command discovery documentation for details on file naming, extension filtering, path conflicts, and when dynamic discovery fits versus static imports.

Value parsers for common CLI inputs

Five new value parsers are available in @optique/core/valueparser starting with this release:

Parser Return type Description
fileSize() number or bigint Human-readable data sizes
color() Color CSS color strings
semVer() SemVerString or SemVer Semantic Versioning 2.0.0
json() Json JSON values with optional type filter
keyValue() readonly [key, value] KEY=VALUE pairs

The new parsers cover common value shapes. fileSize() converts strings like "10MB" or "1.5GiB" to byte counts in SI or IEC units; type: "bigint" handles values above Number.MAX_SAFE_INTEGER. color() returns a structured Color with r, g, b, and a fields from hex notation, rgb()/hsl() functions, or any CSS Level 4 named color including transparent and rebeccapurple. semVer() validates Semantic Versioning 2.0.0 strings strictly and returns either a SemVerString template-literal type or a parsed object with major, minor, patch, and optional preRelease and metadata fields. json() accepts any well-formed JSON and optionally restricts the root type. keyValue() parses KEY=VALUE pairs into readonly tuples with typed keys and values.

A few usage examples:

option("--archive", fileSize({ type: "bigint" }))
// "2EB" → 2000000000000000000n

option("--config", json({ rootType: "object" }))
// '{"host":"localhost","port":3000}' → { host: "localhost", port: 3000 }

option("--setting", keyValue(string(), integer()))
// "PORT=8080" → readonly ["PORT", 8080]

For mixed inputs, firstOf() tries value parsers in order and returns the first success, with the result type inferred as the union of the constituent types:

option("--workers", firstOf(choice(["auto"] as const), integer({ min: 1 })))
// "auto" → "auto"  (type: "auto" | number)
// "4"    → 4

When every constituent enumerates choices, firstOf() merges them for shell completion. Custom value parsers can also implement the new optional validate() method on ValueParser to express validation failures that the generic format()+parse() round-trip cannot cover, which matters when constituent parsers produce overlapping string representations.

Ordered grammars with seq()

The existing tuple() combinator lets child parsers compete by priority, which works well when input order does not matter. Some grammars require declaration order: an optional positional argument before a subcommand name, for instance, needs a guarantee that the positional slot does not accidentally consume the command token. seq() solves this by applying parsers in declaration order and advancing a cursor as each succeeds:

const parser = seq(
  optional(argument(string({ metavar: "PROFILE" }))),
  or(
    command("build", object({
      action: constant("build"),
      target: argument(string({ metavar: "TARGET" })),
    })),
    command("deploy", object({
      action: constant("deploy"),
      environment: argument(string({ metavar: "ENV" })),
      force: option("--force"),
    })),
  ),
);

With this parser, both of these forms work as written:

tool build app
tool staging deploy production --force

Usage output follows declaration order instead of priority order, so the help text matches the grammar:

tool [PROFILE] (build TARGET | deploy ENV [--force])

When the current child parser can finish without consuming more input, seq() can skip it to reach a later command name. It does not backtrack after a later parser succeeds, so ambiguous variadic positionals still need an explicit boundary such as a command name or --.

The design rationale and alternative approaches considered are discussed in #819.

negatableFlag() for paired boolean options

Many CLI conventions offer paired options such as --color and --no-color, where one side explicitly enables something and the other explicitly disables it. Previously, expressing this required two separate flags and manual conflict checking after parsing. The new negatableFlag() parser handles both sides as a single unit:

const color = withDefault(
  negatableFlag({
    positive: "--color",
    negative: "--no-color",
  }, {
    description: message`Force-enable or force-disable colored output.`,
  }),
  () => detectColorSupport(),
  { message: message`auto` },
);

The parser returns true when the positive flag appears and false when the negative flag appears. Providing both on the same invocation is a conflict; providing the same side twice is a duplicate. Both sides support aliases. Optique renders the positive and negative names in one help entry to communicate that they control the same Boolean value.

Without withDefault() or optional(), one side is required:

negatableFlag({ positive: "--color", negative: "--no-color" })
// --color    → true
// --no-color → false
// (neither)  → parse error

The original proposal and implementation are in #801 and #802.

Command aliases

Commands now accept an aliases option for short or legacy names. Aliases parse identically to the canonical name, appear in shell completion, and surface in typo suggestions, but they are hidden from usage and help output:

const install = command("install", object({
  packageName: option("--package", string()),
}), {
  aliases: ["i"],
});

Aliases share the command namespace with sibling commands. Optique throws at construction time if an alias collides with another command name or alias in or(), longestMatch(), object(), or merge(). Sequential compositions like seq() and tuple() are exempt because their children are positional parts of the same command line, not alternatives at the same position.

Async Zod and Valibot helpers

The existing zod() and valibot() helpers are synchronous and reject schemas that require async execution. This release adds zodAsync() in @optique/zod and valibotAsync() in @optique/valibot for schemas with async refinements, transforms, or pipe steps:

import { zodAsync } from "@optique/zod";
import { z } from "zod";

const username = zodAsync(
  z.string().refine(
    async (name) => !(await usernameExists(name)),
    "Username already taken.",
  ),
  { placeholder: "user" },
);
import { valibotAsync } from "@optique/valibot";
import * as v from "valibot";

const email = valibotAsync(
  v.pipeAsync(v.string(), v.email(), v.checkAsync(isAvailableEmail)),
  { placeholder: "user@example.com" },
);

Both helpers preserve the behavior of their synchronous counterparts: metavar inference, choice enumeration, completion suggestions, formatting, custom error messages, and fallback validation for values from source contexts such as bindEnv() and bindConfig().

.env file support in @optique/env

createEnvContext() now accepts an envFile option that loads .env files as an internal fallback layer below real environment variables. Loaded values are used by bindEnv() but never written to process.env or Deno.env.

const envContext = createEnvContext({
  prefix: "MYAPP_",
  envFile: [".env", ".env.local"],
});

envFile: true loads .env from the current working directory. An array loads files in order, with later files overriding earlier ones. Missing files are skipped. The built-in parser supports standard dotenv syntax: comments, quoted values, variable expansion, and literal single-quoted strings. Command substitution is opt-in via envFile.substitute.

bindEnv() now resolves values in this order:

  • CLI argument
  • Environment variable
  • .env file value
  • Default value
  • Error

IPv6 in socketAddress()

socketAddress() now accepts bracketed IPv6 endpoints such as [::1]:8080 and host-only IPv6 literals when a defaultPort is configured. Two new options control IP version filtering:

option("--listen", socketAddress({
  defaultPort: 8080,
  host: { version: "ipv6" },
}))
// "[::1]:8080" → { host: "::1", port: 8080 }
// "::1"        → { host: "::1", port: 8080 }  (default port used)

host.ipv4 and host.ipv6 provide separate restriction objects for each version. format() and normalize() now emit bracketed notation for IPv6 hosts. The existing host.ip option remains as an IPv4 compatibility alias.

Bug fixes

Shell completion now correctly includes root-level meta options such as --help and --version, including plus-prefixed and single-dash aliases, in suggestions from empty prompts and after subcommands. Previously these were omitted or incorrectly triggered when option values happened to look like help/version flags.

In object() parsers that expect no CLI input, the specific error from a required field's complete() is now surfaced instead of the generic “No matching option, command, or argument found.” The fallback message when no input is expected at all is now “No value provided.”

When multiple parser fields share the same deferred dependency node, resolution now yields the same result object across all of them instead of distinct but structurally equal objects.

bindEnv() and bindConfig() now emit a distinct error message when their respective contexts are not registered with the runner, so missing runner context no longer looks like a parsing failure.

@optique/temporal no longer fails on runtimes whose Temporal implementation rejects curated IANA timezone links such as CET. The parser now applies a cross-runtime allowlist before delegating single-segment identifiers to runtime Temporal validation.

Installation

# Deno
deno add jsr:@optique/core

# npm
npm add @optique/core

# pnpm
pnpm add @optique/core

# Bun
bun add @optique/core

For command discovery:

# Deno
deno add jsr:@optique/discover

# npm
npm add @optique/discover

For environment variable support:

# Deno
deno add jsr:@optique/env

# npm
npm add @optique/env

If you have ideas for future improvements or encounter any issues, please let us know through GitHub Issues. For more information about Optique and its features, visit the documentation or check out the full changelog.