Optique 0.10.0: Runtime context, config files, man pages, and network parsers · dahlia optique · Discussion #108

11 min read Original article ↗

We're excited to announce Optique 0.10.0, the largest release yet! This release introduces a runtime context system for composing external data sources, a config file integration package, man page generation, inter-option dependencies, 11 new network value parsers, and several help output improvements driven by community feedback.

Optique is a type-safe combinatorial CLI parser for TypeScript, providing a functional approach to building command-line interfaces with composable parsers and full type inference.

Annotations and source contexts

Real-world CLI applications rarely rely on command-line arguments alone. They pull configuration from files, environment variables, and other external sources, typically following a priority chain like CLI > environment > config file > defaults.

Optique 0.10.0 introduces two complementary systems to support this pattern natively.

Annotations: a low-level primitive

The new annotations system allows runtime data to be injected into a parsing session via symbol-keyed records. Parsers can access this data during both parse() and complete() phases, enabling use cases like config file fallbacks and environment-based validation.

import { parse } from "@optique/core/parser";
import { getAnnotations } from "@optique/core/annotations";

const configDataKey = Symbol.for("@myapp/config");

// Inject external data into parsing
const result = parse(parser, args, {
  annotations: {
    [configDataKey]: configData,
  },
});

// Access it inside a custom parser's complete() method
const annotations = getAnnotations(state);
const config = annotations?.[configDataKey];

Annotations are symbol-keyed to prevent naming conflicts between packages, and the entire system is opt-in: existing parsers work unchanged since the options parameter is optional.

Source contexts: a high-level abstraction

Building on annotations, the SourceContext interface and runWith() function provide a structured way to compose multiple data sources with automatic priority handling. For the design rationale and discussion, see #85.

import { runWith } from "@optique/core/facade";
import type { SourceContext } from "@optique/core/context";

const envContext: SourceContext = {
  id: Symbol.for("@myapp/env"),
  getAnnotations() {
    return {
      [Symbol.for("@myapp/env")]: {
        HOST: process.env.MYAPP_HOST,
      }
    };
  }
};

const result = await runWith(
  parser,
  "myapp",
  [envContext],
  { args: process.argv.slice(2) }
);

The runWith() function uses a smart two-phase approach: static contexts (like environment variables) return data immediately, while dynamic contexts (like config files whose paths come from CLI arguments) get a second chance to provide data after an initial parse. If all contexts are static, the second parse is skipped for optimization.

Importantly, runWith() ensures that --help, --version, and shell completion always work, even when config files are missing or contexts would fail to load. For details on this early-exit optimization, see #92.

New @optique/config package

The new @optique/config package provides first-class config file support with type-safe validation using Standard Schema (Zod, Valibot, ArkType, and others). The original proposal and API design are discussed in #84 and #90.

import { createConfigContext, bindConfig } from "@optique/config";
import { runWithConfig } from "@optique/config/run";
import * as v from "valibot";

const configSchema = v.object({
  host: v.optional(v.string()),
  port: v.optional(v.number()),
});

const configContext = createConfigContext({
  schema: configSchema,
});

const parser = object({
  config: withDefault(option("--config", string()), "config.json"),
  host: bindConfig(optional(option("--host", string())), {
    context: configContext,
    key: (config) => config.host,
    default: "localhost",
  }),
  port: bindConfig(optional(option("--port", integer())), {
    context: configContext,
    key: (config) => config.port,
    default: 3000,
  }),
});

const result = await runWithConfig(parser, configContext, {
  args: process.argv.slice(2),
  getConfigPath: (parsed) => parsed.config,
});

