#Optique

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hollo.social

0.3.0 is out with dependent options and flexible parser composition, shaped by feedback from @z9mb1's work migrating @fedify CLI from Cliffy to Optique.

https://hackers.pub/@hongminhee/2025/optique-030

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub


We're releasing Optique 0.3.0 with several improvements that make building complex CLI applications more straightforward. This release focuses on expanding parser flexibility and improving the help system based on feedback from the community, particularly from the Fedify project's migration from Cliffy to Optique. Special thanks to @z9mb1 for her invaluable insights during this process.

What's new

  • Required Boolean flags with the new flag() parser for dependent options patterns
  • Flexible type defaults in withDefault() supporting union types for conditional CLI structures
  • Extended or() capacity now supporting up to 10 parsers (previously 5)
  • Enhanced merge() combinator that works with any object-producing parser, not just object()
  • Context-aware help using the new longestMatch() combinator
  • Version display support in both @optique/core and @optique/run
  • Structured output functions for consistent terminal formatting

Required Boolean flags with flag()

The new flag() parser creates Boolean flags that must be explicitly provided. While option() defaults to false when absent, flag() fails parsing entirely if not present. This subtle difference enables cleaner patterns for dependent options.

Consider a scenario where certain options only make sense when a mode is explicitly enabled:

import { flag, object, option, withDefault } from "@optique/core/parser";
import { integer } from "@optique/core/valueparser";

// Without the --advanced flag, these options aren't available
const parser = withDefault(
  object({
    advanced: flag("--advanced"),
    maxThreads: option("--threads", integer()),
    cacheSize: option("--cache-size", integer())
  }),
  { advanced: false as const }
);

// Usage:
// myapp                    → { advanced: false }
// myapp --advanced         → Error: --threads and --cache-size required
// myapp --advanced --threads 4 --cache-size 100 → Success

This pattern is particularly useful for confirmation flags (--yes-i-am-sure) or mode switches that fundamentally change how your CLI behaves.

Union types in withDefault()

Previously, withDefault() required the default value to match the parser's type exactly. Now it supports different types, creating union types that enable conditional CLI structures:

const conditionalParser = withDefault(
  object({
    server: flag("-s", "--server"),
    port: option("-p", "--port", integer()),
    host: option("-h", "--host", string())
  }),
  { server: false as const }
);

// Result type is now a union:
// | { server: false }
// | { server: true, port: number, host: string }

This change makes it much easier to build CLIs where different flags enable different sets of options, without resorting to complex or() chains.

More flexible merge() combinator

The merge() combinator now accepts any parser that produces object-like values. Previously limited to object() parsers, it now works with withDefault(), map(), and other transformative parsers:

const transformedConfig = map(
  object({
    host: option("--host", string()),
    port: option("--port", integer())
  }),
  ({ host, port }) => ({ endpoint: `${host}:${port}` })
);

const conditionalFeatures = withDefault(
  object({
    experimental: flag("--experimental"),
    debugLevel: option("--debug-level", integer())
  }),
  { experimental: false as const }
);

// Can now merge different parser types
const appConfig = merge(
  transformedConfig,        // map() result
  conditionalFeatures,      // withDefault() parser
  object({                  // traditional object()
    verbose: option("-v", "--verbose")
  })
);

This improvement came from recognizing that many parsers ultimately produce objects, and artificially restricting merge() to only object() parsers was limiting composition patterns.

Context-aware help with longestMatch()

The new longestMatch() combinator selects the parser that consumes the most input tokens. This enables sophisticated help systems where command --help shows help for that specific command rather than global help:

const normalParser = object({
  help: constant(false),
  command: or(
    command("list", listOptions),
    command("add", addOptions)
  )
});

const contextualHelp = object({
  help: constant(true),
  commands: multiple(argument(string())),
  helpFlag: flag("--help")
});

const cli = longestMatch(normalParser, contextualHelp);

