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.

  • System 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
  • 0 Votes
    1 Posts
    6 Views
    🎉 ratatui_ruby v1.0.0-beta.1 is out!Ruby bindings to Ratatui, the Rust TUI library. Native performance, Ruby expressiveness.Why 1.0? The API is stable. Projects like Sidekiq are building on it. Beta means we need real-world usage to find edge-case bugs.New website: https://www.ratatui-ruby.devgem install ratatui_ruby --preHelp shape the 1.0.0 final release: try it and report bugs!rubygems.org/gems/ratatui_ruby#ruby #rust #tui #cli #ratatui #programming #software #coding #ui
  • 0 Votes
    1 Posts
    8 Views
    If you've built CLI tools, you've written code like this: if (opts.reporter === "junit" && !opts.outputFile) { throw new Error("--output-file is required for junit reporter"); } if (opts.reporter === "html" && !opts.outputFile) { throw new Error("--output-file is required for html reporter"); } if (opts.reporter === "console" && opts.outputFile) { console.warn("--output-file is ignored for console reporter"); } A few months ago, I wrote Stop writing CLI validation. Parse it right the first time. about parsing individual option values correctly. But it didn't cover the relationships between options. In the code above, --output-file only makes sense when --reporter is junit or html. When it's console, the option shouldn't exist at all. We're using TypeScript. We have a powerful type system. And yet, here we are, writing runtime checks that the compiler can't help with. Every time we add a new reporter type, we need to remember to update these checks. Every time we refactor, we hope we didn't miss one. The state of TypeScript CLI parsers The old guard—Commander, yargs, minimist—were built before TypeScript became mainstream. They give you bags of strings and leave type safety as an exercise for the reader. But we've made progress. Modern TypeScript-first libraries like cmd-ts and Clipanion (the library powering Yarn Berry) take types seriously: // cmd-ts const app = command({ args: { reporter: option({ type: string, long: 'reporter' }), outputFile: option({ type: string, long: 'output-file' }), }, handler: (args) => { // args.reporter: string // args.outputFile: string }, }); // Clipanion class TestCommand extends Command { reporter = Option.String('--reporter'); outputFile = Option.String('--output-file'); } These libraries infer types for individual options. --port is a number. --verbose is a boolean. That's real progress. But here's what they can't do: express that --output-file is required when --reporter is junit, and forbidden when --reporter is console. The relationship between options isn't captured in the type system. So you end up writing validation code anyway: handler: (args) => { // Both cmd-ts and Clipanion need this if (args.reporter === "junit" && !args.outputFile) { throw new Error("--output-file required for junit"); } // args.outputFile is still string | undefined // TypeScript doesn't know it's definitely string when reporter is "junit" } Rust's clap and Python's Click have requires and conflicts_with attributes, but those are runtime checks too. They don't change the result type. If the parser configuration knows about option relationships, why doesn't that knowledge show up in the result type? Modeling relationships with conditional() Optique treats option relationships as a first-class concept. Here's the test reporter scenario: import { conditional, object } from "@optique/core/constructs"; import { option } from "@optique/core/primitives"; import { choice, string } from "@optique/core/valueparser"; import { run } from "@optique/run"; 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()), openBrowser: option("--open-browser"), }), } ); const [reporter, config] = run(parser); The conditional() combinator takes a discriminator option (--reporter) and a map of branches. Each branch defines what other options are valid for that discriminator value. TypeScript infers the result type automatically: type Result = | ["console", {}] | ["junit", { outputFile: string }] | ["html", { outputFile: string; openBrowser: boolean }]; When reporter is "junit", outputFile is string—not string | undefined. The relationship is encoded in the type. Now your business logic gets real type safety: const [reporter, config] = run(parser); switch (reporter) { case "console": runWithConsoleOutput(); break; case "junit": // TypeScript knows config.outputFile is string writeJUnitReport(config.outputFile); break; case "html": // TypeScript knows config.outputFile and config.openBrowser exist writeHtmlReport(config.outputFile); if (config.openBrowser) openInBrowser(config.outputFile); break; } No validation code. No runtime checks. If you add a new reporter type and forget to handle it in the switch, the compiler tells you. A more complex example: database connections Test reporters are a nice example, but let's try something with more variation. Database connection strings: myapp --db=sqlite --file=./data.db myapp --db=postgres --host=localhost --port=5432 --user=admin myapp --db=mysql --host=localhost --port=3306 --user=root --ssl Each database type needs completely different options: SQLite just needs a file path PostgreSQL needs host, port, user, and optionally password MySQL needs host, port, user, and has an SSL flag Here's how you model this: import { conditional, object } from "@optique/core/constructs"; import { withDefault, optional } from "@optique/core/modifiers"; import { option } from "@optique/core/primitives"; import { choice, string, integer } from "@optique/core/valueparser"; const dbParser = conditional( option("--db", choice(["sqlite", "postgres", "mysql"])), { sqlite: object({ file: option("--file", string()), }), postgres: object({ host: option("--host", string()), port: withDefault(option("--port", integer()), 5432), user: option("--user", string()), password: optional(option("--password", string())), }), mysql: object({ host: option("--host", string()), port: withDefault(option("--port", integer()), 3306), user: option("--user", string()), ssl: option("--ssl"), }), } ); The inferred type: type DbConfig = | ["sqlite", { file: string }] | ["postgres", { host: string; port: number; user: string; password?: string }] | ["mysql", { host: string; port: number; user: string; ssl: boolean }]; Notice the details: PostgreSQL defaults to port 5432, MySQL to 3306. PostgreSQL has an optional password, MySQL has an SSL flag. Each database type has exactly the options it needs—no more, no less. With this structure, writing dbConfig.ssl when the mode is sqlite isn't a runtime error—it's a compile-time impossibility. Try expressing this with requires_if attributes. You can't. The relationships are too rich. The pattern is everywhere Once you see it, you find this pattern in many CLI tools: Authentication modes: const authParser = conditional( option("--auth", choice(["none", "basic", "token", "oauth"])), { none: object({}), basic: object({ username: option("--username", string()), password: option("--password", string()), }), token: object({ token: option("--token", string()), }), oauth: object({ clientId: option("--client-id", string()), clientSecret: option("--client-secret", string()), tokenUrl: option("--token-url", url()), }), } ); Deployment targets, output formats, connection protocols—anywhere you have a mode selector that determines what other options are valid. Why conditional() exists Optique already has an or() combinator for mutually exclusive alternatives. Why do we need conditional()? The or() combinator distinguishes branches based on structure—which options are present. It works well for subcommands like git commit vs git push, where the arguments differ completely. But in the reporter example, the structure is identical: every branch has a --reporter flag. The difference lies in the flag's value, not its presence. // This won't work as intended const parser = or( object({ reporter: option("--reporter", choice(["console"])) }), object({ reporter: option("--reporter", choice(["junit", "html"])), outputFile: option("--output-file", string()) }), ); When you pass --reporter junit, or() tries to pick a branch based on what options are present. Both branches have --reporter, so it can't distinguish them structurally. conditional() solves this by reading the discriminator's value first, then selecting the appropriate branch. It bridges the gap between structural parsing and value-based decisions. The structure is the constraint Instead of parsing options into a loose type and then validating relationships, define a parser whose structure is the constraint. Traditional approach Optique approach Parse → Validate → Use Parse (with constraints) → Use Types and validation logic maintained separately Types reflect the constraints Mismatches found at runtime Mismatches found at compile time The parser definition becomes the single source of truth. Add a new reporter type? The parser definition changes, the inferred type changes, and the compiler shows you everywhere that needs updating. Try it If this resonates with a CLI you're building: Documentation Tutorial conditional() reference GitHub Next time you're about to write an if statement checking option relationships, ask: could the parser express this constraint instead? The structure of your parser is the constraint. You might not need that validation code at all.
  • 0 Votes
    1 Posts
    11 Views
    Optique 0.6.0 is here, bringing intelligent shell completion to your type-safe command-line applications. This release introduces built-in completion support for Bash, zsh, fish, PowerShell, and Nushell, making your CLIs more discoverable and user-friendly—all without sacrificing type safety or requiring duplicate definitions. For those new to [Optique]: it's a TypeScript CLI parser library that takes a fundamentally different approach from traditional configuration-based parsers. Instead of describing your CLI with configuration objects, you compose parsers from small, type-safe functions. TypeScript automatically infers the exact types of your parsed data, ensuring compile-time safety while the parser structure itself provides runtime validation. Think of it as bringing the composability of parser combinators (inspired by Haskell's optparse-applicative) together with the type safety of TypeScript's type system. Shell completion that just works The standout feature of this release is comprehensive shell completion support. Unlike many CLI frameworks that require separate completion definitions, Optique's completion system leverages the same parser structure used for argument parsing. This means your completion suggestions automatically stay synchronized with your CLI's actual behavior—no duplicate definitions, no manual maintenance. import { object } from "@optique/core/constructs"; import { argument, option } from "@optique/core/primitives"; import { string, choice } from "@optique/core/valueparser"; import { run } from "@optique/run"; const parser = object({ format: option("-f", "--format", choice(["json", "yaml", "xml"])), output: option("-o", "--output", string({ metavar: "FILE" })), verbose: option("-v", "--verbose"), input: argument(string({ metavar: "INPUT" })), }); // Enable completion with a single option const config = run(parser, { completion: "both" }); Users can now press Tab to get intelligent suggestions: myapp <TAB> # Shows available commands and options myapp --format <TAB> # Shows: json, yaml, xml myapp --format=<TAB> # Same suggestions with equals syntax myapp -<TAB> # Shows: -f, -o, -v, and other short options Setting up completion is straightforward. Users generate a completion script for their shell and source it: # Bash myapp completion bash > ~/.bashrc.d/myapp.bash source ~/.bashrc.d/myapp.bash # zsh myapp completion zsh > ~/.zsh/completions/_myapp # fish myapp completion fish > ~/.config/fish/completions/myapp.fish # PowerShell myapp completion pwsh > myapp-completion.ps1 . ./myapp-completion.ps1 # Nushell myapp completion nu | save myapp-completion.nu source myapp-completion.nu The completion system works automatically with all Optique parser types. When you use choice() value parsers, the available options become completion suggestions. When you use path() parsers, file system completion kicks in with proper handling of extensions and file types. Subcommands, options, and arguments all provide context-aware suggestions. What makes Optique's completion special is that it leverages the same parser structure used for argument parsing. Every parser has an optional suggest() method that provides context-aware suggestions based on the current input. Parser combinators like object() and or() automatically aggregate suggestions from their constituent parsers, ensuring your completion logic stays in your TypeScript code where it benefits from type safety and testing. Optique handles the differences between shells transparently. Bash uses the complete command with proper handling of word splitting, zsh leverages its powerful compdef system with completion descriptions, fish provides tab-separated format with automatic file type detection, PowerShell uses Register-ArgumentCompleter with AST-based parsing, and Nushell integrates with its external completer system. For file and directory completions, Optique delegates to each shell's native file completion system, ensuring proper handling of spaces, symlinks, and platform-specific path conventions. Custom completion suggestions For domain-specific value parsers, you can implement custom completion logic that provides intelligent suggestions based on your application's needs: import type { ValueParser, ValueParserResult } from "@optique/core/valueparser"; import type { Suggestion } from "@optique/core/parser"; import { message } from "@optique/core/message"; function httpMethod(): ValueParser<string> { const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; return { metavar: "METHOD", parse(input: string): ValueParserResult<string> { const method = input.toUpperCase(); if (methods.includes(method)) { return { success: true, value: method }; } return { success: false, error: message`Invalid HTTP method: ${input}. Valid methods: ${methods.join(", ")}.`, }; }, format(value: string): string { return value; }, *suggest(prefix: string): Iterable<Suggestion> { for (const method of methods) { if (method.toLowerCase().startsWith(prefix.toLowerCase())) { yield { kind: "literal", text: method, description: message`HTTP ${method} request method` }; } } }, }; } The built-in value parsers also provide intelligent suggestions. For instance, the locale() parser suggests common locale identifiers, the url() parser offers protocol completions when configured with allowedProtocols, and the timezone parsers from @optique/temporal use Intl.supportedValuesOf() for dynamic timezone suggestions. Enhanced command documentation This release also introduces new documentation capabilities for the command() parser. You can now provide separate brief and description texts, along with a footer for examples and additional information: import { command, object, constant } from "@optique/core/primitives"; import { message } from "@optique/core/message"; const deployCommand = command( "deploy", object({ action: constant("deploy"), // ... options }), { brief: message`Deploy application to production`, // Shown in command list description: message`Deploy the application to the production environment. This command handles database migrations, asset compilation, and cache warming automatically. It performs health checks before switching traffic to ensure zero-downtime deployment.`, // Shown in detailed help footer: message`Examples: myapp deploy --environment staging --dry-run myapp deploy --environment production --force For deployment documentation, see: https://docs.example.com/deploy` } ); The brief text appears when listing commands (like myapp help), while description provides detailed information when viewing command-specific help (myapp deploy --help or myapp help deploy). The footer appears at the bottom of the help text, perfect for examples and additional resources. Command-line example formatting To make help text and examples clearer, we've added a new commandLine() message term type. This displays command-line snippets with distinct cyan coloring in terminals, making it immediately clear what users should type: import { message, commandLine } from "@optique/core/message"; import { run } from "@optique/run"; const config = run(parser, { footer: message`Examples: ${commandLine("myapp --format json input.txt")} ${commandLine("myapp --format=yaml --output result.yml data.txt")} To enable shell completion: ${commandLine("myapp completion bash > ~/.bashrc.d/myapp.bash")} ${commandLine("source ~/.bashrc.d/myapp.bash")}`, completion: "both" }); These command examples stand out visually in help text, making it easier for users to understand how to use your CLI. Migration guide If you're already using Optique, adding completion support is straightforward: Update to Optique 0.6.0 Add the completion option to your run() configuration: // Before const config = run(parser, { help: "both" }); // After const config = run(parser, { help: "both", completion: "both" // Adds both 'completion' command and '--completion' option }); That's it! Your CLI now supports shell completion. The completion option accepts three modes: "command": Only the completion subcommand (e.g., myapp completion bash) "option": Only the --completion option (e.g., myapp --completion bash) "both": Both patterns work For custom value parsers, you can optionally add a suggest() method to provide domain-specific completions. Existing parsers continue to work without modification—they just won't provide custom suggestions beyond what the parser structure implies. Looking forward Shell completion has been one of the most requested features for Optique, and we're thrilled to deliver it in a way that maintains our core principles: type safety, composability, and zero duplication. Your parser definitions remain the single source of truth for both parsing and completion behavior. This release represents a significant step toward making Optique-based CLIs as user-friendly as they are developer-friendly. The completion system proves that we can provide sophisticated runtime features without sacrificing the compile-time guarantees that make Optique unique. We hope you find the new shell completion feature useful and look forward to seeing what you build with it! Getting started To start using Optique 0.6.0: deno add --jsr @optique/core@^0.6.0 @optique/run@^0.6.0 npm add @optique/core@^0.6.0 @optique/run@^0.6.0 pnpm add @optique/core@^0.6.0 @optique/run@^0.6.0 yarn add @optique/core@^0.6.0 @optique/run@^0.6.0 bun add @optique/core@^0.6.0 @optique/run@^0.6.0 For complete documentation, visit optique.dev. Check out the new shell completion guide for detailed setup instructions and advanced usage patterns. For bug reports and feature requests, please visit our GitHub repository.
  • 0 Votes
    5 Posts
    18 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 😅