Optique

repository·main·Indexed 20 days ago

https://github.com/dahlia/optique

A type-safe, combinatorial CLI parser for TypeScript inspired by Haskell's optparse-applicative. It transforms command-line input into well-typed data structures and supports universal JavaScript runtimes including Node.js, Deno, and Bun. The ecosystem includes @optique/clack for interactive prompts, @optique/config for configuration file support with Standard Schema validation, and @optique/core for shared types and parser combinators.

Tokens
232.6K
Snippets
544
Records
685
Agent score
72%

What's inside Optique

  1. Overview of Optique features and capabilities

    main

    Optique provides a suite of tools for building robust CLIs:

    • Parser Combinators: Use object(), or(), merge(), optional(), multiple(), map(), conditional(), and passThrough() to build complex parsers.
    • Type Safety: Automatic TypeScript type inference for all compositions.
    • Rich Value Parsers: Built-in support for strings, numbers, URLs, locales, UUIDs, networking types (port(), ipv4(), hostname(), email()), and Temporal types.
    • Validation Integrations: Support for Standard Schema, Zod, and Valibot.
    • Configuration & Environment: Load settings from config files (@optique/config) or environment variables (@optique/env).
    • Interactive Fallbacks: Prompt users for missing values using @optique/inquirer or @optique/clack.
    • Documentation & Shell Support: Automatically derive help text, man pages (@optique/man), and shell completions (Bash, zsh, fish, PowerShell, Nushell) from your parser definitions.
    • Command Discovery: Use @optique/discover to split large command trees into separate files.
  2. Compare Optique and Commander.js for CLI development

    main

    Deciding between Optique and Commander.js depends on your CLI's complexity and requirements:

    Choose Commander.js if:

    • You want the most widely known Node.js CLI API with abundant community examples.
    • Your CLI is a flat list of flags and simple subcommands.
    • You only need simple pairwise flag conflicts (using Option.conflicts()).
    • You prefer an imperative, incremental builder style.

    Choose Optique if:

    • You have complex mutually exclusive groups of options and want them enforced by the type system.
    • You need to share and recompose option sets as first-class values across multiple commands.
    • You want a unified model for resolving values from CLI args, environment variables, config files, and interactive prompts.
    • You want automatic generation of --help, shell completions (5 shells), and man pages from a single definition.
    • You want built-in schema validation using Standard Schema-compatible validators (like Zod or Valibot).
  3. Use @optique/core for custom argument parsing

    main

    The @optique/core package provides shared types and parser combinators for building type-safe combinatorial CLI parsers. It is designed for universal JavaScript runtimes (Node.js, Deno, Bun, edge functions, and browsers).

    When to use @optique/core

    Use this package when:

    • Building web applications or libraries.
    • You need full control over argument sources and error handling.
    • Working in environments without process (e.g., browsers, web workers).
    • Building reusable parser components.

    When to use @optique/run instead

    If you are building a standard CLI application for Node.js, Bun, or Deno, consider using @optique/run. It provides automatic process.argv handling, process.exit() integration, and automatic terminal capability detection (colors, width).

  4. Compare Optique vs. oclif

    main

    Choosing between Optique and oclif depends on whether you need a full CLI framework or a lightweight parsing library.

    oclif (Framework)

    • Best for: Large, extensible CLI products requiring a plugin system, command scaffolding, file-based command discovery, and automatic README/help generation.
    • Key Features: Mature plugin ecosystem, lifecycle hooks, and rich declarative flag relationships (e.g., exclusive, exactlyOne).
    • Trade-offs: Higher runtime footprint and a rigid project structure.

    Optique (Library)

    • Best for: Focused tools, lightweight applications, or when you want to bring your own command loader/structure.
    • Key Features: Zero-dependency core, type-safe discriminated unions for mutually exclusive option groups, and composable resolution layers (CLI > Env > Config > Prompt).
    • Trade-offs: Does not provide scaffolding, plugin systems, or automatic documentation generation.
  5. Compare Optique with other CLI libraries

    main

    Optique uses a combinatorial, type-first approach that differs from the builder, declarative, or class-based styles used by other libraries. To decide if Optique is right for your project, evaluate it against competitors across three primary scenarios where its model diverges most:

    1. Mutually exclusive option groups: Expressing "either this group of options, or that one, but never a mix."
    2. Shared option groups across subcommands: Defining a set of options once and reusing them across several commands.
    3. Value resolution priority: Resolving a value from CLI args → env vars → config file → interactive prompt in a specific order.

    Optique's strengths lie in its ability to handle these scenarios natively with high type fidelity, whereas many other libraries require manual logic or external packages to achieve the same results.

  6. Choose between @optique/run and @optique/core

    main

    Optique provides two main ways to handle parsing depending on your use case:

    Use @optique/run when:

    • Building standalone CLI applications.
    • You want automatic handling of process.argv (Node) or Deno.args (Deno).
    • You want automatic terminal capability detection (colors, width).
    • You want built-in help text generation and automatic exit codes on error.

    Use @optique/core when:

    • Building libraries that need to parse arguments but shouldn't control the process.
    • Working in environments without node:process (e.g., web browsers).
    • You need full manual control over error handling and result processing.
    • You want to integrate parsing into a larger, existing application logic flow.
  7. Use mutually exclusive option groups with Optique

    main

    While oclif handles flag-to-flag relationships (like exclusive: ['otherFlag']), Optique excels at making entire option groups mutually exclusive using the or() construct. This approach ensures that the resulting parsed value is a discriminated union, meaning the TypeScript type explicitly reflects which branch was chosen, preventing invalid states at the type level.

    import { object, or } from "@optique/core/constructs";
    import type { InferValue } from "@optique/core/parser";
    import { constant, option } from "@optique/core/primitives";
    import { string, integer } from "@optique/core/valueparser";
    
    const auth = object({
      mode: constant("auth"),
      token: option("--auth-token", string()),
      key: option("--auth-key", string()),
    });
    
    const config = object({
      mode: constant("config"),
      file: option("--config-file", string()),
      port: option("--config-port", integer()),
    });
    
    const parser = or(auth, config);
    type Value = InferValue<typeof parser>;
    // Value is a discriminated union: exactly one of the branches above, never a mix.
  8. Compare `conditional()` and `or()`

    main

    Use conditional() when you have an explicit discriminator option that determines which set of options is valid. Use or() for more general mutually exclusive alternatives.

    Featureor()conditional()
    DiscriminatorManual with constant()Explicit discriminator option
    Branch selectionFirst matching parserBased on discriminator value
    Result typeUnion of branch typesTuple [discriminator, branchType]
    Default handlingVia parser orderingExplicit default branch
    Type narrowingVia discriminator fieldVia tuple first element
  9. How derived defaults work in Optique

    main

    The @optique/derived-defaults package enables CLI applications to compute default values based on the results of the first-pass parse. This allows for a clear priority hierarchy when resolving values:

    1. CLI arguments: Explicitly provided flags/arguments take highest priority.
    2. Derived defaults: Values computed from the first-pass parse result.
    3. Static defaults: Standard default values defined within the Optique parser.

    Key features include:

    • Two-pass defaults: Values are derived from already parsed CLI values.
    • Async resolver support: Works with runAsync() and runWith().
    • Fallback validation: Derived values are validated through the wrapped Optique parser.
    • Custom help text: Supports help text for values computed at runtime.
    • Composable contexts: Integrates with Optique's context system.
    // Priority: CLI arguments > derived defaults > static defaults
  10. How structured messages work in Optique

    main

    Optique uses a structured message system to create rich, type-safe error messages and help text. Instead of using plain strings, you use the message template literal function to embed semantic components. This separates content from presentation, ensuring consistent styling (colors, quotes, italics) for CLI elements like option names, user values, and metavariables across your entire application.

    Key benefits include:

    • Consistency: All messages follow the same visual conventions.
    • Semantic Clarity: Distinguishes between user input, CLI flags, and environment variables.
    • Automatic Formatting: Handles colors and terminal-specific features like clickable hyperlinks.
    import { message, optionName } from "@optique/core/message";
    
    // Simple text message
    const greeting = message`Welcome to the application!`;
    
    // Message with embedded values
    const error = message`Expected port between ${minPort} and ${maxPort}, got ${actualPort}.`;
    
    // Message with CLI-specific elements
    const optionError = message`Option ${optionName("--port")} requires a valid number.`;
  11. Use per-command preflight hooks

    main

    If only a specific command requires setup (e.g., refreshing an auth token), define hooks directly within the defineCommand object.

    Command-level hooks nest inside program-level hooks. The execution order is:

    1. program.beforeEach
    2. command.beforeEach
    3. handler
    4. command.afterEach (on success)
    5. program.afterEach (on success)

    On failure, the order is:

    1. command.onError
    2. program.onError
    import { defineCommand } from "@optique/discover/command";
    import { object } from "@optique/core/constructs";
    import { option } from "@optique/core/primitives";
    
    export default defineCommand({
      parser: object({ /* ... */ }),
      hooks: {
        beforeEach() {
          return { resource: { release: () => {} } };
        },
        afterEach(context) {
          context.resource?.release();
        },
      },
      handler(value) {
        console.log("Running command");
      },
    });
  12. Best practices for building custom SourceContexts

    main

    When implementing custom SourceContext objects, follow these guidelines to ensure stability and compatibility:

    • Use unique symbols: Always use Symbol.for() with a namespaced string (e.g., Symbol.for("@your-package/key")) to prevent collisions.
    • Declare phase explicitly: Always set phase: "single-pass" or phase: "two-pass". This makes the refinement intent explicit to the runner.
    • Handle missing data gracefully: Never throw errors inside getAnnotations(). If data is missing, the file is unreadable, or the parse phase is incorrect, return an empty object {}.
    • Keep contexts focused: Each context should represent exactly one data source (e.g., one for ENV, one for a specific config file).
    • Document the annotation key: Ensure it is clear which key in the Annotations object corresponds to your context's data.