Skip to content

Piero Bosio Social Web Site Personale Logo Fediverso

Social Forum federato con il resto del mondo. Non contano le istanze, contano le persone

Stop writing CLI validation. Parse it right the first time.

  • I have this bad habit. When something annoys me enough times,
    I end up building a library for it. This time, it was CLI validation code.

    See, I spend a lot of time reading other people's code. Open source projects,
    work stuff, random GitHub repos I stumble upon at 2 AM. And I kept noticing this
    thing: every CLI tool has the same ugly validation code tucked away somewhere.
    You know the kind:

    if (!opts.server && opts.port) {
      throw new Error("--port requires --server flag");
    }
    
    if (opts.server && !opts.port) {
      opts.port = 3000; // default port
    }
    
    // wait, what if they pass --port without a value?
    // what if the port is out of range?
    // what if...
    

    It's not even that this code is hard to write. It's that it's everywhere.
    Every project. Every CLI tool. The same patterns, slightly different flavors.
    Options that depend on other options. Flags that can't be used together.
    Arguments that only make sense in certain modes.

    And here's what really got me: we solved this problem years ago for other types
    of data. Just… not for CLIs.

    The problem with validation

    There's this blog post that completely changed how I think about parsing.
    It's called Parse, don't validate by Alexis King. The gist? Don't parse data
    into a loose type and then check if it's valid. Parse it directly into a type
    that can only be valid.

    Think about it. When you get JSON from an API, you don't just parse it as any
    and then write a bunch of if-statements. You use something like Zod to parse
    it directly into the shape you want. Invalid data? The parser rejects it. Done.

    But with CLIs? We parse arguments into some bag of properties and then spend
    the next 100 lines checking if that bag makes sense. It's backwards.

    So yeah, I built Optique. Not because the world desperately needed another CLI
    parser (it didn't), but because I was tired of seeing—and writing—the same
    validation code everywhere.

    Three patterns I was sick of validating

    Dependent options

    This one's everywhere. You have an option that only makes sense when another
    option is enabled.

    The old way? Parse everything, then check:

    const opts = parseArgs(process.argv);
    if (!opts.server && opts.port) {
      throw new Error("--port requires --server");
    }
    if (opts.server && !opts.port) {
      opts.port = 3000;
    }
    // More validation probably lurking elsewhere...
    

    With Optique, you just describe what you want:

    const config = withDefault(
      object({
        server: flag("--server"),
        port: option("--port", integer()),
        workers: option("--workers", integer())
      }),
      { server: false }
    );
    

    Here's what TypeScript infers for config's type:

    type Config = 
      | { readonly server: false }
      | { readonly server: true; readonly port: number; readonly workers: number }
    

    The type system now understands that when server is false, port literally
    doesn't exist. Not undefined, not null—it's not there. Try to access it and
    TypeScript yells at you. No runtime validation needed.

    Mutually exclusive options

    Another classic. Pick one output format: JSON, YAML, or XML. But definitely not
    two.

    I used to write this mess:

    if ((opts.json ? 1 : 0) + (opts.yaml ? 1 : 0) + (opts.xml ? 1 : 0) > 1) {
      throw new Error('Choose only one output format');
    }
    

    (Don't judge me, you've written something similar.)

    Now?

    const format = or(
      map(option("--json"), () => "json" as const),
      map(option("--yaml"), () => "yaml" as const),
      map(option("--xml"), () => "xml" as const)
    );
    

    The or() combinator means exactly one succeeds. The result is just
    "json" | "yaml" | "xml". A single string. Not three booleans to juggle.

    Environment-specific requirements

    Production needs auth. Development needs debug flags. Docker needs different
    options than local. You know the drill.

    Instead of a validation maze, you just describe each environment:

    const envConfig = or(
      object({
        env: constant("prod"),
        auth: option("--auth", string()),      // Required in prod
        ssl: option("--ssl"),
        monitoring: option("--monitoring", url())
      }),
      object({
        env: constant("dev"),
        debug: optional(option("--debug")),    // Optional in dev
        verbose: option("--verbose")
      })
    );
    

    No auth in production? Parser fails immediately. Trying to access --auth in
    dev mode? TypeScript won't let you—the field doesn't exist on that type.

    “But parser combinators though…”

    I know, I know. “Parser combinators” sounds like something you'd need
    a CS degree to understand.

    Here's the thing: I don't have a CS degree. Actually, I don't have any degree.
    But I've been using parser combinators for years because they're actually… not
    that hard? It's just that the name makes them sound way scarier than they are.

    I'd been using them for other stuff—parsing config files, DSLs, whatever.
    But somehow it never clicked that you could use them for CLI parsing until
    I saw Haskell's optparse-applicative. That was a real “wait, of course”
    moment. Like, why are we doing this any other way?

    Turns out it's stupidly simple. A parser is just a function. Combinators are
    just functions that take parsers and return new parsers. That's it.

    // This is a parser
    const port = option("--port", integer());
    
    // This is also a parser (made from smaller parsers)
    const server = object({
      port: port,
      host: option("--host", string())
    });
    
    // Still a parser (parsers all the way down)
    const config = or(server, client);
    

    No monads. No category theory. Just functions. Boring, beautiful functions.

    TypeScript does the heavy lifting

    Here's the thing that still feels like cheating: I don't write types for my CLI
    configs anymore. TypeScript just… figures it out.

    const cli = or(
      command("deploy", object({
        action: constant("deploy"),
        environment: argument(string()),
        replicas: option("--replicas", integer())
      })),
      command("rollback", object({
        action: constant("rollback"),
        version: argument(string()),
        force: option("--force")
      }))
    );
    
    // TypeScript infers this type automatically:
    type Cli = 
      | { 
          readonly action: "deploy"
          readonly environment: string
          readonly replicas: number
        }
      | { 
          readonly action: "rollback"
          readonly version: string
          readonly force: boolean
        }
    

    TypeScript knows that if action is "deploy", then environment exists but
    version doesn't. It knows replicas is a number. It knows force is
    a boolean. I didn't tell it any of this.

    This isn't just about nice autocomplete (though yeah, the autocomplete is great).
    It's about catching bugs before they happen. Forget to handle a new option
    somewhere? Code won't compile.

    What actually changed for me

    I've been dogfooding this for a few weeks. Some real talk:

    I delete code now. Not refactor. Delete. That validation logic that used to
    be 30% of my CLI code? Gone. It feels weird every time.

    Refactoring isn't scary. Want to know something that usually terrifies me?
    Changing how a CLI takes its arguments. Like going from --input file.txt to
    just file.txt as a positional argument. With traditional parsers,
    you're hunting down validation logic everywhere. With this?
    You change the parser definition, TypeScript immediately shows you every place
    that breaks, you fix them, done. What used to be an hour of “did I catch
    everything?” is now “fix the red squiggles and move on.”

    My CLIs got fancier. When adding complex option relationships doesn't mean
    writing complex validation, you just… add them. Mutually exclusive groups?
    Sure. Context-dependent options? Why not. The parser handles it.

    The reusability is real too:

    const networkOptions = object({
      host: option("--host", string()),
      port: option("--port", integer())
    });
    
    // Reuse everywhere, compose differently
    const devServer = merge(networkOptions, debugOptions);
    const prodServer = merge(networkOptions, authOptions);
    const testServer = merge(networkOptions, mockOptions);
    

    But honestly? The biggest change is trust. If it compiles, the CLI logic works.
    Not “probably works” or “works unless someone passes weird arguments.”
    It just works.

    Should you care?

    If you're writing a 10-line script that takes one argument, you don't need this.
    process.argv[2] and call it a day.

    But if you've ever:

    • Had validation logic get out of sync with your actual options
    • Discovered in production that certain option combinations explode
    • Spent an afternoon tracking down why --verbose breaks when used with
      --json
    • Written the same “option A requires option B” check for the fifth time

    Then yeah, maybe you're tired of this stuff too.

    Fair warning: Optique is young. I'm still figuring things out, the API might
    shift a bit. But the core idea—parse, don't validate—that's solid.
    And I haven't written validation code in months.

    Still feels weird. Good weird.

    Try it or don't

    If this resonates:

    I'm not saying Optique is the answer to all CLI problems. I'm just saying
    I was tired of writing the same validation code everywhere, so I built something
    that makes it unnecessary.

    Take it or leave it. But that validation code you're about to write?
    You probably don't need it.

  • instance-fosstodon.org@relay.fedi.buzzundefined instance-fosstodon.org@relay.fedi.buzz shared this topic on
  • I have this bad habit. When something annoys me enough times,
    I end up building a library for it. This time, it was CLI validation code.

    See, I spend a lot of time reading other people's code. Open source projects,
    work stuff, random GitHub repos I stumble upon at 2 AM. And I kept noticing this
    thing: every CLI tool has the same ugly validation code tucked away somewhere.
    You know the kind:

    if (!opts.server && opts.port) {
      throw new Error("--port requires --server flag");
    }
    
    if (opts.server && !opts.port) {
      opts.port = 3000; // default port
    }
    
    // wait, what if they pass --port without a value?
    // what if the port is out of range?
    // what if...
    

    It's not even that this code is hard to write. It's that it's everywhere.
    Every project. Every CLI tool. The same patterns, slightly different flavors.
    Options that depend on other options. Flags that can't be used together.
    Arguments that only make sense in certain modes.

    And here's what really got me: we solved this problem years ago for other types
    of data. Just… not for CLIs.

    The problem with validation

    There's this blog post that completely changed how I think about parsing.
    It's called Parse, don't validate by Alexis King. The gist? Don't parse data
    into a loose type and then check if it's valid. Parse it directly into a type
    that can only be valid.

    Think about it. When you get JSON from an API, you don't just parse it as any
    and then write a bunch of if-statements. You use something like Zod to parse
    it directly into the shape you want. Invalid data? The parser rejects it. Done.

    But with CLIs? We parse arguments into some bag of properties and then spend
    the next 100 lines checking if that bag makes sense. It's backwards.

    So yeah, I built Optique. Not because the world desperately needed another CLI
    parser (it didn't), but because I was tired of seeing—and writing—the same
    validation code everywhere.

    Three patterns I was sick of validating

    Dependent options

    This one's everywhere. You have an option that only makes sense when another
    option is enabled.

    The old way? Parse everything, then check:

    const opts = parseArgs(process.argv);
    if (!opts.server && opts.port) {
      throw new Error("--port requires --server");
    }
    if (opts.server && !opts.port) {
      opts.port = 3000;
    }
    // More validation probably lurking elsewhere...
    

    With Optique, you just describe what you want:

    const config = withDefault(
      object({
        server: flag("--server"),
        port: option("--port", integer()),
        workers: option("--workers", integer())
      }),
      { server: false }
    );
    

    Here's what TypeScript infers for config's type:

    type Config = 
      | { readonly server: false }
      | { readonly server: true; readonly port: number; readonly workers: number }
    

    The type system now understands that when server is false, port literally
    doesn't exist. Not undefined, not null—it's not there. Try to access it and
    TypeScript yells at you. No runtime validation needed.

    Mutually exclusive options

    Another classic. Pick one output format: JSON, YAML, or XML. But definitely not
    two.

    I used to write this mess:

    if ((opts.json ? 1 : 0) + (opts.yaml ? 1 : 0) + (opts.xml ? 1 : 0) > 1) {
      throw new Error('Choose only one output format');
    }
    

    (Don't judge me, you've written something similar.)

    Now?

    const format = or(
      map(option("--json"), () => "json" as const),
      map(option("--yaml"), () => "yaml" as const),
      map(option("--xml"), () => "xml" as const)
    );
    

    The or() combinator means exactly one succeeds. The result is just
    "json" | "yaml" | "xml". A single string. Not three booleans to juggle.

    Environment-specific requirements

    Production needs auth. Development needs debug flags. Docker needs different
    options than local. You know the drill.

    Instead of a validation maze, you just describe each environment:

    const envConfig = or(
      object({
        env: constant("prod"),
        auth: option("--auth", string()),      // Required in prod
        ssl: option("--ssl"),
        monitoring: option("--monitoring", url())
      }),
      object({
        env: constant("dev"),
        debug: optional(option("--debug")),    // Optional in dev
        verbose: option("--verbose")
      })
    );
    

    No auth in production? Parser fails immediately. Trying to access --auth in
    dev mode? TypeScript won't let you—the field doesn't exist on that type.

    “But parser combinators though…”

    I know, I know. “Parser combinators” sounds like something you'd need
    a CS degree to understand.

    Here's the thing: I don't have a CS degree. Actually, I don't have any degree.
    But I've been using parser combinators for years because they're actually… not
    that hard? It's just that the name makes them sound way scarier than they are.

    I'd been using them for other stuff—parsing config files, DSLs, whatever.
    But somehow it never clicked that you could use them for CLI parsing until
    I saw Haskell's optparse-applicative. That was a real “wait, of course”
    moment. Like, why are we doing this any other way?

    Turns out it's stupidly simple. A parser is just a function. Combinators are
    just functions that take parsers and return new parsers. That's it.

    // This is a parser
    const port = option("--port", integer());
    
    // This is also a parser (made from smaller parsers)
    const server = object({
      port: port,
      host: option("--host", string())
    });
    
    // Still a parser (parsers all the way down)
    const config = or(server, client);
    

    No monads. No category theory. Just functions. Boring, beautiful functions.

    TypeScript does the heavy lifting

    Here's the thing that still feels like cheating: I don't write types for my CLI
    configs anymore. TypeScript just… figures it out.

    const cli = or(
      command("deploy", object({
        action: constant("deploy"),
        environment: argument(string()),
        replicas: option("--replicas", integer())
      })),
      command("rollback", object({
        action: constant("rollback"),
        version: argument(string()),
        force: option("--force")
      }))
    );
    
    // TypeScript infers this type automatically:
    type Cli = 
      | { 
          readonly action: "deploy"
          readonly environment: string
          readonly replicas: number
        }
      | { 
          readonly action: "rollback"
          readonly version: string
          readonly force: boolean
        }
    

    TypeScript knows that if action is "deploy", then environment exists but
    version doesn't. It knows replicas is a number. It knows force is
    a boolean. I didn't tell it any of this.

    This isn't just about nice autocomplete (though yeah, the autocomplete is great).
    It's about catching bugs before they happen. Forget to handle a new option
    somewhere? Code won't compile.

    What actually changed for me

    I've been dogfooding this for a few weeks. Some real talk:

    I delete code now. Not refactor. Delete. That validation logic that used to
    be 30% of my CLI code? Gone. It feels weird every time.

    Refactoring isn't scary. Want to know something that usually terrifies me?
    Changing how a CLI takes its arguments. Like going from --input file.txt to
    just file.txt as a positional argument. With traditional parsers,
    you're hunting down validation logic everywhere. With this?
    You change the parser definition, TypeScript immediately shows you every place
    that breaks, you fix them, done. What used to be an hour of “did I catch
    everything?” is now “fix the red squiggles and move on.”

    My CLIs got fancier. When adding complex option relationships doesn't mean
    writing complex validation, you just… add them. Mutually exclusive groups?
    Sure. Context-dependent options? Why not. The parser handles it.

    The reusability is real too:

    const networkOptions = object({
      host: option("--host", string()),
      port: option("--port", integer())
    });
    
    // Reuse everywhere, compose differently
    const devServer = merge(networkOptions, debugOptions);
    const prodServer = merge(networkOptions, authOptions);
    const testServer = merge(networkOptions, mockOptions);
    

    But honestly? The biggest change is trust. If it compiles, the CLI logic works.
    Not “probably works” or “works unless someone passes weird arguments.”
    It just works.

    Should you care?

    If you're writing a 10-line script that takes one argument, you don't need this.
    process.argv[2] and call it a day.

    But if you've ever:

    • Had validation logic get out of sync with your actual options
    • Discovered in production that certain option combinations explode
    • Spent an afternoon tracking down why --verbose breaks when used with
      --json
    • Written the same “option A requires option B” check for the fifth time

    Then yeah, maybe you're tired of this stuff too.

    Fair warning: Optique is young. I'm still figuring things out, the API might
    shift a bit. But the core idea—parse, don't validate—that's solid.
    And I haven't written validation code in months.

    Still feels weird. Good weird.

    Try it or don't

    If this resonates:

    I'm not saying Optique is the answer to all CLI problems. I'm just saying
    I was tired of writing the same validation code everywhere, so I built something
    that makes it unnecessary.

    Take it or leave it. But that validation code you're about to write?
    You probably don't need it.

    @hongminhee this is cool! I've never used NodeJS CLI apps, but I run into similar issues to the ones you described when using Python


Gli ultimi otto messaggi ricevuti dalla Federazione
Post suggeriti
  • twitch-dl 3.3.0 - Twitch CLI downloader

    Uncategorized twitch cli
    1
    0 Votes
    1 Posts
    0 Views
    twitch-dl 3.3.0 - Twitch CLI downloaderThis version lets you download HD streams (1440p, 2160p, where available) without being logged in and regardless of your geo location.https://github.com/ihabunek/twitch-dl/releases/tag/3.3.0#twitch #cli
  • 0 Votes
    1 Posts
    5 Views
    We're excited to announce Optique 0.8.0! This release introduces powerful new features for building sophisticated CLI applications: the conditional() combinator for discriminated union patterns, the passThrough() parser for wrapper tools, and the new @optique/logtape package for seamless logging configuration. 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. New conditional parsing with conditional() Ever needed to enable different sets of options based on a discriminator value? The new conditional() combinator makes this pattern first-class. It creates discriminated unions where certain options only become valid when a specific discriminator value is selected. import { conditional, object } from "@optique/core/constructs"; import { option } from "@optique/core/primitives"; import { choice, string } from "@optique/core/valueparser"; const parser = conditional( option("--reporter", choice(["console", "junit", "html"])), { console: object({}), junit: object({ outputFile: option("--output-file", string()) }), html: object({ outputFile: option("--output-file", string()) }), } ); // Result type: ["console", {}] | ["junit", { outputFile: string }] | ... Key features: Explicit discriminator option determines which branch is selected Tuple result [discriminator, branchValue] for clear type narrowing Optional default branch for when discriminator is not provided Clear error messages indicating which options are required for each discriminator value The conditional() parser provides a more structured alternative to or() for discriminated union patterns. Use it when you have an explicit discriminator option that determines which set of options is valid. See the conditional() documentation for more details and examples. Pass-through options with passThrough() Building wrapper CLI tools that need to forward unrecognized options to an underlying tool? The new passThrough() parser enables legitimate wrapper/proxy patterns by capturing unknown options without validation errors. import { object } from "@optique/core/constructs"; import { option, passThrough } from "@optique/core/primitives"; const parser = object({ debug: option("--debug"), extra: passThrough(), }); // mycli --debug --foo=bar --baz=qux // → { debug: true, extra: ["--foo=bar", "--baz=qux"] } Key features: Three capture formats: "equalsOnly" (default, safest), "nextToken" (captures --opt val pairs), and "greedy" (captures all remaining tokens) Lowest priority (−10) ensures explicit parsers always match first Respects -- options terminator in "equalsOnly" and "nextToken" modes Works seamlessly with object(), subcommands, and other combinators This feature is designed for building Docker-like CLIs, build tool wrappers, or any tool that proxies commands to another process. See the passThrough() documentation for usage patterns and best practices. LogTape logging integration The new @optique/logtape package provides seamless integration with LogTape, enabling you to configure logging through command-line arguments with various parsing strategies. # Deno deno add --jsr @optique/logtape @logtape/logtape # npm npm add @optique/logtape @logtape/logtape Quick start with the loggingOptions() preset: import { loggingOptions, createLoggingConfig } from "@optique/logtape"; import { object } from "@optique/core/constructs"; import { parse } from "@optique/core/parser"; import { configure } from "@logtape/logtape"; const parser = object({ logging: loggingOptions({ level: "verbosity" }), }); const args = ["-vv", "--log-output=-"]; const result = parse(parser, args); if (result.success) { const config = await createLoggingConfig(result.value.logging); await configure(config); } The package offers multiple approaches to control log verbosity: verbosity() parser: The classic -v/-vv/-vvv pattern where each flag increases verbosity (no flags → "warning", -v → "info", -vv → "debug", -vvv → "trace") debug() parser: Simple --debug/-d flag that toggles between normal and debug levels logLevel() value parser: Explicit --log-level=debug option for direct level selection logOutput() parser: Log output destination with - for console or file path for file output See the LogTape integration documentation for complete examples and configuration options. Bug fix: negative integers now accepted Fixed an issue where the integer() value parser rejected negative integers when using type: "number". The regex pattern has been updated from /^\d+$/ to /^-?\d+$/ to correctly handle values like -42. Note that type: "bigint" already accepted negative integers, so this change brings consistency between the two types. Installation # Deno deno add jsr:@optique/core # npm npm add @optique/core # pnpm pnpm add @optique/core # Yarn yarn add @optique/core # Bun bun add @optique/core For the LogTape integration: # Deno deno add --jsr @optique/logtape @logtape/logtape # npm npm add @optique/logtape @logtape/logtape # pnpm pnpm add @optique/logtape @logtape/logtape # Yarn yarn add @optique/logtape @logtape/logtape # Bun bun add @optique/logtape @logtape/logtape Looking forward Optique 0.8.0 continues our focus on making CLI development more expressive and type-safe. The conditional() combinator brings discriminated union patterns to the forefront, passThrough() enables new wrapper tool use cases, and the LogTape integration makes logging configuration a breeze. As always, all new features maintain full backward compatibility—your existing parsers continue to work unchanged. We're grateful to the community for feedback and suggestions. 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.
  • 0 Votes
    1 Posts
    11 Views
    We're pleased to announce the release of Optique 0.5.0, which brings significant improvements to error handling, help text generation, and overall developer experience. This release maintains full backward compatibility, so you can upgrade without modifying existing code. Better code organization through module separation The large @optique/core/parser module has been refactored into three focused modules that better reflect their purposes. Primitive parsers like option() and argument() now live in @optique/core/primitives, modifier functions such as optional() and withDefault() have moved to @optique/core/modifiers, and combinator functions including object() and or() are now in @optique/core/constructs. // Before: everything from one module import { option, flag, argument, // primitives optional, withDefault, multiple, // modifiers object, or, merge // constructs } from "@optique/core/parser"; // After: organized imports (recommended) import { option, flag, argument } from "@optique/core/primitives"; import { optional, withDefault, multiple } from "@optique/core/modifiers"; import { object, or, merge } from "@optique/core/constructs"; While we recommend importing from these specialized modules for better clarity, all functions continue to be re-exported from the original @optique/core/parser module to ensure your existing code works unchanged. This reorganization makes the codebase more maintainable and helps developers understand the relationships between different parser types. Smarter error handling with automatic conversion One of the most requested features has been better error handling for default value callbacks in withDefault(). Previously, if your callback threw an error—say, when an environment variable wasn't set—that error would bubble up as a runtime exception. Starting with 0.5.0, these errors are automatically caught and converted to parser-level errors, providing consistent error formatting and proper exit codes. // Before (0.4.x): runtime exception that crashes the app const parser = object({ apiUrl: withDefault(option("--url", url()), () => { if (!process.env.API_URL) { throw new Error("API_URL not set"); // Uncaught exception! } return new URL(process.env.API_URL); }) }); // After (0.5.0): graceful parser error const parser = object({ apiUrl: withDefault(option("--url", url()), () => { if (!process.env.API_URL) { throw new Error("API_URL not set"); // Automatically caught and formatted } return new URL(process.env.API_URL); }) }); We've also introduced the WithDefaultError class, which accepts structured messages instead of plain strings. This means you can now throw errors with rich formatting that matches the rest of Optique's error output: import { WithDefaultError, message, envVar } from "@optique/core"; const parser = object({ // Plain error - automatically converted to text databaseUrl: withDefault(option("--db", url()), () => { if (!process.env.DATABASE_URL) { throw new Error("Database URL not configured"); } return new URL(process.env.DATABASE_URL); }), // Rich error with structured message apiToken: withDefault(option("--token", string()), () => { if (!process.env.API_TOKEN) { throw new WithDefaultError( message`Environment variable ${envVar("API_TOKEN")} is required for authentication` ); } return process.env.API_TOKEN; }) }); The new envVar message component ensures environment variables are visually distinct in error messages, appearing bold and underlined in colored output or wrapped in backticks in plain text. More helpful help text with custom default descriptions Default values in help text can sometimes be misleading, especially when they come from environment variables or are computed at runtime. Optique 0.5.0 allows you to customize how default values appear in help output through an optional third parameter to withDefault(). import { withDefault, message, envVar } from "@optique/core"; const parser = object({ // Before: shows actual URL value in help apiUrl: withDefault( option("--api-url", url()), new URL("https://api.example.com") ), // Help shows: --api-url URL [https://api.example.com] // After: shows descriptive text apiUrl: withDefault( option("--api-url", url()), new URL("https://api.example.com"), { message: message`Default API endpoint` } ), // Help shows: --api-url URL [Default API endpoint] }); This is particularly useful for environment variables and computed defaults: const parser = object({ // Environment variable authToken: withDefault( option("--token", string()), () => process.env.AUTH_TOKEN || "anonymous", { message: message`${envVar("AUTH_TOKEN")} or anonymous` } ), // Help shows: --token STRING [AUTH_TOKEN or anonymous] // Computed value workers: withDefault( option("--workers", integer()), () => os.cpus().length, { message: message`Number of CPU cores` } ), // Help shows: --workers INT [Number of CPU cores] // Sensitive information apiKey: withDefault( option("--api-key", string()), () => process.env.SECRET_KEY || "", { message: message`From secure storage` } ), // Help shows: --api-key STRING [From secure storage] }); Instead of displaying the actual default value, you can now show descriptive text that better explains where the value comes from. This is particularly useful for sensitive information like API tokens or for computed defaults like the number of CPU cores. The help system now properly handles ANSI color codes in default value displays, maintaining dim styling even when inner components have their own color formatting. This ensures default values remain visually distinct from the main help text. Comprehensive error message customization We've added a systematic way to customize error messages across all parser types and combinators. Every parser now accepts an errors option that lets you provide context-specific feedback instead of generic error messages. This applies to primitive parsers, value parsers, combinators, and even specialized parsers in companion packages. Primitive parser errors import { option, flag, argument, command } from "@optique/core/primitives"; import { message, optionName, metavar } from "@optique/core/message"; // Option parser with custom errors const serverPort = option("--port", integer(), { errors: { missing: message`Server port is required. Use ${optionName("--port")} to specify.`, invalidValue: (error) => message`Invalid port number: ${error}`, endOfInput: message`${optionName("--port")} requires a ${metavar("PORT")} number.` } }); // Command parser with custom errors const deployCommand = command("deploy", deployParser, { errors: { notMatched: (expected, actual) => message`Unknown command "${actual}". Did you mean "${expected}"?` } }); Value parser errors Error customization can be static messages for consistent errors or dynamic functions that incorporate the problematic input: import { integer, choice, string } from "@optique/core/valueparser"; // Integer with range validation const port = integer({ min: 1024, max: 65535, errors: { invalidInteger: message`Port must be a valid number.`, belowMinimum: (value, min) => message`Port ${String(value)} is reserved. Use ${String(min)} or higher.`, aboveMaximum: (value, max) => message`Port ${String(value)} exceeds maximum. Use ${String(max)} or lower.` } }); // Choice with helpful suggestions const logLevel = choice(["debug", "info", "warn", "error"], { errors: { invalidChoice: (input, choices) => message`"${input}" is not a valid log level. Choose from: ${values(choices)}.` } }); // String with pattern validation const email = string({ pattern: /^[^@]+@[^@]+\.[^@]+$/, errors: { patternMismatch: (input) => message`"${input}" is not a valid email address. Use format: user@example.com` } }); Combinator errors import { or, multiple, object } from "@optique/core/constructs"; // Or combinator with custom no-match error const format = or( flag("--json"), flag("--yaml"), flag("--xml"), { errors: { noMatch: message`Please specify an output format: --json, --yaml, or --xml.`, unexpectedInput: (token) => message`Unknown format option "${token}".` } } ); // Multiple parser with count validation const inputFiles = multiple(argument(string()), { min: 1, max: 5, errors: { tooFew: (count, min) => message`At least ${String(min)} file required, but got ${String(count)}.`, tooMany: (count, max) => message`Maximum ${String(max)} files allowed, but got ${String(count)}.` } }); Package-specific errors Both @optique/run and @optique/temporal packages have been updated with error customization support for their specialized parsers: // @optique/run path parser import { path } from "@optique/run/valueparser"; const configFile = option("--config", path({ mustExist: true, type: "file", extensions: [".json", ".yaml"], errors: { pathNotFound: (input) => message`Configuration file "${input}" not found. Please check the path.`, notAFile: (input) => message`"${input}" is a directory. Please specify a file.`, invalidExtension: (input, extensions, actual) => message`Invalid config format "${actual}". Use ${values(extensions)}.` } })); // @optique/temporal instant parser import { instant, duration } from "@optique/temporal"; const timestamp = option("--time", instant({ errors: { invalidFormat: (input) => message`"${input}" is not a valid timestamp. Use ISO 8601 format: 2024-01-01T12:00:00Z` } })); const timeout = option("--timeout", duration({ errors: { invalidFormat: (input) => message`"${input}" is not a valid duration. Use ISO 8601 format: PT30S (30 seconds), PT5M (5 minutes)` } })); Error customization integrates seamlessly with Optique's structured message format, ensuring consistent styling across all error output. The system helps you provide helpful, actionable feedback that guides users toward correct usage rather than leaving them confused by generic error messages. Looking forward This release focuses on improving the developer experience without breaking existing code. Every new feature is opt-in, and all changes maintain backward compatibility. We believe these improvements make Optique more pleasant to work with, especially when building user-friendly CLI applications that need clear error messages and helpful documentation. We're grateful to the community members who suggested these improvements and helped shape this release through discussions and issue reports. Your feedback continues to drive Optique's evolution toward being a more capable and ergonomic CLI parser for TypeScript. To upgrade to Optique 0.5.0, simply update your dependencies: npm update @optique/core @optique/run # or deno update For detailed migration guidance and API documentation, please refer to the official documentation. While no code changes are required, we encourage you to explore the new error customization options and help text improvements to enhance your CLI applications.
  • 0 Votes
    5 Posts
    14 Views
    @shollyethan oooh thank you for the heads-up Ethan! 🙏I’m not close to being ready yet (I need a few more months)… but good to hear there’s a v6.1 already 😅