The package supports single-file loading with automatic format detection, custom file parsers for formats like TOML or YAML, and multi-file merging scenarios (system, user, project config cascading) via the load callback. It delegates to runWith() internally, so help, version, and completion features work even when config files are missing (see #93 for the refactoring that made this possible).

See the config file integration guide for detailed documentation.

New @optique/man package

The new @optique/man package generates Unix man pages from parser metadata, eliminating the need to maintain man pages separately from parser definitions. The original proposal is in #77.

import { defineProgram } from "@optique/core/program";
import { generateManPage } from "@optique/man";

const prog = defineProgram({
  parser: myParser,
  metadata: {
    name: "myapp",
    version: "1.0.0",
    author: message`Hong Minhee`,
  },
});

const manPage = generateManPage(prog, { section: 1 });

Optique's structured Message system maps naturally to roff formatting: optionName() becomes bold, metavar() becomes italic, and so on. The package also includes a CLI tool for build-time generation:

# Generate man page from a TypeScript file
optique-man ./src/cli.ts -s 1 -o myapp.1

# Use a named export
optique-man ./src/cli.ts -s 1 -e myProgram

The optique-man CLI supports loading TypeScript files directly on Deno, Bun, and Node.js 25.2.0+ (or Node.js with tsx).

See the man page documentation for complete examples.

Program interface

Previously, metadata like program name, version, and descriptions had to be passed separately each time run() or runParser() was called, and duplicated again for man page generation. The new Program interface bundles a parser with its metadata into a single source of truth. The motivation and design decisions are discussed in #82.

import { defineProgram } from "@optique/core/program";
import { message } from "@optique/core/message";

const prog = defineProgram({
  parser: myParser,
  metadata: {
    name: "myapp",
    version: "1.0.0",
    brief: message`A sample CLI application`,
    author: message`Jane Doe <jane@example.com>`,
  },
});

All major APIs now accept Program objects directly:

import { runParser } from "@optique/core/facade";
import { run } from "@optique/run";
import { generateManPage } from "@optique/man";

// Define once, use everywhere
runParser(prog, ["--name", "Alice"]);
run(prog, { help: "both", colors: true });
generateManPage(prog, { section: 1 });

The ProgramMetadata interface includes fields for author, bugs, and examples, which are now displayed in help output when provided. Both the new Program-based API and the original parser-based API are supported for backward compatibility.

Inter-option dependencies

One of the most architecturally challenging features in this release is inter-option dependency support. When building CLI tools that mirror Git's interface, it's common to have a global option like -C <dir> that changes the working directory for subsequent operations. Ideally, value parsers should be able to reference the parsed value of -C to validate and provide completions from the correct repository. The problem turned out to be surprisingly hard; #74 documents the approaches that were considered and rejected before arriving at the current design, and #76 contains the implementation.

This is a genuinely difficult problem: value parsers normally have no access to other options' parsed values, and all the obvious solutions (dynamic resolvers, context injection, post-parsing validation) either duplicate parsing logic or solve only part of the problem.

Optique's solution uses deferred parsing: dependent options store their raw input during initial parsing, then re-validate using actual dependency values after all options are collected.

import { dependency } from "@optique/core/dependency";
import { choice } from "@optique/core/valueparser";

const modeParser = dependency(choice(["dev", "prod"] as const));

const logLevelParser = modeParser.derive({
  metavar: "LEVEL",
  factory: (mode) => {
    if (mode === "dev") {
      return choice(["debug", "info", "warn", "error"]);
    } else {
      return choice(["warn", "error"]);
    }
  },
  defaultValue: () => "dev" as const,
});

Dependencies work seamlessly with all parser combinators and support context-aware shell completions: the available completions for the dependent option change based on the dependency's current value.

See the inter-option dependencies documentation for detailed examples.

Network value parsers

CLI tools frequently need to parse and validate network-related inputs. Previously, users had to use string() with manual validation or write custom parsers. This release adds 11 built-in network value parsers to @optique/core/valueparser, all with rich validation options, security-conscious implementations (ReDoS prevention), and consistent error messages. The full specification, including security considerations, is in #89.

Parser Validates Example input
port() TCP/UDP port numbers 8080
ipv4() IPv4 addresses 192.168.1.1
ipv6() IPv6 addresses 2001:db8::1
ip() Both IPv4 and IPv6 192.168.1.1 or ::1
cidr() CIDR notation 192.168.0.0/24
hostname() DNS hostnames example.com
domain() Domain names example.com
email() Email addresses user@example.com
socketAddress() Host:port pairs localhost:3000
portRange() Port ranges 8000-8080
macAddress() MAC addresses 00:1A:2B:3C:4D:5E

Each parser provides fine-grained control over what is accepted. For example, ipv4() can restrict private, loopback, link-local, multicast, broadcast, and zero addresses:

// Public IPs only (no private/loopback)
option("--public-ip", ipv4({
  allowPrivate: false,
  allowLoopback: false
}))

// Socket address with default port, non-privileged ports only
option("--listen", socketAddress({
  defaultPort: 8080,
  port: { min: 1024 }
}))

// Email restricted to company domains
option("--work-email", email({
  allowedDomains: ["company.com"]
}))

The nonEmpty() modifier

The new nonEmpty() modifier requires a parser to consume at least one input token to succeed. This solves a specific problem with longestMatch(): when combining parsers where one has all-optional fields (and thus can succeed consuming zero tokens), there was no way to distinguish “no options provided” from “options provided with defaults.” The use case and design are described in #79, and the implementation is in #80.

import { longestMatch, object } from "@optique/core/constructs";
import { nonEmpty, optional, withDefault } from "@optique/core/modifiers";
import { constant, option } from "@optique/core/primitives";
import { string } from "@optique/core/valueparser";

// Without nonEmpty(): activeParser always wins (consumes 0 tokens)
// With nonEmpty(): helpParser wins when no options are provided
const activeParser = nonEmpty(object({
  mode: constant("active" as const),
  cwd: withDefault(option("--cwd", string()), "./default"),
  key: optional(option("--key", string())),
}));

const helpParser = object({ mode: constant("help" as const) });
const parser = longestMatch(activeParser, helpParser);

Help output improvements

Several improvements to help output were driven by community feedback from @cspotcode:

Completion helpVisibility

When using completion: "both", help output previously showed both completion and completions commands, cluttering the display. The new helpVisibility option controls which completion aliases appear in help while keeping both functional at runtime. This was reported by @cspotcode in #99.

run(parser, {
  completion: {
    mode: "both",
    name: "both",
    helpVisibility: "singular",  // Only show "completion" in help
  },
});

showChoices option

Similar to the existing showDefault feature, the new showChoices option displays valid choice values in help output. Previously, users could only discover valid values after providing an invalid one and reading the error message. See #106 for the full design.

runParser(parser, "myapp", args, { showChoices: true });
// Help output shows: --format FORMAT   Output format (choices: json, yaml, xml)

Meta-command grouping

The new group option for help, version, and completion commands allows them to appear under a titled section in help output instead of alongside user-defined commands. This was originally reported as a help ordering issue by @cspotcode in #107.

run(parser, {
  help: { mode: "both", group: "Other" },
  version: { value: "1.0.0", mode: "both", group: "Other" },
  completion: { mode: "both", group: "Other" },
});

New message components

Two new message components improve structured text formatting:

  • url() (and its alias link()): Displays URLs with clickable hyperlinks in terminals that support OSC 8 escape sequences. When colors are enabled, URLs are rendered as clickable links; when quotes are enabled, they are wrapped in angle brackets.

  • lineBreak(): Provides explicit single-line breaks in structured messages. Unlike \n in text() terms (which are treated as soft breaks), lineBreak() always renders as a hard line break.

import { message, url, lineBreak, commandLine } from "@optique/core/message";

const helpMsg = message`Visit ${url("https://example.com")} for details.`;

const examples = message`Examples:${lineBreak()}
  Bash: ${commandLine(`eval "$(mycli completion bash)"`)}${lineBreak()}
  zsh:  ${commandLine(`eval "$(mycli completion zsh)"`)}`;

Breaking changes

Installation

# Deno
deno add jsr:@optique/core

# npm
npm add @optique/core

# pnpm
pnpm add @optique/core

# Bun
bun add @optique/core

For config file support:

# Deno
deno add jsr:@optique/config

# npm
npm add @optique/config

For man page generation:

# Deno
deno add jsr:@optique/man

# npm
npm add @optique/man

Looking forward

Optique 0.10.0 is the last planned pre-release before 1.0.0. The annotations and source context systems introduced in this release lay the groundwork for two new packages planned for 1.0.0:

  • @optique/env (Add @optique/env package for environment variable support #86): Type-safe environment variable support, implementing the SourceContext interface as a static source. It will provide createEnvContext() for defining environment variable prefixes, bindEnv() for binding parsers to environment variables, and a bool() value parser for common boolean representations. Being a static source, it requires no two-phase parsing.

  • @optique/inquirer (Add interactive prompt support via Inquirer.js integration #87): Interactive prompt support via Inquirer.js, enabling CLI tools to automatically prompt for missing values. Unlike config and env integration, prompts execute directly in the parser's complete() phase rather than going through the SourceContext pattern, since they don't read external data but rather ask the user directly.

Together with the existing @optique/config, these packages will enable the full CLI > environment > config > interactive prompt > default value priority chain that production CLI applications need.

We're grateful to @cspotcode for reporting help output issues (#99, #107) that were addressed in this release. 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.