// myapp --help           → Shows global help
// myapp list --help      → Shows help for 'list' command
// myapp add --help       → Shows help for 'add' command

The run() functions in both @optique/core/facade and @optique/run now use this pattern automatically, so your CLI gets context-aware help without any additional configuration.

Version display support

Both @optique/core/facade and @optique/run now support version display through --version flags and version commands. See the runners documentation for details:

// @optique/run - simple API
run(parser, {
  version: "1.0.0",  // Adds --version flag
  help: "both"
});

// @optique/core/facade - detailed control
run(parser, "myapp", args, {
  version: {
    mode: "both",     // --version flag AND version command
    value: "1.0.0",
    onShow: process.exit
  }
});

The API follows the same pattern as help configuration, keeping things consistent and predictable.

Structured output functions

The new output functions in @optique/run provide consistent terminal formatting with automatic capability detection. Learn more in the messages documentation:

import { print, printError, createPrinter } from "@optique/run";
import { message } from "@optique/core/message";

// Standard output with automatic formatting
print(message`Processing ${filename}...`);

// Error output to stderr with optional exit
printError(message`File ${filename} not found`, { exitCode: 1 });

// Custom printer for specific needs
const debugPrint = createPrinter({
  stream: "stderr",
  colors: true,
  maxWidth: 80
});

debugPrint(message`Debug: ${details}`);

These functions automatically detect terminal capabilities and apply appropriate formatting, making your CLI output consistent across different environments.

Breaking changes

While we've tried to maintain backward compatibility, there are a few changes to be aware of:

  • The help option in @optique/run no longer accepts "none". Simply omit the option to disable help.
  • Custom parsers implementing getDocFragments() need to update their signature to use DocState<TState> instead of direct state values.
  • The object() parser now uses greedy parsing, attempting to consume all matching fields in one pass. This shouldn't affect most use cases but may change parsing order in complex scenarios.

Upgrading to 0.3.0

To upgrade to Optique 0.3.0, update both packages:

# Deno (JSR)
deno add @optique/core@^0.3.0 @optique/run@^0.3.0

# npm
npm update @optique/core @optique/run

# pnpm
pnpm update @optique/core @optique/run

# Yarn
yarn upgrade @optique/core @optique/run

# Bun
bun update @optique/core @optique/run

If you're only using the core package:

# Deno (JSR)
deno add @optique/core@^0.3.0

# npm
npm update @optique/core

Looking ahead

These improvements came from real-world usage and community feedback. We're particularly interested in hearing how the new dependent options patterns work for your use cases, and whether the context-aware help system meets your needs.

As always, you can find complete documentation at optique.dev and file issues or suggestions on GitHub.

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hollo.social

0.3.0 is out with dependent options and flexible parser composition, shaped by feedback from @z9mb1's work migrating @fedify CLI from Cliffy to Optique.

https://hackers.pub/@hongminhee/2025/optique-030

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub


We're releasing Optique 0.3.0 with several improvements that make building complex CLI applications more straightforward. This release focuses on expanding parser flexibility and improving the help system based on feedback from the community, particularly from the Fedify project's migration from Cliffy to Optique. Special thanks to @z9mb1 for her invaluable insights during this process.

What's new

  • Required Boolean flags with the new flag() parser for dependent options patterns
  • Flexible type defaults in withDefault() supporting union types for conditional CLI structures
  • Extended or() capacity now supporting up to 10 parsers (previously 5)
  • Enhanced merge() combinator that works with any object-producing parser, not just object()
  • Context-aware help using the new longestMatch() combinator
  • Version display support in both @optique/core and @optique/run
  • Structured output functions for consistent terminal formatting

Required Boolean flags with flag()

The new flag() parser creates Boolean flags that must be explicitly provided. While option() defaults to false when absent, flag() fails parsing entirely if not present. This subtle difference enables cleaner patterns for dependent options.

Consider a scenario where certain options only make sense when a mode is explicitly enabled:

import { flag, object, option, withDefault } from "@optique/core/parser";
import { integer } from "@optique/core/valueparser";

// Without the --advanced flag, these options aren't available
const parser = withDefault(
  object({
    advanced: flag("--advanced"),
    maxThreads: option("--threads", integer()),
    cacheSize: option("--cache-size", integer())
  }),
  { advanced: false as const }
);

// Usage:
// myapp                    → { advanced: false }
// myapp --advanced         → Error: --threads and --cache-size required
// myapp --advanced --threads 4 --cache-size 100 → Success

This pattern is particularly useful for confirmation flags (--yes-i-am-sure) or mode switches that fundamentally change how your CLI behaves.

Union types in withDefault()

Previously, withDefault() required the default value to match the parser's type exactly. Now it supports different types, creating union types that enable conditional CLI structures:

const conditionalParser = withDefault(
  object({
    server: flag("-s", "--server"),
    port: option("-p", "--port", integer()),
    host: option("-h", "--host", string())
  }),
  { server: false as const }
);

// Result type is now a union:
// | { server: false }
// | { server: true, port: number, host: string }

This change makes it much easier to build CLIs where different flags enable different sets of options, without resorting to complex or() chains.

More flexible merge() combinator

The merge() combinator now accepts any parser that produces object-like values. Previously limited to object() parsers, it now works with withDefault(), map(), and other transformative parsers:

const transformedConfig = map(
  object({
    host: option("--host", string()),
    port: option("--port", integer())
  }),
  ({ host, port }) => ({ endpoint: `${host}:${port}` })
);

const conditionalFeatures = withDefault(
  object({
    experimental: flag("--experimental"),
    debugLevel: option("--debug-level", integer())
  }),
  { experimental: false as const }
);

// Can now merge different parser types
const appConfig = merge(
  transformedConfig,        // map() result
  conditionalFeatures,      // withDefault() parser
  object({                  // traditional object()
    verbose: option("-v", "--verbose")
  })
);

This improvement came from recognizing that many parsers ultimately produce objects, and artificially restricting merge() to only object() parsers was limiting composition patterns.

Context-aware help with longestMatch()

The new longestMatch() combinator selects the parser that consumes the most input tokens. This enables sophisticated help systems where command --help shows help for that specific command rather than global help:

const normalParser = object({
  help: constant(false),
  command: or(
    command("list", listOptions),
    command("add", addOptions)
  )
});

const contextualHelp = object({
  help: constant(true),
  commands: multiple(argument(string())),
  helpFlag: flag("--help")
});

const cli = longestMatch(normalParser, contextualHelp);

// myapp --help           → Shows global help
// myapp list --help      → Shows help for 'list' command
// myapp add --help       → Shows help for 'add' command

The run() functions in both @optique/core/facade and @optique/run now use this pattern automatically, so your CLI gets context-aware help without any additional configuration.

Version display support

Both @optique/core/facade and @optique/run now support version display through --version flags and version commands. See the runners documentation for details:

// @optique/run - simple API
run(parser, {
  version: "1.0.0",  // Adds --version flag
  help: "both"
});

// @optique/core/facade - detailed control
run(parser, "myapp", args, {
  version: {
    mode: "both",     // --version flag AND version command
    value: "1.0.0",
    onShow: process.exit
  }
});

The API follows the same pattern as help configuration, keeping things consistent and predictable.

Structured output functions

The new output functions in @optique/run provide consistent terminal formatting with automatic capability detection. Learn more in the messages documentation:

import { print, printError, createPrinter } from "@optique/run";
import { message } from "@optique/core/message";

// Standard output with automatic formatting
print(message`Processing ${filename}...`);

// Error output to stderr with optional exit
printError(message`File ${filename} not found`, { exitCode: 1 });

// Custom printer for specific needs
const debugPrint = createPrinter({
  stream: "stderr",
  colors: true,
  maxWidth: 80
});

debugPrint(message`Debug: ${details}`);

These functions automatically detect terminal capabilities and apply appropriate formatting, making your CLI output consistent across different environments.

Breaking changes

While we've tried to maintain backward compatibility, there are a few changes to be aware of:

  • The help option in @optique/run no longer accepts "none". Simply omit the option to disable help.
  • Custom parsers implementing getDocFragments() need to update their signature to use DocState<TState> instead of direct state values.
  • The object() parser now uses greedy parsing, attempting to consume all matching fields in one pass. This shouldn't affect most use cases but may change parsing order in complex scenarios.

Upgrading to 0.3.0

To upgrade to Optique 0.3.0, update both packages:

# Deno (JSR)
deno add @optique/core@^0.3.0 @optique/run@^0.3.0

# npm
npm update @optique/core @optique/run

# pnpm
pnpm update @optique/core @optique/run

# Yarn
yarn upgrade @optique/core @optique/run

# Bun
bun update @optique/core @optique/run

If you're only using the core package:

# Deno (JSR)
deno add @optique/core@^0.3.0

# npm
npm update @optique/core

Looking ahead

These improvements came from real-world usage and community feedback. We're particularly interested in hearing how the new dependent options patterns work for your use cases, and whether the context-aware help system meets your needs.

As always, you can find complete documentation at optique.dev and file issues or suggestions on GitHub.

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hollo.social

0.3.0 is out with dependent options and flexible parser composition, shaped by feedback from @z9mb1's work migrating @fedify CLI from Cliffy to Optique.

https://hackers.pub/@hongminhee/2025/optique-030

洪 民憙 (Hong Minhee)'s avatar
洪 民憙 (Hong Minhee)

@hongminhee@hackers.pub


We're releasing Optique 0.3.0 with several improvements that make building complex CLI applications more straightforward. This release focuses on expanding parser flexibility and improving the help system based on feedback from the community, particularly from the Fedify project's migration from Cliffy to Optique. Special thanks to @z9mb1 for her invaluable insights during this process.

What's new

  • Required Boolean flags with the new flag() parser for dependent options patterns
  • Flexible type defaults in withDefault() supporting union types for conditional CLI structures
  • Extended or() capacity now supporting up to 10 parsers (previously 5)
  • Enhanced merge() combinator that works with any object-producing parser, not just object()
  • Context-aware help using the new longestMatch() combinator
  • Version display support in both @optique/core and @optique/run
  • Structured output functions for consistent terminal formatting

Required Boolean flags with flag()

The new flag() parser creates Boolean flags that must be explicitly provided. While option() defaults to false when absent, flag() fails parsing entirely if not present. This subtle difference enables cleaner patterns for dependent options.

Consider a scenario where certain options only make sense when a mode is explicitly enabled:

import { flag, object, option, withDefault } from "@optique/core/parser";
import { integer } from "@optique/core/valueparser";

// Without the --advanced flag, these options aren't available
const parser = withDefault(
  object({
    advanced: flag("--advanced"),
    maxThreads: option("--threads", integer()),
    cacheSize: option("--cache-size", integer())
  }),
  { advanced: false as const }
);

// Usage:
// myapp                    → { advanced: false }
// myapp --advanced         → Error: --threads and --cache-size required
// myapp --advanced --threads 4 --cache-size 100 → Success

This pattern is particularly useful for confirmation flags (--yes-i-am-sure) or mode switches that fundamentally change how your CLI behaves.

Union types in withDefault()

Previously, withDefault() required the default value to match the parser's type exactly. Now it supports different types, creating union types that enable conditional CLI structures:

const conditionalParser = withDefault(
  object({
    server: flag("-s", "--server"),
    port: option("-p", "--port", integer()),
    host: option("-h", "--host", string())
  }),
  { server: false as const }
);

// Result type is now a union:
// | { server: false }
// | { server: true, port: number, host: string }

This change makes it much easier to build CLIs where different flags enable different sets of options, without resorting to complex or() chains.

More flexible merge() combinator

The merge() combinator now accepts any parser that produces object-like values. Previously limited to object() parsers, it now works with withDefault(), map(), and other transformative parsers:

const transformedConfig = map(
  object({
    host: option("--host", string()),
    port: option("--port", integer())
  }),
  ({ host, port }) => ({ endpoint: `${host}:${port}` })
);

const conditionalFeatures = withDefault(
  object({
    experimental: flag("--experimental"),
    debugLevel: option("--debug-level", integer())
  }),
  { experimental: false as const }
);

// Can now merge different parser types
const appConfig = merge(
  transformedConfig,        // map() result
  conditionalFeatures,      // withDefault() parser
  object({                  // traditional object()
    verbose: option("-v", "--verbose")
  })
);

This improvement came from recognizing that many parsers ultimately produce objects, and artificially restricting merge() to only object() parsers was limiting composition patterns.

Context-aware help with longestMatch()

The new longestMatch() combinator selects the parser that consumes the most input tokens. This enables sophisticated help systems where command --help shows help for that specific command rather than global help:

const normalParser = object({
  help: constant(false),
  command: or(
    command("list", listOptions),
    command("add", addOptions)
  )
});

const contextualHelp = object({
  help: constant(true),
  commands: multiple(argument(string())),
  helpFlag: flag("--help")
});

const cli = longestMatch(normalParser, contextualHelp);

// myapp --help           → Shows global help
// myapp list --help      → Shows help for 'list' command
// myapp add --help       → Shows help for 'add' command

The run() functions in both @optique/core/facade and @optique/run now use this pattern automatically, so your CLI gets context-aware help without any additional configuration.

Version display support

Both @optique/core/facade and @optique/run now support version display through --version flags and version commands. See the runners documentation for details:

// @optique/run - simple API
run(parser, {
  version: "1.0.0",  // Adds --version flag
  help: "both"
});

// @optique/core/facade - detailed control
run(parser, "myapp", args, {
  version: {
    mode: "both",     // --version flag AND version command
    value: "1.0.0",
    onShow: process.exit
  }
});

The API follows the same pattern as help configuration, keeping things consistent and predictable.

Structured output functions

The new output functions in @optique/run provide consistent terminal formatting with automatic capability detection. Learn more in the messages documentation:

import { print, printError, createPrinter } from "@optique/run";
import { message } from "@optique/core/message";

// Standard output with automatic formatting
print(message`Processing ${filename}...`);

// Error output to stderr with optional exit
printError(message`File ${filename} not found`, { exitCode: 1 });

// Custom printer for specific needs
const debugPrint = createPrinter({
  stream: "stderr",
  colors: true,
  maxWidth: 80
});

debugPrint(message`Debug: ${details}`);

These functions automatically detect terminal capabilities and apply appropriate formatting, making your CLI output consistent across different environments.

Breaking changes

While we've tried to maintain backward compatibility, there are a few changes to be aware of:

  • The help option in @optique/run no longer accepts "none". Simply omit the option to disable help.
  • Custom parsers implementing getDocFragments() need to update their signature to use DocState<TState> instead of direct state values.
  • The object() parser now uses greedy parsing, attempting to consume all matching fields in one pass. This shouldn't affect most use cases but may change parsing order in complex scenarios.

Upgrading to 0.3.0

To upgrade to Optique 0.3.0, update both packages:

# Deno (JSR)
deno add @optique/core@^0.3.0 @optique/run@^0.3.0

# npm
npm update @optique/core @optique/run

# pnpm
pnpm update @optique/core @optique/run

# Yarn
yarn upgrade @optique/core @optique/run

# Bun
bun update @optique/core @optique/run

If you're only using the core package:

# Deno (JSR)
deno add @optique/core@^0.3.0

# npm
npm update @optique/core

Looking ahead

These improvements came from real-world usage and community feedback. We're particularly interested in hearing how the new dependent options patterns work for your use cases, and whether the context-aware help system meets your needs.

As always, you can find complete documentation at optique.dev and file issues or suggestions on GitHub.